pyre-cli 10.1.0 → 10.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +211 -494
  2. package/dist/{checkCmd-RKW6QGDR.js → checkCmd-WQAHAUWO.js} +1 -1
  3. package/dist/{chunk-GHRDW646.js → chunk-23YWCI3O.js} +375 -135
  4. package/dist/{service-Q74BBILO.js → chunk-KFPTD266.js} +43 -3
  5. package/dist/{doctor-GYHFTNZQ.js → chunk-KVUMOA6C.js} +7 -6
  6. package/dist/{chunk-53O2RGSL.js → chunk-QM5L43EK.js} +19 -2
  7. package/dist/{chunk-6UHZP4QP.js → chunk-XTOOEKLM.js} +24 -12
  8. package/dist/{collectors-H43PN4PS.js → collectors-JDN5Y7EV.js} +1 -1
  9. package/dist/{completions-JKYEJNGR.js → completions-ZVTNE52B.js} +15 -11
  10. package/dist/{config-GYGJXU2Q.js → config-J4YMADEF.js} +3 -1
  11. package/dist/doctor-LHFW4UJP.js +10 -0
  12. package/dist/firebaseSync-ON3RZ6KX.js +91 -0
  13. package/dist/index.js +4245 -736
  14. package/dist/monitors/platform/windows-ui.ps1 +42 -0
  15. package/dist/{netUsageCmd-WKBTW2YG.js → netUsageCmd-67IBZZYN.js} +2 -2
  16. package/dist/{pipeCmd-7REDBLBU.js → pipeCmd-2N4JPKYE.js} +1 -1
  17. package/dist/{prometheusCmd-PL64BLN5.js → prometheusCmd-LEUM5WCZ.js} +1 -1
  18. package/dist/serveCmd-L7LVJOZN.js +353 -0
  19. package/dist/service-7GIX3YNC.js +16 -0
  20. package/dist/syncCmd-42IRF633.js +246 -0
  21. package/dist/{topoCmd-DU2HHZNH.js → topoCmd-DZ3YS5IL.js} +1 -1
  22. package/dist/{watchdogCmd-UF53JPZB.js → watchdogCmd-6GR4UN6B.js} +7 -7
  23. package/dist/{wizard-TD7BGX6O.js → wizard-JQMQWFAW.js} +1 -1
  24. package/package.json +4 -3
  25. package/parity.md +2 -0
  26. package/src/completions.ts +15 -11
  27. package/src/easy_features.test.ts +149 -0
  28. package/src/firebaseSync.ts +157 -0
  29. package/src/formatters/html_template.ts +3086 -598
  30. package/src/formatters/render.ts +25 -12
  31. package/src/index.ts +518 -24
  32. package/src/live/input.ts +501 -148
  33. package/src/live/render.ts +163 -18
  34. package/src/live/state.ts +52 -2
  35. package/src/live/types.ts +1 -1
  36. package/src/monitors/collectors.ts +3 -10
  37. package/src/monitors/platform/windows-ui.ps1 +42 -0
  38. package/src/monitors/platform/windows.ts +442 -147
  39. package/src/monitors/types.ts +8 -0
  40. package/src/serveCmd.ts +17 -2
  41. package/src/service.ts +49 -0
  42. package/src/state/config.ts +48 -0
  43. package/src/syncCmd.ts +159 -11
  44. package/src/web/dashboard.ts +205 -0
  45. package/dist/serveCmd-2H5VLF3P.js +0 -153
  46. package/dist/syncCmd-A2W3QB3S.js +0 -126
@@ -43,6 +43,14 @@ export interface GpuData {
43
43
  utilization: number;
44
44
  temperature?: number;
45
45
  processes: number;
46
+ powerDraw?: number;
47
+ }
48
+
49
+ export interface DisplayInfo {
50
+ name: string;
51
+ resolution: string;
52
+ connectionType?: string;
53
+ isMain?: boolean;
46
54
  }
47
55
 
