idlewatch 0.1.0 → 0.1.1
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.
- package/bin/idlewatch-agent.js +339 -3
- package/package.json +1 -1
- package/tui/src/main.rs +96 -17
package/bin/idlewatch-agent.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from 'fs'
|
|
3
3
|
import { accessSync, constants } from 'node:fs'
|
|
4
|
+
import http from 'node:http'
|
|
4
5
|
import os from 'os'
|
|
5
6
|
import path from 'path'
|
|
6
7
|
import process from 'process'
|
|
@@ -17,7 +18,7 @@ import { enrichWithOpenClawFleetTelemetry } from '../src/telemetry-mapping.js'
|
|
|
17
18
|
import pkg from '../package.json' with { type: 'json' }
|
|
18
19
|
|
|
19
20
|
function printHelp() {
|
|
20
|
-
console.log(`idlewatch-agent\n\nUsage:\n idlewatch-agent [quickstart] [--dry-run] [--once] [--help]\n\nOptions:\n quickstart Run first-run enrollment wizard and generate secure env config\n --dry-run Collect and print one telemetry sample, then exit without Firebase writes\n --once Collect and publish one telemetry sample, then exit\n --help Show this help message\n\nEnvironment:\n IDLEWATCH_HOST Optional custom host label (default: hostname)\n IDLEWATCH_INTERVAL_MS Sampling interval in ms (default: 10000)\n IDLEWATCH_LOCAL_LOG_PATH Optional NDJSON file path for local sample durability\n IDLEWATCH_OPENCLAW_USAGE OpenClaw usage lookup mode: auto|off (default: auto)\n IDLEWATCH_OPENCLAW_PROBE_TIMEOUT_MS OpenClaw command timeout per probe in ms (default: 2500)\n IDLEWATCH_OPENCLAW_PROBE_RETRIES Extra OpenClaw probe sweep retries after first pass (default: 1)\n IDLEWATCH_OPENCLAW_MAX_OUTPUT_BYTES Max per-command OpenClaw probe output capture in bytes before truncation (default: 2097152 / 2MB)\n IDLEWATCH_OPENCLAW_MAX_OUTPUT_BYTES_HARD_CAP Hard cap for auto-retry output capture escalation (default: 16777216 / 16MB)\n IDLEWATCH_USAGE_STALE_MS Mark OpenClaw usage stale beyond this age in ms (default: max(interval*3,60000))\n IDLEWATCH_USAGE_NEAR_STALE_MS Mark OpenClaw usage as aging beyond this age in ms (default: floor((stale+grace)*0.85))\n IDLEWATCH_USAGE_STALE_GRACE_MS Extra grace window before status becomes stale (default: min(interval,10000))\n IDLEWATCH_USAGE_REFRESH_REPROBES Forced uncached reprobes when usage crosses stale threshold (default: 1)\n IDLEWATCH_USAGE_REFRESH_DELAY_MS Delay between forced stale-threshold reprobes in ms (default: 250)\n IDLEWATCH_USAGE_REFRESH_ON_NEAR_STALE Trigger refresh when usage is near-stale: 1|0 (default: 1)\n IDLEWATCH_USAGE_IDLE_AFTER_MS Downgrade stale usage alerts to idle notice beyond this age in ms (default: 21600000)\n IDLEWATCH_OPENCLAW_LAST_GOOD_MAX_AGE_MS Reuse last successful usage snapshot after probe failures up to this age in ms\n IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH Persist/reuse last successful usage snapshot across restarts (default: ~/.idlewatch/cache/<host>-openclaw-last-good.json)\n IDLEWATCH_REQUIRE_FIREBASE_WRITES Require Firebase publish path in --once mode: 1|0 (default: 0)\n FIREBASE_PROJECT_ID Firebase project id\n FIREBASE_SERVICE_ACCOUNT_FILE Path to service account JSON file (preferred for production)\n FIREBASE_SERVICE_ACCOUNT_JSON Raw JSON service account (supported, less secure than file path)\n FIREBASE_SERVICE_ACCOUNT_B64 Base64-encoded JSON service account (legacy)\n FIRESTORE_EMULATOR_HOST Optional Firestore emulator host; allows local writes without service-account creds\n IDLEWATCH_CLOUD_INGEST_URL Optional cloud ingest endpoint (e.g. https://idlewatch.com/api/ingest)\n IDLEWATCH_CLOUD_API_KEY Cloud API key from dashboard for device linking\n IDLEWATCH_REQUIRE_CLOUD_WRITES Require cloud publish path in --once mode: 1|0 (default: 0)\n`)
|
|
21
|
+
console.log(`idlewatch-agent\n\nUsage:\n idlewatch-agent [quickstart|dashboard] [--dry-run] [--once] [--help]\n\nOptions:\n quickstart Run first-run enrollment wizard and generate secure env config\n dashboard Launch local dashboard from local IdleWatch logs\n --dry-run Collect and print one telemetry sample, then exit without Firebase writes\n --once Collect and publish one telemetry sample, then exit\n --help Show this help message\n\nEnvironment:\n IDLEWATCH_HOST Optional custom host label (default: hostname)\n IDLEWATCH_INTERVAL_MS Sampling interval in ms (default: 10000)\n IDLEWATCH_LOCAL_LOG_PATH Optional NDJSON file path for local sample durability\n IDLEWATCH_DASHBOARD_PORT Local dashboard HTTP port (default: 4373)\n IDLEWATCH_OPENCLAW_USAGE OpenClaw usage lookup mode: auto|off (default: auto)\n IDLEWATCH_OPENCLAW_PROBE_TIMEOUT_MS OpenClaw command timeout per probe in ms (default: 2500)\n IDLEWATCH_OPENCLAW_PROBE_RETRIES Extra OpenClaw probe sweep retries after first pass (default: 1)\n IDLEWATCH_OPENCLAW_MAX_OUTPUT_BYTES Max per-command OpenClaw probe output capture in bytes before truncation (default: 2097152 / 2MB)\n IDLEWATCH_OPENCLAW_MAX_OUTPUT_BYTES_HARD_CAP Hard cap for auto-retry output capture escalation (default: 16777216 / 16MB)\n IDLEWATCH_USAGE_STALE_MS Mark OpenClaw usage stale beyond this age in ms (default: max(interval*3,60000))\n IDLEWATCH_USAGE_NEAR_STALE_MS Mark OpenClaw usage as aging beyond this age in ms (default: floor((stale+grace)*0.85))\n IDLEWATCH_USAGE_STALE_GRACE_MS Extra grace window before status becomes stale (default: min(interval,10000))\n IDLEWATCH_USAGE_REFRESH_REPROBES Forced uncached reprobes when usage crosses stale threshold (default: 1)\n IDLEWATCH_USAGE_REFRESH_DELAY_MS Delay between forced stale-threshold reprobes in ms (default: 250)\n IDLEWATCH_USAGE_REFRESH_ON_NEAR_STALE Trigger refresh when usage is near-stale: 1|0 (default: 1)\n IDLEWATCH_USAGE_IDLE_AFTER_MS Downgrade stale usage alerts to idle notice beyond this age in ms (default: 21600000)\n IDLEWATCH_OPENCLAW_LAST_GOOD_MAX_AGE_MS Reuse last successful usage snapshot after probe failures up to this age in ms\n IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH Persist/reuse last successful usage snapshot across restarts (default: ~/.idlewatch/cache/<host>-openclaw-last-good.json)\n IDLEWATCH_REQUIRE_FIREBASE_WRITES Require Firebase publish path in --once mode: 1|0 (default: 0)\n FIREBASE_PROJECT_ID Firebase project id\n FIREBASE_SERVICE_ACCOUNT_FILE Path to service account JSON file (preferred for production)\n FIREBASE_SERVICE_ACCOUNT_JSON Raw JSON service account (supported, less secure than file path)\n FIREBASE_SERVICE_ACCOUNT_B64 Base64-encoded JSON service account (legacy)\n FIRESTORE_EMULATOR_HOST Optional Firestore emulator host; allows local writes without service-account creds\n IDLEWATCH_CLOUD_INGEST_URL Optional cloud ingest endpoint (e.g. https://idlewatch.com/api/ingest)\n IDLEWATCH_CLOUD_API_KEY Cloud API key from dashboard for device linking\n IDLEWATCH_REQUIRE_CLOUD_WRITES Require cloud publish path in --once mode: 1|0 (default: 0)\n`)
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
const require = createRequire(import.meta.url)
|
|
@@ -54,14 +55,284 @@ function parseMonitorTargets(raw) {
|
|
|
54
55
|
return new Set(parsed)
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
function formatBytes(bytes) {
|
|
59
|
+
if (!Number.isFinite(bytes) || bytes < 0) return '0 B'
|
|
60
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
61
|
+
let value = bytes
|
|
62
|
+
let unit = 0
|
|
63
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
64
|
+
value /= 1024
|
|
65
|
+
unit += 1
|
|
66
|
+
}
|
|
67
|
+
const fixed = value >= 10 || unit === 0 ? value.toFixed(0) : value.toFixed(1)
|
|
68
|
+
return `${fixed} ${units[unit]}`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function resolveDashboardLogPath(host) {
|
|
72
|
+
if (process.env.IDLEWATCH_LOCAL_LOG_PATH) {
|
|
73
|
+
return path.resolve(process.env.IDLEWATCH_LOCAL_LOG_PATH)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const envFile = path.join(os.homedir(), '.idlewatch', 'idlewatch.env')
|
|
77
|
+
if (fs.existsSync(envFile)) {
|
|
78
|
+
try {
|
|
79
|
+
const parsed = parseEnvFileToObject(envFile)
|
|
80
|
+
if (parsed.IDLEWATCH_LOCAL_LOG_PATH) {
|
|
81
|
+
return path.resolve(parsed.IDLEWATCH_LOCAL_LOG_PATH)
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
// ignore malformed env file and fallback
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const safeHost = host.replace(/[^a-zA-Z0-9_.-]/g, '_')
|
|
89
|
+
return path.join(os.homedir(), '.idlewatch', 'logs', `${safeHost}-metrics.ndjson`)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseLocalRows(logPath, maxLines = 2500) {
|
|
93
|
+
if (!fs.existsSync(logPath)) return []
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const raw = fs.readFileSync(logPath, 'utf8')
|
|
97
|
+
const lines = raw.split(/\r?\n/).filter(Boolean)
|
|
98
|
+
const selected = lines.slice(Math.max(0, lines.length - maxLines))
|
|
99
|
+
|
|
100
|
+
return selected
|
|
101
|
+
.map((line) => {
|
|
102
|
+
try {
|
|
103
|
+
return JSON.parse(line)
|
|
104
|
+
} catch {
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
.filter((item) => item && Number.isFinite(Number(item.ts)))
|
|
109
|
+
.sort((a, b) => Number(a.ts) - Number(b.ts))
|
|
110
|
+
} catch {
|
|
111
|
+
return []
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function buildTokenDailyEstimate(rows) {
|
|
116
|
+
if (!Array.isArray(rows) || rows.length === 0) return []
|
|
117
|
+
|
|
118
|
+
const totals = new Map()
|
|
119
|
+
const previousBySource = new Map()
|
|
120
|
+
|
|
121
|
+
for (const row of rows) {
|
|
122
|
+
const ts = Number(row.ts || 0)
|
|
123
|
+
const tokensPerMin = Math.max(0, Number(row.tokensPerMin || 0))
|
|
124
|
+
const sourceKey = `${row.hostId || row.host || 'host'}::${row.deviceId || row.device || 'device'}`
|
|
125
|
+
const prevTs = previousBySource.get(sourceKey)
|
|
126
|
+
|
|
127
|
+
if (Number.isFinite(prevTs) && ts > prevTs && tokensPerMin > 0) {
|
|
128
|
+
const deltaMinutes = Math.max(0, Math.min(10, (ts - prevTs) / 60000))
|
|
129
|
+
const estimate = tokensPerMin * deltaMinutes
|
|
130
|
+
const day = new Date(ts)
|
|
131
|
+
const dayKey = `${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`
|
|
132
|
+
totals.set(dayKey, (totals.get(dayKey) || 0) + estimate)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
previousBySource.set(sourceKey, ts)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return [...totals.entries()]
|
|
139
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
140
|
+
.map(([day, tokens]) => ({ day: day.slice(5), tokens: Math.round(tokens) }))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function buildLocalDashboardPayload(logPath) {
|
|
144
|
+
const rows = parseLocalRows(logPath)
|
|
145
|
+
let bytes = 0
|
|
146
|
+
try {
|
|
147
|
+
bytes = fs.statSync(logPath).size
|
|
148
|
+
} catch {
|
|
149
|
+
bytes = 0
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const series = rows.map((row) => ({
|
|
153
|
+
ts: Number(row.ts || 0),
|
|
154
|
+
cpu: Number(row.cpuPct || 0),
|
|
155
|
+
memory: Number(row.memPct || 0),
|
|
156
|
+
gpu: Number(row.gpuPct || 0),
|
|
157
|
+
tokens: Number(row.tokensPerMin || 0)
|
|
158
|
+
}))
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
logPath,
|
|
162
|
+
logBytes: bytes,
|
|
163
|
+
logSizeHuman: formatBytes(bytes),
|
|
164
|
+
sampleCount: rows.length,
|
|
165
|
+
latestTs: rows.length ? Number(rows[rows.length - 1].ts || 0) : null,
|
|
166
|
+
series,
|
|
167
|
+
tokenDaily: buildTokenDailyEstimate(rows)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function renderLocalDashboardHtml() {
|
|
172
|
+
return `<!doctype html>
|
|
173
|
+
<html>
|
|
174
|
+
<head>
|
|
175
|
+
<meta charset="utf-8" />
|
|
176
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
177
|
+
<title>IdleWatch local dashboard</title>
|
|
178
|
+
<style>
|
|
179
|
+
body { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; margin:0; background:#0a0c1a; color:#eef1ff; }
|
|
180
|
+
.wrap { max-width: 1080px; margin: 30px auto; padding: 0 16px; }
|
|
181
|
+
.grid { display:grid; grid-template-columns: repeat(auto-fit,minmax(220px,1fr)); gap:10px; }
|
|
182
|
+
.card { background:#121633; border:1px solid #2a2f5b; border-radius:12px; padding:12px; }
|
|
183
|
+
.label { color:#9ba5d8; font-size:12px; text-transform:uppercase; letter-spacing:.08em; }
|
|
184
|
+
.value { margin-top:6px; font-size:18px; }
|
|
185
|
+
.chart { margin-top:10px; background:#121633; border:1px solid #2a2f5b; border-radius:12px; padding:12px; }
|
|
186
|
+
canvas { width:100% !important; height:280px !important; }
|
|
187
|
+
code { background:#1a1f42; border:1px solid #2f356e; border-radius:6px; padding:2px 6px; }
|
|
188
|
+
.sub { color:#98a2d6; font-size:13px; }
|
|
189
|
+
a { color:#7ce4ff; }
|
|
190
|
+
</style>
|
|
191
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
192
|
+
</head>
|
|
193
|
+
<body>
|
|
194
|
+
<main class="wrap">
|
|
195
|
+
<h1>IdleWatch local dashboard</h1>
|
|
196
|
+
<p class="sub">Live view from your local NDJSON log file.</p>
|
|
197
|
+
<div class="grid">
|
|
198
|
+
<div class="card"><div class="label">Log path</div><div class="value"><code id="log-path">—</code></div></div>
|
|
199
|
+
<div class="card"><div class="label">Storage</div><div class="value" id="log-size">—</div></div>
|
|
200
|
+
<div class="card"><div class="label">Samples</div><div class="value" id="sample-count">—</div></div>
|
|
201
|
+
<div class="card"><div class="label">Last sample</div><div class="value" id="last-sample">—</div></div>
|
|
202
|
+
</div>
|
|
203
|
+
<div class="chart"><h3>System load (%)</h3><canvas id="system-chart"></canvas></div>
|
|
204
|
+
<div class="chart"><h3>Tokens / min</h3><canvas id="tokens-chart"></canvas></div>
|
|
205
|
+
<div class="chart"><h3>Token / day (estimate)</h3><canvas id="daily-chart"></canvas></div>
|
|
206
|
+
</main>
|
|
207
|
+
<script>
|
|
208
|
+
const fmt = (n) => Number.isFinite(n) ? n.toLocaleString() : '—';
|
|
209
|
+
const fmtTs = (ts) => Number.isFinite(ts) && ts > 0 ? new Date(ts).toLocaleString() : '—';
|
|
210
|
+
let systemChart, tokensChart, dailyChart;
|
|
211
|
+
|
|
212
|
+
function draw(payload) {
|
|
213
|
+
document.getElementById('log-path').textContent = payload.logPath || '—';
|
|
214
|
+
document.getElementById('log-size').textContent = payload.logSizeHuman || '0 B';
|
|
215
|
+
document.getElementById('sample-count').textContent = fmt(payload.sampleCount || 0);
|
|
216
|
+
document.getElementById('last-sample').textContent = fmtTs(payload.latestTs);
|
|
217
|
+
|
|
218
|
+
const labels = (payload.series || []).map((r) => new Date(r.ts).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'}));
|
|
219
|
+
const cpu = (payload.series || []).map((r) => r.cpu ?? 0);
|
|
220
|
+
const memory = (payload.series || []).map((r) => r.memory ?? 0);
|
|
221
|
+
const gpu = (payload.series || []).map((r) => r.gpu ?? 0);
|
|
222
|
+
const tokens = (payload.series || []).map((r) => r.tokens ?? 0);
|
|
223
|
+
const dayLabels = (payload.tokenDaily || []).map((r) => r.day);
|
|
224
|
+
const dayTokens = (payload.tokenDaily || []).map((r) => r.tokens);
|
|
225
|
+
|
|
226
|
+
if (systemChart) systemChart.destroy();
|
|
227
|
+
if (tokensChart) tokensChart.destroy();
|
|
228
|
+
if (dailyChart) dailyChart.destroy();
|
|
229
|
+
|
|
230
|
+
systemChart = new Chart(document.getElementById('system-chart'), {
|
|
231
|
+
type: 'line',
|
|
232
|
+
data: {
|
|
233
|
+
labels,
|
|
234
|
+
datasets: [
|
|
235
|
+
{ label: 'CPU', data: cpu, borderColor: '#00c853', tension: 0.2, pointRadius: 0 },
|
|
236
|
+
{ label: 'Memory', data: memory, borderColor: '#2979ff', tension: 0.2, pointRadius: 0 },
|
|
237
|
+
{ label: 'GPU', data: gpu, borderColor: '#a855f7', tension: 0.2, pointRadius: 0 }
|
|
238
|
+
]
|
|
239
|
+
},
|
|
240
|
+
options: { responsive:true, plugins:{legend:{labels:{color:'#d8defa'}}}, scales:{x:{ticks:{color:'#9aa2d8'}}, y:{min:0,max:100,ticks:{color:'#9aa2d8'}}} }
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
tokensChart = new Chart(document.getElementById('tokens-chart'), {
|
|
244
|
+
type: 'line',
|
|
245
|
+
data: { labels, datasets: [{ label: 'Tokens/min', data: tokens, borderColor: '#ff7a18', tension: 0.2, pointRadius: 0 }] },
|
|
246
|
+
options: { responsive:true, plugins:{legend:{labels:{color:'#d8defa'}}}, scales:{x:{ticks:{color:'#9aa2d8'}}, y:{ticks:{color:'#9aa2d8'}}} }
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
dailyChart = new Chart(document.getElementById('daily-chart'), {
|
|
250
|
+
type: 'bar',
|
|
251
|
+
data: { labels: dayLabels, datasets: [{ label: 'Tokens/day', data: dayTokens, backgroundColor: '#22d3ee' }] },
|
|
252
|
+
options: { responsive:true, plugins:{legend:{labels:{color:'#d8defa'}}}, scales:{x:{ticks:{color:'#9aa2d8'}}, y:{ticks:{color:'#9aa2d8'}}} }
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function refresh() {
|
|
257
|
+
const response = await fetch('/api/local-status', { cache: 'no-store' });
|
|
258
|
+
const payload = await response.json();
|
|
259
|
+
draw(payload);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
refresh();
|
|
263
|
+
setInterval(refresh, 10000);
|
|
264
|
+
</script>
|
|
265
|
+
</body>
|
|
266
|
+
</html>`
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function openUrl(url) {
|
|
270
|
+
try {
|
|
271
|
+
if (process.platform === 'darwin') {
|
|
272
|
+
spawnSync('open', [url], { stdio: 'ignore' })
|
|
273
|
+
} else if (process.platform === 'win32') {
|
|
274
|
+
spawnSync('cmd', ['/c', 'start', '', url], { stdio: 'ignore' })
|
|
275
|
+
} else {
|
|
276
|
+
spawnSync('xdg-open', [url], { stdio: 'ignore' })
|
|
277
|
+
}
|
|
278
|
+
} catch {
|
|
279
|
+
// no-op
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function runLocalDashboard({ host }) {
|
|
284
|
+
const logPath = resolveDashboardLogPath(host)
|
|
285
|
+
const portRaw = Number(process.env.IDLEWATCH_DASHBOARD_PORT || 4373)
|
|
286
|
+
const port = Number.isFinite(portRaw) && portRaw > 0 ? portRaw : 4373
|
|
287
|
+
|
|
288
|
+
const server = http.createServer((req, res) => {
|
|
289
|
+
if (req.url === '/api/local-status') {
|
|
290
|
+
const payload = buildLocalDashboardPayload(logPath)
|
|
291
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
292
|
+
res.end(JSON.stringify(payload))
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (req.url === '/health') {
|
|
297
|
+
const payload = buildLocalDashboardPayload(logPath)
|
|
298
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
299
|
+
res.end(JSON.stringify({ ok: true, logPath: payload.logPath, samples: payload.sampleCount }))
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
|
304
|
+
res.end(renderLocalDashboardHtml())
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
server.listen(port, '127.0.0.1', () => {
|
|
308
|
+
const url = `http://127.0.0.1:${port}`
|
|
309
|
+
const payload = buildLocalDashboardPayload(logPath)
|
|
310
|
+
console.log(`idlewatch local dashboard ready: ${url}`)
|
|
311
|
+
console.log(`log file: ${payload.logPath} (${payload.logSizeHuman})`)
|
|
312
|
+
openUrl(url)
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
server.on('error', (err) => {
|
|
316
|
+
console.error(`Local dashboard failed: ${err.message}`)
|
|
317
|
+
process.exit(1)
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
|
|
57
321
|
const argv = process.argv.slice(2)
|
|
58
322
|
const quickstartRequested = argv[0] === 'quickstart' || argv.includes('--quickstart')
|
|
323
|
+
const dashboardRequested = argv[0] === 'dashboard' || argv.includes('--dashboard')
|
|
59
324
|
const args = new Set(argv)
|
|
60
325
|
if (args.has('--help') || args.has('-h')) {
|
|
61
326
|
printHelp()
|
|
62
327
|
process.exit(0)
|
|
63
328
|
}
|
|
64
329
|
|
|
330
|
+
if (dashboardRequested) {
|
|
331
|
+
const host = process.env.IDLEWATCH_HOST || os.hostname()
|
|
332
|
+
runLocalDashboard({ host })
|
|
333
|
+
await new Promise(() => {})
|
|
334
|
+
}
|
|
335
|
+
|
|
65
336
|
if (quickstartRequested) {
|
|
66
337
|
try {
|
|
67
338
|
const result = await runEnrollmentWizard()
|
|
@@ -111,6 +382,9 @@ const REQUIRE_FIREBASE_WRITES = process.env.IDLEWATCH_REQUIRE_FIREBASE_WRITES ==
|
|
|
111
382
|
const CLOUD_INGEST_URL = process.env.IDLEWATCH_CLOUD_INGEST_URL
|
|
112
383
|
const CLOUD_API_KEY = process.env.IDLEWATCH_CLOUD_API_KEY
|
|
113
384
|
const REQUIRE_CLOUD_WRITES = process.env.IDLEWATCH_REQUIRE_CLOUD_WRITES === '1'
|
|
385
|
+
let cloudIngestKickedOut = false
|
|
386
|
+
let cloudIngestKickoutReason = null
|
|
387
|
+
let cloudIngestKickoutNotified = false
|
|
114
388
|
const OPENCLAW_PROBE_TIMEOUT_MS = Number(process.env.IDLEWATCH_OPENCLAW_PROBE_TIMEOUT_MS || 2500)
|
|
115
389
|
const OPENCLAW_PROBE_MAX_OUTPUT_BYTES = process.env.IDLEWATCH_OPENCLAW_MAX_OUTPUT_BYTES
|
|
116
390
|
? Number(process.env.IDLEWATCH_OPENCLAW_MAX_OUTPUT_BYTES)
|
|
@@ -322,6 +596,21 @@ function ensureDirFor(filePath) {
|
|
|
322
596
|
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
323
597
|
}
|
|
324
598
|
|
|
599
|
+
function getLocalLogUsage() {
|
|
600
|
+
try {
|
|
601
|
+
const stat = fs.statSync(LOCAL_LOG_PATH)
|
|
602
|
+
return {
|
|
603
|
+
path: LOCAL_LOG_PATH,
|
|
604
|
+
bytes: Number(stat.size || 0)
|
|
605
|
+
}
|
|
606
|
+
} catch {
|
|
607
|
+
return {
|
|
608
|
+
path: LOCAL_LOG_PATH,
|
|
609
|
+
bytes: 0
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
325
614
|
function appendLocal(row) {
|
|
326
615
|
try {
|
|
327
616
|
ensureDirFor(LOCAL_LOG_PATH)
|
|
@@ -329,6 +618,7 @@ function appendLocal(row) {
|
|
|
329
618
|
} catch (err) {
|
|
330
619
|
console.error(`Local log append failed (${LOCAL_LOG_PATH}): ${err.message}`)
|
|
331
620
|
}
|
|
621
|
+
return getLocalLogUsage()
|
|
332
622
|
}
|
|
333
623
|
|
|
334
624
|
function snapshotCpuTimes() {
|
|
@@ -789,6 +1079,8 @@ async function publish(row, retries = 2) {
|
|
|
789
1079
|
if (DRY_RUN) return false
|
|
790
1080
|
|
|
791
1081
|
if (CLOUD_INGEST_URL && CLOUD_API_KEY) {
|
|
1082
|
+
if (cloudIngestKickedOut) return false
|
|
1083
|
+
|
|
792
1084
|
let attempt = 0
|
|
793
1085
|
while (attempt <= retries) {
|
|
794
1086
|
try {
|
|
@@ -800,11 +1092,31 @@ async function publish(row, retries = 2) {
|
|
|
800
1092
|
},
|
|
801
1093
|
body: JSON.stringify(row)
|
|
802
1094
|
})
|
|
1095
|
+
|
|
803
1096
|
if (!response.ok) {
|
|
1097
|
+
let detail = null
|
|
1098
|
+
try {
|
|
1099
|
+
const payload = await response.json()
|
|
1100
|
+
detail = payload?.detail || payload?.error || payload?.message || null
|
|
1101
|
+
} catch {
|
|
1102
|
+
try {
|
|
1103
|
+
detail = (await response.text())?.slice(0, 180) || null
|
|
1104
|
+
} catch {
|
|
1105
|
+
detail = null
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
if (response.status === 401 || response.status === 403) {
|
|
1110
|
+
cloudIngestKickedOut = true
|
|
1111
|
+
cloudIngestKickoutReason = detail || `http_${response.status}`
|
|
1112
|
+
return false
|
|
1113
|
+
}
|
|
1114
|
+
|
|
804
1115
|
throw new Error(`cloud_ingest_failed_${response.status}`)
|
|
805
1116
|
}
|
|
806
1117
|
return true
|
|
807
1118
|
} catch (err) {
|
|
1119
|
+
if (cloudIngestKickedOut) throw err
|
|
808
1120
|
if (attempt >= retries) throw err
|
|
809
1121
|
await new Promise((resolve) => setTimeout(resolve, 300 * (attempt + 1)))
|
|
810
1122
|
attempt += 1
|
|
@@ -951,7 +1263,11 @@ async function collectSample() {
|
|
|
951
1263
|
usageStaleMsThreshold: USAGE_STALE_MS,
|
|
952
1264
|
usageNearStaleMsThreshold: USAGE_NEAR_STALE_MS,
|
|
953
1265
|
usageStaleGraceMs: USAGE_STALE_GRACE_MS,
|
|
954
|
-
memPressureSource: memPressure.source
|
|
1266
|
+
memPressureSource: memPressure.source,
|
|
1267
|
+
cloudIngestionStatus: CLOUD_INGEST_URL && CLOUD_API_KEY
|
|
1268
|
+
? cloudIngestKickedOut ? 'kicked-out' : 'enabled'
|
|
1269
|
+
: 'disabled',
|
|
1270
|
+
cloudIngestionReason: cloudIngestKickoutReason
|
|
955
1271
|
}
|
|
956
1272
|
|
|
957
1273
|
const usageAlert = deriveUsageAlert(source, { usageAgeMs: usageFreshness.usageAgeMs, idleAfterMs: USAGE_IDLE_AFTER_MS })
|
|
@@ -977,6 +1293,8 @@ async function collectSample() {
|
|
|
977
1293
|
openclawAgentId: MONITOR_OPENCLAW ? (usage?.agentId ?? null) : null,
|
|
978
1294
|
openclawUsageTs: MONITOR_OPENCLAW ? (usage?.usageTimestampMs ?? null) : null,
|
|
979
1295
|
openclawUsageAgeMs: MONITOR_OPENCLAW ? usageFreshness.usageAgeMs : null,
|
|
1296
|
+
localLogPath: LOCAL_LOG_PATH,
|
|
1297
|
+
localLogBytes: null,
|
|
980
1298
|
source
|
|
981
1299
|
}
|
|
982
1300
|
|
|
@@ -990,13 +1308,31 @@ async function collectSample() {
|
|
|
990
1308
|
|
|
991
1309
|
async function tick() {
|
|
992
1310
|
const row = await collectSample()
|
|
1311
|
+
const localUsage = appendLocal(row)
|
|
1312
|
+
row.localLogPath = localUsage.path
|
|
1313
|
+
row.localLogBytes = localUsage.bytes
|
|
1314
|
+
|
|
993
1315
|
console.log(JSON.stringify(row))
|
|
994
|
-
|
|
1316
|
+
|
|
995
1317
|
const published = await publish(row)
|
|
1318
|
+
|
|
1319
|
+
if (cloudIngestKickedOut && !cloudIngestKickoutNotified) {
|
|
1320
|
+
cloudIngestKickoutNotified = true
|
|
1321
|
+
console.error(
|
|
1322
|
+
`Cloud ingest disabled: API key rejected (${cloudIngestKickoutReason || 'unauthorized'}). Run idlewatch quickstart to link a new key.`
|
|
1323
|
+
)
|
|
1324
|
+
}
|
|
1325
|
+
|
|
996
1326
|
if (REQUIRE_FIREBASE_WRITES && ONCE && !published) {
|
|
997
1327
|
throw new Error('Firebase write was required but not executed. Check Firebase configuration and connectivity.')
|
|
998
1328
|
}
|
|
1329
|
+
|
|
999
1330
|
if (REQUIRE_CLOUD_WRITES && ONCE && !published) {
|
|
1331
|
+
if (cloudIngestKickedOut) {
|
|
1332
|
+
throw new Error(
|
|
1333
|
+
`Cloud API key was rejected (${cloudIngestKickoutReason || 'unauthorized'}). This device was disconnected. Run idlewatch quickstart with a new API key.`
|
|
1334
|
+
)
|
|
1335
|
+
}
|
|
1000
1336
|
throw new Error('Cloud write was required but not executed. Check API key and cloud connectivity.')
|
|
1001
1337
|
}
|
|
1002
1338
|
}
|
package/package.json
CHANGED
package/tui/src/main.rs
CHANGED
|
@@ -10,7 +10,7 @@ use ratatui::{
|
|
|
10
10
|
};
|
|
11
11
|
use std::{
|
|
12
12
|
fs,
|
|
13
|
-
io
|
|
13
|
+
io,
|
|
14
14
|
path::{Path, PathBuf},
|
|
15
15
|
process::{Command, Stdio},
|
|
16
16
|
time::Duration,
|
|
@@ -127,8 +127,8 @@ fn render_mode_menu(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, selec
|
|
|
127
127
|
if i == selected {
|
|
128
128
|
ListItem::new(format!("❯ {}", item)).style(
|
|
129
129
|
Style::default()
|
|
130
|
-
.fg(Color::
|
|
131
|
-
.bg(Color::
|
|
130
|
+
.fg(Color::White)
|
|
131
|
+
.bg(Color::Blue)
|
|
132
132
|
.add_modifier(Modifier::BOLD),
|
|
133
133
|
)
|
|
134
134
|
} else {
|
|
@@ -199,8 +199,8 @@ fn render_monitor_menu(
|
|
|
199
199
|
if idx == cursor {
|
|
200
200
|
ListItem::new(format!("❯ {}", line)).style(
|
|
201
201
|
Style::default()
|
|
202
|
-
.fg(Color::
|
|
203
|
-
.bg(Color::
|
|
202
|
+
.fg(Color::White)
|
|
203
|
+
.bg(Color::Blue)
|
|
204
204
|
.add_modifier(Modifier::BOLD),
|
|
205
205
|
)
|
|
206
206
|
} else if target.available {
|
|
@@ -227,12 +227,57 @@ fn render_monitor_menu(
|
|
|
227
227
|
Ok(())
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
-
fn
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
230
|
+
fn render_api_key_prompt(
|
|
231
|
+
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
|
232
|
+
input: &str,
|
|
233
|
+
) -> Result<()> {
|
|
234
|
+
terminal.draw(|f| {
|
|
235
|
+
let chunks = Layout::default()
|
|
236
|
+
.direction(Direction::Vertical)
|
|
237
|
+
.margin(1)
|
|
238
|
+
.constraints([
|
|
239
|
+
Constraint::Length(4),
|
|
240
|
+
Constraint::Length(6),
|
|
241
|
+
Constraint::Min(1),
|
|
242
|
+
])
|
|
243
|
+
.split(f.area());
|
|
244
|
+
|
|
245
|
+
let header = Paragraph::new("Link this device to your IdleWatch account")
|
|
246
|
+
.style(Style::default().fg(Color::LightMagenta).add_modifier(Modifier::BOLD))
|
|
247
|
+
.block(
|
|
248
|
+
Block::default()
|
|
249
|
+
.borders(Borders::ALL)
|
|
250
|
+
.title("Cloud API Key")
|
|
251
|
+
.border_style(Style::default().fg(Color::Magenta)),
|
|
252
|
+
);
|
|
253
|
+
f.render_widget(header, chunks[0]);
|
|
254
|
+
|
|
255
|
+
let content = if input.trim().is_empty() {
|
|
256
|
+
"Paste your API key from idlewatch.com/api"
|
|
257
|
+
} else {
|
|
258
|
+
input
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
let input_style = if input.trim().is_empty() {
|
|
262
|
+
Style::default().fg(Color::DarkGray)
|
|
263
|
+
} else {
|
|
264
|
+
Style::default().fg(Color::White)
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
let input_box = Paragraph::new(content).style(input_style).block(
|
|
268
|
+
Block::default()
|
|
269
|
+
.borders(Borders::ALL)
|
|
270
|
+
.title("API key")
|
|
271
|
+
.border_style(Style::default().fg(Color::Cyan)),
|
|
272
|
+
);
|
|
273
|
+
f.render_widget(input_box, chunks[1]);
|
|
274
|
+
|
|
275
|
+
let help = Paragraph::new("Paste key • Backspace edit • Enter continue • q quit")
|
|
276
|
+
.style(Style::default().fg(Color::LightMagenta).add_modifier(Modifier::BOLD));
|
|
277
|
+
f.render_widget(help, chunks[2]);
|
|
278
|
+
})?;
|
|
279
|
+
|
|
280
|
+
Ok(())
|
|
236
281
|
}
|
|
237
282
|
|
|
238
283
|
fn main() -> Result<()> {
|
|
@@ -307,14 +352,48 @@ fn main() -> Result<()> {
|
|
|
307
352
|
}
|
|
308
353
|
}
|
|
309
354
|
|
|
310
|
-
disable_raw_mode()?;
|
|
311
|
-
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
|
312
|
-
|
|
313
355
|
let mode = match selected_mode {
|
|
314
356
|
0 => "production",
|
|
315
357
|
_ => "local",
|
|
316
358
|
};
|
|
317
359
|
|
|
360
|
+
let mut cloud_api_key_input = String::new();
|
|
361
|
+
if mode == "production" {
|
|
362
|
+
loop {
|
|
363
|
+
render_api_key_prompt(&mut terminal, &cloud_api_key_input)?;
|
|
364
|
+
if event::poll(Duration::from_millis(250))? {
|
|
365
|
+
match event::read()? {
|
|
366
|
+
Event::Key(key) => match key.code {
|
|
367
|
+
KeyCode::Enter => {
|
|
368
|
+
if !cloud_api_key_input.trim().is_empty() {
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
KeyCode::Backspace => {
|
|
373
|
+
cloud_api_key_input.pop();
|
|
374
|
+
}
|
|
375
|
+
KeyCode::Char('q') | KeyCode::Esc => {
|
|
376
|
+
disable_raw_mode()?;
|
|
377
|
+
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
|
378
|
+
return Ok(());
|
|
379
|
+
}
|
|
380
|
+
KeyCode::Char(c) => {
|
|
381
|
+
cloud_api_key_input.push(c);
|
|
382
|
+
}
|
|
383
|
+
_ => {}
|
|
384
|
+
},
|
|
385
|
+
Event::Paste(text) => {
|
|
386
|
+
cloud_api_key_input.push_str(&text);
|
|
387
|
+
}
|
|
388
|
+
_ => {}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
disable_raw_mode()?;
|
|
395
|
+
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
|
396
|
+
|
|
318
397
|
let selected_keys = monitor_targets
|
|
319
398
|
.iter()
|
|
320
399
|
.filter(|item| item.selected && item.available)
|
|
@@ -351,12 +430,12 @@ fn main() -> Result<()> {
|
|
|
351
430
|
}
|
|
352
431
|
|
|
353
432
|
if mode == "production" {
|
|
354
|
-
let api_key =
|
|
355
|
-
if api_key.
|
|
433
|
+
let api_key = cloud_api_key_input.trim().to_string();
|
|
434
|
+
if api_key.is_empty() {
|
|
356
435
|
return Err(anyhow!("cloud API key is required"));
|
|
357
436
|
}
|
|
358
437
|
env_lines.push("IDLEWATCH_CLOUD_INGEST_URL=https://api.idlewatch.com/api/ingest".to_string());
|
|
359
|
-
env_lines.push(format!("IDLEWATCH_CLOUD_API_KEY={}", api_key
|
|
438
|
+
env_lines.push(format!("IDLEWATCH_CLOUD_API_KEY={}", api_key));
|
|
360
439
|
env_lines.push("IDLEWATCH_REQUIRE_CLOUD_WRITES=1".to_string());
|
|
361
440
|
}
|
|
362
441
|
|