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 abf71162b1
Some checks failed
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / build-and-deploy (pull_request) Has been cancelled
CI/CD / test-sqlite (pull_request) Has been cancelled
CI/CD / test-go-pg (pull_request) Has been cancelled
CI/CD / test-frontend (pull_request) Has been cancelled
Feat v0.6.12 extension css isolation
Prefix enforcement prevents extension CSS from leaking into the kernel
or sibling extensions. All 12 in-tree packages migrated to .ext-{slug}-*
naming convention.

- Add data-ext attribute to extension mount container
- Add CSS linter (scripts/lint-package-css.sh) enforcing .ext-{slug} prefix
- Add kernel CSS contract doc (docs/EXTENSION-CSS.md)
- Migrate 12 packages: chat, dashboard, editor, git-board, hello-dashboard,
  icd-test-runner, notes, schedules, sdk-test-runner, tasks,
  team-activity-log, workflow-demo (CSS + JS in lockstep)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 11:54:51 +00:00

419 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 — standard navigation bar
* 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;
var Topbar = sw.shell?.Topbar;
// ── 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(); }, []);
function handleSave() {
setEditing(null);
loadItems();
}
return html`
<div class="ext-schedules-app">
${Topbar ? html`
<${Topbar} title="Schedules">
<span class="ext-schedules-count">${items.length} schedule${items.length !== 1 ? 's' : ''}</span>
<${Button} variant="primary" size="sm" onClick=${() => setEditing({})}>+ New Schedule<//>
<//>
` : html`
<div class="ext-schedules-header">
<h1>Schedules</h1>
<${Button} variant="primary" size="sm" onClick=${() => setEditing({})}>+ New Schedule<//>
</div>
`}
${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);
})();