Files
immich-frame/tools/log-viewer.html
cottongin 7fa74c03c1 feat: add deep sleep batch caching, wake log, and log viewer tools
- Batch-fetch asset IDs in deep sleep mode with NVS caching to reduce
  network calls and improve battery life (configurable batch size 1-50)
- Add wake_log module: persistent CSV log of each photo cycle surviving
  reboots and deep sleep, with download/clear via web UI
- Add browser-based log viewer and log comparison tools (tools/)
- Update .gitignore to exclude .cursor/, chat-summaries/, wake_*.csv,
  and .DS_Store

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 12:59:03 -04:00

433 lines
18 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PaperColor Wake Log Viewer</title>
<script src="./vendor/chart.umd.min.js"></script>
<script src="./vendor/chartjs-adapter-date-fns.bundle.min.js"></script>
<style>
:root {
--bg: #f8fafc;
--card: #ffffff;
--text: #1e293b;
--muted: #64748b;
--border: #e2e8f0;
--accent: #2563eb;
--success: #16a34a;
--danger: #dc2626;
--warn: #d97706;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f172a;
--card: #1e293b;
--text: #f1f5f9;
--muted: #94a3b8;
--border: #334155;
--accent: #60a5fa;
--success: #4ade80;
--danger: #f87171;
--warn: #fbbf24;
}
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
padding: 24px;
}
h1 { font-size: 22px; margin-bottom: 4px; }
h2 { font-size: 16px; color: var(--muted); margin-bottom: 16px; }
h3 { font-size: 14px; color: var(--muted); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; }
.drop-zone {
border: 2px dashed var(--border);
border-radius: 12px;
padding: 48px;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
margin-bottom: 24px;
}
.drop-zone:hover, .drop-zone.dragover {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 5%, transparent);
}
.drop-zone input { display: none; }
.drop-zone p { color: var(--muted); font-size: 15px; }
.drop-zone p strong { color: var(--text); }
.hidden { display: none !important; }
.summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 24px;
}
.stat-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
}
.stat-card .label { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; }
.stat-card .value { font-size: 20px; font-weight: 600; margin-top: 2px; }
.filters {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
}
.filters label { font-size: 13px; color: var(--muted); display: flex; align-items: center; gap: 4px; cursor: pointer; }
.filters input[type="checkbox"] { width: 16px; height: 16px; }
.chart-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 24px;
}
@media (max-width: 900px) { .chart-grid { grid-template-columns: 1fr; } }
.chart-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
}
.chart-card canvas { width: 100% !important; height: 200px !important; }
.table-wrap {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
overflow-x: auto;
margin-bottom: 24px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
white-space: nowrap;
}
th, td { padding: 6px 10px; text-align: left; border-bottom: 1px solid var(--border); }
th {
background: var(--bg);
position: sticky;
top: 0;
cursor: pointer;
user-select: none;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.3px;
color: var(--muted);
}
th:hover { color: var(--text); }
th .sort-arrow { margin-left: 4px; font-size: 10px; }
.result-ok { color: var(--success); font-weight: 600; }
.result-fail { color: var(--danger); font-weight: 600; }
.wake-sleep { color: var(--warn); }
.wake-normal { color: var(--accent); }
.id-cell { max-width: 100px; overflow: hidden; text-overflow: ellipsis; }
</style>
</head>
<body>
<h1>PaperColor Wake Log Viewer</h1>
<h2>Drop or select a wake_log.csv file to visualize</h2>
<div class="drop-zone" id="dropZone">
<p><strong>Drop wake_log.csv here</strong> or click to browse</p>
<input type="file" id="fileInput" accept=".csv,text/csv">
</div>
<div id="viewer" class="hidden">
<div class="summary" id="summary"></div>
<div class="filters" id="filters">
<label><input type="checkbox" id="filterNormal" checked> Normal</label>
<label><input type="checkbox" id="filterSleep" checked> Sleep</label>
<label><input type="checkbox" id="filterOk" checked> OK</label>
<label><input type="checkbox" id="filterFail" checked> Failures</label>
</div>
<div class="chart-grid" id="charts">
<div class="chart-card"><h3>Battery %</h3><canvas id="chartBattery"></canvas></div>
<div class="chart-card"><h3>WiFi RSSI (dBm)</h3><canvas id="chartRssi"></canvas></div>
<div class="chart-card"><h3>Free Memory (KB)</h3><canvas id="chartMemory"></canvas></div>
<div class="chart-card"><h3>Cycle Duration (ms)</h3><canvas id="chartCycle"></canvas></div>
</div>
<div class="table-wrap">
<table>
<thead id="tableHead"></thead>
<tbody id="tableBody"></tbody>
</table>
</div>
</div>
<script>
const COLUMNS = [
'timestamp','battery_pct','free_psram_kb','free_heap_kb','wifi_rssi',
'wifi_ms','cycle_ms','rtc_valid','boot_count','filter',
'asset_id','photo_date','result','wake_type','last_error_ts','errors_total'
];
let allRows = [];
let sortCol = 0;
let sortAsc = false;
let charts = {};
// --- File input ---
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('dragover'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('dragover');
if (e.dataTransfer.files.length) loadFile(e.dataTransfer.files[0]);
});
fileInput.addEventListener('change', e => { if (e.target.files.length) loadFile(e.target.files[0]); });
function loadFile(file) {
const reader = new FileReader();
reader.onload = e => {
const text = e.target.result.trim();
if (!text) return;
parseCSV(text);
document.getElementById('viewer').classList.remove('hidden');
dropZone.classList.add('hidden');
render();
};
reader.readAsText(file);
}
function parseCSV(text) {
const lines = text.split('\n').filter(l => l.trim());
allRows = lines.map(line => {
const parts = line.split(',');
return {
timestamp: parts[0] || '',
battery_pct: parseInt(parts[1]) || 0,
free_psram_kb: parseInt(parts[2]) || 0,
free_heap_kb: parseInt(parts[3]) || 0,
wifi_rssi: parseInt(parts[4]) || 0,
wifi_ms: parseInt(parts[5]) || 0,
cycle_ms: parseInt(parts[6]) || 0,
rtc_valid: parts[7] === '1',
boot_count: parseInt(parts[8]) || 0,
filter: parts[9] || '',
asset_id: parts[10] || '',
photo_date: parts[11] || '',
result: parts[12] || '',
wake_type: parts[13] || '',
last_error_ts: parts[14] || '',
errors_total: parseInt(parts[15]) || 0,
};
});
}
function getFiltered() {
const showNormal = document.getElementById('filterNormal').checked;
const showSleep = document.getElementById('filterSleep').checked;
const showOk = document.getElementById('filterOk').checked;
const showFail = document.getElementById('filterFail').checked;
return allRows.filter(r => {
if (r.wake_type === 'normal' && !showNormal) return false;
if (r.wake_type === 'sleep' && !showSleep) return false;
if (r.result === 'ok' && !showOk) return false;
if (r.result !== 'ok' && !showFail) return false;
return true;
});
}
// --- Filters ---
document.querySelectorAll('.filters input').forEach(el => {
el.addEventListener('change', render);
});
// --- Render ---
function render() {
const rows = getFiltered();
renderSummary(rows);
renderCharts(rows);
renderTable(rows);
}
function renderSummary(rows) {
if (rows.length === 0) {
document.getElementById('summary').innerHTML = '<div class="stat-card"><div class="value">No data</div></div>';
return;
}
const first = rows[0].timestamp;
const last = rows[rows.length - 1].timestamp;
const okCount = rows.filter(r => r.result === 'ok').length;
const successRate = ((okCount / rows.length) * 100).toFixed(1);
const avgBatt = (rows.reduce((s, r) => s + r.battery_pct, 0) / rows.length).toFixed(0);
const rssiVals = rows.map(r => r.wifi_rssi).filter(v => v !== 0);
const minRssi = rssiVals.length ? Math.min(...rssiVals) : 'N/A';
const maxRssi = rssiVals.length ? Math.max(...rssiVals) : 'N/A';
const bootMin = Math.min(...rows.map(r => r.boot_count));
const bootMax = Math.max(...rows.map(r => r.boot_count));
const totalErrors = rows.length > 0 ? rows[rows.length - 1].errors_total : 0;
const formatTs = ts => {
if (!ts || ts === 'no_clock') return ts;
try { return new Date(ts).toLocaleString(); } catch { return ts; }
};
document.getElementById('summary').innerHTML = `
<div class="stat-card"><div class="label">Entries</div><div class="value">${rows.length}</div></div>
<div class="stat-card"><div class="label">Success Rate</div><div class="value">${successRate}%</div></div>
<div class="stat-card"><div class="label">Avg Battery</div><div class="value">${avgBatt}%</div></div>
<div class="stat-card"><div class="label">RSSI Range</div><div class="value">${maxRssi} / ${minRssi} dBm</div></div>
<div class="stat-card"><div class="label">Boot Count</div><div class="value">${bootMin}–${bootMax}</div></div>
<div class="stat-card"><div class="label">Total Errors</div><div class="value">${totalErrors}</div></div>
<div class="stat-card"><div class="label">First Entry</div><div class="value" style="font-size:13px">${formatTs(first)}</div></div>
<div class="stat-card"><div class="label">Last Entry</div><div class="value" style="font-size:13px">${formatTs(last)}</div></div>
`;
}
function makeDatasets(rows, key, label, color) {
const normalPts = [];
const sleepPts = [];
const failPts = [];
rows.forEach(r => {
const pt = { x: new Date(r.timestamp), y: r[key] };
if (r.result !== 'ok') failPts.push(pt);
else if (r.wake_type === 'sleep') sleepPts.push(pt);
else normalPts.push(pt);
});
return [
{ label: label + ' (normal)', data: normalPts, borderColor: '#2563eb', backgroundColor: '#2563eb88', pointRadius: 3, showLine: true, tension: 0.3 },
{ label: label + ' (sleep)', data: sleepPts, borderColor: '#d97706', backgroundColor: '#d9770688', pointRadius: 3, showLine: true, tension: 0.3 },
{ label: label + ' (fail)', data: failPts, borderColor: '#dc2626', backgroundColor: '#dc262688', pointRadius: 5, pointStyle: 'crossRot', showLine: false },
];
}
function chartOpts(yLabel) {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const gridColor = isDark ? '#334155' : '#e2e8f0';
const textColor = isDark ? '#94a3b8' : '#64748b';
return {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'nearest', intersect: false },
plugins: { legend: { display: true, labels: { boxWidth: 10, font: { size: 11 }, color: textColor } } },
scales: {
x: { type: 'time', time: { tooltipFormat: 'MMM d, HH:mm' }, grid: { color: gridColor }, ticks: { color: textColor, font: { size: 10 } } },
y: { title: { display: true, text: yLabel, color: textColor, font: { size: 11 } }, grid: { color: gridColor }, ticks: { color: textColor, font: { size: 10 } } }
}
};
}
function renderCharts(rows) {
Object.values(charts).forEach(c => c.destroy());
charts = {};
const validRows = rows.filter(r => r.timestamp && r.timestamp !== 'no_clock');
charts.battery = new Chart(document.getElementById('chartBattery'), {
type: 'scatter',
data: { datasets: makeDatasets(validRows, 'battery_pct', 'Battery', '#2563eb') },
options: chartOpts('Battery %')
});
charts.rssi = new Chart(document.getElementById('chartRssi'), {
type: 'scatter',
data: { datasets: makeDatasets(validRows, 'wifi_rssi', 'RSSI', '#2563eb') },
options: chartOpts('dBm')
});
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const gridColor = isDark ? '#334155' : '#e2e8f0';
const textColor = isDark ? '#94a3b8' : '#64748b';
const memNormal = [], memSleep = [];
const heapNormal = [], heapSleep = [];
validRows.forEach(r => {
const pt1 = { x: new Date(r.timestamp), y: r.free_psram_kb };
const pt2 = { x: new Date(r.timestamp), y: r.free_heap_kb };
if (r.wake_type === 'sleep') { memSleep.push(pt1); heapSleep.push(pt2); }
else { memNormal.push(pt1); heapNormal.push(pt2); }
});
charts.memory = new Chart(document.getElementById('chartMemory'), {
type: 'scatter',
data: { datasets: [
{ label: 'PSRAM (normal)', data: memNormal, borderColor: '#2563eb', backgroundColor: '#2563eb88', pointRadius: 3, showLine: true, tension: 0.3 },
{ label: 'PSRAM (sleep)', data: memSleep, borderColor: '#d97706', backgroundColor: '#d9770688', pointRadius: 3, showLine: true, tension: 0.3 },
{ label: 'Heap (normal)', data: heapNormal, borderColor: '#16a34a', backgroundColor: '#16a34a88', pointRadius: 3, showLine: true, tension: 0.3 },
{ label: 'Heap (sleep)', data: heapSleep, borderColor: '#9333ea', backgroundColor: '#9333ea88', pointRadius: 3, showLine: true, tension: 0.3 },
]},
options: chartOpts('KB')
});
charts.cycle = new Chart(document.getElementById('chartCycle'), {
type: 'scatter',
data: { datasets: makeDatasets(validRows, 'cycle_ms', 'Cycle', '#2563eb') },
options: chartOpts('ms')
});
}
function renderTable(rows) {
const thead = document.getElementById('tableHead');
const tbody = document.getElementById('tableBody');
thead.innerHTML = '<tr>' + COLUMNS.map((col, i) =>
`<th onclick="sortTable(${i})">${col.replace(/_/g, ' ')}<span class="sort-arrow">${sortCol === i ? (sortAsc ? '▲' : '▼') : ''}</span></th>`
).join('') + '</tr>';
const sorted = [...rows].sort((a, b) => {
const key = COLUMNS[sortCol];
let av = a[key], bv = b[key];
if (typeof av === 'number') return sortAsc ? av - bv : bv - av;
av = String(av); bv = String(bv);
return sortAsc ? av.localeCompare(bv) : bv.localeCompare(av);
});
tbody.innerHTML = sorted.map(r => {
const resultClass = r.result === 'ok' ? 'result-ok' : 'result-fail';
const wakeClass = r.wake_type === 'sleep' ? 'wake-sleep' : 'wake-normal';
const shortTs = r.timestamp && r.timestamp !== 'no_clock'
? new Date(r.timestamp).toLocaleString(undefined, { month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' })
: r.timestamp;
const shortId = r.asset_id ? r.asset_id.substring(0, 8) + '...' : '';
const shortPhotoDate = r.photo_date && r.photo_date !== ''
? r.photo_date.substring(0, 10)
: '';
return `<tr>
<td>${shortTs}</td>
<td>${r.battery_pct}%</td>
<td>${r.free_psram_kb}</td>
<td>${r.free_heap_kb}</td>
<td>${r.wifi_rssi}</td>
<td>${r.wifi_ms}</td>
<td>${r.cycle_ms}</td>
<td>${r.rtc_valid ? '✓' : '✗'}</td>
<td>${r.boot_count}</td>
<td>${r.filter}</td>
<td class="id-cell" title="${r.asset_id}">${shortId}</td>
<td>${shortPhotoDate}</td>
<td class="${resultClass}">${r.result}</td>
<td class="${wakeClass}">${r.wake_type}</td>
<td>${r.last_error_ts}</td>
<td>${r.errors_total}</td>
</tr>`;
}).join('');
}
window.sortTable = function(col) {
if (sortCol === col) sortAsc = !sortAsc;
else { sortCol = col; sortAsc = true; }
render();
};
</script>
</body>
</html>