Files
core/internal/api/ui_index.tmpl
Michael Chus abb3f10f3c Add UI enhancements: charts, firmware summary, uninstalled components, source chips
- Dashboard: line charts (assets over time, components total + uninstalled)
  with filled areas, shared x-axis (Mon YYYY), auto-formatted y-labels (1k/1M)
  and global start date derived from earliest FirstSeenAt across components
- /ui/ingest/history: source type chips (Ingest / CSV Import / Manual / System)
- /ui/component/models: firmware version count column, column filters,
  sortable headers, vendor distribution pie chart
- /ui/component/{vendor}/{model}: firmware version summary table with
  per-version healthy/unknown/failed counts, failed rows highlighted
- /ui/component/uninstalled: new page + nav item; components not installed
  on any server, two-level grouping by vendor then model (collapsed by default)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 21:41:48 +03:00

299 lines
11 KiB
Cheetah

{{define "index"}}
<!DOCTYPE html>
<html lang="en">
{{template "head" .}}
<body>
{{template "topbar" .}}
<main class="container">
<section class="card">
<h2>Registry Snapshot</h2>
<div class="stats">
<div class="stat"><span>Assets</span><strong>{{.AssetCount}}</strong></div>
<div class="stat"><span>Components</span><strong>{{.ComponentCount}}</strong></div>
</div>
</section>
{{if .AssetsDayChartJSON}}
<section class="card">
<h2>Assets Over Time</h2>
<canvas id="assets-chart" height="160"></canvas>
</section>
{{end}}
{{if .ComponentsDayChartJSON}}
<section class="card">
<h2>Components Over Time</h2>
<div class="meta" style="margin-bottom:8px;">
<span style="display:inline-flex;align-items:center;gap:6px;margin-right:16px;"><span style="width:12px;height:3px;background:#6366f1;display:inline-block;border-radius:2px;"></span>Total</span>
<span style="display:inline-flex;align-items:center;gap:6px;"><span style="width:12px;height:3px;background:#f59e0b;display:inline-block;border-radius:2px;border:1px dashed #f59e0b;"></span>Uninstalled</span>
</div>
<canvas id="components-chart" height="160"></canvas>
</section>
{{end}}
<section class="card">
<h2>Latest Assets</h2>
{{if .Assets}}
<table class="table" data-disable-auto-filters="true">
<thead>
<tr>
<th>Status</th>
<th>Name</th>
<th>Vendor Serial</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{{range $item := .Assets}}
<tr class="clickable" onclick="navigateToRow('{{assetUIURL $item}}')">
<td><span class="badge {{assetStatusClass (index $.AssetStatusByID $item.ID)}}">{{assetStatusText (index $.AssetStatusByID $item.ID)}}</span></td>
<td>{{$item.Name}}</td>
<td>{{$item.VendorSerial}}</td>
<td title="{{formatTimeFull $item.CreatedAt}}">{{formatTime $item.CreatedAt}}</td>
</tr>
{{end}}
</tbody>
</table>
{{template "pagination" .AssetsPager}}
{{else}}
<div class="meta">No assets yet.</div>
{{end}}
<div class="meta"><a href="/ui/asset">View all asset</a></div>
</section>
<section class="card">
<h2>Latest Components</h2>
{{if .Components}}
<table class="table" data-disable-auto-filters="true">
<thead>
<tr>
<th>Status</th>
<th>Vendor Serial</th>
<th>First Seen</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{{range $item := .Components}}
<tr class="clickable" onclick="navigateToRow('{{componentUIURL $item}}')">
<td><span class="badge {{componentStatusClass (index $.ComponentStatusByID $item.ID)}}">{{componentStatusText (index $.ComponentStatusByID $item.ID)}}</span></td>
<td>{{componentLabelByID $item.ID $.ComponentLabelByID}}</td>
<td title="{{formatTimePtrFull $item.FirstSeenAt}}">{{formatTimePtr $item.FirstSeenAt}}</td>
<td title="{{formatTimeFull $item.CreatedAt}}">{{formatTime $item.CreatedAt}}</td>
</tr>
{{end}}
</tbody>
</table>
{{template "pagination" .ComponentsPager}}
{{else}}
<div class="meta">No components yet.</div>
{{end}}
<div class="meta"><a href="/ui/component">View all component</a></div>
</section>
</main>
<script>
(function () {
// Format large numbers: 1500 → 1.5k, 1200000 → 1.2M etc.
function fmtNum(v) {
if (v >= 1e9) return (v / 1e9).toFixed(v % 1e9 === 0 ? 0 : 1).replace(/\.0$/, '') + 'B';
if (v >= 1e6) return (v / 1e6).toFixed(v % 1e6 === 0 ? 0 : 1).replace(/\.0$/, '') + 'M';
if (v >= 1e3) return (v / 1e3).toFixed(v % 1e3 === 0 ? 0 : 1).replace(/\.0$/, '') + 'k';
return String(v);
}
// Nice round max for y-axis
function niceMax(raw) {
if (raw <= 0) return 10;
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
const steps = [1, 2, 2.5, 5, 10];
for (const s of steps) {
const candidate = Math.ceil(raw / (mag * s)) * mag * s;
if (candidate >= raw) return candidate;
}
return Math.ceil(raw / mag) * mag;
}
// Build dense date spine between first and last date (no gaps)
function buildSpine(allDates) {
if (!allDates.length) return [];
const parse = s => new Date(s + 'T00:00:00Z');
const fmt = d => d.toISOString().slice(0, 10);
const first = parse(allDates[0]), last = parse(allDates[allDates.length - 1]);
const spine = [];
for (let d = new Date(first); d <= last; d.setUTCDate(d.getUTCDate() + 1)) {
spine.push(fmt(new Date(d)));
}
return spine;
}
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function fmtDateLabel(iso) {
// iso = "YYYY-MM-DD"
const y = iso.slice(0, 4), m = parseInt(iso.slice(5, 7), 10) - 1;
return MONTHS[m] + ' ' + y;
}
function drawLineChart(canvasId, datasets, opts) {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const ctx = canvas.getContext('2d');
const W = canvas.parentElement ? (canvas.parentElement.clientWidth || 640) : 640;
canvas.width = W;
const H = canvas.height;
const pad = { top: 16, right: 20, bottom: 36, left: 52 };
const chartW = W - pad.left - pad.right;
const chartH = H - pad.top - pad.bottom;
// Use shared spine if provided, otherwise build from dataset dates
let spine;
if (opts && opts.spine && opts.spine.length) {
spine = opts.spine;
} else {
const allDates = [];
const dateSet = {};
datasets.forEach(ds => ds.points.forEach(p => {
if (!dateSet[p.date]) { dateSet[p.date] = true; allDates.push(p.date); }
}));
allDates.sort();
if (!allDates.length) return;
spine = buildSpine(allDates);
}
const n = spine.length;
if (!n) return;
// Forward-fill each dataset over the spine
const filledVals = datasets.map(ds => {
const byDate = {};
ds.points.forEach(p => { byDate[p.date] = p.value; });
let last = 0;
return spine.map(d => { if (byDate[d] !== undefined) last = byDate[d]; return last; });
});
let maxVal = 1;
filledVals.forEach(vals => vals.forEach(v => { if (v > maxVal) maxVal = v; }));
const yMax = niceMax(maxVal);
const xOf = i => pad.left + (n <= 1 ? chartW / 2 : (i / (n - 1)) * chartW);
const yOf = v => pad.top + chartH - (v / yMax) * chartH;
ctx.clearRect(0, 0, W, H);
// Y grid + labels
const ySteps = 4;
ctx.font = '11px system-ui,sans-serif';
ctx.fillStyle = '#9ca3af';
ctx.strokeStyle = '#f0f0f0';
ctx.lineWidth = 1;
for (let i = 0; i <= ySteps; i++) {
const v = Math.round((yMax / ySteps) * i);
const y = yOf(v);
ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(pad.left + chartW, y); ctx.stroke();
ctx.textAlign = 'right';
ctx.fillText(fmtNum(v), pad.left - 6, y + 4);
}
// X labels: one tick per month-start; thin out if too many to fit (~80px per label)
const monthStartIdxs = [];
spine.forEach((d, i) => { if (d.slice(8) === '01') monthStartIdxs.push(i); });
// Always include last day index for a visual end anchor (no label overlap guard needed separately)
const maxLabels = Math.max(2, Math.floor(chartW / 80));
let labelIdxs;
if (monthStartIdxs.length <= maxLabels) {
labelIdxs = monthStartIdxs;
} else {
// Keep every Nth month so we fit
const step = Math.ceil(monthStartIdxs.length / maxLabels);
labelIdxs = monthStartIdxs.filter((_, k) => k % step === 0);
}
// Ensure at least the first and last month ticks are present
if (monthStartIdxs.length && !labelIdxs.includes(monthStartIdxs[0]))
labelIdxs.unshift(monthStartIdxs[0]);
if (monthStartIdxs.length && !labelIdxs.includes(monthStartIdxs[monthStartIdxs.length - 1]))
labelIdxs.push(monthStartIdxs[monthStartIdxs.length - 1]);
ctx.textAlign = 'center';
ctx.fillStyle = '#9ca3af';
// Light tick marks at month boundaries
ctx.strokeStyle = '#e5e7eb';
ctx.lineWidth = 1;
labelIdxs.forEach(i => {
const x = xOf(i);
ctx.beginPath(); ctx.moveTo(x, pad.top + chartH); ctx.lineTo(x, pad.top + chartH + 4); ctx.stroke();
ctx.fillText(fmtDateLabel(spine[i]), x, H - pad.bottom + 16);
});
// Draw filled areas (bottom datasets first so top ones overlap)
const fillDatasets = datasets.filter(ds => ds.fill);
const lineDatasets = datasets.filter(ds => !ds.fill);
// Filled areas: draw from bottom to top of stack
fillDatasets.slice().reverse().forEach((ds, ri) => {
const idx = fillDatasets.length - 1 - ri;
const vals = filledVals[datasets.indexOf(ds)];
ctx.beginPath();
ctx.moveTo(xOf(0), yOf(vals[0]));
for (let i = 1; i < n; i++) ctx.lineTo(xOf(i), yOf(vals[i]));
ctx.lineTo(xOf(n - 1), yOf(0));
ctx.lineTo(xOf(0), yOf(0));
ctx.closePath();
const grad = ctx.createLinearGradient(0, pad.top, 0, pad.top + chartH);
grad.addColorStop(0, ds.color + '55');
grad.addColorStop(1, ds.color + '08');
ctx.fillStyle = grad;
ctx.fill();
// Line on top of fill
ctx.beginPath();
ctx.strokeStyle = ds.color;
ctx.lineWidth = 2;
ctx.setLineDash([]);
vals.forEach((v, i) => { if (i === 0) ctx.moveTo(xOf(i), yOf(v)); else ctx.lineTo(xOf(i), yOf(v)); });
ctx.stroke();
void idx;
});
// Plain lines (no fill)
lineDatasets.forEach(ds => {
const vals = filledVals[datasets.indexOf(ds)];
ctx.beginPath();
ctx.strokeStyle = ds.color;
ctx.lineWidth = 2;
if (ds.dashed) ctx.setLineDash([5, 4]); else ctx.setLineDash([]);
vals.forEach((v, i) => { if (i === 0) ctx.moveTo(xOf(i), yOf(v)); else ctx.lineTo(xOf(i), yOf(v)); });
ctx.stroke();
ctx.setLineDash([]);
});
}
requestAnimationFrame(function () {
const assetsRaw = {{safeJS .AssetsDayChartJSON}};
const compRaw = {{safeJS .ComponentsDayChartJSON}};
// Build shared date spine across both datasets so axes are identical
const allPoints = [];
if (assetsRaw) assetsRaw.forEach(p => allPoints.push(p.date));
if (compRaw) compRaw.forEach(p => allPoints.push(p.date));
allPoints.sort();
const sharedSpine = allPoints.length ? buildSpine([allPoints[0], allPoints[allPoints.length - 1]]) : null;
if (assetsRaw && assetsRaw.length) {
drawLineChart('assets-chart', [{
points: assetsRaw.map(p => ({ date: p.date, value: p.count })),
color: '#6366f1',
fill: true
}], { spine: sharedSpine });
}
if (compRaw && compRaw.length) {
drawLineChart('components-chart', [
{ points: compRaw.map(p => ({ date: p.date, value: p.total })), color: '#6366f1', fill: true },
{ points: compRaw.map(p => ({ date: p.date, value: p.uninstalled })), color: '#f59e0b', fill: true }
], { spine: sharedSpine });
}
});
})();
</script>
</body>
</html>
{{end}}