create-safest-tools 0.2.0
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/LICENSE +201 -0
- package/README.md +71 -0
- package/bin/create-safest-tools.mjs +12 -0
- package/package.json +34 -0
- package/src/cli.mjs +128 -0
- package/src/config.mjs +151 -0
- package/src/scaffold.mjs +30 -0
- package/template/LICENSE +201 -0
- package/template/README.md +35 -0
- package/template/console/README.md +28 -0
- package/template/console/analytics/AnalyticsWorkspace.tsx +55 -0
- package/template/console/analytics/controller.ts +133 -0
- package/template/console/analytics/events.ts +33 -0
- package/template/console/analytics/store.ts +33 -0
- package/template/console/analytics/types.ts +38 -0
- package/template/console/appeals/AppealWorkspace.tsx +73 -0
- package/template/console/appeals/controller.ts +125 -0
- package/template/console/appeals/events.ts +33 -0
- package/template/console/appeals/store.ts +35 -0
- package/template/console/appeals/types.ts +37 -0
- package/template/console/assistant/AssistantWorkspace.tsx +133 -0
- package/template/console/assistant/types.ts +34 -0
- package/template/console/auth/AccountJourney.tsx +69 -0
- package/template/console/auth/InvitationAcceptance.tsx +145 -0
- package/template/console/auth/OAuthButtons.tsx +78 -0
- package/template/console/auth/OperatorLogin.tsx +110 -0
- package/template/console/auth/PasswordRecovery.tsx +132 -0
- package/template/console/auth/account-route.ts +15 -0
- package/template/console/auth/browser-navigation.ts +7 -0
- package/template/console/auth/events.ts +15 -0
- package/template/console/command/CommandCentre.tsx +119 -0
- package/template/console/command/controller.ts +267 -0
- package/template/console/command/events.ts +19 -0
- package/template/console/command/store.ts +36 -0
- package/template/console/command/types.ts +61 -0
- package/template/console/components/AnalystIdentity.tsx +25 -0
- package/template/console/components/PageHeader.tsx +27 -0
- package/template/console/configuration/ConfigurationDialog.tsx +200 -0
- package/template/console/configuration/ConfigurationWorkspace.tsx +66 -0
- package/template/console/configuration/FormBuilder.tsx +143 -0
- package/template/console/configuration/api.ts +40 -0
- package/template/console/configuration/events.ts +14 -0
- package/template/console/configuration/types.ts +79 -0
- package/template/console/lib/format.ts +49 -0
- package/template/console/lib/http.ts +96 -0
- package/template/console/main.tsx +225 -0
- package/template/console/operations/OperationsWorkspace.tsx +126 -0
- package/template/console/operations/controller.ts +237 -0
- package/template/console/operations/events.ts +37 -0
- package/template/console/operations/store.ts +32 -0
- package/template/console/operations/types.ts +107 -0
- package/template/console/people/PeopleWorkspace.tsx +81 -0
- package/template/console/people/types.ts +25 -0
- package/template/console/profile/ProfileWorkspace.tsx +136 -0
- package/template/console/profile/events.ts +9 -0
- package/template/console/profile/types.ts +6 -0
- package/template/console/quality/QualityWorkspace.tsx +87 -0
- package/template/console/quality/controller.ts +154 -0
- package/template/console/quality/events.ts +34 -0
- package/template/console/quality/store.ts +36 -0
- package/template/console/quality/types.ts +70 -0
- package/template/console/queues/QueueEditor.tsx +173 -0
- package/template/console/queues/QueueWorkspace.tsx +109 -0
- package/template/console/queues/controller.ts +194 -0
- package/template/console/queues/events.ts +34 -0
- package/template/console/queues/store.ts +37 -0
- package/template/console/queues/types.ts +102 -0
- package/template/console/registry/RegistryDialog.tsx +198 -0
- package/template/console/registry/RegistryWorkspace.tsx +108 -0
- package/template/console/registry/events.ts +14 -0
- package/template/console/registry/types.ts +52 -0
- package/template/console/reports/ReportDrawer.tsx +159 -0
- package/template/console/reports/ReportWorkspace.tsx +90 -0
- package/template/console/reports/controller.ts +517 -0
- package/template/console/reports/events.ts +42 -0
- package/template/console/reports/store.ts +39 -0
- package/template/console/reports/types.ts +230 -0
- package/template/console/settings/BrandEditor.tsx +126 -0
- package/template/console/settings/ChannelDialog.tsx +241 -0
- package/template/console/settings/SettingsWorkspace.tsx +155 -0
- package/template/console/settings/events.ts +19 -0
- package/template/console/settings/handoff.ts +22 -0
- package/template/console/settings/types.ts +93 -0
- package/template/console/shell/WorkspaceShell.tsx +160 -0
- package/template/console/shell/controller.ts +182 -0
- package/template/console/shell/events.ts +25 -0
- package/template/console/shell/navigation.ts +61 -0
- package/template/console/shell/store.ts +56 -0
- package/template/console/shell/types.ts +86 -0
- package/template/console/workflows/CreateWorkflowDialog.tsx +87 -0
- package/template/console/workflows/WorkflowCanvas.tsx +42 -0
- package/template/console/workflows/WorkflowDialogs.tsx +6 -0
- package/template/console/workflows/WorkflowStudio.tsx +331 -0
- package/template/console/workflows/WorkflowWorkspace.tsx +82 -0
- package/template/console/workflows/events.ts +17 -0
- package/template/console/workflows/graph.ts +156 -0
- package/template/console/workflows/templates.ts +70 -0
- package/template/console/workflows/types.ts +201 -0
- package/template/gitignore.template +18 -0
- package/template/migrations/0001_reports_foundation.sql +444 -0
- package/template/migrations/0002_human_report_loop.sql +65 -0
- package/template/migrations/0003_delivery_reliability.sql +18 -0
- package/template/migrations/0004_public_intake.sql +14 -0
- package/template/migrations/0005_operations_visibility.sql +24 -0
- package/template/migrations/0006_ai_governance.sql +198 -0
- package/template/migrations/0007_ai_release_gates.sql +6 -0
- package/template/migrations/0008_retention_analytics_exports.sql +52 -0
- package/template/migrations/0009_retention_derived_copies.sql +14 -0
- package/template/migrations/0010_ai_quality_controls.sql +26 -0
- package/template/migrations/0011_analyst_presence.sql +28 -0
- package/template/migrations/0012_queue_policies.sql +87 -0
- package/template/migrations/0013_routing_agents.sql +63 -0
- package/template/migrations/0014_webhook_enrichments.sql +122 -0
- package/template/migrations/0015_queue_owned_ai.sql +55 -0
- package/template/migrations/0016_operator_accounts.sql +67 -0
- package/template/migrations/0017_operator_profiles_and_recovery.sql +33 -0
- package/template/migrations/0018_platform_configuration.sql +382 -0
- package/template/migrations/0019_workflow_authoring_runtime.sql +372 -0
- package/template/migrations/0020_tasks_findings_assistant_budgets.sql +427 -0
- package/template/migrations/0021_abuse_evidence_operations.sql +324 -0
- package/template/migrations/0022_workflow_dispatch_operations.sql +42 -0
- package/template/migrations/0023_component_connection_execution.sql +75 -0
- package/template/migrations/0024_access_runtime_integrity.sql +72 -0
- package/template/migrations/0025_installation_timezone.sql +11 -0
- package/template/migrations/0026_ai_and_egress_execution_controls.sql +49 -0
- package/template/migrations/0027_prompt_and_ai_registry.sql +37 -0
- package/template/migrations/0028_step_attempt_ai_provenance.sql +13 -0
- package/template/migrations/0029_evidence_fetch_transport.sql +5 -0
- package/template/migrations/0030_evidence_dlq_incidents.sql +45 -0
- package/template/migrations/0031_shadow_quality_integrity.sql +7 -0
- package/template/migrations/0032_action_delivery_outbox.sql +55 -0
- package/template/migrations/0033_configuration_and_assistant_drafts.sql +43 -0
- package/template/migrations/0034_installation_integrations.sql +31 -0
- package/template/migrations/0035_workspace_governance.sql +21 -0
- package/template/migrations/0036_published_routing_baseline.sql +15 -0
- package/template/migrations/0037_builtin_phishing_specialist.sql +71 -0
- package/template/migrations/0038_remove_deprecated_enrichment_runtime.sql +228 -0
- package/template/migrations/0039_secure_reporting_channels.sql +45 -0
- package/template/migrations/0040_notification_only_reporting.sql +24 -0
- package/template/migrations/0041_better_auth_credentials.sql +17 -0
- package/template/package.json +47 -0
- package/template/public/_headers +27 -0
- package/template/public/app-icon-192.png +0 -0
- package/template/public/app-icon-512.png +0 -0
- package/template/public/brand-icon.svg +7 -0
- package/template/public/brand-tokens.css +80 -0
- package/template/public/console/auth-shell.js +19340 -0
- package/template/public/customer-brand.js +58 -0
- package/template/public/embed/embed.css +139 -0
- package/template/public/embed/embed.js +408 -0
- package/template/public/embed/index.html +91 -0
- package/template/public/favicon.svg +7 -0
- package/template/public/fonts/Manrope-Variable.ttf +0 -0
- package/template/public/fonts/Newsreader-Italic-Variable.ttf +0 -0
- package/template/public/fonts/Newsreader-Variable.ttf +0 -0
- package/template/public/index.html +123 -0
- package/template/public/logo-primary.svg +7 -0
- package/template/public/logo-reversed.svg +7 -0
- package/template/public/manifest.webmanifest +21 -0
- package/template/public/public-report.js +184 -0
- package/template/public/report/index.html +39 -0
- package/template/public/report/public-report.css +20 -0
- package/template/public/social-card.png +0 -0
- package/template/public/styles.css +1521 -0
- package/template/public/widget.css +80 -0
- package/template/public/widget.js +220 -0
- package/template/reports.config.example.json +38 -0
- package/template/reports.schema.json +89 -0
- package/template/scripts/reports-auth-onboarding.mjs +180 -0
- package/template/scripts/reports-backup.mjs +276 -0
- package/template/scripts/reports-cloudflare-preflight.mjs +152 -0
- package/template/scripts/reports-deploy.mjs +173 -0
- package/template/scripts/reports-plan.mjs +226 -0
- package/template/scripts/reports-restore.mjs +180 -0
- package/template/scripts/reports-secrets.mjs +73 -0
- package/template/scripts/reports-uninstall-plan.mjs +32 -0
- package/template/src/agent-executor.ts +330 -0
- package/template/src/ai-observability.ts +163 -0
- package/template/src/ai-registry-validation.ts +244 -0
- package/template/src/ai-registry.ts +292 -0
- package/template/src/api-cursor.ts +84 -0
- package/template/src/assistant.ts +554 -0
- package/template/src/audit.ts +39 -0
- package/template/src/backup-service.ts +269 -0
- package/template/src/budget-control.ts +127 -0
- package/template/src/component-executor.ts +845 -0
- package/template/src/configuration-registry.ts +550 -0
- package/template/src/connection-egress.ts +430 -0
- package/template/src/connection-oauth.ts +368 -0
- package/template/src/evidence-service.ts +335 -0
- package/template/src/human-tasks.ts +316 -0
- package/template/src/index.ts +4114 -0
- package/template/src/installation-admin.ts +301 -0
- package/template/src/intake-abuse.ts +156 -0
- package/template/src/platform-registry-validation.ts +367 -0
- package/template/src/platform-registry.ts +753 -0
- package/template/src/report-admin.ts +342 -0
- package/template/src/report-ai-quality.ts +390 -0
- package/template/src/report-ai-validation.ts +80 -0
- package/template/src/report-ai.ts +857 -0
- package/template/src/report-auth.ts +292 -0
- package/template/src/report-better-auth.ts +583 -0
- package/template/src/report-context-schema.ts +132 -0
- package/template/src/report-context.ts +126 -0
- package/template/src/report-crypto.ts +142 -0
- package/template/src/report-delivery.ts +579 -0
- package/template/src/report-form-validation.ts +51 -0
- package/template/src/report-governance.ts +510 -0
- package/template/src/report-http.ts +90 -0
- package/template/src/report-operations.ts +685 -0
- package/template/src/report-operator-accounts.ts +810 -0
- package/template/src/report-presence.ts +398 -0
- package/template/src/report-queue-validation.ts +309 -0
- package/template/src/report-queues.ts +478 -0
- package/template/src/report-repository.ts +972 -0
- package/template/src/report-router-agent.ts +328 -0
- package/template/src/report-router-validation.ts +81 -0
- package/template/src/report-routing.ts +178 -0
- package/template/src/report-turnstile.ts +142 -0
- package/template/src/report-types.ts +224 -0
- package/template/src/report-validation.ts +279 -0
- package/template/src/report-workflow-validation.ts +119 -0
- package/template/src/report-workflow.ts +886 -0
- package/template/src/shadow-quality.ts +278 -0
- package/template/src/workflow-actions.ts +782 -0
- package/template/src/workflow-compiler.ts +230 -0
- package/template/src/workflow-dynamic-runtime.ts +613 -0
- package/template/src/workflow-effects.ts +316 -0
- package/template/src/workflow-expressions.ts +191 -0
- package/template/src/workflow-platform-types.ts +121 -0
- package/template/src/workflow-platform-validation.ts +540 -0
- package/template/src/workflow-repository.ts +916 -0
- package/template/src/workflow-runs.ts +686 -0
- package/template/src/workflow-simulator.ts +211 -0
- package/template/src/workspace-branding.ts +128 -0
- package/template/src/workspace-governance.ts +279 -0
- package/template/tsconfig.console.json +23 -0
- package/template/tsconfig.json +22 -0
- package/template/vite.console.config.ts +20 -0
- package/template/worker-configuration.d.ts +65 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { createRoot } from "react-dom/client";
|
|
2
|
+
import { flushSync } from "react-dom";
|
|
3
|
+
import { AccountJourney } from "./auth/AccountJourney";
|
|
4
|
+
import { AppealDialog, AppealWorkspace } from "./appeals/AppealWorkspace";
|
|
5
|
+
import { startAppealController } from "./appeals/controller";
|
|
6
|
+
import { startCommandController } from "./command/controller";
|
|
7
|
+
import { AnalyticsWorkspace } from "./analytics/AnalyticsWorkspace";
|
|
8
|
+
import { startAnalyticsController } from "./analytics/controller";
|
|
9
|
+
import { AssistantWorkspace } from "./assistant/AssistantWorkspace";
|
|
10
|
+
import { CommandCentre } from "./command/CommandCentre";
|
|
11
|
+
import { ConfigurationDialog } from "./configuration/ConfigurationDialog";
|
|
12
|
+
import { ConfigurationWorkspace } from "./configuration/ConfigurationWorkspace";
|
|
13
|
+
import { QueueEditor } from "./queues/QueueEditor";
|
|
14
|
+
import { QueueWorkspace } from "./queues/QueueWorkspace";
|
|
15
|
+
import { RegistryDialog } from "./registry/RegistryDialog";
|
|
16
|
+
import { RegistryWorkspace } from "./registry/RegistryWorkspace";
|
|
17
|
+
import { QualityDialog, QualityWorkspace } from "./quality/QualityWorkspace";
|
|
18
|
+
import { startQualityController } from "./quality/controller";
|
|
19
|
+
import { startOperationsController } from "./operations/controller";
|
|
20
|
+
import { startQueueController } from "./queues/controller";
|
|
21
|
+
import { startReportController } from "./reports/controller";
|
|
22
|
+
import { OperationsDialog, OperationsWorkspace } from "./operations/OperationsWorkspace";
|
|
23
|
+
import { PeopleWorkspace } from "./people/PeopleWorkspace";
|
|
24
|
+
import { ProfileWorkspace } from "./profile/ProfileWorkspace";
|
|
25
|
+
import { ReportDrawer } from "./reports/ReportDrawer";
|
|
26
|
+
import { ReportWorkspace } from "./reports/ReportWorkspace";
|
|
27
|
+
import { ChannelDialog } from "./settings/ChannelDialog";
|
|
28
|
+
import { SettingsWorkspace } from "./settings/SettingsWorkspace";
|
|
29
|
+
import { WorkspaceShell } from "./shell/WorkspaceShell";
|
|
30
|
+
import { startShellController } from "./shell/controller";
|
|
31
|
+
import { WorkflowDialogs } from "./workflows/WorkflowDialogs";
|
|
32
|
+
import { WorkflowWorkspace } from "./workflows/WorkflowWorkspace";
|
|
33
|
+
|
|
34
|
+
const mount = document.querySelector<HTMLElement>("#operator-login-root");
|
|
35
|
+
|
|
36
|
+
if (mount) {
|
|
37
|
+
const root = createRoot(mount);
|
|
38
|
+
flushSync(() => root.render(<AccountJourney />));
|
|
39
|
+
mount.dataset.reactMounted = "true";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const shellMount = document.querySelector<HTMLElement>("#workspace-shell-root");
|
|
43
|
+
const navigationMount = document.querySelector<HTMLElement>("#workspace-navigation-root");
|
|
44
|
+
|
|
45
|
+
if (shellMount && navigationMount) {
|
|
46
|
+
const shellRoot = createRoot(shellMount);
|
|
47
|
+
flushSync(() => shellRoot.render(<WorkspaceShell navigationTarget={navigationMount} />));
|
|
48
|
+
shellMount.dataset.reactMounted = "true";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const commandMount = document.querySelector<HTMLElement>("#command-centre-root");
|
|
52
|
+
|
|
53
|
+
if (commandMount) {
|
|
54
|
+
const commandRoot = createRoot(commandMount);
|
|
55
|
+
flushSync(() => commandRoot.render(<CommandCentre />));
|
|
56
|
+
commandMount.dataset.reactMounted = "true";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const reportsMount = document.querySelector<HTMLElement>("#report-workspace-root");
|
|
60
|
+
const reportDrawerMount = document.querySelector<HTMLElement>("#report-drawer-root");
|
|
61
|
+
|
|
62
|
+
if (reportsMount && reportDrawerMount) {
|
|
63
|
+
const reportsRoot = createRoot(reportsMount);
|
|
64
|
+
const reportDrawerRoot = createRoot(reportDrawerMount);
|
|
65
|
+
flushSync(() => {
|
|
66
|
+
reportsRoot.render(<ReportWorkspace />);
|
|
67
|
+
reportDrawerRoot.render(<ReportDrawer />);
|
|
68
|
+
});
|
|
69
|
+
reportsMount.dataset.reactMounted = "true";
|
|
70
|
+
reportDrawerMount.dataset.reactMounted = "true";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const queuesMount = document.querySelector<HTMLElement>("#queue-workspace-root");
|
|
74
|
+
const queueEditorMount = document.querySelector<HTMLElement>("#queue-editor-root");
|
|
75
|
+
|
|
76
|
+
if (queuesMount && queueEditorMount) {
|
|
77
|
+
const queuesRoot = createRoot(queuesMount);
|
|
78
|
+
const queueEditorRoot = createRoot(queueEditorMount);
|
|
79
|
+
flushSync(() => {
|
|
80
|
+
queuesRoot.render(<QueueWorkspace />);
|
|
81
|
+
queueEditorRoot.render(<QueueEditor />);
|
|
82
|
+
});
|
|
83
|
+
queuesMount.dataset.reactMounted = "true";
|
|
84
|
+
queueEditorMount.dataset.reactMounted = "true";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const workflowsMount = document.querySelector<HTMLElement>("#workflow-workspace-root");
|
|
88
|
+
const workflowDialogsMount = document.querySelector<HTMLElement>("#workflow-dialogs-root");
|
|
89
|
+
|
|
90
|
+
if (workflowsMount && workflowDialogsMount) {
|
|
91
|
+
const workflowsRoot = createRoot(workflowsMount);
|
|
92
|
+
const workflowDialogsRoot = createRoot(workflowDialogsMount);
|
|
93
|
+
flushSync(() => {
|
|
94
|
+
workflowsRoot.render(<WorkflowWorkspace />);
|
|
95
|
+
workflowDialogsRoot.render(<WorkflowDialogs />);
|
|
96
|
+
});
|
|
97
|
+
workflowsMount.dataset.reactMounted = "true";
|
|
98
|
+
workflowDialogsMount.dataset.reactMounted = "true";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const registryMount = document.querySelector<HTMLElement>("#registry-workspace-root");
|
|
102
|
+
const registryDialogMount = document.querySelector<HTMLElement>("#registry-dialog-root");
|
|
103
|
+
|
|
104
|
+
if (registryMount && registryDialogMount) {
|
|
105
|
+
const registryRoot = createRoot(registryMount);
|
|
106
|
+
const registryDialogRoot = createRoot(registryDialogMount);
|
|
107
|
+
flushSync(() => {
|
|
108
|
+
registryRoot.render(<RegistryWorkspace />);
|
|
109
|
+
registryDialogRoot.render(<RegistryDialog />);
|
|
110
|
+
});
|
|
111
|
+
registryMount.dataset.reactMounted = "true";
|
|
112
|
+
registryDialogMount.dataset.reactMounted = "true";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const configurationMount = document.querySelector<HTMLElement>("#configuration-workspace-root");
|
|
116
|
+
const configurationDialogMount = document.querySelector<HTMLElement>("#configuration-dialog-root");
|
|
117
|
+
|
|
118
|
+
if (configurationMount && configurationDialogMount) {
|
|
119
|
+
const configurationRoot = createRoot(configurationMount);
|
|
120
|
+
const configurationDialogRoot = createRoot(configurationDialogMount);
|
|
121
|
+
flushSync(() => {
|
|
122
|
+
configurationRoot.render(<ConfigurationWorkspace />);
|
|
123
|
+
configurationDialogRoot.render(<ConfigurationDialog />);
|
|
124
|
+
});
|
|
125
|
+
configurationMount.dataset.reactMounted = "true";
|
|
126
|
+
configurationDialogMount.dataset.reactMounted = "true";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const appealsMount = document.querySelector<HTMLElement>("#appeal-workspace-root");
|
|
130
|
+
const appealDialogMount = document.querySelector<HTMLElement>("#appeal-dialog-root");
|
|
131
|
+
|
|
132
|
+
if (appealsMount && appealDialogMount) {
|
|
133
|
+
const appealsRoot = createRoot(appealsMount);
|
|
134
|
+
const appealDialogRoot = createRoot(appealDialogMount);
|
|
135
|
+
flushSync(() => {
|
|
136
|
+
appealsRoot.render(<AppealWorkspace />);
|
|
137
|
+
appealDialogRoot.render(<AppealDialog />);
|
|
138
|
+
});
|
|
139
|
+
appealsMount.dataset.reactMounted = "true";
|
|
140
|
+
appealDialogMount.dataset.reactMounted = "true";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const qualityMount = document.querySelector<HTMLElement>("#quality-workspace-root");
|
|
144
|
+
const qualityDialogMount = document.querySelector<HTMLElement>("#quality-dialog-root");
|
|
145
|
+
|
|
146
|
+
if (qualityMount && qualityDialogMount) {
|
|
147
|
+
const qualityRoot = createRoot(qualityMount);
|
|
148
|
+
const qualityDialogRoot = createRoot(qualityDialogMount);
|
|
149
|
+
flushSync(() => {
|
|
150
|
+
qualityRoot.render(<QualityWorkspace />);
|
|
151
|
+
qualityDialogRoot.render(<QualityDialog />);
|
|
152
|
+
});
|
|
153
|
+
qualityMount.dataset.reactMounted = "true";
|
|
154
|
+
qualityDialogMount.dataset.reactMounted = "true";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const operationsMount = document.querySelector<HTMLElement>("#operations-workspace-root");
|
|
158
|
+
const operationsDialogMount = document.querySelector<HTMLElement>("#operations-dialog-root");
|
|
159
|
+
|
|
160
|
+
if (operationsMount && operationsDialogMount) {
|
|
161
|
+
const operationsRoot = createRoot(operationsMount);
|
|
162
|
+
const operationsDialogRoot = createRoot(operationsDialogMount);
|
|
163
|
+
flushSync(() => {
|
|
164
|
+
operationsRoot.render(<OperationsWorkspace />);
|
|
165
|
+
operationsDialogRoot.render(<OperationsDialog />);
|
|
166
|
+
});
|
|
167
|
+
operationsMount.dataset.reactMounted = "true";
|
|
168
|
+
operationsDialogMount.dataset.reactMounted = "true";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const analyticsMount = document.querySelector<HTMLElement>("#analytics-workspace-root");
|
|
172
|
+
|
|
173
|
+
if (analyticsMount) {
|
|
174
|
+
const analyticsRoot = createRoot(analyticsMount);
|
|
175
|
+
flushSync(() => analyticsRoot.render(<AnalyticsWorkspace />));
|
|
176
|
+
analyticsMount.dataset.reactMounted = "true";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const peopleMount = document.querySelector<HTMLElement>("#people-workspace-root");
|
|
180
|
+
|
|
181
|
+
if (peopleMount) {
|
|
182
|
+
const peopleRoot = createRoot(peopleMount);
|
|
183
|
+
flushSync(() => peopleRoot.render(<PeopleWorkspace />));
|
|
184
|
+
peopleMount.dataset.reactMounted = "true";
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const profileMount = document.querySelector<HTMLElement>("#profile-workspace-root");
|
|
188
|
+
|
|
189
|
+
if (profileMount) {
|
|
190
|
+
const profileRoot = createRoot(profileMount);
|
|
191
|
+
flushSync(() => profileRoot.render(<ProfileWorkspace />));
|
|
192
|
+
profileMount.dataset.reactMounted = "true";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const assistantMount = document.querySelector<HTMLElement>("#assistant-workspace-root");
|
|
196
|
+
|
|
197
|
+
if (assistantMount) {
|
|
198
|
+
const assistantRoot = createRoot(assistantMount);
|
|
199
|
+
flushSync(() => assistantRoot.render(<AssistantWorkspace />));
|
|
200
|
+
assistantMount.dataset.reactMounted = "true";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
startAnalyticsController();
|
|
204
|
+
startAppealController();
|
|
205
|
+
startQualityController();
|
|
206
|
+
startOperationsController();
|
|
207
|
+
startQueueController();
|
|
208
|
+
startReportController();
|
|
209
|
+
startCommandController();
|
|
210
|
+
|
|
211
|
+
const settingsMount = document.querySelector<HTMLElement>("#settings-workspace-root");
|
|
212
|
+
const channelDialogMount = document.querySelector<HTMLElement>("#channel-dialog-root");
|
|
213
|
+
|
|
214
|
+
if (settingsMount && channelDialogMount) {
|
|
215
|
+
const settingsRoot = createRoot(settingsMount);
|
|
216
|
+
const channelDialogRoot = createRoot(channelDialogMount);
|
|
217
|
+
flushSync(() => {
|
|
218
|
+
settingsRoot.render(<SettingsWorkspace />);
|
|
219
|
+
channelDialogRoot.render(<ChannelDialog />);
|
|
220
|
+
});
|
|
221
|
+
settingsMount.dataset.reactMounted = "true";
|
|
222
|
+
channelDialogMount.dataset.reactMounted = "true";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
startShellController();
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, useSyncExternalStore, type FormEvent, type ReactNode } from "react";
|
|
2
|
+
import { PageHeader } from "../components/PageHeader";
|
|
3
|
+
import { readable, relativeTime } from "../lib/format";
|
|
4
|
+
import {
|
|
5
|
+
dispatchAcknowledgeOperationsIncident,
|
|
6
|
+
dispatchCloseOperationsIncident,
|
|
7
|
+
dispatchOpenOperationsIncident,
|
|
8
|
+
dispatchOperationsFilters,
|
|
9
|
+
dispatchOperationsRefresh,
|
|
10
|
+
dispatchResolveOperationsIncident,
|
|
11
|
+
dispatchRetryOperationsIncident,
|
|
12
|
+
} from "./events";
|
|
13
|
+
import { getOperationsWorkspaceSnapshot, subscribeToOperationsWorkspace } from "./store";
|
|
14
|
+
import type { OperationsDeadLetter, OperationsDeliveryFailure, OperationsIncidentSummary, OperationsResolutionInput } from "./types";
|
|
15
|
+
|
|
16
|
+
function amount(value: number | undefined): number { return Number.isFinite(value) ? Number(value) : 0; }
|
|
17
|
+
|
|
18
|
+
function Metric({ label, value, detail, urgent = false }: { label: string; value: number | string; detail: string; urgent?: boolean }) {
|
|
19
|
+
return <article className={urgent ? "urgent" : undefined}><span>{label}</span><strong>{value}</strong><small>{detail}</small></article>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function IncidentRow({ incident }: { incident: OperationsIncidentSummary }) {
|
|
23
|
+
const urgent = ["high", "critical"].includes(incident.severity) && incident.state !== "resolved";
|
|
24
|
+
return (
|
|
25
|
+
<article className="operation-incident-row">
|
|
26
|
+
<div><strong>{incident.title}</strong><small>{readable(incident.category)} · {incident.occurrenceCount} occurrence{incident.occurrenceCount === 1 ? "" : "s"} · {relativeTime(incident.lastSeenAt)}</small></div>
|
|
27
|
+
<span className={`badge${urgent ? " urgent" : ""}`}>{readable(incident.severity)}</span>
|
|
28
|
+
<span className="operation-state">{readable(incident.state)}</span>
|
|
29
|
+
<button className="secondary" type="button" onClick={() => dispatchOpenOperationsIncident(incident.id)}>Inspect</button>
|
|
30
|
+
</article>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function FailureRecord({ item }: { item: OperationsDeadLetter | OperationsDeliveryFailure }) {
|
|
35
|
+
const deadLetter = "jobType" in item;
|
|
36
|
+
const title = deadLetter
|
|
37
|
+
? `${readable(item.jobType)} · ${item.jobId || item.id}`
|
|
38
|
+
: `${readable(item.deliveryType)} · ${item.reportReference}`;
|
|
39
|
+
const summary = deadLetter
|
|
40
|
+
? `Dead-lettered ${relativeTime(item.lastSeenAt)} · ${item.occurrenceCount} occurrence${item.occurrenceCount === 1 ? "" : "s"}`
|
|
41
|
+
: `${item.attemptCount} attempt${item.attemptCount === 1 ? "" : "s"} · ${relativeTime(item.updatedAt)}`;
|
|
42
|
+
return <article><div><strong>{title}</strong><small>{summary}</small></div><span className="badge urgent">{readable(item.errorCode || "Delivery failed")}</span></article>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function OperationsWorkspace() {
|
|
46
|
+
const snapshot = useSyncExternalStore(subscribeToOperationsWorkspace, getOperationsWorkspaceSnapshot, getOperationsWorkspaceSnapshot);
|
|
47
|
+
const operations = snapshot.operations;
|
|
48
|
+
const routingFailures = amount(operations?.routing.failed) + amount(operations?.routing.deadLetter);
|
|
49
|
+
const actionFailures = amount(operations?.actions.retrying) + amount(operations?.actions.terminalFailed);
|
|
50
|
+
const noticeFailures = amount(operations?.notifications.retrying) + amount(operations?.notifications.terminalFailed)
|
|
51
|
+
+ amount(operations?.emails.retrying) + amount(operations?.emails.terminalFailed);
|
|
52
|
+
const openIncidents = amount(operations?.incidents.open) + amount(operations?.incidents.acknowledged);
|
|
53
|
+
const failureRecords = [...(operations?.deadLetters ?? []), ...(operations?.deliveryFailures ?? [])];
|
|
54
|
+
const updateFilters = (key: "state" | "severity", value: string): void => {
|
|
55
|
+
dispatchOperationsFilters({ ...snapshot.filters, [key]: value } as typeof snapshot.filters);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div className="page-stack" data-react-slice="operations-workspace">
|
|
60
|
+
<PageHeader titleId="operations-title" title="Operations" description="Review failed deliveries, exhausted retries, and other incidents.">
|
|
61
|
+
<div className="surface-buttons"><button className="secondary" type="button" disabled={snapshot.refreshing || !snapshot.canRead} onClick={dispatchOperationsRefresh}>{snapshot.refreshing ? "Refreshing…" : "Refresh"}</button></div>
|
|
62
|
+
</PageHeader>
|
|
63
|
+
<section className="surface operations-surface" aria-labelledby="operations-title">
|
|
64
|
+
<div className="operation-metrics" aria-busy={snapshot.loading || snapshot.refreshing}>
|
|
65
|
+
<Metric label="Routing failures" value={snapshot.loading ? "—" : routingFailures} detail="Failed or dead-letter jobs" urgent={routingFailures > 0} />
|
|
66
|
+
<Metric label="Action failures" value={snapshot.loading ? "—" : actionFailures} detail="Retrying or terminal" urgent={actionFailures > 0} />
|
|
67
|
+
<Metric label="Notice failures" value={snapshot.loading ? "—" : noticeFailures} detail="Email and webhook delivery" urgent={noticeFailures > 0} />
|
|
68
|
+
<Metric label="Open incidents" value={snapshot.loading ? "—" : openIncidents} detail={`${amount(operations?.incidents.highPriority)} high priority`} urgent={amount(operations?.incidents.highPriority) > 0} />
|
|
69
|
+
</div>
|
|
70
|
+
<div className="surface-head operations-section-head"><div><h2>Operational incidents</h2><p>Durable records that can be acknowledged, retried, or resolved.</p></div><div className="filters operations-filters"><label>State<select value={snapshot.filters.state} onChange={(event) => updateFilters("state", event.currentTarget.value)}><option value="">All states</option><option value="open">Open</option><option value="acknowledged">Acknowledged</option><option value="mitigated">Mitigated</option><option value="resolved">Resolved</option></select></label><label>Severity<select value={snapshot.filters.severity} onChange={(event) => updateFilters("severity", event.currentTarget.value)}><option value="">All severities</option><option value="critical">Critical</option><option value="high">High</option><option value="medium">Medium</option><option value="low">Low</option><option value="info">Info</option></select></label></div></div>
|
|
71
|
+
<div className="operation-incident-list" aria-busy={snapshot.loading || snapshot.refreshing}>
|
|
72
|
+
{snapshot.loading ? <div className="skeleton-block" role="status" aria-label="Loading operational incidents" /> : snapshot.incidents.length ? snapshot.incidents.map((incident) => <IncidentRow incident={incident} key={incident.id} />) : <div className="empty compact"><span aria-hidden="true">✓</span><h3>No incidents in this view</h3><p>An incident appears here when a failed job needs manual repair.</p></div>}
|
|
73
|
+
</div>
|
|
74
|
+
{failureRecords.length ? <><div className="surface-head operations-section-head"><div><h2>Recent failure records</h2><p>Retry and dead-letter evidence retained for investigation.</p></div></div><div className="operation-list">{failureRecords.map((item) => <FailureRecord item={item} key={`${"jobType" in item ? "dead" : "delivery"}-${item.id}`} />)}</div></> : null}
|
|
75
|
+
<p className="error surface-error" role="alert">{snapshot.error}</p>
|
|
76
|
+
</section>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function Fact({ label, children }: { label: string; children: ReactNode }) {
|
|
82
|
+
return <><dt>{label}</dt><dd>{children || "—"}</dd></>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function OperationsDialog() {
|
|
86
|
+
const snapshot = useSyncExternalStore(subscribeToOperationsWorkspace, getOperationsWorkspaceSnapshot, getOperationsWorkspaceSnapshot);
|
|
87
|
+
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
88
|
+
const [resolutionCode, setResolutionCode] = useState("manual_resolution");
|
|
89
|
+
const [summary, setSummary] = useState("");
|
|
90
|
+
const record = snapshot.detail.record;
|
|
91
|
+
const incident = record?.incident;
|
|
92
|
+
const resolved = incident?.state === "resolved";
|
|
93
|
+
const pending = snapshot.detail.pendingAction;
|
|
94
|
+
const canMutate = snapshot.canOperate && !resolved && !pending;
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
const dialog = dialogRef.current;
|
|
98
|
+
if (!dialog) return;
|
|
99
|
+
if (snapshot.detail.incidentId && !dialog.open) dialog.showModal();
|
|
100
|
+
if (!snapshot.detail.incidentId && dialog.open) dialog.close();
|
|
101
|
+
}, [snapshot.detail.incidentId]);
|
|
102
|
+
useEffect(() => { setResolutionCode("manual_resolution"); setSummary(""); }, [snapshot.detail.incidentId]);
|
|
103
|
+
|
|
104
|
+
const resolve = (event: FormEvent<HTMLFormElement>): void => {
|
|
105
|
+
event.preventDefault();
|
|
106
|
+
const input: OperationsResolutionInput = { resolutionCode, summary };
|
|
107
|
+
dispatchResolveOperationsIncident(input);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<dialog ref={dialogRef} className="detail-dialog operations-dialog" aria-labelledby="operations-incident-title" onCancel={(event) => { event.preventDefault(); dispatchCloseOperationsIncident(); }} data-react-slice="operations-dialog">
|
|
112
|
+
<div className="detail-head"><div><h2 id="operations-incident-title">{incident?.title || "Operational incident"}</h2><p>{incident ? `${readable(incident.severity)} · ${readable(incident.state)}` : snapshot.detail.loading ? "Loading incident…" : "Operations"}</p></div><button className="icon-button" type="button" aria-label="Close operational incident" onClick={dispatchCloseOperationsIncident}>×</button></div>
|
|
113
|
+
<div className="operations-detail-content">
|
|
114
|
+
{snapshot.detail.loading && !record ? <div className="skeleton-block" role="status" aria-label="Loading operational incident" /> : record && incident ? <>
|
|
115
|
+
<p className="operations-summary">{incident.summary}</p>
|
|
116
|
+
<div className="detail-grid"><section><h3>Incident</h3><dl><Fact label="Category">{readable(incident.category)}</Fact><Fact label="Entity">{incident.entityType ? `${readable(incident.entityType)} · ${incident.entityId || "—"}` : "—"}</Fact><Fact label="Occurrences">{incident.occurrenceCount}</Fact><Fact label="First seen">{new Date(incident.firstSeenAt).toLocaleString()}</Fact><Fact label="Last seen">{new Date(incident.lastSeenAt).toLocaleString()}</Fact></dl></section><section><h3>Ownership and resolution</h3><dl><Fact label="Acknowledged by">{incident.acknowledgedBy || "Not acknowledged"}</Fact><Fact label="Acknowledged at">{incident.acknowledgedAt ? new Date(incident.acknowledgedAt).toLocaleString() : "—"}</Fact><Fact label="Resolved by">{incident.resolvedBy || "Not resolved"}</Fact><Fact label="Resolution">{incident.resolutionSummary || "—"}</Fact></dl></section></div>
|
|
117
|
+
{incident.metadata && Object.keys(incident.metadata as object).length ? <section><h3>Diagnostic metadata</h3><pre>{JSON.stringify(incident.metadata, null, 2)}</pre></section> : null}
|
|
118
|
+
<section><h3>Dead-letter records</h3><div className="operation-detail-list">{record.deadLetters.length ? record.deadLetters.map((item) => <article key={item.id}><div><strong>{readable(item.jobType)} · {item.jobId || item.id}</strong><small>{item.occurrenceCount} occurrence{item.occurrenceCount === 1 ? "" : "s"} · {relativeTime(item.lastSeenAt)}</small></div><span className="badge urgent">{readable(item.errorCode || item.state)}</span></article>) : <p>No dead-letter record is attached.</p>}</div></section>
|
|
119
|
+
{record.repairs.length ? <section><h3>Repair history</h3><div className="operation-detail-list">{record.repairs.map((repair) => <article key={repair.id}><div><strong>{readable(repair.actionType)}</strong><small>{readable(repair.entityType)} · {repair.entityId} · {new Date(repair.plannedAt).toLocaleString()}</small></div><span className="badge">{readable(repair.state)}</span></article>)}</div></section> : null}
|
|
120
|
+
{!resolved ? <div className="operations-actions"><div><button className="secondary" type="button" disabled={!canMutate || incident.state !== "open"} onClick={dispatchAcknowledgeOperationsIncident}>{pending === "acknowledge" ? "Acknowledging…" : "Acknowledge"}</button><button className="secondary" type="button" disabled={!canMutate || !record.deadLetters.length} onClick={dispatchRetryOperationsIncident}>{pending === "retry" ? "Retrying…" : "Retry safe record"}</button></div><form onSubmit={resolve}><label>Resolution code<input pattern="[a-z][a-z0-9_]{1,63}" maxLength={64} required disabled={!canMutate} value={resolutionCode} onChange={(event) => setResolutionCode(event.currentTarget.value)} /></label><label>Resolution summary<textarea minLength={3} maxLength={2000} required disabled={!canMutate} value={summary} onChange={(event) => setSummary(event.currentTarget.value)} /></label><button className="primary" type="submit" disabled={!canMutate}>{pending === "resolve" ? "Resolving…" : "Resolve incident"}</button></form></div> : null}
|
|
121
|
+
</> : null}
|
|
122
|
+
<p className="error" role="alert">{snapshot.detail.error}</p>
|
|
123
|
+
</div>
|
|
124
|
+
</dialog>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { errorMessage, mutationHeaders, requestJson } from "../lib/http";
|
|
2
|
+
import { getShellSnapshot, subscribeToShell } from "../shell/store";
|
|
3
|
+
import { operationsEvents } from "./events";
|
|
4
|
+
import { getOperationsWorkspaceSnapshot, setOperationsWorkspaceSnapshot } from "./store";
|
|
5
|
+
import type {
|
|
6
|
+
OperationsFilters,
|
|
7
|
+
OperationsIncidentRecord,
|
|
8
|
+
OperationsIncidentSummary,
|
|
9
|
+
OperationsPendingAction,
|
|
10
|
+
OperationsResolutionInput,
|
|
11
|
+
OperationsSnapshotCounts,
|
|
12
|
+
OperationsWorkspaceSnapshot,
|
|
13
|
+
} from "./types";
|
|
14
|
+
|
|
15
|
+
interface OperationsResponse { operations?: OperationsSnapshotCounts | null }
|
|
16
|
+
interface IncidentListResponse { incidents?: OperationsIncidentSummary[] }
|
|
17
|
+
|
|
18
|
+
const initialSnapshot = (): OperationsWorkspaceSnapshot => ({
|
|
19
|
+
loading: true,
|
|
20
|
+
refreshing: false,
|
|
21
|
+
error: "",
|
|
22
|
+
canRead: false,
|
|
23
|
+
canOperate: false,
|
|
24
|
+
operations: null,
|
|
25
|
+
incidents: [],
|
|
26
|
+
filters: { state: "", severity: "" },
|
|
27
|
+
detail: { incidentId: null, loading: false, error: "", pendingAction: null, record: null },
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
let actorId = "";
|
|
31
|
+
let requestVersion = 0;
|
|
32
|
+
let started = false;
|
|
33
|
+
|
|
34
|
+
function permissions(snapshot: OperationsWorkspaceSnapshot): OperationsWorkspaceSnapshot {
|
|
35
|
+
const session = getShellSnapshot().session;
|
|
36
|
+
return {
|
|
37
|
+
...snapshot,
|
|
38
|
+
canRead: session?.permissions.readOperations === true,
|
|
39
|
+
canOperate: session?.permissions.operateSystem === true,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function recordValue(value: unknown): Record<string, unknown> {
|
|
44
|
+
return value && typeof value === "object" ? value as Record<string, unknown> : {};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function stringValue(value: unknown): string {
|
|
48
|
+
return typeof value === "string" ? value : "";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function nullableString(value: unknown): string | null {
|
|
52
|
+
return typeof value === "string" ? value : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeIncident(value: unknown): OperationsIncidentSummary {
|
|
56
|
+
const incident = recordValue(value);
|
|
57
|
+
return {
|
|
58
|
+
id: stringValue(incident.id),
|
|
59
|
+
incidentKey: stringValue(incident.incidentKey ?? incident.incident_key),
|
|
60
|
+
severity: stringValue(incident.severity),
|
|
61
|
+
category: stringValue(incident.category),
|
|
62
|
+
state: stringValue(incident.state),
|
|
63
|
+
title: stringValue(incident.title),
|
|
64
|
+
summary: stringValue(incident.summary),
|
|
65
|
+
entityType: nullableString(incident.entityType ?? incident.entity_type),
|
|
66
|
+
entityId: nullableString(incident.entityId ?? incident.entity_id),
|
|
67
|
+
occurrenceCount: Number(incident.occurrenceCount ?? incident.occurrence_count ?? 0),
|
|
68
|
+
firstSeenAt: stringValue(incident.firstSeenAt ?? incident.first_seen_at),
|
|
69
|
+
lastSeenAt: stringValue(incident.lastSeenAt ?? incident.last_seen_at),
|
|
70
|
+
acknowledgedBy: nullableString(incident.acknowledgedBy ?? incident.acknowledged_by),
|
|
71
|
+
acknowledgedAt: nullableString(incident.acknowledgedAt ?? incident.acknowledged_at),
|
|
72
|
+
resolvedBy: nullableString(incident.resolvedBy ?? incident.resolved_by),
|
|
73
|
+
resolvedAt: nullableString(incident.resolvedAt ?? incident.resolved_at),
|
|
74
|
+
resolutionSummary: nullableString(incident.resolutionSummary ?? incident.resolution_summary),
|
|
75
|
+
metadata: incident.metadata ?? {},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normalizeIncidentRecord(value: unknown): OperationsIncidentRecord {
|
|
80
|
+
const result = recordValue(value);
|
|
81
|
+
const deadLetters = Array.isArray(result.deadLetters) ? result.deadLetters : [];
|
|
82
|
+
const repairs = Array.isArray(result.repairs) ? result.repairs : [];
|
|
83
|
+
return {
|
|
84
|
+
incident: normalizeIncident(result.incident),
|
|
85
|
+
deadLetters: deadLetters.map((entry) => {
|
|
86
|
+
const item = recordValue(entry);
|
|
87
|
+
return {
|
|
88
|
+
id: stringValue(item.id),
|
|
89
|
+
jobType: stringValue(item.jobType ?? item.job_type),
|
|
90
|
+
jobId: nullableString(item.jobId ?? item.job_id),
|
|
91
|
+
state: stringValue(item.state),
|
|
92
|
+
errorCode: nullableString(item.errorCode ?? item.error_code),
|
|
93
|
+
occurrenceCount: Number(item.occurrenceCount ?? item.occurrence_count ?? 0),
|
|
94
|
+
lastSeenAt: stringValue(item.lastSeenAt ?? item.last_seen_at),
|
|
95
|
+
};
|
|
96
|
+
}),
|
|
97
|
+
repairs: repairs.map((entry) => {
|
|
98
|
+
const item = recordValue(entry);
|
|
99
|
+
return {
|
|
100
|
+
id: stringValue(item.id),
|
|
101
|
+
actionType: stringValue(item.actionType ?? item.action_type),
|
|
102
|
+
entityType: stringValue(item.entityType ?? item.entity_type),
|
|
103
|
+
entityId: stringValue(item.entityId ?? item.entity_id),
|
|
104
|
+
state: stringValue(item.state),
|
|
105
|
+
plannedBy: nullableString(item.plannedBy ?? item.planned_by),
|
|
106
|
+
plannedAt: stringValue(item.plannedAt ?? item.planned_at),
|
|
107
|
+
appliedAt: nullableString(item.appliedAt ?? item.applied_at),
|
|
108
|
+
errorCode: nullableString(item.errorCode ?? item.error_code),
|
|
109
|
+
};
|
|
110
|
+
}),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function loadOperations(): Promise<void> {
|
|
115
|
+
const session = getShellSnapshot().session;
|
|
116
|
+
if (!session?.permissions.readOperations) {
|
|
117
|
+
setOperationsWorkspaceSnapshot(permissions({ ...initialSnapshot(), loading: false }));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const current = getOperationsWorkspaceSnapshot();
|
|
121
|
+
setOperationsWorkspaceSnapshot(permissions({ ...current, error: "", refreshing: !current.loading }));
|
|
122
|
+
const query = new URLSearchParams({ limit: "100" });
|
|
123
|
+
if (current.filters.state) query.set("state", current.filters.state);
|
|
124
|
+
if (current.filters.severity) query.set("severity", current.filters.severity);
|
|
125
|
+
try {
|
|
126
|
+
const [snapshotResponse, incidentResponse] = await Promise.all([
|
|
127
|
+
requestJson<OperationsResponse>("/v1/admin/operations"),
|
|
128
|
+
requestJson<IncidentListResponse>(`/v1/admin/operations/incidents?${query}`),
|
|
129
|
+
]);
|
|
130
|
+
setOperationsWorkspaceSnapshot((snapshot) => permissions({
|
|
131
|
+
...snapshot,
|
|
132
|
+
operations: snapshotResponse.operations ?? null,
|
|
133
|
+
incidents: (incidentResponse.incidents ?? []).map(normalizeIncident),
|
|
134
|
+
}));
|
|
135
|
+
} catch (cause) {
|
|
136
|
+
setOperationsWorkspaceSnapshot((snapshot) => ({ ...snapshot, error: errorMessage(cause, "Operations could not be loaded.") }));
|
|
137
|
+
} finally {
|
|
138
|
+
setOperationsWorkspaceSnapshot((snapshot) => permissions({ ...snapshot, loading: false, refreshing: false }));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function openIncident(incidentId: string): Promise<void> {
|
|
143
|
+
const version = ++requestVersion;
|
|
144
|
+
setOperationsWorkspaceSnapshot((snapshot) => permissions({
|
|
145
|
+
...snapshot,
|
|
146
|
+
detail: {
|
|
147
|
+
...snapshot.detail,
|
|
148
|
+
incidentId,
|
|
149
|
+
loading: true,
|
|
150
|
+
error: "",
|
|
151
|
+
record: snapshot.detail.record?.incident.id === incidentId ? snapshot.detail.record : null,
|
|
152
|
+
},
|
|
153
|
+
}));
|
|
154
|
+
try {
|
|
155
|
+
const response = await requestJson<unknown>(`/v1/admin/operations/incidents/${encodeURIComponent(incidentId)}`);
|
|
156
|
+
if (version !== requestVersion || getOperationsWorkspaceSnapshot().detail.incidentId !== incidentId) return;
|
|
157
|
+
setOperationsWorkspaceSnapshot((snapshot) => permissions({ ...snapshot, detail: { ...snapshot.detail, record: normalizeIncidentRecord(response) } }));
|
|
158
|
+
} catch (cause) {
|
|
159
|
+
if (version !== requestVersion || getOperationsWorkspaceSnapshot().detail.incidentId !== incidentId) return;
|
|
160
|
+
setOperationsWorkspaceSnapshot((snapshot) => ({ ...snapshot, detail: { ...snapshot.detail, error: errorMessage(cause, "The operational incident could not be opened.") } }));
|
|
161
|
+
} finally {
|
|
162
|
+
if (version === requestVersion && getOperationsWorkspaceSnapshot().detail.incidentId === incidentId) {
|
|
163
|
+
setOperationsWorkspaceSnapshot((snapshot) => permissions({ ...snapshot, detail: { ...snapshot.detail, loading: false } }));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function closeIncident(): void {
|
|
169
|
+
requestVersion += 1;
|
|
170
|
+
setOperationsWorkspaceSnapshot((snapshot) => permissions({
|
|
171
|
+
...snapshot,
|
|
172
|
+
detail: { incidentId: null, loading: false, error: "", pendingAction: null, record: null },
|
|
173
|
+
}));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function mutateIncident(action: Exclude<OperationsPendingAction, null>, input?: OperationsResolutionInput): Promise<void> {
|
|
177
|
+
const snapshot = getOperationsWorkspaceSnapshot();
|
|
178
|
+
const incidentId = snapshot.detail.incidentId;
|
|
179
|
+
if (!incidentId || !snapshot.canOperate || snapshot.detail.pendingAction) return;
|
|
180
|
+
setOperationsWorkspaceSnapshot({ ...snapshot, detail: { ...snapshot.detail, error: "", pendingAction: action } });
|
|
181
|
+
try {
|
|
182
|
+
await requestJson(`/v1/admin/operations/incidents/${encodeURIComponent(incidentId)}/${action}`, {
|
|
183
|
+
method: "POST",
|
|
184
|
+
headers: mutationHeaders(action === "resolve"),
|
|
185
|
+
...(action === "resolve" && input ? { body: JSON.stringify({ resolution_code: input.resolutionCode, summary: input.summary }) } : {}),
|
|
186
|
+
});
|
|
187
|
+
await loadOperations();
|
|
188
|
+
await openIncident(incidentId);
|
|
189
|
+
} catch (cause) {
|
|
190
|
+
const fallback = action === "acknowledge" ? "The incident could not be acknowledged."
|
|
191
|
+
: action === "retry" ? "The incident could not be retried."
|
|
192
|
+
: "The incident could not be resolved.";
|
|
193
|
+
setOperationsWorkspaceSnapshot((current) => ({ ...current, detail: { ...current.detail, error: errorMessage(cause, fallback) } }));
|
|
194
|
+
} finally {
|
|
195
|
+
setOperationsWorkspaceSnapshot((current) => permissions({ ...current, detail: { ...current.detail, pendingAction: null } }));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function validFilters(value: unknown): value is OperationsFilters {
|
|
200
|
+
if (!value || typeof value !== "object") return false;
|
|
201
|
+
const filters = value as Partial<OperationsFilters>;
|
|
202
|
+
return ["", "open", "acknowledged", "mitigated", "resolved"].includes(filters.state ?? "invalid")
|
|
203
|
+
&& ["", "info", "low", "medium", "high", "critical"].includes(filters.severity ?? "invalid");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function startOperationsController(): void {
|
|
207
|
+
if (started) return;
|
|
208
|
+
started = true;
|
|
209
|
+
subscribeToShell(() => {
|
|
210
|
+
const session = getShellSnapshot().session;
|
|
211
|
+
const nextActorId = session?.actor.id || "";
|
|
212
|
+
if (nextActorId === actorId) {
|
|
213
|
+
setOperationsWorkspaceSnapshot((snapshot) => permissions(snapshot));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
actorId = nextActorId;
|
|
217
|
+
requestVersion += 1;
|
|
218
|
+
setOperationsWorkspaceSnapshot(initialSnapshot());
|
|
219
|
+
if (session) void loadOperations();
|
|
220
|
+
});
|
|
221
|
+
window.addEventListener(operationsEvents.refresh, () => { void loadOperations(); });
|
|
222
|
+
window.addEventListener(operationsEvents.filters, (event) => {
|
|
223
|
+
if (!(event instanceof CustomEvent) || !validFilters(event.detail)) return;
|
|
224
|
+
setOperationsWorkspaceSnapshot((snapshot) => ({ ...snapshot, filters: event.detail }));
|
|
225
|
+
void loadOperations();
|
|
226
|
+
});
|
|
227
|
+
window.addEventListener(operationsEvents.open, (event) => {
|
|
228
|
+
if (event instanceof CustomEvent && typeof event.detail?.incidentId === "string") void openIncident(event.detail.incidentId);
|
|
229
|
+
});
|
|
230
|
+
window.addEventListener(operationsEvents.close, closeIncident);
|
|
231
|
+
window.addEventListener(operationsEvents.acknowledge, () => { void mutateIncident("acknowledge"); });
|
|
232
|
+
window.addEventListener(operationsEvents.retry, () => { void mutateIncident("retry"); });
|
|
233
|
+
window.addEventListener(operationsEvents.resolve, (event) => {
|
|
234
|
+
if (!(event instanceof CustomEvent) || typeof event.detail?.resolutionCode !== "string" || typeof event.detail?.summary !== "string") return;
|
|
235
|
+
void mutateIncident("resolve", event.detail as OperationsResolutionInput);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { OperationsFilters, OperationsResolutionInput, OperationsWorkspaceSnapshot } from "./types";
|
|
2
|
+
|
|
3
|
+
export const operationsEvents = {
|
|
4
|
+
state: "safest:operations:state",
|
|
5
|
+
refresh: "safest:operations:refresh",
|
|
6
|
+
filters: "safest:operations:filters",
|
|
7
|
+
open: "safest:operations:open",
|
|
8
|
+
close: "safest:operations:close",
|
|
9
|
+
acknowledge: "safest:operations:acknowledge",
|
|
10
|
+
retry: "safest:operations:retry",
|
|
11
|
+
resolve: "safest:operations:resolve",
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
function dispatch<TDetail>(type: string, detail?: TDetail): void {
|
|
15
|
+
window.dispatchEvent(detail === undefined ? new Event(type) : new CustomEvent(type, { detail }));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function dispatchOperationsRefresh(): void { dispatch(operationsEvents.refresh); }
|
|
19
|
+
export function dispatchOperationsFilters(filters: OperationsFilters): void { dispatch(operationsEvents.filters, filters); }
|
|
20
|
+
export function dispatchOpenOperationsIncident(incidentId: string): void { dispatch(operationsEvents.open, { incidentId }); }
|
|
21
|
+
export function dispatchCloseOperationsIncident(): void { dispatch(operationsEvents.close); }
|
|
22
|
+
export function dispatchAcknowledgeOperationsIncident(): void { dispatch(operationsEvents.acknowledge); }
|
|
23
|
+
export function dispatchRetryOperationsIncident(): void { dispatch(operationsEvents.retry); }
|
|
24
|
+
export function dispatchResolveOperationsIncident(input: OperationsResolutionInput): void { dispatch(operationsEvents.resolve, input); }
|
|
25
|
+
|
|
26
|
+
export function isOperationsWorkspaceSnapshot(value: unknown): value is OperationsWorkspaceSnapshot {
|
|
27
|
+
if (!value || typeof value !== "object") return false;
|
|
28
|
+
const candidate = value as Partial<OperationsWorkspaceSnapshot>;
|
|
29
|
+
return typeof candidate.loading === "boolean"
|
|
30
|
+
&& typeof candidate.refreshing === "boolean"
|
|
31
|
+
&& typeof candidate.error === "string"
|
|
32
|
+
&& typeof candidate.canRead === "boolean"
|
|
33
|
+
&& typeof candidate.canOperate === "boolean"
|
|
34
|
+
&& Array.isArray(candidate.incidents)
|
|
35
|
+
&& typeof candidate.filters === "object"
|
|
36
|
+
&& typeof candidate.detail === "object";
|
|
37
|
+
}
|