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.
Files changed (131) hide show
  1. package/docs/README.md +7 -1
  2. package/docs/admin-deployments.md +201 -0
  3. package/docs/admin-dns-certificates.md +3 -3
  4. package/docs/admin-dns-domains.md +92 -0
  5. package/docs/admin-dns.md +4 -2
  6. package/docs/admin-edge.md +200 -196
  7. package/docs/admin-geolocation.md +100 -0
  8. package/docs/admin-maintenance.md +133 -0
  9. package/docs/admin-modals.md +17 -2
  10. package/docs/admin-operational-fleet.md +206 -0
  11. package/docs/admin-presentation.md +51 -0
  12. package/docs/admin-signin.md +12 -2
  13. package/docs/client.md +7 -0
  14. package/docs/combobox.md +18 -0
  15. package/docs/popover.md +5 -4
  16. package/package.json +1 -1
  17. package/src/admin/core/OperationsHelp.tsx +10 -0
  18. package/src/admin/core/index.ts +6 -4
  19. package/src/admin/core/route-context.ts +6 -0
  20. package/src/admin/deployments/ApiDeploymentDetail.tsx +58 -0
  21. package/src/admin/deployments/DeploymentsPage.tsx +73 -0
  22. package/src/admin/deployments/WebAppDeploySetup.tsx +49 -0
  23. package/src/admin/deployments/WebAppDeploymentDetail.tsx +59 -0
  24. package/src/admin/deployments/WebAppDetail.tsx +104 -0
  25. package/src/admin/deployments/WebAppEditDialog.tsx +70 -0
  26. package/src/admin/deployments/WebAppKeyDialog.tsx +25 -0
  27. package/src/admin/deployments/WebAppOnboardingDialog.tsx +217 -0
  28. package/src/admin/deployments/WebAppRollbackDialog.tsx +82 -0
  29. package/src/admin/deployments/WebAppUploadPanel.tsx +132 -0
  30. package/src/admin/deployments/api.ts +158 -0
  31. package/src/admin/deployments/authority-state.ts +9 -0
  32. package/src/admin/deployments/deployment-state.ts +13 -0
  33. package/src/admin/deployments/index.ts +4 -0
  34. package/src/admin/deployments/onboarding-state.ts +11 -0
  35. package/src/admin/deployments/permissions.ts +37 -0
  36. package/src/admin/deployments/receipts.ts +78 -0
  37. package/src/admin/deployments/sanitize.ts +73 -0
  38. package/src/admin/deployments/shared.tsx +39 -0
  39. package/src/admin/deployments/types.ts +238 -0
  40. package/src/admin/deployments/upload.ts +53 -0
  41. package/src/admin/deployments/useDeploymentObservation.ts +104 -0
  42. package/src/admin/dns/DomainDetail.tsx +10 -6
  43. package/src/admin/dns/DomainOnboardingContent.tsx +44 -0
  44. package/src/admin/dns/DomainOnboardingDialog.tsx +5 -24
  45. package/src/admin/dns/DomainPurchaseWizard.tsx +1 -1
  46. package/src/admin/dns/DomainRegisterExisting.tsx +63 -0
  47. package/src/admin/dns/DomainRegistrarSettings.tsx +52 -0
  48. package/src/admin/dns/DomainsPage.tsx +22 -13
  49. package/src/admin/dns/SharedValidationDialog.tsx +1 -1
  50. package/src/admin/dns/api.ts +4 -2
  51. package/src/admin/dns/domain-navigation.ts +16 -0
  52. package/src/admin/dns/registrar-api.ts +13 -0
  53. package/src/admin/domains/assistant.ts +5 -4
  54. package/src/admin/domains/communications.ts +10 -9
  55. package/src/admin/domains/identity.ts +8 -7
  56. package/src/admin/domains/infrastructure.ts +19 -13
  57. package/src/admin/domains/lazy-page.ts +6 -0
  58. package/src/admin/domains/observability.ts +6 -5
  59. package/src/admin/domains/operations.ts +22 -7
  60. package/src/admin/domains/security.ts +16 -15
  61. package/src/admin/edge/EdgeHelp.tsx +10 -0
  62. package/src/admin/edge/ServingReadiness.tsx +54 -0
  63. package/src/admin/edge/VhostDetail.tsx +145 -139
  64. package/src/admin/edge/VhostWizard.tsx +204 -115
  65. package/src/admin/edge/VhostsPage.tsx +27 -28
  66. package/src/admin/edge/api.ts +24 -10
  67. package/src/admin/edge/models.ts +4 -4
  68. package/src/admin/edge/serving-data.ts +78 -0
  69. package/src/admin/fleet/CapacityEditor.tsx +81 -0
  70. package/src/admin/fleet/CapacityInspector.tsx +19 -0
  71. package/src/admin/fleet/CapacityProgress.tsx +9 -0
  72. package/src/admin/fleet/FleetHelp.tsx +7 -0
  73. package/src/admin/fleet/FleetOverview.tsx +150 -0
  74. package/src/admin/fleet/FleetPage.tsx +11 -0
  75. package/src/admin/fleet/FleetWorkspace.tsx +82 -0
  76. package/src/admin/fleet/ScaleFleetDialog.tsx +169 -0
  77. package/src/admin/fleet/access.ts +22 -0
  78. package/src/admin/fleet/api.ts +77 -0
  79. package/src/admin/fleet/data.ts +148 -0
  80. package/src/admin/fleet/index.ts +8 -0
  81. package/src/admin/fleet/operations.ts +179 -0
  82. package/src/admin/fleet/standalone-data.ts +87 -0
  83. package/src/admin/fleet/types.ts +208 -0
  84. package/src/admin/geolocation/GeoLocationPage.tsx +171 -0
  85. package/src/admin/geolocation/data.ts +138 -0
  86. package/src/admin/geolocation/index.ts +3 -0
  87. package/src/admin/identity/members/GroupMembersPanel.tsx +2 -8
  88. package/src/admin/index.ts +8 -0
  89. package/src/admin/maintenance/MaintenanceDialogs.tsx +161 -0
  90. package/src/admin/maintenance/MaintenancePage.tsx +442 -0
  91. package/src/admin/maintenance/data.ts +486 -0
  92. package/src/admin/maintenance/index.ts +3 -0
  93. package/src/admin/maintenance/operations.ts +208 -0
  94. package/src/admin/public/infrastructure.ts +1 -0
  95. package/src/admin/public/operations.ts +3 -0
  96. package/src/admin/registry.ts +6 -2
  97. package/src/admin/settings/SettingDetail.tsx +7 -2
  98. package/src/admin/settings/SettingEditor.tsx +2 -1
  99. package/src/admin/settings/SettingsPage.tsx +3 -1
  100. package/src/admin/settings/model.ts +1 -0
  101. package/src/admin/signin/SigninPage.tsx +17 -3
  102. package/src/admin/signin/api.ts +2 -0
  103. package/src/charts/MetricsChartView.tsx +4 -11
  104. package/src/charts/MetricsMiniWidget.tsx +3 -15
  105. package/src/client/client.ts +6 -0
  106. package/src/client/deployments-mock.ts +375 -0
  107. package/src/client/domain-operations-mock.ts +37 -0
  108. package/src/client/geo-provider-mock.ts +82 -0
  109. package/src/client/maintenance-mock.ts +285 -0
  110. package/src/client/mock.ts +52 -10
  111. package/src/client/operational-fleet-mock.ts +279 -0
  112. package/src/client/operations-mock.ts +85 -0
  113. package/src/client/serving-mock.ts +22 -0
  114. package/src/styles/components/admin-deployments.css +35 -0
  115. package/src/styles/components/admin-domains.css +12 -0
  116. package/src/styles/components/admin-edge.css +125 -1
  117. package/src/styles/components/admin-geolocation.css +40 -0
  118. package/src/styles/components/admin-maintenance.css +127 -0
  119. package/src/styles/components/admin-operational-fleet.css +38 -0
  120. package/src/styles/components/admin-operations.css +9 -0
  121. package/src/styles/components/admin-signin.css +2 -0
  122. package/src/styles/components/combobox.css +12 -2
  123. package/src/styles/components/fleet-page.css +9 -0
  124. package/src/styles/index.css +7 -0
  125. package/src/ui/ComboBox.tsx +39 -8
  126. package/src/ui/FilterBar.tsx +3 -5
  127. package/src/ui/ModelTable.tsx +2 -8
  128. package/src/ui/Popover.tsx +15 -7
  129. package/src/ui/date/TimePicker.tsx +4 -14
  130. package/src/ui/image-editor/ImageEditor.tsx +4 -5
  131. package/src/ui/schema-form-core.tsx +3 -5
