feat: add Copy for AI snapshot button to webui
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f21be4f4d4
commit
ae7446a04b
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue