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

1""" 

2Agent Dashboard API Blueprint 

3 

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 

14 

15from flask import Blueprint, jsonify, request, make_response 

16 

17logger = logging.getLogger('hevolve_social') 

18 

19dashboard_bp = Blueprint('social_dashboard', __name__) 

20 

21 

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. 

25 

26 Priority-ordered: what matters most RIGHT NOW appears first. 

27 Status reflects reality, not cache. 

28 

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 

37 

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 

48 

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() 

59 

60 

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 

72 

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} 

80 

81 return jsonify({'success': True, 'data': data}), 200 

82 

83 

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 

100 

101 

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 

106 

107 db = get_db() 

108 try: 

109 data = {} 

110 

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' 

117 

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' 

128 

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' 

143 

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 

164 

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 

186 

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 

200 

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 

217 

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 

233 

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' 

241 

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' 

248 

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() 

255 

256 

257@dashboard_bp.route('/api/social/dashboard/topology', methods=['GET']) 

258def get_topology(): 

259 """Network topology: peer graph for UI visualization. 

260 

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 

265 

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 

275 

276 # ─── Query all known peers ─── 

277 peers = db.query(PeerNode).all() 

278 

279 nodes = [] 

280 edges = [] 

281 peer_node_ids = set() 

282 

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 }) 

296 

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 

311 

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 }) 

323 

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') 

332 

333 edges.append({ 

334 'source_node_id': self_node_id, 

335 'target_node_id': peer.node_id, 

336 'latency_ms': latency_ms, 

337 }) 

338 

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 }) 

346 

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()