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:
@@ -927,3 +927,132 @@ fake_convo() {
|
||||
run devdesk attach --presence alpha off
|
||||
[ ! -e "$TMP/presence/${sid#\$}" ]
|
||||
}
|
||||
|
||||
# A fake Proxmox cluster for `devdesk guests`: DEVDESK_PVE_SSH answers the
|
||||
# inventory (pvesh) from $FAKE_PVE/inventory and each node's scan from node-<name>.
|
||||
fake_pve() {
|
||||
export FAKE_PVE="$TMP/pve" DEVDESK_PVE_SSH="$TMP/fake-ssh" DEVDESK_PVE_NODES="down-node large1"
|
||||
mkdir -p "$FAKE_PVE"
|
||||
cat >"$DEVDESK_PVE_SSH" <<'EOF'
|
||||
#!/bin/bash
|
||||
while [ "${1:-}" = -o ]; do shift 2; done
|
||||
case $1:$2 in
|
||||
down-node:*) exit 255 ;;
|
||||
*:*pvesh*) cat "$FAKE_PVE/inventory" ;;
|
||||
*) cat "$FAKE_PVE/node-$1" 2>/dev/null || exit 255 ;;
|
||||
esac
|
||||
EOF
|
||||
chmod +x "$DEVDESK_PVE_SSH"
|
||||
pve_state running
|
||||
printf 'large1\t10.10.10.2 10.10.90.10 fe80::1\n139\t10.10.10.27 \n102\t127.0.0.1 10.10.10.23 \n' >"$FAKE_PVE/node-large1"
|
||||
}
|
||||
|
||||
# Draupnir (CT 110) running on monitor-02, or stopped (and so absent from the node scan).
|
||||
pve_state() {
|
||||
printf '%s\n' \
|
||||
'[{"vmid":110,"name":"draupnir","node":"monitor-02","status":"'"$1"'","type":"lxc","hastate":"'"${1/running/started}"'"},' \
|
||||
' {"vmid":139,"name":"nginxproxymanager","node":"large1","status":"running","type":"lxc","hastate":"started"},' \
|
||||
' {"vmid":102,"name":"homeAssistant","node":"large1","status":"running","type":"qemu"}]' \
|
||||
'[{"id":"cluster","type":"cluster"},{"name":"large1","ip":"10.10.90.10","online":1,"type":"node"},' \
|
||||
' {"name":"monitor-02","ip":"10.10.10.9","online":1,"type":"node"}]' >"$FAKE_PVE/inventory"
|
||||
if [ "$1" = running ]; then
|
||||
printf 'monitor-02\t10.10.10.9\n110\t10.10.10.24\n' >"$FAKE_PVE/node-monitor-02"
|
||||
else
|
||||
printf 'monitor-02\t10.10.10.9\n' >"$FAKE_PVE/node-monitor-02"
|
||||
fi
|
||||
}
|
||||
|
||||
# Grafana's rules API, served from a file.
|
||||
fake_grafana() {
|
||||
export DEVDESK_GRAFANA_URL="file://$TMP/grafana" DEVDESK_GRAFANA_PUBLIC_URL=https://grafana.example
|
||||
mkdir -p "$TMP/grafana/api/prometheus/grafana/api/v1"
|
||||
jq -n '{data: {groups: [{name: "Bots", file: "Matrix", rules: [
|
||||
{uid: "u-draupnir", name: "Draupnir Down", state: "firing", query: "up{job=\"draupnir-node\"}", labels: {severity: "critical"},
|
||||
annotations: {summary: "Draupnir is unreachable", description: "Cannot scrape 10.10.10.24:9100"},
|
||||
alerts: [{state: "Alerting", activeAt: "2026-10-11T07:10:00Z", value: "0", labels: {instance: "10.10.10.24:9100", job: "draupnir-node", alertname: "Draupnir Down"}}]},
|
||||
{uid: "u-ceph", name: "Ceph Unhealthy", state: "firing", query: "ceph_health_status", labels: {severity: "warning"},
|
||||
annotations: {summary: "status={{ $value }}"},
|
||||
alerts: [{state: "Alerting", value: "1e+00", labels: {instance: "http://10.10.10.2:9283/metrics", job: "ceph"}}]},
|
||||
{uid: "u-count", name: "Exporter Down", state: "firing", query: "count(up == 0)", labels: {},
|
||||
annotations: {summary: "{{ $value }} down"}, alerts: [{state: "Alerting", value: "2", labels: {alertname: "Exporter Down"}}]},
|
||||
{uid: "u-lan", name: "Printer Offline", state: "pending", query: "up", labels: {},
|
||||
annotations: {}, alerts: [{state: "Pending", value: "0", labels: {instance: "10.99.0.1:9100"}}]},
|
||||
{uid: "u-ok", name: "Fine", state: "inactive", alerts: []}]}]}}' >"$TMP/grafana/api/prometheus/grafana/api/v1/rules"
|
||||
}
|
||||
|
||||
@test "guests: where each CT/VM runs, from any node that answers; stopped ones keep their last IP (#39)" {
|
||||
fake_pve
|
||||
run devdesk guests
|
||||
[ "$status" -eq 0 ]
|
||||
[[ $output == *"110"*"draupnir"*"monitor-02"*"running"*"started"*"10.10.10.24"* ]]
|
||||
[[ $output == *"102"*"vm"*"homeAssistant"*"large1"*"10.10.10.23"* ]]
|
||||
[[ $output != *"127.0.0.1"* ]]
|
||||
run devdesk guests --json 10.10.10.27
|
||||
[ "$(jq -r '[.guests[].id] | join(",")' <<<"$output")" = 139 ]
|
||||
[ "$(jq -r '.nodes[] | select(.name == "large1") | .ips | join(",")' <<<"$output")" = 10.10.10.2,10.10.90.10 ]
|
||||
# Draupnir stops: its IP is remembered, marked as last seen.
|
||||
pve_state stopped
|
||||
run devdesk guests drau
|
||||
[[ $output == *"stopped"*"10.10.10.24 (last seen)"* ]]
|
||||
# No node answers: the cache, with a warning.
|
||||
run env DEVDESK_PVE_NODES=down-node devdesk guests draupnir
|
||||
[ "$status" -eq 0 ]
|
||||
[[ $output == *"no Proxmox node answered"*"10.10.10.24"* ]]
|
||||
run env DEVDESK_PVE_NODES= devdesk guests
|
||||
[ "$status" -ne 0 ]
|
||||
[[ $output == *"DEVDESK_PVE_NODES"* ]]
|
||||
}
|
||||
|
||||
@test "alerts name the guest or node each one is about (#39)" {
|
||||
fake_pve
|
||||
fake_grafana
|
||||
devdesk guests >/dev/null
|
||||
pve_state stopped
|
||||
devdesk guests >/dev/null
|
||||
run devdesk alerts
|
||||
[ "$status" -eq 0 ]
|
||||
t() { jq -c --arg u "$1" '.alerts[] | select(.uid == $u) | .targets | map({kind, id, name, node, by})' <<<"$output"; }
|
||||
[ "$(t u-draupnir)" = '[{"kind":"ct","id":110,"name":"draupnir","node":"monitor-02","by":"ip"}]' ]
|
||||
[ "$(t u-ceph)" = '[{"kind":"node","id":null,"name":"large1","node":"large1","by":"ip"}]' ]
|
||||
[ "$(t u-count)" = '[]' ]
|
||||
[ "$(t u-lan)" = '[{"kind":"unknown","id":null,"name":null,"node":null,"by":null}]' ]
|
||||
[ "$(jq -r '.alerts[] | select(.uid == "u-ceph") | .summary' <<<"$output")" = "status=1" ]
|
||||
[ "$(jq -r '[.alerts[].uid] | join(",")' <<<"$output")" = u-draupnir,u-ceph,u-count,u-lan ]
|
||||
# Never seen running: matched by the name in its job label instead.
|
||||
rm "$DEVDESK_STATE_DIR/guests.json"
|
||||
devdesk guests >/dev/null
|
||||
run devdesk alerts
|
||||
[ "$(t u-draupnir)" = '[{"kind":"ct","id":110,"name":"draupnir","node":"monitor-02","by":"name"}]' ]
|
||||
}
|
||||
|
||||
@test "investigate starts Claude in its own session with the alert and where it runs (#39)" {
|
||||
fake_pve
|
||||
fake_grafana
|
||||
devdesk guests >/dev/null
|
||||
start_server
|
||||
run devdesk attach --investigate alert-draupnir-down u-draupnir
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(jq -c . <<<"$output")" = '{"ok":true,"session":"alert-draupnir-down","started":true}' ]
|
||||
claude_args alert-draupnir-down >/dev/null
|
||||
log="$FAKE_LOG/alert-draupnir-down"
|
||||
grep -q '^-n alert-draupnir-down Investigate this Grafana alert' "$log"
|
||||
grep -q 'Alert: Draupnir Down (critical, firing since' "$log"
|
||||
grep -q 'Query: `up{job="draupnir-node"}`' "$log"
|
||||
grep -q '10.10.10.24 is CT 110 "draupnir" on node monitor-02, running, HA state started; commands inside it: `ssh monitor-02 pct exec 110 -- <command>`' "$log"
|
||||
grep -q '`devdesk guests 110` shows where it is now' "$log"
|
||||
# The prompt is sent once: not again when Claude is resumed.
|
||||
[ ! -e "$DEVDESK_STATE_DIR/prompts/alert-draupnir-down.txt" ]
|
||||
run devdesk attach --investigate alert-draupnir-down u-draupnir
|
||||
[ "$(jq -r .started <<<"$output")" = false ]
|
||||
# Aggregates get a hint instead; alerts that resolved meanwhile are refused.
|
||||
run devdesk attach --investigate exporters u-count
|
||||
claude_args exporters >/dev/null
|
||||
grep -q 'The rule aggregates' "$FAKE_LOG/exporters"
|
||||
run grep -c 'Where it runs' "$FAKE_LOG/exporters"
|
||||
[ "$output" = 0 ]
|
||||
run devdesk attach --investigate gone u-ok
|
||||
[ "$status" -ne 0 ]
|
||||
[[ $output == *"no longer firing"* ]]
|
||||
run devdesk attach --investigate 'bad name' u-ok
|
||||
[[ $output == *"invalid session name"* ]]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user