feat: session status at a glance, file & image drop, live Ops Alerts (0.4.0)
- 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:
@@ -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'))));
|
||||
}
|
||||
Reference in New Issue
Block a user