Files
core/internal/api/ui_components_models.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

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}}