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,58 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { useMe } from '../../client/runtime';
|
|
4
|
+
import { Tabs } from '../../ui/Tabs';
|
|
5
|
+
import { datetime } from '../../ui/format';
|
|
6
|
+
import { modal } from '../../ui/modal';
|
|
7
|
+
import { canReadApi, canRecoverApi, isInteractive, assertOwner } from './permissions';
|
|
8
|
+
import { getPlatform, recoverApi } from './api';
|
|
9
|
+
import { apiActions, platformProof } from './deployment-state';
|
|
10
|
+
import { makeReceipt, settleReceipt } from './receipts';
|
|
11
|
+
import { useDeploymentObservation, useDeploymentOwner } from './useDeploymentObservation';
|
|
12
|
+
import { DeploymentHelp, ErrorNotice, Facts, Status, useCloseOnOwnerChange, useWrite } from './shared';
|
|
13
|
+
import type { ApiAction } from './types';
|
|
14
|
+
export function ApiDeploymentDetail({ id, onClose }: {
|
|
15
|
+
id: string;
|
|
16
|
+
onClose: () => void;
|
|
17
|
+
}) {
|
|
18
|
+
const me = useMe(), s = useDeploymentOwner(), queryClient = useQueryClient();
|
|
19
|
+
const [tracking, setTracking] = useState<string | null>(null);
|
|
20
|
+
useCloseOnOwnerChange(s.key, onClose);
|
|
21
|
+
const q = useQuery({
|
|
22
|
+
queryKey: ['deployments', s.key, 'api-detail', id], queryFn: ({ signal }) => getPlatform(s.owner, signal), enabled: canReadApi(me.data) && isInteractive(), retry: false
|
|
23
|
+
});
|
|
24
|
+
const selected = q.data?.deployments.data?.items.find(x => x.id === id);
|
|
25
|
+
const identity = tracking ?? (selected && ['requested', 'queued', 'canary', 'fleet', 'deploying'].includes(selected.status) ? id : null);
|
|
26
|
+
const observed = useDeploymentObservation(s.owner, identity, async (signal) => { const p = await getPlatform(s.owner, signal); const a = p.deployments.data?.items.find(x => x.id === identity); if (!a)
|
|
27
|
+
throw new Error('Tracked deployment not observed'); return a; }, a => ['converged', 'failed', 'superseded', 'verified', 'partial', 'unknown'].includes(a.status));
|
|
28
|
+
const row = observed.value ?? selected, latest = q.data?.deployments.data?.items[0]?.id === row?.id, can = canRecoverApi(me.data) && isInteractive();
|
|
29
|
+
useEffect(() => { if (observed.state === 'terminal') void queryClient.invalidateQueries({ queryKey: ['deployments', s.key] }); }, [observed.state, observed.value?.id, observed.value?.status, queryClient, s.key]);
|
|
30
|
+
const openRecovery = async (action: ApiAction) => { let busy = false; await modal.open(close => <ApiRecoveryDialog id={row!.id} action={action} onBusy={value => { busy = value; }} onClose={() => close(null)} onResult={result => { setTracking(result); void q.refetch(); }}/>, { signal: s.signal, canDismiss: () => !busy }); };
|
|
31
|
+
if (!canReadApi(me.data) || !isInteractive())
|
|
32
|
+
return <div className="modal-pad"><p>API authority is no longer available.</p><button className="btn" onClick={onClose}>Close</button></div>;
|
|
33
|
+
return <div className="modal-pad admin-deployments deployment-dialog"><header className="deployment-dialog-header"><div><h2 className="modal-title">API deployment {row && <DeploymentHelp label="API attempt information"><Facts rows={[["Attempt", row.id], ["Source", row.source], ["Actor", row.actor], ["Retry of", row.retry_of]]}/><p>Reported success and restart proof are separate. Recovery applies to the latest attempt and does not accept an arbitrary commit.</p></DeploymentHelp>}</h2><p className="dim mono">{row?.sha.slice(0, 12) || 'Attempt details'}</p></div><div className="deployment-actions"><button className="btn" disabled={q.isFetching} onClick={() => { void q.refetch(); observed.refresh(); }}>Refresh evidence</button><button className="btn" aria-label="Close API deployment" onClick={onClose}>Close</button></div></header>
|
|
34
|
+
{q.isError && <ErrorNotice>Deployment observation unavailable. {q.data ? 'Previous evidence is shown.' : 'Refresh to try again.'}</ErrorNotice>}
|
|
35
|
+
{row && <div className="deployment-toolbar"><Status value={row.status}/>{can && latest && <div className="deployment-actions">{apiActions(row.status).map(action => <button key={action} className="btn" onClick={() => void openRecovery(action)}>{action === 'retry' ? 'Retry same commit' : action === 'verify' ? 'Verify nodes' : 'Converge fleet'}</button>)}</div>}</div>}
|
|
36
|
+
<Tabs ariaLabel="API deployment details" items={[
|
|
37
|
+
{key:'overview',label:'Overview',panel:<div className="deployment-tab-content">{row ? <><Facts rows={[["Commit", <code>{row.sha}</code>], ['Evidence', platformProof(row)], ['Started', row.started ? datetime(row.started) : row.created ? datetime(row.created) : 'Time unavailable'], ['Finished', row.finished ? datetime(row.finished) : 'Not finished']]}/>{row.detail && <p>{row.detail}</p>}</> : <p>{q.isPending ? 'Loading attempt…' : q.data?.deployments.data ? 'Attempt unavailable in the latest 50 records.' : 'The collector did not return deployment evidence.'}</p>}</div>},
|
|
38
|
+
{key:'nodes',label:'Nodes',panel:<div className="deployment-tab-content">{row?.node_evidence.length ? row.node_evidence.map((n,i)=><article className="deployment-evidence" key={i}><strong>{n.runner}</strong><Status value={n.status}/>{n.sha && <p className="mono">{n.sha}</p>}{n.detail && <p>{n.detail}</p>}</article>) : <p>No node proof observed.</p>}</div>},
|
|
39
|
+
{key:'activity',label:'Activity',panel:<div className="deployment-tab-content">{row?.diagnosis.length ? <><h3>Failure diagnosis</h3>{row.diagnosis.map((d,i)=><article className="deployment-evidence" key={i}><strong>{d.runner}</strong><Status value={d.status}/><p>{d.detail}</p>{d.stderr_tail && <pre className="deployment-code">{d.stderr_tail}</pre>}</article>)}</> : <p>No failure diagnosis recorded.</p>}{!!row?.transitions.length && <><h3>Activity</h3>{row.transitions.map((t,i)=><p key={i}>{t.runner} · {t.status}{t.detail && ` · ${t.detail}`}</p>)}</>}</div>}
|
|
40
|
+
]}/>
|
|
41
|
+
{identity && <p className="dim" role="status">{observed.state === 'observing' ? 'Observing this attempt…' : observed.state === 'terminal' ? 'Observation complete.' : 'Observation stopped; outcome may be unknown. Refresh to observe again.'}</p>}
|
|
42
|
+
</div>;
|
|
43
|
+
}
|
|
44
|
+
function ApiRecoveryDialog({ id, action, onClose, onBusy, onResult }: {
|
|
45
|
+
id: string;
|
|
46
|
+
action: ApiAction;
|
|
47
|
+
onClose: () => void;
|
|
48
|
+
onBusy: (v: boolean) => void;
|
|
49
|
+
onResult: (id: string) => void;
|
|
50
|
+
}) {
|
|
51
|
+
const me = useMe(), s = useDeploymentOwner(), w = useWrite(s.owner, s.signal, onBusy);
|
|
52
|
+
const execute = () => w.run(async () => { if (!canRecoverApi(me.data) || !isInteractive())
|
|
53
|
+
throw new Error('API recovery authority unavailable'); const fresh = await getPlatform(s.owner, s.signal), source = fresh.deployments.data?.items.find(x => x.id === id); if (!source || !apiActions(source.status).includes(action) || fresh.deployments.data?.items[0]?.id !== id)
|
|
54
|
+
throw new Error('Attempt was replaced or changed. Refresh and review again.'); assertOwner(s.owner, s.signal); const receipt = makeReceipt(s.owner, 'api', `${id}:${action}`, 0, id); const result = await recoverApi(s.owner, receipt, id, action, s.signal); if (action === 'retry' && result.sha !== source.sha)
|
|
55
|
+
throw new Error('Retry did not preserve source SHA'); settleReceipt(receipt); onResult(result.id); onClose(); });
|
|
56
|
+
const label = { retry: 'Retry same commit', verify: 'Verify nodes', converge: 'Converge fleet' }[action];
|
|
57
|
+
return <div className="modal-pad admin-deployments"><h2 className="modal-title">{label} <DeploymentHelp label="API recovery target">Attempt <code>{id}</code>. The latest attempt and authority are checked before sending. A retry preserves its commit and tracks a new attempt.</DeploymentHelp></h2><p className="modal-message">{action === 'retry' ? 'Deploy the same commit as a new attempt.' : action === 'verify' ? 'Refresh runtime proof from the nodes in this attempt.' : 'Apply the current desired serving configuration across the fleet, then verify it.'}</p>{w.error && <ErrorNotice>{w.error}</ErrorNotice>}<div className="modal-actions"><button className="btn" disabled={w.busy} onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={w.busy} onClick={() => void execute()}>{label}</button></div></div>;
|
|
58
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
2
|
+
import { useSearchParams } from 'react-router-dom';
|
|
3
|
+
import { registerNonFilterParams, useMe } from '../../client/runtime';
|
|
4
|
+
import { Tabs } from '../../ui/Tabs';
|
|
5
|
+
import { datetime } from '../../ui/format';
|
|
6
|
+
import { modal } from '../../ui/modal';
|
|
7
|
+
import { backendGlobalCan } from '../fleet/access';
|
|
8
|
+
import { useAdminRouteContext } from '../core/route-context';
|
|
9
|
+
import { canOnboard, canReadApi, canReadBootstrap, canReadWebApps, isInteractive, assertOwner } from './permissions';
|
|
10
|
+
import { getBootstrap, getPlatform, getWebApps } from './api';
|
|
11
|
+
import { useDeploymentOwner } from './useDeploymentObservation';
|
|
12
|
+
import { DeploymentHelp, ErrorNotice, Facts, Status } from './shared';
|
|
13
|
+
import type { DeploymentsDestinations } from './types';
|
|
14
|
+
import { platformProof } from './deployment-state';
|
|
15
|
+
registerNonFilterParams('deployment_lane', 'deployment_app', 'deployment_tab');
|
|
16
|
+
export function DeploymentsPage({ destinations }: {
|
|
17
|
+
destinations?: DeploymentsDestinations;
|
|
18
|
+
} = {}) {
|
|
19
|
+
const me = useMe(), scope = useDeploymentOwner(), queryClient = useQueryClient(), [params, setParams] = useSearchParams();
|
|
20
|
+
const registered = useAdminRouteContext();
|
|
21
|
+
const mount = registered?.mount === '/' ? '' : registered?.mount;
|
|
22
|
+
const candidates = destinations ?? (mount !== undefined ? { domains: `${mount}/dns/domains`, serving: `${mount}/edge/vhosts` } : {});
|
|
23
|
+
const admittedDestinations: DeploymentsDestinations = {};
|
|
24
|
+
if (backendGlobalCan(me.data, ['view_dns', 'manage_dns', 'security'])) for (const key of ['domains', 'serving'] as const) {
|
|
25
|
+
const path = candidates[key];
|
|
26
|
+
if (path && (!registered || registered.paths.includes(path))) admittedDestinations[key] = path;
|
|
27
|
+
}
|
|
28
|
+
const interactive = isInteractive(), api = interactive && canReadApi(me.data), web = interactive && canReadWebApps(me.data);
|
|
29
|
+
const lane = params.get('deployment_lane') === 'webapps' && web ? 'webapps' : api ? 'api' : 'webapps';
|
|
30
|
+
const bootstrap = useQuery({
|
|
31
|
+
queryKey: ['deployments', scope.key, 'bootstrap'], queryFn: ({ signal }) => getBootstrap(scope.owner, signal), enabled: web && lane === 'webapps' && canReadBootstrap(me.data), retry: false
|
|
32
|
+
});
|
|
33
|
+
const select = (lane: string) => { setParams({ ...Object.fromEntries([...params].filter(([name]) => name === 'demo')), deployment_lane: lane }, { replace: true }); };
|
|
34
|
+
const newApp = async () => { const { WebAppOnboardingDialog } = await import('./WebAppOnboardingDialog'); assertOwner(scope.owner, scope.signal); let busy = false; await modal.open(close => <WebAppOnboardingDialog destinations={admittedDestinations} bootstrap={bootstrap.data!} onChanged={() => { void queryClient.invalidateQueries({ queryKey: ['deployments', scope.key] }); }} onClose={() => close(null)} onBusy={value => { busy = value; }}/>, {
|
|
35
|
+
signal: scope.signal, canDismiss: () => !busy, size: 'lg'
|
|
36
|
+
}); await queryClient.invalidateQueries({ queryKey: ['deployments', scope.key] }); };
|
|
37
|
+
if (!api && !web)
|
|
38
|
+
return <div className="panel panel-pad"><h1>Deployments</h1><p>API or WebApp read authority in this global Admin workspace is required.</p></div>;
|
|
39
|
+
const items = [...(api ? [{
|
|
40
|
+
key: 'api', label: 'API', panel: <ApiLane key={scope.key}/>
|
|
41
|
+
}] : []), ...(web ? [{
|
|
42
|
+
key: 'webapps', label: 'WebApps', panel: <WebAppsLane key={scope.key} destinations={admittedDestinations}/>
|
|
43
|
+
}] : [])];
|
|
44
|
+
return <div className="admin-deployments"><header className="page-header"><div><h1>Deployments</h1><p className="dim">Review releases and deployment progress.</p></div>{lane === 'webapps' && web && canOnboard(bootstrap.data) && <button className="btn btn-primary" onClick={() => void newApp()}>New WebApp</button>}</header>{items.length === 1 ? items[0]!.panel : <Tabs ariaLabel="Deployment type" items={items} activeKey={lane} onActiveKeyChange={select}/>}</div>;
|
|
45
|
+
}
|
|
46
|
+
function ApiLane() {
|
|
47
|
+
const me = useMe(), s = useDeploymentOwner();
|
|
48
|
+
const q = useQuery({
|
|
49
|
+
queryKey: ['deployments', s.key, 'platform', 'deployments,api'], queryFn: ({ signal }) => getPlatform(s.owner, signal), enabled: canReadApi(me.data) && isInteractive(), retry: false
|
|
50
|
+
});
|
|
51
|
+
const inspect = async (id: string) => { const { ApiDeploymentDetail } = await import('./ApiDeploymentDetail'); assertOwner(s.owner, s.signal); await modal.open(close => <ApiDeploymentDetail id={id} onClose={() => close(null)}/>, { signal: s.signal, size: 'lg' }); };
|
|
52
|
+
const d = q.data?.deployments, serving = d?.data?.currently_serving;
|
|
53
|
+
const stale = d?.status === 'stale' || !!d?.stale_after && Date.parse(d.stale_after) <= Date.now();
|
|
54
|
+
return <section><div className="deployment-toolbar"><h2>API</h2><button className="btn" disabled={q.isFetching} onClick={() => void q.refetch()}>Refresh API</button></div>
|
|
55
|
+
{q.isError && <ErrorNotice>API observation unavailable. {q.data ? 'Previous evidence is shown below.' : 'Refresh to try again.'}</ErrorNotice>}
|
|
56
|
+
{q.isPending ? <p role="status">Loading API evidence…</p> : !d?.data ? <div className="panel panel-pad"><h3>Deployment observation unavailable</h3><p>{d?.reason || 'The collector did not return deployment evidence. Refresh to try again.'}</p><Status value={d?.status ?? 'unknown'}/></div> : <>
|
|
57
|
+
{stale && <p className="text-warn" role="status">This deployment observation is stale. Refresh before acting.</p>}
|
|
58
|
+
<div className="deployment-current"><div className="deployment-toolbar"><div className="eyebrow">Last convergence</div><DeploymentHelp label="API convergence"><Facts rows={[["Collector", <Status value={d.status}/>], ["Observed", d.observed_at ? datetime(d.observed_at) : 'Time unavailable'], ['Framework', serving?.framework_version], ['Framework mode', d.data.framework_pin.mode], ['Coordination', d.data.coordination.state]]}/><p>Last convergence is dated evidence, not fresh proof for every current node. Framework changes belong to Maintenance.</p></DeploymentHelp></div><strong className="mono" title={serving?.sha}>{serving?.sha.slice(0, 12) ?? 'No convergence recorded'}</strong><p className="dim">{serving?.converged_at ? datetime(serving.converged_at) : 'Convergence time unknown'}</p></div>
|
|
59
|
+
<h3>Latest attempts</h3>{d.data.items.map((row, i) => <button className="deployment-record" key={row.id} onClick={() => void inspect(row.id)}><span><strong>{i === 0 ? 'Latest · ' : ''}{row.sha.slice(0, 12)}</strong><small>{platformProof(row)}</small></span><Status value={row.status}/></button>)}{!d.data.items.length && <p>No deployment attempts recorded.</p>}
|
|
60
|
+
{d.data.items.length >= d.data.limit && d.data.limit > 0 && <p className="dim">Showing the latest {d.data.limit} attempts.</p>}
|
|
61
|
+
</>}
|
|
62
|
+
</section>;
|
|
63
|
+
}
|
|
64
|
+
function WebAppsLane({ destinations }: {
|
|
65
|
+
destinations?: DeploymentsDestinations;
|
|
66
|
+
}) {
|
|
67
|
+
const me = useMe(), s = useDeploymentOwner();
|
|
68
|
+
const q = useQuery({
|
|
69
|
+
queryKey: ['deployments', s.key, 'summaries'], queryFn: ({ signal }) => getWebApps(s.owner, signal), enabled: canReadWebApps(me.data) && isInteractive(), retry: false
|
|
70
|
+
});
|
|
71
|
+
const inspect = async (id: number) => { const { WebAppDetail } = await import('./WebAppDetail'); assertOwner(s.owner, s.signal); await modal.open(close => <WebAppDetail id={id} destinations={destinations} onClose={() => close(null)}/>, { signal: s.signal, size: 'lg' }); };
|
|
72
|
+
return <section><div className="deployment-toolbar"><h2>WebApps</h2><button className="btn" disabled={q.isFetching} onClick={() => void q.refetch()}>Refresh WebApps</button></div>{q.isError ? <ErrorNotice>WebApp summary unavailable. Refresh to try again.</ErrorNotice> : q.isPending ? <p>Loading WebApps…</p> : <>{q.data?.truncated && <p className="text-warn">More apps exist; this list is truncated.</p>}{q.data?.items.map(row => <button className="deployment-record" key={row.webapp.id} onClick={() => void inspect(row.webapp.id)}><span><strong>{row.webapp.display_name || row.webapp.slug}</strong><small>{row.address?.hostname || 'No public address'}</small><small>Desired version: {row.current_release?.version ?? 'No release'}</small></span><span className="deployment-attempt-summary"><small>Latest attempt{row.latest_deployment?.release?.version && row.latest_deployment.release.version !== row.current_release?.version ? ` · ${row.latest_deployment.release.version}` : ''}</small><Status value={row.latest_deployment?.status ?? 'not started'}/></span></button>)}{!q.data?.items.length && <p>No WebApps yet.</p>}</>}</section>;
|
|
73
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { modal } from '../../ui/modal';
|
|
3
|
+
import { assertOwner } from './permissions';
|
|
4
|
+
import { Tabs } from '../../ui/Tabs';
|
|
5
|
+
import { downloadBlob, useMe } from '../../client/runtime';
|
|
6
|
+
import { getWorkflow, getKeyStatus, resolveAppAuthority, getWebApp } from './api';
|
|
7
|
+
import { isInteractive } from './permissions';
|
|
8
|
+
import { useDeploymentOwner } from './useDeploymentObservation';
|
|
9
|
+
import { ErrorNotice, useWrite } from './shared';
|
|
10
|
+
import type { WebAppObject } from './types';
|
|
11
|
+
export function WebAppDeploySetup({ app, workflowAllowed, releaseAllowed, keyAllowed, deploymentKey, onKey, onChanged }: {
|
|
12
|
+
app: WebAppObject;
|
|
13
|
+
workflowAllowed: boolean;
|
|
14
|
+
releaseAllowed: boolean;
|
|
15
|
+
keyAllowed: boolean;
|
|
16
|
+
deploymentKey?: { linked: boolean; active: boolean };
|
|
17
|
+
onKey: (action: 'mint' | 'rotate' | 'revoke') => void;
|
|
18
|
+
onChanged?: () => void;
|
|
19
|
+
}) {
|
|
20
|
+
const s = useDeploymentOwner(), me = useMe(), w = useWrite(s.owner, s.signal);
|
|
21
|
+
const [key, setKey] = useState(deploymentKey), [keyError, setKeyError] = useState('');
|
|
22
|
+
useEffect(() => { setKey(deploymentKey); setKeyError(''); }, [deploymentKey?.linked, deploymentKey?.active, s.key]);
|
|
23
|
+
const refreshKey = () => w.run(async () => { const next = await getKeyStatus(s.owner, app.id, app.group.id, s.signal); setKey(next); setKeyError(''); });
|
|
24
|
+
useEffect(() => { if (!keyAllowed || deploymentKey) return; let disposed = false; void getKeyStatus(s.owner, app.id, app.group.id, s.signal).then(next => { if (!disposed) setKey(next); }).catch(() => { if (!disposed && !s.signal.aborted) setKeyError('Key status unavailable. Refresh before choosing a key action.'); }); return () => { disposed = true; }; }, [s.key, s.signal, keyAllowed, app.id, app.group.id, !!deploymentKey]);
|
|
25
|
+
const generate = () => w.run(async () => {
|
|
26
|
+
const object = await getWebApp(s.owner, app.id, s.signal), authority = await resolveAppAuthority(s.owner, me.data, object, 'workflow', s.signal);
|
|
27
|
+
if (!isInteractive() || !authority.workflow) throw new Error('Workflow authority unavailable');
|
|
28
|
+
const workflow = await getWorkflow(s.owner, app.id, object.group.id, s.signal);
|
|
29
|
+
assertOwner(s.owner, s.signal);
|
|
30
|
+
void modal.open(close => <WorkflowDialog workflow={workflow} onClose={() => close(null)}/>, { signal: s.signal, size: 'lg' });
|
|
31
|
+
});
|
|
32
|
+
const loadUpload = async () => { const { WebAppUploadPanel } = await import('./WebAppUploadPanel'); assertOwner(s.owner, s.signal); let busy = false; await modal.open(close => <UploadDialog app={app} Upload={WebAppUploadPanel} onChanged={onChanged} onClose={() => close(null)} onBusy={value => { busy = value; }}/>, { signal: s.signal, canDismiss: () => !busy, size: 'lg' }); };
|
|
33
|
+
const showIntegration = () => { void modal.open(close => <div className="modal-pad admin-deployments"><h2 className="modal-title">Other CI integration</h2><ol className="deployment-instructions"><li>Store this app’s deployment key in your CI secret store.</li><li>Register <code>POST /api/edge/release</code> with webapp <code>{app.id}</code>, a unique version and a SHA-256/size manifest.</li><li>PUT each file to its returned signed URL using the exact checksum header. Send no API bearer credentials or cookies to storage.</li><li>Call <code>POST /api/edge/release/complete</code> with the release ID, then read <code>/api/edge/release/deployment/ID</code> until terminal.</li></ol><p>Only <code>live</code> is a successful rollout. Registration and completion alone are not proof.</p><div className="modal-actions"><button className="btn" onClick={() => close(null)}>Close</button></div></div>, { signal: s.signal, size: 'md' }); };
|
|
34
|
+
const keyControls = <section className="deployment-key-setup"><h3>Deployment key</h3><p className="dim">{key ? key.active ? 'Active key linked. Its value cannot be retrieved.' : key.linked ? 'The linked key is inactive. Rotate it to reconnect CI.' : 'Create a key for this app, then save it as a CI secret.' : 'Key status not observed.'}</p>{keyAllowed && <div className="deployment-actions">{key && (!key.linked ? <button className="btn" disabled={w.busy} onClick={() => onKey('mint')}>Create deployment key…</button> : <><button className="btn" disabled={w.busy} onClick={() => onKey('rotate')}>Rotate key…</button>{key.active && <button className="btn" disabled={w.busy} onClick={() => onKey('revoke')}>Revoke key…</button>}</>)}<button className="btn" disabled={w.busy} onClick={() => void refreshKey()}>Refresh key status</button></div>}{keyError && <ErrorNotice>{keyError}</ErrorNotice>}</section>;
|
|
35
|
+
return <div className="deployment-setup"><Tabs ariaLabel="Deployment method" items={[
|
|
36
|
+
{ key: 'github', label: 'GitHub', panel: <div className="deployment-stack"><p>Deploy automatically when you push to your repository.</p><ol className="deployment-instructions"><li>Save the generated workflow in your repository.</li><li>Save the deployment key as the Actions secret <code>MOJO_DEPLOY_KEY</code>.</li></ol><div><button className="btn btn-primary" disabled={!workflowAllowed || w.busy} onClick={() => void generate()}>View GitHub workflow</button></div>{keyControls}</div> },
|
|
37
|
+
{ key: 'upload', label: 'Upload', panel: <div className="deployment-stack"><p>Deploy an already built site from a folder or selected files.</p><div><button className="btn btn-primary" disabled={!releaseAllowed} onClick={() => void loadUpload()}>Choose built artifact</button></div></div> },
|
|
38
|
+
{ key: 'other-ci', label: 'Other CI', panel: <div className="deployment-stack"><p>Connect another CI service using this app’s deployment key and release API.</p><div><button className="btn" onClick={showIntegration}>View integration steps</button></div>{keyControls}</div> },
|
|
39
|
+
]}/>{w.error && <ErrorNotice>{w.error}</ErrorNotice>}</div>;
|
|
40
|
+
}
|
|
41
|
+
function WorkflowDialog({ workflow, onClose }: { workflow: { filename: string; yaml: string }; onClose: () => void }) {
|
|
42
|
+
const [message, setMessage] = useState('');
|
|
43
|
+
return <div className="modal-pad admin-deployments"><h2 className="modal-title">GitHub workflow</h2><p>Save this file at <code>{workflow.filename}</code>.</p><pre className="deployment-code">{workflow.yaml}</pre>{message && <p role="status">{message}</p>}<div className="modal-actions"><button className="btn" onClick={onClose}>Close</button><button className="btn" onClick={() => void navigator.clipboard.writeText(workflow.yaml).then(() => setMessage('Workflow copied')).catch(() => setMessage('Clipboard unavailable; download the workflow'))}>Copy workflow</button><button className="btn btn-primary" onClick={() => downloadBlob(new Blob([workflow.yaml], { type: 'text/yaml' }), 'deploy-webapp.yml')}>Download workflow</button></div></div>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function UploadDialog({ app, Upload, onClose, onBusy, onChanged }: { app: WebAppObject; Upload: typeof import('./WebAppUploadPanel').WebAppUploadPanel; onClose: () => void; onBusy: (value: boolean) => void; onChanged?: () => void }) {
|
|
47
|
+
const [busy, setBusy] = useState(false);
|
|
48
|
+
return <div className="modal-pad admin-deployments"><header className="deployment-dialog-header"><h2 className="modal-title">Upload built files · {app.display_name || app.slug}</h2><button type="button" className="btn btn-icon" aria-label="Close upload" disabled={busy} onClick={onClose}><i className="bi bi-x-lg" aria-hidden="true"/></button></header><Upload app={app} onChanged={onChanged} onBusy={value => { setBusy(value); onBusy(value); }}/></div>;
|
|
49
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import { useMe } from '../../client/runtime';
|
|
3
|
+
import { Tabs } from '../../ui/Tabs';
|
|
4
|
+
import { datetime } from '../../ui/format';
|
|
5
|
+
import { getAttempt, getDeployment, resolveAppAuthority } from './api';
|
|
6
|
+
import { assertOwner, canReadWebApps, isInteractive } from './permissions';
|
|
7
|
+
import { useDeploymentObservation, useDeploymentOwner } from './useDeploymentObservation';
|
|
8
|
+
import { DeploymentHelp, ErrorNotice, Facts, Status, useCloseOnOwnerChange } from './shared';
|
|
9
|
+
import type { Attempt, DeploymentStatus, WebAppObject } from './types';
|
|
10
|
+
|
|
11
|
+
const terminal = (status: string) => ['live', 'failed', 'rolled_back', 'superseded'].includes(status);
|
|
12
|
+
const active = (status: string) => ['requested', 'queued', 'deploying', 'canary', 'fleet', 'verifying'].includes(status);
|
|
13
|
+
interface Evidence { attempt: Attempt; rollout: DeploymentStatus | null }
|
|
14
|
+
|
|
15
|
+
/** Ordinary CI attempts are inspectable through viewer-safe model history. */
|
|
16
|
+
export function WebAppDeploymentDetail({ app, attempt, detailedAllowed, onClose, onChanged }: {
|
|
17
|
+
app: WebAppObject;
|
|
18
|
+
attempt: Attempt;
|
|
19
|
+
detailedAllowed: boolean;
|
|
20
|
+
onClose: () => void;
|
|
21
|
+
onChanged: () => void;
|
|
22
|
+
}) {
|
|
23
|
+
const s = useDeploymentOwner(), me = useMe(), notified = useRef('');
|
|
24
|
+
const admitted = canReadWebApps(me.data) && isInteractive();
|
|
25
|
+
useCloseOnOwnerChange(s.key, onClose);
|
|
26
|
+
const observation = useDeploymentObservation<Evidence>(s.owner, admitted ? `${app.id}:${attempt.id}:${detailedAllowed}` : null, async signal => {
|
|
27
|
+
const current = await getAttempt(s.owner, attempt.id, app.id, signal);
|
|
28
|
+
let rollout: DeploymentStatus | null = null;
|
|
29
|
+
if (detailedAllowed && current.release) {
|
|
30
|
+
const authority = await resolveAppAuthority(s.owner, me.data, app, 'release', signal);
|
|
31
|
+
if (authority.release) rollout = await getDeployment(s.owner, current.id, app.id, current.release.id, signal);
|
|
32
|
+
}
|
|
33
|
+
assertOwner(s.owner, signal);
|
|
34
|
+
return { attempt: current, rollout };
|
|
35
|
+
}, evidence => terminal(evidence.attempt.status), evidence => active(evidence.attempt.status));
|
|
36
|
+
const current = observation.value?.attempt ?? attempt, rollout = observation.value?.rollout;
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (observation.state !== 'terminal' || !observation.value) return;
|
|
39
|
+
const key = `${s.key}:${observation.value.attempt.id}:${observation.value.attempt.status}`;
|
|
40
|
+
if (notified.current !== key) { notified.current = key; onChanged(); }
|
|
41
|
+
}, [observation.state, observation.value, s.key, onChanged]);
|
|
42
|
+
if (!admitted) return <div className="modal-pad admin-deployments"><ErrorNotice>Deployment read authority is unavailable.</ErrorNotice><button className="btn" onClick={onClose}>Close</button></div>;
|
|
43
|
+
const overview = <div>
|
|
44
|
+
<Facts rows={[["Attempted version", current.release?.version || 'Not reported'], ["Outcome", <Status value={current.status}/>], ["Started", current.started || current.created ? datetime(current.started || current.created) : 'Not reported'], ["Finished", current.finished ? datetime(current.finished) : 'Not reported']]}/>
|
|
45
|
+
{current.detail && <p>{current.detail}</p>}
|
|
46
|
+
<p className="dim"><DeploymentHelp label="deployment outcome">Only live is a successful rollout. Rolled back, failed and superseded attempts are unsuccessful. The app's desired version is a separate saved pointer, and nodes can be mixed after a failed rollout.</DeploymentHelp> {observation.observedAt ? `Observed ${new Date(observation.observedAt).toLocaleTimeString()}` : 'Refreshing saved evidence…'}</p>
|
|
47
|
+
{observation.state === 'observing' && <p role="status">Observing deployment progress…</p>}
|
|
48
|
+
{observation.state === 'error' && <ErrorNotice>Latest observation unavailable. The displayed evidence may be stale; the outcome is unconfirmed.</ErrorNotice>}
|
|
49
|
+
{observation.state === 'stopped' && <p role="status" className="text-warn">Automatic observation stopped. Refresh only reads this deployment.</p>}
|
|
50
|
+
</div>;
|
|
51
|
+
const nodePanel = rollout && (rollout.targets.length > 0 || rollout.rollback_targets.length > 0) ? <div>{[['Deployment nodes', rollout.targets], ['Rollback nodes', rollout.rollback_targets]].map(([title, nodes]) => {
|
|
52
|
+
const entries = nodes as DeploymentStatus['targets'];
|
|
53
|
+
return <section key={String(title)}><h3>{String(title)}</h3>{entries.length ? entries.map((node, index) => <div className="deployment-evidence" key={`${node.runner}:${index}`}><strong>{node.runner || 'Unnamed node'}</strong><p><Status value={node.status}/></p>{node.detail && <p>{node.detail}</p>}{node.sha && <p className="mono">{node.sha}</p>}</div>) : <p className="dim">No node evidence was returned.</p>}</section>;
|
|
54
|
+
})}</div> : null;
|
|
55
|
+
return <div className="modal-pad admin-deployments deployment-dialog"><header className="deployment-dialog-header"><div className="deployment-dialog-heading"><h2 className="modal-title">Deployment {current.release?.version || `#${current.id}`}</h2><p className="dim">{app.display_name || app.slug}</p></div><button className="btn" aria-label="Close deployment" onClick={onClose}><i className="bi bi-x-lg" aria-hidden="true"/></button></header>
|
|
56
|
+
{nodePanel ? <Tabs ariaLabel="Deployment evidence" items={[{ key: 'overview', label: 'Overview', panel: overview }, { key: 'nodes', label: 'Nodes', panel: nodePanel }]}/> : overview}
|
|
57
|
+
<div className="modal-actions"><button className="btn" onClick={onClose}>Close</button><button className="btn" disabled={observation.state === 'observing'} onClick={observation.refresh}>Refresh deployment</button></div>
|
|
58
|
+
</div>;
|
|
59
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createContext, lazy, Suspense, useContext, useEffect, useState } from 'react';
|
|
2
|
+
import { Link } from 'react-router-dom';
|
|
3
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
4
|
+
import { useMe } from '../../client/runtime';
|
|
5
|
+
import { modal } from '../../ui/modal';
|
|
6
|
+
import { Tabs } from '../../ui/Tabs';
|
|
7
|
+
import { datetime } from '../../ui/format';
|
|
8
|
+
import { ModelTable, type ModelTableQueryAdapterProps } from '../../ui/ModelTable';
|
|
9
|
+
import { backendGlobalCan } from '../fleet/access';
|
|
10
|
+
import { getAttempt, getDeployment, getHealth, getHistory, getMember, getSummary, getWebApp } from './api';
|
|
11
|
+
import { assertOwner, canReadWebApps, isInteractive, webAppAuthority, type DeploymentOwner } from './permissions';
|
|
12
|
+
import { rollbackEligible } from './deployment-state';
|
|
13
|
+
import { useDeploymentOwner } from './useDeploymentObservation';
|
|
14
|
+
import { DeploymentHelp, ErrorNotice, Facts, Status, useCloseOnOwnerChange } from './shared';
|
|
15
|
+
import { useAppActionsRefused } from './authority-state';
|
|
16
|
+
import { appUnsettled, receiptsFor, settleReceipt } from './receipts';
|
|
17
|
+
import type { Attempt, Release, DeploymentsDestinations } from './types';
|
|
18
|
+
|
|
19
|
+
const DeploySetup = lazy(() => import('./WebAppDeploySetup').then(module => ({ default: module.WebAppDeploySetup })));
|
|
20
|
+
|
|
21
|
+
export function WebAppDetail({ id, onClose, destinations }: {
|
|
22
|
+
id: number;
|
|
23
|
+
onClose: () => void;
|
|
24
|
+
destinations?: DeploymentsDestinations;
|
|
25
|
+
}) {
|
|
26
|
+
const me = useMe(), s = useDeploymentOwner(), client = useQueryClient();
|
|
27
|
+
const [health, setHealth] = useState(''), [healthBusy, setHealthBusy] = useState(false);
|
|
28
|
+
const read = canReadWebApps(me.data) && isInteractive();
|
|
29
|
+
useCloseOnOwnerChange(s.key, onClose);
|
|
30
|
+
const summary = useQuery({ queryKey: ['deployments', s.key, id, 'summary'], queryFn: ({ signal }) => getSummary(s.owner, id, signal), enabled: read, retry: false });
|
|
31
|
+
const object = useQuery({ queryKey: ['deployments', s.key, id, 'default'], queryFn: ({ signal }) => getWebApp(s.owner, id, signal), enabled: read, retry: false });
|
|
32
|
+
const globalAuthority = backendGlobalCan(me.data, ['manage_webapp']) && backendGlobalCan(me.data, ['manage_dns', 'security']);
|
|
33
|
+
const member = useQuery({ queryKey: ['deployments', s.key, id, 'member', object.data?.group.id], queryFn: ({ signal }) => getMember(s.owner, object.data!.group.id, signal), enabled: read && !!object.data && !globalAuthority, retry: false });
|
|
34
|
+
const actionsRefused = useAppActionsRefused(s.owner, id);
|
|
35
|
+
const authority = object.data ? webAppAuthority(me.data, object.data, member.data) : { read: false, custom: false, release: false, workflow: false };
|
|
36
|
+
if (actionsRefused) { authority.custom = false; authority.release = false; authority.workflow = false; }
|
|
37
|
+
const app = summary.data, group = object.data?.group.id ?? 0;
|
|
38
|
+
const latest = useQuery({ queryKey: ['deployments', s.key, id, 'latest-attempt', app?.latest_deployment?.id], queryFn: ({ signal }) => getAttempt(s.owner, app!.latest_deployment!.id, id, signal), enabled: read && !!app?.latest_deployment && !app.latest_deployment.release, retry: false });
|
|
39
|
+
const refresh = async () => { await client.invalidateQueries({ queryKey: ['deployments', s.key] }); };
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const receipt = appUnsettled(s.owner, id);
|
|
42
|
+
if (authority.release && receipt?.deployment && receipt.release) void getDeployment(s.owner, receipt.deployment, id, receipt.release, s.signal).then(result => { if (result.terminal) settleReceipt(receipt); }).catch(() => {});
|
|
43
|
+
}, [s.key, id, summary.dataUpdatedAt, authority.release]);
|
|
44
|
+
const inspectAttempt = async (attempt: Attempt) => {
|
|
45
|
+
if (!object.data) return;
|
|
46
|
+
const { WebAppDeploymentDetail } = await import('./WebAppDeploymentDetail'); assertOwner(s.owner, s.signal);
|
|
47
|
+
await modal.open(close => <WebAppDeploymentDetail app={object.data!} attempt={attempt} detailedAllowed={authority.release} onChanged={() => void refresh()} onClose={() => close(null)}/>, { signal: s.signal, size: 'lg' });
|
|
48
|
+
};
|
|
49
|
+
const launchRollback = async (release: Release) => {
|
|
50
|
+
if (!object.data || !authority.custom) return;
|
|
51
|
+
const { WebAppRollbackDialog } = await import('./WebAppRollbackDialog'); assertOwner(s.owner, s.signal); let busy = false;
|
|
52
|
+
await modal.open(close => <WebAppRollbackDialog app={object.data!} release={release} onChanged={() => void refresh()} onClose={() => close(null)} onBusy={value => { busy = value; }}/>, { signal: s.signal, canDismiss: () => !busy, size: 'lg' });
|
|
53
|
+
};
|
|
54
|
+
const launchKey = async (action: 'mint' | 'rotate' | 'revoke') => {
|
|
55
|
+
if (!object.data || !authority.custom) return;
|
|
56
|
+
const { WebAppKeyDialog } = await import('./WebAppKeyDialog'); assertOwner(s.owner, s.signal); let busy = false;
|
|
57
|
+
await modal.open(close => <WebAppKeyDialog app={object.data!} action={action} onChanged={() => void refresh()} onClose={() => close(null)} onBusy={value => { busy = value; }}/>, { signal: s.signal, canDismiss: () => !busy });
|
|
58
|
+
};
|
|
59
|
+
const edit = async () => {
|
|
60
|
+
if (!object.data || !authority.release) return;
|
|
61
|
+
const { WebAppEditDialog } = await import('./WebAppEditDialog'); assertOwner(s.owner, s.signal); let busy = false;
|
|
62
|
+
await modal.open(close => <WebAppEditDialog app={object.data!} onChanged={() => void refresh()} onClose={() => close(null)} onBusy={value => { busy = value; }}/>, { signal: s.signal, canDismiss: () => !busy, size: 'md' });
|
|
63
|
+
};
|
|
64
|
+
const resume = async () => {
|
|
65
|
+
const { WebAppOnboardingDialog } = await import('./WebAppOnboardingDialog'); assertOwner(s.owner, s.signal); let busy = false;
|
|
66
|
+
await modal.open(close => <WebAppOnboardingDialog destinations={destinations} resume={receiptsFor(s.owner).find(receipt => receipt.kind === 'create' && receipt.app === id)} onChanged={() => void refresh()} onClose={() => close(null)} onBusy={value => { busy = value; }}/>, { signal: s.signal, canDismiss: () => !busy, size: 'lg' });
|
|
67
|
+
};
|
|
68
|
+
const checkHealth = async () => {
|
|
69
|
+
if (healthBusy || !group) return;
|
|
70
|
+
setHealthBusy(true);
|
|
71
|
+
try { const result = await getHealth(s.owner, id, group, s.signal); assertOwner(s.owner, s.signal); setHealth(`${result.status}: ${result.detail}${result.checked ? ` · ${datetime(result.checked)}` : ''}`); }
|
|
72
|
+
catch { if (!s.signal.aborted) setHealth('Observation unavailable. Reachability is unconfirmed.'); }
|
|
73
|
+
finally { if (!s.signal.aborted) setHealthBusy(false); }
|
|
74
|
+
};
|
|
75
|
+
if (!read || summary.isError || object.isError) return <div className="modal-pad admin-deployments"><h2 className="modal-title">WebApp unavailable</h2><ErrorNotice>App evidence could not be read. No app actions are available.</ErrorNotice><div className="modal-actions"><button className="btn" onClick={onClose}>Close</button>{read && <button className="btn" onClick={() => void refresh()}>Refresh app</button>}</div></div>;
|
|
76
|
+
if (!app || !object.data) return <div className="modal-pad admin-deployments"><p role="status">Loading WebApp…</p><button className="btn" onClick={onClose}>Close</button></div>;
|
|
77
|
+
const attempted = app.latest_deployment?.release ?? latest.data?.release;
|
|
78
|
+
const historyContext = { owner: s.owner, scopeKey: s.key, app: id };
|
|
79
|
+
const setupIncomplete = app.onboarding.status !== 'succeeded', pending = appUnsettled(s.owner, id);
|
|
80
|
+
return <div className="modal-pad admin-deployments deployment-dialog">
|
|
81
|
+
<header className="deployment-dialog-header"><div className="deployment-dialog-heading"><h2 className="modal-title">{app.webapp.display_name || app.webapp.slug}</h2><p className="dim">{app.address?.hostname || 'No public address'}</p></div><div className="deployment-row-actions"><button className="btn" disabled={summary.isFetching || object.isFetching} onClick={() => void refresh()}>Refresh app</button><button className="btn" aria-label="Close WebApp" onClick={onClose}><i className="bi bi-x-lg" aria-hidden="true"/></button></div></header>
|
|
82
|
+
<Tabs ariaLabel="WebApp tasks" items={[
|
|
83
|
+
{ key: 'overview', label: 'Overview', panel: <div>
|
|
84
|
+
<div className="deployment-summary-grid"><section><h3>Desired version <DeploymentHelp label="desired version">The saved release pointer describes desired content. It does not prove that every node is serving it.</DeploymentHelp></h3><strong className="mono">{app.current_release?.version || 'No release'}</strong></section><section><h3>Latest deployment</h3>{app.latest_deployment ? <><strong className="mono">{attempted?.version || (latest.isError ? 'Version unavailable' : 'Loading version…')}</strong><p><Status value={app.latest_deployment.status}/></p><button className="btn" onClick={() => void inspectAttempt(latest.data ?? app.latest_deployment!)}>Inspect deployment</button></> : <p className="dim">No deployment attempts.</p>}</section></div>
|
|
85
|
+
{setupIncomplete && <section className="deployment-confirm"><h3>Setup {app.onboarding.status}</h3>{receiptsFor(s.owner).some(receipt => receipt.kind === 'create' && receipt.app === id) ? <button className="btn" onClick={() => void resume()}>Resume saved setup</button> : <p className="dim">No matching saved setup receipt is available in this browser. Existing resources are preserved.</p>}</section>}
|
|
86
|
+
{pending && <p role="status" className="text-warn">{pending.kind === 'create' ? 'Finish or cancel saved setup before starting another change.' : 'A submitted operation has an unconfirmed outcome. Inspect its deployment before starting another change.'}</p>}
|
|
87
|
+
<Facts rows={[["Environment", app.webapp.environment], ["HTTPS certificate", app.address?.certificate?.status || 'Not configured']]}/>
|
|
88
|
+
</div> },
|
|
89
|
+
{ key: 'deployments', label: 'Deployments', panel: <HistoryContext.Provider value={historyContext}><ModelTable<Release | Attempt> endpoint="/api/edge/deployment" title="Deployment attempts" paramsMode="local" searchable={false} defaultParams={{ size: 15 }} fixedParams={{ webapp: id, graph: 'list' }} queryAdapter={AttemptRows} onRowClick={row => void inspectAttempt(row as Attempt)} columns={[{ key: 'release', label: 'Version', render: row => (row as Attempt).release?.version || 'Unobserved' }, { key: 'status', label: 'Outcome', render: row => <Status value={row.status}/> }, { key: 'created', label: 'Started', render: row => datetime(row.created) }]}/></HistoryContext.Provider> },
|
|
90
|
+
{ key: 'releases', label: 'Releases', panel: <div className="deployment-release-history"><HistoryContext.Provider value={historyContext}><ModelTable<Release | Attempt> endpoint="/api/edge/release" title="Registered releases" paramsMode="local" searchable={false} defaultParams={{ size: 25 }} fixedParams={{ webapp: id, graph: 'list' }} queryAdapter={ReleaseRows} columns={[{ key: 'version', label: 'Version', render: row => <span className="mono">{(row as Release).version}{row.id === app.current_release?.id && <small className="dim"> · desired</small>}</span> }, { key: 'status', label: 'Verification', render: row => <Status value={row.status}/> }, { key: 'created', label: 'Registered', render: row => datetime(row.created) }, ...(authority.custom ? [{ key: 'action', label: '', render: (row: Release | Attempt) => rollbackEligible(row as Release, app.current_release) ? <button className="btn" disabled={!!appUnsettled(s.owner, id)} onClick={() => void launchRollback(row as Release)}>Roll back</button> : null }] : [])]}/></HistoryContext.Provider></div> },
|
|
91
|
+
...(authority.workflow || authority.release || authority.custom ? [{ key: 'deploy', label: 'Deploy', panel: <Suspense fallback={<p role="status">Loading deployment methods…</p>}><DeploySetup app={object.data} workflowAllowed={authority.workflow} releaseAllowed={authority.release} keyAllowed={authority.custom} deploymentKey={app.deployment_key} onChanged={() => void refresh()} onKey={action => void launchKey(action)}/></Suspense> }] : []),
|
|
92
|
+
{ key: 'settings', label: 'Settings', panel: <div><div className="deployment-toolbar"><h3>App profile</h3>{authority.release && <button className="btn" onClick={() => void edit()}>Edit app</button>}</div><Facts rows={[["Name", app.webapp.display_name], ["Environment", app.webapp.environment], ["Repository", app.webapp.repository || 'Not configured'], ["Branch", app.webapp.deployment_ref], ["Build output", app.webapp.build_output]]}/><h3>Address & HTTPS</h3><Facts rows={[["Address", app.address?.hostname || 'Not configured'], ["Certificate", app.address?.certificate?.status || 'Not configured'], ["Expires", app.address?.certificate?.not_after ? datetime(app.address.certificate.not_after) : 'Unknown']]}/><div className="deployment-row-actions">{(['domains', 'serving'] as const).map(key => { const path = destinations?.[key]; return path && /^\/[a-z0-9/_-]+$/i.test(path) ? <Link key={key} className="btn" to={path} onClick={onClose}>{key === 'domains' ? 'Open Domains' : 'Open Serving'}</Link> : null; })}{authority.read && <button className="btn" disabled={!group || healthBusy} onClick={() => void checkHealth()}>{healthBusy ? 'Checking HTTPS…' : 'Check HTTPS'}</button>}</div>{health && <p role="status">{health}</p>}{member.isError && !globalAuthority && <p className="dim">Workspace action authority could not be confirmed. Read-only app evidence remains available.</p>}</div> },
|
|
93
|
+
]}/>
|
|
94
|
+
</div>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const HistoryContext = createContext<{ owner: DeploymentOwner; scopeKey: string; app: number } | null>(null);
|
|
98
|
+
function HistoryRows({ params, enabled, children, kind }: { kind: 'release' | 'deployment' } & ModelTableQueryAdapterProps<Release | Attempt>) {
|
|
99
|
+
const context = useContext(HistoryContext)!;
|
|
100
|
+
const query = useQuery({ queryKey: ['deployments', context.scopeKey, context.app, kind, params.start, params.size], queryFn: ({ signal }) => getHistory(context.owner, context.app, kind, Number(params.start) || 0, signal, Number(params.size) || (kind === 'release' ? 25 : 15)), enabled, retry: false });
|
|
101
|
+
return children({ data: query.data ? { rows: query.data.items, count: query.data.count } : undefined, status: query.status, isFetching: query.isFetching, error: query.error, refetch: query.refetch });
|
|
102
|
+
}
|
|
103
|
+
function ReleaseRows(props: ModelTableQueryAdapterProps<Release | Attempt>) { return <HistoryRows {...props} kind="release"/>; }
|
|
104
|
+
function AttemptRows(props: ModelTableQueryAdapterProps<Release | Attempt>) { return <HistoryRows {...props} kind="deployment"/>; }
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useRef, useState } from 'react';
|
|
2
|
+
import { useMe } from '../../client/runtime';
|
|
3
|
+
import { getWebApp, resolveAppAuthority, saveWebAppProfile } from './api';
|
|
4
|
+
import { assertOwner, isInteractive } from './permissions';
|
|
5
|
+
import { makeReceipt, receiptsFor, saveReceipt, settleReceipt } from './receipts';
|
|
6
|
+
import { useDeploymentOwner } from './useDeploymentObservation';
|
|
7
|
+
import { DeploymentHelp, ErrorNotice, useCloseOnOwnerChange, useWrite } from './shared';
|
|
8
|
+
import type { WebAppObject } from './types';
|
|
9
|
+
|
|
10
|
+
type Profile = { display_name: string; environment: string; github_repository: string; deployment_ref: string; build_output: string };
|
|
11
|
+
const profile = (app: WebAppObject): Profile => ({ display_name: app.display_name, environment: app.environment, github_repository: app.repository, deployment_ref: app.deployment_ref, build_output: app.build_output });
|
|
12
|
+
|
|
13
|
+
export function WebAppEditDialog({ app, onClose, onChanged, onBusy }: {
|
|
14
|
+
app: WebAppObject;
|
|
15
|
+
onClose: () => void;
|
|
16
|
+
onChanged: () => void;
|
|
17
|
+
onBusy: (busy: boolean) => void;
|
|
18
|
+
}) {
|
|
19
|
+
const s = useDeploymentOwner(), me = useMe(), write = useWrite(s.owner, s.signal, onBusy);
|
|
20
|
+
const [base, setBase] = useState(() => profile(app)), [draft, setDraft] = useState(() => profile(app)), [notice, setNotice] = useState(''), [reviewedUnknown, setReviewedUnknown] = useState(''), [acknowledgement, setAcknowledgement] = useState('');
|
|
21
|
+
const form = useRef<HTMLFormElement>(null);
|
|
22
|
+
useCloseOnOwnerChange(s.key, onClose);
|
|
23
|
+
const unknownReceipts = () => receiptsFor(s.owner).filter(receipt => receipt.kind === 'profile' && receipt.app === app.id && receipt.submitted && !receipt.settled);
|
|
24
|
+
const unknownSignature = () => JSON.stringify(unknownReceipts().map(receipt => [receipt.key, receipt.operation, receipt.submittedAt ?? 0]));
|
|
25
|
+
const pending = unknownReceipts().length > 0;
|
|
26
|
+
const canAcknowledge = pending && reviewedUnknown === unknownSignature();
|
|
27
|
+
const dirty = JSON.stringify(base) !== JSON.stringify(draft);
|
|
28
|
+
const change = (key: keyof Profile, value: string) => { setNotice(''); setDraft(previous => ({ ...previous, [key]: value })); };
|
|
29
|
+
const reload = () => write.run(async () => {
|
|
30
|
+
const before = unknownSignature(); setReviewedUnknown(''); setAcknowledgement('');
|
|
31
|
+
const current = await getWebApp(s.owner, app.id, s.signal);
|
|
32
|
+
assertOwner(s.owner, s.signal);
|
|
33
|
+
if (current.group.id !== app.group.id) throw new Error('App ownership changed. Reopen the app before editing.');
|
|
34
|
+
setBase(profile(current)); setDraft(profile(current));
|
|
35
|
+
for (const receipt of receiptsFor(s.owner)) if (receipt.kind === 'profile' && receipt.app === app.id && !receipt.submitted && !receipt.settled) settleReceipt(receipt);
|
|
36
|
+
const after = unknownSignature();
|
|
37
|
+
if (unknownReceipts().length && before === after) setReviewedUnknown(after);
|
|
38
|
+
setNotice(unknownReceipts().length ? 'Current settings loaded. This read does not confirm that the earlier save has finished.' : 'Current saved settings loaded. Review them before making another change.'); onChanged();
|
|
39
|
+
});
|
|
40
|
+
const acknowledge = () => write.run(async () => {
|
|
41
|
+
if (!canAcknowledge || acknowledgement !== `${app.slug}/unknown-save` || reviewedUnknown !== unknownSignature()) return;
|
|
42
|
+
for (const receipt of unknownReceipts()) saveReceipt({ ...receipt, settled: true, acknowledged: true });
|
|
43
|
+
setReviewedUnknown(''); setAcknowledgement('');
|
|
44
|
+
setNotice('Unknown outcome acknowledged. The earlier save may still finish. Review and change the fields, then save separately if you wish to continue.'); onChanged();
|
|
45
|
+
});
|
|
46
|
+
const save = () => write.run(async () => {
|
|
47
|
+
if (!form.current?.reportValidity() || !dirty || pending || !isInteractive()) return;
|
|
48
|
+
const current = await getWebApp(s.owner, app.id, s.signal);
|
|
49
|
+
if (current.group.id !== app.group.id) throw new Error('App ownership changed. Reopen the app before editing.');
|
|
50
|
+
const authority = await resolveAppAuthority(s.owner, me.data, current, 'release', s.signal);
|
|
51
|
+
if (!authority.release) throw new Error('App edit authority is unavailable.');
|
|
52
|
+
const fresh = profile(current), patch = { ...draft };
|
|
53
|
+
if (Object.keys(patch).some(key => fresh[key as keyof Profile] !== base[key as keyof Profile])) throw new Error('Saved settings changed. Reload saved settings and review before saving.');
|
|
54
|
+
setReviewedUnknown(''); setAcknowledgement('');
|
|
55
|
+
const receipt = makeReceipt(s.owner, 'profile', `${app.id}:profile`, app.id, 'profile');
|
|
56
|
+
await saveWebAppProfile(s.owner, receipt, current, patch, s.signal);
|
|
57
|
+
const observed = await getWebApp(s.owner, app.id, s.signal); assertOwner(s.owner, s.signal);
|
|
58
|
+
if (observed.group.id !== app.group.id) throw new Error('Saved app ownership changed. Reopen the app.');
|
|
59
|
+
const saved = profile(observed);
|
|
60
|
+
if (Object.entries(patch).some(([key, value]) => saved[key as keyof Profile] !== value)) throw new Error('Saved settings do not match this change. Reload saved settings to review.');
|
|
61
|
+
settleReceipt(receipt); onChanged(); onClose();
|
|
62
|
+
});
|
|
63
|
+
return <form ref={form} className="modal-pad admin-deployments" onSubmit={event => { event.preventDefault(); void save(); }}><h2 className="modal-title">Edit {app.display_name || app.slug}</h2>
|
|
64
|
+
{pending && <p role="status" className="text-warn">A previous save has an unconfirmed outcome. Reload saved settings to review current values. No save will be repeated automatically.</p>}
|
|
65
|
+
<fieldset disabled={write.busy || pending} className="deployment-fields"><label>Name<input maxLength={120} value={draft.display_name} onChange={event => change('display_name', event.target.value)}/></label><label>Environment<select value={draft.environment} onChange={event => change('environment', event.target.value)}>{!['production', 'staging', 'preview', 'development'].includes(draft.environment) && <option value={draft.environment} disabled>{draft.environment || 'Unknown environment'}</option>}{['production', 'staging', 'preview', 'development'].map(value => <option value={value} key={value}>{value[0]!.toUpperCase() + value.slice(1)}</option>)}</select></label><label>GitHub repository<input maxLength={255} placeholder="owner/repository" value={draft.github_repository} onChange={event => change('github_repository', event.target.value)}/></label><div className="deployment-form"><label>Branch<input required maxLength={255} value={draft.deployment_ref} onChange={event => change('deployment_ref', event.target.value)}/></label><label><span className="field-label">Build output <DeploymentHelp label="build output">The directory CI builds, such as dist. Changing this profile does not deploy a release.</DeploymentHelp></span><input required maxLength={255} value={draft.build_output} onChange={event => change('build_output', event.target.value)}/></label></div></fieldset>
|
|
66
|
+
{canAcknowledge && <section className="deployment-confirm"><p className="text-warn">The earlier save may still finish and overwrite a new change.</p><label>Type {app.slug}/unknown-save<input aria-label="Unknown save acknowledgement" value={acknowledgement} disabled={write.busy} onChange={event => setAcknowledgement(event.target.value)}/></label><button type="button" className="btn" disabled={write.busy || acknowledgement !== `${app.slug}/unknown-save`} onClick={() => void acknowledge()}>Acknowledge unknown save</button></section>}
|
|
67
|
+
{write.error && <ErrorNotice>{write.error}</ErrorNotice>}{notice && <p role="status">{notice}</p>}
|
|
68
|
+
<div className="modal-actions"><button type="button" className="btn" disabled={write.busy} onClick={onClose}>Cancel</button><button type="button" className="btn" disabled={write.busy} onClick={() => void reload()}>Reload saved settings</button><button type="submit" className="btn btn-primary" disabled={write.busy || pending || !dirty}>Save changes</button></div>
|
|
69
|
+
</form>;
|
|
70
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useMe } from '../../client/runtime';
|
|
3
|
+
import { changeKey, getKeyStatus, resolveAppAuthority, getWebApp } from './api';
|
|
4
|
+
import { isInteractive } from './permissions';
|
|
5
|
+
import { makeReceipt, settleReceipt } from './receipts';
|
|
6
|
+
import { useDeploymentOwner } from './useDeploymentObservation';
|
|
7
|
+
import { ErrorNotice, useWrite } from './shared';
|
|
8
|
+
import type { WebAppObject } from './types';
|
|
9
|
+
export function WebAppKeyDialog({ app, action, onClose, onBusy, onChanged }: {
|
|
10
|
+
app: WebAppObject;
|
|
11
|
+
action: 'mint' | 'rotate' | 'revoke';
|
|
12
|
+
onClose: () => void;
|
|
13
|
+
onBusy: (v: boolean) => void;
|
|
14
|
+
onChanged: () => void;
|
|
15
|
+
}) {
|
|
16
|
+
const s = useDeploymentOwner(), me = useMe(), w = useWrite(s.owner, s.signal, onBusy), secret = useRef<string | null>(null);
|
|
17
|
+
const [reveal, setReveal] = useState(false), [done, setDone] = useState(false), [unavailable, setUnavailable] = useState(false), [confirmation, setConfirmation] = useState(''), [copied, setCopied] = useState(false);
|
|
18
|
+
useEffect(() => () => { secret.current = null; }, [s.key]);
|
|
19
|
+
const execute = () => w.run(async () => { const object = await getWebApp(s.owner, app.id, s.signal); if (object.group.id !== app.group.id)
|
|
20
|
+
throw new Error('App ownership changed'); const authority = await resolveAppAuthority(s.owner, me.data, object, 'custom', s.signal); if (!isInteractive() || !authority.custom)
|
|
21
|
+
throw new Error('Exact app credential authority is unavailable'); await getKeyStatus(s.owner, app.id, app.group.id, s.signal); const r = makeReceipt(s.owner, 'key', `${app.id}:${action}`, app.id, action); const result = await changeKey(s.owner, r, app.id, app.group.id, action, s.signal); secret.current = result.token; setUnavailable(action !== 'revoke' && !result.token); setDone(true); settleReceipt(r); onChanged(); });
|
|
22
|
+
const close = () => { if (w.busy)
|
|
23
|
+
return; secret.current = null; onClose(); };
|
|
24
|
+
return <div className="modal-pad admin-deployments"><h2 className="modal-title">{action === 'mint' ? 'Create' : action === 'rotate' ? 'Rotate' : 'Revoke'} MOJO_DEPLOY_KEY</h2><p className="modal-message">{app.display_name || app.slug}: {action === 'revoke' ? 'Future CI deployments lose this credential. Existing serving content stays.' : 'The key is delivered once. Store it in your CI secrets as MOJO_DEPLOY_KEY. Rotation requires updating CI before its next deployment.'}</p>{!done ? <label>Type {app.slug} to confirm<input value={confirmation} disabled={w.busy} onChange={e => setConfirmation(e.target.value)}/></label> : unavailable ? <p role="status">Secret unavailable. This receipt was already delivered or its response was lost. No token can be recovered from metadata. Close and explicitly start a new rotation.</p> : action === 'revoke' ? <p role="status">Revocation receipt observed. The serving release remains independent.</p> : <><p>The value clears when this dialog closes.</p><input aria-label="One-time deployment key" type={reveal ? 'text' : 'password'} value={secret.current ?? ''} readOnly autoComplete="off"/><button className="btn" onClick={() => setReveal(v => !v)}>{reveal ? 'Hide' : 'Reveal once'}</button><button className="btn" onClick={() => void navigator.clipboard.writeText(secret.current ?? '').then(() => setCopied(true)).catch(() => setCopied(false))}>{copied ? 'Copied' : 'Copy key'}</button></>}{w.error && <ErrorNotice>{w.error}</ErrorNotice>}<div className="modal-actions"><button className="btn" disabled={w.busy} onClick={close}>{done ? 'I saved it · Close' : 'Cancel'}</button>{!done && <button className="btn btn-primary" disabled={w.busy || confirmation !== app.slug} onClick={() => void execute()}>Confirm {action}</button>}</div></div>;
|
|
25
|
+
}
|