portal-mojo 0.2.19 → 0.2.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/README.md +7 -1
- package/docs/admin-credentials.md +27 -0
- package/docs/admin-deployments.md +201 -0
- package/docs/admin-dns-certificates.md +160 -0
- package/docs/admin-dns-domains.md +92 -0
- package/docs/admin-dns.md +18 -0
- package/docs/admin-edge.md +200 -196
- package/docs/admin-fleet-configuration.md +98 -2
- package/docs/admin-geolocation.md +100 -0
- package/docs/admin-identity-directories.md +12 -0
- package/docs/admin-maintenance.md +133 -0
- package/docs/admin-modals.md +45 -2
- package/docs/admin-operational-fleet.md +206 -0
- package/docs/admin-presentation.md +51 -0
- package/docs/admin-signin.md +12 -2
- package/docs/auth-pages.md +18 -6
- package/docs/auth.md +16 -2
- package/docs/client.md +7 -0
- package/docs/collection-select.md +14 -0
- package/docs/combobox.md +18 -0
- package/docs/popover.md +5 -4
- package/package.json +1 -1
- package/src/admin/core/OperationsHelp.tsx +10 -0
- package/src/admin/core/index.ts +6 -4
- package/src/admin/core/route-context.ts +6 -0
- package/src/admin/credentials/AcmeConsumerKeyDialog.tsx +88 -0
- package/src/admin/credentials/acme-consumer-key.ts +34 -0
- package/src/admin/credentials/group-api-keys.tsx +5 -3
- package/src/admin/deployments/ApiDeploymentDetail.tsx +58 -0
- package/src/admin/deployments/DeploymentsPage.tsx +73 -0
- package/src/admin/deployments/WebAppDeploySetup.tsx +49 -0
- package/src/admin/deployments/WebAppDeploymentDetail.tsx +59 -0
- package/src/admin/deployments/WebAppDetail.tsx +104 -0
- package/src/admin/deployments/WebAppEditDialog.tsx +70 -0
- package/src/admin/deployments/WebAppKeyDialog.tsx +25 -0
- package/src/admin/deployments/WebAppOnboardingDialog.tsx +217 -0
- package/src/admin/deployments/WebAppRollbackDialog.tsx +82 -0
- package/src/admin/deployments/WebAppUploadPanel.tsx +132 -0
- package/src/admin/deployments/api.ts +158 -0
- package/src/admin/deployments/authority-state.ts +9 -0
- package/src/admin/deployments/deployment-state.ts +13 -0
- package/src/admin/deployments/index.ts +4 -0
- package/src/admin/deployments/onboarding-state.ts +11 -0
- package/src/admin/deployments/permissions.ts +37 -0
- package/src/admin/deployments/receipts.ts +78 -0
- package/src/admin/deployments/sanitize.ts +73 -0
- package/src/admin/deployments/shared.tsx +39 -0
- package/src/admin/deployments/types.ts +238 -0
- package/src/admin/deployments/upload.ts +53 -0
- package/src/admin/deployments/useDeploymentObservation.ts +104 -0
- package/src/admin/dns/CertificateDetail.tsx +8 -3
- package/src/admin/dns/CertificateRequestDialog.tsx +45 -7
- package/src/admin/dns/DomainDetail.tsx +14 -4
- package/src/admin/dns/DomainOnboardingContent.tsx +44 -0
- package/src/admin/dns/DomainOnboardingDialog.tsx +5 -24
- package/src/admin/dns/DomainPurchaseWizard.tsx +1 -1
- package/src/admin/dns/DomainRegisterExisting.tsx +63 -0
- package/src/admin/dns/DomainRegistrarSettings.tsx +52 -0
- package/src/admin/dns/DomainsPage.tsx +26 -13
- package/src/admin/dns/SharedValidationDialog.tsx +298 -0
- package/src/admin/dns/SharedValidationPanel.tsx +76 -0
- package/src/admin/dns/api.ts +4 -2
- package/src/admin/dns/domain-navigation.ts +16 -0
- package/src/admin/dns/registrar-api.ts +13 -0
- package/src/admin/dns/shared-validation-api.ts +90 -0
- package/src/admin/dns/shared-validation-data.ts +106 -0
- package/src/admin/domains/assistant.ts +5 -4
- package/src/admin/domains/communications.ts +10 -9
- package/src/admin/domains/identity.ts +8 -7
- package/src/admin/domains/infrastructure.ts +19 -13
- package/src/admin/domains/lazy-page.ts +6 -0
- package/src/admin/domains/observability.ts +6 -5
- package/src/admin/domains/operations.ts +27 -10
- package/src/admin/domains/security.ts +16 -15
- package/src/admin/edge/EdgeHelp.tsx +10 -0
- package/src/admin/edge/ServingReadiness.tsx +54 -0
- package/src/admin/edge/VhostDetail.tsx +145 -139
- package/src/admin/edge/VhostWizard.tsx +204 -115
- package/src/admin/edge/VhostsPage.tsx +27 -28
- package/src/admin/edge/api.ts +24 -10
- package/src/admin/edge/models.ts +4 -4
- package/src/admin/edge/serving-data.ts +78 -0
- package/src/admin/fleet/CapacityEditor.tsx +81 -0
- package/src/admin/fleet/CapacityInspector.tsx +19 -0
- package/src/admin/fleet/CapacityProgress.tsx +9 -0
- package/src/admin/fleet/FleetHelp.tsx +7 -0
- package/src/admin/fleet/FleetOverview.tsx +150 -0
- package/src/admin/fleet/FleetPage.tsx +11 -0
- package/src/admin/fleet/FleetWorkspace.tsx +82 -0
- package/src/admin/fleet/ScaleFleetDialog.tsx +169 -0
- package/src/admin/fleet/access.ts +22 -0
- package/src/admin/fleet/api.ts +77 -0
- package/src/admin/fleet/data.ts +148 -0
- package/src/admin/fleet/index.ts +8 -0
- package/src/admin/fleet/operations.ts +179 -0
- package/src/admin/fleet/standalone-data.ts +87 -0
- package/src/admin/fleet/types.ts +208 -0
- package/src/admin/fleet-configuration/FleetConfigurationPage.tsx +14 -8
- package/src/admin/fleet-configuration/SharedValidationConnectionPanel.tsx +166 -0
- package/src/admin/fleet-configuration/data.ts +4 -0
- package/src/admin/fleet-configuration/operator-destinations.ts +20 -0
- package/src/admin/fleet-configuration/shared-validation-data.ts +143 -0
- package/src/admin/geolocation/GeoLocationPage.tsx +171 -0
- package/src/admin/geolocation/data.ts +138 -0
- package/src/admin/geolocation/index.ts +3 -0
- package/src/admin/identity/groups/group-sections/SubGroupsSection.tsx +73 -2
- package/src/admin/identity/groups/models.ts +2 -0
- package/src/admin/identity/members/GroupMembersPanel.tsx +2 -8
- package/src/admin/index.ts +8 -0
- package/src/admin/maintenance/MaintenanceDialogs.tsx +161 -0
- package/src/admin/maintenance/MaintenancePage.tsx +442 -0
- package/src/admin/maintenance/data.ts +486 -0
- package/src/admin/maintenance/index.ts +3 -0
- package/src/admin/maintenance/operations.ts +208 -0
- package/src/admin/public/infrastructure.ts +1 -0
- package/src/admin/public/operations.ts +3 -0
- package/src/admin/registry.ts +6 -2
- package/src/admin/settings/SettingDetail.tsx +7 -2
- package/src/admin/settings/SettingEditor.tsx +2 -1
- package/src/admin/settings/SettingsPage.tsx +3 -1
- package/src/admin/settings/model.ts +1 -0
- package/src/admin/signin/SigninPage.tsx +17 -3
- package/src/admin/signin/api.ts +2 -0
- package/src/charts/MetricsChartView.tsx +4 -11
- package/src/charts/MetricsMiniWidget.tsx +3 -15
- package/src/client/auth.ts +77 -25
- package/src/client/client.ts +6 -0
- package/src/client/deployments-mock.ts +375 -0
- package/src/client/domain-operations-mock.ts +37 -0
- package/src/client/fleet-mock.ts +73 -1
- package/src/client/geo-provider-mock.ts +82 -0
- package/src/client/index.ts +2 -2
- package/src/client/maintenance-mock.ts +285 -0
- package/src/client/mock.ts +280 -11
- package/src/client/operational-fleet-mock.ts +279 -0
- package/src/client/operations-mock.ts +85 -0
- package/src/client/serving-mock.ts +22 -0
- package/src/styles/components/admin-deployments.css +35 -0
- package/src/styles/components/admin-dns-certificates.css +33 -0
- package/src/styles/components/admin-domains.css +12 -0
- package/src/styles/components/admin-edge.css +125 -1
- package/src/styles/components/admin-geolocation.css +40 -0
- package/src/styles/components/admin-maintenance.css +127 -0
- package/src/styles/components/admin-operational-fleet.css +38 -0
- package/src/styles/components/admin-operations.css +9 -0
- package/src/styles/components/admin-signin.css +2 -0
- package/src/styles/components/combobox.css +12 -2
- package/src/styles/components/fleet-page.css +9 -0
- package/src/styles/index.css +7 -0
- package/src/ui/CollectionSelect.tsx +30 -8
- package/src/ui/ComboBox.tsx +39 -8
- package/src/ui/FilterBar.tsx +3 -5
- package/src/ui/ModelTable.tsx +2 -8
- package/src/ui/Popover.tsx +15 -7
- package/src/ui/date/TimePicker.tsx +4 -14
- package/src/ui/image-editor/ImageEditor.tsx +4 -5
- package/src/ui/schema-form-core.tsx +3 -5
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/** Private GeoLocation protocol. Raw credentials never enter Query/Mutation state. */
|
|
2
|
+
import { mojoCall, MojoError } from '../../client/runtime';
|
|
3
|
+
import { OperatorError, operatorOrigin, type PrivateScope } from '../fleet-configuration/shared-validation-data';
|
|
4
|
+
|
|
5
|
+
export const GEOLOCATION_ENDPOINT = '/api/account/admin/settings';
|
|
6
|
+
export const GEOLOCATION_KEYS = ['GEOIP_PRIMARY_PROVIDER', 'GEOIP_FALLBACK_PROVIDER', 'GEOIP_ADDITIONAL_PROVIDERS', 'GEOIP_MOJO_PROVIDER_URL', 'GEOIP_MOJO_SYNC_ENABLED'] as const;
|
|
7
|
+
export interface GeoDraft {
|
|
8
|
+
GEOIP_PRIMARY_PROVIDER: string;
|
|
9
|
+
GEOIP_FALLBACK_PROVIDER: string;
|
|
10
|
+
GEOIP_ADDITIONAL_PROVIDERS: string[];
|
|
11
|
+
GEOIP_MOJO_PROVIDER_URL: string;
|
|
12
|
+
GEOIP_MOJO_SYNC_ENABLED: boolean;
|
|
13
|
+
}
|
|
14
|
+
export interface GeoVerdict { success: boolean; code: string | null; message: string }
|
|
15
|
+
export interface StoredGeoVerification { ok: boolean; code: string | null; message: string; at: string }
|
|
16
|
+
export interface GeoField { key: string; label: string; description: string; value_type: string; constraints: string }
|
|
17
|
+
export interface GeoLocationState {
|
|
18
|
+
available: boolean; bucket_configured: boolean; restart_configured: boolean; pending_restart: boolean;
|
|
19
|
+
object_key: string; delegated_keys: string[]; geoip_providers: string[]; fleet_fields: GeoField[];
|
|
20
|
+
loaded_revision: string | null; published_revision: string | null; configuration_revision: string | null;
|
|
21
|
+
published_version: string | null; remote_error: string | null;
|
|
22
|
+
geoip: GeoDraft & { GEOIP_API_KEY_MOJO_CONFIGURED: boolean; GEOIP_API_KEY_MOJO_HINT: string };
|
|
23
|
+
verify_state: { geoip?: StoredGeoVerification };
|
|
24
|
+
}
|
|
25
|
+
export interface GeoPublication { published: boolean; unchanged: boolean; topic: 'geoip'; revision: string; published_revision: string | null; version_id: string | null; pending_restart: boolean; results: { geoip: GeoVerdict } }
|
|
26
|
+
export type GeoKeyEdit = { mode: 'untouched' } | { mode: 'replace'; value: string } | { mode: 'clear'; confirmed: true };
|
|
27
|
+
export class GeoLocationError extends Error {
|
|
28
|
+
constructor(public kind: 'invalid' | 'validation' | 'unsupported' | 'denied' | 'fresh' | 'conflict' | 'unavailable' | 'uncertain', message?: string) {
|
|
29
|
+
super(message ?? ({ invalid: 'GeoLocation returned an incompatible response. Refresh before continuing.', validation: 'Check the provider settings and try again.', unsupported: 'This backend does not support GeoLocation provider setup.', denied: 'GeoLocation requires a current interactive superuser session.', fresh: 'Authenticate again, then Refresh and deliberately retry. The action was not replayed.', conflict: 'Provider configuration changed; reload before publishing. Refresh and review the configuration.', unavailable: 'GeoLocation could not be read. Refresh before continuing.', uncertain: 'The save may have persisted. Refresh authoritative configuration before another attempt.' }[kind]));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const invalid = (): never => { throw new GeoLocationError('invalid'); };
|
|
33
|
+
const object = (v: unknown): Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v) ? v as Record<string, unknown> : invalid();
|
|
34
|
+
const text = (v: unknown, max = 512): string => typeof v === 'string' && v.length <= max && !/[\x00-\x1f\x7f]/.test(v) ? v : invalid();
|
|
35
|
+
const flag = (v: unknown): boolean => typeof v === 'boolean' ? v : invalid();
|
|
36
|
+
const list = (v: unknown, max = 128): unknown[] => Array.isArray(v) && v.length <= max ? v : invalid();
|
|
37
|
+
const nullable = <T>(v: unknown, parse: (v: unknown) => T): T | null => v === null ? null : parse(v);
|
|
38
|
+
export const geoRevision = (v: unknown): string => typeof v === 'string' && /^[a-f0-9]{32,64}$/.test(v) ? v : invalid();
|
|
39
|
+
export const geoProvider = (v: unknown): string => typeof v === 'string' && /^[a-z][a-z0-9_-]{0,31}$/.test(v) ? v : invalid();
|
|
40
|
+
export function geoOrigin(v: unknown): string {
|
|
41
|
+
const value = text(v);
|
|
42
|
+
try {
|
|
43
|
+
const u = new URL(value);
|
|
44
|
+
if (value.trim() !== value || /[?#]/.test(value) || u.protocol !== 'https:' || !u.hostname || u.username || u.password || u.search || u.hash || !['', '/'].includes(u.pathname) || u.port && u.port !== '443') invalid();
|
|
45
|
+
// Match backend normalization: retain an explicitly supplied :443, remove only slash.
|
|
46
|
+
return value.replace(/\/$/, '');
|
|
47
|
+
} catch { return invalid(); }
|
|
48
|
+
}
|
|
49
|
+
export function geoDraft(value: GeoDraft): GeoDraft {
|
|
50
|
+
const additional = list(value.GEOIP_ADDITIONAL_PROVIDERS, 16).map(geoProvider);
|
|
51
|
+
if (new Set(additional).size !== additional.length) invalid();
|
|
52
|
+
return { GEOIP_PRIMARY_PROVIDER: geoProvider(value.GEOIP_PRIMARY_PROVIDER), GEOIP_FALLBACK_PROVIDER: geoProvider(value.GEOIP_FALLBACK_PROVIDER), GEOIP_ADDITIONAL_PROVIDERS: [...additional], GEOIP_MOJO_PROVIDER_URL: geoOrigin(value.GEOIP_MOJO_PROVIDER_URL), GEOIP_MOJO_SYNC_ENABLED: flag(value.GEOIP_MOJO_SYNC_ENABLED) };
|
|
53
|
+
}
|
|
54
|
+
/** Positive projection discards the catalog, SMS, credentials, and every unknown field. */
|
|
55
|
+
export function parseGeoLocation(value: unknown): GeoLocationState {
|
|
56
|
+
const root = object(value); if (root.schema_version !== 1 || root.provider_setup == null) throw new GeoLocationError('unsupported');
|
|
57
|
+
const r = object(root.provider_setup), g = object(r.geoip), verify = object(r.verify_state);
|
|
58
|
+
const fields = list(r.fleet_fields).map(value => { const f = object(value); return { key: text(f.key, 80), label: text(f.label, 200), description: text(f.description, 2000), value_type: text(f.value_type, 32), constraints: text(f.constraints, 500) }; });
|
|
59
|
+
if (new Set(fields.map(f => f.key)).size !== fields.length) invalid();
|
|
60
|
+
const hint = text(g.GEOIP_API_KEY_MOJO_HINT, 4); if (hint !== '' && hint.length !== 4) invalid();
|
|
61
|
+
const stored = verify.geoip == null ? undefined : object(verify.geoip);
|
|
62
|
+
return { available: flag(r.available), bucket_configured: flag(r.bucket_configured), restart_configured: flag(r.restart_configured), pending_restart: flag(r.pending_restart),
|
|
63
|
+
object_key: text(r.object_key, 1024), delegated_keys: list(r.delegated_keys, 1024).map(v => text(v, 100)), geoip_providers: list(r.geoip_providers).map(geoProvider), fleet_fields: fields,
|
|
64
|
+
loaded_revision: nullable(r.loaded_revision, geoRevision), published_revision: nullable(r.published_revision, geoRevision), configuration_revision: nullable(r.configuration_revision, geoRevision),
|
|
65
|
+
published_version: nullable(r.published_version, v => text(v, 1024)), remote_error: nullable(r.remote_error, v => text(v, 200)),
|
|
66
|
+
geoip: { ...geoDraft(g as unknown as GeoDraft), GEOIP_API_KEY_MOJO_CONFIGURED: flag(g.GEOIP_API_KEY_MOJO_CONFIGURED), GEOIP_API_KEY_MOJO_HINT: hint },
|
|
67
|
+
verify_state: stored ? { geoip: { ok: flag(stored.ok), code: nullable(stored.code, v => text(v, 64)), message: text(stored.message, 200), at: text(stored.at, 40) } } : {},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** Credential diagnostics need the provider schema, independently of Fleet publishing. */
|
|
71
|
+
export function geoTestBlock(state: GeoLocationState): string | null {
|
|
72
|
+
const types = ['string', 'string', 'list', 'origin', 'boolean'];
|
|
73
|
+
if (!GEOLOCATION_KEYS.every((key, i) => state.delegated_keys.includes(key) && state.fleet_fields.some(f => f.key === key && f.value_type === types[i]))) return 'All five GeoIP fields must be registered and delegated before provider setup can change.';
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
export function geoWriteBlock(state: GeoLocationState): string | null {
|
|
77
|
+
if (state.remote_error) return 'The published configuration could not be read. Resolve the remote read failure and Refresh.';
|
|
78
|
+
const schema = geoTestBlock(state); if (schema) return schema;
|
|
79
|
+
if (!state.available || !state.bucket_configured || !state.restart_configured || !state.object_key) return 'Fleet publishing is unavailable. Configure its exact bucket and object, all provider delegations, KMS encryption and config-sync restart in deployment settings.';
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
const providerLabels: Record<string, string> = { mojo: 'MojoVerify', ipinfo: 'IPinfo', ipstack: 'IPstack', 'ip-api': 'IP-API', maxmind: 'MaxMind' };
|
|
83
|
+
export const geoProviderLabel = (provider: string): string => providerLabels[provider] ?? provider;
|
|
84
|
+
export function geoProviderOptions(state: GeoLocationState, draft: GeoDraft): string[] {
|
|
85
|
+
return [...new Set([...state.geoip_providers, draft.GEOIP_PRIMARY_PROVIDER, draft.GEOIP_FALLBACK_PROVIDER, ...draft.GEOIP_ADDITIONAL_PROVIDERS])];
|
|
86
|
+
}
|
|
87
|
+
export function buildGeoPayload(action: 'test_providers' | 'configure_providers', revision: string | null, draft: GeoDraft, edit: GeoKeyEdit = { mode: 'untouched' }): Record<string, unknown> {
|
|
88
|
+
nullable(revision, geoRevision);
|
|
89
|
+
let geoip: GeoDraft & { GEOIP_API_KEY_MOJO?: string; clear_api_key?: true };
|
|
90
|
+
try { geoip = geoDraft(draft); } catch { throw new GeoLocationError('validation', 'Use valid provider identifiers, up to 16 unique additional providers, and one HTTPS origin without a path, query, fragment or alternate port.'); }
|
|
91
|
+
if (edit.mode === 'replace') {
|
|
92
|
+
if (typeof edit.value !== 'string' || edit.value.length > 4096) throw new GeoLocationError('validation', 'The replacement key must be at most 4096 characters.');
|
|
93
|
+
if (edit.value !== '') geoip.GEOIP_API_KEY_MOJO = edit.value;
|
|
94
|
+
} else if (edit.mode === 'clear') {
|
|
95
|
+
if (edit.confirmed !== true) throw new GeoLocationError('validation', 'Confirm key removal first.');
|
|
96
|
+
geoip.clear_api_key = true;
|
|
97
|
+
}
|
|
98
|
+
return { action, topic: 'geoip', providers: { expected_revision: revision, geoip } };
|
|
99
|
+
}
|
|
100
|
+
export function parseGeoVerdict(value: unknown): GeoVerdict { const r = object(value); return { success: flag(r.success), code: nullable(r.code, v => text(v, 64)), message: text(r.message, 200) }; }
|
|
101
|
+
export function parseGeoTest(value: unknown): GeoVerdict {
|
|
102
|
+
const r = object(value); if (r.tested !== true || r.topic !== 'geoip') invalid();
|
|
103
|
+
const result = parseGeoVerdict(object(r.results).geoip); if (flag(r.success) !== result.success) invalid(); return result;
|
|
104
|
+
}
|
|
105
|
+
export function parseGeoPublication(value: unknown): GeoPublication {
|
|
106
|
+
const r = object(value); if (r.topic !== 'geoip') invalid();
|
|
107
|
+
const out: GeoPublication = { published: flag(r.published), unchanged: flag(r.unchanged), topic: 'geoip', revision: geoRevision(r.revision), published_revision: nullable(r.published_revision, geoRevision), version_id: nullable(r.version_id, v => text(v, 1024)), pending_restart: flag(r.pending_restart), results: { geoip: parseGeoVerdict(object(r.results).geoip) } };
|
|
108
|
+
if (out.published === out.unchanged || !out.results.geoip.success || out.published && (!out.pending_restart || out.published_revision !== out.revision)) invalid();
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
async function geoCall(body: Record<string, unknown> | null, scope: PrivateScope = {}): Promise<unknown> {
|
|
112
|
+
try {
|
|
113
|
+
if (body) operatorOrigin();
|
|
114
|
+
const reply = await mojoCall(GEOLOCATION_ENDPOINT, { ...(body ? { method: 'POST', body, refusal: 'return' } as const : {}), signal: scope.signal, beforeSend: () => { if (body) operatorOrigin(); scope.beforeSend?.(); } });
|
|
115
|
+
scope.beforeSend?.();
|
|
116
|
+
return reply.data ?? reply;
|
|
117
|
+
} catch (reason) {
|
|
118
|
+
if (reason instanceof GeoLocationError) throw reason;
|
|
119
|
+
if (reason instanceof OperatorError && reason.kind === 'denied') throw new GeoLocationError('denied');
|
|
120
|
+
if (scope.signal?.aborted) throw new GeoLocationError('denied');
|
|
121
|
+
if (reason instanceof MojoError) {
|
|
122
|
+
if (reason.status === 440) throw new GeoLocationError('fresh');
|
|
123
|
+
if ([401, 403].includes(reason.status)) throw new GeoLocationError('denied');
|
|
124
|
+
if ([404, 405].includes(reason.status)) throw new GeoLocationError('unsupported');
|
|
125
|
+
if (reason.status === 409 || reason.status === 400 && /changed; reload before publishing/i.test(reason.message)) throw new GeoLocationError('conflict');
|
|
126
|
+
if (reason.status === 400) throw new GeoLocationError('validation', 'The server rejected the provider settings or credential. Check the MojoVerify key. Pending service configuration may need Fleet Configuration applied first, or a replacement key.');
|
|
127
|
+
}
|
|
128
|
+
throw new GeoLocationError(body?.action === 'configure_providers' ? 'uncertain' : 'unavailable');
|
|
129
|
+
} finally {
|
|
130
|
+
// The only raw-bearing object has a deliberately short lifetime, including rejected calls.
|
|
131
|
+
if (body) delete ((body.providers as { geoip: Record<string, unknown> }).geoip).GEOIP_API_KEY_MOJO;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
export const readGeoLocation = async (scope?: PrivateScope): Promise<GeoLocationState> => parseGeoLocation(await geoCall(null, scope));
|
|
135
|
+
export const testGeoLocation = async (revision: string | null, draft: GeoDraft, edit?: GeoKeyEdit, scope?: PrivateScope): Promise<GeoVerdict> => parseGeoTest(await geoCall(buildGeoPayload('test_providers', revision, draft, edit), scope));
|
|
136
|
+
export const saveGeoLocation = async (revision: string | null, draft: GeoDraft, edit?: GeoKeyEdit, scope?: PrivateScope): Promise<GeoPublication> => parseGeoPublication(await geoCall(buildGeoPayload('configure_providers', revision, draft, edit), scope));
|
|
137
|
+
/** Mount-relative sibling destination, including an embedded Admin prefix. */
|
|
138
|
+
export const geoFleetRoute = (pathname: string): string => pathname.replace(/\/geolocation\/?$/, '/fleet-configuration');
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { GeoLocationPage } from './GeoLocationPage';
|
|
2
|
+
export { GEOLOCATION_ENDPOINT, GEOLOCATION_KEYS, GeoLocationError, parseGeoLocation, geoDraft, geoProviderOptions, geoWriteBlock, geoFleetRoute, buildGeoPayload, parseGeoTest, parseGeoPublication, readGeoLocation, testGeoLocation, saveGeoLocation } from './data';
|
|
3
|
+
export type { GeoDraft, GeoField, GeoKeyEdit, GeoLocationState, GeoVerdict, GeoPublication, StoredGeoVerification } from './data';
|
|
@@ -3,15 +3,85 @@
|
|
|
3
3
|
// Name / Kind badge / Status / Created — with row click-through to that
|
|
4
4
|
// group's stacked GroupDetail, and the "Add Group" flow (GroupForms.create
|
|
5
5
|
// minus the redundant parent field; the parent IS this group —
|
|
6
|
-
// GroupView._addSubGroup:1670-1689).
|
|
6
|
+
// GroupView._addSubGroup:1670-1689). Above the table, system admins get the
|
|
7
|
+
// django-mojo #9107/#9126 switch that lets this group's own administrators
|
|
8
|
+
// create groups directly under it (no web-mojo source; new in portal-mojo).
|
|
9
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
7
10
|
import {
|
|
8
11
|
Badge, Eyebrow,
|
|
9
12
|
fmt, formModal, toast,
|
|
10
13
|
} from 'portal-mojo/ui';
|
|
11
|
-
import { useCan } from 'portal-mojo/client/runtime';
|
|
14
|
+
import { MojoError, useCan } from 'portal-mojo/client/runtime';
|
|
12
15
|
import { GROUP_MANAGE_PERMS, GroupModel, type GroupRow } from '../models';
|
|
13
16
|
import { GROUP_KIND_COMBO_OPTIONS, kindLabel } from './models';
|
|
14
17
|
|
|
18
|
+
/** The policy key django-mojo keeps under metadata.protected (Group.SUBGROUP_POLICY_KEY). */
|
|
19
|
+
const SUBGROUP_POLICY_KEY = 'allow_member_subgroups';
|
|
20
|
+
|
|
21
|
+
function protectedMetadata(group: GroupRow): Record<string, unknown> {
|
|
22
|
+
const value = group.metadata?.protected;
|
|
23
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** On only when exactly true — the backend's Group.allows_member_subgroups(). */
|
|
27
|
+
export function allowsMemberSubgroups(group: GroupRow): boolean {
|
|
28
|
+
return protectedMetadata(group)[SUBGROUP_POLICY_KEY] === true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The system-admin switch. The server action answers with the new value,
|
|
33
|
+
* not the row, so the one-record cache is written through here and the
|
|
34
|
+
* action's own invalidation refetches the group behind it.
|
|
35
|
+
*/
|
|
36
|
+
function MemberSubgroupsSwitch({ group }: { group: GroupRow }) {
|
|
37
|
+
const qc = useQueryClient();
|
|
38
|
+
const policy = GroupModel.useAction(SUBGROUP_POLICY_KEY);
|
|
39
|
+
const on = allowsMemberSubgroups(group);
|
|
40
|
+
|
|
41
|
+
const change = async (next: boolean) => {
|
|
42
|
+
try {
|
|
43
|
+
const outcome = await policy.mutateAsync({ id: group.id, payload: next });
|
|
44
|
+
const echoed = outcome.result.payload[SUBGROUP_POLICY_KEY];
|
|
45
|
+
const value = typeof echoed === 'boolean' ? echoed : next;
|
|
46
|
+
// Off removes the key, as the server does.
|
|
47
|
+
const nextProtected = { ...protectedMetadata(group) };
|
|
48
|
+
if (value) nextProtected[SUBGROUP_POLICY_KEY] = true;
|
|
49
|
+
else delete nextProtected[SUBGROUP_POLICY_KEY];
|
|
50
|
+
qc.setQueryData(GroupModel.keys.one(group.id), {
|
|
51
|
+
...group,
|
|
52
|
+
metadata: { ...group.metadata, protected: nextProtected },
|
|
53
|
+
});
|
|
54
|
+
toast.success(value
|
|
55
|
+
? `${group.name}'s administrators can now create sub-groups`
|
|
56
|
+
: `${group.name}'s administrators can no longer create sub-groups`);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
toast.error(err instanceof MojoError && err.status === 403
|
|
59
|
+
? 'You do not have permission to change who can create sub-groups'
|
|
60
|
+
: err instanceof Error ? err.message : 'Could not change the setting');
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div style={{ marginBottom: 12 }}>
|
|
66
|
+
<label className="switch-row">
|
|
67
|
+
<input
|
|
68
|
+
type="checkbox"
|
|
69
|
+
role="switch"
|
|
70
|
+
className="switch"
|
|
71
|
+
checked={on}
|
|
72
|
+
disabled={policy.isPending}
|
|
73
|
+
onChange={(e) => void change(e.target.checked)}
|
|
74
|
+
/>
|
|
75
|
+
<span className="field-label">Administrators can create sub-groups</span>
|
|
76
|
+
</label>
|
|
77
|
+
<span className="field-help">
|
|
78
|
+
While on, people who manage this group as members can create groups directly under it.
|
|
79
|
+
It does not pass down to the new groups, and turning it off leaves existing groups alone.
|
|
80
|
+
</span>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
15
85
|
/** GroupModel.useSave().mutateAsync — what the add-sub-group flow needs. */
|
|
16
86
|
type GroupSaveFn = (vars: { id: number | string | null; changes: Record<string, unknown> }) => Promise<GroupRow>;
|
|
17
87
|
|
|
@@ -54,6 +124,7 @@ export function SubGroupsSection({ group, openGroup }: {
|
|
|
54
124
|
return (
|
|
55
125
|
<>
|
|
56
126
|
<Eyebrow>Sub-groups</Eyebrow>
|
|
127
|
+
{canManage && <MemberSubgroupsSwitch group={group} />}
|
|
57
128
|
{canManage && (
|
|
58
129
|
<div className="ga-toolbar">
|
|
59
130
|
<button className="btn btn-primary btn-compact" onClick={() => void runAddSubGroupFlow(group, save.mutateAsync)}>
|
|
@@ -66,5 +66,7 @@ export const GroupModel = defineModel<GroupRow>({
|
|
|
66
66
|
actions: {
|
|
67
67
|
disable: { permissions: GROUP_DESTRUCTIVE_PERMS },
|
|
68
68
|
reactivate: { permissions: GROUP_DESTRUCTIVE_PERMS },
|
|
69
|
+
// django-mojo #9126: answers {status, allow_member_subgroups}, not the row.
|
|
70
|
+
allow_member_subgroups: { permissions: GROUP_MANAGE_PERMS, response: 'payload' },
|
|
69
71
|
},
|
|
70
72
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ComboBox } from '../../../ui/ComboBox';
|
|
1
2
|
import { useEffect, useState } from 'react';
|
|
2
3
|
import { useQueryClient } from '@tanstack/react-query';
|
|
3
4
|
import { useCan, type Params } from '../../../client/runtime';
|
|
@@ -135,14 +136,7 @@ export function GroupMembersPanel({
|
|
|
135
136
|
className="input" type="search" placeholder="Search members…"
|
|
136
137
|
value={search} onChange={(event) => setSearch(event.target.value)}
|
|
137
138
|
/>
|
|
138
|
-
<
|
|
139
|
-
className="input input-compact" value={status} aria-label="Status filter"
|
|
140
|
-
onChange={(event) => setStatus(event.target.value as StatusFilter)}
|
|
141
|
-
>
|
|
142
|
-
<option value="active">Active</option>
|
|
143
|
-
<option value="inactive">Inactive</option>
|
|
144
|
-
<option value="all">All</option>
|
|
145
|
-
</select>
|
|
139
|
+
<ComboBox allowCustom={false} maxSuggestions={Number.MAX_SAFE_INTEGER} value={String(status)} aria-label="Status filter" onChange={(selectedValue) => setStatus(String(selectedValue) as StatusFilter)} options={[({ value: "active", label: "Active" }), ({ value: "inactive", label: "Inactive" }), ({ value: "all", label: "All" })]} />
|
|
146
140
|
</div>
|
|
147
141
|
{query.isPending && <div className="skel skel-block" />}
|
|
148
142
|
{query.error && <p className="text-bad">{query.error.message}</p>}
|
package/src/admin/index.ts
CHANGED
|
@@ -26,3 +26,11 @@ export * from './assistant';
|
|
|
26
26
|
export { ADMIN_SECTIONS } from './registry';
|
|
27
27
|
export { FleetConfigurationPage, FleetNodeStatus } from './fleet-configuration/FleetConfigurationPage';
|
|
28
28
|
export { FLEET_CONFIGURATION_ADMIN_SECTION } from './domains/operations';
|
|
29
|
+
export * from './fleet';
|
|
30
|
+
export * from './geolocation';
|
|
31
|
+
export { FLEET_ADMIN_SECTION } from './domains/infrastructure';
|
|
32
|
+
export { GEOLOCATION_ADMIN_SECTION } from './domains/operations';
|
|
33
|
+
export * from './maintenance';
|
|
34
|
+
export { MAINTENANCE_ADMIN_SECTION } from './domains/operations';
|
|
35
|
+
export * from './deployments';
|
|
36
|
+
export { DEPLOYMENTS_ADMIN_SECTION } from './domains/operations';
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { frameworkActionLabel, type Finding, type Framework } from './data';
|
|
3
|
+
import { datetime } from '../../ui/format';
|
|
4
|
+
import { FleetHelp as MaintenanceHelp } from '../fleet/FleetHelp';
|
|
5
|
+
import type { MaintenanceAttempt } from './operations';
|
|
6
|
+
export function MaintenanceDialog({
|
|
7
|
+
finding,
|
|
8
|
+
framework,
|
|
9
|
+
busy,
|
|
10
|
+
onConfirm,
|
|
11
|
+
onClose,
|
|
12
|
+
}: {
|
|
13
|
+
finding?: Finding;
|
|
14
|
+
framework?: Framework;
|
|
15
|
+
busy: boolean;
|
|
16
|
+
onConfirm: (immediate: boolean) => void;
|
|
17
|
+
onClose: () => void;
|
|
18
|
+
}) {
|
|
19
|
+
const dialog = useRef<HTMLDialogElement>(null);
|
|
20
|
+
const [typed, setTyped] = useState(''),
|
|
21
|
+
[windowChoice, setWindowChoice] = useState<'now' | 'window' | ''>('');
|
|
22
|
+
const expected = finding?.resource_id ?? framework?.latest ?? '';
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const d = dialog.current;
|
|
25
|
+
d?.showModal();
|
|
26
|
+
return () => d?.close();
|
|
27
|
+
}, []);
|
|
28
|
+
return (
|
|
29
|
+
<dialog
|
|
30
|
+
ref={dialog}
|
|
31
|
+
className="maintenance-dialog"
|
|
32
|
+
aria-labelledby="maintenance-confirm-title"
|
|
33
|
+
onCancel={(e) => {
|
|
34
|
+
e.preventDefault();
|
|
35
|
+
if (!busy) onClose();
|
|
36
|
+
}}
|
|
37
|
+
>
|
|
38
|
+
<div className="maintenance-dialog-body">
|
|
39
|
+
<h2 id="maintenance-confirm-title">
|
|
40
|
+
{finding
|
|
41
|
+
? `Upgrade ${finding.resource_id}?`
|
|
42
|
+
: framework
|
|
43
|
+
? frameworkActionLabel(framework)
|
|
44
|
+
: 'Review update'}
|
|
45
|
+
</h2>
|
|
46
|
+
<div className="maintenance-dialog-scroll">
|
|
47
|
+
<p>
|
|
48
|
+
{finding
|
|
49
|
+
? `${finding.engine}: ${finding.current_version} → ${finding.available_major}`
|
|
50
|
+
: `Installed ${framework?.installed ?? 'unknown'} → ${framework?.latest ?? 'unknown'}`}
|
|
51
|
+
</p>
|
|
52
|
+
<p className="maintenance-warning">
|
|
53
|
+
{finding
|
|
54
|
+
? finding.kind === 'elasticache'
|
|
55
|
+
? 'This restarts cache nodes. Clients reconnect and cached data may be lost. A major upgrade cannot be undone; recovery means restoring a snapshot.'
|
|
56
|
+
: 'The database goes offline during the engine upgrade. Multi-AZ may fail over; a single instance is unavailable. A major upgrade cannot be undone; recovery means restoring a snapshot.'
|
|
57
|
+
: 'This clears the current pin, restores automatic latest-version updates, and redeploys the last converged application SHA. Services restart as the canary migrates and the fleet rolls. Acceptance does not prove fleet completion.'}
|
|
58
|
+
</p>
|
|
59
|
+
{finding && (
|
|
60
|
+
<fieldset disabled={busy}>
|
|
61
|
+
<legend>Choose the outage window</legend>
|
|
62
|
+
<label>
|
|
63
|
+
<input
|
|
64
|
+
type="radio"
|
|
65
|
+
name="maintenance-window"
|
|
66
|
+
checked={windowChoice === 'window'}
|
|
67
|
+
onChange={() => setWindowChoice('window')}
|
|
68
|
+
/>{' '}
|
|
69
|
+
Next maintenance window
|
|
70
|
+
</label>
|
|
71
|
+
<label>
|
|
72
|
+
<input
|
|
73
|
+
type="radio"
|
|
74
|
+
name="maintenance-window"
|
|
75
|
+
checked={windowChoice === 'now'}
|
|
76
|
+
onChange={() => setWindowChoice('now')}
|
|
77
|
+
/>{' '}
|
|
78
|
+
Immediately
|
|
79
|
+
</label>
|
|
80
|
+
</fieldset>
|
|
81
|
+
)}
|
|
82
|
+
<label className="maintenance-confirm-label">
|
|
83
|
+
Type {expected} to confirm
|
|
84
|
+
<input
|
|
85
|
+
autoFocus
|
|
86
|
+
autoComplete="off"
|
|
87
|
+
value={typed}
|
|
88
|
+
disabled={busy}
|
|
89
|
+
onChange={(e) => setTyped(e.target.value)}
|
|
90
|
+
/>
|
|
91
|
+
</label>
|
|
92
|
+
</div>
|
|
93
|
+
<div className="maintenance-actions">
|
|
94
|
+
<button className="btn" disabled={busy} onClick={onClose}>
|
|
95
|
+
Cancel
|
|
96
|
+
</button>
|
|
97
|
+
<button
|
|
98
|
+
className="btn btn-danger"
|
|
99
|
+
disabled={busy || typed !== expected || !expected || (!!finding && !windowChoice)}
|
|
100
|
+
onClick={() => onConfirm(windowChoice === 'now')}
|
|
101
|
+
>
|
|
102
|
+
{busy ? 'Requesting…' : finding ? 'Apply upgrade' : 'Confirm framework update'}
|
|
103
|
+
</button>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</dialog>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function MaintenancePolicyDialog({ framework, busy, error, onSave, onClose }: {
|
|
111
|
+
framework: Framework; busy: boolean; error: string; onSave: (value: string) => void; onClose: () => void;
|
|
112
|
+
}) {
|
|
113
|
+
const dialog = useRef<HTMLDialogElement>(null);
|
|
114
|
+
const [mode, setMode] = useState(framework.pin.mode);
|
|
115
|
+
const [version, setVersion] = useState(framework.pin.mode === 'pinned' ? framework.pin.value ?? '' : framework.installed ?? '');
|
|
116
|
+
const value = mode === 'latest' ? '' : mode === 'hold' ? 'hold' : version.trim().toLowerCase();
|
|
117
|
+
const valid = mode !== 'pinned' || /^[0-9][a-z0-9._!+-]{0,63}$/.test(value);
|
|
118
|
+
const unchanged = mode === framework.pin.mode && (mode !== 'pinned' || value === framework.pin.value);
|
|
119
|
+
useEffect(() => { const d = dialog.current; d?.showModal(); return () => d?.close(); }, []);
|
|
120
|
+
return <dialog ref={dialog} className="maintenance-dialog" aria-labelledby="maintenance-policy-title" onCancel={e => { e.preventDefault(); if (!busy) onClose(); }}>
|
|
121
|
+
<div className="maintenance-dialog-body"><h2 id="maintenance-policy-title">Framework update policy</h2>
|
|
122
|
+
<div className="maintenance-dialog-scroll">
|
|
123
|
+
<p>This saves policy only. Nodes change on a later deployment.</p>
|
|
124
|
+
<fieldset disabled={busy}><legend>Version policy</legend>
|
|
125
|
+
<label><input type="radio" name="framework-policy" checked={mode === 'latest'} onChange={() => setMode('latest')} /> Automatic</label>
|
|
126
|
+
<label><input type="radio" name="framework-policy" checked={mode === 'hold'} onChange={() => setMode('hold')} /> Hold</label>
|
|
127
|
+
<label><input type="radio" name="framework-policy" checked={mode === 'pinned'} onChange={() => setMode('pinned')} /> Exact version</label>
|
|
128
|
+
</fieldset>
|
|
129
|
+
{mode === 'pinned' && <label className="field"><span className="field-label">Version</span><input className="input" disabled={busy} value={version} onChange={e => setVersion(e.target.value)} autoComplete="off" placeholder="1.39.6" /></label>}
|
|
130
|
+
<p className="dim">{mode === 'latest' ? 'Each deployment installs the newest published release.' : mode === 'hold' ? 'Keep the framework version from the last converged deployment.' : 'Each deployment installs this exact version.'} <MaintenanceHelp label="framework policy"><p>Hold requires a last converged framework version. Exact version checks syntax here; release availability is checked during deployment.</p><p>The separate framework Update action clears Hold or Exact and deploys the latest published version.</p></MaintenanceHelp></p>
|
|
131
|
+
{error && <p role="alert" className="form-alert">{error}</p>}
|
|
132
|
+
</div>
|
|
133
|
+
<div className="maintenance-actions"><button className="btn" disabled={busy} onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy || !valid || unchanged} onClick={() => onSave(value)}>{busy ? 'Saving…' : 'Save policy'}</button></div>
|
|
134
|
+
</div>
|
|
135
|
+
</dialog>;
|
|
136
|
+
}
|
|
137
|
+
export function MaintenanceProgressDialog({ attempt, busy, onObserve, onClose, deploymentsHref }: {
|
|
138
|
+
attempt: MaintenanceAttempt; busy: boolean; onObserve: () => void; onClose: () => void; deploymentsHref?: string;
|
|
139
|
+
}) {
|
|
140
|
+
const dialog = useRef<HTMLDialogElement>(null);
|
|
141
|
+
useEffect(() => { const d = dialog.current; d?.showModal(); return () => d?.close(); }, []);
|
|
142
|
+
const resource = 'kind' in attempt.input ? attempt.input : null;
|
|
143
|
+
const target = resource?.target_version ?? ('version' in attempt.input ? attempt.input.version : 'policy' in attempt.input ? attempt.input.policy || 'Automatic' : '');
|
|
144
|
+
const observation = attempt.observation;
|
|
145
|
+
return <dialog ref={dialog} className="maintenance-dialog" aria-labelledby="maintenance-progress-title" onCancel={e => { e.preventDefault(); if (!busy) onClose(); }}>
|
|
146
|
+
<div className="maintenance-dialog-body"><h2 id="maintenance-progress-title">Update progress</h2>
|
|
147
|
+
<div className="maintenance-dialog-scroll">
|
|
148
|
+
<strong>{resource?.resource ?? ('policy' in attempt.input ? 'Framework policy' : 'Django-MOJO')}</strong>
|
|
149
|
+
<p role="status">{attempt.message}</p>
|
|
150
|
+
<dl className="maintenance-progress-facts"><dt>Target</dt><dd>{target}</dd>{resource && <><dt>When</dt><dd>{resource.apply_immediately ? 'Immediately' : 'Next configured AWS window'}</dd></>}<dt>Last observation</dt><dd>{attempt.observedAt ? datetime(attempt.observedAt) : 'Not observed yet'}</dd></dl>
|
|
151
|
+
{observation && <><p>{observation.found ? `${observation.status ?? 'Status unknown'} · ${observation.engine_version ?? 'Version unknown'}` : 'Resource not found — outcome remains unconfirmed.'}</p>{observation.pending_version && <p>Pending version: {observation.pending_version}</p>}<p className="dim">Observed versions: {observation.engine_versions.join(', ') || 'Unknown'} · {observation.settled ? 'Provider settled' : 'Still changing'}</p></>}
|
|
152
|
+
{attempt.deployment && <p className="dim">Deployment {attempt.deployment.id}</p>}
|
|
153
|
+
{attempt.frameworkObservation?.nodes.map(node => <p key={node.runner}>{node.runner} · {node.state} · {node.version ?? 'Version unreported'}</p>)}
|
|
154
|
+
{'version' in attempt.input && !attempt.deployment && <p className="maintenance-warning">Deployment acceptance is unknown. Reconcile server deployment logs; a matching version or latest deployment cannot identify this request.</p>}
|
|
155
|
+
<p className="dim">Closing this dialog does not cancel submitted work. <MaintenanceHelp label="update recovery"><p>Unknown outcomes stay guarded. Refresh only reads evidence and never resends a write. Receipts survive navigation in this document, but a full browser reload loses them.</p>{resource?.kind === 'rds-cluster' && <p>Aurora reports cluster-level settlement; individual database member proof is unavailable here.</p>}</MaintenanceHelp></p>
|
|
156
|
+
{deploymentsHref && 'version' in attempt.input && <a href={deploymentsHref}>Open Deployments</a>}
|
|
157
|
+
</div>
|
|
158
|
+
<div className="maintenance-actions"><button className="btn" disabled={busy} onClick={onClose}>Close</button>{attempt.state !== 'complete' && attempt.state !== 'refused' && (!('version' in attempt.input) || !!attempt.deployment) && <button className="btn btn-primary" disabled={busy || attempt.state === 'sending'} onClick={onObserve}>{busy ? 'Observing…' : 'Refresh status'}</button>}</div>
|
|
159
|
+
</div>
|
|
160
|
+
</dialog>;
|
|
161
|
+
}
|