48
56
  export interface PowerData {
package/src/serveCmd.ts CHANGED
@@ -2,9 +2,11 @@
2
2
  * pyre serve — Dedicated REST API Mode.
3
3
  *
4
4
  * Exposes all metrics as structured JSON REST endpoints with optional
5
- * API key authorization and CORS support.
5
+ * API key authorization and CORS support. Also serves a live Chart.js
6
+ * dashboard at the root path (/).
6
7
  *
7
8
  * Endpoints:
9
+ * GET / → Live Chart.js web dashboard (auto-refresh every 2s)
8
10
  * GET /api/all
9
11
  * GET /api/cpu
10
12
  * GET /api/memory
@@ -20,6 +22,7 @@
20
22
  import http from 'node:http';
21
23
  import os from 'node:os';
22
24
  import chalk from 'chalk';
25
+ import { getWebDashboardHtml } from './web/dashboard.js';
23
26
  import {
24
27
  collectAll,
25
28
  collectCpu,
@@ -77,6 +80,15 @@ export async function runServeCommand(opts: ServeOptions = {}): Promise<void> {
77
80
  let data: any;
78
81
 
79
82
  switch (pathname) {
83
+ case '/':
84
+ case '/dashboard':
85
+ case '/web': {
86
+ const apiBase = `http://${req.headers.host || `localhost:${port}`}/api/all`;
87
+ const html = getWebDashboardHtml(apiBase, apiKey);
88
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
89
+ res.end(html);
90
+ return;
91
+ }
80
92
  case '/api/all':
81
93
  case '/api/stats':
82
94
  case '/api':
@@ -128,6 +140,8 @@ export async function runServeCommand(opts: ServeOptions = {}): Promise<void> {
128
140
  res.end(JSON.stringify({
129
141
  error: 'Not found',
130
142
  availableEndpoints: [
143
+ '/',
144
+ '/dashboard',
131
145
  '/api/all',
132
146
  '/api/cpu',
133
147
  '/api/memory',
@@ -164,8 +178,9 @@ export async function runServeCommand(opts: ServeOptions = {}): Promise<void> {
164
178
 
165
179
  await new Promise<void>((resolve) => server.listen(port, '0.0.0.0', resolve));
166
180
 
167
- console.log(chalk.bold('\n 🔥 pyre serve — REST API Server\n'));
181
+ console.log(chalk.bold('\n 🔥 pyre serve — REST API + Web Dashboard\n'));
168
182
  console.log(` ${chalk.green('✔')} API base URL: ${chalk.cyan(`http://0.0.0.0:${port}/api/all`)}`);
183
+ console.log(` ${chalk.green('✔')} Dashboard: ${chalk.cyan(`http://0.0.0.0:${port}/`)}`);
169
184
  console.log(` ${chalk.green('✔')} Health check: ${chalk.cyan(`http://0.0.0.0:${port}/api/health`)}`);
170
185
  if (apiKey) {
171
186
  console.log(` ${chalk.yellow('🔒')} API Key protection: ${chalk.bold('ENABLED')} (Pass via header x-api-key or ?key=)`);
package/src/service.ts CHANGED
@@ -36,6 +36,55 @@ export function uninstallService(): void {
36
36
  }
37
37
  }
38
38
 
39
+ export interface ServiceStatus {
40
+ installed: boolean;
41
+ platform: string;
42
+ serviceLabel: string;
43
+ path: string;
44
+ logPath: string;
45
+ }
46
+
47
+ export function isServiceInstalled(): boolean {
48
+ const platform = process.platform;
49
+ if (platform === 'darwin') {
50
+ return fs.existsSync(PLIST_PATH);
51
+ } else if (platform === 'linux') {
52
+ return fs.existsSync(SYSTEMD_UNIT_PATH);
53
+ } else if (platform === 'win32') {
54
+ try {
55
+ execSync(`schtasks /query /tn "${TASK_NAME}"`, { stdio: 'pipe' });
56
+ return true;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+ return false;
62
+ }
63
+
64
+ export function getServiceStatus(): ServiceStatus {
65
+ const platform = process.platform;
66
+ const installed = isServiceInstalled();
67
+ let serviceLabel = SERVICE_LABEL;
68
+ let servicePath = PLIST_PATH;
69
+ const logPath = path.join(os.homedir(), '.config', 'pyre', 'web.log');
70
+
71
+ if (platform === 'linux') {
72
+ serviceLabel = 'pyre-web.service';
73
+ servicePath = SYSTEMD_UNIT_PATH;
74
+ } else if (platform === 'win32') {
75
+ serviceLabel = TASK_NAME;
76
+ servicePath = 'Windows Task Scheduler (onlogon)';
77
+ }
78
+
79
+ return {
80
+ installed,
81
+ platform,
82
+ serviceLabel,
83
+ path: servicePath,
84
+ logPath,
85
+ };
86
+ }
87
+
39
88
  // Backward-compatible exports for existing callers
40
89
  export const installLaunchdAgent = installService;
41
90
  export const uninstallLaunchdAgent = uninstallService;
@@ -14,6 +14,20 @@ import os from 'node:os';
14
14
  import path from 'node:path';
15
15
 
16
16
 
17
+ export interface PyreBookmark {
18
+ name: string;
19
+ activePanel: string;
20
+ sortMode: string;
21
+ processFilter: string;
22
+ treeView: boolean;
23
+ showGraphs: boolean;
24
+ graphMode: string;
25
+ trackedPid: number | null;
26
+ currentTheme: string;
27
+ panelLayout: string[];
28
+ detailed: boolean;
29
+ }
30
+
17
31
  export interface PyreConfig {
18
32
  theme?: string;
19
33
  interval?: number;
@@ -56,6 +70,10 @@ export interface PyreConfig {
56
70
  /** Optional API key for external cloud providers */
57
71
  aiApiKey?: string;
58
72
  dockerModeConfirmed?: boolean;
73
+ /** Use Firebase Firestore as the sync backend instead of a local folder */
74
+ firebaseSyncEnabled?: boolean;
75
+ /** Unique host identifier used as the Firestore document ID (defaults to os.hostname()) */
76
+ firebaseHostId?: string;
59
77
  visiblePanels?: {
60
78
  cpu?: boolean;
61
79
  mem?: boolean;
@@ -73,6 +91,19 @@ export interface PyreConfig {
73
91
  tree?: boolean;
74
92
  };
75
93
  panelLayout?: string[];
94
+ /** Saved session bookmarks */
95
+ bookmarks?: PyreBookmark[];
96
+ /** Graph zoom level (1-5, higher = more history points visible) */
97
+ graphZoomLevel?: number;
98
+ /** Web UI preferences */
99
+ compact?: boolean;
100
+ alwaysOnTop?: boolean;
101
+ exposeLan?: boolean;
102
+ port?: number;
103
+ historyDuration?: number;
104
+ tableDensity?: string;
105
+ defaultFilter?: string;
106
+ defaultSort?: string;
76
107
  }
77
108
 
78
109
  export const DEFAULT_CONFIG: Required<PyreConfig> = {
@@ -109,6 +140,8 @@ export const DEFAULT_CONFIG: Required<PyreConfig> = {
109
140
  aiModel: 'expert-rules-v1',
110
141
  aiApiKey: '',
111
142
  dockerModeConfirmed: false,
143
+ firebaseSyncEnabled: false,
144
+ firebaseHostId: '',
112
145
  visiblePanels: {
113
146
  cpu: true,
114
147
  mem: true,
@@ -126,6 +159,16 @@ export const DEFAULT_CONFIG: Required<PyreConfig> = {
126
159
  tree: false,
127
160
  },
128
161
  panelLayout: ['mem', 'disk', 'net'],
162
+ bookmarks: [],
163
+ graphZoomLevel: 1,
164
+ compact: false,
165
+ alwaysOnTop: false,
166
+ exposeLan: false,
167
+ port: 3000,
168
+ historyDuration: 60,
169
+ tableDensity: 'standard',
170
+ defaultFilter: 'all',
171
+ defaultSort: 'cpu',
129
172
  };
130
173
 
131
174
  export function getConfigDir(): string {
@@ -220,3 +263,8 @@ export function getConfigPath(): string {
220
263
  return getConfigFile();
221
264
  }
222
265
 
266
+ export function resetConfig(): Required<PyreConfig> {
267
+ writeConfig(DEFAULT_CONFIG);
268
+ return { ...DEFAULT_CONFIG };
269
+ }
270
+
package/src/syncCmd.ts CHANGED
@@ -3,19 +3,27 @@
3
3
  *
4
4
  * Synchronizes configuration profiles (~/.config/pyre/profiles/),
5
5
  * alert rules, thresholds, and anomaly history across multiple Macs
6
- * via iCloud Drive or any user-defined sync folder.
6
+ * via iCloud Drive / any user-defined sync folder, OR via Firebase Firestore.
7
7
  *
8
8
  * Commands:
9
9
  * pyre sync status
10
10
  * pyre sync push
11
- * pyre sync pull
11
+ * pyre sync pull [hostname]
12
+ * pyre sync firebase <enable|disable|status>
12
13
  */
13
14
  import fs from 'node:fs';
14
15
  import path from 'node:path';
15
16
  import os from 'node:os';
16
17
  import crypto from 'node:crypto';
17
18
  import chalk from 'chalk';
18
- import { getConfigDir, getConfigFile, getProfilesDir, readConfig, writeConfig } from './state/config.js';
19
+ import {
20
+ getConfigDir,
21
+ getConfigFile,
22
+ getProfilesDir,
23
+ readConfig,
24
+ writeConfig,
25
+ PyreConfig,
26
+ } from './state/config.js';
19
27
 
20
28
  export interface SyncOptions {
21
29
  syncDir?: string;
@@ -58,7 +66,147 @@ function computeDirChecksum(dirPath: string): string {
58
66
  return hash.digest('hex').slice(0, 16);
59
67
  }
60
68
 
69
+ // ─── Load profiles from disk ──────────────────────────────────────────────────
70
+
71
+ function loadLocalProfiles(): Record<string, PyreConfig> {
72
+ const profilesDir = getProfilesDir();
73
+ const profiles: Record<string, PyreConfig> = {};
74
+ if (!fs.existsSync(profilesDir)) return profiles;
75
+ for (const f of fs.readdirSync(profilesDir).filter((f) => f.endsWith('.json'))) {
76
+ try {
77
+ const name = f.replace(/\.json$/, '');
78
+ profiles[name] = JSON.parse(fs.readFileSync(path.join(profilesDir, f), 'utf-8'));
79
+ } catch { /* skip corrupted profiles */ }
80
+ }
81
+ return profiles;
82
+ }
83
+
84
+ function saveLocalProfiles(profiles: Record<string, PyreConfig>): void {
85
+ const profilesDir = getProfilesDir();
86
+ if (!fs.existsSync(profilesDir)) fs.mkdirSync(profilesDir, { recursive: true });
87
+ for (const [name, cfg] of Object.entries(profiles)) {
88
+ fs.writeFileSync(path.join(profilesDir, `${name}.json`), JSON.stringify(cfg, null, 2) + '\n');
89
+ }
90
+ }
91
+
92
+ // ─── Firebase subcommand ──────────────────────────────────────────────────────
93
+
94
+ async function runFirebaseSubcommand(action: string): Promise<void> {
95
+ const config = readConfig();
96
+
97
+ if (action === 'enable') {
98
+ writeConfig({ firebaseSyncEnabled: true });
99
+ console.log(chalk.green(' ✔ Firebase sync enabled.'));
100
+ console.log(chalk.dim(' Run "pyre sync push" to upload your config and profiles to Firestore.\n'));
101
+ return;
102
+ }
103
+
104
+ if (action === 'disable') {
105
+ writeConfig({ firebaseSyncEnabled: false });
106
+ console.log(chalk.yellow(' ✔ Firebase sync disabled. Falling back to local folder sync.\n'));
107
+ return;
108
+ }
109
+
110
+ if (action === 'status' || !action) {
111
+ console.log(chalk.bold(` Firebase Sync: ${config.firebaseSyncEnabled ? chalk.green('ENABLED') : chalk.dim('DISABLED')}`));
112
+ if (!config.firebaseSyncEnabled) {
113
+ console.log(chalk.dim(' Run "pyre sync firebase enable" to activate.\n'));
114
+ return;
115
+ }
116
+ const { firebaseStatus } = await import('./firebaseSync.js');
117
+ const hosts = await firebaseStatus();
118
+ if (hosts.length === 0) {
119
+ console.log(chalk.dim(' No remote hosts found in Firestore. Run "pyre sync push" to upload.\n'));
120
+ return;
121
+ }
122
+ console.log();
123
+ for (const h of hosts) {
124
+ console.log(` ${chalk.bold(h.sourceHost)} (${chalk.cyan(h.hostId)})`);
125
+ console.log(` Last Updated: ${chalk.green(h.lastUpdated)}`);
126
+ console.log(` Profiles: ${chalk.bold(String(h.profilesCount))}`);
127
+ console.log(` Checksum: ${chalk.dim(h.checksum)}\n`);
128
+ }
129
+ return;
130
+ }
131
+
132
+ console.log(chalk.yellow(` Unknown firebase action: "${action}". Available: enable, disable, status\n`));
133
+ }
134
+
135
+ // ─── Main export ──────────────────────────────────────────────────────────────
136
+
61
137
  export async function runSyncCommand(subcommand: string = 'status', opts: SyncOptions = {}): Promise<void> {
138
+ const config = readConfig();
139
+
140
+ console.log(chalk.bold('\n 🔥 pyre sync — Configuration & Profile Cloud Sync\n'));
141
+
142
+ // ── Firebase subcommand ────────────────────────────────────────────────────
143
+ if (subcommand === 'firebase') {
144
+ const action = process.argv[process.argv.indexOf('firebase') + 1] || 'status';
145
+ await runFirebaseSubcommand(action);
146
+ return;
147
+ }
148
+
149
+ // ── Firebase backend ───────────────────────────────────────────────────────
150
+ if (config.firebaseSyncEnabled) {
151
+ console.log(` Backend: ${chalk.cyan('Firebase Firestore')} (${chalk.dim('dearborn-internal-tool')})`);
152
+ const hostId = config.firebaseHostId || undefined;
153
+
154
+ if (subcommand === 'status') {
155
+ const { firebaseStatus } = await import('./firebaseSync.js');
156
+ const hosts = await firebaseStatus();
157
+ if (hosts.length === 0) {
158
+ console.log(chalk.dim(' No remote hosts found. Run "pyre sync push" to upload your config.\n'));
159
+ return;
160
+ }
161
+ console.log();
162
+ for (const h of hosts) {
163
+ console.log(` ${chalk.bold(h.sourceHost)} (${chalk.cyan(h.hostId)})`);
164
+ console.log(` Last Updated: ${chalk.green(h.lastUpdated)}`);
165
+ console.log(` Profiles: ${chalk.bold(String(h.profilesCount))}`);
166
+ console.log(` Checksum: ${chalk.dim(h.checksum)}\n`);
167
+ }
168
+ return;
169
+ }
170
+
171
+ if (subcommand === 'push') {
172
+ const { firebasePush } = await import('./firebaseSync.js');
173
+ const profiles = loadLocalProfiles();
174
+ const result = await firebasePush(config, profiles, hostId);
175
+ const profileCount = Object.keys(profiles).length;
176
+ console.log(chalk.green(` ✔ Successfully pushed config and ${profileCount} profile(s) to Firestore.`));
177
+ console.log(` Host ID: ${chalk.cyan(result.hostId)}`);
178
+ console.log(` Checksum: ${chalk.dim(result.checksum)} | Timestamp: ${chalk.dim(result.lastUpdated)}\n`);
179
+ return;
180
+ }
181
+
182
+ if (subcommand === 'pull') {
183
+ // Optional: pyre sync pull <hostname>
184
+ const targetHost = process.argv[process.argv.indexOf('pull') + 1];
185
+ const resolvedTarget = (targetHost && !targetHost.startsWith('-')) ? targetHost : undefined;
186
+
187
+ const { firebasePull } = await import('./firebaseSync.js');
188
+ const result = await firebasePull(resolvedTarget);
189
+
190
+ if (!result) {
191
+ const hint = resolvedTarget ? `host "${resolvedTarget}"` : 'any host';
192
+ console.error(chalk.red(` ✖ No sync data found for ${hint} in Firestore. Run "pyre sync push" from another machine.\n`));
193
+ process.exit(1);
194
+ }
195
+
196
+ writeConfig(result.doc.config);
197
+ saveLocalProfiles(result.doc.profiles);
198
+
199
+ const pulledCount = result.doc.profilesCount;
200
+ console.log(chalk.green(` ✔ Successfully pulled config and ${pulledCount} profile(s) from ${chalk.bold(result.doc.sourceHost)}.`));
201
+ console.log(chalk.dim(` Applied to ${getConfigFile()}\n`));
202
+ return;
203
+ }
204
+
205
+ console.log(chalk.yellow(` Unknown subcommand: "${subcommand}". Available: status, push, pull, firebase\n`));
206
+ return;
207
+ }
208
+
209
+ // ── Folder-based backend (original behaviour) ──────────────────────────────
62
210
  const syncDir = opts.syncDir || getDefaultSyncDir();
63
211
  const manifestPath = path.join(syncDir, 'sync-manifest.json');
64
212
  const remoteProfilesDir = path.join(syncDir, 'profiles');
@@ -67,22 +215,22 @@ export async function runSyncCommand(subcommand: string = 'status', opts: SyncOp
67
215
  const localProfilesDir = getProfilesDir();
68
216
  const localConfigFile = getConfigFile();
69
217
 
70
- console.log(chalk.bold('\n 🔥 pyre sync — Configuration & Profile Cloud Sync\n'));
71
- console.log(` Sync Target Directory: ${chalk.cyan(syncDir)}`);
218
+ console.log(` Backend: ${chalk.cyan('Local Folder')}`);
219
+ console.log(` Sync Target: ${chalk.cyan(syncDir)}`);
72
220
 
73
221
  if (subcommand === 'status' || !subcommand) {
74
222
  const hasSyncDir = fs.existsSync(syncDir);
75
223
  const hasManifest = fs.existsSync(manifestPath);
76
224
 
77
- console.log(` Local Profiles: ${chalk.bold(String(fs.existsSync(localProfilesDir) ? fs.readdirSync(localProfilesDir).filter(f => f.endsWith('.json')).length : 0))}`);
78
- console.log(` Sync Target Available: ${hasSyncDir ? chalk.green('YES') : chalk.yellow('NO (will be created on push)')}`);
225
+ console.log(` Local Profiles: ${chalk.bold(String(fs.existsSync(localProfilesDir) ? fs.readdirSync(localProfilesDir).filter(f => f.endsWith('.json')).length : 0))}`);
226
+ console.log(` Sync Target Exists: ${hasSyncDir ? chalk.green('YES') : chalk.yellow('NO (will be created on push)')}`);
79
227
 
80
228
  if (hasManifest) {
81
229
  try {
82
230
  const manifest: SyncManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
83
- console.log(` Remote Last Updated: ${chalk.green(manifest.lastUpdated)} (from ${chalk.bold(manifest.sourceHost)})`);
84
- console.log(` Remote Profiles Count: ${chalk.bold(String(manifest.profilesCount))}`);
85
- console.log(` Remote Checksum: ${chalk.dim(manifest.checksum)}`);
231
+ console.log(` Remote Last Updated: ${chalk.green(manifest.lastUpdated)} (from ${chalk.bold(manifest.sourceHost)})`);
232
+ console.log(` Remote Profiles: ${chalk.bold(String(manifest.profilesCount))}`);
233
+ console.log(` Remote Checksum: ${chalk.dim(manifest.checksum)}`);
86
234
  } catch {
87
235
  console.log(chalk.yellow(' Remote manifest corrupted.'));
88
236
  }
@@ -162,5 +310,5 @@ export async function runSyncCommand(subcommand: string = 'status', opts: SyncOp
162
310
  return;
163
311
  }
164
312
 
165
- console.log(chalk.yellow(` Unknown subcommand: "${subcommand}". Available: status, push, pull\n`));
313
+ console.log(chalk.yellow(` Unknown subcommand: "${subcommand}". Available: status, push, pull, firebase\n`));
166
314
  }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * pyre web dashboard module.
3
+ *
4
+ * Provides a proper auto-refreshing web dashboard with
5
+ * live Chart.js graphs served over HTTP. Powers `pyre web`.
6
+ *
7
+ * The dashboard polls the pyre REST API every 2 seconds
8
+ * and renders live sparkline-style charts for:
9
+ * - CPU usage %
10
+ * - Memory usage %
11
+ * - Temperature °C
12
+ * - Network RX/TX rate
13
+ * - Power draw (watts)
14
+ */
15
+
16
+ export interface WebDashboardOptions {
17
+ port: number;
18
+ apiUrl: string;
19
+ apiKey?: string;
20
+ }
21
+
22
+ const DASHBOARD_HTML = (apiUrl: string, apiKey?: string): string => {
23
+ const authHeader = apiKey ? `"x-api-key": "${apiKey}",\n ` : '';
24
+ return `<!DOCTYPE html>
25
+ <html lang="en">
26
+ <head>
27
+ <meta charset="UTF-8"/>
28
+ <meta name="viewport" content="width=device-width,initial-scale=1.0"/>
29
+ <title>Pyre — Live Dashboard</title>
30
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
31
+ <style>
32
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
33
+ :root{
34
+ --bg:#0a0a0f;--surface:#141418;--border:rgba(255,255,255,.08);
35
+ --text:#f5f5f7;--muted:#a1a1a6;--accent:#ff5500;
36
+ --cpu:#00d4ff;--mem:#ff9f0a;--gpu:#af52de;--thm:#ff453a;
37
+ --net:#34c759;--pwr:#ffcc02;--bat:#64d2ff;
38
+ --ok:#34c759;--warn:#ff9f0a;--crit:#ff453a;
39
+ }
40
+ html,body{height:100%;background:var(--bg);color:var(--text);
41
+ font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","SF Pro Text","Helvetica Neue",Helvetica,Arial,sans-serif;
42
+ -webkit-font-smoothing:antialiased;overflow-x:hidden}
43
+ header{position:sticky;top:0;z-index:10;height:48px;background:rgba(10,10,15,.9);
44
+ backdrop-filter:saturate(180%) blur(20px);border-bottom:1px solid var(--border);
45
+ display:flex;align-items:center;padding:0 20px;gap:16px}
46
+ .logo{font-weight:700;font-size:15px;color:var(--accent);letter-spacing:-.01em}
47
+ .hostname{font-size:13px;color:var(--muted)}
48
+ .status{margin-left:auto;display:flex;gap:12px;align-items:center}
49
+ .badge{padding:3px 10px;border-radius:980px;font-size:11px;font-weight:600;font-family:ui-monospace,monospace}
50
+ .badge-live{background:rgba(52,199,89,.15);color:var(--ok);border:1px solid rgba(52,199,89,.3)}
51
+ .badge-err{background:rgba(255,69,58,.15);color:var(--crit);border:1px solid rgba(255,69,58,.3)}
52
+ .refresh{font-size:11px;color:var(--muted);font-family:ui-monospace,monospace}
53
+ main{padding:20px;display:grid;gap:16px;
54
+ grid-template-columns:repeat(auto-fit,minmax(340px,1fr));
55
+ max-width:1600px;margin:0 auto}
56
+ .card{background:var(--surface);border:1px solid var(--border);border-radius:16px;
57
+ padding:16px 18px;display:flex;flex-direction:column;min-height:200px}
58
+ .card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}
59
+ .card-title{font-size:13px;font-weight:600;color:var(--muted);letter-spacing:-.01em;text-transform:uppercase}
60
+ .card-value{font-size:24px;font-weight:700;font-family:ui-monospace,monospace;letter-spacing:-.02em}
61
+ .card-sub{font-size:11px;color:var(--muted);margin-top:2px;font-family:ui-monospace,monospace}
62
+ .chart-wrap{flex:1;position:relative;min-height:80px;margin-top:8px}
63
+ canvas{width:100%!important;height:100%!important}
64
+ .summary{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:16px}
65
+ .stat{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:12px 14px}
66
+ .stat-label{font-size:11px;color:var(--muted);text-transform:uppercase;font-weight:600;letter-spacing:-.01em}
67
+ .stat-val{font-size:20px;font-weight:700;font-family:ui-monospace,monospace;margin-top:2px}
68
+ .footer{text-align:center;padding:16px;font-size:11px;color:var(--muted)}
69
+ @media(max-width:768px){main{grid-template-columns:1fr}.summary{grid-template-columns:repeat(2,1fr)}}
70
+ </style>
71
+ </head>
72
+ <body>
73
+ <header>
74
+ <div class="logo">🔥 Pyre</div>
75
+ <div class="hostname" id="hostname">loading…</div>
76
+ <div class="status">
77
+ <div class="refresh" id="refresh">refreshing 2s</div>
78
+ <div class="badge badge-live" id="liveBadge">● LIVE</div>
79
+ </div>
80
+ </header>
81
+ <main>
82
+ <div class="summary" id="summary"></div>
83
+ <div class="card">
84
+ <div class="card-header">
85
+ <div class="card-title" style="color:var(--cpu)">CPU Usage %</div>
86
+ <div class="card-value" id="cpuVal" style="color:var(--cpu)">--</div>
87
+ </div>
88
+ <div class="chart-wrap"><canvas id="cpuChart"></canvas></div>
89
+ </div>
90
+ <div class="card">
91
+ <div class="card-header">
92
+ <div class="card-title" style="color:var(--mem)">Memory Usage %</div>
93
+ <div class="card-value" id="memVal" style="color:var(--mem)">--</div>
94
+ </div>
95
+ <div class="chart-wrap"><canvas id="memChart"></canvas></div>
96
+ </div>
97
+ <div class="card">
98
+ <div class="card-header">
99
+ <div class="card-title" style="color:var(--thm)">Temperature °C</div>
100
+ <div class="card-value" id="tempVal" style="color:var(--thm)">--</div>
101
+ </div>
102
+ <div class="chart-wrap"><canvas id="tempChart"></canvas></div>
103
+ </div>
104
+ <div class="card">
105
+ <div class="card-header">
106
+ <div class="card-title" style="color:var(--net)">Network RX/TX MB/s</div>
107
+ <div class="card-value" id="netVal" style="color:var(--net)">--</div>
108
+ </div>
109
+ <div class="chart-wrap"><canvas id="netChart"></canvas></div>
110
+ </div>
111
+ <div class="card">
112
+ <div class="card-header">
113
+ <div class="card-title" style="color:var(--pwr)">Power Draw (W)</div>
114
+ <div class="card-value" id="pwrVal" style="color:var(--pwr)">--</div>
115
+ </div>
116
+ <div class="chart-wrap"><canvas id="pwrChart"></canvas></div>
117
+ </div>
118
+ </main>
119
+ <div class="footer">Pyre live dashboard · auto-refresh every 2s · <span id="lastUpdate">--</span></div>
120
+ <script>
121
+ const MAX_POINTS=60;
122
+ const history={cpu:[],mem:[],temp:[],netRx:[],netTx:[],pwr:[],labels:[]};
123
+ const chartOpts=(color,label,unit='')=>({
124
+ type:'line',data:{labels:history.labels,datasets:[{
125
+ label:label,data:[],borderColor:color,backgroundColor:color+'22',
126
+ borderWidth:2,pointRadius:0,tension:.4,fill:true
127
+ }]},
128
+ options:{responsive:true,maintainAspectRatio:false,animation:false,
129
+ scales:{x:{display:false},y:{beginAtZero:true,grid:{color:'rgba(255,255,255,.05)'},
130
+ ticks:{color:'#a1a1a6',font:{size:10},callback:v=>v+unit}}},
131
+ plugins:{legend:{display:false}},
132
+ interaction:{intersect:false,mode:'index'}}
133
+ });
134
+ const charts={
135
+ cpu:new Chart(document.getElementById('cpuChart'),chartOpts('#00d4ff','CPU %','%')),
136
+ mem:new Chart(document.getElementById('memChart'),chartOpts('#ff9f0a','Memory %','%')),
137
+ temp:new Chart(document.getElementById('tempChart'),chartOpts('#ff453a','Temp','°C')),
138
+ net:new Chart(document.getElementById('netChart'),chartOpts('#34c759','Net','MB/s')),
139
+ pwr:new Chart(document.getElementById('pwrChart'),chartOpts('#ffcc02','Power','W')),
140
+ };
141
+ function fmtTime(d){return d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',second:'2-digit'})}
142
+ function fmtBytes(b){if(!b)return'0';if(b<1024)return b+'B';if(b<1048576)return(b/1024).toFixed(1)+'KB';return(b/1048576).toFixed(1)+'MB'}
143
+ const AUTH_HEADERS = { ${authHeader} };
144
+ async function fetchData(){
145
+ try{
146
+ const r=await fetch('${apiUrl}',{headers:AUTH_HEADERS});
147
+ if(!r.ok)throw new Error(r.status);
148
+ const d=await r.json();
149
+ document.getElementById('hostname').textContent=d.header?.hostname||'pyre';
150
+ document.getElementById('lastUpdate').textContent='updated '+fmtTime(new Date());
151
+ const cpu=d.cpu?.usage??0,mem=d.memory?.usagePercent??0;
152
+ const temp=d.cpu?.temperature??d.thermal?.temperatures?.cpu_die??null;
153
+ const rxRate=d.network?.rxRate??(d.network?.rxBytes?0:0);
154
+ const txRate=d.network?.txRate??(d.network?.txBytes?0:0);
155
+ const pwr=d.power?.combinedWatts??d.power?.cpuWatts??0;
156
+ const now=fmtTime(new Date());
157
+ history.labels.push(now);
158
+ history.cpu.push(cpu);history.mem.push(mem);
159
+ history.temp.push(temp);history.netRx.push(rxRate/1048576);history.netTx.push(txRate/1048576);
160
+ history.pwr.push(pwr);
161
+ if(history.labels.length>MAX_POINTS){
162
+ history.labels.shift();history.cpu.shift();history.mem.shift();
163
+ history.temp.shift();history.netRx.shift();history.netTx.shift();history.pwr.shift();
164
+ }
165
+ function updChart(chart,data,color){
166
+ chart.data.labels=history.labels;
167
+ chart.data.datasets[0].data=data;
168
+ chart.update('none');
169
+ }
170
+ updChart(charts.cpu,history.cpu,'#00d4ff');
171
+ updChart(charts.mem,history.mem,'#ff9f0a');
172
+ const tempData=history.temp.map(v=>v===null?null:v);
173
+ updChart(charts.temp,tempData,'#ff453a');
174
+ const netData=history.netRx.map((r,i)=>r+history.netTx[i]);
175
+ updChart(charts.net,netData,'#34c759');
176
+ updChart(charts.pwr,history.pwr,'#ffcc02');
177
+ document.getElementById('cpuVal').textContent=cpu.toFixed(1)+'%';
178
+ document.getElementById('memVal').textContent=mem.toFixed(1)+'%';
179
+ document.getElementById('tempVal').textContent=temp!==null?temp.toFixed(1)+'°C':'--';
180
+ document.getElementById('netVal').textContent=fmtBytes(rxRate+txRate)+'/s';
181
+ document.getElementById('pwrVal').textContent=pwr.toFixed(1)+'W';
182
+ const summary=document.getElementById('summary');
183
+ summary.innerHTML=
184
+ '<div class="stat"><div class="stat-label">CPU</div><div class="stat-val" style="color:var(--cpu)">'+cpu.toFixed(1)+'%</div></div>'+
185
+ '<div class="stat"><div class="stat-label">Memory</div><div class="stat-val" style="color:var(--mem)">'+mem.toFixed(1)+'%</div></div>'+
186
+ '<div class="stat"><div class="stat-label">Temp</div><div class="stat-val" style="color:var(--thm)">'+(temp!==null?temp.toFixed(1)+'°C':'--')+'</div></div>'+
187
+ '<div class="stat"><div class="stat-label">Network</div><div class="stat-val" style="color:var(--net)">'+fmtBytes(d.network?.rxBytes??0)+' ↓</div></div>'+
188
+ '<div class="stat"><div class="stat-label">Power</div><div class="stat-val" style="color:var(--pwr)">'+(pwr>0?pwr.toFixed(1)+'W':'--')+'</div></div>';
189
+ const badge=document.getElementById('liveBadge');
190
+ badge.className='badge badge-live';badge.textContent='● LIVE';
191
+ }catch(e){
192
+ const badge=document.getElementById('liveBadge');
193
+ badge.className='badge badge-err';badge.textContent='⚠ ERR';
194
+ console.error('pyre dashboard fetch error:',e);
195
+ }
196
+ }
197
+ fetchData();setInterval(fetchData,2000);
198
+ </script>
199
+ </body>
200
+ </html>`;
201
+ };
202
+
203
+ export function getWebDashboardHtml(apiUrl = 'http://localhost:8080/api/all', apiKey?: string): string {
204
+ return DASHBOARD_HTML(apiUrl, apiKey);
205
+ }