Dashboard
-+
-+
- Dashboard
-+
-+
-
-
-@@ -269,6 +287,10 @@
- System Overview
-
-
-+
-+
- Pending Actions
-+
-+
-
-
-
-+
-+ Active Incidents
-
-@@ -276,6 +298,20 @@
-
-+
-+
-
-
-+
-+
-+
-+ Pending Approval
-+ -+
-+
-+ Active / History
-+ -+
-
-@@ -291,11 +327,24 @@
-
-
-
-+
-+
-+
-+
-
-
-+
-+ Runtime Topology
-+
-+
-
-
-
-+
-+
-+
-+
-+
-
-
-
-@@ -335,6 +384,34 @@
- }
- }
-
-+ async function postData(endpoint, data) {
-+ try {
-+ const res = await fetch(endpoint, {
-+ method: 'POST',
-+ headers: {'Content-Type': 'application/json'},
-+ body: JSON.stringify(data)
-+ });
-+ return await res.json();
-+ } catch (e) {
-+ console.error('Post error:', endpoint, e);
-+ return null;
-+ }
-+ }
-+
-+ async function mutateAction(id, status) {
-+ const res = await postData('/action/mutate', {id, status});
-+ if (res && res.status === 'ok') {
-+ refreshData();
-+ } else {
-+ alert('Mutation failed');
-+ }
-+ }
-+
-+ function setOperatorMode(mode) {
-+ console.log('Operator mode set to:', mode);
-+ // In real system, this would call backend
-+ }
-+
- function formatTime(ts) {
- if (!ts) return 'N/A';
- return new Date(ts * 1000).toLocaleString();
-@@ -368,6 +445,53 @@
- }
- }
-
-+ if (currentView === 'dashboard' || currentView === 'actions') {
-+ const actions = await fetchData('/actions');
-+ if (actions) {
-+ if (currentView === 'dashboard') {
-+ const dashActions = document.getElementById('dashboard-actions-summary');
-+ const pendingCount = actions.pending.length;
-+ dashActions.innerHTML = `
-+
Pending
${pendingCount}
-+ Running
${actions.running.length}
-+ `;
-+ }
-+ if (currentView === 'actions') {
-+ const pendingEl = document.getElementById('actions-pending');
-+ const historyEl = document.getElementById('actions-history');
-+
-+ pendingEl.innerHTML = actions.pending.map(a => `
-+
-+
-+ `).join('') || 'No pending actions.';
-+
-+ const history = [...actions.approved, ...actions.running, ...actions.completed, ...actions.failed];
-+ historyEl.innerHTML = history.sort((a,b) => b.timestamp - a.timestamp).map(a => `
-+
-+
-+ ${a.type.toUpperCase()}
-+ ${a.risk_level}
-+ ${a.description}
-+Target
${a.target.node} ${a.target.service || ''}
-+ Confidence
${Math.round(a.confidence*100)}%
-+
-+
-+
-+
-+
-+
-+ `).join('') || 'No history.';
-+ }
-+ }
-+ }
-+
- if (currentView === 'dashboard' || currentView === 'events') {
- const incidents = await fetchData('/incidents');
- if (currentView === 'dashboard') {
-@@ -474,6 +598,64 @@
- `).join('');
- }
-
-+ if (currentView === 'topology') {
-+ const nodes = await fetchData('/nodes');
-+ const services = await fetchData('/services');
-+ const topMap = document.getElementById('topology-map');
-+ if (nodes && services) {
-+ topMap.innerHTML = nodes.map(node => {
-+ const nodeServices = services.filter(s => s.node === node.hostname || s.node === node.id);
-+ return `
-+
-+ ${a.type.toUpperCase()}
-+ ${a.status}
-+
-+ ${a.description}
-+ ${formatTime(a.timestamp)} | Target: ${a.target.node}
-+ ${a.status === 'approved' ? `` : ''}
-+
-+
-+ `;
-+ }).join('');
-+ }
-+ }
-+
-+ if (currentView === 'correlation') {
-+ const incidents = await fetchData('/incidents');
-+ const actions = await fetchData('/actions');
-+ const list = document.getElementById('correlation-chains');
-+ if (incidents && actions) {
-+ const allActions = Object.values(actions).flat();
-+ list.innerHTML = incidents.map(inc => {
-+ const related = allActions.filter(a => a.correlation_chain && a.correlation_chain.includes(inc.id));
-+ return `
-+
-+
-+ ${node.hostname}
-+ ${node.health}
-+ Capabilities
-+ ${node.capabilities.join(', ')}
-+ Services
-+
-+ ${nodeServices.length > 0 ? nodeServices.map(s => `
-+
-+
-+ ${s.name}
-+ ${s.health}
-+
-+ ${s.dependencies.length > 0 ? `dep: ${s.dependencies.join(', ')}
` : ''}
-+ `).join('') : 'None
'}
-+
-+
-+ `;
-+ }).join('');
-+ }
-+ }
- if (currentView === 'settings') {
- const config = await fetchData('/config');
- const content = document.getElementById('settings-content');
-@@ -482,6 +664,8 @@
-
-+
-+ Incident: ${inc.id || 'INC-001'}
-+ Active
-+ ${inc.message}
-+Related Actions
-+ ${related.map(a => `
-+
-+ ${a.type} (${a.status})
-+ ${a.description} -+
-+ `).join('') || '-+ ${a.description} -+
No actions yet
'}
-+ ${config.auto_mode ? 'Enabled' : 'Disabled'}
- Action Thresholds
- ${JSON.stringify(config.action_thresholds, null, 2)}
-+ Telegram Integration
-+ Ready for mobile approval flows. Hook: /api/v1/telegram/webhook
-
- `;
- }
-diff --git a/webui/web.py b/webui/web.py
-index 053ac1a..4727274 100644
---- a/webui/web.py
-+++ b/webui/web.py
-@@ -8,6 +8,7 @@ from pathlib import Path
- STATE_DIR = Path("/opt/homelab/state")
- EVENTS_DIR = Path("/opt/homelab/events")
- WORLD_DIR = Path("/opt/homelab/world")
-+ACTIONS_DIR = Path("/opt/homelab/actions")
- EVENT_LOG = Path("/tmp/agent-events.log")
- STATIC_DIR = Path(__file__).parent
- REDIS_HOST = os.getenv("REDIS_HOST", "redis")
-@@ -164,6 +165,55 @@ def current_events():
- return sorted(events, key=lambda x: x.get("timestamp", 0), reverse=True)
-
-
-+def current_actions():
-+ actions = {}
-+ statuses = ["pending", "approved", "running", "completed", "failed", "rejected"]
-+ for status in statuses:
-+ actions[status] = []
-+ status_dir = ACTIONS_DIR / status
-+ if status_dir.exists():
-+ for f in status_dir.glob("*.json"):
-+ data = read_json_file(f)
-+ if data:
-+ actions[status].append(data)
-+ return actions
-+
-+
-+def mutate_action(action_id, target_status):
-+ statuses = ["pending", "approved", "running", "completed", "failed", "rejected"]
-+ if target_status not in statuses:
-+ return False, f"Invalid target status: {target_status}"
-+
-+ # Find where the action is
-+ source_path = None
-+ for status in statuses:
-+ p = ACTIONS_DIR / status / f"{action_id}.json"
-+ if p.exists():
-+ source_path = p
-+ break
-+
-+ if not source_path:
-+ return False, f"Action {action_id} not found"
-+
-+ target_dir = ACTIONS_DIR / target_status
-+ target_dir.mkdir(parents=True, exist_ok=True)
-+ target_path = target_dir / f"{action_id}.json"
-+
-+ try:
-+ data = json.loads(source_path.read_text())
-+ data["status"] = target_status
-+ data["last_mutation"] = os.path.getmtime(source_path) # or current time
-+ import time
-+ data["last_mutation"] = time.time()
-+
-+ target_path.write_text(json.dumps(data, indent=2))
-+ if source_path != target_path:
-+ source_path.unlink()
-+ return True, "Success"
-+ except Exception as e:
-+ return False, str(e)
-+
-+
- def send_json(status, payload, handler):
- body = (json.dumps(payload) + "\n").encode("utf-8")
- handler.send_response(status)
-@@ -207,6 +257,10 @@ class Handler(BaseHTTPRequestHandler):
- send_json(200, current_events(), self)
- return
-
-+ if self.path == "/actions":
-+ send_json(200, current_actions(), self)
-+ return
-+
- if self.path == "/logs":
- print("LOGS endpoint called", flush=True)
- body = ("\n".join(tail_lines(EVENT_LOG, 200)) + "\n").encode("utf-8")
-@@ -236,6 +290,7 @@ class Handler(BaseHTTPRequestHandler):
- "/auto-mode",
- "/config",
- "/events",
-+ "/action/mutate",
- ):
- self.send_error(404)
- return
-@@ -291,6 +346,19 @@ class Handler(BaseHTTPRequestHandler):
- send_json(200, {"status": "sent"}, self)
- return
-
-+ if self.path == "/action/mutate":
-+ action_id = payload.get("id")
-+ target = payload.get("status")
-+ if not action_id or not target:
-+ self.send_error(400, "id and status are required")
-+ return
-+ success, msg = mutate_action(action_id, target)
-+ if success:
-+ send_json(200, {"status": "ok"}, self)
-+ else:
-+ self.send_error(500, msg)
-+ return
-+
- if not command:
- self.send_error(400, "command is required")
- return
\ No newline at end of file