diff --git a/openspec/specs/cron-channel-targeting/spec.md b/openspec/specs/cron-channel-targeting/spec.md new file mode 100644 index 00000000..30cce324 --- /dev/null +++ b/openspec/specs/cron-channel-targeting/spec.md @@ -0,0 +1,29 @@ +## Purpose + +Allow cron jobs to deliver their output to a specific connector channel (e.g. "telegram", "web") instead of always using the last-interacted channel. This enables scenarios where scheduled market analysis goes to Telegram while interactive chat stays on the web UI. + +## Requirements + +### Requirement: Channel field on CronJob +The `CronJob` interface in `src/task/cron/engine.ts` SHALL include an optional `channel?: string` field. When set, the cron job's output SHALL be delivered to that specific connector channel. + +### Requirement: Channel propagation through CronFirePayload +The `CronFirePayload` interface SHALL include the `channel?: string` field, propagated from the originating `CronJob`. The cron engine SHALL pass `job.channel` into the fire event payload. + +### Requirement: Channel in CRUD operations +- `CronJobCreate` SHALL accept an optional `channel` field. +- `CronJobPatch` SHALL accept an optional `channel` field. +- The cron engine `add()` SHALL persist the channel value. +- The cron engine `update()` SHALL update the channel value when provided. + +### Requirement: AI tool support +The `cronAdd` and `cronUpdate` AI tools SHALL expose a `channel` parameter described as "Deliver results to a specific connector channel (e.g. 'telegram', 'web'). Defaults to last-interacted." The tools SHALL pass the channel value through to the engine. + +### Requirement: ConnectorCenter targeted delivery +`ConnectorCenter.notify()` and `ConnectorCenter.notifyStream()` SHALL accept a `channel` option in `NotifyOpts`. When `channel` is provided, the method SHALL look up the connector by name via `this.get(opts.channel)` instead of using the last-interacted fallback via `this.resolveTarget()`. + +### Requirement: CronListener delivery +The `CronListener` SHALL pass `payload.channel` into the `connectorCenter.notify()` call's options, enabling the fired job's output to reach the intended channel. + +### Requirement: API route support +The `POST /api/cron/jobs` route SHALL accept an optional `channel` field in the request body and pass it to `cronEngine.add()`. diff --git a/openspec/specs/cron-ui-page/spec.md b/openspec/specs/cron-ui-page/spec.md new file mode 100644 index 00000000..1f206b46 --- /dev/null +++ b/openspec/specs/cron-ui-page/spec.md @@ -0,0 +1,128 @@ +## Purpose + +Provide a web UI page for managing cron jobs — listing, creating, editing, enabling/disabling, triggering, and deleting scheduled jobs. Complements the existing backend cron API routes (`/api/cron/jobs`) and the AI-facing cron tools, giving the user a visual dashboard for all scheduled tasks including heartbeat, market briefings, and trading signal monitors. + +## Requirements + +### Requirement: CronPage route and sidebar entry +The Cron Jobs page SHALL be registered as a new route in `ui/src/App.tsx`: +- Page type: `'cron'` +- Route path: `/cron` +- Component: `CronPage` from `ui/src/pages/CronPage.tsx` + +The sidebar (`ui/src/components/Sidebar.tsx`) SHALL include a "Cron Jobs" navigation item with a clock icon, positioned after the Heartbeat entry. + +#### Scenario: Navigation to Cron page +- **WHEN** the user clicks "Cron Jobs" in the sidebar +- **THEN** the app SHALL navigate to `/cron` and render `CronPage` + +#### Scenario: Active state in sidebar +- **WHEN** the current path is `/cron` +- **THEN** the "Cron Jobs" nav item SHALL display with the active indicator (accent bar + highlighted text) + +### Requirement: Job list display +`CronPage` SHALL fetch all cron jobs from `api.cron.list()` on mount and display them as cards. Each job card SHALL show: +- Job name (with "Heartbeat" display for `__heartbeat__` jobs) +- Status badge: green "OK" for last successful run, red "Error (Nx)" for consecutive errors, gray "Never run" for new jobs +- Schedule label: human-readable format (e.g. `every 4h`, `0 9 * * 1-5`, `once @ 2026-04-01T09:00:00Z`) +- Job ID +- Next run time (relative + absolute) +- Last run time (absolute) +- Collapsible payload preview showing the full prompt text + +#### Scenario: Jobs loaded and displayed +- **WHEN** the CronPage mounts +- **THEN** all jobs from `/api/cron/jobs` SHALL be displayed as cards + +#### Scenario: Heartbeat job shown separately +- **WHEN** a job with `name: '__heartbeat__'` exists +- **THEN** it SHALL be displayed first, with a "system" badge and "Heartbeat" as the display name + +#### Scenario: Disabled jobs visually distinct +- **WHEN** a job has `enabled: false` +- **THEN** the card SHALL render with reduced opacity (60%) + +### Requirement: Toggle enable/disable +Each job card SHALL include a `Toggle` component that enables or disables the job via `api.cron.update(id, { enabled })`. The job list SHALL refresh after toggling. + +#### Scenario: Disable a job +- **WHEN** the user toggles a job from enabled to disabled +- **THEN** `PUT /api/cron/jobs/:id` SHALL be called with `{ enabled: false }` and the list SHALL reload + +### Requirement: Run Now button +Each job card SHALL include a "Run" button that triggers immediate execution via `api.cron.runNow(id)`. The button SHALL show a loading state while the request is in flight and display a "Job triggered!" feedback message on success. + +#### Scenario: Manual trigger +- **WHEN** the user clicks "Run" on a job +- **THEN** `POST /api/cron/jobs/:id/run` SHALL be called, triggering the job immediately + +### Requirement: Delete with confirmation +Non-heartbeat job cards SHALL include a delete button (trash icon). Clicking it SHALL show inline "Yes" / "No" confirmation buttons. Confirming SHALL call `api.cron.remove(id)` and refresh the list. The heartbeat job SHALL NOT have a delete button. + +#### Scenario: Delete confirmed +- **WHEN** the user clicks delete then confirms +- **THEN** `DELETE /api/cron/jobs/:id` SHALL be called and the job SHALL disappear from the list + +#### Scenario: Delete cancelled +- **WHEN** the user clicks delete then clicks "No" +- **THEN** the confirmation UI SHALL dismiss without any API call + +### Requirement: Create/Edit modal +The page SHALL include a "New Job" button (in the page header) and "Edit" buttons on each non-heartbeat card. Both SHALL open a modal form with the following fields: +- **Name** (text input, required) +- **Schedule Type** (select: Cron 5-field / Interval / One-shot) +- **Schedule Value** (text input with placeholder matching the selected type) +- **Channel** (select: Default / Telegram / Web) +- **Payload** (textarea, monospace, min 200px height, required) +- **Enabled** (toggle) + +The modal SHALL close on backdrop click, the X button, or the Cancel button. On submit, it SHALL call `api.cron.add()` for new jobs or `api.cron.update()` for edits, then refresh the list. + +#### Scenario: Create new job +- **WHEN** the user fills the form and clicks "Create" +- **THEN** `POST /api/cron/jobs` SHALL be called with the form data and the new job SHALL appear in the list + +#### Scenario: Edit existing job +- **WHEN** the user edits a job and clicks "Update" +- **THEN** `PUT /api/cron/jobs/:id` SHALL be called with the changed fields + +#### Scenario: Validation +- **WHEN** the user submits with empty name, schedule, or payload +- **THEN** the form SHALL display an error message and NOT call the API + +### Requirement: Recent cron events +The page SHALL include a "Recent Cron Events" section that fetches the last 500 event log entries, filters to `cron.*` types, and displays the most recent 30 in a table with columns: +- Time (formatted date + time) +- Type (fire / done / error, color-coded: green for done, red for error, purple for fire) +- Job name +- Details (duration for done events, error message for error events) + +#### Scenario: Events displayed +- **WHEN** the CronPage loads and cron events exist in the event log +- **THEN** the events table SHALL show the most recent 30 cron-related events + +#### Scenario: No events +- **WHEN** no cron events exist +- **THEN** the table SHALL show "No cron events yet" + +### Requirement: API client +The UI SHALL use the existing `ui/src/api/cron.ts` module which provides: +- `cronApi.list()` → `GET /api/cron/jobs` → `{ jobs: CronJob[] }` +- `cronApi.add(params)` → `POST /api/cron/jobs` → `{ id: string }` +- `cronApi.update(id, patch)` → `PUT /api/cron/jobs/:id` +- `cronApi.remove(id)` → `DELETE /api/cron/jobs/:id` +- `cronApi.runNow(id)` → `POST /api/cron/jobs/:id/run` + +The `CronJob` type is already defined in `ui/src/api/types.ts` with fields: `id`, `name`, `enabled`, `schedule` (CronSchedule), `payload`, `state` (CronJobState), `createdAt`. + +### Requirement: Consistent design system +The CronPage SHALL use the same UI components as other pages: +- `PageHeader` for title bar with job count and action buttons +- `Section` / `Card` containers from `ui/src/components/form.tsx` +- `Toggle` from `ui/src/components/Toggle.tsx` +- `inputClass` for form inputs +- Standard color tokens (`text`, `text-muted`, `bg`, `bg-secondary`, `bg-tertiary`, `border`, `accent`, `green`, `red`, `purple`) + +#### Scenario: Visual consistency +- **WHEN** the CronPage is rendered alongside other pages (Heartbeat, Settings, etc.) +- **THEN** the styling SHALL be visually consistent with the rest of the application diff --git a/src/connectors/web/routes/cron.ts b/src/connectors/web/routes/cron.ts index f2e481cb..557e53cb 100644 --- a/src/connectors/web/routes/cron.ts +++ b/src/connectors/web/routes/cron.ts @@ -17,6 +17,7 @@ export function createCronRoutes(ctx: EngineContext) { payload: string schedule: { kind: string; at?: string; every?: string; cron?: string } enabled?: boolean + channel?: string }>() if (!body.name || !body.payload || !body.schedule?.kind) { return c.json({ error: 'name, payload, and schedule are required' }, 400) @@ -26,6 +27,7 @@ export function createCronRoutes(ctx: EngineContext) { payload: body.payload, schedule: body.schedule as CronSchedule, enabled: body.enabled, + channel: body.channel, }) return c.json({ id }) } catch (err) { diff --git a/src/core/connector-center.ts b/src/core/connector-center.ts index 1c7ee00a..3a43fc3a 100644 --- a/src/core/connector-center.ts +++ b/src/core/connector-center.ts @@ -24,6 +24,8 @@ export interface NotifyOpts { kind?: 'message' | 'notification' media?: MediaAttachment[] source?: 'heartbeat' | 'cron' | 'manual' + /** Override: deliver to this specific channel instead of last-interacted. */ + channel?: string } /** Result of a notify() call. */ @@ -89,7 +91,7 @@ export class ConnectorCenter { * Falls back to the first registered connector if no interaction yet. */ async notify(text: string, opts?: NotifyOpts): Promise { - const target = this.resolveTarget() + const target = opts?.channel ? this.get(opts.channel) : this.resolveTarget() if (!target) return { delivered: false } const payload = this.buildPayload(text, opts) @@ -103,7 +105,7 @@ export class ConnectorCenter { * Otherwise drains the stream and falls back to send() with the completed result. */ async notifyStream(stream: StreamableResult, opts?: NotifyOpts): Promise { - const target = this.resolveTarget() + const target = opts?.channel ? this.get(opts.channel) : this.resolveTarget() if (!target) { await stream // drain to prevent hanging generator return { delivered: false } diff --git a/src/task/cron/engine.ts b/src/task/cron/engine.ts index bb0dd075..79ea9198 100644 --- a/src/task/cron/engine.ts +++ b/src/task/cron/engine.ts @@ -37,6 +37,8 @@ export interface CronJob { enabled: boolean schedule: CronSchedule payload: string + /** Deliver results to this specific connector channel (e.g. "telegram", "web"). */ + channel?: string state: CronJobState createdAt: number } @@ -45,6 +47,8 @@ export interface CronFirePayload { jobId: string jobName: string payload: string + /** Target connector channel for delivery. */ + channel?: string } // ==================== CRUD Types ==================== @@ -54,6 +58,7 @@ export interface CronJobCreate { schedule: CronSchedule payload: string enabled?: boolean + channel?: string } export interface CronJobPatch { @@ -61,6 +66,7 @@ export interface CronJobPatch { schedule?: CronSchedule payload?: string enabled?: boolean + channel?: string } // ==================== Engine Interface ==================== @@ -163,6 +169,7 @@ export function createCronEngine(opts: CronEngineOpts): CronEngine { jobId: job.id, jobName: job.name, payload: job.payload, + channel: job.channel, } satisfies CronFirePayload) job.state.lastStatus = 'ok' @@ -220,6 +227,7 @@ export function createCronEngine(opts: CronEngineOpts): CronEngine { enabled: params.enabled ?? true, schedule: params.schedule, payload: params.payload, + channel: params.channel, state: { nextRunAtMs: computeNextRun(params.schedule, currentMs), lastRunAtMs: null, @@ -246,6 +254,7 @@ export function createCronEngine(opts: CronEngineOpts): CronEngine { if (patch.name !== undefined) job.name = patch.name if (patch.payload !== undefined) job.payload = patch.payload if (patch.enabled !== undefined) job.enabled = patch.enabled + if (patch.channel !== undefined) job.channel = patch.channel if (patch.schedule !== undefined) { job.schedule = patch.schedule diff --git a/src/task/cron/listener.ts b/src/task/cron/listener.ts index 61c2b6cf..f3efce8e 100644 --- a/src/task/cron/listener.ts +++ b/src/task/cron/listener.ts @@ -63,11 +63,12 @@ export function createCronListener(opts: CronListenerOpts): CronListener { historyPreamble: 'The following is the recent cron session conversation. This is an automated cron job execution.', }) - // Send notification through the last-interacted connector + // Send notification through the targeted connector (or last-interacted fallback) try { await connectorCenter.notify(result.text, { media: result.media, source: 'cron', + channel: payload.channel, }) } catch (sendErr) { console.warn(`cron-listener: send failed for job ${payload.jobId}:`, sendErr) diff --git a/src/task/cron/tools.ts b/src/task/cron/tools.ts index 84665287..8e60febe 100644 --- a/src/task/cron/tools.ts +++ b/src/task/cron/tools.ts @@ -61,12 +61,13 @@ export function createCronTools(cronEngine: CronEngine) { payload: z.string().describe('The reminder/instruction text delivered to you when the job fires'), schedule: scheduleSchema.optional().describe('When the job should run'), enabled: z.boolean().optional().describe('Whether the job starts enabled (default: true)'), + channel: z.string().optional().describe('Deliver results to a specific connector channel (e.g. "telegram", "web"). Defaults to last-interacted.'), sessionTarget: z .enum(['main', 'isolated']) .optional() .describe('Where to run: "main" injects into heartbeat session (default), "isolated" runs in a fresh session'), }), - execute: async ({ name, payload, schedule, enabled }) => { + execute: async ({ name, payload, schedule, enabled, channel }) => { if (!schedule) { return { error: 'schedule is required' } } @@ -75,6 +76,7 @@ export function createCronTools(cronEngine: CronEngine) { payload, schedule, enabled, + channel, }) return { id } }, @@ -91,14 +93,15 @@ export function createCronTools(cronEngine: CronEngine) { payload: z.string().optional().describe('New payload text'), schedule: scheduleSchema.optional().describe('New schedule'), enabled: z.boolean().optional().describe('Enable or disable the job'), + channel: z.string().optional().describe('Deliver results to a specific connector channel (e.g. "telegram", "web").'), sessionTarget: z .enum(['main', 'isolated']) .optional() .describe('New session target'), }), - execute: async ({ id, name, payload, schedule, enabled }) => { + execute: async ({ id, name, payload, schedule, enabled, channel }) => { try { - await cronEngine.update(id, { name, payload, schedule, enabled }) + await cronEngine.update(id, { name, payload, schedule, enabled, channel }) return { ok: true } } catch (err) { return { error: err instanceof Error ? err.message : String(err) } diff --git a/ui/src/App.tsx b/ui/src/App.tsx index b0819cf2..9b187d18 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -12,11 +12,12 @@ import { TradingPage } from './pages/TradingPage' import { ConnectorsPage } from './pages/ConnectorsPage' import { DevPage } from './pages/DevPage' import { HeartbeatPage } from './pages/HeartbeatPage' +import { CronPage } from './pages/CronPage' import { ToolsPage } from './pages/ToolsPage' import { AgentStatusPage } from './pages/AgentStatusPage' export type Page = - | 'chat' | 'portfolio' | 'events' | 'agent-status' | 'heartbeat' | 'market-data' | 'news' | 'connectors' + | 'chat' | 'portfolio' | 'events' | 'agent-status' | 'heartbeat' | 'cron' | 'market-data' | 'news' | 'connectors' | 'trading' | 'ai-provider' | 'settings' | 'tools' | 'dev' @@ -27,6 +28,7 @@ export const ROUTES: Record = { 'events': '/events', 'agent-status': '/agent-status', 'heartbeat': '/heartbeat', + 'cron': '/cron', 'market-data': '/market-data', 'news': '/news', 'connectors': '/connectors', @@ -70,6 +72,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/cron.ts b/ui/src/api/cron.ts index 3ddb0e3b..c051802f 100644 --- a/ui/src/api/cron.ts +++ b/ui/src/api/cron.ts @@ -8,7 +8,7 @@ export const cronApi = { return res.json() }, - async add(params: { name: string; payload: string; schedule: CronSchedule; enabled?: boolean }): Promise<{ id: string }> { + async add(params: { name: string; payload: string; schedule: CronSchedule; enabled?: boolean; channel?: string }): Promise<{ id: string }> { const res = await fetch('/api/cron/jobs', { method: 'POST', headers, @@ -21,7 +21,7 @@ export const cronApi = { return res.json() }, - async update(id: string, patch: Partial<{ name: string; payload: string; schedule: CronSchedule; enabled: boolean }>): Promise { + async update(id: string, patch: Partial<{ name: string; payload: string; schedule: CronSchedule; enabled: boolean; channel: string }>): Promise { const res = await fetch(`/api/cron/jobs/${id}`, { method: 'PUT', headers, diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index ba752935..845b3c97 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -141,6 +141,7 @@ export interface CronJob { enabled: boolean schedule: CronSchedule payload: string + channel?: string state: CronJobState createdAt: number } diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index a8241628..d9ab289b 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -87,6 +87,16 @@ const NAV_ITEMS: NavItem[] = [ ), }, + { + page: 'cron' as const, + label: 'Cron Jobs', + icon: (active: boolean) => ( + + + + + ), + }, { page: 'market-data', label: 'Market Data', diff --git a/ui/src/pages/CronPage.tsx b/ui/src/pages/CronPage.tsx new file mode 100644 index 00000000..f5eb90cd --- /dev/null +++ b/ui/src/pages/CronPage.tsx @@ -0,0 +1,524 @@ +import { useState, useEffect, useCallback } from 'react' +import { api, type CronJob, type CronSchedule, type EventLogEntry } from '../api' +import { Toggle } from '../components/Toggle' +import { PageHeader } from '../components/PageHeader' +import { Section, inputClass } from '../components/form' + +function formatDateTime(ts: number): string { + const d = new Date(ts) + const date = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + const time = d.toLocaleTimeString('en-US', { hour12: false }) + return `${date} ${time}` +} + +function formatRelative(ms: number): string { + const now = Date.now() + const diff = ms - now + if (diff < 0) return 'overdue' + const mins = Math.floor(diff / 60_000) + if (mins < 60) return `in ${mins}m` + const hrs = Math.floor(mins / 60) + if (hrs < 24) return `in ${hrs}h ${mins % 60}m` + return `in ${Math.floor(hrs / 24)}d` +} + +function scheduleLabel(s: CronSchedule): string { + switch (s.kind) { + case 'at': return `once @ ${s.at}` + case 'every': return `every ${s.every}` + case 'cron': return s.cron + } +} + +function statusBadge(state: CronJob['state']): { color: string; label: string } { + if (state.lastStatus === 'ok') return { color: 'bg-green', label: 'OK' } + if (state.lastStatus === 'error') return { color: 'bg-red', label: `Error (${state.consecutiveErrors}x)` } + return { color: 'bg-text-muted', label: 'Never run' } +} + +// ==================== Job Card ==================== + +interface JobCardProps { + job: CronJob + onToggle: (id: string, enabled: boolean) => void + onRunNow: (id: string) => void + onDelete: (id: string) => void + onEdit: (job: CronJob) => void +} + +function JobCard({ job, onToggle, onRunNow, onDelete, onEdit }: JobCardProps) { + const [running, setRunning] = useState(false) + const [confirmDelete, setConfirmDelete] = useState(false) + const badge = statusBadge(job.state) + const isHeartbeat = job.name === '__heartbeat__' + + const handleRunNow = async () => { + setRunning(true) + try { await onRunNow(job.id) } finally { setRunning(false) } + } + + return ( +
+
+
+
+

