portal-mojo 0.2.20 → 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-deployments.md +201 -0
- package/docs/admin-dns-certificates.md +3 -3
- package/docs/admin-dns-domains.md +92 -0
- package/docs/admin-dns.md +4 -2
- package/docs/admin-edge.md +200 -196
- package/docs/admin-geolocation.md +100 -0
- package/docs/admin-maintenance.md +133 -0
- package/docs/admin-modals.md +17 -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/client.md +7 -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/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/DomainDetail.tsx +10 -6
- 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 +22 -13
- package/src/admin/dns/SharedValidationDialog.tsx +1 -1
- 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/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 +22 -7
- 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/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/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/client.ts +6 -0
- package/src/client/deployments-mock.ts +375 -0
- package/src/client/domain-operations-mock.ts +37 -0
- package/src/client/geo-provider-mock.ts +82 -0
- package/src/client/maintenance-mock.ts +285 -0
- package/src/client/mock.ts +52 -10
- 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-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/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,158 @@
|
|
|
1
|
+
import { mojoCall, isReauthRequired, requestFreshAuth } from '../../client/runtime';
|
|
2
|
+
import { MojoError } from '../../client/errors';
|
|
3
|
+
import { appActionsRefused, refuseAppActions } from './authority-state';
|
|
4
|
+
import type { FetchOpts } from '../../client/client';
|
|
5
|
+
import { projectAttempt, projectBootstrap, projectDeployment, projectOperation, projectOptions, projectPlatform, projectPlatformAttempt, projectPrecheck, projectRelease, projectSummaries, projectSummary, projectWebAppObject, record, text, positiveId, list, uuid } from './sanitize';
|
|
6
|
+
import { assertOwner, webAppAuthority, type DeploymentOwner } from './permissions';
|
|
7
|
+
import { appUnsettled, receiptsFor, saveReceipt, singleFlight, type ActionReceipt } from './receipts';
|
|
8
|
+
import type { BackendGlobalUser } from '../fleet/access';
|
|
9
|
+
import type { WebAppObject } from './types';
|
|
10
|
+
import type { ApiAction, CreateProfile, ManifestEntry, OnboardingOperation } from './types';
|
|
11
|
+
const ROOT = '/api/edge';
|
|
12
|
+
function privateError(error: unknown): Error { if (error instanceof DOMException && error.name === 'AbortError')
|
|
13
|
+
return new DOMException('Deployment owner changed', 'AbortError'); const status = error && typeof error === 'object' && 'status' in error && typeof error.status === 'number' ? error.status : 0; return new MojoError(status === 440 ? 'Fresh authentication required' : status === 403 ? 'Deployment observation or action denied' : 'Deployment request unavailable; outcome may be unknown', status); }
|
|
14
|
+
export async function safeCall<T>(owner: DeploymentOwner, path: string, project: (v: unknown) => T, opts: FetchOpts = {}): Promise<T> { assertOwner(owner, opts.signal); try {
|
|
15
|
+
const body = await mojoCall(path, {
|
|
16
|
+
...opts, unscoped: true, beforeSend: () => { assertOwner(owner, opts.signal); opts.beforeSend?.(); }
|
|
17
|
+
});
|
|
18
|
+
assertOwner(owner, opts.signal);
|
|
19
|
+
return project(body.data);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
throw privateError(error);
|
|
23
|
+
} }
|
|
24
|
+
export const getBootstrap = (o: DeploymentOwner, signal?: AbortSignal) => safeCall(o, '/api/account/admin/bootstrap', projectBootstrap, { signal });
|
|
25
|
+
export const getPlatform = (o: DeploymentOwner, signal?: AbortSignal) => safeCall(o, '/api/account/admin/platform', projectPlatform, { params: { sections: 'deployments,api' }, signal });
|
|
26
|
+
export const getWebApps = (o: DeploymentOwner, signal?: AbortSignal) => safeCall(o, `${ROOT}/webapp/summaries`, projectSummaries, { signal });
|
|
27
|
+
export const getSummary = (o: DeploymentOwner, id: number, signal?: AbortSignal) => safeCall(o, `${ROOT}/webapp/summary`, v => { const s = projectSummary(v); if (s.webapp.id !== id)
|
|
28
|
+
throw new Error('App identity changed'); return s; }, { params: { webapp: id }, signal });
|
|
29
|
+
export const getWebApp = (o: DeploymentOwner, id: number, signal?: AbortSignal) => safeCall(o, `${ROOT}/webapp/${id}`, v => { const s = projectWebAppObject(v); if (s.id !== id || !s.group.id)
|
|
30
|
+
throw new Error('App ownership unavailable'); return s; }, { params: { graph: 'default' }, signal });
|
|
31
|
+
export const getMember = (o: DeploymentOwner, group: number, signal?: AbortSignal) => safeCall(o, `/api/group/${group}/member`, v => { const r = record(v), p = record(r.permissions); return { permissions: Object.fromEntries(['view_dns', 'manage_dns', 'security', 'manage_webapp'].filter(k => Object.hasOwn(p, k)).map(k => [k, p[k]])) }; }, { signal });
|
|
32
|
+
export async function resolveAppAuthority(o: DeploymentOwner, user: BackendGlobalUser | undefined, app: WebAppObject, kind: 'custom' | 'workflow' | 'release', signal?: AbortSignal) { const base = webAppAuthority(user, app); return base[kind] ? base : webAppAuthority(user, app, await getMember(o, app.group.id, signal)); }
|
|
33
|
+
export async function getHistory(o: DeploymentOwner, app: number, kind: 'release' | 'deployment', start = 0, signal?: AbortSignal, size = kind === 'release' ? 25 : 15) { const body = await mojoCall(`${ROOT}/${kind}`, {
|
|
34
|
+
unscoped: true, signal, params: {
|
|
35
|
+
webapp: app, graph: 'list', sort: '-created', start, size: Math.max(1, Math.min(100, Math.floor(size)))
|
|
36
|
+
}, beforeSend: () => assertOwner(o, signal)
|
|
37
|
+
}).catch(error => { throw privateError(error); }); assertOwner(o, signal); return { items: list(body.data, Math.max(1, Math.min(100, Math.floor(size)))).map(v => kind === 'release' ? projectRelease(v) : projectAttempt(v)), count: typeof body.count === 'number' ? body.count : 0 }; }
|
|
38
|
+
export const getDeployment = (o: DeploymentOwner, id: number, app: number, release: number, signal?: AbortSignal) => safeCall(o, `${ROOT}/release/deployment/${id}`, v => { const d = projectDeployment(v); if (d.deployment !== id || d.webapp !== app || d.release !== release)
|
|
39
|
+
throw new Error('Deployment identity mismatch'); return d; }, { signal });
|
|
40
|
+
/** Generic graphs omit the parent: bind the read through both exact server filters. */
|
|
41
|
+
export const getAttempt = (o: DeploymentOwner, id: number, app: number, signal?: AbortSignal) => safeCall(o, `${ROOT}/deployment`, v => {
|
|
42
|
+
const rows = list(v, 2); const row = projectAttempt(rows[0]);
|
|
43
|
+
if (!positiveId(id) || !positiveId(app) || rows.length !== 1 || row.id !== id || !row.release?.id) throw new Error('Attempt unavailable in this WebApp');
|
|
44
|
+
return row;
|
|
45
|
+
}, { params: { webapp: app, id, graph: 'default', start: 0, size: 1 }, signal });
|
|
46
|
+
export const getRelease = (o: DeploymentOwner, id: number, app: number, signal?: AbortSignal) => safeCall(o, `${ROOT}/release`, v => {
|
|
47
|
+
const rows = list(v, 2); const row = projectRelease(rows[0]);
|
|
48
|
+
if (!positiveId(id) || !positiveId(app) || rows.length !== 1 || row.id !== id) throw new Error('Release unavailable in this WebApp');
|
|
49
|
+
return row;
|
|
50
|
+
}, { params: { webapp: app, id, graph: 'default', start: 0, size: 1 }, signal });
|
|
51
|
+
export async function write<T>(o: DeploymentOwner, r: ActionReceipt, path: string, body: Record<string, unknown>, project: (v: unknown) => T, signal?: AbortSignal): Promise<T> { assertOwner(o, signal); if (appActionsRefused(o, r.app))
|
|
52
|
+
throw new Error('App actions were refused. Refresh safe state and establish a new session before another mutation.'); if (appUnsettled(o, r.app, r.key))
|
|
53
|
+
throw new Error('Another app operation has an unknown outcome. Refresh its exact status before continuing.'); if (['rollback', 'profile'].includes(r.kind) && r.submitted && !r.settled)
|
|
54
|
+
throw new Error(r.kind === 'profile' ? 'Settings save outcome is unknown. Reload saved settings before making another save.' : 'Rollback delivery is unknown. Observe summary/history; no rollback was replayed.'); saveReceipt({
|
|
55
|
+
...r, submitted: true, submittedAt: r.submittedAt ?? Date.now()
|
|
56
|
+
}); return singleFlight(`${o.actor}:${r.app || r.key}`, async () => { try {
|
|
57
|
+
return await safeCall(o, path, project, {
|
|
58
|
+
method: 'POST', body, signal, ...(r.kind === 'api' ? { idempotencyKey: r.operation } : {})
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (isReauthRequired(error)) {
|
|
63
|
+
saveReceipt({ ...r, submitted: false, ...(r.kind === 'profile' ? { settled: true } : {}) });
|
|
64
|
+
await requestFreshAuth();
|
|
65
|
+
throw new Error('Authentication renewed or cancelled. Refresh authoritative state and explicitly confirm again; nothing was replayed.');
|
|
66
|
+
}
|
|
67
|
+
if (error instanceof MojoError && error.status === 403 && r.app)
|
|
68
|
+
refuseAppActions(o, r.app);
|
|
69
|
+
if (error && typeof error === 'object' && 'status' in error && [400, 401, 403, 404, 409, 440].includes(Number(error.status)))
|
|
70
|
+
saveReceipt({ ...r, settled: r.kind==='upload'&&r.uploadPhase==='completion_submitted'?false:true });
|
|
71
|
+
throw new Error(error instanceof DOMException && error.name === 'AbortError' ? 'Session or view changed. The submitted operation may persist. Resume to reconcile.' : 'The operation was refused or delivery is uncertain. Refresh authoritative state before retrying.');
|
|
72
|
+
} }); }
|
|
73
|
+
export type WebAppProfilePatch = { display_name: string; environment: string; github_repository: string; deployment_ref: string; build_output: string };
|
|
74
|
+
export function saveWebAppProfile(o: DeploymentOwner, r: ActionReceipt, app: WebAppObject, patch: WebAppProfilePatch, signal?: AbortSignal) {
|
|
75
|
+
const fields = ['display_name', 'environment', 'github_repository', 'deployment_ref', 'build_output'] as const;
|
|
76
|
+
if (r.kind !== 'profile' || r.app !== app.id || Object.keys(patch).some(key => !fields.includes(key as typeof fields[number])) || fields.some(key => typeof patch[key] !== 'string')) throw new Error('Unsupported WebApp profile fields');
|
|
77
|
+
return write(o, r, `${ROOT}/webapp/${app.id}`, Object.fromEntries(fields.map(key => [key, patch[key]])), v => {
|
|
78
|
+
const saved = projectWebAppObject(v);
|
|
79
|
+
if (saved.id !== app.id || saved.group.id !== app.group.id) throw new Error('WebApp ownership changed');
|
|
80
|
+
return saved;
|
|
81
|
+
}, signal);
|
|
82
|
+
}
|
|
83
|
+
export async function recoverApi(o: DeploymentOwner, r: ActionReceipt, id: string, action: ApiAction, signal?: AbortSignal) { if (!uuid(id))
|
|
84
|
+
throw new Error('Invalid deployment ID'); return write(o, r, `/api/account/admin/platform/deploy/${action}`, { deployment: id }, v => { const d = projectPlatformAttempt(record(v).deployment); if (!d.id || (action !== 'retry' && d.id !== id))
|
|
85
|
+
throw new Error('Deployment identity mismatch'); return d; }, signal); }
|
|
86
|
+
export const rollback = (o: DeploymentOwner, r: ActionReceipt, app: number, release: number, group: number, signal?: AbortSignal) => write(o, r, `${ROOT}/webapp/rollback`, {
|
|
87
|
+
webapp: app, release, group
|
|
88
|
+
}, v => { const d = projectDeployment(v); if (!d.deployment || d.webapp !== app || d.release !== release)
|
|
89
|
+
throw new Error('Rollback identity mismatch'); return d; }, signal);
|
|
90
|
+
export const getOptions = (o: DeploymentOwner, intent: {
|
|
91
|
+
group: number;
|
|
92
|
+
} | {
|
|
93
|
+
group_intent: 'new';
|
|
94
|
+
}, signal?: AbortSignal) => safeCall(o, `${ROOT}/webapp/onboarding/options`, projectOptions, { params: intent, signal });
|
|
95
|
+
export const precheckAddress = (o: DeploymentOwner, intent: {
|
|
96
|
+
group: number;
|
|
97
|
+
} | {
|
|
98
|
+
group_intent: 'new';
|
|
99
|
+
}, url: string, signal?: AbortSignal) => safeCall(o, `${ROOT}/webapp/onboarding/precheck`, projectPrecheck, { params: { ...intent, url }, signal });
|
|
100
|
+
export const getOperation = (o: DeploymentOwner, id: string, signal?: AbortSignal) => safeCall(o, `${ROOT}/webapp/onboarding/detail`, v => { const p = projectOperation(v); if (p.operation_id !== id)
|
|
101
|
+
throw new Error('Operation identity mismatch'); return p; }, { params: { operation: id }, signal });
|
|
102
|
+
export const createApp = (o: DeploymentOwner, r: ActionReceipt, p: CreateProfile, signal?: AbortSignal) => write(o, r, `${ROOT}/webapp/onboarding/create`, { ...p }, v => { const operation = projectOperation(record(v).operation); if (operation.operation_id !== p.operation_id)
|
|
103
|
+
throw new Error('Operation identity mismatch'); return operation; }, signal);
|
|
104
|
+
export const chooseOnboarding = (o: DeploymentOwner, r: ActionReceipt, p: OnboardingOperation, choice: Record<string, unknown>, signal?: AbortSignal) => write(o, r, `${ROOT}/webapp/onboarding/choose`, {
|
|
105
|
+
operation: p.operation_id, revision: p.revision, step: p.cursor, choice
|
|
106
|
+
}, v => { const next = projectOperation(v); if (next.operation_id !== p.operation_id)
|
|
107
|
+
throw new Error('Operation identity mismatch'); return next; }, signal);
|
|
108
|
+
export const cancelOnboarding = (o: DeploymentOwner, r: ActionReceipt, id: string, signal?: AbortSignal) => write(o, r, `${ROOT}/webapp/onboarding/cancel`, { operation: id }, projectOperation, signal);
|
|
109
|
+
export const getWorkflow = (o: DeploymentOwner, app: number, group: number, signal?: AbortSignal) => safeCall(o, `${ROOT}/webapp/onboarding/workflow`, v => { const r = record(v); return {
|
|
110
|
+
filename: '.github/workflows/deploy-webapp.yml', repository: text(r.repository), yaml: text(r.yaml, 32000)
|
|
111
|
+
}; }, {
|
|
112
|
+
method: 'POST', body: { webapp: app, group }, signal
|
|
113
|
+
}).catch(async (error) => { if (isReauthRequired(error)) {
|
|
114
|
+
await requestFreshAuth();
|
|
115
|
+
throw new Error('Authentication prompted. Review and explicitly generate the workflow again; nothing was replayed.');
|
|
116
|
+
} throw error; });
|
|
117
|
+
/** Secret never crosses a Query/Mutation cache or error object. */
|
|
118
|
+
export const changeKey = (o: DeploymentOwner, r: ActionReceipt, app: number, group: number, action: 'mint' | 'rotate' | 'revoke', signal?: AbortSignal) => write(o, r, `${ROOT}/webapp/${action === 'revoke' ? 'revoke_key' : 'link_key'}`, {
|
|
119
|
+
webapp: app, group, operation_id: r.operation, ...(action === 'revoke' ? {} : { action })
|
|
120
|
+
}, v => { const x = record(v); if (positiveId(x.webapp) !== app)
|
|
121
|
+
throw new Error('Key receipt identity mismatch'); return { token: typeof x.token === 'string' ? x.token.slice(0, 8192) : null, replayed: x.replayed === true }; }, signal);
|
|
122
|
+
export const getKeyStatus = (o: DeploymentOwner, app: number, group: number, signal?: AbortSignal) => safeCall(o, `${ROOT}/webapp/key_status`, v => { const r = record(v), s = record(r.status); if (positiveId(r.webapp) !== app)
|
|
123
|
+
throw new Error('Key identity mismatch'); return { linked: s.linked === true, active: s.active === true }; }, { params: { webapp: app, group }, signal });
|
|
124
|
+
export const getHealth = (o: DeploymentOwner, app: number, group: number, signal?: AbortSignal) => safeCall(o, `${ROOT}/webapp/health`, v => { const r = record(v); if (positiveId(r.webapp) !== app)
|
|
125
|
+
throw new Error('Health identity mismatch'); return {
|
|
126
|
+
status: text(r.status) || 'unknown', detail: text(r.detail, 500), checked: text(r.checked)
|
|
127
|
+
}; }, { params: { webapp: app, group }, signal });
|
|
128
|
+
export async function registerUpload(o: DeploymentOwner, r: ActionReceipt, app: number, group: number, version: string, manifest: ManifestEntry[], signal?: AbortSignal) { const current=receiptsFor(o).find(x=>x.key===r.key)??r;if(['completion_submitted','observing'].includes(current.uploadPhase??''))throw new Error('Completion was already submitted. Reconcile its exact deployment before a new explicit deployment decision.');return write(o, r, `${ROOT}/release`, {
|
|
129
|
+
webapp: app, group, version, manifest
|
|
130
|
+
}, v => { const d = record(v); if (!positiveId(d.release) || text(d.version) !== version)
|
|
131
|
+
throw new Error('Release identity mismatch'); return {
|
|
132
|
+
release: positiveId(d.release), status: text(d.status), uploads: list(d.uploads, 5000).map(v => { const u = record(v), h = record(u.headers); return {
|
|
133
|
+
path: text(u.path, 1024), url: text(u.url, 8192), headers: { 'x-amz-checksum-sha256': text(h['x-amz-checksum-sha256'], 64) }
|
|
134
|
+
}; })
|
|
135
|
+
}; }, signal); }
|
|
136
|
+
/** Completion is a deployment request, not an idempotent verification read. */
|
|
137
|
+
export async function completeUpload(o:DeploymentOwner,r:ActionReceipt,release:number,app:number,group:number,signal?:AbortSignal){
|
|
138
|
+
return singleFlight(`upload-completion:${o.actor}:${app}`,async()=>{
|
|
139
|
+
const current=receiptsFor(o).find(x=>x.key===r.key)??r;
|
|
140
|
+
if(['completion_submitted','observing'].includes(current.uploadPhase??''))throw new Error('Completion delivery is unsettled or already observed. Reconcile before consenting to another deployment.');
|
|
141
|
+
if(current.release!==release||current.app!==app)throw new Error('Upload receipt identity changed');
|
|
142
|
+
const history=await getHistory(o,app,'deployment',0,signal,100);
|
|
143
|
+
const submitted=saveReceipt({...current,uploadPhase:'completion_submitted',completionSubmittedAt:Date.now(),completionBaseline:history.items.map(x=>x.id)});
|
|
144
|
+
const result=await write(o,submitted,`${ROOT}/release/complete`,{release,group},v=>{const d=record(v);if(positiveId(d.release)!==release||positiveId(d.webapp)!==app||!positiveId(d.deployment))throw new Error('Completion identity mismatch');return {deployment:positiveId(d.deployment),release,app};},signal);
|
|
145
|
+
saveReceipt({...submitted,uploadPhase:'observing',deployment:result.deployment});return result;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
export async function reconcileUploadCompletion(o:DeploymentOwner,r:ActionReceipt,signal?:AbortSignal){
|
|
149
|
+
const current=receiptsFor(o).find(x=>x.key===r.key)??r;
|
|
150
|
+
if(current.kind!=='upload'||!current.release||!['completion_submitted','observing'].includes(current.uploadPhase??''))throw new Error('No submitted completion receipt is available');
|
|
151
|
+
const [summary,history]=await Promise.all([getSummary(o,current.app,signal),getHistory(o,current.app,'deployment',0,signal,100)]);
|
|
152
|
+
const candidates=history.items.filter(x=>{const a=x as import('./types').Attempt;return !!a.release&&a.release.id===current.release&&a.release.version===current.target&&!(current.completionBaseline??[]).includes(a.id);});
|
|
153
|
+
const id=current.deployment??(candidates.length===1?candidates[0]!.id:0);
|
|
154
|
+
const status=id?await getDeployment(o,id,current.app,current.release,signal):null;
|
|
155
|
+
const signature=JSON.stringify([summary.current_release?.id??null,history.items.map(x=>[x.id,x.status,(x as import('./types').Attempt).release?.id])]);
|
|
156
|
+
if(status)saveReceipt({...current,uploadPhase:'observing',deployment:status.deployment,settled:status.terminal});
|
|
157
|
+
return {receipt:receiptsFor(o).find(x=>x.key===current.key)!,status,signature};
|
|
158
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react';
|
|
2
|
+
import { ownerKey, type DeploymentOwner } from './permissions';
|
|
3
|
+
const refused = new Set<string>();
|
|
4
|
+
const listeners = new Set<() => void>();
|
|
5
|
+
const key = (owner: DeploymentOwner, app: number) => `${ownerKey(owner)}:${app}`;
|
|
6
|
+
export function refuseAppActions(owner: DeploymentOwner, app: number): void { refused.add(key(owner, app)); for (const fn of listeners)
|
|
7
|
+
fn(); }
|
|
8
|
+
export function appActionsRefused(owner: DeploymentOwner, app: number): boolean { return refused.has(key(owner, app)); }
|
|
9
|
+
export function useAppActionsRefused(owner: DeploymentOwner, app: number): boolean { return useSyncExternalStore(fn => { listeners.add(fn); return () => listeners.delete(fn); }, () => appActionsRefused(owner, app), () => true); }
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ApiAction, PlatformAttempt, Release } from './types';
|
|
2
|
+
export function apiActions(status: string): ApiAction[] { switch (status) {
|
|
3
|
+
case 'failed': return ['retry', 'verify'];
|
|
4
|
+
case 'verified':
|
|
5
|
+
case 'partial':
|
|
6
|
+
case 'unknown': return ['verify', 'converge'];
|
|
7
|
+
case 'converged': return ['verify'];
|
|
8
|
+
default: return [];
|
|
9
|
+
} }
|
|
10
|
+
export function rollbackEligible(release: Release, current: Release | null): boolean { return release.id > 0 && release.id !== current?.id && ['uploaded', 'live', 'superseded'].includes(release.status); }
|
|
11
|
+
export function platformProof(attempt: PlatformAttempt): string { const n = attempt.node_summary; if (attempt.status === 'converged' && n.expected > 0 && n.proven === n.expected)
|
|
12
|
+
return `${n.proven}/${n.expected} restart proofs at observation`; return `${n.proven}/${n.expected} restart proofs; ${n.reported} reported, ${n.failed} failed (${attempt.status})`; }
|
|
13
|
+
export function deploymentTone(status: string): string { return ['live', 'converged'].includes(status) ? 'ok' : ['failed', 'rolled_back'].includes(status) ? 'bad' : ['requested', 'queued', 'deploying', 'canary', 'fleet', 'partial', 'verified'].includes(status) ? 'warn' : 'dim'; }
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { DeploymentsPage } from './DeploymentsPage';
|
|
2
|
+
export { WebAppDetail } from './WebAppDetail';
|
|
3
|
+
export { DEPLOYMENTS_PERMISSIONS } from './permissions';
|
|
4
|
+
export type { WebAppSummary, PlatformAttempt, OnboardingOperation, DeploymentStatus, DeploymentsDestinations } from './types';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { OnboardingOperation } from './types';
|
|
2
|
+
export const onboardingTerminal = (o: OnboardingOperation) => ['succeeded', 'failed', 'cancelled'].includes(o.status);
|
|
3
|
+
export function onboardingPollable(o: OnboardingOperation): boolean { return !onboardingTerminal(o) && ['app', 'address', 'github', 'verify'].includes(o.cursor) && (o.status === 'active' || (o.status === 'waiting' && !!o.next_attempt_at)); }
|
|
4
|
+
export function onboardingGeneration(o: OnboardingOperation): string { return `${o.operation_id}:${o.revision}:${o.cursor}`; }
|
|
5
|
+
export function managedChoice(o: OnboardingOperation, domain: number, label: string): Record<string, unknown> | null { switch (o.cursor) {
|
|
6
|
+
case 'app': return {};
|
|
7
|
+
case 'address': return domain > 0 && /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label) ? { domain, label } : null;
|
|
8
|
+
case 'github': return { skip: true };
|
|
9
|
+
case 'verify': return {};
|
|
10
|
+
default: return null;
|
|
11
|
+
} }
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { backendGlobalCan, type BackendGlobalUser } from '../fleet/access';
|
|
2
|
+
import { decodeJwt } from '../../client/jwt';
|
|
3
|
+
import { getAccessToken, getAuthSessionRevision, getAuthSnapshot } from '../../client/auth';
|
|
4
|
+
import type { Bootstrap, WebAppObject } from './types';
|
|
5
|
+
export const DEPLOYMENTS_PERMISSIONS = ['sys.view_platform', 'sys.manage_platform', 'sys.admin', 'sys.view_dns', 'sys.manage_dns', 'sys.security'];
|
|
6
|
+
const active = (u: BackendGlobalUser | null | undefined) => !!u && (!('is_active' in u) || u.is_active !== false);
|
|
7
|
+
export const canReadApi = (u: BackendGlobalUser | null | undefined) => active(u) && backendGlobalCan(u, ['view_platform', 'manage_platform', 'admin']);
|
|
8
|
+
export const canRecoverApi = (u: BackendGlobalUser | null | undefined) => active(u) && backendGlobalCan(u, ['manage_platform', 'admin']);
|
|
9
|
+
export const canReadWebApps = (u: BackendGlobalUser | null | undefined) => active(u) && backendGlobalCan(u, ['view_dns', 'manage_dns', 'security']);
|
|
10
|
+
export const canReadBootstrap = (u: BackendGlobalUser | null | undefined) => active(u) && backendGlobalCan(u, ['view_admin', 'manage_users', 'manage_settings', 'admin']);
|
|
11
|
+
export function isInteractive(): boolean { const s = getAuthSnapshot(), c = decodeJwt(getAccessToken()); return s.authenticated && !!c && c.credential_kind !== 'api_key' && c.credential_kind !== 'group_scoped_token'; }
|
|
12
|
+
export interface DeploymentOwner {
|
|
13
|
+
actor: string;
|
|
14
|
+
origin: string;
|
|
15
|
+
session: number;
|
|
16
|
+
signal?: AbortSignal;
|
|
17
|
+
}
|
|
18
|
+
export function deploymentOwner(): DeploymentOwner { return {
|
|
19
|
+
actor: getAuthSnapshot().uid ?? '', origin: typeof window === 'undefined' ? '' : window.location.origin, session: getAuthSessionRevision()
|
|
20
|
+
}; }
|
|
21
|
+
export function ownerKey(o: DeploymentOwner): string { return `${o.origin}:${o.actor}:${o.session}`; }
|
|
22
|
+
export function assertOwner(o: DeploymentOwner, signal?: AbortSignal): void { signal?.throwIfAborted(); o.signal?.throwIfAborted(); const now = deploymentOwner(); if (!getAuthSnapshot().authenticated || !o.actor || ownerKey(o) !== ownerKey(now))
|
|
23
|
+
throw new DOMException('Session changed; refresh and review again', 'AbortError'); }
|
|
24
|
+
/** SAVE_PERMS is the first configured RestMeta clause for writes; VIEW is read-only. */
|
|
25
|
+
export function webAppAuthority(user: BackendGlobalUser | null | undefined, app: WebAppObject, member?: {
|
|
26
|
+
permissions?: Record<string, unknown>;
|
|
27
|
+
} | null) {
|
|
28
|
+
const local = member ? { permissions: member.permissions } : null;
|
|
29
|
+
const can = (grants: readonly string[]) => backendGlobalCan(user, grants) || backendGlobalCan(local, grants);
|
|
30
|
+
const object = active(user) && app.id > 0 && app.group.id > 0;
|
|
31
|
+
const view = object && can(['view_dns', 'manage_dns', 'security']);
|
|
32
|
+
const save = object && can(['manage_dns', 'security']);
|
|
33
|
+
return {
|
|
34
|
+
read: view, custom: save && can(['manage_webapp']), release: save, workflow: active(user) && app.group.id > 0 && (can(['security']) || (can(['manage_webapp']) && can(['manage_dns'])))
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function canOnboard(bootstrap: Bootstrap | undefined): boolean { return !!bootstrap && (bootstrap.groups.length > 0 || bootstrap.can_create_webapp_group); }
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { CreateProfile, ManifestEntry } from './types';
|
|
2
|
+
import { record, text, uuid, positiveId, list } from './sanitize';
|
|
3
|
+
import type { DeploymentOwner } from './permissions';
|
|
4
|
+
export interface ActionReceipt {
|
|
5
|
+
version: 1;
|
|
6
|
+
actor: string;
|
|
7
|
+
origin: string;
|
|
8
|
+
key: string;
|
|
9
|
+
operation: string;
|
|
10
|
+
kind: 'create' | 'api' | 'key' | 'rollback' | 'upload' | 'profile';
|
|
11
|
+
app: number;
|
|
12
|
+
target: string;
|
|
13
|
+
settled: boolean;
|
|
14
|
+
submitted?: boolean;
|
|
15
|
+
submittedAt?: number;
|
|
16
|
+
acknowledged?: boolean;
|
|
17
|
+
address?: {
|
|
18
|
+
domain: number;
|
|
19
|
+
label: string;
|
|
20
|
+
};
|
|
21
|
+
onboardingGroup?: number;
|
|
22
|
+
profile?: CreateProfile;
|
|
23
|
+
manifest?: ManifestEntry[];
|
|
24
|
+
release?: number;
|
|
25
|
+
deployment?: number;
|
|
26
|
+
uploadPhase?: 'registered' | 'completion_submitted' | 'observing';
|
|
27
|
+
completionSubmittedAt?: number;
|
|
28
|
+
completionBaseline?: number[];
|
|
29
|
+
}
|
|
30
|
+
const STORAGE = 'mojo.deployment.receipts.v1';
|
|
31
|
+
const memory = new Map<string, ActionReceipt>();
|
|
32
|
+
const active = new Set<string>();
|
|
33
|
+
function project(v: unknown): ActionReceipt | null { const r = record(v); if (r.version !== 1 || !['create', 'api', 'key', 'rollback', 'upload', 'profile'].includes(String(r.kind)) || !uuid(r.operation) || !text(r.actor) || !text(r.origin) || !text(r.key))
|
|
34
|
+
return null; const receipt: ActionReceipt = {
|
|
35
|
+
version: 1, actor: text(r.actor), origin: text(r.origin), key: text(r.key), operation: uuid(r.operation), kind: r.kind as ActionReceipt['kind'], app: positiveId(r.app), target: text(r.target), settled: r.settled === true, submitted: r.submitted === true, submittedAt: typeof r.submittedAt === 'number' ? r.submittedAt : undefined, acknowledged: r.acknowledged === true, release: positiveId(r.release) || undefined, deployment: positiveId(r.deployment) || undefined
|
|
36
|
+
}; if(positiveId(r.onboardingGroup))receipt.onboardingGroup=positiveId(r.onboardingGroup);if(r.kind==='upload'){
|
|
37
|
+
if(['registered','completion_submitted','observing'].includes(String(r.uploadPhase)))receipt.uploadPhase=r.uploadPhase as ActionReceipt['uploadPhase'];
|
|
38
|
+
if(typeof r.completionSubmittedAt==='number'&&Number.isFinite(r.completionSubmittedAt))receipt.completionSubmittedAt=r.completionSubmittedAt;
|
|
39
|
+
if(Array.isArray(r.completionBaseline))receipt.completionBaseline=list(r.completionBaseline,100).map(positiveId).filter(Boolean);
|
|
40
|
+
} if (r.address) {
|
|
41
|
+
const a = record(r.address);
|
|
42
|
+
receipt.address = { domain: positiveId(a.domain), label: text(a.label, 63) };
|
|
43
|
+
} if (r.kind === 'create' && r.profile) {
|
|
44
|
+
const p = record(r.profile);
|
|
45
|
+
receipt.profile = {
|
|
46
|
+
operation_id: receipt.operation, ...(positiveId(p.group) ? { group: positiveId(p.group) } : { group_intent: 'new' as const }), slug: text(p.slug, 64), display_name: text(p.display_name, 120), environment: text(p.environment, 16), bucket: text(p.bucket), github_repository: text(p.github_repository), deployment_ref: text(p.deployment_ref), build_output: text(p.build_output)
|
|
47
|
+
};
|
|
48
|
+
} if (r.kind === 'upload' && Array.isArray(r.manifest))
|
|
49
|
+
receipt.manifest = list(r.manifest, 5000).map(v => { const p = record(v); return {
|
|
50
|
+
path: text(p.path, 1024), sha256: text(p.sha256, 64), size: typeof p.size === 'number' ? p.size : 0
|
|
51
|
+
}; }); return receipt; }
|
|
52
|
+
function restore() { try {
|
|
53
|
+
for (const raw of JSON.parse(sessionStorage.getItem(STORAGE) ?? '[]')) {
|
|
54
|
+
const r = project(raw);
|
|
55
|
+
if (r && !memory.has(r.key))
|
|
56
|
+
memory.set(r.key, r);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch { /* session-only fallback */ } }
|
|
60
|
+
function persist() { try {
|
|
61
|
+
sessionStorage.setItem(STORAGE, JSON.stringify([...memory.values()].slice(-32)));
|
|
62
|
+
}
|
|
63
|
+
catch { /* receipt remains for this tab */ } }
|
|
64
|
+
export function receiptsFor(owner: DeploymentOwner): ActionReceipt[] { restore(); return [...memory.values()].filter(r => r.actor === owner.actor && r.origin === owner.origin).map(r => ({ ...r })); }
|
|
65
|
+
export function saveReceipt(receipt: ActionReceipt) { const safe = project(receipt); if (!safe)
|
|
66
|
+
throw new Error('Invalid deployment receipt'); memory.set(safe.key, safe); persist(); return safe; }
|
|
67
|
+
export function makeReceipt(owner: DeploymentOwner, kind: ActionReceipt['kind'], scope: string, app = 0, target = ''): ActionReceipt { const key = `${owner.origin}:${owner.actor}:${kind}:${scope}`; const previous = receiptsFor(owner).find(r => r.key === key); return previous && (!previous.settled || (previous.kind==='upload'&&['completion_submitted','observing'].includes(previous.uploadPhase??''))) ? previous : saveReceipt({
|
|
68
|
+
version: 1, actor: owner.actor, origin: owner.origin, key, operation: crypto.randomUUID(), kind, app, target, settled: false
|
|
69
|
+
}); }
|
|
70
|
+
export function appUnsettled(owner: DeploymentOwner, app: number, except?: string): ActionReceipt | undefined { return receiptsFor(owner).find(r => r.app === app && app > 0 && !r.settled && r.key !== except); }
|
|
71
|
+
export function settleReceipt(r: ActionReceipt) { saveReceipt({ ...r, settled: true }); }
|
|
72
|
+
export async function singleFlight<T>(key: string, fn: () => Promise<T>): Promise<T> { if (active.has(key))
|
|
73
|
+
throw new Error('An operation for this target is already pending'); active.add(key); try {
|
|
74
|
+
return await fn();
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
active.delete(key);
|
|
78
|
+
} }
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { detectTemporal } from '../../ui/date/fns';
|
|
2
|
+
import type { AddressPrecheck, Attempt, Bootstrap, Certificate, Collector, DeploymentStatus, OnboardingOperation, OnboardingOptions, PlatformAttempt, PlatformSnapshot, Proof, Release, WebApp, WebAppList, WebAppObject, WebAppSummary } from './types';
|
|
3
|
+
export const record = (v: unknown): Record<string, unknown> => v && typeof v === 'object' && !Array.isArray(v) ? v as Record<string, unknown> : {};
|
|
4
|
+
export const text = (v: unknown, max = 256): string => typeof v === 'string' ? v.slice(0, max).replace(/[\u0000-\u0008\u000b-\u001f]/g, '') : typeof v === 'number' && Number.isFinite(v) ? String(v) : '';
|
|
5
|
+
export function dateText(v: unknown): string { const temporal = detectTemporal(v); if (!temporal)
|
|
6
|
+
return text(v); const d = new Date(temporal.ms); return Number.isNaN(d.getTime()) ? text(v) : d.toISOString(); }
|
|
7
|
+
export const positiveId = (v: unknown): number => typeof v === 'number' && Number.isSafeInteger(v) && v > 0 ? v : 0;
|
|
8
|
+
export const count = (v: unknown): number => typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : 0;
|
|
9
|
+
export const list = (v: unknown, max = 128): unknown[] => Array.isArray(v) ? v.slice(0, max) : [];
|
|
10
|
+
export function uuid(v: unknown): string { const s = text(v, 36); return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(s) ? s : ''; }
|
|
11
|
+
function certificate(v: unknown): Certificate | null { if (!v)
|
|
12
|
+
return null; const r = record(v); return { status: text(r.status) || 'unknown', not_after: text(r.not_after) }; }
|
|
13
|
+
export function projectRelease(v: unknown): Release { const r = record(v); return {
|
|
14
|
+
id: positiveId(r.id), version: text(r.version), status: text(r.status) || 'unknown', source: text(r.source) || 'unknown', created: text(r.created)
|
|
15
|
+
}; }
|
|
16
|
+
export function projectWebApp(v: unknown): WebApp { const r = record(v); return {
|
|
17
|
+
id: positiveId(r.id), slug: text(r.slug, 64), display_name: text(r.display_name, 120), environment: text(r.environment), deployment_ref: text(r.deployment_ref), repository: text(r.repository ?? r.github_repository), build_output: text(r.build_output)
|
|
18
|
+
}; }
|
|
19
|
+
export function projectWebAppObject(v: unknown): WebAppObject { const r = record(v), g = record(r.group); return { ...projectWebApp(r), group: { id: positiveId(g.id), name: text(g.name) } }; }
|
|
20
|
+
export function projectAttempt(v: unknown): Attempt { const r = record(v); return {
|
|
21
|
+
id: positiveId(r.id), status: text(r.status) || 'unknown', created: dateText(r.created), started: dateText(r.started), finished: dateText(r.finished), detail: text(r.detail, 1500), release: r.release ? projectRelease(r.release) : null
|
|
22
|
+
}; }
|
|
23
|
+
export function projectSummary(v: unknown): WebAppSummary { const r = record(v), a = record(r.address), o = record(r.onboarding), k = record(r.deployment_key); return {
|
|
24
|
+
webapp: projectWebApp(r.webapp), address: r.address ? {
|
|
25
|
+
hostname: text(a.hostname), certificate: certificate(a.certificate), aliases: list(a.aliases, 32).map(x => { const v = record(x); return { hostname: text(v.hostname), certificate: certificate(v.certificate) }; })
|
|
26
|
+
} : null, current_release: r.current_release ? projectRelease(r.current_release) : null, latest_deployment: r.latest_deployment ? projectAttempt(r.latest_deployment) : null, onboarding: { status: text(o.status) || 'unknown', cursor: text(o.cursor) }, deployment_key: { linked: k.linked === true, active: k.active === true }
|
|
27
|
+
}; }
|
|
28
|
+
export function projectSummaries(v: unknown): WebAppList { const r = record(v); if (r.schema_version !== 1)
|
|
29
|
+
throw new Error('Unsupported WebApp summary schema'); return {
|
|
30
|
+
items: list(r.items, 50).map(projectSummary).filter(x => x.webapp.id > 0), count: count(r.count), limit: count(r.limit), truncated: r.truncated === true
|
|
31
|
+
}; }
|
|
32
|
+
function proof(v: unknown): Proof { const r = record(v), p = record(r.proof), d = record(r.detail); return {
|
|
33
|
+
runner: text(r.runner ?? r.runner_id), status: text(r.status ?? r.state ?? r.kind) || 'unknown', detail: text(typeof r.detail === 'string' ? r.detail : d.message ?? d.reason ?? d.phase ?? r.reason ?? r.message, 1500), sha: text(p.platform_sha), deployment: uuid(p.platform_deployment), stderr_tail: text(d.stderr_tail ?? r.stderr_tail, 2000)
|
|
34
|
+
}; }
|
|
35
|
+
export function projectPlatformAttempt(v: unknown): PlatformAttempt { const r = record(v), n = record(r.node_summary), d = record(r.detail); return {
|
|
36
|
+
id: uuid(r.id), sha: text(r.sha, 40), framework_version: text(r.framework_version), status: text(r.status) || 'unknown', source: text(r.source), actor: text(r.actor), retry_of: uuid(r.retry_of), created: dateText(r.created), started: dateText(r.started), finished: dateText(r.finished), duration_seconds: typeof r.duration_seconds === 'number' ? r.duration_seconds : null, desired_commit: text(r.desired_commit, 40), current_commits: list(r.current_commits).map(x => text(x, 40)), frozen_roster: list(r.frozen_roster).map(x => text(x)), transitions: list(r.transitions).map(proof), node_evidence: list(r.node_evidence).map(proof), diagnosis: list(r.diagnosis).map(proof), detail: text(typeof r.detail === 'string' ? r.detail : d.message ?? d.reason, 1500), node_summary: {
|
|
37
|
+
expected: count(n.expected), proven: count(n.proven), reported: count(n.reported), failed: count(n.failed), dispatched: count(n.dispatched), other: count(n.other)
|
|
38
|
+
}
|
|
39
|
+
}; }
|
|
40
|
+
function collector<T>(v: unknown, project: (v: unknown) => T): Collector<T> { const r = record(v); return {
|
|
41
|
+
status: text(r.status) || 'unknown', observed_at: text(r.observed_at ?? r.checked_at), stale_after: dateText(r.stale_after), reason: text(r.reason_detail ?? r.reason, 500), data: r.data && ['healthy', 'degraded', 'unhealthy', 'unknown', 'ok', 'unconfigured', 'stale'].includes(String(r.status)) ? project(r.data) : null
|
|
42
|
+
}; }
|
|
43
|
+
export function projectPlatform(v: unknown): PlatformSnapshot { const r = record(v), s = record(r.sections); if (r.schema_version !== 1)
|
|
44
|
+
throw new Error('Unsupported platform schema'); return { deployments: collector(s.deployments, v => { const d = record(v), c = record(d.currently_serving), o = record(d.coordination), f = record(d.framework_pin); return {
|
|
45
|
+
items: list(d.items, 50).map(projectPlatformAttempt).filter(x => x.id), limit: count(d.limit), desired_commit: text(d.desired_commit), desired_deployment: uuid(d.desired_deployment), currently_serving: d.currently_serving ? {
|
|
46
|
+
deployment: uuid(c.deployment), sha: text(c.sha, 40), framework_version: text(c.framework_version), converged_at: dateText(c.converged_at)
|
|
47
|
+
} : null, coordination: {
|
|
48
|
+
state: text(o.state), deployment: uuid(o.deployment), sha: text(o.sha, 40), at: text(o.at)
|
|
49
|
+
}, framework_pin: { mode: text(f.mode), resolved: text(f.resolved) }
|
|
50
|
+
}; }), api: collector(s.api, v => { const a = record(v); return { version: text(a.version), commit: text(a.commit) }; }) }; }
|
|
51
|
+
export function projectBootstrap(v: unknown): Bootstrap { const r = record(v); return {
|
|
52
|
+
groups: list(r.webapp_groups, 128).map(v => { const g = record(v); return {
|
|
53
|
+
id: positiveId(g.id), name: text(g.name), can_manage_dns: g.can_manage_dns === true
|
|
54
|
+
}; }).filter(x => x.id > 0), can_create_webapp_group: r.can_create_webapp_group === true, setup: record(r.capabilities).setup === true
|
|
55
|
+
}; }
|
|
56
|
+
export function projectDeployment(v: unknown): DeploymentStatus { const r = record(v), status = text(r.status) || 'unknown'; return {
|
|
57
|
+
deployment: positiveId(r.deployment), webapp: positiveId(r.webapp), release: positiveId(r.release), version: text(r.version), status, terminal: ['live', 'failed', 'rolled_back', 'superseded'].includes(status) && r.terminal === true, success: status === 'live' && r.success === true, detail: text(r.detail, 1500), created: dateText(r.created), started: dateText(r.started), finished: dateText(r.finished), targets: list(r.targets).map(proof), rollback_targets: list(r.rollback_targets).map(proof)
|
|
58
|
+
}; }
|
|
59
|
+
export function projectOperation(v: unknown): OnboardingOperation { const r = record(v), g = record(r.group), p = record(r.profile), e = record(r.evidence), a = record(e.app), d = record(e.address), h = record(e.github), f = record(e.verify), resources = record(r.resources); if (r.schema_version !== 1 || !uuid(r.operation_id))
|
|
60
|
+
throw new Error('Invalid onboarding operation'); return {
|
|
61
|
+
operation_id: uuid(r.operation_id), group: { id: positiveId(g.id), name: text(g.name) }, status: text(r.status) || 'unknown', cursor: text(r.cursor) || 'unknown', revision: count(r.revision), profile: { slug: text(p.slug, 64), display_name: text(p.display_name, 120) }, evidence: {
|
|
62
|
+
app: { status: text(a.status) }, address: { status: text(d.status), hostname: text(d.hostname) }, github: { status: text(h.status), reason: text(h.reason) }, verify: { status: text(f.status), detail: text(f.detail) }
|
|
63
|
+
}, activity: list(r.activity, 64).map(v => { const x = record(v); return {
|
|
64
|
+
message: text(x.message, 500), kind: text(x.kind), at: dateText(x.at)
|
|
65
|
+
}; }), resources: { webapp: positiveId(resources.webapp) || null }, next_attempt_at: dateText(r.next_attempt_at), last_error: text(r.last_error, 1500), modified: dateText(r.modified)
|
|
66
|
+
}; }
|
|
67
|
+
export function projectOptions(v: unknown): OnboardingOptions { const r = record(v), d = record(r.apps_domain); if (r.schema_version !== 1)
|
|
68
|
+
throw new Error('Unsupported onboarding options'); return {
|
|
69
|
+
buckets: list(r.buckets, 64).map(x => text(x)).filter(Boolean), environments: list(r.environments, 8).map(x => text(x)), destination: !!r.destination, destination_error: text(r.destination_error, 500), apps_domain: r.apps_domain ? { id: positiveId(d.id), name: text(d.name) } : null, apps_domain_error: text(r.apps_domain_error, 500), github_connected: r.github_connected === true
|
|
70
|
+
}; }
|
|
71
|
+
export function projectPrecheck(v: unknown): AddressPrecheck { const r = record(v), d = record(r.domain); return {
|
|
72
|
+
verdict: text(r.verdict) || 'unknown', hostname: text(r.hostname ?? record(r.normalized).hostname), reason: text(r.reason, 500), domain: r.domain ? { id: positiveId(d.id), name: text(d.name) } : null, label: text(r.label ?? record(r.normalized).label)
|
|
73
|
+
}; }
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
2
|
+
import { deploymentTone } from './deployment-state';
|
|
3
|
+
import type { DeploymentOwner } from './permissions';
|
|
4
|
+
import { assertOwner } from './permissions';
|
|
5
|
+
export { FleetHelp as DeploymentHelp } from '../fleet/FleetHelp';
|
|
6
|
+
export function Status({ value }: {
|
|
7
|
+
value: string;
|
|
8
|
+
}) { return <span className={`deployment-status text-${deploymentTone(value)}`}>{value || 'unknown'}</span>; }
|
|
9
|
+
export function Facts({ rows }: {
|
|
10
|
+
rows: [
|
|
11
|
+
string,
|
|
12
|
+
ReactNode
|
|
13
|
+
][];
|
|
14
|
+
}) { return <dl className="deployment-facts">{rows.map(([label, value]) => <div key={label}><dt>{label}</dt><dd>{value || 'Unknown'}</dd></div>)}</dl>; }
|
|
15
|
+
export function ErrorNotice({ children }: {
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
}) { return <p role="alert" className="text-bad">{children}</p>; }
|
|
18
|
+
/** A ref locks the initiating event before React's next render. */
|
|
19
|
+
export function useWrite(owner: DeploymentOwner, signal: AbortSignal, onBusy?: (busy: boolean) => void) { const lock = useRef(false), alive = useRef(true); const [busy, setBusy] = useState(false), [error, setError] = useState(''); useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); return {
|
|
20
|
+
busy, error, run: async (fn: () => Promise<void>) => { if (lock.current)
|
|
21
|
+
return; lock.current = true; setBusy(true); setError(''); onBusy?.(true); try {
|
|
22
|
+
assertOwner(owner, signal);
|
|
23
|
+
await fn();
|
|
24
|
+
assertOwner(owner, signal);
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
if (alive.current && !signal.aborted)
|
|
28
|
+
setError(e instanceof Error ? e.message : 'Operation outcome unknown. Refresh to reconcile.');
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
lock.current = false;
|
|
32
|
+
if (alive.current) {
|
|
33
|
+
setBusy(false);
|
|
34
|
+
onBusy?.(false);
|
|
35
|
+
}
|
|
36
|
+
} }
|
|
37
|
+
}; }
|
|
38
|
+
export function useCloseOnOwnerChange(key: string, onClose: () => void) { const previous = useRef(key); useEffect(() => { if (previous.current !== key)
|
|
39
|
+
onClose(); previous.current = key; }, [key, onClose]); }
|