This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/packages/schedules/js/main.js
Jeffrey Smith 4a6109b74f
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-runners (pull_request) Has been skipped
CI/CD / test-frontend (pull_request) Successful in 7s
CI/CD / test-sqlite (pull_request) Successful in 2m48s
CI/CD / test-go-pg (pull_request) Successful in 2m53s
CI/CD / build-and-deploy (pull_request) Successful in 1m8s
Feat v0.7.3 extension shell migration
Migrate Chat, Notes, and Schedules from legacy sw.shell.Topbar to the
v0.7.0 shell topbar contract (sw.shell.topbar.setTitle/setSlot),
eliminating double topbars on all extension surfaces.

- Chat v0.3.0: reactive slot for thread title + People button
- Notes v0.9.0: slot for New/Import/Graph buttons
- Schedules v0.2.0: reactive slot for count + New button
- 6 new shell-topbar runner tests (2 per surface)
- Headless E2E deferred to v0.7.5

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:52:40 +00:00

417 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Schedules — Cron Job Management Surface (v0.1.0)
*
* Wraps the kernel /api/v1/schedules API. No Starlark backend —
* this surface talks directly to the core scheduled-tasks endpoints.
*
* SDK usage:
* sw.shell.topbar — shell topbar API
* sw.ui.* — primitive components
* sw.api.* — raw REST calls to /api/v1/schedules
*/
(async function () {
'use strict';
var mount = document.getElementById('extension-mount');
if (!mount) return;
var base = window.__BASE__ || '';
var ver = window.__VERSION__ || '0';
// ── Boot SDK ───────────────────────────────
try {
if (!window.preact) {
var { h, render } = await import(base + '/js/sw/vendor/preact.module.js');
var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js');
var htmModule = await import(base + '/js/sw/vendor/htm.module.js');
window.preact = { h, render };
window.hooks = hooksModule;
window.html = htmModule.default.bind(h);
}
var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver);
await sdk.boot();
} catch (e) {
mount.innerHTML = '<p style="color:var(--danger);padding:24px;">SDK boot failed: ' + e.message + '</p>';
return;
}
var { html } = window;
var { useState, useEffect, useCallback } = hooks;
var { render } = preact;
var { Button, FormField, Dialog, Spinner, Banner } = sw.ui;
// ── API helpers (core kernel endpoints) ────
var API = '/api/v1/schedules';
function apiList() { return sw.api.get(API); }
function apiCreate(data) { return sw.api.post(API, data); }
function apiUpdate(id, d) { return sw.api.put(API + '/' + id, d); }
function apiDelete(id) { return sw.api.del(API + '/' + id); }
function apiRun(id) { return sw.api.post(API + '/' + id + '/run', {}); }
function apiLogs(id) { return sw.api.get(API + '/' + id + '/logs'); }
// ═══════════════════════════════════════════
// Cron description helper
// ═══════════════════════════════════════════
var DAYS = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
function describeCron(expr) {
if (!expr) return '';
var parts = expr.trim().split(/\s+/);
if (parts.length !== 5) return expr;
var min = parts[0], hr = parts[1], dom = parts[2], mon = parts[3], dow = parts[4];
// Format time
var timeStr = '';
if (min !== '*' && hr !== '*') {
var h = parseInt(hr, 10);
var m = parseInt(min, 10);
var ampm = h >= 12 ? 'PM' : 'AM';
var h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
timeStr = h12 + ':' + String(m).padStart(2, '0') + ' ' + ampm;
}
// Every N minutes
if (min.startsWith('*/') && hr === '*' && dom === '*' && mon === '*' && dow === '*') {
return 'Every ' + min.slice(2) + ' minutes';
}
// Every hour at :MM
if (min !== '*' && hr === '*' && dom === '*' && mon === '*' && dow === '*') {
return 'Every hour at :' + String(parseInt(min, 10)).padStart(2, '0');
}
// Daily
if (timeStr && dom === '*' && mon === '*' && dow === '*') {
return 'Daily at ' + timeStr;
}
// Weekdays
if (timeStr && dom === '*' && mon === '*' && dow === '1-5') {
return 'Weekdays at ' + timeStr;
}
// Specific days
if (timeStr && dom === '*' && mon === '*' && dow !== '*') {
var dayNames = dow.split(',').map(function(d) {
var n = parseInt(d, 10);
return DAYS[n] || d;
});
return dayNames.join(', ') + ' at ' + timeStr;
}
return expr;
}
// ═══════════════════════════════════════════
// ScheduleForm — create / edit dialog
// ═══════════════════════════════════════════
function ScheduleForm({ schedule, onSave, onClose }) {
var [name, setName] = useState(schedule ? schedule.name : '');
var [desc, setDesc] = useState(schedule ? schedule.description : '');
var [cron, setCron] = useState(schedule ? schedule.cron_expr : '');
var [script, setScript] = useState(schedule ? schedule.script : '');
var [enabled, setEnabled] = useState(schedule ? schedule.enabled : true);
var [saving, setSaving] = useState(false);
var [error, setError] = useState('');
var preview = describeCron(cron);
async function handleSubmit(e) {
e.preventDefault();
if (!name.trim()) { setError('Name is required'); return; }
if (!cron.trim()) { setError('Cron expression is required'); return; }
setSaving(true);
setError('');
try {
var data = { name: name.trim(), description: desc, cron_expr: cron.trim(), script: script, enabled: enabled };
if (schedule) {
await apiUpdate(schedule.id, data);
} else {
await apiCreate(data);
}
onSave();
} catch (err) {
var msg = err.message || 'Save failed';
if (err.body?.error) msg = err.body.error;
setError(msg);
} finally {
setSaving(false);
}
}
return html`
<${Dialog} open onClose=${onClose} title=${schedule ? 'Edit Schedule' : 'New Schedule'}>
<form onSubmit=${handleSubmit} class="ext-schedules-form">
${error && html`<${Banner} variant="danger" text=${error} />`}
<${FormField} label="Name" required>
<input type="text" value=${name} onInput=${e => setName(e.target.value)}
placeholder="e.g. Daily cleanup" autofocus />
<//>
<${FormField} label="Description">
<input type="text" value=${desc} onInput=${e => setDesc(e.target.value)}
placeholder="What does this job do?" />
<//>
<${FormField} label="Cron Expression" required>
<input type="text" value=${cron} onInput=${e => setCron(e.target.value)}
placeholder="e.g. 0 9 * * 1-5" class="ext-schedules-cron-input" />
${preview && preview !== cron && html`
<div class="ext-schedules-cron-preview">${preview}</div>
`}
<//>
<${FormField} label="Script">
<textarea rows="6" value=${script} onInput=${e => setScript(e.target.value)}
placeholder="Starlark script (optional)" class="ext-schedules-script" />
<//>
<div class="ext-schedules-form__row">
<label class="ext-schedules-toggle">
<input type="checkbox" checked=${enabled} onChange=${e => setEnabled(e.target.checked)} />
<span>Enabled</span>
</label>
</div>
<div class="ext-schedules-form__actions">
<${Button} variant="ghost" type="button" onClick=${onClose}>Cancel<//>
<${Button} variant="primary" type="submit" disabled=${saving}>
${saving ? 'Saving…' : (schedule ? 'Update' : 'Create')}
<//>
</div>
</form>
<//>
`;
}
// ═══════════════════════════════════════════
// ScheduleLogsPanel — execution history
// ═══════════════════════════════════════════
function ScheduleLogsPanel({ scheduleId, onClose }) {
var [logs, setLogs] = useState([]);
var [loading, setLoading] = useState(true);
useEffect(function () {
if (!scheduleId) return;
setLoading(true);
apiLogs(scheduleId).then(function (res) {
setLogs(res.logs || []);
}).catch(function () {
setLogs([]);
}).finally(function () {
setLoading(false);
});
}, [scheduleId]);
return html`
<div class="ext-schedules-logs">
<div class="ext-schedules-logs__header">
<span>Execution History</span>
<button class="ext-schedules-btn ext-schedules-btn--sm" onClick=${onClose}>Close</button>
</div>
${loading && html`<div class="ext-schedules-logs__loading"><${Spinner} size="sm" /></div>`}
${!loading && logs.length === 0 && html`
<div class="ext-schedules-logs__empty">No executions yet</div>
`}
${!loading && logs.map(function (log) {
var success = log.status === 'success' || log.status === 'ok';
return html`
<div class="ext-schedules-log-entry" key=${log.id || log.started_at}>
<span class="ext-schedules-log-entry__status ext-schedules-log-entry__status--${success ? 'ok' : 'fail'}">
${success ? '✓' : '✗'}
</span>
<span class="ext-schedules-log-entry__time">${_formatTime(log.started_at)}</span>
<span class="ext-schedules-log-entry__duration">${_formatDuration(log.duration_ms)}</span>
${log.error && html`<span class="ext-schedules-log-entry__error" title=${log.error}>${log.error}</span>`}
</div>
`;
})}
</div>
`;
}
function _formatTime(ts) {
if (!ts) return '—';
try {
return new Date(ts).toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
});
} catch (_) { return ts; }
}
function _formatDuration(ms) {
if (ms == null) return '';
if (ms < 1000) return ms + 'ms';
return (ms / 1000).toFixed(1) + 's';
}
// ═══════════════════════════════════════════
// ScheduleList — main table
// ═══════════════════════════════════════════
function ScheduleList({ items, loading, onEdit, onShowLogs, onRefresh }) {
async function handleToggle(item) {
try {
await apiUpdate(item.id, { enabled: !item.enabled });
onRefresh();
} catch (err) {
sw.toast(err.message || 'Failed to toggle', 'danger');
}
}
async function handleRun(item) {
try {
await apiRun(item.id);
sw.toast('Schedule queued for execution', 'success');
} catch (err) {
sw.toast(err.message || 'Run failed', 'danger');
}
}
async function handleDelete(item) {
if (!await sw.confirm('Delete schedule "' + item.name + '"?', { destructive: true })) return;
try {
await apiDelete(item.id);
sw.toast('Schedule deleted', 'success');
onRefresh();
} catch (err) {
sw.toast(err.message || 'Delete failed', 'danger');
}
}
return html`
<div class="ext-schedules-list">
${loading && html`<div class="ext-schedules-list__loading"><${Spinner} size="md" /></div>`}
${!loading && items.length === 0 && html`
<div class="ext-schedules-list__empty">No schedules yet. Create one to get started.</div>
`}
${!loading && items.length > 0 && html`
<table class="ext-schedules-table">
<thead>
<tr>
<th>Name</th>
<th>Schedule</th>
<th>Next Run</th>
<th>Enabled</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${items.map(function (s) {
return html`
<tr key=${s.id} class="ext-schedules-row">
<td class="ext-schedules-row__name">
<button class="ext-schedules-link" onClick=${() => onEdit(s)}>${s.name}</button>
${s.description && html`<div class="ext-schedules-row__desc">${s.description}</div>`}
</td>
<td class="ext-schedules-row__cron">
<span class="ext-schedules-cron-badge">${s.cron_expr}</span>
<div class="ext-schedules-cron-human">${describeCron(s.cron_expr)}</div>
</td>
<td class="ext-schedules-row__next">${s.next_fire_at ? _formatTime(s.next_fire_at) : '—'}</td>
<td>
<button class="ext-schedules-toggle-btn ext-schedules-toggle-btn--${s.enabled ? 'on' : 'off'}"
onClick=${() => handleToggle(s)}
title=${s.enabled ? 'Disable' : 'Enable'}>
${s.enabled ? 'On' : 'Off'}
</button>
</td>
<td class="ext-schedules-row__actions">
<button class="ext-schedules-btn ext-schedules-btn--sm" onClick=${() => handleRun(s)} title="Run now">▶</button>
<button class="ext-schedules-btn ext-schedules-btn--sm" onClick=${() => onShowLogs(s.id)} title="View logs">📋</button>
<button class="ext-schedules-btn ext-schedules-btn--sm ext-schedules-btn--danger" onClick=${() => handleDelete(s)}
title="Delete">×</button>
</td>
</tr>
`;
})}
</tbody>
</table>
`}
</div>
`;
}
// ═══════════════════════════════════════════
// ScheduleApp — root component
// ═══════════════════════════════════════════
function ScheduleApp() {
var [items, setItems] = useState([]);
var [loading, setLoading] = useState(true);
var [error, setError] = useState('');
var [editing, setEditing] = useState(null); // null=closed, {}=new, {id}=edit
var [logsFor, setLogsFor] = useState(null); // schedule id or null
var loadItems = useCallback(async function () {
setLoading(true);
try {
var res = await apiList();
setItems(res.schedules || []);
setError('');
} catch (err) {
setError(err.message || 'Failed to load schedules');
} finally {
setLoading(false);
}
}, []);
useEffect(function () { loadItems(); }, []);
// ── Shell topbar ───────────────────────────
useEffect(function () {
if (!sw.shell?.topbar) return;
sw.shell.topbar.setTitle('Schedules');
sw.shell.topbar.setSlot(html`
<span class="ext-schedules-count">${items.length} schedule${items.length !== 1 ? 's' : ''}</span>
<${Button} variant="primary" size="sm" onClick=${() => setEditing({})}>+ New Schedule<//>
`);
}, [items.length]);
function handleSave() {
setEditing(null);
loadItems();
}
return html`
<div class="ext-schedules-app">
${error && html`<${Banner} variant="danger" text=${error} />`}
<div class="ext-schedules-body">
<${ScheduleList}
items=${items}
loading=${loading}
onEdit=${setEditing}
onShowLogs=${setLogsFor}
onRefresh=${loadItems}
/>
${logsFor && html`
<${ScheduleLogsPanel} scheduleId=${logsFor} onClose=${() => setLogsFor(null)} />
`}
</div>
${editing !== null && html`
<${ScheduleForm}
schedule=${editing.id ? editing : null}
onSave=${handleSave}
onClose=${() => setEditing(null)}
/>
`}
</div>
`;
}
// ── Mount ──────────────────────────────────
render(html`<${ScheduleApp} />`, mount);
})();