+ {isHeartbeat ? 'Heartbeat' : job.name} +

+ + {badge.label} + + {isHeartbeat && ( + + system + + )} +
+
+
+ {scheduleLabel(job.schedule)} + | + ID: {job.id} +
+ {job.state.nextRunAtMs && ( +
Next: {formatRelative(job.state.nextRunAtMs)} ({formatDateTime(job.state.nextRunAtMs)})
+ )} + {job.state.lastRunAtMs && ( +
Last: {formatDateTime(job.state.lastRunAtMs)}
+ )} +
+
+ +
+ + {!isHeartbeat && ( + + )} + onToggle(job.id, v)} size="sm" /> + {!isHeartbeat && ( + confirmDelete ? ( +
+ + +
+ ) : ( + + ) + )} +
+
+ + {/* Payload preview */} +
+ + Show payload + +
+          {job.payload}
+        
+
+
+ ) +} + +// ==================== Add/Edit Modal ==================== + +interface JobFormData { + name: string + scheduleKind: 'at' | 'every' | 'cron' + scheduleValue: string + payload: string + channel: string + enabled: boolean +} + +const defaultForm: JobFormData = { + name: '', + scheduleKind: 'cron', + scheduleValue: '', + payload: '', + channel: 'telegram', + enabled: true, +} + +function jobToForm(job: CronJob): JobFormData { + const s = job.schedule + return { + name: job.name, + scheduleKind: s.kind, + scheduleValue: s.kind === 'at' ? s.at : s.kind === 'every' ? s.every : s.cron, + payload: job.payload, + channel: job.channel || '', + enabled: job.enabled, + } +} + +function formToSchedule(form: JobFormData): CronSchedule { + switch (form.scheduleKind) { + case 'at': return { kind: 'at', at: form.scheduleValue } + case 'every': return { kind: 'every', every: form.scheduleValue } + case 'cron': return { kind: 'cron', cron: form.scheduleValue } + } +} + +interface JobModalProps { + editing: CronJob | null + onClose: () => void + onSave: (form: JobFormData, editingId: string | null) => Promise +} + +function JobModal({ editing, onClose, onSave }: JobModalProps) { + const [form, setForm] = useState(editing ? jobToForm(editing) : defaultForm) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!form.name.trim() || !form.scheduleValue.trim() || !form.payload.trim()) { + setError('Name, schedule, and payload are required') + return + } + setSaving(true) + setError(null) + try { + await onSave(form, editing?.id ?? null) + onClose() + } catch (err) { + setError(err instanceof Error ? err.message : 'Save failed') + } finally { + setSaving(false) + } + } + + const update = (patch: Partial) => setForm((f) => ({ ...f, ...patch })) + + return ( +
+
e.stopPropagation()} + onSubmit={handleSubmit} + className="bg-bg-secondary border border-border rounded-xl w-full max-w-[640px] max-h-[90vh] flex flex-col shadow-2xl mx-4" + > +
+

{editing ? 'Edit Job' : 'New Cron Job'}

+ +
+ +
+
+ + update({ name: e.target.value })} placeholder="My signal check" /> +
+ +
+
+ + +
+
+ + update({ scheduleValue: e.target.value })} + placeholder={form.scheduleKind === 'cron' ? '0 9 * * 1-5' : form.scheduleKind === 'every' ? '4h' : '2026-04-01T09:00:00Z'} + /> +
+
+ +
+ + +
+ +
+ +