diff --git a/CHANGELOG.md b/CHANGELOG.md index fc51e19..822b28c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,40 @@ All notable changes to Switchboard Core are documented here. +## [Unreleased] β€” v0.2.4 + +### Added + +- **`sw.shell.Topbar`**: Standard navigation bar component for surfaces. + Composes title + extension slot + NotificationBell + UserMenu into a + consistent 44px bar. Surfaces use `<${sw.shell.Topbar} title="...">` with + children rendered in the extension slot. Graceful fallback if unavailable. +- **Schedules surface** (`packages/schedules/`): New package wrapping the + kernel `/api/v1/schedules` API. Table view with cron badge + human-readable + preview, next fire time, enable/disable toggle, manual run, and execution + logs panel. Create/edit dialog with live cron-to-english preview. +- **Manifest `icon` field**: Packages can declare an emoji icon in + `manifest.json` (`"icon": "⏰"`). Served via the surfaces API `icon` field. + Rendered in the UserMenu flyout next to each surface name. + +### Changed + +- **UserMenu**: Surface list now driven entirely by the `/api/v1/surfaces` + API. Removed hardcoded Chat, Notes, Projects links (gutted in Phase 0). + Core surfaces (Admin, Settings, Team Admin, Workflow) filtered from the + API list and handled as dedicated menu items with RBAC gating. +- **Tasks surface**: Replaced custom `.tasks-header` with `sw.shell.Topbar`. + View tabs and create button rendered in the extension slot. + +### Fixed + +- **`sw.isAdmin` RBAC regression**: `isAdmin()` in `can.js` was checking the + deprecated `user.role === 'admin'` field instead of the v0.2.0 RBAC grant + `surface.admin.access`. Admin menu item and admin-gated features now appear + correctly for users in the Admins group. + +--- + ## [Unreleased] β€” v0.2.2 ### Added diff --git a/ROADMAP.md b/ROADMAP.md index f834725..bca20f7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -64,8 +64,35 @@ SDK stabilization, and the first rebuilt extension (tasks). | Step | Status | Description | |------|--------|-------------| -| SDK stabilization | πŸ”² | `sb.slots()`, `sb.actions`, `sb.api.ext()`, `sb.storage`, theme tokens, primitive UI | -| Task extension | πŸ”² | First proof-of-concept β€” full task system rebuilt as Starlark extension using triggers + ext_data + notifications | +| SDK stabilization | βœ… | `sw.api.ext()`, `sw.storage`, `sw.theme.tokens`, `sw.ui`, `sw.slots`, `sw.actions` β€” six new SDK modules for extension development | +| Task extension | βœ… | Full task surface rebuilt as Starlark extension: CRUD API, kanban/list views, event triggers, webhook integration, notifications on completion | + +### v0.2.4 β€” Shell Navigation + Schedules + +| Step | Status | Description | +|------|--------|-------------| +| SDK Topbar | βœ… | `sw.shell.Topbar` β€” composable navigation bar (title + extension slot + bell + user menu). Surfaces get consistent nav for free. | +| Schedules surface | βœ… | New `packages/schedules/` wrapping kernel cron API. Table view, cron preview, enable/disable, manual run, execution logs. | +| Manifest icons | βœ… | `icon` field in manifest.json (emoji). Surfaces API returns icon. UserMenu renders per-surface icons. | +| UserMenu cleanup | βœ… | Removed dead Chat/Notes/Projects links. Menu driven by surfaces API. Core surfaces filtered. | +| isAdmin RBAC fix | βœ… | `can.js` isAdmin() now checks `surface.admin.access` grant instead of deprecated role column. | + +### v0.2.5 β€” UI Polish + Dead Code Audit + +| Step | Status | Description | +|------|--------|-------------| +| UI bug pass | ⬚ | Systematic review of all surfaces (admin, settings, dashboard, git-board, tasks, schedules). Fix broken CSS, missing states, responsive issues, dark/light theme gaps. | +| Dead code sweep | ⬚ | Audit for Phase 0 remnants: orphaned CSS classes, stale imports, references to gutted chat/notes/projects in templates and route registrations, unused Go handlers/stores. | +| Template cleanup | ⬚ | Remove stale CSS link tags (sw-chat-pane.css, sw-notes-pane.css etc.) from HTML templates. Clean up base.html surface wiring. | +| Package proof-of-concept status | ⬚ | Mark tasks + schedules packages as PoC (not permanent). Document what "permanent" means and the graduation criteria for extensions shipping with core. | + +### v0.2.6 β€” Workflow Architecture + +| Step | Status | Description | +|------|--------|-------------| +| Workflow design session | ⬚ | Define what "workflow" means in the extension-first model. Determine if the existing `workflows` table/handler survives, gets rebuilt, or gets removed. Document the Starlark contract for multi-step automation. | +| Trigger composition model | ⬚ | How do triggers, schedules, and workflows compose? Event chains, conditional branching, error handling. Design doc before code. | +| Extension lifecycle | ⬚ | Define permanent vs PoC extensions. Package graduation criteria. Dependency policy. What ships with core vs what's installed separately. | ## v0.3.0 β€” Notes Surface diff --git a/VERSION b/VERSION index 0ea3a94..7179039 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.0 +0.2.3 diff --git a/packages/dashboard/manifest.json b/packages/dashboard/manifest.json index 04073f0..26c9a5c 100644 --- a/packages/dashboard/manifest.json +++ b/packages/dashboard/manifest.json @@ -5,6 +5,7 @@ "version": "0.31.1", "tier": "browser", "author": "Switchboard Core", + "icon": "πŸ“Š", "description": "Project dashboard exercising all SDK primitives", "route": "/s/dashboard", "permissions": [], diff --git a/packages/editor/manifest.json b/packages/editor/manifest.json index c1efe6e..fb4a51f 100644 --- a/packages/editor/manifest.json +++ b/packages/editor/manifest.json @@ -5,6 +5,7 @@ "version": "0.31.0", "tier": "browser", "author": "Switchboard Core", + "icon": "✏️", "description": "Code editor with workspace management, file tree, and AI assist", "route": "/s/editor", "layout": "editor", diff --git a/packages/git-board/manifest.json b/packages/git-board/manifest.json index a3cc0a5..8f05ddc 100644 --- a/packages/git-board/manifest.json +++ b/packages/git-board/manifest.json @@ -7,6 +7,7 @@ "auth": "authenticated", "layout": "single", "version": "0.2.0", + "icon": "πŸ”€", "description": "Gitea issue and PR board powered by the gitea-client library. Uses extension connections for authentication.", "author": "switchboard", diff --git a/packages/schedules/css/main.css b/packages/schedules/css/main.css new file mode 100644 index 0000000..c212c43 --- /dev/null +++ b/packages/schedules/css/main.css @@ -0,0 +1,277 @@ +/* ── Schedules Surface ───────────────────── */ + +.sched-app { + display: flex; + flex-direction: column; + height: 100%; + background: var(--bg); + color: var(--text); +} + +.sched-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border); +} + +.sched-header h1 { + font-size: 1rem; + font-weight: 600; + margin: 0; +} + +.sched-body { + flex: 1; + overflow: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.sched-count { + font-size: 0.8rem; + color: var(--text-3); +} + +/* ── Table ────────────────────────────────── */ + +.sched-list__loading, +.sched-list__empty { + padding: 40px 16px; + text-align: center; + color: var(--text-3); + font-size: 0.85rem; +} + +.sched-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} + +.sched-table th { + text-align: left; + padding: 8px 12px; + font-weight: 600; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-3); + border-bottom: 1px solid var(--border); +} + +.sched-table td { + padding: 10px 12px; + border-bottom: 1px solid var(--border); + vertical-align: top; +} + +.sched-row:hover { + background: var(--bg-hover); +} + +.sched-row__name { min-width: 160px; } + +.sched-link { + background: none; + border: none; + color: var(--accent); + cursor: pointer; + font-weight: 500; + font-size: 0.85rem; + padding: 0; + text-align: left; +} +.sched-link:hover { text-decoration: underline; } + +.sched-row__desc { + font-size: 0.75rem; + color: var(--text-3); + margin-top: 2px; +} + +.sched-row__cron { white-space: nowrap; } + +.sched-cron-badge { + font-family: monospace; + font-size: 0.8rem; + background: var(--bg-raised); + padding: 2px 6px; + border-radius: 4px; + color: var(--text-2); +} + +.sched-cron-human { + font-size: 0.75rem; + color: var(--text-3); + margin-top: 2px; +} + +.sched-row__next { + font-size: 0.8rem; + color: var(--text-2); + white-space: nowrap; +} + +.sched-row__actions { + display: flex; + gap: 4px; + white-space: nowrap; +} + +/* ── Toggle button ────────────────────────── */ + +.sched-toggle-btn { + font-size: 0.75rem; + font-weight: 600; + padding: 2px 10px; + border-radius: 10px; + border: 1px solid var(--border); + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.sched-toggle-btn--on { + background: var(--success); + color: #fff; + border-color: var(--success); +} + +.sched-toggle-btn--off { + background: var(--bg-raised); + color: var(--text-3); +} + +.sched-toggle-btn:hover { opacity: 0.85; } + +/* ── Action buttons ───────────────────────── */ + +.sched-btn { + background: none; + border: 1px solid var(--border); + color: var(--text-2); + border-radius: 4px; + cursor: pointer; + transition: background 0.15s; +} +.sched-btn:hover { background: var(--bg-hover); } + +.sched-btn--sm { + padding: 3px 8px; + font-size: 0.8rem; +} + +.sched-btn--danger:hover { + background: var(--danger-dim); + color: var(--danger); + border-color: var(--danger); +} + +/* ── Form ─────────────────────────────────── */ + +.sched-form { display: flex; flex-direction: column; gap: 12px; } +.sched-form__row { display: flex; align-items: center; gap: 12px; } +.sched-form__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; } + +.sched-cron-input { + font-family: monospace; +} + +.sched-cron-preview { + font-size: 0.8rem; + color: var(--accent); + margin-top: 4px; + font-style: italic; +} + +.sched-script { + font-family: monospace; + font-size: 0.85rem; + resize: vertical; +} + +.sched-toggle { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.85rem; + cursor: pointer; +} + +/* ── Logs panel ───────────────────────────── */ + +.sched-logs { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} + +.sched-logs__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-bottom: 1px solid var(--border); + font-size: 0.85rem; + font-weight: 600; +} + +.sched-logs__loading, +.sched-logs__empty { + padding: 20px 12px; + text-align: center; + color: var(--text-3); + font-size: 0.8rem; +} + +.sched-log-entry { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 12px; + border-bottom: 1px solid var(--border); + font-size: 0.8rem; +} +.sched-log-entry:last-child { border-bottom: none; } + +.sched-log-entry__status { + font-weight: 700; + width: 18px; + text-align: center; +} +.sched-log-entry__status--ok { color: var(--success); } +.sched-log-entry__status--fail { color: var(--danger); } + +.sched-log-entry__time { + color: var(--text-2); + white-space: nowrap; +} + +.sched-log-entry__duration { + color: var(--text-3); + font-family: monospace; + font-size: 0.75rem; +} + +.sched-log-entry__error { + color: var(--danger); + font-size: 0.75rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 300px; +} + +/* ── Responsive ───────────────────────────── */ + +@media (max-width: 768px) { + .sched-table th:nth-child(3), + .sched-table td:nth-child(3) { + display: none; + } + .sched-body { padding: 8px; } +} diff --git a/packages/schedules/js/main.js b/packages/schedules/js/main.js new file mode 100644 index 0000000..9d033f1 --- /dev/null +++ b/packages/schedules/js/main.js @@ -0,0 +1,418 @@ +/** + * 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 = '

SDK boot failed: ' + e.message + '

'; + 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'}> +
+ ${error && html`<${Banner} variant="danger" text=${error} />`} + <${FormField} label="Name" required> + setName(e.target.value)} + placeholder="e.g. Daily cleanup" autofocus /> + + <${FormField} label="Description"> + setDesc(e.target.value)} + placeholder="What does this job do?" /> + + <${FormField} label="Cron Expression" required> + setCron(e.target.value)} + placeholder="e.g. 0 9 * * 1-5" class="sched-cron-input" /> + ${preview && preview !== cron && html` +
${preview}
+ `} + + <${FormField} label="Script"> +