@flame0510/project-aether 1.1.9

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.
Files changed (290) hide show
  1. package/README.md +150 -0
  2. package/agent-templates/README.md +37 -0
  3. package/agent-templates/argus/AGENTS.md +52 -0
  4. package/agent-templates/argus/IDENTITY.md +7 -0
  5. package/agent-templates/argus/MEMORY.md +9 -0
  6. package/agent-templates/argus/SOUL.md +24 -0
  7. package/agent-templates/argus/TOOLS.md +8 -0
  8. package/agent-templates/atlas/AGENTS.md +47 -0
  9. package/agent-templates/atlas/HEARTBEAT.md +10 -0
  10. package/agent-templates/atlas/IDENTITY.md +6 -0
  11. package/agent-templates/atlas/MEMORY.md +9 -0
  12. package/agent-templates/atlas/SOUL.md +21 -0
  13. package/agent-templates/atlas/TOOLS.md +4 -0
  14. package/agent-templates/base-image/.trigger +0 -0
  15. package/agent-templates/base-image/Dockerfile +90 -0
  16. package/agent-templates/base-image/entrypoint.sh +37 -0
  17. package/agent-templates/custom/AGENTS.md +29 -0
  18. package/agent-templates/custom/BOOTSTRAP.md +56 -0
  19. package/agent-templates/custom/MEMORY.md +8 -0
  20. package/agent-templates/prometheus/AGENTS.md +37 -0
  21. package/agent-templates/prometheus/IDENTITY.md +6 -0
  22. package/agent-templates/prometheus/MEMORY.md +9 -0
  23. package/agent-templates/prometheus/SOUL.md +21 -0
  24. package/agent-templates/prometheus/TOOLS.md +4 -0
  25. package/app/agents/ChannelManager.tsx +465 -0
  26. package/app/agents/ImageDownloadBanner.tsx +159 -0
  27. package/app/agents/PageClient.tsx +1285 -0
  28. package/app/agents/create/PageClient.tsx +437 -0
  29. package/app/agents/create/page.tsx +126 -0
  30. package/app/agents/loading.tsx +14 -0
  31. package/app/agents/page.tsx +5 -0
  32. package/app/api/agents/[id]/backup/route.ts +169 -0
  33. package/app/api/agents/[id]/channels/pairing/route.ts +69 -0
  34. package/app/api/agents/[id]/channels/route.ts +14 -0
  35. package/app/api/agents/[id]/channels/telegram/route.ts +50 -0
  36. package/app/api/agents/[id]/lifecycle/route.ts +94 -0
  37. package/app/api/agents/[id]/recreate/route.ts +199 -0
  38. package/app/api/agents/[id]/restart/route.ts +33 -0
  39. package/app/api/agents/[id]/restore/route.ts +81 -0
  40. package/app/api/agents/[id]/route.ts +280 -0
  41. package/app/api/agents/channels-summary/route.ts +39 -0
  42. package/app/api/agents/config-json/route.ts +58 -0
  43. package/app/api/agents/create/route.ts +488 -0
  44. package/app/api/agents/download-image/route.ts +32 -0
  45. package/app/api/agents/image-status/route.ts +126 -0
  46. package/app/api/agents/route.ts +213 -0
  47. package/app/api/agents/token/route.ts +94 -0
  48. package/app/api/agents-active/route.ts +190 -0
  49. package/app/api/agents-config/route.ts +629 -0
  50. package/app/api/alerts/smoke/route.ts +26 -0
  51. package/app/api/aliases/add/route.ts +28 -0
  52. package/app/api/aliases/remove/route.ts +27 -0
  53. package/app/api/assistant/route.ts +204 -0
  54. package/app/api/auth/check/route.ts +38 -0
  55. package/app/api/auth/login/route.ts +85 -0
  56. package/app/api/auth/logout/route.ts +7 -0
  57. package/app/api/auth/status/route.ts +33 -0
  58. package/app/api/config/env/route.ts +132 -0
  59. package/app/api/config/restart/route.ts +26 -0
  60. package/app/api/containers/route.ts +75 -0
  61. package/app/api/cost-override/route.ts +46 -0
  62. package/app/api/costs/route.ts +84 -0
  63. package/app/api/credentials/[id]/reveal/route.ts +21 -0
  64. package/app/api/credentials/[id]/route.ts +52 -0
  65. package/app/api/credentials/[id]/sync/route.ts +58 -0
  66. package/app/api/credentials/detect/route.ts +246 -0
  67. package/app/api/credentials/route.ts +33 -0
  68. package/app/api/crons/[id]/route.ts +69 -0
  69. package/app/api/crons/route.ts +12 -0
  70. package/app/api/crons/runs/route.ts +31 -0
  71. package/app/api/debug/route.ts +11 -0
  72. package/app/api/envcheck/route.ts +18 -0
  73. package/app/api/events/route.ts +24 -0
  74. package/app/api/gateway/agent/route.ts +154 -0
  75. package/app/api/gateway/provider/balance/route.ts +38 -0
  76. package/app/api/gateway/provider/keys.ts +38 -0
  77. package/app/api/gateway/provider/oauth/route.ts +304 -0
  78. package/app/api/gateway/provider/route.ts +209 -0
  79. package/app/api/gateway/route.ts +223 -0
  80. package/app/api/gateway/sync.ts +189 -0
  81. package/app/api/lineage/route.ts +54 -0
  82. package/app/api/memory-context/route.ts +15 -0
  83. package/app/api/metrics/route.ts +35 -0
  84. package/app/api/models/route.ts +77 -0
  85. package/app/api/plugins/route.js +37 -0
  86. package/app/api/provider/upstream.ts +142 -0
  87. package/app/api/provider/v1/chat/completions/route.ts +146 -0
  88. package/app/api/provider/v1/models/route.ts +167 -0
  89. package/app/api/provider-usage/route.ts +629 -0
  90. package/app/api/session/route.ts +39 -0
  91. package/app/api/sessions/[id]/route.ts +38 -0
  92. package/app/api/sessions/route.ts +23 -0
  93. package/app/api/setup/agent-image/route.ts +102 -0
  94. package/app/api/setup/password/route.ts +111 -0
  95. package/app/api/setup/restart/route.ts +76 -0
  96. package/app/api/skills/delete/route.js +105 -0
  97. package/app/api/skills/promote/route.js +129 -0
  98. package/app/api/skills/route.js +203 -0
  99. package/app/api/skills/save/route.js +142 -0
  100. package/app/api/stats/route.ts +34 -0
  101. package/app/api/stats-since/route.ts +20 -0
  102. package/app/api/stream/route.ts +70 -0
  103. package/app/api/system-health/route.ts +233 -0
  104. package/app/api/tool-calls/route.ts +24 -0
  105. package/app/api/tools-config/route.ts +163 -0
  106. package/app/api/update-check/route.ts +127 -0
  107. package/app/api/version/route.ts +25 -0
  108. package/app/api/wizard/complete/route.ts +32 -0
  109. package/app/api/wizard/reset/route.ts +28 -0
  110. package/app/api/wizard/status/route.ts +28 -0
  111. package/app/api/workspace/route.ts +392 -0
  112. package/app/api/workspace/stream/route.ts +136 -0
  113. package/app/components/AppShell.tsx +33 -0
  114. package/app/components/AuthGuard.tsx +91 -0
  115. package/app/components/CostBreakdown.tsx +37 -0
  116. package/app/components/DashboardHeader.tsx +73 -0
  117. package/app/components/DashboardLayout.tsx +76 -0
  118. package/app/components/DashboardToolbar.tsx +61 -0
  119. package/app/components/FloatingActions.tsx +33 -0
  120. package/app/components/Icons.tsx +78 -0
  121. package/app/components/LineageGraphPage.tsx +142 -0
  122. package/app/components/LiveFeed.tsx +48 -0
  123. package/app/components/MobileBottomNav.tsx +84 -0
  124. package/app/components/ModelPickerModal.tsx +94 -0
  125. package/app/components/PasswordInput.tsx +86 -0
  126. package/app/components/PulseChat.tsx +362 -0
  127. package/app/components/Rev4aLoader.tsx +48 -0
  128. package/app/components/SessionDrawer.tsx +263 -0
  129. package/app/components/SessionTopology.tsx +289 -0
  130. package/app/components/Sidebar.tsx +294 -0
  131. package/app/components/Skeleton.tsx +133 -0
  132. package/app/components/SystemCockpit.tsx +169 -0
  133. package/app/components/VersionBanner.tsx +105 -0
  134. package/app/components/WizardButton.tsx +30 -0
  135. package/app/components/ui/Badge.tsx +30 -0
  136. package/app/components/ui/Button.tsx +59 -0
  137. package/app/components/ui/ConfirmModal.tsx +46 -0
  138. package/app/components/ui/FilterBar.tsx +33 -0
  139. package/app/components/ui/Input.tsx +25 -0
  140. package/app/components/ui/ItemList.tsx +83 -0
  141. package/app/components/ui/LoadingSpinner.tsx +30 -0
  142. package/app/components/ui/Metric.tsx +22 -0
  143. package/app/components/ui/Modal.tsx +42 -0
  144. package/app/components/ui/Page.tsx +18 -0
  145. package/app/components/ui/Pill.tsx +6 -0
  146. package/app/components/ui/PropertyList.tsx +24 -0
  147. package/app/components/ui/Select.tsx +26 -0
  148. package/app/components/ui/StatusCard.tsx +32 -0
  149. package/app/components/ui/Surface.tsx +14 -0
  150. package/app/components/ui/Tabs.tsx +55 -0
  151. package/app/components/ui/TemplateOption.tsx +59 -0
  152. package/app/components/ui/Toast.tsx +44 -0
  153. package/app/components/ui/index.ts +19 -0
  154. package/app/components/ui/tokens.ts +18 -0
  155. package/app/config/PageClient.tsx +293 -0
  156. package/app/config/loading.tsx +10 -0
  157. package/app/config/page.tsx +7 -0
  158. package/app/containers/ContainersClient.tsx +129 -0
  159. package/app/containers/loading.tsx +21 -0
  160. package/app/containers/page.tsx +5 -0
  161. package/app/containers/terminal/[id]/TerminalClient.tsx +313 -0
  162. package/app/containers/terminal/[id]/page.tsx +6 -0
  163. package/app/credentials/PageClient.tsx +598 -0
  164. package/app/credentials/loading.tsx +10 -0
  165. package/app/credentials/page.tsx +7 -0
  166. package/app/crons/PageClient.tsx +538 -0
  167. package/app/crons/loading.tsx +10 -0
  168. package/app/crons/page.tsx +5 -0
  169. package/app/design-system.ts +57 -0
  170. package/app/gateway/PageClient.tsx +1048 -0
  171. package/app/gateway/loading.tsx +24 -0
  172. package/app/gateway/page.tsx +5 -0
  173. package/app/globals.css +1474 -0
  174. package/app/layout.tsx +45 -0
  175. package/app/lib/models-context.tsx +41 -0
  176. package/app/lineage/loading.tsx +10 -0
  177. package/app/lineage/page.tsx +13 -0
  178. package/app/loading.tsx +16 -0
  179. package/app/login/page.tsx +160 -0
  180. package/app/memory/MemoryContextPageClient.tsx +196 -0
  181. package/app/memory/loading.tsx +10 -0
  182. package/app/memory/page.tsx +8 -0
  183. package/app/page.tsx +31 -0
  184. package/app/plugins/PageClient.tsx +296 -0
  185. package/app/plugins/loading.tsx +10 -0
  186. package/app/plugins/page.tsx +5 -0
  187. package/app/setup/PageClient.tsx +227 -0
  188. package/app/setup/loading.tsx +21 -0
  189. package/app/setup/page.tsx +7 -0
  190. package/app/skills/PageClient.tsx +499 -0
  191. package/app/skills/loading.tsx +10 -0
  192. package/app/skills/page.tsx +5 -0
  193. package/app/tools/ToolsPageClient.tsx +308 -0
  194. package/app/tools/loading.tsx +10 -0
  195. package/app/tools/page.tsx +62 -0
  196. package/app/tools/tools-catalog.ts +141 -0
  197. package/app/wizard/PageClient.tsx +485 -0
  198. package/app/wizard/icons.tsx +89 -0
  199. package/app/wizard/loading.tsx +24 -0
  200. package/app/wizard/page.tsx +7 -0
  201. package/app/wizard/storage.ts +33 -0
  202. package/app/wizard/useWizard.ts +101 -0
  203. package/app/workspace/WorkspaceClient.tsx +633 -0
  204. package/app/workspace/loading.tsx +36 -0
  205. package/app/workspace/page.tsx +8 -0
  206. package/bin/docker-entrypoint.sh +21 -0
  207. package/bin/postinstall.js +20 -0
  208. package/bin/rev4a.js +696 -0
  209. package/daemon.js +783 -0
  210. package/docs/ARCHITECTURE.md +557 -0
  211. package/docs/CONTAINER-TERMINAL.md +280 -0
  212. package/docs/DESIGN-SYSTEM.md +99 -0
  213. package/docs/FRONTEND-ARCHITECTURE.md +119 -0
  214. package/docs/REV4A.md +504 -0
  215. package/docs/dev/API-REFERENCE.md +1835 -0
  216. package/docs/dev/DATABASE.md +224 -0
  217. package/docs/dev/GATEWAY.md +300 -0
  218. package/docs/dev/PROVIDERS.md +229 -0
  219. package/docs/dev/SESSION-MAINTENANCE-PLAN.md +607 -0
  220. package/docs/dev/WORKSPACE.md +199 -0
  221. package/docs/rag/DATA-FRESHNESS.md +109 -0
  222. package/docs/rag/GLOSSARY.md +125 -0
  223. package/docs/rag/REV4A-OVERVIEW.md +135 -0
  224. package/docs/rag/WHAT-I-CAN-ANSWER.md +124 -0
  225. package/lib/agent-setup.ts +130 -0
  226. package/lib/alerts.ts +115 -0
  227. package/lib/apiFetch.ts +7 -0
  228. package/lib/auth.ts +28 -0
  229. package/lib/billing.ts +100 -0
  230. package/lib/buildAgentImage.ts +346 -0
  231. package/lib/channelManager.ts +386 -0
  232. package/lib/container.ts +60 -0
  233. package/lib/credentials/db.ts +86 -0
  234. package/lib/credentials/delivery.ts +448 -0
  235. package/lib/credentials/detect.ts +149 -0
  236. package/lib/credentials/hash.ts +9 -0
  237. package/lib/credentials/providers.ts +110 -0
  238. package/lib/credentials/vault.ts +195 -0
  239. package/lib/db-bootstrap.d.ts +3 -0
  240. package/lib/db-bootstrap.mjs +144 -0
  241. package/lib/db.ts +25 -0
  242. package/lib/docker-utils.ts +60 -0
  243. package/lib/hooks/useDashboard.ts +264 -0
  244. package/lib/hooks/useRev4aTimezone.ts +28 -0
  245. package/lib/hooks/useScreenshot.ts +30 -0
  246. package/lib/memory-context.ts +385 -0
  247. package/lib/model-catalogue.ts +131 -0
  248. package/lib/model-pricing.ts +63 -0
  249. package/lib/openclaw-cron.ts +350 -0
  250. package/lib/patterns/ApiAdapter.ts +73 -0
  251. package/lib/patterns/ApiClient.ts +65 -0
  252. package/lib/patterns/EventBus.ts +113 -0
  253. package/lib/patterns/FilterStrategy.ts +79 -0
  254. package/lib/patterns/SessionFactory.ts +117 -0
  255. package/lib/patterns/sessionPresentation.ts +117 -0
  256. package/lib/provider-balance.ts +163 -0
  257. package/lib/requireAuth.tsx +38 -0
  258. package/lib/rev4a-auth.d.ts +3 -0
  259. package/lib/rev4a-auth.js +36 -0
  260. package/lib/rev4a-paths.ts +145 -0
  261. package/lib/timezone.ts +46 -0
  262. package/lib/types/index.ts +96 -0
  263. package/lib/utils/format.ts +82 -0
  264. package/lineage.js +56 -0
  265. package/model-pricing.json +482 -0
  266. package/models.config.json +844 -0
  267. package/next-env.d.ts +6 -0
  268. package/next.config.mjs +7 -0
  269. package/package.json +82 -0
  270. package/public/apple-touch-icon.png +0 -0
  271. package/public/favicon-64.png +0 -0
  272. package/public/favicon.svg +58 -0
  273. package/public/icon-192-maskable.png +0 -0
  274. package/public/icon-192.png +0 -0
  275. package/public/icon-512-maskable.png +0 -0
  276. package/public/icon-512.png +0 -0
  277. package/public/manifest.json +38 -0
  278. package/public/rev4a-logo.png +0 -0
  279. package/public/rev4a-logo.svg +58 -0
  280. package/rev4a-rules/AGENTS.md +37 -0
  281. package/scripts/add-auth.cjs +185 -0
  282. package/scripts/backup.sh +66 -0
  283. package/scripts/check-providers.mjs +143 -0
  284. package/scripts/docker/traefik/docker-compose.yml +32 -0
  285. package/scripts/docker/traefik/dynamic/.gitkeep +18 -0
  286. package/scripts/docker/traefik/traefik.yml +37 -0
  287. package/scripts/remove-auth-profile.js +56 -0
  288. package/scripts/restore.sh +82 -0
  289. package/terminal-ws-server.js +139 -0
  290. package/tsconfig.json +42 -0