@@ -0,0 +1,217 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { useQuery } from '@tanstack/react-query';
3
+ import { Tabs } from '../../ui/Tabs';
4
+ import { modal } from '../../ui/modal';
5
+ import { Link } from 'react-router-dom';
6
+ import { getBootstrap, getOperation, getOptions, precheckAddress, createApp, chooseOnboarding, cancelOnboarding } from './api';
7
+ import { canReadBootstrap, canReadWebApps, canOnboard, isInteractive } from './permissions';
8
+ import { useMe } from '../../client/runtime';
9
+ import { useDeploymentObservation, useDeploymentOwner } from './useDeploymentObservation';
10
+ import { managedChoice, onboardingGeneration, onboardingPollable, onboardingTerminal } from './onboarding-state';
11
+ import { makeReceipt, receiptsFor, saveReceipt, settleReceipt, type ActionReceipt } from './receipts';
12
+ import { ErrorNotice, Facts, Status, useWrite } from './shared';
13
+ import type { AddressPrecheck, Bootstrap, DeploymentsDestinations, OnboardingOperation } from './types';
14
+
15
+ export function WebAppOnboardingDialog({ bootstrap, resume, destinations, onClose, onBusy, onChanged }: {
16
+ bootstrap?: Bootstrap;
17
+ resume?: ActionReceipt;
18
+ destinations?: DeploymentsDestinations;
19
+ onClose: () => void;
20
+ onBusy: (v: boolean) => void;
21
+ onChanged?: () => void;
22
+ }) {
23
+ const s = useDeploymentOwner(), me = useMe(), w = useWrite(s.owner, s.signal, onBusy);
24
+ const initial = resume ?? receiptsFor(s.owner).find(r => r.kind === 'create' && !r.settled);
25
+ const [receipt, setReceipt] = useState<ActionReceipt | undefined>(initial);
26
+ const [createTab, setCreateTab] = useState('app');
27
+ const [operation, setOperation] = useState<OnboardingOperation | null>(null);
28
+ const [workspace, setWorkspace] = useState(String(initial?.profile?.group ?? bootstrap?.groups[0]?.id ?? 'new'));
29
+ const [name, setName] = useState(initial?.profile?.display_name ?? ''), [slug, setSlug] = useState(initial?.profile?.slug ?? '');
30
+ const [address, setAddress] = useState(''), [checked, setChecked] = useState<AddressPrecheck | null>(null);
31
+ const [repository, setRepository] = useState(initial?.profile?.github_repository ?? '');
32
+ const [ref, setRef] = useState(initial?.profile?.deployment_ref ?? 'main'), [output, setOutput] = useState(initial?.profile?.build_output ?? 'dist');
33
+ const [githubMode, setGithubMode] = useState<'skip' | 'verify' | 'attest'>('skip');
34
+ const [environment, setEnvironment] = useState(initial?.profile?.environment ?? 'production');
35
+ const intent = workspace === 'new' ? { group_intent: 'new' as const } : { group: Number(workspace) };
36
+ const eligible = !!bootstrap && (workspace === 'new' ? bootstrap.can_create_webapp_group : bootstrap.groups.some(g => g.id === Number(workspace)));
37
+ const observer = useDeploymentObservation(s.owner, receipt?.operation ?? null,
38
+ signal => getOperation(s.owner, receipt!.operation, signal), onboardingTerminal, onboardingPollable);
39
+ const latest = observer.value ?? operation;
40
+ const generation = latest ? onboardingGeneration(latest) : '';
41
+ const choiceSent = useRef(new Set<string>());
42
+ // The created operation, not a bootstrap guess or local group intent, owns continuation scope.
43
+ const resolvedIntent = latest?.group.id ? { group: latest.group.id } : intent;
44
+ const options = useQuery({
45
+ queryKey: ['deployments', s.key, 'onboarding-options', latest?.group.id ?? workspace],
46
+ queryFn: ({ signal }) => getOptions(s.owner, resolvedIntent, signal),
47
+ enabled: isInteractive() && (receipt ? !!latest?.group.id : eligible), retry: false,
48
+ });
49
+ const acceptOperation = (value: OnboardingOperation, current: ActionReceipt) => {
50
+ if (current.onboardingGroup && current.onboardingGroup !== value.group.id)
51
+ throw new Error('The operation workspace identity changed. Observe the saved operation again.');
52
+ const next = saveReceipt({ ...current, onboardingGroup: value.group.id,
53
+ app: value.resources.webapp ?? current.app, settled: onboardingTerminal(value) });
54
+ setReceipt(next); setOperation(value);
55
+ };
56
+ useEffect(() => {
57
+ if (latest && receipt && (!receipt.onboardingGroup || receipt.app !== latest.resources.webapp || receipt.settled !== onboardingTerminal(latest)))
58
+ acceptOperation(latest, receipt);
59
+ }, [generation, latest?.status]);
60
+ const changed = useRef('');
61
+ useEffect(() => {
62
+ if (!latest?.resources.webapp) return;
63
+ const next = `${latest.resources.webapp}:${onboardingTerminal(latest) ? latest.status : 'created'}`;
64
+ if (changed.current !== next) { changed.current = next; onChanged?.(); }
65
+ }, [latest?.resources.webapp, latest?.status, onChanged]);
66
+ const setupReady = !!options.data?.destination && !!options.data.buckets.length;
67
+ const newIntent = !receipt && workspace === 'new';
68
+ const addressReady = checked?.verdict === 'ready' && !!checked.domain;
69
+ const savedAddress = receipt?.address;
70
+ const addressChoiceReady = addressReady || (!address.trim() && !!savedAddress);
71
+ const savedHostname = savedAddress && options.data?.apps_domain?.id === savedAddress.domain ? `${savedAddress.label ? `${savedAddress.label}.` : ''}${options.data.apps_domain.name}` : '';
72
+ const validSlug = /^[a-z0-9][a-z0-9-]{0,63}$/.test(slug);
73
+ const creationReady = setupReady && !!name.trim() && validSlug && (newIntent || addressReady);
74
+ const reviewApp = createTab === 'build' && !creationReady;
75
+ const domainsPath = destinations?.domains;
76
+ const domainLink = domainsPath && /^\/[a-z0-9/_-]+$/i.test(domainsPath) && canReadWebApps(me.data)
77
+ ? <Link className="btn" to={domainsPath} onClick={onClose}>Open Domains to configure this workspace</Link> : null;
78
+ const verifyAddress = () => w.run(async () => {
79
+ const value = await precheckAddress(s.owner, resolvedIntent,
80
+ address || `${slug}.${options.data?.apps_domain?.name ?? ''}`, s.signal);
81
+ setChecked(value);
82
+ if (receipt && value.verdict === 'ready' && value.domain) {
83
+ const next = saveReceipt({ ...receipt, address: { domain: value.domain.id, label: value.label } });
84
+ setReceipt(next);
85
+ }
86
+ });
87
+ const create = () => w.run(async () => {
88
+ if (!bootstrap || !eligible || !setupReady || !validSlug || (!newIntent && !addressReady)) return;
89
+ if (repository && !/^[a-z0-9_.-]+\/[a-z0-9_.-]+$/i.test(repository)) throw new Error('Use a GitHub OWNER/REPOSITORY identifier');
90
+ if (!/^[a-z0-9_./-]{1,255}$/i.test(ref) || ref.includes('..')) throw new Error('Use a valid branch or tag');
91
+ if (!/^[a-z0-9_./-]{1,255}$/i.test(output) || output.startsWith('/') || output.split('/').some(p => !p || p === '..' || p === '.'))
92
+ throw new Error('Use a relative build output directory');
93
+ const fresh = await getBootstrap(s.owner, s.signal);
94
+ if (!canOnboard(fresh) || (workspace !== 'new' && !fresh.groups.some(g => g.id === Number(workspace))) ||
95
+ (workspace === 'new' && !fresh.can_create_webapp_group)) throw new Error('Workspace eligibility changed');
96
+ const action = makeReceipt(s.owner, 'create', crypto.randomUUID());
97
+ const profile = { operation_id: action.operation, ...intent, slug, display_name: name, environment,
98
+ bucket: options.data!.buckets[0]!, github_repository: repository, deployment_ref: ref, build_output: output };
99
+ const frozen = saveReceipt({ ...action, profile,
100
+ ...(!newIntent && checked?.domain ? { address: { domain: checked.domain.id, label: checked.label } } : {}) });
101
+ setReceipt(frozen);
102
+ acceptOperation(await createApp(s.owner, frozen, profile, s.signal), frozen);
103
+ observer.refresh();
104
+ });
105
+ const retryCreate = () => w.run(async () => {
106
+ if (!receipt?.profile) return;
107
+ try { acceptOperation(await getOperation(s.owner, receipt.operation, s.signal), receipt); observer.refresh(); }
108
+ catch (error) {
109
+ if (!(error && typeof error === 'object' && 'status' in error && error.status === 404))
110
+ throw new Error('The operation could not be observed. Refresh before retrying create.');
111
+ acceptOperation(await createApp(s.owner, receipt, receipt.profile, s.signal), receipt); observer.refresh();
112
+ }
113
+ });
114
+ const choose = () => w.run(async () => {
115
+ if (!latest || !receipt) return;
116
+ const fresh = await getOperation(s.owner, latest.operation_id, s.signal);
117
+ acceptOperation(fresh, receipt);
118
+ if (onboardingGeneration(fresh) !== generation) throw new Error('Setup changed. Review the observed cursor and choose again.');
119
+ const selected = addressReady && checked?.domain ? { domain: checked.domain.id, label: checked.label } : !address.trim() ? receipt.address : undefined;
120
+ let choice = managedChoice(fresh, selected?.domain ?? 0, selected?.label ?? slug);
121
+ if (fresh.cursor === 'github' && githubMode !== 'skip') {
122
+ if (!repository.trim()) throw new Error('Enter an OWNER/REPOSITORY before requesting verification');
123
+ choice = { repository: repository.trim(), ref, output, ...(githubMode === 'attest' ? { attest_unavailable: true } : {}) };
124
+ }
125
+ if (!choice) throw new Error('Configure a domain for the operation workspace, refresh options, then check the public address.');
126
+ if (choiceSent.current.has(generation)) throw new Error('That observed choice was already submitted. Refresh the server operation.');
127
+ choiceSent.current.add(generation);
128
+ const action = makeReceipt(s.owner, 'create', `${generation}:choice`);
129
+ try { acceptOperation(await chooseOnboarding(s.owner, action, fresh, choice, s.signal), receipt); settleReceipt(action); }
130
+ finally { observer.refresh(); }
131
+ });
132
+ const cancel = () => w.run(async () => {
133
+ if (!latest || !receipt) return;
134
+ const confirmed = await modal.open<boolean>(close => <div className="modal-pad"><h2 className="modal-title">Cancel server setup?</h2><p className="modal-message">Stop setup for {latest.profile.display_name || latest.profile.slug}. Resources already created may remain.</p><div className="modal-actions"><button className="btn" onClick={() => close(false)}>Keep setup</button><button className="btn btn-danger" onClick={() => close(true)}>Cancel setup</button></div></div>, { signal: s.signal, size: 'md' });
135
+ if (!confirmed) return;
136
+ const action = makeReceipt(s.owner, 'create', `${latest.operation_id}:cancel`);
137
+ acceptOperation(await cancelOnboarding(s.owner, action, latest.operation_id, s.signal), receipt);
138
+ settleReceipt(action); observer.refresh();
139
+ });
140
+ if (!isInteractive() || (!receipt && !canReadBootstrap(me.data)))
141
+ return <div className="modal-pad"><p>Interactive setup authority unavailable.</p><button className="btn" onClick={onClose}>Close</button></div>;
142
+ const addressFields = <><label>Public address<input value={address} disabled={w.busy} onChange={e => { setAddress(e.target.value); setChecked(null); }}
143
+ placeholder={options.data?.apps_domain ? `${slug || 'my-app'}.${options.data.apps_domain.name}` : 'An address under this workspace’s configured domain'} /></label>
144
+ <button className="btn" disabled={w.busy || !setupReady || (!address && !options.data?.apps_domain) || !validSlug} onClick={() => void verifyAddress()}>Check public address</button>
145
+ {checked && <p role="status">{checked.verdict}: {checked.hostname} {checked.reason}</p>}</>;
146
+ const setupDetails = () => { void modal.open(close => <div className="modal-pad admin-deployments"><h2 className="modal-title">{receipt ? 'Setup details' : 'Deployment destination'}</h2>{receipt ? <><Facts rows={[
147
+ ['Operation', receipt.operation], ['Workspace', latest ? `${latest.group.name} (#${latest.group.id})` : 'Not observed'], ['Revision', latest?.revision], ['Server step', latest?.cursor], ['Observation', observer.state], ['Last observed', observer.observedAt ? new Date(observer.observedAt).toLocaleTimeString() : 'Not observed'],
148
+ ]}/>{latest?.activity.length ? <><h3>Activity</h3>{latest.activity.map((a, i) => <p key={i}>{a.message}</p>)}</> : <p>No activity observed.</p>}</> : <Facts rows={[
149
+ ['Environment', environment], ['Release bucket', options.data?.buckets[0]], ['Managed domain', options.data?.apps_domain?.name],
150
+ ]}/>}<div className="modal-actions"><button className="btn" onClick={() => close(null)}>Close</button></div></div>, { signal: s.signal, size: 'md' }); };
151
+ const steps = [{ key: 'app', label: 'App' }, { key: 'address', label: 'Address' }, { key: 'github', label: 'Repository' }, { key: 'verify', label: 'HTTPS' }];
152
+ const currentStep = steps.findIndex(step => step.key === latest?.cursor);
153
+ return <div className="modal-pad admin-deployments">
154
+ <h2 className="modal-title">{receipt ? latest?.profile.display_name || 'WebApp setup' : 'New WebApp'}</h2>
155
+ <p className="modal-message">You can close and resume setup at any time.</p>
156
+ {!receipt ? <>
157
+ <Tabs ariaLabel="New WebApp" activeKey={createTab} onActiveKeyChange={setCreateTab} items={[
158
+ { key: 'app', label: 'App', panel: <><div className="deployment-form">
159
+ <label>Workspace<select value={workspace} disabled={w.busy} onChange={e => { setWorkspace(e.target.value); setChecked(null); }}>
160
+ {bootstrap?.groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}{bootstrap?.can_create_webapp_group && <option value="new">New workspace</option>}
161
+ </select></label>
162
+ <label>Name<input value={name} maxLength={120} disabled={w.busy} onChange={e => setName(e.target.value)} /></label>
163
+ <label>App label<input value={slug} maxLength={64} disabled={w.busy} onChange={e => { setSlug(e.target.value); setChecked(null); }} placeholder="my-app" /></label>
164
+ {!newIntent && addressFields}
165
+ </div>{newIntent && <p className="dim">Create the app first, then connect a domain to its new workspace and resume address setup.</p>}</> },
166
+ { key: 'build', label: 'Build options', panel: <div className="deployment-form">
167
+ <label>GitHub OWNER/REPOSITORY (optional)<input value={repository} disabled={w.busy} onChange={e => setRepository(e.target.value)} /></label>
168
+ <label>Branch<input value={ref} disabled={w.busy} onChange={e => setRef(e.target.value)} /></label>
169
+ <label>Build output<input value={output} disabled={w.busy} onChange={e => setOutput(e.target.value)} /></label>
170
+ <label>Environment<select value={environment} disabled={w.busy} onChange={e => setEnvironment(e.target.value)}>{(options.data?.environments ?? ['production']).map(e => <option key={e}>{e}</option>)}</select></label>
171
+ </div> },
172
+ ]}/>
173
+ {options.isPending ? <p>Resolving deployment destination…</p> : options.isError ? <ErrorNotice>Setup options unavailable.</ErrorNotice> :
174
+ !setupReady ? <ErrorNotice>{options.data?.destination_error || 'No allowed release bucket. Complete installation setup before creating an app.'}</ErrorNotice> :
175
+ <button className="btn" onClick={setupDetails}><i className="bi bi-info-circle" aria-hidden="true"/> Deployment destination</button>}
176
+ </> : <>
177
+ <ol className="deployment-setup-progress" aria-label="Setup progress">{steps.map((step, index) => <li key={step.key} aria-current={index === currentStep && !!latest && !onboardingTerminal(latest) ? 'step' : undefined} className={latest?.status === 'succeeded' || index < currentStep ? 'is-complete' : index === currentStep ? 'is-current' : ''}>{step.label}</li>)}</ol>
178
+ <div className="deployment-toolbar"><h3>{latest && onboardingTerminal(latest) ? latest.status === 'succeeded' ? 'Setup complete' : 'Setup stopped' : steps[currentStep]?.label || 'Observe saved setup'}</h3><Status value={latest?.status ?? 'unknown'}/></div>
179
+ {latest?.last_error && <ErrorNotice>{latest.last_error}</ErrorNotice>}
180
+ {(observer.state === 'error' || observer.state === 'stopped' && (!latest || onboardingPollable(latest))) && <p role="status" className="text-warn">Observation unavailable or stopped; outcome is unconfirmed. Refresh to check server setup.</p>}
181
+ {latest?.cursor === 'address' && !onboardingTerminal(latest) && <section className="deployment-stack">
182
+ <p>Public address for {latest.group.name}.</p>
183
+ {savedAddress && !address.trim() && <p role="status">A checked address is saved{savedHostname && <>: <code>{savedHostname}</code></>}. Continue to use it, or check a replacement.</p>}
184
+ {savedAddress && !!address.trim() && !addressReady && <p role="status" className="dim">Check the replacement address before continuing.</p>}
185
+ {!options.data?.apps_domain && <p role="status">{options.data?.apps_domain_error || 'No managed apps domain was observed.'} Configure or connect a domain for {latest.group.name}, then refresh and check the public address.</p>}
186
+ {domainLink}
187
+ <div><button className="btn" disabled={w.busy || options.isFetching} onClick={() => void options.refetch()}>Refresh workspace address options</button></div>
188
+ {addressFields}
189
+ </section>}
190
+ {latest?.cursor === 'github' && !onboardingTerminal(latest) && <section className="deployment-stack">
191
+ {latest.evidence.github.status && <p role="status">Repository verification: {latest.evidence.github.status}{latest.evidence.github.reason && ` · ${latest.evidence.github.reason}`}</p>}
192
+ <label>GitHub verification<select value={githubMode} disabled={w.busy} onChange={e => setGithubMode(e.target.value as 'skip' | 'verify' | 'attest')}>
193
+ <option value="skip">Skip optional repository verification</option><option value="verify">Verify the submitted repository</option>
194
+ {latest.evidence.github.status === 'unavailable' && <option value="attest">I attest this repository when verification is unavailable</option>}
195
+ </select>{githubMode !== 'skip' && <input aria-label="Repository to verify" value={repository} disabled={w.busy} onChange={e => setRepository(e.target.value)} />}</label>
196
+ {latest.evidence.github.status === 'unavailable' && <p className="dim">Unavailable verification does not attest repository ownership.</p>}
197
+ </section>}
198
+ {latest?.cursor === 'app' && !onboardingTerminal(latest) && <p>Continue to create the app in {latest.group.name}.</p>}
199
+ {latest?.cursor === 'verify' && !onboardingTerminal(latest) && <p>Continue to check HTTPS for the configured public address.</p>}
200
+ {latest?.status === 'waiting' && !latest.next_attempt_at && <p className="dim">Waiting for your choice. Automatic observation has paused.</p>}
201
+ {!latest && <p>Delivery may be uncertain. Read the saved operation before explicitly retrying its identical create receipt.</p>}
202
+ {latest && currentStep < 0 && !onboardingTerminal(latest) && <p className="text-warn">This setup step is not supported here. Refresh or inspect Setup details before continuing.</p>}
203
+ <button className="btn" onClick={setupDetails}><i className="bi bi-info-circle" aria-hidden="true"/> Setup details</button>
204
+ </>}
205
+ {!receipt && reviewApp && <p role="status" className="dim">{!name.trim() || !validSlug ? 'Add a name and valid app label on App before creating this WebApp.' : !newIntent && !addressReady ? 'Check the public address on App before creating this WebApp.' : 'Review the app and its deployment destination before creating this WebApp.'}</p>}
206
+ {w.error && <ErrorNotice>{w.error}</ErrorNotice>}
207
+ <div className="modal-actions"><button className="btn" disabled={w.busy} onClick={onClose}>Close</button>
208
+ {receipt ? <><button className="btn" disabled={w.busy} onClick={observer.refresh}>Refresh / Resume observation</button>
209
+ {!latest && receipt.profile && <button className="btn" disabled={w.busy} onClick={() => void retryCreate()}>Reconcile / retry same create</button>}
210
+ {latest && !onboardingTerminal(latest) && <><button className="btn" disabled={w.busy} onClick={() => void cancel()}>Cancel server setup</button>
211
+ <button className="btn btn-primary" disabled={w.busy || choiceSent.current.has(generation) || !['app', 'address', 'github', 'verify'].includes(latest.cursor) || (latest.cursor === 'address' && !addressChoiceReady)} onClick={() => void choose()}>
212
+ {latest.cursor === 'github' ? (githubMode === 'skip' ? 'Skip GitHub verification' : githubMode === 'attest' ? 'Attest unavailable verification' : 'Verify repository') : latest.cursor === 'address' ? 'Apply address' : latest.cursor === 'verify' ? 'Check HTTPS' : 'Create app'}
213
+ </button></>}
214
+ </> : reviewApp ? <button className="btn btn-primary" disabled={w.busy} onClick={() => setCreateTab('app')}>Review app</button> : <button className="btn btn-primary" disabled={w.busy || !creationReady} onClick={() => void create()}>Create WebApp</button>}
215
+ </div>
216
+ </div>;
217
+ }
@@ -0,0 +1,82 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { useQuery } from '@tanstack/react-query';
3
+ import { useMe } from '../../client/runtime';
4
+ import { getDeployment, getHistory, getRelease, resolveAppAuthority, getSummary, getWebApp, rollback } from './api';
5
+ import { assertOwner, isInteractive } from './permissions';
6
+ import { rollbackEligible } from './deployment-state';
7
+ import { makeReceipt, receiptsFor, saveReceipt, settleReceipt } from './receipts';
8
+ import { useDeploymentObservation, useDeploymentOwner } from './useDeploymentObservation';
9
+ import { ErrorNotice, Status, useWrite } from './shared';
10
+ import type { DeploymentStatus, Release, WebAppObject } from './types';
11
+ export function WebAppRollbackDialog({ app, release, onClose, onBusy, onChanged }: {
12
+ app: WebAppObject;
13
+ release?: Release;
14
+ onClose: () => void;
15
+ onBusy: (v: boolean) => void;
16
+ onChanged: () => void;
17
+ }) {
18
+ const s = useDeploymentOwner(), me = useMe(), w = useWrite(s.owner, s.signal, onBusy);
19
+ const pending = receiptsFor(s.owner).find(r => r.kind === 'rollback' && r.app === app.id && r.submitted && !r.settled);
20
+ const [start, setStart] = useState(0), [selection, setSelection] = useState<Release | undefined>(release), [confirmation, setConfirmation] = useState('');
21
+ const [tracked, setTracked] = useState<DeploymentStatus | null>(() => pending?.deployment && pending.release ? { deployment: pending.deployment, webapp: app.id, release: pending.release, version: '', status: 'unknown', terminal: false, success: false, detail: '', created: '', started: '', finished: '', targets: [], rollback_targets: [] } : null);
22
+ const [reconciled, setReconciled] = useState(false), [acknowledgement, setAcknowledgement] = useState('');
23
+ const q = useQuery({ queryKey: ['deployments', s.key, app.id, 'rollback-targets', start], queryFn: async ({ signal }) => ({ summary: await getSummary(s.owner, app.id, signal), releases: await getHistory(s.owner, app.id, 'release', start, signal) }), retry: false });
24
+ const selected = selection, target = selected?.id ?? 0;
25
+ const observation = useDeploymentObservation(s.owner, tracked ? String(tracked.deployment) : null, signal => getDeployment(s.owner, tracked!.deployment, app.id, tracked!.release, signal), d => d.terminal);
26
+ const execute = () => w.run(async () => {
27
+ if (!selected || confirmation !== `${app.slug}/${selected.version}` || !q.data || q.isError) return;
28
+ const [object, summary, fresh] = await Promise.all([getWebApp(s.owner, app.id, s.signal), getSummary(s.owner, app.id, s.signal), getRelease(s.owner, target, app.id, s.signal)]);
29
+ if (object.group.id !== app.group.id) throw new Error('App ownership changed');
30
+ const authority = await resolveAppAuthority(s.owner, me.data, object, 'custom', s.signal);
31
+ if (!isInteractive() || !authority.custom) throw new Error('Exact app authority is unavailable');
32
+ if (!rollbackEligible(fresh, summary.current_release) || fresh.version !== selected.version) throw new Error('Target changed; refresh and confirm again');
33
+ assertOwner(s.owner, s.signal);
34
+ const receipt = makeReceipt(s.owner, 'rollback', `${app.id}:${target}`, app.id, String(target));
35
+ const result = await rollback(s.owner, receipt, app.id, target, app.group.id, s.signal);
36
+ saveReceipt({ ...receipt, deployment: result.deployment, release: target });
37
+ setTracked(result); setConfirmation(''); onChanged();
38
+ });
39
+ const uncertain = receiptsFor(s.owner).find(r => r.kind === 'rollback' && r.app === app.id && r.submitted && !r.settled && !r.deployment);
40
+ const reconcile = () => w.run(async () => {
41
+ if (!uncertain) return;
42
+ const summary = await getSummary(s.owner, app.id, s.signal), history = await getHistory(s.owner, app.id, 'deployment', 0, s.signal);
43
+ const matches = history.items.filter(a => (a as import('./types').Attempt).release?.id === Number(uncertain.target) && Date.parse(a.created) >= (uncertain.submittedAt ?? 0) - 1000) as import('./types').Attempt[];
44
+ if (matches.length === 1) {
45
+ const match = matches[0]!, status = await getDeployment(s.owner, match.id, app.id, Number(uncertain.target), s.signal);
46
+ saveReceipt({ ...uncertain, deployment: status.deployment, release: status.release, settled: status.terminal }); setTracked(status);
47
+ } else {
48
+ setConfirmation('');
49
+ if (summary.current_release?.id === Number(uncertain.target)) throw new Error('The target is already the current desired pointer. Refresh its deployment evidence instead of dispatching another rollback.');
50
+ setReconciled(true);
51
+ }
52
+ });
53
+ const acknowledge = () => {
54
+ if (!uncertain || !reconciled || acknowledgement !== `${app.slug}/unknown-outcome`) return;
55
+ saveReceipt({ ...uncertain, settled: true, acknowledged: true }); setReconciled(false); setAcknowledgement(''); setConfirmation('');
56
+ };
57
+ const outcome = observation.value ?? tracked, notified = useRef(new Set<number>());
58
+ useEffect(() => {
59
+ if (!outcome?.terminal) return;
60
+ setTracked(current => current?.deployment === outcome.deployment && current !== outcome ? outcome : current);
61
+ const receipt = receiptsFor(s.owner).find(r => r.kind === 'rollback' && r.app === app.id && r.deployment === outcome.deployment);
62
+ if (receipt && !receipt.settled) settleReceipt(receipt);
63
+ if (!notified.current.has(outcome.deployment)) { notified.current.add(outcome.deployment); onChanged(); }
64
+ }, [outcome?.deployment, outcome?.terminal, onChanged]);
65
+ const succeeded = outcome?.terminal === true && outcome.success === true && outcome.status === 'live';
66
+ const releases = (q.data?.releases.items ?? []) as Release[], eligible = releases.filter(r => rollbackEligible(r, q.data!.summary.current_release));
67
+ return <div className="modal-pad admin-deployments"><h2 className="modal-title">{succeeded ? 'Rollback complete' : tracked ? 'Rollback progress' : `Roll back ${app.display_name || app.slug}`}</h2>{!tracked && <p className="modal-message">Choose a verified release to deploy. A failed rollout can leave mixed nodes.</p>}
68
+ {!tracked && <>
69
+ {q.isError && <ErrorNotice>Release observation unavailable. Refresh before choosing a target.</ErrorNotice>}
70
+ <label>Verified release<select value={target} disabled={w.busy || q.isFetching} onChange={e => { setSelection(releases.find(r => r.id === Number(e.target.value))); setConfirmation(''); }}><option value={0}>Choose a version</option>{selected && !eligible.some(r => r.id === target) && <option value={target}>{selected.version} · selected</option>}{eligible.map(r => <option key={r.id} value={r.id}>{r.version} · {r.status}</option>)}</select></label>
71
+ <div className="deployment-pagination" aria-label="Release history pagination"><span className="dim">{q.data ? `${q.data.releases.count ? start + 1 : 0}–${Math.min(start + releases.length, q.data.releases.count)} of ${q.data.releases.count} registered releases` : 'Loading releases…'}</span><div className="deployment-actions"><button className="btn" disabled={w.busy || q.isFetching || start === 0} onClick={() => setStart(value => Math.max(0, value - 25))}>Newer releases</button><button className="btn" disabled={w.busy || q.isFetching || !q.data || start + 25 >= q.data.releases.count} onClick={() => setStart(value => value + 25)}>Older releases</button><button className="btn" disabled={w.busy || q.isFetching} onClick={() => void q.refetch()}>Refresh releases</button></div></div>
72
+ {q.data && !eligible.length && <p className="dim">No eligible rollback target on this page.</p>}
73
+ {selected && <label>Type {app.slug}/{selected.version}<input value={confirmation} disabled={w.busy} onChange={e => setConfirmation(e.target.value)}/></label>}
74
+ </>}
75
+ {uncertain && <div className="deployment-confirm"><p>A previous rollback may have persisted. Observe it before another decision.</p><button className="btn" disabled={w.busy} onClick={() => void reconcile()}>Observe uncertain rollback</button>{reconciled && <><p>No unique matching attempt was observed in bounded history. A new rollback can overlap the earlier operation. Acknowledge this unknown outcome, then review and type the app/version again to consent to a new operation.</p><label>Type {app.slug}/unknown-outcome<input value={acknowledgement} disabled={w.busy} onChange={e => setAcknowledgement(e.target.value)}/></label><button className="btn" disabled={w.busy || acknowledgement !== `${app.slug}/unknown-outcome`} onClick={acknowledge}>Acknowledge unknown outcome</button></>}</div>}
76
+ {w.error && <ErrorNotice>{w.error}</ErrorNotice>}
77
+ {succeeded ? <div className="deployment-result" role="status"><strong>{outcome.version || selected?.version || 'Selected release'}</strong> <Status value="live"/><p>Deployment completed.</p></div> : tracked && <p role="status">Deployment {tracked.deployment}: <Status value={outcome?.status ?? 'unknown'}/></p>}
78
+ {['error', 'stopped'].includes(observation.state) && <p className="text-warn">{succeeded ? 'Latest refresh unavailable. Showing the last observed result.' : 'Observation unavailable; outcome unconfirmed. Refresh only observes.'}</p>}
79
+ {!succeeded && outcome?.detail && <p>{outcome.detail}</p>}
80
+ <div className="modal-actions"><button className="btn" disabled={w.busy} onClick={onClose}>{tracked ? 'Close' : 'Cancel'}</button>{tracked ? <button className="btn" disabled={w.busy || observation.state === 'observing'} onClick={observation.refresh}>Refresh exact deployment</button> : <button className="btn btn-primary" disabled={w.busy || q.isFetching || q.isError || !q.data || !selected || !rollbackEligible(selected, q.data.summary.current_release) || confirmation !== `${app.slug}/${selected.version}`} onClick={() => void execute()}>Confirm rollback</button>}</div>
81
+ </div>;
82
+ }
@@ -0,0 +1,132 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { usingMockTransport, useMe } from '../../client/runtime';
3
+ import { completeUpload, getDeployment, reconcileUploadCompletion, resolveAppAuthority, getWebApp, registerUpload } from './api';
4
+ import { isInteractive } from './permissions';
5
+ import { buildManifest, manifestEqual, putReleaseFiles } from './upload';
6
+ import { makeReceipt, receiptsFor, saveReceipt, settleReceipt } from './receipts';
7
+ import { useDeploymentObservation, useDeploymentOwner } from './useDeploymentObservation';
8
+ import { DeploymentHelp, ErrorNotice, Status, useWrite } from './shared';
9
+ import type { DeploymentStatus, WebAppObject } from './types';
10
+
11
+ export function WebAppUploadPanel({ app, onClose, onBusy, onChanged }: {
12
+ app: WebAppObject;
13
+ onClose?: () => void;
14
+ onBusy?: (v: boolean) => void;
15
+ onChanged?: () => void;
16
+ }) {
17
+ const s = useDeploymentOwner(), me = useMe(), w = useWrite(s.owner, s.signal, onBusy);
18
+ const files = useRef<File[]>([]);
19
+ const existing = receiptsFor(s.owner).find(r => r.kind === 'upload' && r.app === app.id && !r.settled);
20
+ const [version, setVersion] = useState(existing?.target ?? '');
21
+ const [fileCount, setFileCount] = useState(0), [progress, setProgress] = useState('');
22
+ const [tracked, setTracked] = useState<{ deployment: number; release: number } | null>(
23
+ existing?.deployment && existing.release ? { deployment: existing.deployment, release: existing.release } : null,
24
+ );
25
+ const [reconciliation, setReconciliation] = useState<Awaited<ReturnType<typeof reconcileUploadCompletion>> | null>(null);
26
+ const [consent, setConsent] = useState('');
27
+ const [lastTerminal, setLastTerminal] = useState<DeploymentStatus | null>(null);
28
+ const observe = useDeploymentObservation(s.owner, tracked ? String(tracked.deployment) : null,
29
+ signal => getDeployment(s.owner, tracked!.deployment, app.id, tracked!.release, signal), d => d.terminal);
30
+ const completionSubmitted = !!existing && ['completion_submitted', 'observing'].includes(existing.uploadPhase ?? '');
31
+
32
+ const checkAuthority = async () => {
33
+ const object = await getWebApp(s.owner, app.id, s.signal);
34
+ const authority = await resolveAppAuthority(s.owner, me.data, object, 'release', s.signal);
35
+ if (!isInteractive() || object.group.id !== app.group.id || !authority.release)
36
+ throw new Error('Exact app release authority unavailable');
37
+ };
38
+ const upload = () => w.run(async () => {
39
+ if (completionSubmitted) throw new Error('Completion was already submitted. Observe its deployment before a new decision.');
40
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(version))
41
+ throw new Error('Use a version of 1–128 letters, numbers, dots, dashes or underscores');
42
+ await checkAuthority();
43
+ setProgress('Hashing selected files…');
44
+ const manifest = await buildManifest(files.current, s.signal);
45
+ const receipt = makeReceipt(s.owner, 'upload', `${app.id}:${version}`, app.id, version);
46
+ if (receipt.manifest && !manifestEqual(receipt.manifest, manifest))
47
+ throw new Error('Reselect the identical normalized files and checksums for this pending version');
48
+ const frozen = saveReceipt({ ...receipt, manifest });
49
+ setProgress('Registering immutable release…');
50
+ const registration = await registerUpload(s.owner, frozen, app.id, app.group.id, version, manifest, s.signal);
51
+ const registered = saveReceipt({ ...frozen, release: registration.release, uploadPhase: 'registered' });
52
+ setProgress('Uploading checksum-bound files…');
53
+ const put = usingMockTransport() ? (await import('../../client/deployments-mock')).deploymentMockStorageFetch : fetch;
54
+ await putReleaseFiles(files.current, manifest, registration.uploads, s.signal,
55
+ n => setProgress(`${n}/${manifest.length} files uploaded`), put);
56
+ setProgress('Verifying release and starting deployment…');
57
+ const result = await completeUpload(s.owner, registered, registration.release, app.id, app.group.id, s.signal);
58
+ setTracked({ deployment: result.deployment, release: registration.release });
59
+ files.current = []; setFileCount(0);
60
+ setProgress('Completion accepted; observing exact deployment.');
61
+ });
62
+ const reconcile = () => w.run(async () => {
63
+ const receipt = existing ?? receiptsFor(s.owner).find(r => r.kind === 'upload' && r.app === app.id && r.target === version);
64
+ if (!receipt) throw new Error('No submitted completion receipt is available');
65
+ const result = await reconcileUploadCompletion(s.owner, receipt, s.signal);
66
+ setReconciliation(result); setConsent('');
67
+ if (result.status) setTracked({ deployment: result.status.deployment, release: result.status.release });
68
+ setProgress(result.status ? 'Matching exact deployment observed.' : 'No unique matching attempt was established. Completion outcome remains unknown.');
69
+ });
70
+ const renewConsent = () => w.run(async () => {
71
+ if (!reconciliation || consent !== `${app.slug}/${version}`) return;
72
+ await checkAuthority();
73
+ const fresh = await reconcileUploadCompletion(s.owner, reconciliation.receipt, s.signal);
74
+ if (fresh.signature !== reconciliation.signature || (fresh.status && !fresh.status.terminal)) {
75
+ setReconciliation(fresh); setConsent('');
76
+ if (fresh.status) setTracked({ deployment: fresh.status.deployment, release: fresh.status.release });
77
+ throw new Error('Deployment evidence changed or is active. Review it before another deployment decision.');
78
+ }
79
+ saveReceipt({ ...fresh.receipt, operation: crypto.randomUUID(), uploadPhase: 'registered',
80
+ completionSubmittedAt: undefined, completionBaseline: undefined, deployment: undefined,
81
+ submitted: false, submittedAt: undefined, settled: false });
82
+ setTracked(null); setReconciliation(null); setLastTerminal(null); setConsent('');
83
+ files.current = []; setFileCount(0);
84
+ setProgress('New deployment consent recorded. Reselect the identical artifact and deliberately upload/deploy again.');
85
+ });
86
+ const forTracked = (value: DeploymentStatus | null | undefined) => value && tracked && value.deployment === tracked.deployment && value.release === tracked.release && value.webapp === app.id ? value : null;
87
+ // A later exact terminal observation outranks an earlier reconciliation read.
88
+ const outcome = forTracked(observe.value) ?? forTracked(lastTerminal) ?? forTracked(reconciliation?.status);
89
+ const succeeded = outcome?.terminal === true && outcome.success === true && outcome.status === 'live';
90
+ const notified = useRef(new Set<number>());
91
+ useEffect(() => { if (observe.value?.terminal) setLastTerminal(observe.value); }, [observe.value]);
92
+ useEffect(() => {
93
+ if (!observe.value?.terminal) return;
94
+ const receipt = receiptsFor(s.owner).find(r => r.kind === 'upload' && r.app === app.id && r.deployment === observe.value?.deployment);
95
+ if (receipt && !receipt.settled) settleReceipt(receipt);
96
+ if (!notified.current.has(observe.value.deployment)) {
97
+ notified.current.add(observe.value.deployment); onChanged?.();
98
+ }
99
+ }, [observe.value?.deployment, observe.value?.terminal, onChanged]);
100
+ const lockedFiles = w.busy || !!tracked || completionSubmitted;
101
+ const mayRenew = !!reconciliation && (!reconciliation.status ||
102
+ (reconciliation.status.terminal && !reconciliation.status.success));
103
+ if (succeeded) return <div className="deployment-stack">
104
+ <div className="deployment-result" role="status"><strong>{outcome.version || version}</strong> <Status value="live"/><p>Deployment completed.</p></div>
105
+ {['error', 'stopped'].includes(observe.state) && <p className="text-warn">Latest refresh unavailable. Showing the last observed result.</p>}
106
+ <div className="modal-actions">{onClose && <button className="btn" disabled={w.busy} onClick={onClose}>Close upload</button>}<button className="btn" disabled={w.busy || observe.state === 'observing'} onClick={observe.refresh}>Refresh exact deployment</button></div>
107
+ </div>;
108
+ return <div>
109
+ <label>Release version<input value={version} disabled={w.busy || !!tracked || !!existing} onChange={e => setVersion(e.target.value)} /></label>
110
+ <label>Built files<input type="file" multiple disabled={lockedFiles} onChange={e => { files.current = Array.from(e.target.files ?? []); setFileCount(files.current.length); }} /></label>
111
+ <label>Built folder<input type="file" multiple {...{ webkitdirectory: '', directory: '' }} disabled={lockedFiles} onChange={e => {
112
+ files.current = Array.from(e.target.files ?? []).map(file => {
113
+ const copy = new File([file], file.name, { type: file.type, lastModified: file.lastModified });
114
+ Object.defineProperty(copy, 'webkitRelativePath', { value: (file.webkitRelativePath || file.name).split('/').slice(1).join('/') || file.name });
115
+ return copy;
116
+ }); setFileCount(files.current.length);
117
+ }} /></label>
118
+ <p>{fileCount} files selected <DeploymentHelp label="Upload limits and resume">Default limits are 5000 files and 1 GiB; this installation may set lower limits. Reselect and rehash the identical files to resume an upload that has not submitted completion.</DeploymentHelp></p>
119
+ {existing && !tracked && !completionSubmitted && <p>Pending version {existing.target}. Signed URLs were discarded; identical manifest registration remints them.</p>}
120
+ {completionSubmitted && !tracked && <p role="status">Completion may already have started a deployment. Re-registering or sending completion again can start another rollout. Reconcile the recorded release first.</p>}
121
+ {w.error && <ErrorNotice>{w.error}</ErrorNotice>}
122
+ {progress && !tracked && <p role="status">{progress}</p>}
123
+ {tracked ? <><p>Deployment {tracked.deployment}: <Status value={outcome?.status ?? 'unknown'} />{['error', 'stopped'].includes(observe.state) && ' · Observation unavailable; outcome unconfirmed'}</p><button className="btn" disabled={w.busy} onClick={observe.refresh}>Refresh exact deployment</button></> :
124
+ !completionSubmitted && <button className="btn btn-primary" disabled={w.busy || !fileCount || !version} onClick={() => void upload()}>Upload and deploy verified release</button>}
125
+ {(completionSubmitted || tracked) && <button className="btn" disabled={w.busy} onClick={() => void reconcile()}>Reconcile submitted completion</button>}
126
+ {onClose && <div className="modal-actions"><button className="btn" disabled={w.busy} onClick={onClose}>Close upload</button></div>}
127
+ {mayRenew && <div className="deployment-confirm"><p>The prior completion may have run. Starting again is a new deployment decision and can overlap an unobserved attempt. This does not erase or retry the earlier operation.</p>
128
+ <label>New deployment consent: type {app.slug}/{version}<input value={consent} disabled={w.busy} onChange={e => setConsent(e.target.value)} /></label>
129
+ <button className="btn" disabled={w.busy || consent !== `${app.slug}/${version}`} onClick={() => void renewConsent()}>Consent to a new deployment attempt</button>
130
+ </div>}
131
+ </div>;
132
+ }