Compare commits

...

2 commits

Author SHA1 Message Date
oskar ae7446a04b feat: add Copy for AI snapshot button to webui
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 12:05:37 +02:00
oskar f21be4f4d4 ops: align vps desired state with control-plane architecture, remove legacy agent-system references
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 11:40:55 +02:00
5 changed files with 107 additions and 3 deletions

View file

@ -1 +0,0 @@
npm

View file

@ -23,7 +23,7 @@ services:
local: local:
- stability-agent - stability-agent
external: external:
- piha:agent-system-redis - piha:redis
ports: ports:
- name: http - name: http
container_port: 18180 container_port: 18180
@ -32,3 +32,12 @@ services:
config_path: /opt/homelab/config/control-plane config_path: /opt/homelab/config/control-plane
data_path: /opt/homelab/data/control-plane data_path: /opt/homelab/data/control-plane
logs_path: /opt/homelab/logs/control-plane logs_path: /opt/homelab/logs/control-plane
node_exporter:
role: metrics-exporter
deployment_model: docker-compose
exposure: local-only
offline_required: true
depends_on:
local: []
external: []

View file

@ -27,6 +27,7 @@ nodes:
roles: roles:
- edge - edge
- ingress - ingress
- control-plane
chelsty-infra: chelsty-infra:
site: chelsty site: chelsty

View file

@ -277,8 +277,9 @@
<option value="maintenance">MAINTENANCE</option> <option value="maintenance">MAINTENANCE</option>
</select> </select>
</div> </div>
<div class="header-actions"> <div class="header-actions" style="display:flex; gap:8px; align-items:center">
<button onclick="refreshData()">Refresh</button> <button onclick="refreshData()">Refresh</button>
<button id="copy-ai-btn" onclick="copyForAI()">Copy for AI</button>
</div> </div>
</header> </header>
@ -691,6 +692,73 @@
} }
} }
async function copyForAI() {
const btn = document.getElementById('copy-ai-btn');
const original = btn.textContent;
btn.textContent = 'Copying...';
btn.disabled = true;
try {
const snap = await fetchData('/snapshot');
if (!snap) throw new Error('snapshot fetch failed');
const now = new Date(snap.timestamp);
const dateStr = now.toISOString().slice(0, 16).replace('T', ' ');
const lines = [];
lines.push(`=== HOMELAB SNAPSHOT ${dateStr} ===`);
if (snap.nodes && snap.nodes.length > 0) {
lines.push('NODES: ' + snap.nodes.map(n =>
`${(n.hostname || n.id || '?').toUpperCase()} ${(n.health || 'unknown').toUpperCase()}`
).join(', '));
} else {
lines.push('NODES: none');
}
if (snap.non_nominal_services && snap.non_nominal_services.length > 0) {
lines.push('ERRORS: ' + snap.non_nominal_services.map(s =>
`${s.name} (${s.node}) - ${s.health}`
).join(', '));
} else {
lines.push(`ERRORS: none (${snap.nominal_service_count} nominal)`);
}
const activeIncidents = (snap.incidents || []).filter(i => !['resolved', 'closed'].includes(i.status));
if (activeIncidents.length > 0) {
lines.push('INCIDENTS: ' + activeIncidents.map(i =>
`[${i.severity}] ${i.message} (${i.node})`
).join('; '));
} else {
lines.push('INCIDENTS: none');
}
if (snap.events && snap.events.length > 0) {
lines.push(`EVENTS (last ${snap.events.length}):`);
snap.events.forEach(ev => {
const ts = ev.timestamp
? new Date(ev.timestamp * 1000).toISOString().slice(11, 19)
: '?';
const svc = ev.service ? '/' + ev.service : '';
lines.push(` ${ts} [${ev.severity || ev.level || '?'}] ${ev.type} - ${ev.message || ''} (${ev.node || ''}${svc})`);
});
} else {
lines.push('EVENTS (last 10): none');
}
const s = snap.summary || {};
lines.push(`SUMMARY: status=${s.status || '?'} nodes=${s.node_count ?? '?'} services=${s.service_count ?? '?'} incidents=${s.incident_count ?? '?'}`);
await navigator.clipboard.writeText(lines.join('\n'));
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = original; btn.disabled = false; }, 2000);
} catch (e) {
console.error('copyForAI error:', e);
btn.textContent = 'Error';
setTimeout(() => { btn.textContent = original; btn.disabled = false; }, 2000);
}
}
// Initial load // Initial load
refreshData(); refreshData();
// Poll for updates // Poll for updates

View file

@ -1,6 +1,7 @@
import json import json
import os import os
import time import time
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
@ -141,6 +142,28 @@ def mutate_action(action_id, target_status):
return False, str(e) return False, str(e)
def get_snapshot():
nodes = current_nodes()
services = current_services()
incidents = current_incidents()
events = current_events()
summary = current_summary()
non_nominal = [s for s in services if s.get("health") != "nominal"]
nominal_count = len(services) - len(non_nominal)
return {
"timestamp": datetime.now(timezone.utc).isoformat(),
"summary": summary,
"nodes": nodes,
"non_nominal_services": non_nominal,
"nominal_service_count": nominal_count,
"total_service_count": len(services),
"incidents": incidents,
"events": events[:10],
}
def send_json(status, payload, handler): def send_json(status, payload, handler):
body = (json.dumps(payload) + "\n").encode("utf-8") body = (json.dumps(payload) + "\n").encode("utf-8")
handler.send_response(status) handler.send_response(status)
@ -188,6 +211,10 @@ class Handler(BaseHTTPRequestHandler):
send_json(200, current_actions(), self) send_json(200, current_actions(), self)
return return
if self.path == "/snapshot":
send_json(200, get_snapshot(), self)
return
if self.path in ("/", "/index.html"): if self.path in ("/", "/index.html"):
body = (STATIC_DIR / "index.html").read_bytes() body = (STATIC_DIR / "index.html").read_bytes()
self.send_response(200) self.send_response(200)