@@ -0,0 +1,538 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState, useRef } from 'react';
4
+ import { useResponsive } from '../design-system';
5
+ import { Button, Pill } from '../components/ui';
6
+ import { SkeletonLines } from '../components/Skeleton';
7
+ import { apiFetch } from '@/lib/apiFetch';
8
+ import { useRev4aTimezone } from '@/lib/hooks/useRev4aTimezone';
9
+ import { formatDateTimeInTimezone } from '@/lib/timezone';
10
+
11
+
12
+ interface CronSchedule { kind?: string; expr?: string; tz?: string; [k: string]: unknown }
13
+ interface CronPayload { kind?: string; message?: string; model?: string; [k: string]: unknown }
14
+ interface CronState { lastStatus?: string; nextRunAtMs?: number | null; lastRunAtMs?: number | null; [k: string]: unknown }
15
+ interface CronDelivery { mode?: string; channel?: string; to?: string; [k: string]: unknown }
16
+
17
+ interface CronJob {
18
+ id?: string;
19
+ name?: string;
20
+ description?: string;
21
+ agentId?: string;
22
+ containerName?: string;
23
+ sessionKey?: string;
24
+ sessionTarget?: string;
25
+ wakeMode?: string;
26
+ schedule?: CronSchedule | string;
27
+ scheduleExpr?: string;
28
+ computedNextRunAtMs?: number | null;
29
+ payload?: CronPayload;
30
+ delivery?: CronDelivery;
31
+ state?: CronState;
32
+ enabled?: boolean;
33
+ createdAtMs?: number;
34
+ source?: 'host' | 'container';
35
+ [key: string]: unknown;
36
+ }
37
+
38
+ interface CronRunEntry {
39
+ ts?: number;
40
+ jobId?: string;
41
+ action?: string;
42
+ status?: string;
43
+ error?: string;
44
+ summary?: string;
45
+ runId?: string;
46
+ runAtMs?: number;
47
+ durationMs?: number;
48
+ nextRunAtMs?: number;
49
+ model?: string;
50
+ usage?: Record<string, number>;
51
+ diagnostics?: { summary?: string; entries?: Array<Record<string, unknown>> };
52
+ [key: string]: unknown;
53
+ }
54
+
55
+ function formatTs(ms: number, timezone: string): string {
56
+ return formatDateTimeInTimezone(ms, {
57
+ day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit',
58
+ hour12: false,
59
+ }, timezone);
60
+ }
61
+
62
+ function describeCron(expr: string): string {
63
+ const parts = expr.trim().split(/\s+/);
64
+ if (parts.length < 5) return '';
65
+ const [min, hour, dom, month, dow] = parts;
66
+
67
+ const every = (f: string) => f === '*' || f === '*/1';
68
+ const everyN = (f: string) => f.startsWith('*/');
69
+ const fixed = (f: string) => /^\d+$/.test(f);
70
+
71
+ if (every(min) && every(hour) && every(dom) && every(month) && every(dow))
72
+ return 'every minute';
73
+
74
+ if (everyN(min) && every(hour) && every(dom) && every(month) && every(dow)) {
75
+ const n = min.split('/')[1];
76
+ return n === '1' ? 'every minute' : `every ${n} minutes`;
77
+ }
78
+
79
+ if (fixed(min) && every(hour) && every(dom) && every(month) && every(dow))
80
+ return min === '0' ? 'every hour' : `every hour at minute ${min}`;
81
+
82
+ if (everyN(hour) && every(dom) && every(month) && every(dow)) {
83
+ const n = hour.split('/')[1];
84
+ const suffix = fixed(min) && min !== '0' ? ` at minute ${min}` : '';
85
+ return n === '1' ? `every hour${suffix}` : `every ${n} hours${suffix}`;
86
+ }
87
+
88
+ if (fixed(min) && fixed(hour) && every(dom) && every(month) && every(dow)) {
89
+ const h = hour.padStart(2, '0'), m = min.padStart(2, '0');
90
+ return `every day at ${h}:${m}`;
91
+ }
92
+
93
+ if (fixed(min) && fixed(hour) && every(dom) && every(month) && fixed(dow)) {
94
+ const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
95
+ const d = days[parseInt(dow)] ?? dow;
96
+ const h = hour.padStart(2, '0'), m = min.padStart(2, '0');
97
+ return `every ${d} at ${h}:${m}`;
98
+ }
99
+
100
+ if (fixed(min) && fixed(hour) && fixed(dom) && every(month) && every(dow)) {
101
+ const h = hour.padStart(2, '0'), m = min.padStart(2, '0');
102
+ return `on day ${dom} of each month at ${h}:${m}`;
103
+ }
104
+
105
+ return '';
106
+ }
107
+
108
+ // ── Agent filter helpers ──────────────────────────────────────────────────
109
+
110
+ interface AgentFilter {
111
+ key: string; // 'all' | 'host' | containerName
112
+ label: string; // 'ALL' | 'HOST' | agentId
113
+ source: string; // 'all' | 'host' | 'container'
114
+ containerName?: string;
115
+ }
116
+
117
+ function buildAgentFilters(jobs: CronJob[]): AgentFilter[] {
118
+ const seen = new Set<string>();
119
+ const filters: AgentFilter[] = [{ key: 'all', label: 'ALL', source: 'all' }];
120
+
121
+ for (const job of jobs) {
122
+ if (job.source === 'host' && !seen.has('host')) {
123
+ seen.add('host');
124
+ filters.push({ key: 'host', label: 'HOST', source: 'host' });
125
+ }
126
+ if (job.containerName && !seen.has(job.containerName)) {
127
+ seen.add(job.containerName);
128
+ filters.push({
129
+ key: job.containerName,
130
+ label: job.containerName,
131
+ source: 'container',
132
+ containerName: job.containerName,
133
+ });
134
+ }
135
+ }
136
+ return filters;
137
+ }
138
+
139
+ function filterJobs(jobs: CronJob[], agentKey: string): CronJob[] {
140
+ if (agentKey === 'all') return jobs;
141
+ if (agentKey === 'host') return jobs.filter(j => j.source === 'host');
142
+ return jobs.filter(j => j.containerName === agentKey);
143
+ }
144
+
145
+
146
+ export default function CronsPage() {
147
+ const [jobs, setJobs] = useState<CronJob[]>([]);
148
+ const [loading, setLoading] = useState(true);
149
+ const [busyJobId, setBusyJobId] = useState<string | null>(null);
150
+ const [agentFilter, setAgentFilter] = useState('all');
151
+ const [selectedJob, setSelectedJob] = useState<CronJob | null>(null);
152
+ const [runs, setRuns] = useState<CronRunEntry[]>([]);
153
+ const [runsLoading, setRunsLoading] = useState(false);
154
+ const [runsError, setRunsError] = useState<string | null>(null);
155
+ const isMobile = useResponsive('lg');
156
+ const [tab, setTab] = useState<'jobs' | 'runs'>('jobs');
157
+ const timezone = useRev4aTimezone();
158
+
159
+ const agentFilters = buildAgentFilters(jobs);
160
+ const filteredJobs = filterJobs(jobs, agentFilter);
161
+
162
+ // Prevent stale responses overwriting the latest requested job's runs
163
+ const latestRunRequest = useRef<string | null>(null);
164
+
165
+ async function toggleJob(job: CronJob) {
166
+ const id = job.id;
167
+ if (!id) return;
168
+ setBusyJobId(id);
169
+ try {
170
+ const body: Record<string, unknown> = { enabled: !(job.enabled !== false) };
171
+ if (job.containerName) body.containerName = job.containerName;
172
+ const res = await apiFetch(`/api/crons/${encodeURIComponent(id)}`, {
173
+ method: 'PATCH',
174
+ headers: { 'Content-Type': 'application/json' },
175
+ body: JSON.stringify(body),
176
+ });
177
+ if (res.ok) {
178
+ const updated = await res.json() as CronJob;
179
+ setJobs((prev) => prev.map((j) => j.id === id ? { ...j, ...updated } : j));
180
+ }
181
+ } catch { /* keep UI alive */ }
182
+ finally { setBusyJobId(null); }
183
+ }
184
+
185
+ async function deleteJob(job: CronJob) {
186
+ const id = job.id;
187
+ if (!id) return;
188
+ if (!confirm(`Delete cron job "${job.name ?? id}"? This cannot be undone.`)) return;
189
+ setBusyJobId(id);
190
+ try {
191
+ const params = new URLSearchParams();
192
+ if (job.containerName) params.set('containerName', job.containerName);
193
+ const res = await apiFetch(`/api/crons/${encodeURIComponent(id)}?${params.toString()}`, {
194
+ method: 'DELETE',
195
+ });
196
+ if (res.ok) {
197
+ setJobs((prev) => prev.filter((j) => j.id !== id));
198
+ if (selectedJob?.id === id) { setSelectedJob(null); setRuns([]); setRunsError(null); }
199
+ }
200
+ } catch { /* keep UI alive */ }
201
+ finally { setBusyJobId(null); }
202
+ }
203
+
204
+ async function load(signal?: AbortSignal) {
205
+ try {
206
+ const jobsRes = await apiFetch('/api/crons', { signal });
207
+ if (signal?.aborted) return;
208
+ if (jobsRes.ok) {
209
+ const data = await jobsRes.json() as CronJob[] | { jobs: CronJob[] };
210
+ if (!signal?.aborted) setJobs(Array.isArray(data) ? data : (data.jobs ?? []));
211
+ }
212
+ } catch { /* keep UI alive */ }
213
+ finally { if (!signal?.aborted) setLoading(false); }
214
+ }
215
+
216
+ // Clear selected job when filter changes (old selection may no longer be visible)
217
+ useEffect(() => {
218
+ setSelectedJob(null);
219
+ setRuns([]);
220
+ setRunsError(null);
221
+ }, [agentFilter]);
222
+
223
+ // Load runs when a job is selected
224
+ useEffect(() => {
225
+ const job = selectedJob;
226
+ if (!job) { setRuns([]); setRunsError(null); return; }
227
+
228
+ const jobId = job.id;
229
+ const cName = job.containerName;
230
+ if (!jobId || !cName) {
231
+ setRuns([]);
232
+ setRunsError(null);
233
+ setRunsLoading(false);
234
+ return;
235
+ }
236
+
237
+ const requestKey = `${cName}:${jobId}`;
238
+ latestRunRequest.current = requestKey;
239
+
240
+ let cancelled = false;
241
+ setRunsLoading(true);
242
+ setRunsError(null);
243
+ setRuns([]);
244
+
245
+ (async () => {
246
+ try {
247
+ const res = await apiFetch(
248
+ `/api/crons/runs?containerName=${encodeURIComponent(cName)}&jobId=${encodeURIComponent(jobId)}&limit=30`
249
+ );
250
+ if (cancelled || latestRunRequest.current !== requestKey) return;
251
+ if (res.ok) {
252
+ const data = await res.json();
253
+ setRuns((data.entries ?? []) as CronRunEntry[]);
254
+ } else {
255
+ setRunsError('Failed to load runs');
256
+ }
257
+ } catch {
258
+ if (!cancelled && latestRunRequest.current === requestKey) setRunsError('Failed to load runs');
259
+ } finally {
260
+ if (!cancelled && latestRunRequest.current === requestKey) setRunsLoading(false);
261
+ }
262
+ })();
263
+
264
+ return () => { cancelled = true; };
265
+ }, [selectedJob?.id, selectedJob?.containerName]);
266
+
267
+ useEffect(() => {
268
+ const controller = new AbortController();
269
+ void load(controller.signal);
270
+ const t = setInterval(() => { if (!controller.signal.aborted) void load(controller.signal); }, 15_000);
271
+ return () => { controller.abort(); clearInterval(t); };
272
+ }, []);
273
+
274
+ return (
275
+ <div className="crons-page">
276
+ <div className="crons-header">
277
+ <span className="crons-header__title">CRONS</span>
278
+ <span className="crons-header__meta">{runs.length} runs loaded</span>
279
+ </div>
280
+
281
+ {loading && <SkeletonLines count={4} />}
282
+
283
+ {/* Agent filter bar */}
284
+ {agentFilters.length > 1 && (
285
+ <div className="toolbar" style={{ flexWrap: 'wrap' }}>
286
+ {agentFilters.map((af) => (
287
+ <Button
288
+ key={af.key}
289
+ variant={agentFilter === af.key ? 'primary' : 'ghost'}
290
+ size="sm"
291
+ onClick={() => setAgentFilter(af.key)}
292
+ >
293
+ {af.label} ({filterJobs(jobs, af.key).length})
294
+ </Button>
295
+ ))}
296
+ </div>
297
+ )}
298
+
299
+ {isMobile && (
300
+ <div className="crons-mobile-tabs">
301
+ <Button variant={tab === 'jobs' ? 'primary' : 'ghost'} size="sm" onClick={() => setTab('jobs')}>JOBS</Button>
302
+ <Button variant={tab === 'runs' ? 'primary' : 'ghost'} size="sm" onClick={() => setTab('runs')}>RUNS</Button>
303
+ </div>
304
+ )}
305
+
306
+ <div style={{ flex: 1, display: 'flex', flexDirection: isMobile ? 'column' : 'row', minHeight: 0, overflow: 'hidden' }}>
307
+ {/* Scheduled jobs */}
308
+ <section style={{ width: isMobile ? '100%' : '40%', borderRight: isMobile ? 'none' : '1px solid var(--border)', display: isMobile && tab !== 'jobs' ? 'none' : 'flex', flexDirection: 'column', minHeight: 0 }}>
309
+ <div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border)', fontSize: 10, color: '#555', flexShrink: 0 }}>
310
+ SCHEDULED JOBS ({filteredJobs.length})
311
+ </div>
312
+ <div style={{ flex: 1, overflowY: 'auto' }}>
313
+ {filteredJobs.length === 0 && !loading && (
314
+ <div style={{ padding: '20px 12px', fontSize: 11, color: '#555' }}>No scheduled jobs found</div>
315
+ )}
316
+ {filteredJobs.map((job, i) => {
317
+ const expr = job.scheduleExpr
318
+ ?? (typeof job.schedule === 'string' ? job.schedule : (job.schedule as CronSchedule | undefined)?.expr ?? '');
319
+ const tz = typeof job.schedule === 'object' && job.schedule !== null
320
+ ? (job.schedule as CronSchedule).tz : undefined;
321
+ const model = job.payload?.model;
322
+ const prompt = job.payload?.message ?? (job.payload as { text?: string } | undefined)?.text;
323
+ const nextRun = job.computedNextRunAtMs ?? job.state?.nextRunAtMs;
324
+ const lastRun = job.state?.lastRunAtMs;
325
+ const lastStatus = job.state?.lastStatus;
326
+ const isEnabled = job.enabled !== false;
327
+ const isContainered = job.source === 'container' && !!job.containerName;
328
+ const isSelected = selectedJob?.id === job.id;
329
+ return (
330
+ <div
331
+ key={job.id ?? i}
332
+ onClick={() => setSelectedJob(isSelected ? null : job)}
333
+ style={{
334
+ padding: '12px 14px', borderBottom: '1px solid var(--border)',
335
+ opacity: isEnabled ? 1 : 0.6,
336
+ cursor: 'pointer',
337
+ background: isSelected ? 'var(--violet-bg)' : 'transparent',
338
+ borderLeft: isSelected ? '2px solid var(--violet)' : '2px solid transparent',
339
+ transition: 'background 0.15s, border-color 0.15s',
340
+ }}>
341
+ {/* Header row */}
342
+ <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 6, gap: 8 }}>
343
+ <div style={{ flex: 1, minWidth: 0 }}>
344
+ <div style={{ color: 'var(--violet)', fontSize: 12, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
345
+ {job.name ?? job.id ?? `job-${i}`}
346
+ </div>
347
+ {job.description && (
348
+ <div style={{ fontSize: 10, color: '#888', marginTop: 2, lineHeight: 1.4 }}>{String(job.description)}</div>
349
+ )}
350
+ </div>
351
+ <div style={{ display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
352
+ {isContainered && (
353
+ <span style={{ fontSize: 9 }}><Pill tone="success">{job.containerName ?? job.agentId ?? 'agent'}</Pill></span>
354
+ )}
355
+ {job.id && (
356
+ <>
357
+ <button
358
+ onClick={() => void toggleJob(job)}
359
+ disabled={busyJobId === job.id}
360
+ title={isEnabled ? 'Disable job' : 'Enable job'}
361
+ style={{
362
+ display: 'flex', alignItems: 'center', gap: 5,
363
+ padding: '3px 8px 3px 5px',
364
+ border: `1px solid ${isEnabled ? '#255b3f' : 'var(--border)'}`,
365
+ borderRadius: 0,
366
+ background: isEnabled ? 'rgba(34,197,94,0.08)' : 'var(--bg2)',
367
+ cursor: busyJobId === job.id ? 'not-allowed' : 'pointer',
368
+ opacity: busyJobId === job.id ? 0.5 : 1,
369
+ transition: 'all 0.15s', outline: 'none',
370
+ }}
371
+ >
372
+ <span style={{
373
+ display: 'inline-flex', width: 26, height: 14, borderRadius: 0,
374
+ background: busyJobId === job.id ? '#555' : isEnabled ? 'var(--green)' : '#3a3a3a',
375
+ alignItems: 'center', padding: '0 2px',
376
+ justifyContent: isEnabled ? 'flex-end' : 'flex-start',
377
+ transition: 'all 0.15s', flexShrink: 0,
378
+ }}>
379
+ <span style={{ width: 10, height: 10, borderRadius: 1, background: '#fff' }} />
380
+ </span>
381
+ <span style={{ fontSize: 9, letterSpacing: '0.07em', color: isEnabled ? 'var(--green)' : '#666' }}>
382
+ {busyJobId === job.id ? '···' : isEnabled ? 'ON' : 'OFF'}
383
+ </span>
384
+ </button>
385
+ <button
386
+ onClick={(e) => { e.stopPropagation(); void deleteJob(job); }}
387
+ disabled={busyJobId === job.id}
388
+ title="Delete job"
389
+ style={{
390
+ display: 'flex', alignItems: 'center', gap: 3,
391
+ padding: '3px 6px',
392
+ border: '1px solid var(--border)',
393
+ borderRadius: 0,
394
+ background: 'var(--bg2)',
395
+ cursor: busyJobId === job.id ? 'not-allowed' : 'pointer',
396
+ opacity: busyJobId === job.id ? 0.5 : 1,
397
+ transition: 'all 0.15s', outline: 'none',
398
+ }}
399
+ >
400
+ <span style={{ fontSize: 9, letterSpacing: '0.07em', color: 'var(--red)' }}>DEL</span>
401
+ </button>
402
+ </>
403
+ )}
404
+ </div>
405
+ </div>
406
+
407
+ {/* Schedule */}
408
+ {expr && (
409
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6, flexWrap: 'wrap' }}>
410
+ <span style={{ fontSize: 10, color: '#555' }}>schedule</span>
411
+ <span style={{ fontSize: 11, color: '#60a5fa', fontFamily: 'monospace' }}>{expr}</span>
412
+ {tz && <span style={{ fontSize: 10, color: '#555' }}>{tz}</span>}
413
+ {describeCron(expr) && (
414
+ <span style={{ fontSize: 10, color: '#a3a3a3', fontStyle: 'italic' }}>({describeCron(expr)})</span>
415
+ )}
416
+ </div>
417
+ )}
418
+
419
+ {/* Timing grid */}
420
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 12px', marginBottom: 6 }}>
421
+ {nextRun != null && (
422
+ <div>
423
+ <div style={{ fontSize: 9, color: '#555', letterSpacing: '0.06em', marginBottom: 1 }}>NEXT RUN</div>
424
+ <div style={{ fontSize: 11, color: isEnabled ? '#22c55e' : '#666' }}>{formatTs(nextRun, timezone)}</div>
425
+ </div>
426
+ )}
427
+ {lastRun != null && (
428
+ <div>
429
+ <div style={{ fontSize: 9, color: '#555', letterSpacing: '0.06em', marginBottom: 1 }}>LAST RUN</div>
430
+ <div style={{ fontSize: 11, color: '#888' }}>{formatTs(lastRun, timezone)}</div>
431
+ </div>
432
+ )}
433
+ {job.createdAtMs != null && (
434
+ <div>
435
+ <div style={{ fontSize: 9, color: '#555', letterSpacing: '0.06em', marginBottom: 1 }}>CREATED</div>
436
+ <div style={{ fontSize: 11, color: '#888' }}>{formatTs(job.createdAtMs as number, timezone)}</div>
437
+ </div>
438
+ )}
439
+ {lastStatus && (
440
+ <div style={{ display: 'flex', alignItems: 'flex-end' }}>
441
+ <Pill tone={lastStatus === 'ok' ? 'success' : lastStatus === 'error' ? 'danger' : 'neutral'}>{lastStatus.toUpperCase()}</Pill>
442
+ </div>
443
+ )}
444
+ </div>
445
+
446
+ {/* Meta row */}
447
+ <div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', fontSize: 10, color: '#555', borderTop: '1px solid var(--border)', paddingTop: 6, marginTop: 2 }}>
448
+ {job.source === 'host' && <span>source: <span style={{ color: '#888' }}>host (gateway)</span></span>}
449
+ {isContainered && <span>container: <span style={{ color: '#888' }}>{String(job.containerName)}</span></span>}
450
+ {model && <span>model: <span style={{ color: '#888' }}>{String(model)}</span></span>}
451
+ {job.sessionTarget && <span>target: <span style={{ color: '#888' }}>{String(job.sessionTarget)}</span></span>}
452
+ {job.delivery?.channel && <span>→ <span style={{ color: '#888' }}>{String(job.delivery.channel)}</span></span>}
453
+ {job.id && <span style={{ color: '#444', marginLeft: 'auto' }}>{String(job.id).slice(0, 8)}</span>}
454
+ </div>
455
+
456
+ {/* Prompt preview */}
457
+ {prompt && (
458
+ <div style={{ fontSize: 10, color: '#444', marginTop: 6, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontStyle: 'italic' }}>
459
+ &ldquo;{String(prompt).slice(0, 120)}{String(prompt).length > 120 ? '…' : ''}&rdquo;
460
+ </div>
461
+ )}
462
+ </div>
463
+ );
464
+ })}
465
+ </div>
466
+ </section>
467
+
468
+ {/* Cron run history */}
469
+ <section style={{ flex: 1, display: isMobile && tab !== 'runs' ? 'none' : 'flex', flexDirection: 'column', minHeight: 0 }}>
470
+ <div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border)', fontSize: 10, color: '#555', flexShrink: 0 }}>
471
+ RUN HISTORY {selectedJob ? `— ${selectedJob.name ?? selectedJob.id?.slice(0, 8) ?? ''}` : ''}
472
+ </div>
473
+ <div style={{ flex: 1, overflowY: 'auto' }}>
474
+ {!selectedJob && !loading && (
475
+ <div style={{ padding: '20px 12px', fontSize: 11, color: '#555' }}>Select a cron job to view its run history</div>
476
+ )}
477
+ {selectedJob && runsLoading && <SkeletonLines count={3} />}
478
+ {selectedJob && !runsLoading && runsError && (
479
+ <div style={{ padding: '20px 12px', fontSize: 11, color: 'var(--red)' }}>{runsError}</div>
480
+ )}
481
+ {selectedJob && !runsLoading && !runsError && runs.length === 0 && (
482
+ <div style={{ padding: '20px 12px', fontSize: 11, color: '#555' }}>No runs recorded yet</div>
483
+ )}
484
+ {selectedJob && !runsLoading && runs
485
+ .slice()
486
+ .sort((a, b) => (b.ts ?? 0) - (a.ts ?? 0))
487
+ .map((r) => {
488
+ const ts = r.ts ?? 0;
489
+ const status = r.status ?? 'unknown';
490
+ const action = r.action ?? '';
491
+ const dur = r.durationMs;
492
+ const runId = r.runId?.slice(-12) ?? '';
493
+ const error = r.error ?? r.diagnostics?.summary;
494
+ return (
495
+ <div key={r.runId ?? ts} style={{
496
+ padding: '8px 12px',
497
+ borderBottom: '1px solid var(--border)',
498
+ }}>
499
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
500
+ <span style={{
501
+ width: 6, height: 6, borderRadius: 1,
502
+ background: status === 'ok' ? 'var(--green)' : status === 'error' ? 'var(--red)' : 'var(--yellow)',
503
+ display: 'inline-block', flexShrink: 0,
504
+ }} />
505
+ <span style={{ fontSize: 10, color: 'var(--violet)', letterSpacing: '0.06em' }}>
506
+ {action.toUpperCase()}
507
+ </span>
508
+ <Pill tone={status === 'ok' ? 'success' : status === 'error' ? 'danger' : 'neutral'}>
509
+ {status.toUpperCase()}
510
+ </Pill>
511
+ {dur != null && (
512
+ <span style={{ fontSize: 10, color: '#555', marginLeft: 'auto' }}>
513
+ {dur >= 1000 ? `${(dur / 1000).toFixed(1)}s` : `${dur}ms`}
514
+ </span>
515
+ )}
516
+ </div>
517
+ <div style={{ fontSize: 10, color: '#555', marginBottom: error ? 4 : 0 }}>
518
+ {formatTs(ts, timezone)}{runId ? ` · #${runId}` : ''}
519
+ </div>
520
+ {error && (
521
+ <div style={{
522
+ fontSize: 9, color: 'var(--red)',
523
+ padding: '4px 6px', background: 'rgba(239,68,68,0.06)',
524
+ marginTop: 2, maxHeight: 50, overflow: 'hidden',
525
+ textOverflow: 'ellipsis',
526
+ }}>
527
+ {String(error).slice(0, 180)}
528
+ </div>
529
+ )}
530
+ </div>
531
+ );
532
+ })}
533
+ </div>
534
+ </section>
535
+ </div>
536
+ </div>
537
+ );
538
+ }
@@ -0,0 +1,10 @@
1
+ import { Skeleton, CardRowSkeleton } from '../components/Skeleton';
2
+
3
+ const PageSkeleton = () => (
4
+ <div style={{ padding: '24px 20px', maxWidth: 1080, margin: '0 auto' }}>
5
+ <Skeleton width={120} height={20} accent style={{ marginBottom: 20 }} />
6
+ <CardRowSkeleton count={5} height={48} />
7
+ </div>
8
+ );
9
+
10
+ export default PageSkeleton;
@@ -0,0 +1,5 @@
1
+ import CronsPageClient from './PageClient';
2
+
3
+ export default function CronsPage() {
4
+ return <CronsPageClient />;
5
+ }
@@ -0,0 +1,57 @@
1
+ // ═══════════════════════════════════════════════════════════════
2
+ // Rev4a Design System — Responsive Breakpoints & Tokens
3
+ // ═══════════════════════════════════════════════════════════════
4
+ // Source of truth for all responsive values.
5
+ // Change here → propagates everywhere.
6
+ // ═══════════════════════════════════════════════════════════════
7
+
8
+ /** Bootstrap v5 breakpoint categories, used as responsive values */
9
+ export const Breakpoint = {
10
+ /** 576px */
11
+ sm: 576,
12
+ /** 768px — general mobile layout boundary */
13
+ md: 768,
14
+ /** 992px — used by dense pages (Agents, Plugins, etc.) */
15
+ lg: 992,
16
+ /** 1200px */
17
+ xl: 1200,
18
+ /** 1400px */
19
+ xxl: 1400,
20
+ } as const satisfies Record<string, number>;
21
+
22
+ export type BreakpointKey = keyof typeof Breakpoint;
23
+
24
+ /**
25
+ * Reactive hook that returns `true` when the viewport is at or below
26
+ * the given breakpoint key.
27
+ *
28
+ * Uses `matchMedia` instead of `resize` listener for zero-CPU passive monitoring.
29
+ *
30
+ * @example
31
+ * const isMobile = useResponsive('md'); // true at ≤ 768px
32
+ * const isMobileExtended = useResponsive('lg'); // true at ≤ 992px
33
+ */
34
+ export function useResponsive(bp: BreakpointKey): boolean {
35
+ const getInitial = (): boolean => {
36
+ if (typeof window === 'undefined') return false;
37
+ const key = ((window as any)?.__REV4A_BREAKPOINT_OVERRIDE?.[bp] ?? Breakpoint[bp]) as number;
38
+ return window.matchMedia(`(max-width: ${key - 1}px)`).matches;
39
+ };
40
+
41
+ const [matches, setMatches] = useState(getInitial);
42
+ const mqlRef = useRef<MediaQueryList | null>(null);
43
+ const key = (typeof window !== 'undefined' ? (window as any)?.__REV4A_BREAKPOINT_OVERRIDE?.[bp] : undefined) ?? Breakpoint[bp];
44
+
45
+ useEffect(() => {
46
+ const mq = window.matchMedia(`(max-width: ${key - 1}px)`);
47
+ mqlRef.current = mq;
48
+ const onChange = () => setMatches(mq.matches);
49
+ onChange();
50
+ mq.addEventListener('change', onChange);
51
+ return () => mq.removeEventListener('change', onChange);
52
+ }, [bp, key]);
53
+
54
+ return matches;
55
+ }
56
+
57
+ import { useState, useEffect, useRef } from 'react';