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>
This commit is contained in:
+27
-12
@@ -1,8 +1,10 @@
|
||||
// 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).
|
||||
// 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',
|
||||
@@ -18,14 +20,24 @@ function age(iso) {
|
||||
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.';
|
||||
// "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');
|
||||
@@ -59,11 +71,14 @@ export function renderAlerts(root, data) {
|
||||
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-ask-claude': investigatePrompt(a), title: 'Type the prompt into the current session (not sent)' }, 'Ask Claude'),
|
||||
el('button', {
|
||||
type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm dd-ask', 'data-ask-claude': investigatePrompt(a),
|
||||
'data-ask-session': `alert-${a.name}`, title: 'Open a separate ops session for this alert and type the prompt there (not sent)',
|
||||
}, 'In a new session')))));
|
||||
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')))));
|
||||
}
|
||||
|
||||
@@ -376,6 +376,15 @@ export async function sessionAction(action, ...args) {
|
||||
throw new Error(message.replace(/^devdesk:\s*/, ''));
|
||||
}
|
||||
|
||||
// Ops Alerts → Ask Claude (devdesk#39): start Claude in SESSION on alert rule UID.
|
||||
// The server looks the alert up and writes the prompt. {session, started}.
|
||||
export async function investigateAlert(session, uid) {
|
||||
const output = stripAnsi(await runCommand(['--investigate', session, uid], 40000)).trim();
|
||||
if (/"ok"\s*:\s*true/.test(output)) return parseListOutput(output);
|
||||
const message = output.split(/\r?\n/).filter(Boolean).pop() || 'no response';
|
||||
throw new Error(message.replace(/^devdesk:\s*/, ''));
|
||||
}
|
||||
|
||||
function concat(chunks) {
|
||||
const total = chunks.reduce((n, c) => n + c.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
|
||||
+27
-35
@@ -2,9 +2,9 @@
|
||||
import { THEMES, createTerminal, loadFonts } from './term.js';
|
||||
import { getTheme, initTheme, onThemeChange, toggleTheme } from './theme.js';
|
||||
import {
|
||||
TtydConnection, fetchAlerts, fetchRepo, fetchToken, sessionAction, uploadFile, AuthExpiredError, UnauthorizedError,
|
||||
TtydConnection, fetchAlerts, fetchRepo, fetchToken, investigateAlert, sessionAction, uploadFile, AuthExpiredError, UnauthorizedError,
|
||||
} from './connection.js';
|
||||
import { parseTitle, pasteSafe, sessionFromSearch, slugify } from './protocol.js';
|
||||
import { parseTitle, pasteSafe, sessionFromSearch } from './protocol.js';
|
||||
import { SessionsPanel, recentNames, rememberSession, sessionStatus } from './sessions.js';
|
||||
import { renderAlerts } from './alerts.js';
|
||||
import { renderRepo } from './repo.js';
|
||||
@@ -595,36 +595,28 @@ async function toggleNotifications() {
|
||||
}
|
||||
}
|
||||
|
||||
// "In a new session" (devdesk#18): open (or reuse) a session for this alert,
|
||||
// wait until its Claude is up (SessionStart sets it idle), then type the
|
||||
// prompt without sending it.
|
||||
let pendingAsk = null;
|
||||
function askInSession(name, text) {
|
||||
// Ops Alerts → Ask Claude (devdesk#39): the server starts Claude in a session of
|
||||
// its own with the alert, its rule and where it runs (CT id, node) as the first
|
||||
// prompt; then we open that session. A session already running is just opened.
|
||||
async function investigate(button) {
|
||||
const { investigate: uid, session } = button.dataset;
|
||||
if (!uid || !session || button.disabled) return;
|
||||
closeDrawerIfNarrow();
|
||||
pendingAsk = { session: name, text, until: Date.now() + 60000 };
|
||||
connect(name);
|
||||
toast.info(`Opening ${name}; the prompt will be typed when Claude is ready`);
|
||||
const tick = async () => {
|
||||
if (!pendingAsk || pendingAsk.session !== name) return;
|
||||
if (Date.now() > pendingAsk.until) {
|
||||
toast.warning(`${name}: Claude didn't start in time; the prompt was not typed`);
|
||||
pendingAsk = null;
|
||||
return;
|
||||
}
|
||||
button.disabled = true;
|
||||
const label = button.textContent;
|
||||
button.textContent = 'Starting…';
|
||||
try {
|
||||
const result = await investigateAlert(session, uid);
|
||||
if (result.started) toast.success(`Claude is on it in ${session}`);
|
||||
else toast.info(`${session} is already running: opening it`);
|
||||
await sessionsPanel.reload();
|
||||
const s = sessionsPanel.data?.sessions?.find((x) => x.name === name);
|
||||
const ready = s && ['idle', 'waiting', 'working'].includes(sessionStatus(s).key);
|
||||
if (ready && state.session === name && state.conn?.isOpen) {
|
||||
setTimeout(() => {
|
||||
term.paste(pasteSafe(pendingAsk.text));
|
||||
term.focus();
|
||||
pendingAsk = null;
|
||||
}, 800);
|
||||
return;
|
||||
}
|
||||
setTimeout(tick, 1500);
|
||||
};
|
||||
setTimeout(tick, 1500);
|
||||
connect(session, { existing: true });
|
||||
} catch (error) {
|
||||
toast.error(`Ask Claude: ${error.message}`);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = label;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- history & usage
|
||||
@@ -1189,11 +1181,11 @@ async function boot() {
|
||||
onData: (_data, meta) => onSessionData(meta),
|
||||
},
|
||||
});
|
||||
bindAskClaude($('sidebar'), (text, session) => {
|
||||
if (session) {
|
||||
askInSession(slugify(session).slice(0, 48) || 'alert', text);
|
||||
return;
|
||||
}
|
||||
$('widget-alerts').addEventListener('click', (event) => {
|
||||
const button = event.target.closest('[data-investigate]');
|
||||
if (button) investigate(button);
|
||||
});
|
||||
bindAskClaude($('sidebar'), (text) => {
|
||||
// Alert text is untrusted: no control characters (a newline would run it in a
|
||||
// shell window, CR would submit), and bracketed paste around it (devdesk#2).
|
||||
term.paste(pasteSafe(text));
|
||||
|
||||
+4
-5
@@ -1,12 +1,11 @@
|
||||
// "Ask Claude": any [data-ask-claude] button (Ops Alerts, Repo/CI) types its
|
||||
// prompt into a session without pressing Enter, so you can review it before
|
||||
// sending: the current session, or (data-ask-session) a separate one, e.g. an
|
||||
// ops session per alert (devdesk#18).
|
||||
// "Ask Claude": any [data-ask-claude] button (Repo/CI) types its prompt into the
|
||||
// current session without pressing Enter, so you can review it before sending.
|
||||
// (Ops Alerts start their own session instead: devdesk#39.)
|
||||
|
||||
export function bindAskClaude(root, send) {
|
||||
root.addEventListener('click', (event) => {
|
||||
const button = event.target.closest('[data-ask-claude]');
|
||||
if (!button || button.disabled) return;
|
||||
send(button.dataset.askClaude, button.dataset.askSession || null);
|
||||
send(button.dataset.askClaude);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -402,6 +402,9 @@ body.dd-sidebar-open .dd-sidebar {
|
||||
.dd-alert-name { font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dd-alert-age { margin-left: auto; white-space: nowrap; }
|
||||
.dd-alert-instances { overflow-wrap: anywhere; }
|
||||
.dd-alert-where { color: var(--accent-cyan); font-family: var(--font-mono); overflow-wrap: anywhere; }
|
||||
.dd-alert-where::before { content: "\2192 "; color: var(--text-muted); }
|
||||
.dd-alert-where.is-guess { color: var(--text-secondary); }
|
||||
.dd-alert-summary { color: var(--text-secondary); }
|
||||
.dd-alert .dd-ask { align-self: flex-start; margin-top: 2px; }
|
||||
.dd-all-clear { display: flex; align-items: center; gap: var(--space-sm); color: var(--accent-green); font-size: 0.74rem; }
|
||||
|
||||
Reference in New Issue
Block a user