- 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>
132 lines
5.0 KiB
Cheetah
132 lines
5.0 KiB
Cheetah
{{define "components_models"}}
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
{{template "head" .}}
|
|
<body>
|
|
{{template "topbar" .}}
|
|
{{template "breadcrumbs" .}}
|
|
|
|
<main class="container">
|
|
|
|
{{if .VendorChartJSON}}
|
|
<section class="card">
|
|
<h2>Vendor Distribution</h2>
|
|
<div style="display:flex; gap:24px; align-items:flex-start; flex-wrap:wrap;">
|
|
<canvas id="vendor-pie-chart" width="220" height="220" style="flex:0 0 auto;"></canvas>
|
|
<div id="vendor-pie-legend" style="display:grid; gap:6px; align-content:start; min-width:0; flex:1;"></div>
|
|
</div>
|
|
</section>
|
|
{{end}}
|
|
|
|
<section class="card">
|
|
<h2>Unique Component Models</h2>
|
|
|
|
{{/* Column filters */}}
|
|
<form method="get" action="/ui/component/models" style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:16px; align-items:flex-end;">
|
|
<div style="display:flex; flex-direction:column; gap:4px;">
|
|
<label class="meta" style="margin:0;" for="filter-vendor">Vendor</label>
|
|
<select id="filter-vendor" name="vendor" style="min-width:120px;">
|
|
<option value="">All vendors</option>
|
|
{{range .VendorOptions}}
|
|
<option value="{{.}}" {{if eq . $.FilterVendor}}selected{{end}}>{{.}}</option>
|
|
{{end}}
|
|
</select>
|
|
</div>
|
|
<div style="display:flex; flex-direction:column; gap:4px;">
|
|
<label class="meta" style="margin:0;" for="filter-model">Model</label>
|
|
<input id="filter-model" name="model" type="text" value="{{.FilterModel}}" placeholder="Filter model…" style="min-width:160px;" />
|
|
</div>
|
|
<input type="hidden" name="sort" value="{{.SortBy}}" />
|
|
<input type="hidden" name="dir" value="{{.SortDir}}" />
|
|
<button class="button button-secondary" type="submit">Apply</button>
|
|
{{if or .FilterVendor .FilterModel}}
|
|
<a href="/ui/component/models" class="button button-secondary">Clear</a>
|
|
{{end}}
|
|
</form>
|
|
|
|
{{if .Rows}}
|
|
<table class="table" data-disable-auto-filters="true">
|
|
<thead>
|
|
<tr>
|
|
<th>{{sortHeader "vendor" "Vendor" .SortBy .SortDir .FilterVendor .FilterModel}}</th>
|
|
<th>{{sortHeader "model" "Model" .SortBy .SortDir .FilterVendor .FilterModel}}</th>
|
|
<th>{{sortHeader "total" "Total" .SortBy .SortDir .FilterVendor .FilterModel}}</th>
|
|
<th>{{sortHeader "failed" "Failed" .SortBy .SortDir .FilterVendor .FilterModel}}</th>
|
|
<th>{{sortHeader "firmware" "FW Versions (installed)" .SortBy .SortDir .FilterVendor .FilterModel}}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{{range .Rows}}
|
|
<tr class="clickable" onclick="navigateToRow('{{.URL}}')">
|
|
<td>{{.Vendor}}</td>
|
|
<td>{{.Model}}</td>
|
|
<td>{{.Total}}</td>
|
|
<td>{{if gt .Failed 0}}<span class="badge status-red">{{.Failed}}</span>{{else}}{{.Failed}}{{end}}</td>
|
|
<td>{{if gt .FirmwareVersionCount 0}}{{.FirmwareVersionCount}}{{else}}<span class="meta">—</span>{{end}}</td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
{{template "pagination" .Pager}}
|
|
<div class="meta">Click a row to open components of this model with status filters.</div>
|
|
{{else}}
|
|
<div class="meta">No component models match the current filter.</div>
|
|
{{end}}
|
|
</section>
|
|
</main>
|
|
|
|
<script>
|
|
(function () {
|
|
requestAnimationFrame(function() {
|
|
const raw = {{safeJS .VendorChartJSON}};
|
|
if (!raw || !raw.length) return;
|
|
|
|
const canvas = document.getElementById('vendor-pie-chart');
|
|
if (!canvas) return;
|
|
const legend = document.getElementById('vendor-pie-legend');
|
|
const ctx = canvas.getContext('2d');
|
|
const W = canvas.width, H = canvas.height;
|
|
const cx = W / 2, cy = H / 2, r = Math.min(W, H) / 2 - 8;
|
|
|
|
const palette = [
|
|
'#6366f1','#22c55e','#f59e0b','#ef4444','#3b82f6',
|
|
'#a855f7','#14b8a6','#f97316','#ec4899','#84cc16',
|
|
'#06b6d4','#8b5cf6','#d946ef','#10b981','#f43f5e'
|
|
];
|
|
|
|
const total = raw.reduce((s, d) => s + d.value, 0);
|
|
let angle = -Math.PI / 2;
|
|
|
|
raw.forEach((d, i) => {
|
|
const slice = (d.value / total) * 2 * Math.PI;
|
|
const color = palette[i % palette.length];
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx, cy);
|
|
ctx.arc(cx, cy, r, angle, angle + slice);
|
|
ctx.closePath();
|
|
ctx.fillStyle = color;
|
|
ctx.fill();
|
|
ctx.strokeStyle = '#fff';
|
|
ctx.lineWidth = 2;
|
|
ctx.stroke();
|
|
|
|
angle += slice;
|
|
|
|
if (legend) {
|
|
const pct = ((d.value / total) * 100).toFixed(1);
|
|
const row = document.createElement('div');
|
|
row.style.cssText = 'display:flex;align-items:center;gap:8px;';
|
|
row.innerHTML =
|
|
`<span style="width:10px;height:10px;border-radius:999px;background:${color};flex:0 0 auto;display:inline-block;"></span>` +
|
|
`<span class="meta" style="margin:0;">${d.label}: ${d.value} (${pct}%)</span>`;
|
|
legend.appendChild(row);
|
|
}
|
|
});
|
|
}); // end requestAnimationFrame
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
{{end}}
|