@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,270 @@
|
|
|
1
|
+
import { Moon, Sun, BookOpen, KeyRound, Github, Search, Activity } from 'lucide-react';
|
|
2
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
3
|
+
import { Link } from 'react-router-dom';
|
|
4
|
+
|
|
5
|
+
import { AccountMenu, type CollapsedItem } from '@/components/AccountMenu';
|
|
6
|
+
import { SearchModal } from '@/components/SearchModal';
|
|
7
|
+
import { Button } from '@/components/ui/button';
|
|
8
|
+
import type { BreadcrumbItem } from '@/lib/api';
|
|
9
|
+
|
|
10
|
+
const GITHUB_URL = 'https://github.com/karmaniverous/jeeves-server';
|
|
11
|
+
|
|
12
|
+
interface HeaderProps {
|
|
13
|
+
breadcrumbs?: BreadcrumbItem[];
|
|
14
|
+
isInsider: boolean;
|
|
15
|
+
searchEnabled?: boolean;
|
|
16
|
+
theme: 'light' | 'dark';
|
|
17
|
+
onToggleTheme: () => void;
|
|
18
|
+
keyAge?: string | null;
|
|
19
|
+
onRotateKey?: () => void;
|
|
20
|
+
/** Download dropdown for header bar (icon button variant) */
|
|
21
|
+
downloadDropdown?: React.ReactNode;
|
|
22
|
+
/** Link dropdown for header bar (icon button variant) */
|
|
23
|
+
linkControls?: React.ReactNode;
|
|
24
|
+
/** Download dropdown factory for account menu (receives dismiss callback) */
|
|
25
|
+
downloadMenuItem?: (onDismiss: () => void) => React.ReactNode;
|
|
26
|
+
/** Link dropdown factory for account menu (receives dismiss callback) */
|
|
27
|
+
linkMenuItem?: (onDismiss: () => void) => React.ReactNode;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function Header({
|
|
31
|
+
breadcrumbs = [],
|
|
32
|
+
isInsider,
|
|
33
|
+
searchEnabled,
|
|
34
|
+
theme,
|
|
35
|
+
onToggleTheme,
|
|
36
|
+
keyAge,
|
|
37
|
+
onRotateKey,
|
|
38
|
+
downloadDropdown,
|
|
39
|
+
linkControls,
|
|
40
|
+
downloadMenuItem,
|
|
41
|
+
linkMenuItem,
|
|
42
|
+
}: HeaderProps) {
|
|
43
|
+
const hasKeyMgmt = isInsider && onRotateKey;
|
|
44
|
+
const [readmeUrl, setReadmeUrl] = useState<string | null>(null);
|
|
45
|
+
const [searchOpen, setSearchOpen] = useState(false);
|
|
46
|
+
|
|
47
|
+
// Ctrl/Cmd+K keyboard shortcut
|
|
48
|
+
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
|
49
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
50
|
+
e.preventDefault();
|
|
51
|
+
setSearchOpen(true);
|
|
52
|
+
}
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (searchEnabled && isInsider) {
|
|
57
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
58
|
+
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
59
|
+
}
|
|
60
|
+
}, [searchEnabled, isInsider, handleKeyDown]);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
fetch('/api/readme-link')
|
|
64
|
+
.then(r => r.ok ? r.json() as Promise<{ url: string }> : null)
|
|
65
|
+
.then(data => { if (data?.url) setReadmeUrl(data.url); })
|
|
66
|
+
.catch(() => {});
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
// Build account menu collapsed items in left-to-right header order
|
|
70
|
+
const collapsedItems: CollapsedItem[] = [];
|
|
71
|
+
|
|
72
|
+
const menuItemClass = 'flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-accent transition-colors w-full text-left cursor-pointer';
|
|
73
|
+
|
|
74
|
+
// Link controls — hidden below 400px
|
|
75
|
+
if (linkMenuItem) {
|
|
76
|
+
collapsedItems.push({
|
|
77
|
+
breakpoint: 'bp-400',
|
|
78
|
+
node: linkMenuItem,
|
|
79
|
+
hasNestedDropdown: true,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Download dropdown — hidden below 480px
|
|
84
|
+
if (downloadMenuItem) {
|
|
85
|
+
collapsedItems.push({
|
|
86
|
+
breakpoint: 'bp-480',
|
|
87
|
+
node: downloadMenuItem,
|
|
88
|
+
hasNestedDropdown: true,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Key management — hidden below sm (640px)
|
|
93
|
+
if (hasKeyMgmt) {
|
|
94
|
+
collapsedItems.push({
|
|
95
|
+
breakpoint: 'sm',
|
|
96
|
+
node: (
|
|
97
|
+
<button onClick={onRotateKey} className={menuItemClass}>
|
|
98
|
+
<KeyRound className="h-4 w-4 shrink-0" />
|
|
99
|
+
Rotate key{keyAge ? ` (${keyAge})` : ''}
|
|
100
|
+
</button>
|
|
101
|
+
),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Runner — hidden below md (768px)
|
|
106
|
+
if (isInsider) {
|
|
107
|
+
collapsedItems.push({
|
|
108
|
+
breakpoint: 'md',
|
|
109
|
+
node: (
|
|
110
|
+
<Link to="/runner" className={menuItemClass}>
|
|
111
|
+
<Activity className="h-4 w-4 shrink-0" />
|
|
112
|
+
Runner
|
|
113
|
+
</Link>
|
|
114
|
+
),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// README — hidden below md (768px)
|
|
119
|
+
if (readmeUrl) {
|
|
120
|
+
collapsedItems.push({
|
|
121
|
+
breakpoint: 'md',
|
|
122
|
+
node: (
|
|
123
|
+
<a href={readmeUrl} className={menuItemClass}>
|
|
124
|
+
<BookOpen className="h-4 w-4 shrink-0" />
|
|
125
|
+
README
|
|
126
|
+
</a>
|
|
127
|
+
),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// GitHub — hidden below md (768px)
|
|
132
|
+
collapsedItems.push({
|
|
133
|
+
breakpoint: 'md',
|
|
134
|
+
node: (
|
|
135
|
+
<a href={GITHUB_URL} target="_blank" rel="noopener noreferrer" className={menuItemClass}>
|
|
136
|
+
<Github className="h-4 w-4 shrink-0" />
|
|
137
|
+
GitHub
|
|
138
|
+
</a>
|
|
139
|
+
),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Theme — hidden below md (768px)
|
|
143
|
+
collapsedItems.push({
|
|
144
|
+
breakpoint: 'md',
|
|
145
|
+
node: (
|
|
146
|
+
<button onClick={onToggleTheme} className={menuItemClass}>
|
|
147
|
+
{theme === 'dark' ? <Sun className="h-4 w-4 shrink-0" /> : <Moon className="h-4 w-4 shrink-0" />}
|
|
148
|
+
{theme === 'dark' ? 'Light mode' : 'Dark mode'}
|
|
149
|
+
</button>
|
|
150
|
+
),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<header className="bg-zinc-800 text-white px-4 py-2">
|
|
155
|
+
<div className="flex items-center gap-1">
|
|
156
|
+
{/* Breadcrumbs — takes remaining space, truncates */}
|
|
157
|
+
<div className="flex items-center min-w-0 flex-1">
|
|
158
|
+
<Link to="/browse" className="text-3xl no-underline shrink-0 mr-1" title="Jeeves Server">
|
|
159
|
+
🎩
|
|
160
|
+
</Link>
|
|
161
|
+
<nav className="flex items-center gap-1 min-w-0 overflow-x-auto overflow-y-hidden scrollbar-thin">
|
|
162
|
+
{breadcrumbs.map((crumb, i) => (
|
|
163
|
+
<span key={crumb.path} className="flex items-center gap-1 shrink-0">
|
|
164
|
+
<span className="text-zinc-500 mx-0.5">/</span>
|
|
165
|
+
{i === breadcrumbs.length - 1 ? (
|
|
166
|
+
<span className="text-zinc-300 truncate max-w-48">{crumb.label}</span>
|
|
167
|
+
) : (
|
|
168
|
+
<Link
|
|
169
|
+
to={`/browse/${crumb.path}`}
|
|
170
|
+
className="text-blue-400 hover:underline truncate max-w-48"
|
|
171
|
+
>
|
|
172
|
+
{crumb.label}
|
|
173
|
+
</Link>
|
|
174
|
+
)}
|
|
175
|
+
</span>
|
|
176
|
+
))}
|
|
177
|
+
</nav>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
{/* Controls — progressively hidden via responsive classes */}
|
|
181
|
+
<div className="flex items-center gap-1 shrink-0">
|
|
182
|
+
{/* Search: visible when configured, always shown */}
|
|
183
|
+
{searchEnabled && isInsider && (
|
|
184
|
+
<Button
|
|
185
|
+
variant="ghost"
|
|
186
|
+
size="icon"
|
|
187
|
+
className="text-zinc-300 hover:text-white hover:bg-white/10 h-8 w-8"
|
|
188
|
+
title="Search (Ctrl+K)"
|
|
189
|
+
onClick={() => setSearchOpen(true)}
|
|
190
|
+
>
|
|
191
|
+
<Search className="h-4 w-4" />
|
|
192
|
+
</Button>
|
|
193
|
+
)}
|
|
194
|
+
|
|
195
|
+
{/* Link controls: visible 400px+ */}
|
|
196
|
+
{linkControls && (
|
|
197
|
+
<div className="hidden min-[400px]:flex items-center">{linkControls}</div>
|
|
198
|
+
)}
|
|
199
|
+
|
|
200
|
+
{/* Download: visible 480px+ */}
|
|
201
|
+
{downloadDropdown && (
|
|
202
|
+
<div className="hidden min-[480px]:flex items-center">{downloadDropdown}</div>
|
|
203
|
+
)}
|
|
204
|
+
|
|
205
|
+
{/* Key management: visible sm+ (640px) */}
|
|
206
|
+
{hasKeyMgmt && (
|
|
207
|
+
<div className="hidden sm:flex items-center gap-1">
|
|
208
|
+
<Button
|
|
209
|
+
variant="ghost"
|
|
210
|
+
size="icon"
|
|
211
|
+
className="text-zinc-300 hover:text-white hover:bg-white/10 h-8 w-8"
|
|
212
|
+
title="Rotate key (invalidates all your shares)"
|
|
213
|
+
onClick={onRotateKey}
|
|
214
|
+
>
|
|
215
|
+
<KeyRound className="h-4 w-4" />
|
|
216
|
+
</Button>
|
|
217
|
+
{keyAge && <span className="text-xs text-zinc-500">{keyAge}</span>}
|
|
218
|
+
</div>
|
|
219
|
+
)}
|
|
220
|
+
|
|
221
|
+
{/* Runner: visible md+ (768px) */}
|
|
222
|
+
{isInsider && (
|
|
223
|
+
<Link to="/runner" title="Runner Dashboard" className="hidden md:inline-flex">
|
|
224
|
+
<Button variant="ghost" size="icon" className="text-zinc-300 hover:text-white hover:bg-white/10 h-8 w-8">
|
|
225
|
+
<Activity className="h-4 w-4" />
|
|
226
|
+
</Button>
|
|
227
|
+
</Link>
|
|
228
|
+
)}
|
|
229
|
+
|
|
230
|
+
{/* README: visible md+ (768px) */}
|
|
231
|
+
{readmeUrl && (
|
|
232
|
+
<a href={readmeUrl} title="README" className="hidden md:inline-flex">
|
|
233
|
+
<Button variant="ghost" size="icon" className="text-zinc-300 hover:text-white hover:bg-white/10 h-8 w-8">
|
|
234
|
+
<BookOpen className="h-4 w-4" />
|
|
235
|
+
</Button>
|
|
236
|
+
</a>
|
|
237
|
+
)}
|
|
238
|
+
|
|
239
|
+
{/* GitHub: visible md+ (768px) */}
|
|
240
|
+
<a href={GITHUB_URL} target="_blank" rel="noopener noreferrer" title="GitHub" className="hidden md:inline-flex">
|
|
241
|
+
<Button variant="ghost" size="icon" className="text-zinc-300 hover:text-white hover:bg-white/10 h-8 w-8">
|
|
242
|
+
<Github className="h-4 w-4" />
|
|
243
|
+
</Button>
|
|
244
|
+
</a>
|
|
245
|
+
|
|
246
|
+
{/* Theme: visible lg+ (1024px) */}
|
|
247
|
+
<Button
|
|
248
|
+
variant="ghost"
|
|
249
|
+
size="icon"
|
|
250
|
+
className="text-zinc-300 hover:text-white hover:bg-white/10 h-8 w-8 hidden md:inline-flex"
|
|
251
|
+
title="Toggle theme"
|
|
252
|
+
onClick={onToggleTheme}
|
|
253
|
+
>
|
|
254
|
+
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
|
255
|
+
</Button>
|
|
256
|
+
|
|
257
|
+
{/* Account menu — always visible */}
|
|
258
|
+
<AccountMenu
|
|
259
|
+
theme={theme}
|
|
260
|
+
onToggleTheme={onToggleTheme}
|
|
261
|
+
collapsedItems={collapsedItems}
|
|
262
|
+
/>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
{searchEnabled && isInsider && (
|
|
266
|
+
<SearchModal open={searchOpen} onClose={() => setSearchOpen(false)} />
|
|
267
|
+
)}
|
|
268
|
+
</header>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Panzoom wrapper for SVG diagrams.
|
|
3
|
+
*
|
|
4
|
+
* Provides fullscreen toggle, zoom-to-fit, and drag/pan for any SVG element.
|
|
5
|
+
*/
|
|
6
|
+
import Panzoom from '@panzoom/panzoom';
|
|
7
|
+
import { createElement, Maximize, Minimize } from 'lucide';
|
|
8
|
+
|
|
9
|
+
// ── CSS class constants ──────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
const WRAPPER_CLASS = 'relative bg-white rounded-lg border border-border overflow-hidden my-4';
|
|
12
|
+
const WRAPPER_FULLSCREEN_CLASS = 'fixed inset-0 z-[100] bg-white flex flex-col';
|
|
13
|
+
const VIEWPORT_CLASS = 'overflow-hidden w-full';
|
|
14
|
+
const VIEWPORT_FULLSCREEN_CLASS = 'overflow-hidden w-full h-full flex-1';
|
|
15
|
+
const INNER_CLASS = 'flex items-center justify-center p-4 [&>svg]:max-w-full [&>svg]:h-auto';
|
|
16
|
+
const INNER_FULLSCREEN_CLASS = 'p-4';
|
|
17
|
+
const FS_BTN_CLASS = 'absolute top-2 right-2 z-10 p-1.5 bg-zinc-800/70 hover:bg-zinc-700 text-white rounded transition-colors';
|
|
18
|
+
const HINT_CLASS = 'text-xs text-muted-foreground text-center py-1 opacity-60';
|
|
19
|
+
const HINT_FULLSCREEN_CLASS = 'text-muted-foreground text-xs text-center py-2 pointer-events-none';
|
|
20
|
+
|
|
21
|
+
const HINT_TEXT = 'Scroll to zoom · Drag to pan';
|
|
22
|
+
const HINT_FULLSCREEN_TEXT = 'Scroll to zoom · Drag to pan · Esc to close';
|
|
23
|
+
|
|
24
|
+
// ── Icon helpers ─────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
function createIcon(iconData: typeof Maximize): SVGSVGElement {
|
|
27
|
+
return createElement(iconData, { size: 16 }) as unknown as SVGSVGElement;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function setButtonIcon(btn: HTMLButtonElement, iconData: typeof Maximize): void {
|
|
31
|
+
btn.innerHTML = '';
|
|
32
|
+
btn.appendChild(createIcon(iconData));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Zoom-to-fit helpers ──────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
function parseSvgDimensions(svg: SVGElement): { w: number; h: number } {
|
|
38
|
+
const vb = svg.getAttribute('viewBox');
|
|
39
|
+
if (vb) {
|
|
40
|
+
const parts = vb.split(/[\s,]+/).map(Number);
|
|
41
|
+
if (parts[2] && parts[3]) return { w: parts[2], h: parts[3] };
|
|
42
|
+
}
|
|
43
|
+
return { w: (svg as SVGSVGElement).clientWidth || 1000, h: (svg as SVGSVGElement).clientHeight || 800 };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function computeZoomToFit(
|
|
47
|
+
svgW: number, svgH: number, viewportW: number, viewportH: number,
|
|
48
|
+
): { fitScale: number; fittedW: number; fittedH: number; startX: number; startY: number } {
|
|
49
|
+
const fitScale = Math.min(viewportW / svgW, viewportH / svgH, 1);
|
|
50
|
+
const fittedW = svgW * fitScale;
|
|
51
|
+
const fittedH = svgH * fitScale;
|
|
52
|
+
return { fitScale, fittedW, fittedH, startX: (viewportW - fittedW) / 2, startY: (viewportH - fittedH) / 2 };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Core factory ─────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
export interface PanzoomWrapperOptions {
|
|
58
|
+
/** Extra CSS class for the wrapper (e.g., 'inline-svg-panzoom') */
|
|
59
|
+
wrapperExtraClass?: string;
|
|
60
|
+
/** Minimum height for the viewport (e.g., '200px') */
|
|
61
|
+
viewportMinHeight?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface PanzoomWrapperResult {
|
|
65
|
+
wrapper: HTMLDivElement;
|
|
66
|
+
/** Call after attaching wrapper to the DOM to initialize Panzoom. */
|
|
67
|
+
initPanzoom: () => void;
|
|
68
|
+
enterFullscreen: () => void;
|
|
69
|
+
exitFullscreen: () => void;
|
|
70
|
+
cleanup: () => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Create a panzoom wrapper around an SVG element.
|
|
75
|
+
*
|
|
76
|
+
* The SVG is moved into the wrapper. Returns the wrapper element plus
|
|
77
|
+
* fullscreen controls and a cleanup function.
|
|
78
|
+
*/
|
|
79
|
+
export function createPanzoomWrapper(
|
|
80
|
+
svg: SVGElement,
|
|
81
|
+
_viewport: HTMLElement,
|
|
82
|
+
options?: PanzoomWrapperOptions,
|
|
83
|
+
): PanzoomWrapperResult {
|
|
84
|
+
const wrapperClass = options?.wrapperExtraClass
|
|
85
|
+
? `${options.wrapperExtraClass} ${WRAPPER_CLASS}`
|
|
86
|
+
: WRAPPER_CLASS;
|
|
87
|
+
|
|
88
|
+
const wrapper = document.createElement('div');
|
|
89
|
+
wrapper.className = wrapperClass;
|
|
90
|
+
wrapper.style.cursor = 'grab';
|
|
91
|
+
|
|
92
|
+
const viewport = document.createElement('div');
|
|
93
|
+
viewport.className = VIEWPORT_CLASS;
|
|
94
|
+
if (options?.viewportMinHeight) viewport.style.minHeight = options.viewportMinHeight;
|
|
95
|
+
|
|
96
|
+
const inner = document.createElement('div');
|
|
97
|
+
inner.className = INNER_CLASS;
|
|
98
|
+
|
|
99
|
+
inner.appendChild(svg);
|
|
100
|
+
viewport.appendChild(inner);
|
|
101
|
+
|
|
102
|
+
// Fullscreen button
|
|
103
|
+
const fsBtn = document.createElement('button');
|
|
104
|
+
fsBtn.className = FS_BTN_CLASS;
|
|
105
|
+
fsBtn.title = 'Fullscreen';
|
|
106
|
+
setButtonIcon(fsBtn, Maximize);
|
|
107
|
+
|
|
108
|
+
// Hint
|
|
109
|
+
const hint = document.createElement('div');
|
|
110
|
+
hint.className = HINT_CLASS;
|
|
111
|
+
hint.textContent = HINT_TEXT;
|
|
112
|
+
|
|
113
|
+
wrapper.appendChild(viewport);
|
|
114
|
+
wrapper.appendChild(fsBtn);
|
|
115
|
+
wrapper.appendChild(hint);
|
|
116
|
+
|
|
117
|
+
// Panzoom init is deferred until the wrapper is in the DOM.
|
|
118
|
+
// Callers must invoke initPanzoom() after attaching the wrapper.
|
|
119
|
+
let pz: ReturnType<typeof Panzoom> | null = null;
|
|
120
|
+
|
|
121
|
+
const wheelHandler = (e: WheelEvent) => { pz?.zoomWithWheel(e); };
|
|
122
|
+
viewport.addEventListener('wheel', wheelHandler, { passive: false });
|
|
123
|
+
|
|
124
|
+
const initPanzoom = () => {
|
|
125
|
+
if (pz) return; // already initialized
|
|
126
|
+
pz = Panzoom(inner, { maxScale: 20, contain: 'outside' });
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
let isFullscreen = false;
|
|
130
|
+
|
|
131
|
+
const enterFullscreen = () => {
|
|
132
|
+
isFullscreen = true;
|
|
133
|
+
wrapper.dataset.origClass = wrapper.className;
|
|
134
|
+
wrapper.className = WRAPPER_FULLSCREEN_CLASS;
|
|
135
|
+
wrapper.style.cursor = 'grab';
|
|
136
|
+
viewport.className = VIEWPORT_FULLSCREEN_CLASS;
|
|
137
|
+
if (options?.viewportMinHeight) viewport.style.minHeight = '';
|
|
138
|
+
inner.className = INNER_FULLSCREEN_CLASS;
|
|
139
|
+
hint.textContent = HINT_FULLSCREEN_TEXT;
|
|
140
|
+
hint.className = HINT_FULLSCREEN_CLASS;
|
|
141
|
+
fsBtn.title = 'Exit fullscreen';
|
|
142
|
+
setButtonIcon(fsBtn, Minimize);
|
|
143
|
+
pz?.destroy();
|
|
144
|
+
pz = null;
|
|
145
|
+
|
|
146
|
+
requestAnimationFrame(() => { requestAnimationFrame(() => { setTimeout(() => {
|
|
147
|
+
const s = inner.querySelector('svg');
|
|
148
|
+
if (s && viewport.clientWidth && viewport.clientHeight) {
|
|
149
|
+
const { w: svgW, h: svgH } = parseSvgDimensions(s);
|
|
150
|
+
const { fitScale, fittedW, fittedH, startX, startY } =
|
|
151
|
+
computeZoomToFit(svgW, svgH, viewport.clientWidth, viewport.clientHeight);
|
|
152
|
+
|
|
153
|
+
s.setAttribute('width', String(Math.round(fittedW)));
|
|
154
|
+
s.setAttribute('height', String(Math.round(fittedH)));
|
|
155
|
+
s.style.width = `${String(Math.round(fittedW))}px`;
|
|
156
|
+
s.style.height = `${String(Math.round(fittedH))}px`;
|
|
157
|
+
|
|
158
|
+
pz = Panzoom(inner, { maxScale: 20 / fitScale, minScale: 0.5, startScale: 1, startX, startY });
|
|
159
|
+
} else {
|
|
160
|
+
pz = Panzoom(inner, { maxScale: 20, minScale: 0.1 });
|
|
161
|
+
}
|
|
162
|
+
}, 50); }); });
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const exitFullscreen = () => {
|
|
166
|
+
isFullscreen = false;
|
|
167
|
+
const s = inner.querySelector('svg');
|
|
168
|
+
if (s) {
|
|
169
|
+
s.setAttribute('width', '100%');
|
|
170
|
+
s.removeAttribute('height');
|
|
171
|
+
s.style.width = '';
|
|
172
|
+
s.style.height = '';
|
|
173
|
+
}
|
|
174
|
+
wrapper.className = wrapper.dataset.origClass ?? wrapperClass;
|
|
175
|
+
wrapper.style.cursor = 'grab';
|
|
176
|
+
viewport.className = VIEWPORT_CLASS;
|
|
177
|
+
if (options?.viewportMinHeight) viewport.style.minHeight = options.viewportMinHeight;
|
|
178
|
+
inner.className = INNER_CLASS;
|
|
179
|
+
hint.textContent = HINT_TEXT;
|
|
180
|
+
hint.className = HINT_CLASS;
|
|
181
|
+
fsBtn.title = 'Fullscreen';
|
|
182
|
+
setButtonIcon(fsBtn, Maximize);
|
|
183
|
+
pz?.destroy();
|
|
184
|
+
pz = Panzoom(inner, { maxScale: 20, contain: 'outside', startScale: 1 });
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
fsBtn.addEventListener('click', () => {
|
|
188
|
+
if (isFullscreen) exitFullscreen(); else enterFullscreen();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const escHandler = (e: KeyboardEvent) => {
|
|
192
|
+
if (e.key === 'Escape' && isFullscreen) exitFullscreen();
|
|
193
|
+
};
|
|
194
|
+
document.addEventListener('keydown', escHandler);
|
|
195
|
+
|
|
196
|
+
const cleanup = () => {
|
|
197
|
+
viewport.removeEventListener('wheel', wheelHandler);
|
|
198
|
+
document.removeEventListener('keydown', escHandler);
|
|
199
|
+
pz?.destroy();
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
return { wrapper, initPanzoom, enterFullscreen, exitFullscreen, cleanup };
|
|
203
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility functions for determining if a file is renderable.
|
|
3
|
+
*/
|
|
4
|
+
import type { FileContent } from '@/lib/api';
|
|
5
|
+
|
|
6
|
+
const RENDERABLE_EXTENSIONS = new Set(['.md', '.svg', '.mmd', '.puml', '.plantuml', '.pu']);
|
|
7
|
+
|
|
8
|
+
export function isRenderable(file: FileContent): boolean {
|
|
9
|
+
return file.type === 'markdown' || file.type === 'svg' || file.type === 'mermaid' || file.type === 'plantuml' || !!file.html;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isRenderableExt(reqPath: string): boolean {
|
|
13
|
+
const ext = reqPath ? `.${reqPath.split('.').pop()?.toLowerCase()}` : '';
|
|
14
|
+
return RENDERABLE_EXTENSIONS.has(ext);
|
|
15
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Job list table for runner dashboard.
|
|
3
|
+
* Split into header and body so the header can be pinned outside the scroll area.
|
|
4
|
+
* Supports column sorting with tri-state cycle: asc → desc → clear.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { ArrowDown, ArrowUp, ArrowUpDown, Play } from 'lucide-react';
|
|
8
|
+
import { useNavigate } from 'react-router-dom';
|
|
9
|
+
|
|
10
|
+
import { Button } from '@/components/ui/button';
|
|
11
|
+
import type { RunnerJob } from '@/lib/runner-api';
|
|
12
|
+
|
|
13
|
+
import { StatusPill } from './StatusPill';
|
|
14
|
+
|
|
15
|
+
/** Sortable column keys. */
|
|
16
|
+
export type SortColumn = 'name' | 'type' | 'schedule' | 'status' | 'lastRun';
|
|
17
|
+
export type SortDirection = 'asc' | 'desc';
|
|
18
|
+
export interface SortState {
|
|
19
|
+
column: SortColumn | null;
|
|
20
|
+
direction: SortDirection;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface JobTableHeaderProps {
|
|
24
|
+
sort: SortState;
|
|
25
|
+
onSort: (column: SortColumn) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface JobTableBodyProps {
|
|
29
|
+
jobs: RunnerJob[];
|
|
30
|
+
onRunNow: (id: string) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Shared column widths so header and body stay aligned. */
|
|
34
|
+
const colClass = {
|
|
35
|
+
name: 'w-[30%] px-3 py-2',
|
|
36
|
+
type: 'w-[10%] px-3 py-2',
|
|
37
|
+
schedule: 'w-[15%] px-3 py-2',
|
|
38
|
+
status: 'w-[12%] px-3 py-2',
|
|
39
|
+
lastRun: 'w-[25%] px-3 py-2',
|
|
40
|
+
action: 'w-[8%] px-3 py-2',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function formatTime(iso: string | null): string {
|
|
44
|
+
if (!iso) return '—';
|
|
45
|
+
try {
|
|
46
|
+
return new Date(iso).toLocaleString();
|
|
47
|
+
} catch {
|
|
48
|
+
return iso;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function SortIcon({ column, sort }: { column: SortColumn; sort: SortState }) {
|
|
53
|
+
if (sort.column !== column) {
|
|
54
|
+
return <ArrowUpDown className="h-3 w-3 opacity-40" />;
|
|
55
|
+
}
|
|
56
|
+
return sort.direction === 'asc'
|
|
57
|
+
? <ArrowUp className="h-3 w-3" />
|
|
58
|
+
: <ArrowDown className="h-3 w-3" />;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface ColumnDef {
|
|
62
|
+
key: SortColumn;
|
|
63
|
+
label: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const columns: ColumnDef[] = [
|
|
67
|
+
{ key: 'name', label: 'Name' },
|
|
68
|
+
{ key: 'type', label: 'Type' },
|
|
69
|
+
{ key: 'schedule', label: 'Schedule' },
|
|
70
|
+
{ key: 'status', label: 'Status' },
|
|
71
|
+
{ key: 'lastRun', label: 'Last Run' },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
export function JobTableHeader({ sort, onSort }: JobTableHeaderProps) {
|
|
75
|
+
return (
|
|
76
|
+
<div className="overflow-x-auto">
|
|
77
|
+
<table className="w-full text-sm">
|
|
78
|
+
<thead>
|
|
79
|
+
<tr className="border-b border-border text-left">
|
|
80
|
+
{columns.map((col) => (
|
|
81
|
+
<th
|
|
82
|
+
key={col.key}
|
|
83
|
+
className={`${colClass[col.key]} font-medium text-muted-foreground cursor-pointer select-none hover:text-foreground transition-colors`}
|
|
84
|
+
onClick={() => onSort(col.key)}
|
|
85
|
+
>
|
|
86
|
+
<span className="inline-flex items-center gap-1">
|
|
87
|
+
{col.label}
|
|
88
|
+
<SortIcon column={col.key} sort={sort} />
|
|
89
|
+
</span>
|
|
90
|
+
</th>
|
|
91
|
+
))}
|
|
92
|
+
<th className={`${colClass.action} font-medium text-muted-foreground`} />
|
|
93
|
+
</tr>
|
|
94
|
+
</thead>
|
|
95
|
+
</table>
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function JobTableBody({ jobs, onRunNow }: JobTableBodyProps) {
|
|
101
|
+
const navigate = useNavigate();
|
|
102
|
+
|
|
103
|
+
if (jobs.length === 0) {
|
|
104
|
+
return (
|
|
105
|
+
<div className="text-center py-8 text-muted-foreground">
|
|
106
|
+
No jobs found
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div className="overflow-x-auto">
|
|
113
|
+
<table className="w-full text-sm">
|
|
114
|
+
<tbody>
|
|
115
|
+
{jobs.map((job) => (
|
|
116
|
+
<tr
|
|
117
|
+
key={job.id}
|
|
118
|
+
className="border-b border-border hover:bg-muted/50 cursor-pointer transition-colors"
|
|
119
|
+
onClick={() => navigate(`/runner/${job.id}`)}
|
|
120
|
+
>
|
|
121
|
+
<td className={`${colClass.name} font-medium`}>{job.name}</td>
|
|
122
|
+
<td className={`${colClass.type} text-muted-foreground`}>{job.type}</td>
|
|
123
|
+
<td className={`${colClass.schedule} font-mono text-xs text-muted-foreground`}>
|
|
124
|
+
{job.schedule}
|
|
125
|
+
</td>
|
|
126
|
+
<td className={colClass.status}>
|
|
127
|
+
<StatusPill status={job.enabled ? job.status : 'disabled'} />
|
|
128
|
+
</td>
|
|
129
|
+
<td className={`${colClass.lastRun} text-muted-foreground text-xs`}>
|
|
130
|
+
{formatTime(job.lastRun)}
|
|
131
|
+
</td>
|
|
132
|
+
<td className={colClass.action}>
|
|
133
|
+
<Button
|
|
134
|
+
variant="ghost"
|
|
135
|
+
size="icon"
|
|
136
|
+
className="h-7 w-7"
|
|
137
|
+
title="Run Now"
|
|
138
|
+
onClick={(e) => {
|
|
139
|
+
e.stopPropagation();
|
|
140
|
+
onRunNow(job.id);
|
|
141
|
+
}}
|
|
142
|
+
>
|
|
143
|
+
<Play className="h-3.5 w-3.5" />
|
|
144
|
+
</Button>
|
|
145
|
+
</td>
|
|
146
|
+
</tr>
|
|
147
|
+
))}
|
|
148
|
+
</tbody>
|
|
149
|
+
</table>
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|