feat: session status at a glance, file & image drop, live Ops Alerts (0.4.0)
build-deb / Lint, test, package (push) Successful in 1m51s
build-deb / Deploy to CT 126 (push) Successful in 3s
build-deb / Notify on failure (push) Skipped

- devdesk-state-hook (Claude UserPromptSubmit/PostToolUse/Notification/Stop/
  SessionStart/SessionEnd) records whether each session is working, waiting on
  you or idle; --list carries it. The sidebar shows it, the header shows the
  current session's state, and a pulsing "needs you" chip jumps to sessions
  waiting for input. Session refresh no longer depends on the sidebar being open.
- `devdesk attach --upload SESSION FILE BYTES` receives a file as base64
  terminal input (raw mode, exact length, size-checked, sanitised name) into
  /var/lib/devdesk/uploads/<session>/. The UI uploads dropped or pasted files and
  pastes the path into Claude, which attaches images itself.
- `devdesk alerts` / `--alerts`: firing and pending Grafana alert rules with
  severity, instances, summary and a link. The panel's "Ask Claude" types an
  investigation prompt into the current session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 19:26:06 -04:00
co-authored by Claude Opus 5.5
parent 531b640ae9
commit 03e4a49e78
14 changed files with 625 additions and 44 deletions
+64
View File
@@ -0,0 +1,64 @@
// Ops Alerts panel: firing/pending Grafana alert rules (`devdesk attach --alerts`),
// each with an "Ask Claude" button that types an investigation prompt into the
// current session (without pressing Enter).
import { el } from './sessions.js';
const SEVERITY = {
critical: 'lt-chip-critical',
warning: 'lt-chip-warn',
};
function age(iso) {
const t = Date.parse(iso || '');
if (Number.isNaN(t)) return '';
const s = Math.max(0, (Date.now() - t) / 1000);
if (s < 3600) return `${Math.max(1, Math.floor(s / 60))}m`;
if (s < 86400) return `${Math.floor(s / 3600)}h`;
return `${Math.floor(s / 86400)}d`;
}
export function investigatePrompt(alert) {
const where = alert.instances?.length ? ` on ${alert.instances.join('; ')}` : '';
return `Investigate the ${alert.state} Grafana alert "${alert.name}"`
+ ` (${alert.severity || 'no severity'}, ${alert.folder} / ${alert.group}${where}, since ${alert.since}).`
+ ` Summary: ${alert.summary || 'n/a'}. Use the grafana MCP server to look at the rule and the related`
+ ' metrics, find the cause, and tell me what you would change before changing anything.';
}
export function renderAlerts(root, data) {
const list = root.querySelector('.dd-alert-list');
const note = root.querySelector('.dd-alerts-note');
const count = root.querySelector('.dd-alert-count');
const alerts = data?.alerts ?? [];
count.textContent = alerts.length ? String(alerts.length) : '';
count.className = `lt-badge lt-badge-sm dd-alert-count${alerts.some((a) => a.severity === 'critical') ? ' lt-badge-p1' : alerts.length ? ' lt-badge-p2' : ''}`;
if (!data) {
root.dataset.state = 'empty';
list.replaceChildren(el('li', { class: 'dd-empty' }, 'Loading…'));
note.textContent = '';
return;
}
if (!data.configured) {
root.dataset.state = 'empty';
list.replaceChildren(el('li', { class: 'dd-empty' }, 'Grafana is not configured.'));
note.textContent = 'Set DEVDESK_GRAFANA_URL and a token file in /etc/devdesk/env.local.';
return;
}
root.dataset.state = 'live';
note.textContent = data.error ? `Grafana unreachable: ${data.error}` : '';
if (!alerts.length) {
list.replaceChildren(el('li', { class: 'dd-all-clear' },
el('span', { class: 'lt-dot lt-dot-up' }), data.error ? 'No data' : 'All clear: nothing firing'));
return;
}
list.replaceChildren(...alerts.map((a) => el('li', { class: `dd-alert is-${a.state}` },
el('div', { class: 'dd-alert-head' },
el('span', { class: `lt-chip ${SEVERITY[a.severity] || 'lt-chip-info'}` }, a.state === 'pending' ? 'pending' : (a.severity || 'alert')),
el('a', { class: 'dd-alert-name', href: a.url, target: '_blank', rel: 'noopener', title: `${a.folder} / ${a.group}` }, a.name),
el('span', { class: 'dd-muted dd-alert-age' }, age(a.since))),
a.instances?.length ? el('div', { class: 'dd-muted dd-alert-instances' }, a.instances.join(' · ')) : null,
a.summary ? el('div', { class: 'dd-alert-summary' }, a.summary) : null,
el('button', { type: 'button', class: 'lt-btn lt-btn-sm dd-ask', 'data-ask-claude': investigatePrompt(a) }, 'Ask Claude'))));
}