create-safest-tools 0.6.0 → 0.6.1
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/README.md +2 -2
- package/package.json +1 -1
- package/src/update.mjs +10 -1
- package/template/README.md +1 -1
- package/template/console/README.md +2 -2
- package/template/console/command/CommandCentre.tsx +94 -63
- package/template/console/command/controller.ts +71 -65
- package/template/console/command/events.ts +1 -2
- package/template/console/command/presentation.ts +34 -0
- package/template/console/command/store.ts +3 -4
- package/template/console/command/types.ts +25 -21
- package/template/console/registry/RegistryDetailsDialog.tsx +1 -1
- package/template/console/registry/RegistryDialog.tsx +22 -23
- package/template/console/reports/EnrichmentFindings.tsx +148 -0
- package/template/console/reports/ReportDrawer.tsx +19 -6
- package/template/console/reports/ReportWorkspace.tsx +104 -14
- package/template/console/reports/controller.ts +31 -13
- package/template/console/reports/presence-presentation.ts +52 -0
- package/template/console/reports/store.ts +1 -1
- package/template/console/reports/types.ts +9 -0
- package/template/console/shell/controller.ts +1 -8
- package/template/migrations/0001_safest_resolve_baseline.sql +0 -1
- package/template/package.json +8 -7
- package/template/public/console/auth-shell.js +9353 -8950
- package/template/public/styles.css +271 -74
- package/template/src/component-executor.ts +2 -2
- package/template/src/connection-oauth.ts +12 -3
- package/template/src/database-readiness.ts +9 -0
- package/template/src/index.ts +13 -11
- package/template/src/platform-registry-validation.ts +25 -2
- package/template/src/platform-registry.ts +10 -7
- package/template/src/report-better-auth.ts +18 -6
- package/template/src/report-delivery.ts +20 -26
- package/template/src/report-email-inbound.ts +33 -22
- package/template/src/report-email.ts +7 -30
- package/template/src/report-http.ts +37 -2
- package/template/src/report-operations.ts +15 -5
- package/template/src/report-operator-accounts.ts +7 -6
- package/template/src/report-presence.ts +15 -9
- package/template/src/report-turnstile.ts +7 -3
- package/template/src/report-workflow.ts +34 -7
- package/template/src/workflow-dynamic-runtime.ts +2 -2
- package/template/src/workflow-effects.ts +22 -21
- package/template/src/workflow-expressions.ts +30 -0
- package/template/src/workflow-loader-code.ts +21 -0
- package/template/src/workflow-runs.ts +52 -9
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ npx create-safest-tools safest-resolve \
|
|
|
18
18
|
--email-from reports@example.com
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
The `--email-from` address is a real, receivable mailbox used for receipts, follow-ups, outcomes, and replies from reporters or affected users. Use a short base address such as `reports@example.com` on a domain onboarded under Cloudflare Email Service → Email Sending, then enable Email Routing for that domain. Setup creates an exact Worker route for this address, enables plus-addressing for signed case replies, and does not enable catch-all mail. Account verification, invitations, and password recovery use a distinct `accounts@` address on the same domain by default; pass `--auth-email-from` to choose another address. Every customer-facing email automatically uses the organization name, logo, colors, type, and spacing saved under
|
|
21
|
+
The `--email-from` address is a real, receivable mailbox used for receipts, follow-ups, outcomes, and replies from reporters or affected users. Use a short base address such as `reports@example.com` on a domain onboarded under Cloudflare Email Service → Email Sending, then enable Email Routing for that domain. Setup creates an exact Worker route for this address, enables plus-addressing for signed case replies, and does not enable catch-all mail. Account verification, invitations, and password recovery use a distinct `accounts@` address on the same domain by default; pass `--auth-email-from` to choose another address. Every customer-facing email automatically uses the organization name, logo, colors, type, and spacing saved under Settings → Branding.
|
|
22
22
|
|
|
23
23
|
The command creates a local project and prints a read-only infrastructure plan. It does not change Cloudflare unless `--deploy` is supplied or the generated project’s `npm run setup` command is run and explicitly confirmed.
|
|
24
24
|
|
|
@@ -46,7 +46,7 @@ npm run setup
|
|
|
46
46
|
|
|
47
47
|
`setup:plan` lists exact resource names and the reporter email route, then exits without modifying Cloudflare. `setup` opens Wrangler's Cloudflare login when needed, lets you choose the owning account, and verifies Workers Paid from the account's Workers usage model. Standard accounts need no separate billing token; only legacy or ambiguous account models use the temporary Billing Read fallback. It then guides Google, GitHub, and Cloudflare OAuth configuration with exact callback URLs and masked secret input. Nothing is provisioned until the exact `DEPLOY <installation-id>` confirmation. After confirmation it verifies Email Routing, enables subaddressing, and deploys only the exact reporter-address route. If Cloudflare interrupts the deployment, run `npm run setup` again; it detects the unfinished installation, reuses the saved authentication choices and exact resources, suppresses expected already-exists errors, and continues through the owner-link step.
|
|
48
48
|
|
|
49
|
-
A new installation applies one current D1 schema baseline. Later releases add only forward-compatible upgrade migrations.
|
|
49
|
+
A new 0.6.1 installation applies one current D1 schema baseline. Later releases add only forward-compatible upgrade migrations. Because 0.6.1 replaces the earlier development migration chain with that baseline, the updater refuses to modify a deployed pre-0.6.1 project in place. Back up that deployment and create a separately named installation instead. Undeployed scaffolds may cross the boundary normally.
|
|
50
50
|
|
|
51
51
|
After deployment, setup prints a 256-bit, single-use owner link that expires after 15 minutes. The raw token stays in the URL fragment, D1 stores only its digest, and the installer never writes it to disk. Open it to create the Safest owner account. If it expires, run `npm run owner:setup`. For later break-glass recovery, authenticate Wrangler and run `npm run owner:recover`.
|
|
52
52
|
|
package/package.json
CHANGED
package/src/update.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { buildWranglerConfiguration } from "./config.mjs";
|
|
|
6
6
|
|
|
7
7
|
const MANIFEST_PATH = ".safest-managed.json";
|
|
8
8
|
const MANIFEST_SCHEMA_VERSION = 1;
|
|
9
|
+
const FRESH_SCHEMA_BASELINE_VERSION = "0.6.1";
|
|
9
10
|
const REBUILDABLE_PATHS = new Set(["public/console/auth-shell.js"]);
|
|
10
11
|
const CUSTOMER_RUNTIME_VAR_KEYS = new Set([
|
|
11
12
|
"ALLOWED_ACTION_CODES",
|
|
@@ -185,6 +186,15 @@ export async function planProjectUpdate({ directory, templateDirectory }) {
|
|
|
185
186
|
if (compareVersions(targetVersion, manifest.installedVersion) < 0) {
|
|
186
187
|
throw new Error(`refusing to downgrade Safest Resolve from ${manifest.installedVersion} to ${targetVersion}`);
|
|
187
188
|
}
|
|
189
|
+
const deployed = (currentWrangler.d1_databases ?? []).some(({ database_id: id }) => typeof id === "string" && id.length > 0);
|
|
190
|
+
if (deployed
|
|
191
|
+
&& compareVersions(manifest.installedVersion, FRESH_SCHEMA_BASELINE_VERSION) < 0
|
|
192
|
+
&& compareVersions(targetVersion, FRESH_SCHEMA_BASELINE_VERSION) >= 0) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`Safest Resolve ${FRESH_SCHEMA_BASELINE_VERSION} starts a new fresh-install schema lineage. `
|
|
195
|
+
+ `Back up this ${manifest.installedVersion} deployment, then create and verify a separately named installation instead of updating it in place.`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
188
198
|
|
|
189
199
|
const added = [];
|
|
190
200
|
const updated = [];
|
|
@@ -205,7 +215,6 @@ export async function planProjectUpdate({ directory, templateDirectory }) {
|
|
|
205
215
|
if (!wranglerMatchesManifest(currentWrangler, manifest.wranglerHash)) conflicts.push("wrangler.jsonc");
|
|
206
216
|
|
|
207
217
|
const desiredWrangler = mergeWranglerRuntimeState(buildWranglerConfiguration(reportsConfiguration), currentWrangler);
|
|
208
|
-
const deployed = (currentWrangler.d1_databases ?? []).some(({ database_id: id }) => typeof id === "string" && id.length > 0);
|
|
209
218
|
const uniqueConflicts = [...new Set(conflicts)].sort();
|
|
210
219
|
return {
|
|
211
220
|
action: "update",
|
package/template/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Before deployment:
|
|
|
14
14
|
4. Type the exact installation confirmation when setup requests it. After deployment, setup prints a 256-bit, single-use owner link that expires after 15 minutes and is never written to disk.
|
|
15
15
|
5. Open the owner link, create the Safest owner account, then invite administrators and analysts from People. Nobody needs a Cloudflare account to sign in. Names and workspace roles are always displayed separately.
|
|
16
16
|
|
|
17
|
-
Setup creates D1, four private R2 buckets, and three Queues, applies
|
|
17
|
+
Setup creates D1, four private R2 buckets, and three Queues, applies the current fresh-install D1 baseline, and deploys the Worker with Dynamic Workflows. Version 0.6.1 begins this schema lineage; the updater blocks deployed pre-0.6.1 projects from crossing it in place and directs operators to a verified backup plus separately named installation. Later releases add only forward-compatible migrations. After owner setup, verify the `database`, `workflow_runtime`, `workers_ai`, `object_storage`, and `queues` setup features, then require an authenticated `GET /ready` response of 200. Public `GET /health` is liveness only. Better Auth owns password hashing, OAuth accounts, sessions, and one-time password-reset tokens in D1. Upgrades always back up D1 before migrations and deployment.
|
|
18
18
|
|
|
19
19
|
If the setup link expires, run `npm run owner:setup`. If the owner loses access later, authenticate Wrangler as the Cloudflare account owner and run `npm run owner:recover`; the resulting single-use link creates a 12-hour break-glass session and is recorded in the audit log.
|
|
20
20
|
|
|
@@ -6,10 +6,10 @@ The console uses React, TypeScript, and Vite. Each product area keeps its DOM, t
|
|
|
6
6
|
|
|
7
7
|
- **Account access:** sign-in, infrastructure-owner bootstrap, password reset, invitation acceptance, and optional profile-picture upload.
|
|
8
8
|
- **Workspace shell:** header, responsive navigation, current route, health, analyst identity, and permission-aware navigation.
|
|
9
|
-
- **Command centre:**
|
|
9
|
+
- **Command centre:** an SLA-aware review queue, reviewer availability, queue health, operational health, and the next best action.
|
|
10
10
|
- **Reports and appeals:** queue filters, presence, claims, participant messages, decisions, delivery history, workflow evidence, and separate appeal ownership.
|
|
11
11
|
- **Queues and workflows:** queue policy publication, routing rollout, workflow drafts, graph editing, checks, fixture tests, publishing, and queue pins.
|
|
12
|
-
- **
|
|
12
|
+
- **Tools, connections, and configuration:** reusable workflow capabilities, governed outbound routes, intake forms, routing rules, decision policies, message templates, and the visual form builder. Runtime APIs and database tables retain the stable `component` name for backward compatibility.
|
|
13
13
|
- **Operations and quality:** AI samples, reviewer corrections, incidents, retries, dead-letter evidence, analytics, audit verification, and CSV export.
|
|
14
14
|
- **People and settings:** invitations, password-reset emails, roles, personal profiles, deployment checks, branding, and reporting channels.
|
|
15
15
|
- **Ask Safest:** scoped conversations, citations, read-only activity, and links back to reports.
|
|
@@ -1,71 +1,86 @@
|
|
|
1
1
|
import { useSyncExternalStore } from "react";
|
|
2
2
|
import { PageHeader } from "../components/PageHeader";
|
|
3
|
-
import { getCommandSnapshot, subscribeToCommand } from "./store";
|
|
4
|
-
import type { CommandAction, CommandMetricSnapshot, CommandQueueLoad, CommandRunSummary } from "./types";
|
|
5
3
|
import { dispatchOpenReport } from "../reports/events";
|
|
6
4
|
import { dispatchNavigation } from "../shell/events";
|
|
5
|
+
import { compactDuration } from "./presentation";
|
|
6
|
+
import { getCommandSnapshot, subscribeToCommand } from "./store";
|
|
7
|
+
import type {
|
|
8
|
+
CommandAction,
|
|
9
|
+
CommandMetricSnapshot,
|
|
10
|
+
CommandQueueLoad,
|
|
11
|
+
CommandSystemHealth,
|
|
12
|
+
CommandStatusTone,
|
|
13
|
+
} from "./types";
|
|
7
14
|
|
|
8
15
|
function performAction(action: CommandAction): void {
|
|
9
16
|
if (action.type === "report") dispatchOpenReport(action.reportId);
|
|
10
17
|
else dispatchNavigation(action.view);
|
|
11
18
|
}
|
|
12
19
|
|
|
13
|
-
function
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const hour = new Date(value).getHours();
|
|
21
|
-
const timeOfDay = hour < 12 ? "morning" : hour < 18 ? "afternoon" : "evening";
|
|
22
|
-
return `Good ${timeOfDay}, ${firstName || "there"}.`;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function EmptyProduct({ icon, title, description }: { icon: string; title: string; description: string }) {
|
|
26
|
-
return <div className="empty-product"><span aria-hidden="true">{icon}</span><h2>{title}</h2><p>{description}</p></div>;
|
|
20
|
+
function EmptyReviewQueue() {
|
|
21
|
+
return (
|
|
22
|
+
<div className="command-empty">
|
|
23
|
+
<span aria-hidden="true"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" /><path d="m8 12 2.5 2.5L16 9" /></svg></span>
|
|
24
|
+
<div><strong>Review queue is clear</strong><small>New reports will appear here as they are routed.</small></div>
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
function Metrics({ metrics, loading }: { metrics: CommandMetricSnapshot; loading: boolean }) {
|
|
30
|
-
const values = [
|
|
31
|
-
{ label: "
|
|
32
|
-
{ label: "
|
|
33
|
-
{ label: "
|
|
34
|
-
{ label: "
|
|
30
|
+
const values: Array<{ label: string; value: number; detail: string; tone: CommandStatusTone }> = [
|
|
31
|
+
{ label: "To review", value: metrics.awaitingReview, detail: "Ready for a decision", tone: metrics.awaitingReview ? "warning" : "positive" },
|
|
32
|
+
{ label: "Urgent", value: metrics.urgent, detail: "Highest priority", tone: metrics.urgent ? "danger" : "positive" },
|
|
33
|
+
{ label: "Unassigned", value: metrics.unassigned, detail: "Needs an owner", tone: metrics.unassigned ? "warning" : "positive" },
|
|
34
|
+
{ label: "Reviewers online", value: metrics.onlineReviewers, detail: "Available now", tone: "neutral" },
|
|
35
35
|
];
|
|
36
36
|
return (
|
|
37
|
-
<section className="
|
|
37
|
+
<section className="command-signals" aria-label="Live review summary" aria-busy={loading}>
|
|
38
38
|
{values.map((item) => (
|
|
39
|
-
<
|
|
39
|
+
<button className={`command-signal ${item.tone}`} type="button" key={item.label} onClick={() => dispatchNavigation("reports")}>
|
|
40
|
+
<span>{item.label}</span><strong>{loading ? "—" : item.value}</strong><small>{item.detail}</small>
|
|
41
|
+
</button>
|
|
40
42
|
))}
|
|
41
43
|
</section>
|
|
42
44
|
);
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
function QueueLoadRow({ queue }: { queue: CommandQueueLoad }) {
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
+
const width = Math.max(queue.openCount ? 7 : 0, queue.pressure);
|
|
49
|
+
const target = queue.targetMinutes ? `${compactDuration(queue.targetMinutes)} response target` : "No response target";
|
|
50
|
+
const risk = queue.breachedCount
|
|
51
|
+
? `${queue.breachedCount} overdue`
|
|
52
|
+
: queue.openCount ? `Oldest waiting ${compactDuration(queue.oldestMinutes)}` : "Queue is clear";
|
|
48
53
|
return (
|
|
49
|
-
<article className=
|
|
50
|
-
<div className="queue-
|
|
51
|
-
<
|
|
52
|
-
|
|
54
|
+
<article className={`queue-health-row ${queue.tone}`}>
|
|
55
|
+
<div className="queue-health-title"><strong>{queue.name}</strong><span>{queue.openCount} to review</span></div>
|
|
56
|
+
<div className="queue-health-meta"><span>{risk}</span><span>{queue.unassignedCount} unassigned</span></div>
|
|
57
|
+
<span className="queue-health-track" role="progressbar" aria-label={`${queue.name} response target usage`} aria-valuemin={0} aria-valuemax={100} aria-valuenow={queue.pressure}>
|
|
58
|
+
<i style={{ width: `${width}%` }} />
|
|
53
59
|
</span>
|
|
54
|
-
<
|
|
60
|
+
<small>{target}</small>
|
|
55
61
|
</article>
|
|
56
62
|
);
|
|
57
63
|
}
|
|
58
64
|
|
|
59
|
-
function
|
|
60
|
-
const
|
|
65
|
+
function SystemHealth({ health, canOpen }: { health: CommandSystemHealth; canOpen: boolean }) {
|
|
66
|
+
const healthy = health.issueCount === 0;
|
|
61
67
|
return (
|
|
62
|
-
<
|
|
63
|
-
<
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
<
|
|
68
|
-
|
|
68
|
+
<section className="surface command-panel system-health-panel">
|
|
69
|
+
<div className="surface-head">
|
|
70
|
+
<div><h2>System health</h2><p>Delivery and automation requiring intervention.</p></div>
|
|
71
|
+
{canOpen ? <button className="text-button" type="button" onClick={() => dispatchNavigation("operations")}>Open operations</button> : null}
|
|
72
|
+
</div>
|
|
73
|
+
<div className={`system-health-summary${healthy ? " healthy" : " danger"}`}>
|
|
74
|
+
<span aria-hidden="true"><svg viewBox="0 0 24 24">{healthy ? <><circle cx="12" cy="12" r="9" /><path d="m8 12 2.5 2.5L16 9" /></> : <><path d="M10.3 3.6 2.4 18a2 2 0 0 0 1.8 3h15.6a2 2 0 0 0 1.8-3L13.7 3.6a2 2 0 0 0-3.4 0z" /><path d="M12 9v4M12 17h.01" /></>}</svg></span>
|
|
75
|
+
<div><strong>{healthy ? "All systems healthy" : `${health.issueCount} system area${health.issueCount === 1 ? "" : "s"} need attention`}</strong><small>{healthy ? "No incidents, failed deliveries, or workflow repairs." : "Open Operations to acknowledge and recover affected work."}</small></div>
|
|
76
|
+
</div>
|
|
77
|
+
<dl className="system-health-facts">
|
|
78
|
+
<div><dt>Incidents</dt><dd>{health.incidentCount}</dd></div>
|
|
79
|
+
<div><dt>Delivery failures</dt><dd>{health.deliveryFailureCount}</dd></div>
|
|
80
|
+
<div><dt>Workflow repairs</dt><dd>{health.workflowIssueCount}</dd></div>
|
|
81
|
+
<div><dt>Active workflows</dt><dd>{health.activeRunCount}</dd></div>
|
|
82
|
+
</dl>
|
|
83
|
+
</section>
|
|
69
84
|
);
|
|
70
85
|
}
|
|
71
86
|
|
|
@@ -75,21 +90,20 @@ function LoadingRows() {
|
|
|
75
90
|
|
|
76
91
|
export function CommandCentre() {
|
|
77
92
|
const snapshot = useSyncExternalStore(subscribeToCommand, getCommandSnapshot, getCommandSnapshot);
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
?
|
|
81
|
-
:
|
|
93
|
+
const reviewCount = snapshot.metrics.awaitingReview;
|
|
94
|
+
const summary = snapshot.loading
|
|
95
|
+
? "Loading the live review queue."
|
|
96
|
+
: reviewCount
|
|
97
|
+
? `${reviewCount} report${reviewCount === 1 ? "" : "s"} awaiting review · oldest waiting ${compactDuration(snapshot.metrics.oldestWaitMinutes)}.`
|
|
98
|
+
: "The review queue is clear. New work will appear here automatically.";
|
|
99
|
+
const nextReport = snapshot.attention[0];
|
|
82
100
|
|
|
83
101
|
return (
|
|
84
102
|
<div data-react-slice="command-centre">
|
|
85
|
-
<PageHeader
|
|
86
|
-
className="command-
|
|
87
|
-
|
|
88
|
-
description={`${formattedDate(snapshot.generatedAt)} · ${welcome} ${snapshot.loading ? "Loading workspace status." : attentionSummary}`}
|
|
89
|
-
>
|
|
90
|
-
<div className="surface-buttons">
|
|
103
|
+
<PageHeader className="command-head" title="Command centre" description={summary}>
|
|
104
|
+
<div className="surface-buttons command-actions">
|
|
105
|
+
{nextReport ? <button className="primary" type="button" onClick={() => performAction(nextReport.action)}><svg aria-hidden="true"><use href="#i-inbox" /></svg>Review next</button> : null}
|
|
91
106
|
{snapshot.permissions.useAssistant ? <button className="secondary command-assistant-action" type="button" onClick={() => dispatchNavigation("assistant")}><svg aria-hidden="true"><use href="#i-spark" /></svg>Ask Safest</button> : null}
|
|
92
|
-
{snapshot.permissions.manageWorkflows ? <button className="primary" type="button" onClick={() => dispatchNavigation("workflows")}><svg aria-hidden="true"><use href="#i-plus" /></svg>New workflow</button> : null}
|
|
93
107
|
</div>
|
|
94
108
|
</PageHeader>
|
|
95
109
|
|
|
@@ -99,21 +113,38 @@ export function CommandCentre() {
|
|
|
99
113
|
|
|
100
114
|
<Metrics metrics={snapshot.metrics} loading={snapshot.loading} />
|
|
101
115
|
|
|
102
|
-
<div className="command-grid">
|
|
103
|
-
<section className="surface command-panel
|
|
104
|
-
|
|
105
|
-
<
|
|
106
|
-
|
|
107
|
-
|
|
116
|
+
<div className="command-workspace-grid">
|
|
117
|
+
<section className="surface command-panel review-priority-panel">
|
|
118
|
+
<div className="surface-head">
|
|
119
|
+
<div><h2>Review queue</h2><p>Ordered by overdue target, priority, ownership, then age.</p></div>
|
|
120
|
+
<div className="surface-head-actions"><span className="live-label"><i />Live</span><button className="text-button" type="button" onClick={() => dispatchNavigation("reports")}>View all</button></div>
|
|
121
|
+
</div>
|
|
122
|
+
<div className="review-priority-list" aria-busy={snapshot.loading}>
|
|
123
|
+
{snapshot.loading ? <LoadingRows /> : snapshot.attention.length ? snapshot.attention.map((item) => (
|
|
124
|
+
<button className={`review-priority-row ${item.tone}`} type="button" key={item.id} onClick={() => performAction(item.action)}>
|
|
125
|
+
<i className="review-priority-rail" aria-hidden="true" />
|
|
126
|
+
<span className="review-priority-main">
|
|
127
|
+
<span className="review-priority-title"><strong>{item.title}</strong><b className={`priority-chip ${item.tone}`}>{item.priorityLabel}</b></span>
|
|
128
|
+
<small>{item.reference} · {item.queueName}</small>
|
|
129
|
+
<span className="review-priority-meta"><span>{item.ownership}</span><span>Received {item.age}</span></span>
|
|
130
|
+
</span>
|
|
131
|
+
<span className={`sla-chip ${item.tone}`}>{item.slaLabel}</span>
|
|
132
|
+
<svg className="review-row-arrow" aria-hidden="true" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6" /></svg>
|
|
133
|
+
</button>
|
|
134
|
+
)) : <EmptyReviewQueue />}
|
|
135
|
+
</div>
|
|
136
|
+
</section>
|
|
108
137
|
|
|
109
|
-
<
|
|
110
|
-
|
|
111
|
-
|
|
138
|
+
<div className="command-side-stack">
|
|
139
|
+
<section className="surface command-panel queue-health-panel">
|
|
140
|
+
<div className="surface-head"><div><h2>Queue health</h2><p>Current wait against each first-response target.</p></div></div>
|
|
141
|
+
<div className="queue-health-list" aria-busy={snapshot.loading}>
|
|
142
|
+
{snapshot.loading ? <LoadingRows /> : snapshot.queueLoads.length ? snapshot.queueLoads.map((queue) => <QueueLoadRow queue={queue} key={queue.id} />) : <div className="command-empty compact"><div><strong>No active queues</strong><small>Published queues will appear here.</small></div></div>}
|
|
143
|
+
</div>
|
|
144
|
+
</section>
|
|
145
|
+
<SystemHealth health={snapshot.systemHealth} canOpen={snapshot.permissions.viewAudit} />
|
|
146
|
+
</div>
|
|
112
147
|
</div>
|
|
113
|
-
|
|
114
|
-
<section className="surface command-panel recent-runs-panel"><div className="surface-head"><h2>Recent workflow runs</h2>{snapshot.permissions.viewAudit ? <button className="text-button" type="button" onClick={() => dispatchNavigation("operations")}>View runs</button> : null}</div><div className="compact-run-list" aria-busy={snapshot.loading}>
|
|
115
|
-
{snapshot.loading ? <LoadingRows /> : snapshot.recentRuns.length ? snapshot.recentRuns.map((run) => <RunRow run={run} key={run.id} />) : <EmptyProduct icon="⌘" title="No recent runs" description="Runs appear here when a report starts a workflow." />}
|
|
116
|
-
</div></section>
|
|
117
148
|
</div>
|
|
118
149
|
);
|
|
119
150
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { requestJson } from "../lib/http";
|
|
2
|
+
import { readable, relativeTime } from "../lib/format";
|
|
2
3
|
import { getOperationsWorkspaceSnapshot, subscribeToOperationsWorkspace } from "../operations/store";
|
|
3
4
|
import { getQueueWorkspaceSnapshot, subscribeToQueueWorkspace } from "../queues/store";
|
|
4
5
|
import type { QueueRecord } from "../queues/types";
|
|
@@ -6,8 +7,9 @@ import { getReportWorkspaceSnapshot, subscribeToReportWorkspace } from "../repor
|
|
|
6
7
|
import { settingsEvents } from "../settings/events";
|
|
7
8
|
import type { SetupStatus } from "../settings/types";
|
|
8
9
|
import { getShellSnapshot, subscribeToShell } from "../shell/store";
|
|
10
|
+
import { priorityLabel, slaStatus } from "./presentation";
|
|
9
11
|
import { setCommandSnapshot } from "./store";
|
|
10
|
-
import type { CommandAttentionItem, CommandQueueLoad,
|
|
12
|
+
import type { CommandAttentionItem, CommandQueueLoad, CommandSnapshot } from "./types";
|
|
11
13
|
|
|
12
14
|
interface CommandReport {
|
|
13
15
|
id: string;
|
|
@@ -37,7 +39,9 @@ interface QueuesResponse { queues?: QueueRecord[] }
|
|
|
37
39
|
interface WorkflowRunsResponse { runs?: WorkflowRun[] }
|
|
38
40
|
|
|
39
41
|
const closedReportStates = new Set(["resolved_by_human", "resolved_by_ai", "closed"]);
|
|
42
|
+
const reviewReportStates = new Set(["human_review", "human_review_requested"]);
|
|
40
43
|
const activeRunStates = new Set(["created", "dispatch_pending", "queued", "running", "waiting", "paused"]);
|
|
44
|
+
const unhealthyRunStates = new Set(["failed", "repair_required"]);
|
|
41
45
|
|
|
42
46
|
let actorId = "";
|
|
43
47
|
let reports: CommandReport[] = [];
|
|
@@ -72,23 +76,9 @@ function queueStoreSignature(): string {
|
|
|
72
76
|
.join("|");
|
|
73
77
|
}
|
|
74
78
|
|
|
75
|
-
function
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
const sentence = `${copy.charAt(0).toUpperCase()}${copy.slice(1)}`;
|
|
79
|
-
const acronyms: Record<string, string> = { ai: "AI", api: "API", csv: "CSV", d1: "D1", http: "HTTP", https: "HTTPS", id: "ID", json: "JSON", r2: "R2", sla: "SLA", url: "URL" };
|
|
80
|
-
return sentence.replace(/\b(ai|api|csv|d1|http|https|id|json|r2|sla|url)\b/giu, (match) => acronyms[match.toLowerCase()] ?? match);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function relativeTime(value: string | undefined): string {
|
|
84
|
-
const milliseconds = Date.now() - Date.parse(value || "");
|
|
85
|
-
if (!Number.isFinite(milliseconds)) return "Unknown";
|
|
86
|
-
const minutes = Math.max(0, Math.floor(milliseconds / 60_000));
|
|
87
|
-
if (minutes < 1) return "Just now";
|
|
88
|
-
if (minutes < 60) return `${minutes}m ago`;
|
|
89
|
-
const hours = Math.floor(minutes / 60);
|
|
90
|
-
if (hours < 48) return `${hours}h ago`;
|
|
91
|
-
return `${Math.floor(hours / 24)}d ago`;
|
|
79
|
+
function reportAgeMinutes(report: CommandReport): number {
|
|
80
|
+
const receivedAt = Date.parse(report.receivedAt);
|
|
81
|
+
return Number.isFinite(receivedAt) ? Math.max(0, (Date.now() - receivedAt) / 60_000) : 0;
|
|
92
82
|
}
|
|
93
83
|
|
|
94
84
|
function reset(): void {
|
|
@@ -104,78 +94,93 @@ function reset(): void {
|
|
|
104
94
|
function buildSnapshot(): CommandSnapshot {
|
|
105
95
|
const session = getShellSnapshot().session;
|
|
106
96
|
const openReports = reports.filter((report) => !closedReportStates.has(report.state));
|
|
107
|
-
const needsHuman = openReports.filter((report) =>
|
|
97
|
+
const needsHuman = openReports.filter((report) => reviewReportStates.has(report.state));
|
|
108
98
|
const activeRuns = runs.filter((run) => activeRunStates.has(run.state));
|
|
99
|
+
const workflowIssueCount = runs.filter((run) => unhealthyRunStates.has(run.state)).length;
|
|
109
100
|
const operations = getOperationsWorkspaceSnapshot().operations;
|
|
110
101
|
const incidentCount = Number(operations?.incidents.open || 0) + Number(operations?.incidents.acknowledged || 0);
|
|
111
|
-
const
|
|
102
|
+
const deliveryFailureCount = Number(operations?.routing.failed || 0)
|
|
103
|
+
+ Number(operations?.routing.deadLetter || 0)
|
|
104
|
+
+ Number(operations?.actions.terminalFailed || 0)
|
|
105
|
+
+ Number(operations?.notifications.terminalFailed || 0)
|
|
106
|
+
+ Number(operations?.emails.terminalFailed || 0);
|
|
107
|
+
const queueById = new Map(queues.map((queue) => [queue.id, queue]));
|
|
108
|
+
const sortedAttention = [...needsHuman].sort((left, right) => {
|
|
109
|
+
const leftTarget = queueById.get(left.queueId || "")?.activePolicy?.responseSlaMinutes ?? null;
|
|
110
|
+
const rightTarget = queueById.get(right.queueId || "")?.activePolicy?.responseSlaMinutes ?? null;
|
|
111
|
+
const leftBreached = slaStatus(reportAgeMinutes(left), leftTarget).breached;
|
|
112
|
+
const rightBreached = slaStatus(reportAgeMinutes(right), rightTarget).breached;
|
|
113
|
+
if (leftBreached !== rightBreached) return leftBreached ? -1 : 1;
|
|
114
|
+
if (left.priority !== right.priority) return right.priority - left.priority;
|
|
115
|
+
if (Boolean(left.claim) !== Boolean(right.claim)) return left.claim ? 1 : -1;
|
|
116
|
+
return Date.parse(left.receivedAt) - Date.parse(right.receivedAt);
|
|
117
|
+
});
|
|
112
118
|
|
|
113
|
-
const attention: CommandAttentionItem[] =
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
urgent: true,
|
|
129
|
-
action: { type: "view", view: "operations" },
|
|
119
|
+
const attention: CommandAttentionItem[] = sortedAttention.slice(0, 6).map((report) => {
|
|
120
|
+
const queue = queueById.get(report.queueId || "");
|
|
121
|
+
const sla = slaStatus(reportAgeMinutes(report), queue?.activePolicy?.responseSlaMinutes ?? null);
|
|
122
|
+
return {
|
|
123
|
+
id: `report-${report.id}`,
|
|
124
|
+
title: `${report.reasonCode ? readable(report.reasonCode) : "Safety"} report`,
|
|
125
|
+
reference: report.reference,
|
|
126
|
+
queueName: queue?.name || "Unrouted",
|
|
127
|
+
age: relativeTime(report.receivedAt),
|
|
128
|
+
ownership: report.claim ? "Assigned" : "Unassigned",
|
|
129
|
+
priorityLabel: priorityLabel(report.priority),
|
|
130
|
+
slaLabel: sla.label,
|
|
131
|
+
tone: report.priority >= 80 ? "danger" : sla.tone,
|
|
132
|
+
action: { type: "report", reportId: report.id },
|
|
133
|
+
};
|
|
130
134
|
});
|
|
131
135
|
|
|
132
|
-
const queueLoads: CommandQueueLoad[] = queues.filter((queue) => queue.status === "active").
|
|
133
|
-
const queuedReports =
|
|
134
|
-
const targetMinutes =
|
|
135
|
-
const
|
|
136
|
-
const
|
|
136
|
+
const queueLoads: CommandQueueLoad[] = queues.filter((queue) => queue.status === "active").map((queue): CommandQueueLoad => {
|
|
137
|
+
const queuedReports = needsHuman.filter((report) => report.queueId === queue.id);
|
|
138
|
+
const targetMinutes = queue.activePolicy?.responseSlaMinutes ?? null;
|
|
139
|
+
const ages = queuedReports.map(reportAgeMinutes);
|
|
140
|
+
const oldestMinutes = ages.length ? Math.max(...ages) : 0;
|
|
141
|
+
const breachedCount = ages.filter((age) => slaStatus(age, targetMinutes).breached).length;
|
|
142
|
+
const pressure = targetMinutes ? Math.min(100, Math.round((oldestMinutes / Math.max(1, targetMinutes)) * 100)) : 0;
|
|
137
143
|
return {
|
|
138
144
|
id: queue.id,
|
|
139
145
|
name: queue.name,
|
|
140
146
|
targetMinutes,
|
|
141
147
|
openCount: queuedReports.length,
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const report = reports.find((candidate) => candidate.id === run.reportId);
|
|
148
|
-
const waitingForHuman = Number(run.openTaskCount || 0) > 0;
|
|
149
|
-
return {
|
|
150
|
-
id: run.id,
|
|
151
|
-
title: run.workflowName || run.workflowKey || `Run ${run.id.slice(0, 8)}`,
|
|
152
|
-
detail: `${report?.reference || run.reportId || "Report"} · v${run.version || "—"}`,
|
|
153
|
-
status: waitingForHuman ? "Waiting for human" : readable(run.state),
|
|
154
|
-
tone: run.state === "failed" || run.state === "repair_required" ? "danger" : waitingForHuman || run.state === "waiting" ? "warning" : "neutral",
|
|
155
|
-
age: relativeTime(run.startedAt || run.createdAt).replace(" ago", ""),
|
|
156
|
-
action: run.reportId ? { type: "report", reportId: run.reportId } : { type: "view", view: "operations" },
|
|
148
|
+
unassignedCount: queuedReports.filter((report) => !report.claim).length,
|
|
149
|
+
oldestMinutes,
|
|
150
|
+
breachedCount,
|
|
151
|
+
pressure,
|
|
152
|
+
tone: breachedCount ? "danger" : pressure >= 75 ? "warning" : "positive",
|
|
157
153
|
};
|
|
158
|
-
});
|
|
154
|
+
}).sort((left, right) => right.breachedCount - left.breachedCount || right.pressure - left.pressure || right.openCount - left.openCount).slice(0, 5);
|
|
159
155
|
|
|
160
156
|
const optionalSetup = new Set(setup?.optional || []);
|
|
161
157
|
const incompleteSetupCount = setup?.features.filter((feature) => feature.state !== "ready" && !optionalSetup.has(feature.featureKey)).length || 0;
|
|
158
|
+
const unassigned = needsHuman.filter((report) => !report.claim).length;
|
|
159
|
+
const urgent = needsHuman.filter((report) => report.priority >= 80).length;
|
|
160
|
+
const oldestWaitMinutes = needsHuman.length ? Math.max(...needsHuman.map(reportAgeMinutes)) : 0;
|
|
161
|
+
const systemIssueCount = Number(incidentCount > 0) + Number(workflowIssueCount > 0) + Number(deliveryFailureCount > 0);
|
|
162
162
|
return {
|
|
163
163
|
loading,
|
|
164
164
|
generatedAt: new Date().toISOString(),
|
|
165
|
-
firstName: displayName.split(" ")[0] || "there",
|
|
166
165
|
metrics: {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
166
|
+
awaitingReview: needsHuman.length,
|
|
167
|
+
urgent,
|
|
168
|
+
unassigned,
|
|
169
|
+
onlineReviewers: getReportWorkspaceSnapshot().presence.length,
|
|
170
|
+
oldestWaitMinutes,
|
|
171
171
|
},
|
|
172
172
|
attention,
|
|
173
173
|
queueLoads,
|
|
174
|
-
|
|
174
|
+
systemHealth: {
|
|
175
|
+
issueCount: systemIssueCount,
|
|
176
|
+
incidentCount,
|
|
177
|
+
workflowIssueCount,
|
|
178
|
+
deliveryFailureCount,
|
|
179
|
+
activeRunCount: activeRuns.length,
|
|
180
|
+
},
|
|
175
181
|
incompleteSetupCount,
|
|
176
182
|
permissions: {
|
|
177
183
|
useAssistant: session?.permissions.useAssistant === true,
|
|
178
|
-
manageWorkflows: session?.permissions.manageWorkflows === true,
|
|
179
184
|
viewAudit: session?.permissions.viewAudit === true,
|
|
180
185
|
manageInstallation: session?.permissions.manageInstallation === true,
|
|
181
186
|
},
|
|
@@ -250,6 +255,7 @@ export function startCommandController(): void {
|
|
|
250
255
|
reportSignature = signature;
|
|
251
256
|
scheduleRefresh();
|
|
252
257
|
}
|
|
258
|
+
publish();
|
|
253
259
|
});
|
|
254
260
|
subscribeToQueueWorkspace(() => {
|
|
255
261
|
const snapshot = getQueueWorkspaceSnapshot();
|
|
@@ -9,11 +9,10 @@ export function isCommandSnapshot(value: unknown): value is CommandSnapshot {
|
|
|
9
9
|
const candidate = value as Partial<CommandSnapshot>;
|
|
10
10
|
return typeof candidate.loading === "boolean"
|
|
11
11
|
&& typeof candidate.generatedAt === "string"
|
|
12
|
-
&& typeof candidate.firstName === "string"
|
|
13
12
|
&& typeof candidate.metrics === "object"
|
|
14
13
|
&& Array.isArray(candidate.attention)
|
|
15
14
|
&& Array.isArray(candidate.queueLoads)
|
|
16
|
-
&&
|
|
15
|
+
&& typeof candidate.systemHealth === "object"
|
|
17
16
|
&& typeof candidate.incompleteSetupCount === "number"
|
|
18
17
|
&& typeof candidate.permissions === "object";
|
|
19
18
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { CommandStatusTone } from "./types";
|
|
2
|
+
|
|
3
|
+
export function compactDuration(minutes: number): string {
|
|
4
|
+
const rounded = Math.max(0, Math.floor(minutes));
|
|
5
|
+
if (rounded < 1) return "now";
|
|
6
|
+
if (rounded < 60) return `${rounded}m`;
|
|
7
|
+
const hours = Math.floor(rounded / 60);
|
|
8
|
+
if (hours < 48) return `${hours}h`;
|
|
9
|
+
return `${Math.floor(hours / 24)}d`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function priorityLabel(priority: number): string {
|
|
13
|
+
if (priority >= 80) return "Urgent";
|
|
14
|
+
if (priority >= 60) return "High";
|
|
15
|
+
return "Standard";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function slaStatus(ageMinutes: number, targetMinutes: number | null): {
|
|
19
|
+
label: string;
|
|
20
|
+
tone: CommandStatusTone;
|
|
21
|
+
breached: boolean;
|
|
22
|
+
} {
|
|
23
|
+
if (!targetMinutes || targetMinutes <= 0) {
|
|
24
|
+
return { label: `${compactDuration(ageMinutes)} waiting`, tone: "neutral", breached: false };
|
|
25
|
+
}
|
|
26
|
+
const remaining = targetMinutes - ageMinutes;
|
|
27
|
+
if (remaining <= 0) {
|
|
28
|
+
return { label: `${compactDuration(Math.abs(remaining))} overdue`, tone: "danger", breached: true };
|
|
29
|
+
}
|
|
30
|
+
if (remaining <= Math.max(15, targetMinutes * .25)) {
|
|
31
|
+
return { label: `${compactDuration(remaining)} to target`, tone: "warning", breached: false };
|
|
32
|
+
}
|
|
33
|
+
return { label: `${compactDuration(remaining)} to target`, tone: "positive", breached: false };
|
|
34
|
+
}
|
|
@@ -6,13 +6,12 @@ const subscribers = new Set<() => void>();
|
|
|
6
6
|
let snapshot: CommandSnapshot = {
|
|
7
7
|
loading: true,
|
|
8
8
|
generatedAt: new Date().toISOString(),
|
|
9
|
-
|
|
10
|
-
metrics: { openReports: 0, needsHuman: 0, activeRuns: 0, unresolvedIncidents: 0 },
|
|
9
|
+
metrics: { awaitingReview: 0, urgent: 0, unassigned: 0, onlineReviewers: 0, oldestWaitMinutes: 0 },
|
|
11
10
|
attention: [],
|
|
12
11
|
queueLoads: [],
|
|
13
|
-
|
|
12
|
+
systemHealth: { issueCount: 0, incidentCount: 0, workflowIssueCount: 0, deliveryFailureCount: 0, activeRunCount: 0 },
|
|
14
13
|
incompleteSetupCount: 0,
|
|
15
|
-
permissions: { useAssistant: false,
|
|
14
|
+
permissions: { useAssistant: false, viewAudit: false, manageInstallation: false },
|
|
16
15
|
};
|
|
17
16
|
|
|
18
17
|
window.addEventListener(commandEvents.state, (event) => {
|