Coverage for integrations / social / api_dashboard.py: 70.6%
153 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2Agent Dashboard API Blueprint
4GET /api/social/dashboard/agents — Truth-grounded unified agent view (auth required)
5GET /api/social/dashboard/health — Node health from watchdog (public)
6GET /api/social/dashboard/system — System-level dashboard (tier, resources, services)
7GET /api/social/dashboard/topology — Network topology (peer graph for UI visualization)
8"""
9import logging
10import os
11import shutil
12import subprocess
13import time
15from flask import Blueprint, jsonify, request, make_response
17logger = logging.getLogger('hevolve_social')
19dashboard_bp = Blueprint('social_dashboard', __name__)
22@dashboard_bp.route('/api/social/dashboard/agents', methods=['GET'])
23def get_agent_dashboard():
24 """Return truth-grounded dashboard of all agents, goals, and daemons.
26 Priority-ordered: what matters most RIGHT NOW appears first.
27 Status reflects reality, not cache.
29 Honors ``If-None-Match``: a fresh 304 short-circuits the 5 SQL
30 queries + 170-row serialization when the dashboard hasn't changed.
31 The React UI polls every 5s — without ETag, every poll re-runs the
32 full pipeline and queues waitress workers under throttle. See
33 DashboardService.get_dashboard_version for the hash inputs.
34 """
35 from .dashboard_service import DashboardService
36 from .models import get_db
38 db = get_db()
39 try:
40 version = DashboardService.get_dashboard_version(db)
41 etag = f'W/"dash-{version}"'
42 # Conditional GET — 304 with no body is the entire point.
43 if request.headers.get('If-None-Match') == etag:
44 resp = make_response('', 304)
45 resp.headers['ETag'] = etag
46 resp.headers['Cache-Control'] = 'private, max-age=2'
47 return resp
49 data = DashboardService.get_dashboard(db)
50 resp = make_response(jsonify({'success': True, 'data': data}), 200)
51 resp.headers['ETag'] = etag
52 resp.headers['Cache-Control'] = 'private, max-age=2'
53 return resp
54 except Exception as e:
55 logger.error(f"Dashboard error: {e}")
56 return jsonify({'success': False, 'error': str(e)}), 500
57 finally:
58 db.close()
61@dashboard_bp.route('/api/social/dashboard/health', methods=['GET'])
62def get_node_health():
63 """Public health endpoint showing watchdog + HevolveAI status."""
64 data = {'watchdog': 'not_started', 'threads': {}, 'world_model': {}}
65 try:
66 from security.node_watchdog import get_watchdog
67 wd = get_watchdog()
68 if wd:
69 data.update(wd.get_health())
70 except Exception:
71 pass
73 try:
74 from integrations.agent_engine.world_model_bridge import (
75 get_world_model_bridge)
76 bridge = get_world_model_bridge()
77 data['world_model'] = bridge.check_health()
78 except Exception:
79 data['world_model'] = {'healthy': False}
81 return jsonify({'success': True, 'data': data}), 200
84@dashboard_bp.route('/api/social/node/capabilities', methods=['GET'])
85def get_node_capabilities():
86 """Public endpoint: this node's hardware profile, contribution tier,
87 and enabled features. Part of the HART OS equilibrium system."""
88 try:
89 from security.system_requirements import get_capabilities
90 caps = get_capabilities()
91 if caps is None:
92 return jsonify({
93 'success': False,
94 'error': 'System requirements not yet checked',
95 }), 503
96 return jsonify({'success': True, 'data': caps.to_dict()}), 200
97 except Exception as e:
98 logger.error(f"Capabilities endpoint error: {e}")
99 return jsonify({'success': False, 'error': str(e)}), 500
102@dashboard_bp.route('/api/social/dashboard/system', methods=['GET'])
103def get_system_info():
104 """System-level dashboard: tier, variant, deployment mode, resources, services."""
105 from .models import get_db
107 db = get_db()
108 try:
109 data = {}
111 # ─── Tier ───
112 try:
113 from security.system_requirements import get_tier_name
114 data['tier'] = get_tier_name()
115 except Exception:
116 data['tier'] = 'unknown'
118 # ─── Variant (HART OS install variant) ───
119 try:
120 variant_path = '/etc/hart/variant'
121 if os.path.isfile(variant_path):
122 with open(variant_path, 'r') as f:
123 data['variant'] = f.read().strip() or 'standalone'
124 else:
125 data['variant'] = 'standalone'
126 except Exception:
127 data['variant'] = 'standalone'
129 # ─── Deployment mode ───
130 try:
131 if os.environ.get('HART_CENTRAL_NODE'):
132 data['deployment_mode'] = 'central'
133 elif os.environ.get('HART_REGIONAL_NODE'):
134 data['deployment_mode'] = 'regional'
135 elif os.environ.get('HART_HEADLESS'):
136 data['deployment_mode'] = 'headless'
137 elif os.environ.get('HART_BUNDLED'):
138 data['deployment_mode'] = 'bundled'
139 else:
140 data['deployment_mode'] = 'standalone'
141 except Exception:
142 data['deployment_mode'] = 'standalone'
144 # ─── CPU usage ───
145 try:
146 # Read /proc/stat for CPU usage (Linux)
147 with open('/proc/stat', 'r') as f:
148 line = f.readline()
149 fields = line.strip().split()[1:]
150 idle = int(fields[3])
151 total = sum(int(x) for x in fields[:7])
152 # Approximate: single sample gives cumulative, not instant %
153 # For a quick snapshot, report non-idle ratio
154 if total > 0:
155 data['cpu_percent'] = round((1.0 - idle / total) * 100, 1)
156 else:
157 data['cpu_percent'] = 0.0
158 except Exception:
159 try:
160 # Fallback: os.cpu_count() only gives core count, not usage
161 data['cpu_percent'] = None
162 except Exception:
163 data['cpu_percent'] = None
165 # ─── RAM ───
166 try:
167 with open('/proc/meminfo', 'r') as f:
168 meminfo = {}
169 for line in f:
170 parts = line.split()
171 if len(parts) >= 2:
172 key = parts[0].rstrip(':')
173 meminfo[key] = int(parts[1]) # in kB
174 total_kb = meminfo.get('MemTotal', 0)
175 available_kb = meminfo.get('MemAvailable', meminfo.get('MemFree', 0))
176 data['ram_total_gb'] = round(total_kb / 1048576, 2)
177 data['ram_used_gb'] = round((total_kb - available_kb) / 1048576, 2)
178 except Exception:
179 try:
180 # Windows / non-Linux fallback via shutil (no RAM info)
181 data['ram_total_gb'] = None
182 data['ram_used_gb'] = None
183 except Exception:
184 data['ram_total_gb'] = None
185 data['ram_used_gb'] = None
187 # ─── Disk ───
188 try:
189 usage = shutil.disk_usage('/')
190 data['disk_total_gb'] = round(usage.total / (1024 ** 3), 2)
191 data['disk_used_gb'] = round(usage.used / (1024 ** 3), 2)
192 except Exception:
193 try:
194 usage = shutil.disk_usage('.')
195 data['disk_total_gb'] = round(usage.total / (1024 ** 3), 2)
196 data['disk_used_gb'] = round(usage.used / (1024 ** 3), 2)
197 except Exception:
198 data['disk_total_gb'] = None
199 data['disk_used_gb'] = None
201 # ─── Services (systemctl status) ───
202 services = {}
203 service_names = [
204 'hart-backend', 'hart-discovery', 'hart-agent-daemon',
205 'hart-vision', 'hart-llm', 'hart-first-boot',
206 ]
207 for svc in service_names:
208 try:
209 result = subprocess.run(
210 ['systemctl', 'is-active', f'{svc}.service'],
211 capture_output=True, text=True, timeout=5
212 )
213 services[svc] = result.stdout.strip() or 'unknown'
214 except Exception:
215 services[svc] = 'unavailable'
216 data['services'] = services
218 # ─── Uptime ───
219 try:
220 with open('/proc/uptime', 'r') as f:
221 data['uptime_seconds'] = round(float(f.read().split()[0]), 1)
222 except Exception:
223 try:
224 # Fallback: time since epoch minus a known boot marker
225 boot_marker = '/var/lib/hart/.first-boot-done'
226 if os.path.isfile(boot_marker):
227 data['uptime_seconds'] = round(
228 time.time() - os.path.getmtime(boot_marker), 1)
229 else:
230 data['uptime_seconds'] = None
231 except Exception:
232 data['uptime_seconds'] = None
234 # ─── Node ID ───
235 try:
236 from security.node_integrity import get_node_identity
237 identity = get_node_identity()
238 data['node_id'] = identity.get('node_id', 'unknown')
239 except Exception:
240 data['node_id'] = 'unknown'
242 # ─── Version (schema version as proxy) ───
243 try:
244 from integrations.social.migrations import SCHEMA_VERSION
245 data['version'] = f'schema-v{SCHEMA_VERSION}'
246 except Exception:
247 data['version'] = 'unknown'
249 return jsonify({'success': True, 'data': data}), 200
250 except Exception as e:
251 logger.error(f"System info error: {e}")
252 return jsonify({'success': False, 'error': str(e)}), 500
253 finally:
254 db.close()
257@dashboard_bp.route('/api/social/dashboard/topology', methods=['GET'])
258def get_topology():
259 """Network topology: peer graph for UI visualization.
261 Returns nodes (peers + self) and edges (gossip connections)
262 suitable for rendering a network graph in the dashboard UI.
263 """
264 from .models import get_db, PeerNode
266 db = get_db()
267 try:
268 # ─── Determine self node ID ───
269 self_node_id = 'unknown'
270 try:
271 from security.node_integrity import get_public_key_hex
272 self_node_id = get_public_key_hex()[:16]
273 except Exception:
274 pass
276 # ─── Query all known peers ───
277 peers = db.query(PeerNode).all()
279 nodes = []
280 edges = []
281 peer_node_ids = set()
283 for peer in peers:
284 peer_node_ids.add(peer.node_id)
285 nodes.append({
286 'node_id': peer.node_id,
287 'tier': peer.tier or 'flat',
288 'region': peer.region_assignment_id or peer.dns_region,
289 'trust_score': peer.contribution_score or 0.0,
290 'status': peer.status or 'unknown',
291 'is_self': (peer.node_id == self_node_id),
292 'capability_tier': peer.capability_tier,
293 'integrity_status': peer.integrity_status or 'unverified',
294 'url': peer.url,
295 })
297 # Include self node if not already in the peer list
298 if self_node_id != 'unknown' and self_node_id not in peer_node_ids:
299 self_tier = 'flat'
300 self_capability = None
301 try:
302 from security.system_requirements import get_tier_name
303 self_capability = get_tier_name()
304 except Exception:
305 pass
306 try:
307 from security.key_delegation import get_node_tier
308 self_tier = get_node_tier()
309 except Exception:
310 pass
312 nodes.append({
313 'node_id': self_node_id,
314 'tier': self_tier,
315 'region': None,
316 'trust_score': 1.0,
317 'status': 'active',
318 'is_self': True,
319 'capability_tier': self_capability,
320 'integrity_status': 'verified',
321 'url': None,
322 })
324 # ─── Build edges from gossip/hierarchy relationships ───
325 for peer in peers:
326 # Edge from self to every known peer (gossip connection)
327 if self_node_id != 'unknown':
328 # Estimate latency from metadata if available
329 latency_ms = None
330 if isinstance(peer.metadata_json, dict):
331 latency_ms = peer.metadata_json.get('latency_ms')
333 edges.append({
334 'source_node_id': self_node_id,
335 'target_node_id': peer.node_id,
336 'latency_ms': latency_ms,
337 })
339 # Edge from peer to its parent (hierarchy link)
340 if peer.parent_node_id and peer.parent_node_id in peer_node_ids:
341 edges.append({
342 'source_node_id': peer.parent_node_id,
343 'target_node_id': peer.node_id,
344 'latency_ms': None,
345 })
347 return jsonify({
348 'success': True,
349 'data': {
350 'self_node_id': self_node_id,
351 'nodes': nodes,
352 'edges': edges,
353 'node_count': len(nodes),
354 'edge_count': len(edges),
355 },
356 }), 200
357 except Exception as e:
358 logger.error(f"Topology error: {e}")
359 return jsonify({'success': False, 'error': str(e)}), 500
360 finally:
361 db.close()