Compare commits
2 commits
8fb4d3d634
...
ae7446a04b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae7446a04b | ||
|
|
f21be4f4d4 |
|
|
@ -1 +0,0 @@
|
|||
npm
|
||||
|
|
@ -23,7 +23,7 @@ services:
|
|||
local:
|
||||
- stability-agent
|
||||
external:
|
||||
- piha:agent-system-redis
|
||||
- piha:redis
|
||||
ports:
|
||||
- name: http
|
||||
container_port: 18180
|
||||
|
|
@ -32,3 +32,12 @@ services:
|
|||
config_path: /opt/homelab/config/control-plane
|
||||
data_path: /opt/homelab/data/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: []
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ nodes:
|
|||
roles:
|
||||
- edge
|
||||
- ingress
|
||||
- control-plane
|
||||
|
||||
chelsty-infra:
|
||||
site: chelsty
|
||||
|
|
|
|||
|
|
@ -277,8 +277,9 @@
|
|||
<option value="maintenance">MAINTENANCE</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<div class="header-actions" style="display:flex; gap:8px; align-items:center">
|
||||
<button onclick="refreshData()">Refresh</button>
|
||||
<button id="copy-ai-btn" onclick="copyForAI()">Copy for AI</button>
|
||||
</div>
|
||||
</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
|
||||
refreshData();
|
||||
// Poll for updates
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -141,6 +142,28 @@ def mutate_action(action_id, target_status):
|
|||
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):
|
||||
body = (json.dumps(payload) + "\n").encode("utf-8")
|
||||
handler.send_response(status)
|
||||
|
|
@ -188,6 +211,10 @@ class Handler(BaseHTTPRequestHandler):
|
|||
send_json(200, current_actions(), self)
|
||||
return
|
||||
|
||||
if self.path == "/snapshot":
|
||||
send_json(200, get_snapshot(), self)
|
||||
return
|
||||
|
||||
if self.path in ("/", "/index.html"):
|
||||
body = (STATIC_DIR / "index.html").read_bytes()
|
||||
self.send_response(200)
|
||||
|
|
|
|||
Loading…
Reference in a new issue