@karmaniverous/jeeves-server 3.0.0-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/.env.local +13 -0
- package/.env.local.template +13 -0
- package/.tsbuildinfo +1 -0
- package/CHANGELOG.md +450 -0
- package/about.md +82 -0
- package/client/README.md +73 -0
- package/client/eslint.config.js +23 -0
- package/client/index.html +14 -0
- package/client/package-lock.json +5181 -0
- package/client/package.json +60 -0
- package/client/public/vite.svg +1 -0
- package/client/src/App.tsx +22 -0
- package/client/src/components/AccountMenu.tsx +167 -0
- package/client/src/components/ActionDropdown.tsx +120 -0
- package/client/src/components/CodeEditor.tsx +143 -0
- package/client/src/components/CodeViewer.tsx +113 -0
- package/client/src/components/ConfirmDialog.tsx +32 -0
- package/client/src/components/DirectoryRow.tsx +62 -0
- package/client/src/components/DirectoryTable.tsx +42 -0
- package/client/src/components/DownloadDropdown.tsx +116 -0
- package/client/src/components/DriveList.tsx +54 -0
- package/client/src/components/EmbeddedDiagramPanzoom.ts +28 -0
- package/client/src/components/FileContentView.tsx +155 -0
- package/client/src/components/InlineSvgPanzoom.ts +60 -0
- package/client/src/components/LazyDiagram.ts +93 -0
- package/client/src/components/LinkDropdown.tsx +134 -0
- package/client/src/components/MarkdownView.tsx +115 -0
- package/client/src/components/MermaidViewer.tsx +21 -0
- package/client/src/components/PlantUmlViewer.tsx +21 -0
- package/client/src/components/SearchModal.tsx +424 -0
- package/client/src/components/SvgViewer.tsx +107 -0
- package/client/src/components/TabBar.tsx +96 -0
- package/client/src/components/layout/Header.tsx +270 -0
- package/client/src/components/panzoom.ts +203 -0
- package/client/src/components/renderableUtils.ts +15 -0
- package/client/src/components/runner/JobTable.tsx +153 -0
- package/client/src/components/runner/RunHistory.tsx +140 -0
- package/client/src/components/runner/StatsBar.tsx +43 -0
- package/client/src/components/runner/StatusPill.tsx +27 -0
- package/client/src/components/runner/jobTableUtils.ts +65 -0
- package/client/src/components/scrollUtils.ts +39 -0
- package/client/src/components/ui/alert-dialog.tsx +107 -0
- package/client/src/components/ui/button.tsx +40 -0
- package/client/src/components/ui/dropdown-menu.tsx +79 -0
- package/client/src/components/ui/input.tsx +26 -0
- package/client/src/components/useActionState.ts +43 -0
- package/client/src/hooks/useFileBrowser.ts +102 -0
- package/client/src/hooks/useFileData.ts +78 -0
- package/client/src/hooks/useScrollAnchor.ts +70 -0
- package/client/src/hooks/useShareSettings.ts +22 -0
- package/client/src/hooks/useTopBar.ts +27 -0
- package/client/src/index.css +281 -0
- package/client/src/lib/AuthContext.ts +27 -0
- package/client/src/lib/api.ts +239 -0
- package/client/src/lib/auth.tsx +50 -0
- package/client/src/lib/codeBlockCm6.ts +129 -0
- package/client/src/lib/codeBlockCopy.ts +43 -0
- package/client/src/lib/codemirror.ts +77 -0
- package/client/src/lib/runner-api.ts +172 -0
- package/client/src/lib/svg.ts +50 -0
- package/client/src/lib/theme.ts +34 -0
- package/client/src/lib/utils.ts +6 -0
- package/client/src/main.tsx +11 -0
- package/client/src/pages/FileBrowser.tsx +135 -0
- package/client/src/pages/Home.tsx +46 -0
- package/client/src/pages/Runner.tsx +151 -0
- package/client/src/pages/RunnerJob.tsx +170 -0
- package/client/tsconfig.app.json +32 -0
- package/client/tsconfig.json +7 -0
- package/client/tsconfig.node.json +26 -0
- package/client/vite.config.ts +35 -0
- package/content/privacy.md +61 -0
- package/content/terms.md +41 -0
- package/dist/client/assets/CodeEditor-0XHVI8Nu.js +1 -0
- package/dist/client/assets/CodeViewer-CykMVsfX.js +1 -0
- package/dist/client/assets/index--MBieNJA.js +1 -0
- package/dist/client/assets/index-BENeXQI_.js +1 -0
- package/dist/client/assets/index-BbBpoOxz.js +1 -0
- package/dist/client/assets/index-BdV9g5AM.js +6 -0
- package/dist/client/assets/index-BjAilRri.js +2 -0
- package/dist/client/assets/index-BqbhWo2I.js +3 -0
- package/dist/client/assets/index-CVbycZ0H.js +1 -0
- package/dist/client/assets/index-Cs5oz2oJ.js +5 -0
- package/dist/client/assets/index-D8KZVveX.js +1 -0
- package/dist/client/assets/index-DC4HMHxY.js +13 -0
- package/dist/client/assets/index-DbMebkkd.css +1 -0
- package/dist/client/assets/index-DcY2RXqX.js +1 -0
- package/dist/client/assets/index-Duy-tZYV.js +1 -0
- package/dist/client/assets/index-Dw7rDFmE.js +7 -0
- package/dist/client/assets/index-FlCUvrjv.js +2 -0
- package/dist/client/assets/index-K6OVmfhg.js +1 -0
- package/dist/client/assets/index-LjwgzZ7F.js +62 -0
- package/dist/client/assets/index-MLwyFRN0.js +1 -0
- package/dist/client/assets/index-OpqBpSjn.js +1 -0
- package/dist/client/assets/index-SsHei0HE.js +1 -0
- package/dist/client/assets/index-uQa2yckk.js +1 -0
- package/dist/client/assets/index-udkXoIER.js +1 -0
- package/dist/client/index.html +15 -0
- package/dist/client/vite.svg +1 -0
- package/dist/src/auth/google.js +57 -0
- package/dist/src/auth/keys.js +185 -0
- package/dist/src/auth/resolve.js +102 -0
- package/dist/src/auth/session.js +57 -0
- package/dist/src/cli/commands/config.js +100 -0
- package/dist/src/cli/commands/config.test.js +84 -0
- package/dist/src/cli/commands/service.js +93 -0
- package/dist/src/cli/commands/start.js +24 -0
- package/dist/src/cli/index.js +20 -0
- package/dist/src/config/index.js +90 -0
- package/dist/src/config/loadConfig.test.js +127 -0
- package/dist/src/config/resolve.js +134 -0
- package/dist/src/config/resolve.test.js +148 -0
- package/dist/src/config/schema.js +159 -0
- package/dist/src/config/substituteEnvVars.js +45 -0
- package/dist/src/config/substituteEnvVars.test.js +51 -0
- package/dist/src/config/types.js +5 -0
- package/dist/src/routes/api/auth-status.js +56 -0
- package/dist/src/routes/api/diagrams.js +35 -0
- package/dist/src/routes/api/directory.js +93 -0
- package/dist/src/routes/api/drives.js +15 -0
- package/dist/src/routes/api/export.js +218 -0
- package/dist/src/routes/api/fileContent.js +286 -0
- package/dist/src/routes/api/index.js +33 -0
- package/dist/src/routes/api/linkInfo.js +71 -0
- package/dist/src/routes/api/linkInfo.test.js +104 -0
- package/dist/src/routes/api/middleware.js +117 -0
- package/dist/src/routes/api/raw.js +38 -0
- package/dist/src/routes/api/runner.js +59 -0
- package/dist/src/routes/api/search.js +236 -0
- package/dist/src/routes/api/sharing.js +203 -0
- package/dist/src/routes/api/status.js +68 -0
- package/dist/src/routes/api/status.test.js +62 -0
- package/dist/src/routes/auth.js +99 -0
- package/dist/src/routes/event.js +77 -0
- package/dist/src/routes/event.test.js +206 -0
- package/dist/src/routes/health.js +10 -0
- package/dist/src/routes/keys.js +129 -0
- package/dist/src/routes/path/index.js +17 -0
- package/dist/src/routes/static.js +30 -0
- package/dist/src/server.js +90 -0
- package/dist/src/services/deepShareLinks.js +163 -0
- package/dist/src/services/diagramCache.js +104 -0
- package/dist/src/services/embeddedDiagrams.js +136 -0
- package/dist/src/services/eventLog.js +55 -0
- package/dist/src/services/eventLog.test.js +113 -0
- package/dist/src/services/eventQueue.js +154 -0
- package/dist/src/services/eventQueue.test.js +104 -0
- package/dist/src/services/export.js +220 -0
- package/dist/src/services/exportCache.js +196 -0
- package/dist/src/services/markdown.js +147 -0
- package/dist/src/services/mermaid.js +97 -0
- package/dist/src/services/plantuml.js +145 -0
- package/dist/src/services/puppeteer.js +156 -0
- package/dist/src/util/breadcrumbs.js +22 -0
- package/dist/src/util/crypto.js +56 -0
- package/dist/src/util/crypto.test.js +99 -0
- package/dist/src/util/fileDetection.js +66 -0
- package/dist/src/util/fileDetection.test.js +89 -0
- package/dist/src/util/formatters.js +43 -0
- package/dist/src/util/formatters.test.js +83 -0
- package/dist/src/util/packageVersion.js +25 -0
- package/dist/src/util/platform.js +148 -0
- package/dist/src/util/state.js +46 -0
- package/dist/vitest.config.js +12 -0
- package/favicon.svg +3 -0
- package/guides/access-decision-flow.mmd +24 -0
- package/guides/access-decision-flow.svg +1 -0
- package/guides/api-integration.md +236 -0
- package/guides/deployment.md +287 -0
- package/guides/event-gateway.md +204 -0
- package/guides/event-gateway.mmd +17 -0
- package/guides/event-gateway.svg +1 -0
- package/guides/exports.md +239 -0
- package/guides/setup.md +313 -0
- package/guides/sharing.md +204 -0
- package/jeeves-server.config.template.json +25 -0
- package/package.json +124 -0
- package/scripts/download-plantuml.js +70 -0
- package/src/auth/google.ts +93 -0
- package/src/auth/keys.ts +252 -0
- package/src/auth/resolve.ts +157 -0
- package/src/auth/session.ts +77 -0
- package/src/cli/commands/config.test.ts +107 -0
- package/src/cli/commands/config.ts +113 -0
- package/src/cli/commands/service.ts +129 -0
- package/src/cli/commands/start.ts +27 -0
- package/src/cli/index.ts +25 -0
- package/src/config/index.ts +113 -0
- package/src/config/loadConfig.test.ts +155 -0
- package/src/config/resolve.test.ts +192 -0
- package/src/config/resolve.ts +173 -0
- package/src/config/schema.ts +179 -0
- package/src/config/substituteEnvVars.test.ts +64 -0
- package/src/config/substituteEnvVars.ts +52 -0
- package/src/config/types.ts +129 -0
- package/src/routes/api/auth-status.ts +85 -0
- package/src/routes/api/diagrams.ts +53 -0
- package/src/routes/api/directory.ts +123 -0
- package/src/routes/api/drives.ts +23 -0
- package/src/routes/api/export.ts +314 -0
- package/src/routes/api/fileContent.ts +414 -0
- package/src/routes/api/index.ts +37 -0
- package/src/routes/api/linkInfo.test.ts +132 -0
- package/src/routes/api/linkInfo.ts +83 -0
- package/src/routes/api/middleware.ts +156 -0
- package/src/routes/api/raw.ts +54 -0
- package/src/routes/api/runner.ts +107 -0
- package/src/routes/api/search.ts +321 -0
- package/src/routes/api/sharing.ts +259 -0
- package/src/routes/api/status.test.ts +72 -0
- package/src/routes/api/status.ts +82 -0
- package/src/routes/auth.ts +143 -0
- package/src/routes/event.test.ts +248 -0
- package/src/routes/event.ts +109 -0
- package/src/routes/health.ts +13 -0
- package/src/routes/keys.ts +192 -0
- package/src/routes/path/index.ts +24 -0
- package/src/routes/static.ts +54 -0
- package/src/server.ts +104 -0
- package/src/services/deepShareLinks.ts +203 -0
- package/src/services/diagramCache.ts +128 -0
- package/src/services/embeddedDiagrams.ts +168 -0
- package/src/services/eventLog.test.ts +144 -0
- package/src/services/eventLog.ts +68 -0
- package/src/services/eventQueue.test.ts +127 -0
- package/src/services/eventQueue.ts +196 -0
- package/src/services/export.ts +267 -0
- package/src/services/exportCache.ts +216 -0
- package/src/services/markdown.ts +189 -0
- package/src/services/mermaid.ts +113 -0
- package/src/services/plantuml.ts +172 -0
- package/src/services/puppeteer.ts +188 -0
- package/src/types/fastify.d.ts +13 -0
- package/src/types/jsonmap.d.ts +10 -0
- package/src/types/plantuml-encoder.d.ts +4 -0
- package/src/util/breadcrumbs.ts +33 -0
- package/src/util/crypto.test.ts +132 -0
- package/src/util/crypto.ts +79 -0
- package/src/util/fileDetection.test.ts +115 -0
- package/src/util/fileDetection.ts +70 -0
- package/src/util/formatters.test.ts +105 -0
- package/src/util/formatters.ts +44 -0
- package/src/util/packageVersion.ts +30 -0
- package/src/util/platform.ts +178 -0
- package/src/util/state.ts +55 -0
- package/test-docs/diagram-retry-test.md +18 -0
- package/test-docs/embedded-diagrams.md +52 -0
- package/test-docs/lazy-diagrams-test.md +333 -0
- package/test-docs/page-a.md +7 -0
- package/test-docs/page-b.md +7 -0
- package/test-docs/page-c.md +7 -0
- package/test-docs/sub/page-d.md +7 -0
- package/test-docs/test-diagram.puml +13 -0
- package/test-docs/validate-deep-share.js +318 -0
- package/tsconfig.json +37 -0
- package/tsdoc.json +13 -0
- package/vendor/.plantuml-version +1 -0
- package/vendor/plantuml.jar +0 -0
- package/vitest.config.js +12 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run history table with expandable stdout/stderr for job detail view.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { ChevronDown, ChevronRight } from 'lucide-react';
|
|
6
|
+
import { useState } from 'react';
|
|
7
|
+
|
|
8
|
+
import type { RunEntry } from '@/lib/runner-api';
|
|
9
|
+
|
|
10
|
+
import { StatusPill } from './StatusPill';
|
|
11
|
+
|
|
12
|
+
interface RunHistoryProps {
|
|
13
|
+
runs: RunEntry[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function formatDuration(ms: number | null): string {
|
|
17
|
+
if (ms === null) return '—';
|
|
18
|
+
if (ms < 1000) return `${String(ms)}ms`;
|
|
19
|
+
const s = ms / 1000;
|
|
20
|
+
if (s < 60) return `${s.toFixed(1)}s`;
|
|
21
|
+
const m = Math.floor(s / 60);
|
|
22
|
+
const rem = Math.round(s % 60);
|
|
23
|
+
return `${String(m)}m ${String(rem)}s`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function formatTime(iso: string | null): string {
|
|
27
|
+
if (!iso) return '—';
|
|
28
|
+
try {
|
|
29
|
+
return new Date(iso).toLocaleString();
|
|
30
|
+
} catch {
|
|
31
|
+
return iso;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function RunHistory({ runs }: RunHistoryProps) {
|
|
36
|
+
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
37
|
+
|
|
38
|
+
const toggle = (id: number) => {
|
|
39
|
+
const key = String(id);
|
|
40
|
+
setExpanded((prev) => {
|
|
41
|
+
const next = new Set(prev);
|
|
42
|
+
if (next.has(key)) next.delete(key);
|
|
43
|
+
else next.add(key);
|
|
44
|
+
return next;
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
if (runs.length === 0) {
|
|
49
|
+
return (
|
|
50
|
+
<div className="text-center py-6 text-muted-foreground text-sm">
|
|
51
|
+
No runs yet
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className="overflow-x-auto">
|
|
58
|
+
<table className="w-full text-sm">
|
|
59
|
+
<thead>
|
|
60
|
+
<tr className="border-b border-border text-left">
|
|
61
|
+
<th className="px-3 py-2 w-8" />
|
|
62
|
+
<th className="px-3 py-2 font-medium text-muted-foreground">Status</th>
|
|
63
|
+
<th className="px-3 py-2 font-medium text-muted-foreground">Trigger</th>
|
|
64
|
+
<th className="px-3 py-2 font-medium text-muted-foreground">Started</th>
|
|
65
|
+
<th className="px-3 py-2 font-medium text-muted-foreground">Duration</th>
|
|
66
|
+
<th className="px-3 py-2 font-medium text-muted-foreground">Exit</th>
|
|
67
|
+
</tr>
|
|
68
|
+
</thead>
|
|
69
|
+
<tbody>
|
|
70
|
+
{runs.map((run) => (
|
|
71
|
+
<RunRow
|
|
72
|
+
key={run.id}
|
|
73
|
+
run={run}
|
|
74
|
+
isExpanded={expanded.has(String(run.id))}
|
|
75
|
+
onToggle={() => toggle(run.id)}
|
|
76
|
+
/>
|
|
77
|
+
))}
|
|
78
|
+
</tbody>
|
|
79
|
+
</table>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface RunRowProps {
|
|
85
|
+
run: RunEntry;
|
|
86
|
+
isExpanded: boolean;
|
|
87
|
+
onToggle: () => void;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function RunRow({ run, isExpanded, onToggle }: RunRowProps) {
|
|
91
|
+
const hasOutput = run.stdoutTail || run.stderrTail;
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<>
|
|
95
|
+
<tr
|
|
96
|
+
className={`border-b border-border transition-colors ${hasOutput ? 'cursor-pointer hover:bg-muted/50' : ''}`}
|
|
97
|
+
onClick={hasOutput ? onToggle : undefined}
|
|
98
|
+
>
|
|
99
|
+
<td className="px-3 py-2">
|
|
100
|
+
{hasOutput && (
|
|
101
|
+
isExpanded
|
|
102
|
+
? <ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
103
|
+
: <ChevronRight className="h-4 w-4 text-muted-foreground" />
|
|
104
|
+
)}
|
|
105
|
+
</td>
|
|
106
|
+
<td className="px-3 py-2"><StatusPill status={run.status} /></td>
|
|
107
|
+
<td className="px-3 py-2 text-muted-foreground">{run.trigger}</td>
|
|
108
|
+
<td className="px-3 py-2 text-muted-foreground text-xs">
|
|
109
|
+
{formatTime(run.startedAt)}
|
|
110
|
+
</td>
|
|
111
|
+
<td className="px-3 py-2 text-muted-foreground text-xs">
|
|
112
|
+
{formatDuration(run.durationMs)}
|
|
113
|
+
</td>
|
|
114
|
+
<td className="px-3 py-2 font-mono text-xs">
|
|
115
|
+
{run.exitCode !== null ? String(run.exitCode) : '—'}
|
|
116
|
+
</td>
|
|
117
|
+
</tr>
|
|
118
|
+
{isExpanded && hasOutput && (
|
|
119
|
+
<tr>
|
|
120
|
+
<td colSpan={6} className="px-3 py-2 bg-muted/30">
|
|
121
|
+
<OutputBlock label="stdout" content={run.stdoutTail} />
|
|
122
|
+
<OutputBlock label="stderr" content={run.stderrTail} />
|
|
123
|
+
</td>
|
|
124
|
+
</tr>
|
|
125
|
+
)}
|
|
126
|
+
</>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function OutputBlock({ label, content }: { label: string; content: string }) {
|
|
131
|
+
if (!content) return null;
|
|
132
|
+
return (
|
|
133
|
+
<div className="mb-2 last:mb-0">
|
|
134
|
+
<div className="text-xs font-medium text-muted-foreground mb-1">{label}</div>
|
|
135
|
+
<pre className="text-xs bg-zinc-900 text-zinc-200 p-2 rounded overflow-x-auto max-h-48 whitespace-pre-wrap">
|
|
136
|
+
{content}
|
|
137
|
+
</pre>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stats summary bar for runner dashboard.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { RunnerStats } from '@/lib/runner-api';
|
|
6
|
+
|
|
7
|
+
interface StatsBarProps {
|
|
8
|
+
stats: RunnerStats | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface StatCardProps {
|
|
12
|
+
label: string;
|
|
13
|
+
value: number;
|
|
14
|
+
color: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function StatCard({ label, value, color }: StatCardProps) {
|
|
18
|
+
return (
|
|
19
|
+
<div className="flex flex-col items-center px-3 py-1">
|
|
20
|
+
<span className={`text-lg font-bold leading-tight ${color}`}>{value}</span>
|
|
21
|
+
<span className="text-xs text-muted-foreground">{label}</span>
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function StatsBar({ stats }: StatsBarProps) {
|
|
27
|
+
if (!stats) {
|
|
28
|
+
return (
|
|
29
|
+
<div className="flex items-center gap-4 p-4 bg-muted/50 rounded-lg">
|
|
30
|
+
<span className="text-sm text-muted-foreground">Loading stats...</span>
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className="flex flex-wrap items-center gap-1 px-2 py-1 bg-muted/50 rounded-lg">
|
|
37
|
+
<StatCard label="Total" value={stats.totalJobs} color="text-foreground" />
|
|
38
|
+
<StatCard label="Running" value={stats.running} color="text-yellow-600 dark:text-yellow-400" />
|
|
39
|
+
<StatCard label="OK (1h)" value={stats.okLastHour} color="text-green-600 dark:text-green-400" />
|
|
40
|
+
<StatCard label="Errors (1h)" value={stats.errorsLastHour} color="text-red-600 dark:text-red-400" />
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Colored status pill for runner job status display.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const statusStyles: Record<string, string> = {
|
|
6
|
+
ok: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
|
7
|
+
error: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
|
8
|
+
running: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
|
|
9
|
+
disabled: 'bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400',
|
|
10
|
+
pending: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const fallbackStyle = 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300';
|
|
14
|
+
|
|
15
|
+
interface StatusPillProps {
|
|
16
|
+
status: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function StatusPill({ status }: StatusPillProps) {
|
|
20
|
+
const display = status ?? 'pending';
|
|
21
|
+
const style = statusStyles[display] ?? fallbackStyle;
|
|
22
|
+
return (
|
|
23
|
+
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${style}`}>
|
|
24
|
+
{display}
|
|
25
|
+
</span>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sorting utilities for the runner job table.
|
|
3
|
+
*/
|
|
4
|
+
import type { RunnerJob } from '@/lib/runner-api';
|
|
5
|
+
|
|
6
|
+
import type { SortColumn, SortState } from './JobTable';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Sort jobs by chosen column, then by lastRun desc, then by name asc.
|
|
10
|
+
* Null lastRun values sort to the end.
|
|
11
|
+
*/
|
|
12
|
+
export function sortJobs(jobs: RunnerJob[], sort: SortState): RunnerJob[] {
|
|
13
|
+
return [...jobs].sort((a, b) => {
|
|
14
|
+
// Primary: chosen sort column
|
|
15
|
+
if (sort.column) {
|
|
16
|
+
const cmp = compareByColumn(a, b, sort.column);
|
|
17
|
+
if (cmp !== 0) return sort.direction === 'asc' ? cmp : -cmp;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Secondary: lastRun desc (nulls last)
|
|
21
|
+
const lastRunCmp = compareNullableDate(a.lastRun, b.lastRun);
|
|
22
|
+
if (lastRunCmp !== 0) return -lastRunCmp; // negative for desc
|
|
23
|
+
|
|
24
|
+
// Tertiary: name asc
|
|
25
|
+
return a.name.localeCompare(b.name);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function compareByColumn(a: RunnerJob, b: RunnerJob, col: SortColumn): number {
|
|
30
|
+
switch (col) {
|
|
31
|
+
case 'name':
|
|
32
|
+
return a.name.localeCompare(b.name);
|
|
33
|
+
case 'type':
|
|
34
|
+
return a.type.localeCompare(b.type);
|
|
35
|
+
case 'schedule':
|
|
36
|
+
return a.schedule.localeCompare(b.schedule);
|
|
37
|
+
case 'status': {
|
|
38
|
+
const sa = a.enabled ? (a.status ?? '') : 'disabled';
|
|
39
|
+
const sb = b.enabled ? (b.status ?? '') : 'disabled';
|
|
40
|
+
return sa.localeCompare(sb);
|
|
41
|
+
}
|
|
42
|
+
case 'lastRun':
|
|
43
|
+
return compareNullableDate(a.lastRun, b.lastRun);
|
|
44
|
+
default:
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function compareNullableDate(a: string | null, b: string | null): number {
|
|
50
|
+
if (!a && !b) return 0;
|
|
51
|
+
if (!a) return -1; // nulls last
|
|
52
|
+
if (!b) return 1;
|
|
53
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Cycle sort: click same column toggles asc↔desc; click different column starts desc. */
|
|
57
|
+
export function nextSort(current: SortState, column: SortColumn): SortState {
|
|
58
|
+
if (current.column === column) {
|
|
59
|
+
// Same column: asc → desc → clear
|
|
60
|
+
if (current.direction === 'desc') return { column, direction: 'asc' };
|
|
61
|
+
return { column: null, direction: 'desc' }; // clear
|
|
62
|
+
}
|
|
63
|
+
// New column: start desc
|
|
64
|
+
return { column, direction: 'desc' };
|
|
65
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smooth scroll utilities for in-page navigation.
|
|
3
|
+
*/
|
|
4
|
+
const SCROLL_DURATION = 600;
|
|
5
|
+
|
|
6
|
+
function smoothScrollTo(container: HTMLElement | Window, targetY: number) {
|
|
7
|
+
const isWindow = container === window;
|
|
8
|
+
const startY = isWindow ? window.scrollY : (container as HTMLElement).scrollTop;
|
|
9
|
+
const diff = targetY - startY;
|
|
10
|
+
if (Math.abs(diff) < 2) return;
|
|
11
|
+
const startTime = performance.now();
|
|
12
|
+
|
|
13
|
+
function step(currentTime: number) {
|
|
14
|
+
const elapsed = currentTime - startTime;
|
|
15
|
+
const progress = Math.min(elapsed / SCROLL_DURATION, 1);
|
|
16
|
+
const ease = progress < 0.5
|
|
17
|
+
? 4 * progress * progress * progress
|
|
18
|
+
: 1 - Math.pow(-2 * progress + 2, 3) / 2;
|
|
19
|
+
if (isWindow) {
|
|
20
|
+
window.scrollTo(0, startY + diff * ease);
|
|
21
|
+
} else {
|
|
22
|
+
(container as HTMLElement).scrollTop = startY + diff * ease;
|
|
23
|
+
}
|
|
24
|
+
if (progress < 1) requestAnimationFrame(step);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
requestAnimationFrame(step);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function scrollToIdInContainer(container: HTMLElement | null, id: string) {
|
|
31
|
+
const el = document.getElementById(id);
|
|
32
|
+
if (el && container) {
|
|
33
|
+
const elRect = el.getBoundingClientRect();
|
|
34
|
+
const containerRect = container.getBoundingClientRect();
|
|
35
|
+
const top = container.scrollTop + (elRect.top - containerRect.top) - 16;
|
|
36
|
+
smoothScrollTo(container, top);
|
|
37
|
+
window.history.replaceState(null, '', `#${id}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
|
3
|
+
|
|
4
|
+
import { cn } from '@/lib/utils';
|
|
5
|
+
|
|
6
|
+
const AlertDialog = AlertDialogPrimitive.Root;
|
|
7
|
+
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
|
8
|
+
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
|
9
|
+
|
|
10
|
+
const AlertDialogOverlay = React.forwardRef<
|
|
11
|
+
React.ComponentRef<typeof AlertDialogPrimitive.Overlay>,
|
|
12
|
+
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
|
13
|
+
>(({ className, ...props }, ref) => (
|
|
14
|
+
<AlertDialogPrimitive.Overlay
|
|
15
|
+
className={cn(
|
|
16
|
+
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
|
17
|
+
className,
|
|
18
|
+
)}
|
|
19
|
+
{...props}
|
|
20
|
+
ref={ref}
|
|
21
|
+
/>
|
|
22
|
+
));
|
|
23
|
+
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
|
24
|
+
|
|
25
|
+
const AlertDialogContent = React.forwardRef<
|
|
26
|
+
React.ComponentRef<typeof AlertDialogPrimitive.Content>,
|
|
27
|
+
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
|
28
|
+
>(({ className, ...props }, ref) => (
|
|
29
|
+
<AlertDialogPortal>
|
|
30
|
+
<AlertDialogOverlay />
|
|
31
|
+
<AlertDialogPrimitive.Content
|
|
32
|
+
ref={ref}
|
|
33
|
+
className={cn(
|
|
34
|
+
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
|
35
|
+
className,
|
|
36
|
+
)}
|
|
37
|
+
{...props}
|
|
38
|
+
/>
|
|
39
|
+
</AlertDialogPortal>
|
|
40
|
+
));
|
|
41
|
+
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
|
42
|
+
|
|
43
|
+
const AlertDialogTitle = React.forwardRef<
|
|
44
|
+
React.ComponentRef<typeof AlertDialogPrimitive.Title>,
|
|
45
|
+
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
|
46
|
+
>(({ className, ...props }, ref) => (
|
|
47
|
+
<AlertDialogPrimitive.Title
|
|
48
|
+
ref={ref}
|
|
49
|
+
className={cn('text-lg font-semibold', className)}
|
|
50
|
+
{...props}
|
|
51
|
+
/>
|
|
52
|
+
));
|
|
53
|
+
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
|
54
|
+
|
|
55
|
+
const AlertDialogDescription = React.forwardRef<
|
|
56
|
+
React.ComponentRef<typeof AlertDialogPrimitive.Description>,
|
|
57
|
+
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
|
58
|
+
>(({ className, ...props }, ref) => (
|
|
59
|
+
<AlertDialogPrimitive.Description
|
|
60
|
+
ref={ref}
|
|
61
|
+
className={cn('text-sm text-muted-foreground', className)}
|
|
62
|
+
{...props}
|
|
63
|
+
/>
|
|
64
|
+
));
|
|
65
|
+
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
|
|
66
|
+
|
|
67
|
+
const AlertDialogAction = React.forwardRef<
|
|
68
|
+
React.ComponentRef<typeof AlertDialogPrimitive.Action>,
|
|
69
|
+
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
|
70
|
+
>(({ className, ...props }, ref) => (
|
|
71
|
+
<AlertDialogPrimitive.Action
|
|
72
|
+
ref={ref}
|
|
73
|
+
className={cn(
|
|
74
|
+
'inline-flex h-10 items-center justify-center rounded-md bg-destructive px-4 py-2 text-sm font-semibold text-white ring-offset-background transition-colors hover:bg-destructive/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
|
75
|
+
className,
|
|
76
|
+
)}
|
|
77
|
+
{...props}
|
|
78
|
+
/>
|
|
79
|
+
));
|
|
80
|
+
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
|
81
|
+
|
|
82
|
+
const AlertDialogCancel = React.forwardRef<
|
|
83
|
+
React.ComponentRef<typeof AlertDialogPrimitive.Cancel>,
|
|
84
|
+
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
|
85
|
+
>(({ className, ...props }, ref) => (
|
|
86
|
+
<AlertDialogPrimitive.Cancel
|
|
87
|
+
ref={ref}
|
|
88
|
+
className={cn(
|
|
89
|
+
'inline-flex h-10 items-center justify-center rounded-md border border-border bg-background px-4 py-2 text-sm font-semibold ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
|
90
|
+
className,
|
|
91
|
+
)}
|
|
92
|
+
{...props}
|
|
93
|
+
/>
|
|
94
|
+
));
|
|
95
|
+
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
|
96
|
+
|
|
97
|
+
export {
|
|
98
|
+
AlertDialog,
|
|
99
|
+
AlertDialogPortal,
|
|
100
|
+
AlertDialogOverlay,
|
|
101
|
+
AlertDialogTrigger,
|
|
102
|
+
AlertDialogContent,
|
|
103
|
+
AlertDialogTitle,
|
|
104
|
+
AlertDialogDescription,
|
|
105
|
+
AlertDialogAction,
|
|
106
|
+
AlertDialogCancel,
|
|
107
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type ButtonHTMLAttributes, forwardRef } from 'react';
|
|
2
|
+
|
|
3
|
+
import { cn } from '@/lib/utils';
|
|
4
|
+
|
|
5
|
+
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
6
|
+
variant?: 'default' | 'outline' | 'ghost' | 'link';
|
|
7
|
+
size?: 'default' | 'sm' | 'lg' | 'icon';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
11
|
+
({ className, variant = 'default', size = 'default', ...props }, ref) => {
|
|
12
|
+
return (
|
|
13
|
+
<button
|
|
14
|
+
className={cn(
|
|
15
|
+
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors',
|
|
16
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
17
|
+
'disabled:pointer-events-none disabled:opacity-50',
|
|
18
|
+
{
|
|
19
|
+
'bg-primary text-primary-foreground hover:bg-primary/90': variant === 'default',
|
|
20
|
+
'border border-border bg-background hover:bg-accent hover:text-accent-foreground': variant === 'outline',
|
|
21
|
+
'hover:bg-accent hover:text-accent-foreground': variant === 'ghost',
|
|
22
|
+
'text-primary underline-offset-4 hover:underline': variant === 'link',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
'h-10 px-4 py-2': size === 'default',
|
|
26
|
+
'h-9 rounded-md px-3': size === 'sm',
|
|
27
|
+
'h-11 rounded-md px-8': size === 'lg',
|
|
28
|
+
'h-10 w-10': size === 'icon',
|
|
29
|
+
},
|
|
30
|
+
className,
|
|
31
|
+
)}
|
|
32
|
+
ref={ref}
|
|
33
|
+
{...props}
|
|
34
|
+
/>
|
|
35
|
+
);
|
|
36
|
+
},
|
|
37
|
+
);
|
|
38
|
+
Button.displayName = 'Button';
|
|
39
|
+
|
|
40
|
+
export { Button };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
|
3
|
+
|
|
4
|
+
import { cn } from '@/lib/utils';
|
|
5
|
+
|
|
6
|
+
const DropdownMenu = DropdownMenuPrimitive.Root;
|
|
7
|
+
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
|
8
|
+
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
|
9
|
+
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
|
10
|
+
|
|
11
|
+
const DropdownMenuContent = React.forwardRef<
|
|
12
|
+
React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
|
|
13
|
+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
|
14
|
+
>(({ className, sideOffset = 4, ...props }, ref) => (
|
|
15
|
+
<DropdownMenuPrimitive.Portal>
|
|
16
|
+
<DropdownMenuPrimitive.Content
|
|
17
|
+
ref={ref}
|
|
18
|
+
sideOffset={sideOffset}
|
|
19
|
+
className={cn(
|
|
20
|
+
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md',
|
|
21
|
+
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
|
22
|
+
className,
|
|
23
|
+
)}
|
|
24
|
+
{...props}
|
|
25
|
+
/>
|
|
26
|
+
</DropdownMenuPrimitive.Portal>
|
|
27
|
+
));
|
|
28
|
+
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
|
29
|
+
|
|
30
|
+
const DropdownMenuItem = React.forwardRef<
|
|
31
|
+
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
|
|
32
|
+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
|
|
33
|
+
>(({ className, inset, ...props }, ref) => (
|
|
34
|
+
<DropdownMenuPrimitive.Item
|
|
35
|
+
ref={ref}
|
|
36
|
+
className={cn(
|
|
37
|
+
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
|
38
|
+
inset && 'pl-8',
|
|
39
|
+
className,
|
|
40
|
+
)}
|
|
41
|
+
{...props}
|
|
42
|
+
/>
|
|
43
|
+
));
|
|
44
|
+
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
|
45
|
+
|
|
46
|
+
const DropdownMenuLabel = React.forwardRef<
|
|
47
|
+
React.ComponentRef<typeof DropdownMenuPrimitive.Label>,
|
|
48
|
+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }
|
|
49
|
+
>(({ className, inset, ...props }, ref) => (
|
|
50
|
+
<DropdownMenuPrimitive.Label
|
|
51
|
+
ref={ref}
|
|
52
|
+
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
|
|
53
|
+
{...props}
|
|
54
|
+
/>
|
|
55
|
+
));
|
|
56
|
+
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
|
57
|
+
|
|
58
|
+
const DropdownMenuSeparator = React.forwardRef<
|
|
59
|
+
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
|
|
60
|
+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
|
61
|
+
>(({ className, ...props }, ref) => (
|
|
62
|
+
<DropdownMenuPrimitive.Separator
|
|
63
|
+
ref={ref}
|
|
64
|
+
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
|
65
|
+
{...props}
|
|
66
|
+
/>
|
|
67
|
+
));
|
|
68
|
+
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
|
69
|
+
|
|
70
|
+
export {
|
|
71
|
+
DropdownMenu,
|
|
72
|
+
DropdownMenuTrigger,
|
|
73
|
+
DropdownMenuContent,
|
|
74
|
+
DropdownMenuItem,
|
|
75
|
+
DropdownMenuLabel,
|
|
76
|
+
DropdownMenuSeparator,
|
|
77
|
+
DropdownMenuGroup,
|
|
78
|
+
DropdownMenuPortal,
|
|
79
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type InputHTMLAttributes, forwardRef } from 'react';
|
|
2
|
+
|
|
3
|
+
import { cn } from '@/lib/utils';
|
|
4
|
+
|
|
5
|
+
const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
|
|
6
|
+
({ className, type, ...props }, ref) => {
|
|
7
|
+
return (
|
|
8
|
+
<input
|
|
9
|
+
type={type}
|
|
10
|
+
className={cn(
|
|
11
|
+
'flex h-10 w-full rounded-md border border-border bg-background px-3 py-2 text-sm',
|
|
12
|
+
'file:border-0 file:bg-transparent file:text-sm file:font-medium',
|
|
13
|
+
'placeholder:text-muted-foreground',
|
|
14
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
15
|
+
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
16
|
+
className,
|
|
17
|
+
)}
|
|
18
|
+
ref={ref}
|
|
19
|
+
{...props}
|
|
20
|
+
/>
|
|
21
|
+
);
|
|
22
|
+
},
|
|
23
|
+
);
|
|
24
|
+
Input.displayName = 'Input';
|
|
25
|
+
|
|
26
|
+
export { Input };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook for managing action dropdown state machine.
|
|
3
|
+
*/
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
|
|
6
|
+
import type { ActionState } from './ActionDropdown';
|
|
7
|
+
|
|
8
|
+
export function useActionState(
|
|
9
|
+
onError?: (error: string) => void,
|
|
10
|
+
onStateChange?: (state: ActionState) => void,
|
|
11
|
+
) {
|
|
12
|
+
const [state, setStateInternal] = useState<ActionState>('idle');
|
|
13
|
+
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
|
14
|
+
|
|
15
|
+
const setState = (s: ActionState) => {
|
|
16
|
+
setStateInternal(s);
|
|
17
|
+
onStateChange?.(s);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const handleAction = async (action: () => Promise<void>) => {
|
|
21
|
+
setState('loading');
|
|
22
|
+
setErrorMsg(null);
|
|
23
|
+
try {
|
|
24
|
+
await action();
|
|
25
|
+
setState('done');
|
|
26
|
+
setTimeout(() => setState('idle'), 1500);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
const msg = err instanceof Error ? err.message : 'Action failed';
|
|
29
|
+
setErrorMsg(msg);
|
|
30
|
+
setState('error');
|
|
31
|
+
onError?.(msg);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const resetOnClose = (open: boolean) => {
|
|
36
|
+
if (!open && state === 'error') {
|
|
37
|
+
setState('idle');
|
|
38
|
+
setErrorMsg(null);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return { state, errorMsg, handleAction, resetOnClose };
|
|
43
|
+
}
|