Files
devdesk/web/src/alerts.js
T
jaredandClaude Opus 5.5 c0f475bffe
build-deb / Lint, test, package (push) Successful in 2m23s
build-deb / Deploy to CT 126 (push) Successful in 30s
build-deb / Notify on failure (push) Skipped
feat: v0.9.2 Ask Claude on an alert starts an investigation session
- Ops Alerts → Ask Claude now calls `devdesk attach --investigate SESSION UID`.
  The server looks the alert up, writes the prompt and starts Claude in a new
  alert-<name> session with it as the first prompt, already sent. Prompts are
  stored in prompts/<session>.txt and consumed once by devdesk-claude-run.
  A session that is already running is just opened.
- The prompt has the rule, query, summary, details and labels, and where it
  runs: the CT/VM id, the node it is on now, status and HA state, and
  `ssh <node> pct exec <id> --`. It reminds Claude that HA guests move
  (`devdesk guests <id>`), and gives aggregate rules a hint.
- `devdesk guests`: Proxmox inventory over SSH (DEVDESK_PVE_NODES). pvesh
  gives the cluster view; each node gives lxc-info and guest-agent IPs.
  Cached in guests.json, keeping last-seen IPs for stopped guests; --alerts
  refreshes it in the background every 5 minutes.
- Alerts carry uid, query, description and targets, matched by instance
  IP/host or by name; each card shows the target.
- cmd_alerts moves to libexec/devdesk-ops.sh.

Closes #39

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:24:49 -04:00

85 lines
4.1 KiB
JavaScript

// Ops Alerts panel: firing/pending Grafana alert rules (`devdesk attach --alerts`),
// the Proxmox guest or node each is about (CT id, the node it runs on now), and
// an "Ask Claude" button that starts an investigation in a session of its own
// (devdesk#39).
import { el } from './dom.js';
import { slugify } from './protocol.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`;
}
// "CT 110 draupnir · monitor-02 · stopped", "node compute-storage-01".
function targetLabel(t) {
if (t.kind === 'unknown') return null;
const what = t.kind === 'node' ? `node ${t.name}` : `${t.kind.toUpperCase()} ${t.id} ${t.name}`;
const parts = [what, t.kind === 'node' ? null : t.node, t.status === 'running' || t.status === 'online' ? null : t.status];
return parts.filter(Boolean).join(' · ') + (t.by === 'name' ? ' (by name)' : '');
}
function targetTitle(t) {
const how = t.kind === 'ct' ? `ssh ${t.node} pct exec ${t.id} -- …`
: t.kind === 'vm' ? `ssh ${t.node} qm guest exec ${t.id} -- …` : `ssh ${t.name}`;
const note = t.by === 'name' ? '\nMatched by name, not by IP (the guest has not been seen running with this IP).'
: t.ip_seen ? '\nIts IP when it was last seen running.' : '';
return `${how}${t.ha ? `\nHA state: ${t.ha}` : ''}${note}`;
}
export const alertSession = (alert) => slugify(`alert-${alert.name}`).toLowerCase().slice(0, 48) || 'alert';
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.targets ?? []).map((t) => {
const label = targetLabel(t);
return label ? el('div', { class: `dd-alert-where${t.by === 'name' ? ' is-guess' : ''}`, title: targetTitle(t) }, label) : null;
}),
a.summary ? el('div', { class: 'dd-alert-summary' }, a.summary) : null,
el('div', { class: 'dd-ask-row' },
el('button', {
type: 'button', class: 'lt-btn lt-btn-sm dd-ask', 'data-investigate': a.uid, 'data-session': alertSession(a),
title: `Start Claude in a new session (${alertSession(a)}) on this alert, with the rule, the query and where it runs (CT id, node)`,
}, 'Ask Claude')))));
}