clawport-ui 0.1.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.example +35 -0
- package/BRANDING.md +131 -0
- package/CLAUDE.md +252 -0
- package/README.md +262 -0
- package/SETUP.md +337 -0
- package/app/agents/[id]/page.tsx +727 -0
- package/app/api/agents/route.ts +12 -0
- package/app/api/chat/[id]/route.ts +139 -0
- package/app/api/cron-runs/route.ts +13 -0
- package/app/api/crons/route.ts +12 -0
- package/app/api/kanban/chat/[id]/route.ts +119 -0
- package/app/api/kanban/chat-history/[ticketId]/route.ts +36 -0
- package/app/api/memory/route.ts +12 -0
- package/app/api/transcribe/route.ts +37 -0
- package/app/api/tts/route.ts +42 -0
- package/app/chat/[id]/page.tsx +10 -0
- package/app/chat/page.tsx +200 -0
- package/app/crons/page.tsx +870 -0
- package/app/docs/page.tsx +399 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +692 -0
- package/app/kanban/page.tsx +327 -0
- package/app/layout.tsx +45 -0
- package/app/memory/page.tsx +685 -0
- package/app/page.tsx +817 -0
- package/app/providers.tsx +37 -0
- package/app/settings/page.tsx +901 -0
- package/app/settings-provider.tsx +209 -0
- package/components/AgentAvatar.tsx +54 -0
- package/components/AgentNode.tsx +122 -0
- package/components/Breadcrumbs.tsx +126 -0
- package/components/DynamicFavicon.tsx +62 -0
- package/components/ErrorState.tsx +97 -0
- package/components/FeedView.tsx +494 -0
- package/components/GlobalSearch.tsx +571 -0
- package/components/GridView.tsx +532 -0
- package/components/ManorMap.tsx +157 -0
- package/components/MobileSidebar.tsx +251 -0
- package/components/NavLinks.tsx +271 -0
- package/components/OnboardingWizard.tsx +1067 -0
- package/components/Sidebar.tsx +115 -0
- package/components/ThemeToggle.tsx +108 -0
- package/components/chat/AgentList.tsx +537 -0
- package/components/chat/ConversationView.tsx +1047 -0
- package/components/chat/FileAttachment.tsx +140 -0
- package/components/chat/MediaPreview.tsx +111 -0
- package/components/chat/VoiceMessage.tsx +139 -0
- package/components/crons/PipelineGraph.tsx +327 -0
- package/components/crons/WeeklySchedule.tsx +630 -0
- package/components/docs/AgentsSection.tsx +209 -0
- package/components/docs/ApiReferenceSection.tsx +256 -0
- package/components/docs/ArchitectureSection.tsx +221 -0
- package/components/docs/ComponentsSection.tsx +253 -0
- package/components/docs/CronSystemSection.tsx +235 -0
- package/components/docs/DocSection.tsx +346 -0
- package/components/docs/GettingStartedSection.tsx +169 -0
- package/components/docs/ThemingSection.tsx +257 -0
- package/components/docs/TroubleshootingSection.tsx +200 -0
- package/components/kanban/AgentPicker.tsx +321 -0
- package/components/kanban/CreateTicketModal.tsx +333 -0
- package/components/kanban/KanbanBoard.tsx +70 -0
- package/components/kanban/KanbanColumn.tsx +166 -0
- package/components/kanban/TicketCard.tsx +245 -0
- package/components/kanban/TicketDetailPanel.tsx +850 -0
- package/components/ui/badge.tsx +48 -0
- package/components/ui/button.tsx +64 -0
- package/components/ui/card.tsx +92 -0
- package/components/ui/dialog.tsx +158 -0
- package/components/ui/scroll-area.tsx +58 -0
- package/components/ui/separator.tsx +28 -0
- package/components/ui/skeleton.tsx +27 -0
- package/components/ui/tabs.tsx +91 -0
- package/components/ui/tooltip.tsx +57 -0
- package/components.json +23 -0
- package/docs/API.md +648 -0
- package/docs/COMPONENTS.md +1059 -0
- package/docs/THEMING.md +795 -0
- package/lib/agents-registry.ts +35 -0
- package/lib/agents.json +282 -0
- package/lib/agents.test.ts +367 -0
- package/lib/agents.ts +32 -0
- package/lib/anthropic.test.ts +422 -0
- package/lib/anthropic.ts +220 -0
- package/lib/api-error.ts +16 -0
- package/lib/audio-recorder.test.ts +72 -0
- package/lib/audio-recorder.ts +169 -0
- package/lib/conversations.test.ts +331 -0
- package/lib/conversations.ts +117 -0
- package/lib/cron-pipelines.test.ts +69 -0
- package/lib/cron-pipelines.ts +58 -0
- package/lib/cron-runs.test.ts +118 -0
- package/lib/cron-runs.ts +67 -0
- package/lib/cron-utils.test.ts +222 -0
- package/lib/cron-utils.ts +160 -0
- package/lib/crons.test.ts +502 -0
- package/lib/crons.ts +114 -0
- package/lib/env.test.ts +44 -0
- package/lib/env.ts +14 -0
- package/lib/kanban/automation.test.ts +245 -0
- package/lib/kanban/automation.ts +143 -0
- package/lib/kanban/chat-store.test.ts +149 -0
- package/lib/kanban/chat-store.ts +81 -0
- package/lib/kanban/store.test.ts +238 -0
- package/lib/kanban/store.ts +98 -0
- package/lib/kanban/types.ts +50 -0
- package/lib/kanban/useAgentWork.ts +78 -0
- package/lib/memory.ts +45 -0
- package/lib/multimodal.test.ts +219 -0
- package/lib/multimodal.ts +68 -0
- package/lib/pipeline.integration.test.ts +343 -0
- package/lib/sanitize.ts +194 -0
- package/lib/settings.test.ts +137 -0
- package/lib/settings.ts +94 -0
- package/lib/styles.ts +24 -0
- package/lib/themes.ts +9 -0
- package/lib/transcribe.test.ts +141 -0
- package/lib/transcribe.ts +111 -0
- package/lib/types.ts +66 -0
- package/lib/utils.ts +6 -0
- package/lib/validation.test.ts +132 -0
- package/lib/validation.ts +80 -0
- package/next.config.ts +7 -0
- package/package.json +56 -0
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/scripts/setup.mjs +215 -0
- package/tsconfig.json +34 -0
- package/vitest.config.ts +17 -0
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
import type { Agent, CronJob, CronRun } from "@/lib/types";
|
|
6
|
+
import { formatDuration } from "@/lib/cron-utils";
|
|
7
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
8
|
+
import { RefreshCw, BarChart3, Calendar, GitBranch, Copy, Check } from "lucide-react";
|
|
9
|
+
import { ErrorState } from "@/components/ErrorState";
|
|
10
|
+
import { WeeklySchedule } from "@/components/crons/WeeklySchedule";
|
|
11
|
+
import { PipelineGraph } from "@/components/crons/PipelineGraph";
|
|
12
|
+
|
|
13
|
+
/* ─── Time helpers ──────────────────────────────────────────────── */
|
|
14
|
+
|
|
15
|
+
function timeAgo(dateStr: string | null): string {
|
|
16
|
+
if (!dateStr) return "never";
|
|
17
|
+
const d = new Date(dateStr);
|
|
18
|
+
if (isNaN(d.getTime())) return "\u2014";
|
|
19
|
+
const diff = Date.now() - d.getTime();
|
|
20
|
+
const mins = Math.floor(diff / 60000);
|
|
21
|
+
const hrs = Math.floor(diff / 3600000);
|
|
22
|
+
const days = Math.floor(diff / 86400000);
|
|
23
|
+
if (diff < 0) {
|
|
24
|
+
const absDiff = Math.abs(diff);
|
|
25
|
+
const m = Math.floor(absDiff / 60000);
|
|
26
|
+
const h = Math.floor(absDiff / 3600000);
|
|
27
|
+
const dy = Math.floor(absDiff / 86400000);
|
|
28
|
+
if (m < 60) return `in ${m}m`;
|
|
29
|
+
if (h < 24) return `in ${h}h`;
|
|
30
|
+
return `in ${dy}d`;
|
|
31
|
+
}
|
|
32
|
+
if (mins < 1) return "just now";
|
|
33
|
+
if (mins < 60) return `${mins}m ago`;
|
|
34
|
+
if (hrs < 24) return `${hrs}h ago`;
|
|
35
|
+
return `${days}d ago`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function nextRunLabel(dateStr: string | null): string {
|
|
39
|
+
if (!dateStr) return "not scheduled";
|
|
40
|
+
const d = new Date(dateStr);
|
|
41
|
+
if (isNaN(d.getTime())) return "\u2014";
|
|
42
|
+
const diff = d.getTime() - Date.now();
|
|
43
|
+
if (diff < 0) return "overdue";
|
|
44
|
+
const mins = Math.floor(diff / 60000);
|
|
45
|
+
const hrs = Math.floor(diff / 3600000);
|
|
46
|
+
const days = Math.floor(diff / 86400000);
|
|
47
|
+
if (mins < 60) return `in ${mins}m`;
|
|
48
|
+
if (hrs < 24) return `in ${hrs}h`;
|
|
49
|
+
return `in ${days}d`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* ─── Types ─────────────────────────────────────────────────────── */
|
|
53
|
+
|
|
54
|
+
type Filter = "all" | "ok" | "error" | "idle";
|
|
55
|
+
type Tab = "overview" | "schedule" | "pipelines";
|
|
56
|
+
|
|
57
|
+
const STATUS_DOT: Record<string, string> = {
|
|
58
|
+
ok: "var(--system-green)",
|
|
59
|
+
error: "var(--system-red)",
|
|
60
|
+
idle: "var(--text-tertiary)",
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const PILLS: { key: Filter; label: string; dotColor: string }[] = [
|
|
64
|
+
{ key: "all", label: "All", dotColor: "var(--text-primary)" },
|
|
65
|
+
{ key: "ok", label: "OK", dotColor: "var(--system-green)" },
|
|
66
|
+
{ key: "error", label: "Errors", dotColor: "var(--system-red)" },
|
|
67
|
+
{ key: "idle", label: "Idle", dotColor: "var(--text-tertiary)" },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
const TAB_ICONS: Record<Tab, React.ComponentType<{ size: number; className?: string }>> = {
|
|
71
|
+
overview: BarChart3,
|
|
72
|
+
schedule: Calendar,
|
|
73
|
+
pipelines: GitBranch,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const TABS: { key: Tab; label: string }[] = [
|
|
77
|
+
{ key: "overview", label: "Overview" },
|
|
78
|
+
{ key: "schedule", label: "Schedule" },
|
|
79
|
+
{ key: "pipelines", label: "Pipelines" },
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
/* ─── Delivery helpers ─────────────────────────────────────────── */
|
|
83
|
+
|
|
84
|
+
function DeliveryBadge({ cron }: { cron: CronJob }) {
|
|
85
|
+
if (!cron.delivery) {
|
|
86
|
+
return (
|
|
87
|
+
<span style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)" }}>
|
|
88
|
+
No delivery configured
|
|
89
|
+
</span>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const { delivery, lastDeliveryStatus } = cron;
|
|
94
|
+
const hasMissingTarget = !delivery.to;
|
|
95
|
+
|
|
96
|
+
if (hasMissingTarget) {
|
|
97
|
+
return (
|
|
98
|
+
<span style={{ fontSize: "var(--text-caption1)", color: "var(--system-red)" }}>
|
|
99
|
+
Target missing — add 'to' field to delivery config
|
|
100
|
+
</span>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const isDelivered = lastDeliveryStatus === "delivered";
|
|
105
|
+
const isUnknown = !lastDeliveryStatus || lastDeliveryStatus === "unknown";
|
|
106
|
+
const color = isDelivered ? "var(--system-green)" : isUnknown ? "var(--system-orange)" : "var(--system-orange)";
|
|
107
|
+
const statusText = isDelivered ? "Delivered" : isUnknown ? "Unknown" : lastDeliveryStatus;
|
|
108
|
+
|
|
109
|
+
// Truncate the "to" field for display
|
|
110
|
+
const toDisplay = delivery.to && delivery.to.length > 20
|
|
111
|
+
? delivery.to.slice(0, 17) + "..."
|
|
112
|
+
: delivery.to;
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<span style={{ fontSize: "var(--text-caption1)" }}>
|
|
116
|
+
<span style={{ color }}>
|
|
117
|
+
{isDelivered ? "\u2713" : "\u25CB"}{" "}
|
|
118
|
+
</span>
|
|
119
|
+
<span style={{ color: "var(--text-secondary)" }}>
|
|
120
|
+
{delivery.channel}
|
|
121
|
+
</span>
|
|
122
|
+
{toDisplay && (
|
|
123
|
+
<span style={{ color: "var(--text-tertiary)", marginLeft: 4 }}>
|
|
124
|
+
{toDisplay}
|
|
125
|
+
</span>
|
|
126
|
+
)}
|
|
127
|
+
<span style={{ color, marginLeft: 8, fontWeight: 500 }}>
|
|
128
|
+
{statusText}
|
|
129
|
+
</span>
|
|
130
|
+
</span>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* ─── Summary Cards ──────────────────────────────────────────── */
|
|
135
|
+
|
|
136
|
+
function HealthCard({ ok, total }: { ok: number; total: number }) {
|
|
137
|
+
const pct = total === 0 ? 100 : Math.round((ok / total) * 100);
|
|
138
|
+
const r = 20;
|
|
139
|
+
const circumference = 2 * Math.PI * r;
|
|
140
|
+
const offset = circumference - (pct / 100) * circumference;
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<div
|
|
144
|
+
style={{
|
|
145
|
+
background: "var(--material-regular)",
|
|
146
|
+
border: "1px solid var(--separator)",
|
|
147
|
+
borderRadius: "var(--radius-md)",
|
|
148
|
+
padding: "var(--space-4)",
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
<div className="flex items-center" style={{ gap: "var(--space-3)" }}>
|
|
152
|
+
<svg width="48" height="48" viewBox="0 0 48 48">
|
|
153
|
+
<circle cx="24" cy="24" r={r} fill="none" stroke="var(--fill-tertiary)" strokeWidth="5" />
|
|
154
|
+
<circle
|
|
155
|
+
cx="24" cy="24" r={r} fill="none"
|
|
156
|
+
stroke="var(--system-green)" strokeWidth="5"
|
|
157
|
+
strokeDasharray={circumference} strokeDashoffset={offset}
|
|
158
|
+
strokeLinecap="round" transform="rotate(-90 24 24)"
|
|
159
|
+
style={{ transition: "stroke-dashoffset 600ms var(--ease-smooth)" }}
|
|
160
|
+
/>
|
|
161
|
+
<text x="24" y="24" textAnchor="middle" dominantBaseline="central"
|
|
162
|
+
fill="var(--text-primary)" fontSize="11" fontWeight="700">{pct}%</text>
|
|
163
|
+
</svg>
|
|
164
|
+
<div>
|
|
165
|
+
<div style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)", fontWeight: "var(--weight-medium)" }}>
|
|
166
|
+
Health
|
|
167
|
+
</div>
|
|
168
|
+
<div style={{ fontSize: "var(--text-footnote)", color: "var(--text-primary)", fontWeight: "var(--weight-semibold)" }}>
|
|
169
|
+
{ok}/{total} healthy
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function AttentionCard({ errors }: { errors: CronJob[] }) {
|
|
178
|
+
const hasErrors = errors.length > 0;
|
|
179
|
+
return (
|
|
180
|
+
<div
|
|
181
|
+
style={{
|
|
182
|
+
background: "var(--material-regular)",
|
|
183
|
+
border: "1px solid var(--separator)",
|
|
184
|
+
borderRadius: "var(--radius-md)",
|
|
185
|
+
padding: "var(--space-4)",
|
|
186
|
+
}}
|
|
187
|
+
>
|
|
188
|
+
<div style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)", fontWeight: "var(--weight-medium)", marginBottom: "var(--space-1)" }}>
|
|
189
|
+
Attention
|
|
190
|
+
</div>
|
|
191
|
+
{hasErrors ? (
|
|
192
|
+
<>
|
|
193
|
+
<div style={{ fontSize: "var(--text-footnote)", color: "var(--system-red)", fontWeight: "var(--weight-semibold)" }}>
|
|
194
|
+
{errors.length} need{errors.length === 1 ? "s" : ""} fix
|
|
195
|
+
</div>
|
|
196
|
+
<div className="truncate" style={{ fontSize: "var(--text-caption2)", color: "var(--text-tertiary)", marginTop: 2 }}>
|
|
197
|
+
{errors[0].name}
|
|
198
|
+
</div>
|
|
199
|
+
</>
|
|
200
|
+
) : (
|
|
201
|
+
<div className="flex items-center" style={{ gap: "var(--space-1)" }}>
|
|
202
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
|
|
203
|
+
<circle cx="8" cy="8" r="7" stroke="var(--system-green)" strokeWidth="1.5" />
|
|
204
|
+
<polyline points="5 8 7 10 11 6" stroke="var(--system-green)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
|
205
|
+
</svg>
|
|
206
|
+
<span style={{ fontSize: "var(--text-footnote)", color: "var(--system-green)", fontWeight: "var(--weight-semibold)" }}>
|
|
207
|
+
All clear
|
|
208
|
+
</span>
|
|
209
|
+
</div>
|
|
210
|
+
)}
|
|
211
|
+
</div>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function DeliveryCard({ crons }: { crons: CronJob[] }) {
|
|
216
|
+
const withDelivery = crons.filter(c => c.delivery);
|
|
217
|
+
const configured = withDelivery.filter(c => c.delivery?.to);
|
|
218
|
+
const missing = withDelivery.filter(c => !c.delivery?.to);
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<div
|
|
222
|
+
style={{
|
|
223
|
+
background: "var(--material-regular)",
|
|
224
|
+
border: "1px solid var(--separator)",
|
|
225
|
+
borderRadius: "var(--radius-md)",
|
|
226
|
+
padding: "var(--space-4)",
|
|
227
|
+
}}
|
|
228
|
+
>
|
|
229
|
+
<div style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)", fontWeight: "var(--weight-medium)", marginBottom: "var(--space-1)" }}>
|
|
230
|
+
Delivery
|
|
231
|
+
</div>
|
|
232
|
+
<div style={{ fontSize: "var(--text-footnote)", color: "var(--text-primary)", fontWeight: "var(--weight-semibold)" }}>
|
|
233
|
+
{configured.length} configured
|
|
234
|
+
</div>
|
|
235
|
+
{missing.length > 0 && (
|
|
236
|
+
<div style={{ fontSize: "var(--text-caption2)", color: "var(--system-orange)", marginTop: 2 }}>
|
|
237
|
+
{missing.length} missing target
|
|
238
|
+
</div>
|
|
239
|
+
)}
|
|
240
|
+
</div>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/* ─── Categorized Error Banners ──────────────────────────────── */
|
|
245
|
+
|
|
246
|
+
function ErrorsBanners({
|
|
247
|
+
crons,
|
|
248
|
+
agentMap,
|
|
249
|
+
onCopy,
|
|
250
|
+
copiedId,
|
|
251
|
+
}: {
|
|
252
|
+
crons: CronJob[];
|
|
253
|
+
agentMap: Map<string, Agent>;
|
|
254
|
+
onCopy: (id: string, text: string) => void;
|
|
255
|
+
copiedId: string | null;
|
|
256
|
+
}) {
|
|
257
|
+
// Execution errors: status=error with actual error messages (not delivery target issues)
|
|
258
|
+
const execErrors = crons.filter(c => c.status === "error" && c.lastError && !c.lastError.includes("delivery target is missing"));
|
|
259
|
+
// Config issues: delivery target missing
|
|
260
|
+
const configIssues = crons.filter(c => c.delivery && !c.delivery.to);
|
|
261
|
+
|
|
262
|
+
if (execErrors.length === 0 && configIssues.length === 0) return null;
|
|
263
|
+
|
|
264
|
+
return (
|
|
265
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-3)", marginBottom: "var(--space-4)" }}>
|
|
266
|
+
{/* Execution Errors */}
|
|
267
|
+
{execErrors.length > 0 && (
|
|
268
|
+
<div
|
|
269
|
+
style={{
|
|
270
|
+
background: "rgba(255,69,58,0.04)",
|
|
271
|
+
borderLeft: "3px solid var(--system-red)",
|
|
272
|
+
borderRadius: "var(--radius-sm)",
|
|
273
|
+
padding: "var(--space-3) var(--space-4)",
|
|
274
|
+
}}
|
|
275
|
+
>
|
|
276
|
+
<div style={{ fontSize: "var(--text-footnote)", color: "var(--system-red)", fontWeight: "var(--weight-semibold)", marginBottom: "var(--space-2)" }}>
|
|
277
|
+
{execErrors.length} execution error{execErrors.length !== 1 ? "s" : ""}
|
|
278
|
+
</div>
|
|
279
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-2)" }}>
|
|
280
|
+
{execErrors.map((cron) => {
|
|
281
|
+
const agent = cron.agentId ? agentMap.get(cron.agentId) : null;
|
|
282
|
+
return (
|
|
283
|
+
<div key={cron.id} className="flex items-center" style={{ gap: "var(--space-2)", fontSize: "var(--text-caption1)", minHeight: 28 }}>
|
|
284
|
+
<span className="flex-shrink-0 rounded-full animate-error-pulse" style={{ width: 6, height: 6, background: "var(--system-red)" }} />
|
|
285
|
+
<span className="flex-shrink-0" style={{ fontWeight: "var(--weight-semibold)", color: "var(--text-primary)" }}>{cron.name}</span>
|
|
286
|
+
{cron.lastError && (
|
|
287
|
+
<span className="truncate" style={{ color: "var(--text-tertiary)", flex: 1, minWidth: 0 }}>{cron.lastError}</span>
|
|
288
|
+
)}
|
|
289
|
+
{cron.consecutiveErrors > 1 && (
|
|
290
|
+
<span style={{ fontSize: "var(--text-caption2)", color: "var(--system-orange)", flexShrink: 0 }}>
|
|
291
|
+
{cron.consecutiveErrors}x
|
|
292
|
+
</span>
|
|
293
|
+
)}
|
|
294
|
+
{cron.lastError && (
|
|
295
|
+
<button
|
|
296
|
+
onClick={() => onCopy(cron.id, cron.lastError!)}
|
|
297
|
+
className="btn-ghost focus-ring flex-shrink-0"
|
|
298
|
+
aria-label={`Copy error for ${cron.name}`}
|
|
299
|
+
style={{ padding: "2px 8px", borderRadius: "var(--radius-sm)", fontSize: "var(--text-caption2)", fontWeight: "var(--weight-medium)", display: "inline-flex", alignItems: "center", gap: 3 }}
|
|
300
|
+
>
|
|
301
|
+
{copiedId === cron.id ? <Check size={12} /> : <Copy size={12} />}
|
|
302
|
+
{copiedId === cron.id ? "Copied" : "Copy"}
|
|
303
|
+
</button>
|
|
304
|
+
)}
|
|
305
|
+
{agent && (
|
|
306
|
+
<Link href={`/chat/${agent.id}`} className="flex-shrink-0 focus-ring" style={{ fontSize: "var(--text-caption2)", color: "var(--system-blue)", textDecoration: "none" }}>
|
|
307
|
+
{agent.name}
|
|
308
|
+
</Link>
|
|
309
|
+
)}
|
|
310
|
+
</div>
|
|
311
|
+
);
|
|
312
|
+
})}
|
|
313
|
+
</div>
|
|
314
|
+
</div>
|
|
315
|
+
)}
|
|
316
|
+
|
|
317
|
+
{/* Configuration Issues */}
|
|
318
|
+
{configIssues.length > 0 && (
|
|
319
|
+
<div
|
|
320
|
+
style={{
|
|
321
|
+
background: "rgba(255,149,0,0.04)",
|
|
322
|
+
borderLeft: "3px solid var(--system-orange)",
|
|
323
|
+
borderRadius: "var(--radius-sm)",
|
|
324
|
+
padding: "var(--space-3) var(--space-4)",
|
|
325
|
+
}}
|
|
326
|
+
>
|
|
327
|
+
<div style={{ fontSize: "var(--text-footnote)", color: "var(--system-orange)", fontWeight: "var(--weight-semibold)", marginBottom: "var(--space-2)" }}>
|
|
328
|
+
{configIssues.length} delivery target{configIssues.length !== 1 ? "s" : ""} missing
|
|
329
|
+
</div>
|
|
330
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-1)" }}>
|
|
331
|
+
{configIssues.map((cron) => (
|
|
332
|
+
<div key={cron.id} className="flex items-center" style={{ gap: "var(--space-2)", fontSize: "var(--text-caption1)" }}>
|
|
333
|
+
<span style={{ width: 6, height: 6, borderRadius: "50%", background: "var(--system-orange)", flexShrink: 0 }} />
|
|
334
|
+
<span style={{ color: "var(--text-primary)", fontWeight: "var(--weight-medium)" }}>{cron.name}</span>
|
|
335
|
+
<span style={{ color: "var(--text-tertiary)" }}>
|
|
336
|
+
{cron.delivery?.channel} — no 'to' field
|
|
337
|
+
</span>
|
|
338
|
+
</div>
|
|
339
|
+
))}
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
)}
|
|
343
|
+
</div>
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/* ─── Recent Runs (lazy-loaded) ──────────────────────────────── */
|
|
348
|
+
|
|
349
|
+
function RecentRuns({ jobId }: { jobId: string }) {
|
|
350
|
+
const [runs, setRuns] = useState<CronRun[] | null>(null);
|
|
351
|
+
const [loading, setLoading] = useState(true);
|
|
352
|
+
|
|
353
|
+
useEffect(() => {
|
|
354
|
+
fetch(`/api/cron-runs?jobId=${encodeURIComponent(jobId)}`)
|
|
355
|
+
.then(r => r.ok ? r.json() : [])
|
|
356
|
+
.then(data => { setRuns((data as CronRun[]).slice(0, 5)); setLoading(false); })
|
|
357
|
+
.catch(() => { setRuns([]); setLoading(false); });
|
|
358
|
+
}, [jobId]);
|
|
359
|
+
|
|
360
|
+
if (loading) {
|
|
361
|
+
return (
|
|
362
|
+
<div style={{ marginTop: "var(--space-3)" }}>
|
|
363
|
+
<div style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)", fontWeight: "var(--weight-semibold)", marginBottom: "var(--space-2)" }}>
|
|
364
|
+
Recent Runs
|
|
365
|
+
</div>
|
|
366
|
+
{[1, 2, 3].map(i => (
|
|
367
|
+
<Skeleton key={i} style={{ height: 16, marginBottom: 4, width: "80%" }} />
|
|
368
|
+
))}
|
|
369
|
+
</div>
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (!runs || runs.length === 0) {
|
|
374
|
+
return (
|
|
375
|
+
<div style={{ marginTop: "var(--space-3)" }}>
|
|
376
|
+
<div style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)", fontWeight: "var(--weight-semibold)", marginBottom: "var(--space-2)" }}>
|
|
377
|
+
Recent Runs
|
|
378
|
+
</div>
|
|
379
|
+
<div style={{ fontSize: "var(--text-caption2)", color: "var(--text-tertiary)" }}>No run history</div>
|
|
380
|
+
</div>
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return (
|
|
385
|
+
<div style={{ marginTop: "var(--space-3)" }}>
|
|
386
|
+
<div style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)", fontWeight: "var(--weight-semibold)", marginBottom: "var(--space-2)" }}>
|
|
387
|
+
Recent Runs
|
|
388
|
+
</div>
|
|
389
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
|
390
|
+
{runs.map((run, i) => {
|
|
391
|
+
const statusDot = run.status === "ok" ? "var(--system-green)" : "var(--system-red)";
|
|
392
|
+
const ago = timeAgo(new Date(run.ts).toISOString());
|
|
393
|
+
const duration = formatDuration(run.durationMs);
|
|
394
|
+
const deliveryStat = run.deliveryStatus === "delivered" ? "Delivered" : run.deliveryStatus === "unknown" ? "Unknown" : run.deliveryStatus || "—";
|
|
395
|
+
const summaryText = run.status === "error" ? (run.error || "Error") : (run.summary || "—");
|
|
396
|
+
const truncatedSummary = summaryText.length > 60 ? summaryText.slice(0, 57) + "..." : summaryText;
|
|
397
|
+
|
|
398
|
+
return (
|
|
399
|
+
<div
|
|
400
|
+
key={`${run.ts}-${i}`}
|
|
401
|
+
className="flex items-center"
|
|
402
|
+
style={{
|
|
403
|
+
gap: "var(--space-2)",
|
|
404
|
+
fontSize: "var(--text-caption2)",
|
|
405
|
+
minHeight: 22,
|
|
406
|
+
padding: "2px 0",
|
|
407
|
+
}}
|
|
408
|
+
>
|
|
409
|
+
<span style={{ width: 6, height: 6, borderRadius: "50%", background: statusDot, flexShrink: 0 }} />
|
|
410
|
+
<span style={{ color: "var(--text-tertiary)", minWidth: 52, flexShrink: 0 }}>{ago}</span>
|
|
411
|
+
<span style={{ color: "var(--text-secondary)", minWidth: 52, flexShrink: 0 }}>{duration}</span>
|
|
412
|
+
<span style={{ color: run.deliveryStatus === "delivered" ? "var(--system-green)" : "var(--text-tertiary)", minWidth: 60, flexShrink: 0 }}>
|
|
413
|
+
{deliveryStat}
|
|
414
|
+
</span>
|
|
415
|
+
<span className="truncate" style={{ color: "var(--text-tertiary)", minWidth: 0, flex: 1 }}>
|
|
416
|
+
{truncatedSummary}
|
|
417
|
+
</span>
|
|
418
|
+
</div>
|
|
419
|
+
);
|
|
420
|
+
})}
|
|
421
|
+
</div>
|
|
422
|
+
</div>
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/* ─── Component ─────────────────────────────────────────────────── */
|
|
427
|
+
|
|
428
|
+
export default function CronsPage() {
|
|
429
|
+
const [crons, setCrons] = useState<CronJob[]>([]);
|
|
430
|
+
const [agents, setAgents] = useState<Agent[]>([]);
|
|
431
|
+
const [filter, setFilter] = useState<Filter>("all");
|
|
432
|
+
const [tab, setTab] = useState<Tab>("overview");
|
|
433
|
+
const [expanded, setExpanded] = useState<string | null>(null);
|
|
434
|
+
const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
|
|
435
|
+
const [loading, setLoading] = useState(true);
|
|
436
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
437
|
+
const [error, setError] = useState<string | null>(null);
|
|
438
|
+
const [updatedAgo, setUpdatedAgo] = useState("just now");
|
|
439
|
+
const [copiedId, setCopiedId] = useState<string | null>(null);
|
|
440
|
+
|
|
441
|
+
const pillsRef = useRef<HTMLDivElement>(null);
|
|
442
|
+
|
|
443
|
+
const refresh = useCallback(() => {
|
|
444
|
+
setRefreshing(true);
|
|
445
|
+
setError(null);
|
|
446
|
+
Promise.all([
|
|
447
|
+
fetch("/api/crons").then((r) => {
|
|
448
|
+
if (!r.ok) throw new Error("Failed to load crons");
|
|
449
|
+
return r.json();
|
|
450
|
+
}),
|
|
451
|
+
fetch("/api/agents").then((r) => {
|
|
452
|
+
if (!r.ok) throw new Error("Failed to load agents");
|
|
453
|
+
return r.json();
|
|
454
|
+
}),
|
|
455
|
+
])
|
|
456
|
+
.then(([c, a]) => {
|
|
457
|
+
setCrons(c);
|
|
458
|
+
setAgents(a);
|
|
459
|
+
setLastRefresh(new Date());
|
|
460
|
+
setLoading(false);
|
|
461
|
+
setRefreshing(false);
|
|
462
|
+
})
|
|
463
|
+
.catch((err) => {
|
|
464
|
+
setError(err instanceof Error ? err.message : "Unknown error");
|
|
465
|
+
setLoading(false);
|
|
466
|
+
setRefreshing(false);
|
|
467
|
+
});
|
|
468
|
+
}, []);
|
|
469
|
+
|
|
470
|
+
useEffect(() => {
|
|
471
|
+
refresh();
|
|
472
|
+
const interval = setInterval(refresh, 60000);
|
|
473
|
+
return () => clearInterval(interval);
|
|
474
|
+
}, [refresh]);
|
|
475
|
+
|
|
476
|
+
useEffect(() => {
|
|
477
|
+
const tick = () => setUpdatedAgo(timeAgo(lastRefresh.toISOString()));
|
|
478
|
+
tick();
|
|
479
|
+
const interval = setInterval(tick, 30000);
|
|
480
|
+
return () => clearInterval(interval);
|
|
481
|
+
}, [lastRefresh]);
|
|
482
|
+
|
|
483
|
+
/* Derived data */
|
|
484
|
+
const agentMap = new Map(agents.map((a) => [a.id, a]));
|
|
485
|
+
const statusOrder: Record<string, number> = { error: 0, idle: 1, ok: 2 };
|
|
486
|
+
const filtered = crons
|
|
487
|
+
.filter((c) => filter === "all" || c.status === filter)
|
|
488
|
+
.sort((a, b) => (statusOrder[a.status] ?? 9) - (statusOrder[b.status] ?? 9));
|
|
489
|
+
const counts = {
|
|
490
|
+
all: crons.length,
|
|
491
|
+
ok: crons.filter((c) => c.status === "ok").length,
|
|
492
|
+
error: crons.filter((c) => c.status === "error").length,
|
|
493
|
+
idle: crons.filter((c) => c.status === "idle").length,
|
|
494
|
+
};
|
|
495
|
+
const errorCrons = crons.filter((c) => c.status === "error");
|
|
496
|
+
|
|
497
|
+
function handlePillKeyDown(e: React.KeyboardEvent) {
|
|
498
|
+
const pills = pillsRef.current;
|
|
499
|
+
if (!pills) return;
|
|
500
|
+
const buttons = Array.from(pills.querySelectorAll<HTMLButtonElement>('[role="tab"]'));
|
|
501
|
+
const current = buttons.findIndex((b) => b.getAttribute("aria-selected") === "true");
|
|
502
|
+
let next = current;
|
|
503
|
+
if (e.key === "ArrowRight" || e.key === "ArrowDown") { e.preventDefault(); next = (current + 1) % buttons.length; }
|
|
504
|
+
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { e.preventDefault(); next = (current - 1 + buttons.length) % buttons.length; }
|
|
505
|
+
if (next !== current) { buttons[next].focus(); buttons[next].click(); }
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function copyError(cronId: string, text: string) {
|
|
509
|
+
navigator.clipboard.writeText(text).then(() => {
|
|
510
|
+
setCopiedId(cronId);
|
|
511
|
+
setTimeout(() => setCopiedId(null), 2000);
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (error && crons.length === 0) {
|
|
516
|
+
return <ErrorState message={error} onRetry={refresh} />;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return (
|
|
520
|
+
<div className="h-full flex flex-col overflow-hidden animate-fade-in" style={{ background: "var(--bg)" }}>
|
|
521
|
+
{/* ── Sticky header ──────────────────────────────────────── */}
|
|
522
|
+
<header
|
|
523
|
+
className="sticky top-0 z-10 flex-shrink-0"
|
|
524
|
+
style={{
|
|
525
|
+
background: "var(--material-regular)",
|
|
526
|
+
backdropFilter: "blur(40px) saturate(180%)",
|
|
527
|
+
WebkitBackdropFilter: "blur(40px) saturate(180%)",
|
|
528
|
+
borderBottom: "1px solid var(--separator)",
|
|
529
|
+
}}
|
|
530
|
+
>
|
|
531
|
+
<div className="flex items-center justify-between" style={{ padding: "var(--space-4) var(--space-6)" }}>
|
|
532
|
+
<div>
|
|
533
|
+
<h1 style={{ fontSize: "var(--text-title1)", fontWeight: "var(--weight-bold)", color: "var(--text-primary)", letterSpacing: "-0.5px", lineHeight: "var(--leading-tight)" }}>
|
|
534
|
+
Cron Monitor
|
|
535
|
+
</h1>
|
|
536
|
+
{!loading && (
|
|
537
|
+
<p style={{ fontSize: "var(--text-footnote)", color: "var(--text-secondary)", marginTop: "var(--space-1)" }}>
|
|
538
|
+
{counts.all} job{counts.all !== 1 ? "s" : ""}
|
|
539
|
+
{counts.error > 0 && (
|
|
540
|
+
<span style={{ color: "var(--system-red)" }}>{" \u00b7 "}{counts.error} error{counts.error !== 1 ? "s" : ""}</span>
|
|
541
|
+
)}
|
|
542
|
+
{" \u00b7 "}{counts.ok} ok
|
|
543
|
+
</p>
|
|
544
|
+
)}
|
|
545
|
+
</div>
|
|
546
|
+
<div className="flex items-center" style={{ gap: "var(--space-3)" }}>
|
|
547
|
+
<span style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)" }}>Updated {updatedAgo}</span>
|
|
548
|
+
<button
|
|
549
|
+
onClick={refresh}
|
|
550
|
+
className="focus-ring"
|
|
551
|
+
aria-label="Refresh cron data"
|
|
552
|
+
style={{ width: 32, height: 32, display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-sm)", border: "none", background: "transparent", color: "var(--text-tertiary)", cursor: "pointer", transition: "color 150ms var(--ease-smooth)" }}
|
|
553
|
+
>
|
|
554
|
+
<RefreshCw size={16} className={refreshing ? "animate-spin" : ""} />
|
|
555
|
+
</button>
|
|
556
|
+
</div>
|
|
557
|
+
</div>
|
|
558
|
+
|
|
559
|
+
{/* ── Tab navigation ─────────────────────────────────── */}
|
|
560
|
+
<div className="flex items-center" style={{ padding: "0 var(--space-6) var(--space-3)", gap: "var(--space-1)" }}>
|
|
561
|
+
{TABS.map((t) => {
|
|
562
|
+
const isActive = tab === t.key;
|
|
563
|
+
const TabIcon = TAB_ICONS[t.key];
|
|
564
|
+
return (
|
|
565
|
+
<button
|
|
566
|
+
key={t.key}
|
|
567
|
+
onClick={() => setTab(t.key)}
|
|
568
|
+
className="focus-ring"
|
|
569
|
+
style={{
|
|
570
|
+
padding: "6px 16px",
|
|
571
|
+
fontSize: "var(--text-footnote)",
|
|
572
|
+
fontWeight: isActive ? "var(--weight-semibold)" : "var(--weight-medium)",
|
|
573
|
+
border: "none",
|
|
574
|
+
borderRadius: "var(--radius-sm)",
|
|
575
|
+
cursor: "pointer",
|
|
576
|
+
transition: "all 200ms var(--ease-smooth)",
|
|
577
|
+
background: isActive ? "var(--accent-fill)" : "transparent",
|
|
578
|
+
color: isActive ? "var(--accent)" : "var(--text-secondary)",
|
|
579
|
+
display: "inline-flex",
|
|
580
|
+
alignItems: "center",
|
|
581
|
+
gap: 6,
|
|
582
|
+
}}
|
|
583
|
+
>
|
|
584
|
+
<TabIcon size={14} />
|
|
585
|
+
{t.label}
|
|
586
|
+
</button>
|
|
587
|
+
);
|
|
588
|
+
})}
|
|
589
|
+
</div>
|
|
590
|
+
</header>
|
|
591
|
+
|
|
592
|
+
{/* ── Scrollable content ─────────────────────────────────── */}
|
|
593
|
+
<div className="flex-1 overflow-y-auto" style={{ padding: "var(--space-4) var(--space-6) var(--space-6)" }}>
|
|
594
|
+
{loading ? (
|
|
595
|
+
<>
|
|
596
|
+
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "var(--space-3)", marginBottom: "var(--space-4)" }}>
|
|
597
|
+
{[1, 2, 3].map((i) => (
|
|
598
|
+
<div key={i} style={{ background: "var(--material-regular)", border: "1px solid var(--separator)", borderRadius: "var(--radius-md)", padding: "var(--space-4)" }}>
|
|
599
|
+
<Skeleton style={{ width: 60, height: 10, marginBottom: 8 }} />
|
|
600
|
+
<Skeleton style={{ width: 80, height: 14 }} />
|
|
601
|
+
</div>
|
|
602
|
+
))}
|
|
603
|
+
</div>
|
|
604
|
+
<div style={{ borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--material-regular)" }}>
|
|
605
|
+
{[1, 2, 3, 4, 5].map((i) => (
|
|
606
|
+
<div key={i} className="flex items-center" style={{ padding: "var(--space-3) var(--space-4)", borderBottom: i < 5 ? "1px solid var(--separator)" : undefined, gap: "var(--space-3)" }}>
|
|
607
|
+
<Skeleton className="flex-shrink-0" style={{ width: 8, height: 8, borderRadius: "50%" }} />
|
|
608
|
+
<Skeleton style={{ width: 180, height: 14 }} />
|
|
609
|
+
<div className="ml-auto flex items-center" style={{ gap: "var(--space-3)" }}>
|
|
610
|
+
<Skeleton style={{ width: 48, height: 12 }} />
|
|
611
|
+
<Skeleton style={{ width: 64, height: 12 }} />
|
|
612
|
+
</div>
|
|
613
|
+
</div>
|
|
614
|
+
))}
|
|
615
|
+
</div>
|
|
616
|
+
</>
|
|
617
|
+
) : (
|
|
618
|
+
<>
|
|
619
|
+
{/* ─── OVERVIEW TAB ─────────────────────────────── */}
|
|
620
|
+
{tab === "overview" && (
|
|
621
|
+
<>
|
|
622
|
+
{/* Summary cards */}
|
|
623
|
+
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "var(--space-3)", marginBottom: "var(--space-4)" }} className="summary-cards-grid">
|
|
624
|
+
<HealthCard ok={counts.ok} total={counts.all} />
|
|
625
|
+
<AttentionCard errors={errorCrons} />
|
|
626
|
+
<DeliveryCard crons={crons} />
|
|
627
|
+
</div>
|
|
628
|
+
|
|
629
|
+
{/* Categorized error banners */}
|
|
630
|
+
<ErrorsBanners crons={crons} agentMap={agentMap} onCopy={copyError} copiedId={copiedId} />
|
|
631
|
+
|
|
632
|
+
{/* Filter pills */}
|
|
633
|
+
<div
|
|
634
|
+
ref={pillsRef}
|
|
635
|
+
role="tablist"
|
|
636
|
+
aria-label="Filter cron jobs by status"
|
|
637
|
+
onKeyDown={handlePillKeyDown}
|
|
638
|
+
className="flex items-center overflow-x-auto flex-shrink-0"
|
|
639
|
+
style={{ marginBottom: "var(--space-3)", gap: "var(--space-2)" }}
|
|
640
|
+
>
|
|
641
|
+
{PILLS.map((pill) => {
|
|
642
|
+
const isActive = filter === pill.key;
|
|
643
|
+
return (
|
|
644
|
+
<button
|
|
645
|
+
key={pill.key}
|
|
646
|
+
role="tab"
|
|
647
|
+
aria-selected={isActive}
|
|
648
|
+
tabIndex={isActive ? 0 : -1}
|
|
649
|
+
onClick={() => setFilter(pill.key)}
|
|
650
|
+
className="focus-ring flex items-center flex-shrink-0"
|
|
651
|
+
style={{
|
|
652
|
+
borderRadius: 20,
|
|
653
|
+
padding: "6px 14px",
|
|
654
|
+
fontSize: "var(--text-footnote)",
|
|
655
|
+
fontWeight: "var(--weight-medium)",
|
|
656
|
+
border: "none",
|
|
657
|
+
cursor: "pointer",
|
|
658
|
+
gap: "var(--space-2)",
|
|
659
|
+
transition: "all 200ms var(--ease-smooth)",
|
|
660
|
+
...(isActive
|
|
661
|
+
? { background: "var(--accent-fill)", color: "var(--accent)", boxShadow: "0 0 0 1px color-mix(in srgb, var(--accent) 40%, transparent)" }
|
|
662
|
+
: { background: "var(--fill-secondary)", color: "var(--text-primary)" }),
|
|
663
|
+
}}
|
|
664
|
+
>
|
|
665
|
+
<span className={`flex-shrink-0 rounded-full ${pill.key === "error" && counts.error > 0 ? "animate-error-pulse" : ""}`} style={{ width: 6, height: 6, background: pill.dotColor }} />
|
|
666
|
+
<span>{pill.label}</span>
|
|
667
|
+
<span style={{ fontWeight: "var(--weight-semibold)", color: isActive ? "var(--accent)" : "var(--text-secondary)" }}>{counts[pill.key]}</span>
|
|
668
|
+
</button>
|
|
669
|
+
);
|
|
670
|
+
})}
|
|
671
|
+
</div>
|
|
672
|
+
|
|
673
|
+
{/* Cron list */}
|
|
674
|
+
{filtered.length === 0 ? (
|
|
675
|
+
<div className="flex flex-col items-center justify-center" style={{ height: 200, color: "var(--text-secondary)", gap: "var(--space-2)" }}>
|
|
676
|
+
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ color: "var(--text-tertiary)", marginBottom: "var(--space-2)" }}>
|
|
677
|
+
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
|
|
678
|
+
</svg>
|
|
679
|
+
<span style={{ fontSize: "var(--text-subheadline)", fontWeight: "var(--weight-medium)" }}>
|
|
680
|
+
{crons.length === 0 ? "No scheduled tasks yet" : "No crons match this filter"}
|
|
681
|
+
</span>
|
|
682
|
+
<span style={{ fontSize: "var(--text-footnote)", color: "var(--text-tertiary)", textAlign: "center", maxWidth: 360, lineHeight: "var(--leading-relaxed)" }}>
|
|
683
|
+
{crons.length === 0 ? "Cron jobs are automated tasks that run on a schedule. They will appear here once your agents have scheduled tasks configured." : "Try selecting a different status filter"}
|
|
684
|
+
</span>
|
|
685
|
+
</div>
|
|
686
|
+
) : (
|
|
687
|
+
<div style={{ borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--material-regular)", backdropFilter: "blur(20px)", WebkitBackdropFilter: "blur(20px)" }}>
|
|
688
|
+
{filtered.map((cron, idx) => {
|
|
689
|
+
const agent = cron.agentId ? agentMap.get(cron.agentId) : null;
|
|
690
|
+
const isExpanded = expanded === cron.id;
|
|
691
|
+
const isError = cron.status === "error";
|
|
692
|
+
const isOverdue = cron.nextRun && nextRunLabel(cron.nextRun) === "overdue";
|
|
693
|
+
|
|
694
|
+
return (
|
|
695
|
+
<div key={cron.id}>
|
|
696
|
+
{idx > 0 && (
|
|
697
|
+
<div style={{ height: 1, background: "var(--separator)", marginLeft: "var(--space-4)", marginRight: "var(--space-4)" }} />
|
|
698
|
+
)}
|
|
699
|
+
|
|
700
|
+
{/* Collapsed row */}
|
|
701
|
+
<div
|
|
702
|
+
role="button"
|
|
703
|
+
tabIndex={0}
|
|
704
|
+
aria-expanded={isExpanded}
|
|
705
|
+
aria-label={`${cron.name}, status ${cron.status}${agent ? `, agent ${agent.name}` : ""}`}
|
|
706
|
+
onClick={() => setExpanded(isExpanded ? null : cron.id)}
|
|
707
|
+
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setExpanded(isExpanded ? null : cron.id); } }}
|
|
708
|
+
className="flex items-center cursor-pointer hover-bg focus-ring"
|
|
709
|
+
style={{
|
|
710
|
+
minHeight: 48,
|
|
711
|
+
padding: "0 var(--space-4)",
|
|
712
|
+
background: isError ? "rgba(255,69,58,0.06)" : undefined,
|
|
713
|
+
borderLeft: `3px solid ${isError ? "var(--system-red)" : cron.status === "ok" ? "var(--system-green)" : "transparent"}`,
|
|
714
|
+
}}
|
|
715
|
+
>
|
|
716
|
+
<span className={`flex-shrink-0 rounded-full ${isError ? "animate-error-pulse" : ""}`} style={{ width: 8, height: 8, background: STATUS_DOT[cron.status] ?? "var(--text-tertiary)" }} />
|
|
717
|
+
<div className="ml-3 min-w-0 flex-1" style={{ display: "flex", flexDirection: "column" }}>
|
|
718
|
+
<span className="truncate" style={{ fontSize: "var(--text-footnote)", fontWeight: "var(--weight-semibold)", color: "var(--text-primary)" }}>{cron.name}</span>
|
|
719
|
+
{agent && (
|
|
720
|
+
<Link href={`/chat/${agent.id}`} onClick={(e) => e.stopPropagation()} className="md:hidden focus-ring" aria-label={`Chat with ${agent.name}`} style={{ fontSize: "var(--text-caption1)", color: "var(--system-blue)", textDecoration: "none", lineHeight: "var(--leading-snug)" }}>
|
|
721
|
+
{agent.name}
|
|
722
|
+
</Link>
|
|
723
|
+
)}
|
|
724
|
+
</div>
|
|
725
|
+
<div className="ml-auto flex items-center flex-shrink-0" style={{ gap: "var(--space-3)" }}>
|
|
726
|
+
{agent ? (
|
|
727
|
+
<Link href={`/chat/${agent.id}`} onClick={(e) => e.stopPropagation()} className="hidden md:inline focus-ring" aria-label={`Chat with ${agent.name}`} style={{ fontSize: "var(--text-caption1)", color: "var(--system-blue)", textDecoration: "none" }}>
|
|
728
|
+
{agent.name}
|
|
729
|
+
</Link>
|
|
730
|
+
) : (
|
|
731
|
+
<span className="hidden md:inline" style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)" }}>{"\u2014"}</span>
|
|
732
|
+
)}
|
|
733
|
+
<span className="hidden md:inline" style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)" }}>
|
|
734
|
+
{cron.scheduleDescription || cron.schedule}
|
|
735
|
+
</span>
|
|
736
|
+
<span aria-hidden="true" style={{ fontSize: "var(--text-footnote)", color: "var(--text-tertiary)", transition: "transform 200ms var(--ease-smooth)", transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)", display: "inline-block" }}>
|
|
737
|
+
›
|
|
738
|
+
</span>
|
|
739
|
+
</div>
|
|
740
|
+
</div>
|
|
741
|
+
|
|
742
|
+
{/* Expanded detail */}
|
|
743
|
+
{isExpanded && (
|
|
744
|
+
<div className="animate-slide-down" style={{ padding: "0 var(--space-4) var(--space-4) var(--space-4)", marginLeft: 3 }}>
|
|
745
|
+
<div style={{ display: "grid", gridTemplateColumns: "auto 1fr", gap: "var(--space-1) var(--space-4)", marginTop: "var(--space-2)", marginBottom: "var(--space-3)" }}>
|
|
746
|
+
{/* Description */}
|
|
747
|
+
{cron.description && (
|
|
748
|
+
<>
|
|
749
|
+
<span style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)" }}>Description</span>
|
|
750
|
+
<span style={{ fontSize: "var(--text-caption1)", color: "var(--text-secondary)" }}>{cron.description}</span>
|
|
751
|
+
</>
|
|
752
|
+
)}
|
|
753
|
+
|
|
754
|
+
{/* Last run */}
|
|
755
|
+
<span style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)" }}>Last run</span>
|
|
756
|
+
<span style={{ fontSize: "var(--text-caption1)", color: "var(--text-secondary)" }}>{timeAgo(cron.lastRun)}</span>
|
|
757
|
+
|
|
758
|
+
{/* Next run */}
|
|
759
|
+
<span style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)" }}>Next run</span>
|
|
760
|
+
<span style={{ fontSize: "var(--text-caption1)", color: isOverdue ? "var(--system-orange)" : "var(--text-secondary)", fontWeight: isOverdue ? "var(--weight-semibold)" : undefined }}>
|
|
761
|
+
{nextRunLabel(cron.nextRun)}
|
|
762
|
+
</span>
|
|
763
|
+
|
|
764
|
+
{/* Duration */}
|
|
765
|
+
{cron.lastDurationMs != null && (
|
|
766
|
+
<>
|
|
767
|
+
<span style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)" }}>Duration</span>
|
|
768
|
+
<span style={{ fontSize: "var(--text-caption1)", color: "var(--text-secondary)" }}>{formatDuration(cron.lastDurationMs)}</span>
|
|
769
|
+
</>
|
|
770
|
+
)}
|
|
771
|
+
|
|
772
|
+
{/* Status */}
|
|
773
|
+
<span style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)" }}>Status</span>
|
|
774
|
+
<span style={{ fontSize: "var(--text-caption1)", color: cron.status === "error" ? "var(--system-red)" : cron.status === "ok" ? "var(--system-green)" : "var(--text-secondary)", fontWeight: "var(--weight-medium)", textTransform: "capitalize" }}>
|
|
775
|
+
{cron.status}
|
|
776
|
+
</span>
|
|
777
|
+
|
|
778
|
+
{/* Schedule */}
|
|
779
|
+
<span style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)" }}>Schedule</span>
|
|
780
|
+
<div>
|
|
781
|
+
{cron.scheduleDescription && (
|
|
782
|
+
<div style={{ fontSize: "var(--text-caption1)", color: "var(--text-secondary)" }}>{cron.scheduleDescription}</div>
|
|
783
|
+
)}
|
|
784
|
+
<div className="font-mono" style={{ fontSize: "var(--text-caption2)", color: "var(--text-tertiary)", marginTop: cron.scheduleDescription ? 2 : 0 }}>
|
|
785
|
+
{cron.schedule}
|
|
786
|
+
{cron.timezone && <span style={{ marginLeft: 8 }}>({cron.timezone})</span>}
|
|
787
|
+
</div>
|
|
788
|
+
</div>
|
|
789
|
+
|
|
790
|
+
{/* Delivery */}
|
|
791
|
+
<span style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)" }}>Delivery</span>
|
|
792
|
+
<DeliveryBadge cron={cron} />
|
|
793
|
+
|
|
794
|
+
{/* Consecutive errors */}
|
|
795
|
+
{cron.consecutiveErrors > 0 && (
|
|
796
|
+
<>
|
|
797
|
+
<span style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)" }}>Errors</span>
|
|
798
|
+
<span style={{ fontSize: "var(--text-caption1)", color: "var(--system-orange)", fontWeight: "var(--weight-medium)" }}>
|
|
799
|
+
{cron.consecutiveErrors} consecutive
|
|
800
|
+
</span>
|
|
801
|
+
</>
|
|
802
|
+
)}
|
|
803
|
+
</div>
|
|
804
|
+
|
|
805
|
+
{/* Error box */}
|
|
806
|
+
{cron.lastError && (
|
|
807
|
+
<div style={{ borderRadius: "var(--radius-sm)", background: "var(--code-bg)", border: "1px solid var(--code-border)", padding: "var(--space-3)", marginBottom: "var(--space-3)" }}>
|
|
808
|
+
<div className="flex items-start justify-between" style={{ gap: "var(--space-2)" }}>
|
|
809
|
+
<pre className="font-mono" style={{ fontSize: "var(--text-caption1)", color: "var(--system-red)", whiteSpace: "pre-wrap", wordBreak: "break-word", margin: 0, flex: 1, lineHeight: "var(--leading-relaxed)" }}>
|
|
810
|
+
{cron.lastError}
|
|
811
|
+
</pre>
|
|
812
|
+
<button
|
|
813
|
+
onClick={(e) => { e.stopPropagation(); copyError(cron.id, cron.lastError!); }}
|
|
814
|
+
className="btn-ghost focus-ring flex-shrink-0"
|
|
815
|
+
aria-label="Copy error text"
|
|
816
|
+
style={{ padding: "4px 10px", borderRadius: "var(--radius-sm)", fontSize: "var(--text-caption2)", fontWeight: "var(--weight-medium)", display: "inline-flex", alignItems: "center", gap: 3 }}
|
|
817
|
+
>
|
|
818
|
+
{copiedId === cron.id ? <Check size={12} /> : <Copy size={12} />}
|
|
819
|
+
{copiedId === cron.id ? "Copied" : "Copy"}
|
|
820
|
+
</button>
|
|
821
|
+
</div>
|
|
822
|
+
</div>
|
|
823
|
+
)}
|
|
824
|
+
|
|
825
|
+
{/* Recent runs */}
|
|
826
|
+
<RecentRuns jobId={cron.id} />
|
|
827
|
+
|
|
828
|
+
{/* Actions */}
|
|
829
|
+
<div className="flex items-center" style={{ gap: "var(--space-2)", marginTop: "var(--space-3)" }}>
|
|
830
|
+
{agent && (
|
|
831
|
+
<Link
|
|
832
|
+
href={`/chat/${agent.id}`}
|
|
833
|
+
className="btn-ghost focus-ring"
|
|
834
|
+
aria-label={`Chat with ${agent.name}`}
|
|
835
|
+
style={{ display: "inline-flex", alignItems: "center", gap: "var(--space-1)", padding: "6px 12px", borderRadius: "var(--radius-sm)", fontSize: "var(--text-caption1)", fontWeight: "var(--weight-medium)", textDecoration: "none", color: "var(--system-blue)" }}
|
|
836
|
+
>
|
|
837
|
+
Chat with {agent.name}
|
|
838
|
+
<span aria-hidden="true" style={{ fontSize: "var(--text-caption1)" }}>{"\u2192"}</span>
|
|
839
|
+
</Link>
|
|
840
|
+
)}
|
|
841
|
+
</div>
|
|
842
|
+
</div>
|
|
843
|
+
)}
|
|
844
|
+
</div>
|
|
845
|
+
);
|
|
846
|
+
})}
|
|
847
|
+
</div>
|
|
848
|
+
)}
|
|
849
|
+
</>
|
|
850
|
+
)}
|
|
851
|
+
|
|
852
|
+
{/* ─── SCHEDULE TAB ──────────────────────────────── */}
|
|
853
|
+
{tab === "schedule" && <WeeklySchedule crons={crons} />}
|
|
854
|
+
|
|
855
|
+
{/* ─── PIPELINES TAB ─────────────────────────────── */}
|
|
856
|
+
{tab === "pipelines" && <PipelineGraph crons={crons} />}
|
|
857
|
+
</>
|
|
858
|
+
)}
|
|
859
|
+
</div>
|
|
860
|
+
|
|
861
|
+
<style>{`
|
|
862
|
+
@media (max-width: 640px) {
|
|
863
|
+
.summary-cards-grid {
|
|
864
|
+
grid-template-columns: 1fr !important;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
`}</style>
|
|
868
|
+
</div>
|
|
869
|
+
);
|
|
870
|
+
}
|