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

2250 lines
80 KiB
Cheetah
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.
{{define "head"}}
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Reanimator - {{.PageTitle}}</title>
<style>
:root {
color-scheme: light;
--bg: #0b0f14;
--bg-secondary: #121821;
--card: #ffffff;
--ink: #101624;
--muted: #5c6b7a;
--accent: #0f766e;
--accent-soft: #d2f4ef;
--warning: #f97316;
--border: #e5e7eb;
--shadow: 0 12px 30px rgba(15, 23, 42, 0.12);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Space Grotesk", "IBM Plex Sans", "Segoe UI", sans-serif;
color: var(--ink);
background: radial-gradient(1000px 600px at 20% -10%, #0f766e44, transparent),
linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
min-height: 100vh;
}
a { color: inherit; }
.topbar {
background: linear-gradient(90deg, #0f766e 0%, #0b4f4c 100%);
color: #ffffff;
padding: 24px 32px;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
box-shadow: var(--shadow);
}
.topbar-main {
display: flex;
flex-direction: column;
justify-content: flex-start;
min-height: 76px;
padding-top: 2px;
}
.topbar-side {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
flex: 1 1 320px;
min-height: 76px;
justify-content: space-between;
}
.topbar-search {
display: flex;
gap: 8px;
width: min(440px, 100%);
justify-content: flex-end;
}
.topbar-search input {
flex: 1;
min-width: 180px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.28);
background: rgba(255,255,255,0.14);
color: #ffffff;
font-family: inherit;
}
.topbar-search input::placeholder {
color: rgba(255,255,255,0.72);
}
.topbar-search button {
padding: 10px 14px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.24);
background: rgba(255,255,255,0.18);
color: #fff;
font-weight: 600;
cursor: pointer;
}
.brand {
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
font-size: 12px;
opacity: 0.8;
}
.title {
font-size: 26px;
font-weight: 700;
margin: 6px 0 0 0;
line-height: 1.12;
}
.subtitle {
margin-top: 6px;
font-size: 13px;
color: rgba(255, 255, 255, 0.8);
min-height: 18px;
display: block;
line-height: 1.35;
white-space: nowrap;
}
.topbar-meta {
min-height: 24px;
display: flex;
align-items: center;
}
.pill-placeholder {
visibility: hidden;
}
.nav {
display: flex;
gap: 16px;
padding: 12px 32px;
background: #f1f5f9;
border-bottom: 1px solid var(--border);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 600;
}
.nav-item {
position: relative;
}
.nav a, .nav-group-label {
text-decoration: none;
color: var(--ink);
opacity: 0.7;
cursor: pointer;
display: block;
}
.nav a.active, .nav-group-label.active {
opacity: 1;
color: var(--accent);
}
.nav-group {
position: relative;
}
.nav-group-label {
display: flex;
align-items: center;
gap: 4px;
}
.nav-group-label::after {
content: '▾';
font-size: 10px;
opacity: 0.5;
}
.nav-submenu {
display: none;
position: absolute;
top: 100%;
left: 0;
background: white;
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: var(--shadow);
margin-top: 4px;
min-width: 160px;
z-index: 100;
padding: 8px 0;
}
.nav-submenu::before {
content: '';
position: absolute;
top: -4px;
left: 0;
right: 0;
height: 4px;
background: transparent;
}
.nav-group:hover .nav-submenu,
.nav-submenu:hover {
display: block;
}
.nav-submenu a {
padding: 8px 16px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
opacity: 0.7;
transition: all 0.2s;
}
.nav-submenu a:hover {
opacity: 1;
background: var(--accent-soft);
}
.nav-submenu a.active {
opacity: 1;
color: var(--accent);
background: var(--accent-soft);
}
.container {
max-width: 1100px;
margin: 28px auto;
padding: 0 24px 48px;
display: grid;
gap: 24px;
}
.card {
background: var(--card);
border-radius: 16px;
padding: 20px 24px;
box-shadow: var(--shadow);
border: 1px solid var(--border);
}
.card h2 {
margin: 0 0 16px 0;
font-size: 18px;
color: var(--ink);
}
.meta-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px 24px;
font-size: 14px;
}
.meta-grid div span {
display: block;
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 4px;
}
.pill {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.pill-neutral {
background: var(--accent-soft);
color: var(--accent);
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.table th {
text-align: left;
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.table td {
padding: 12px 0;
border-bottom: 1px solid var(--border);
}
.table tr:last-child td {
border-bottom: none;
}
.table tr.clickable {
cursor: pointer;
transition: background-color 0.15s;
}
.table tr.clickable:hover {
background: var(--accent-soft);
}
.table-filter-row th {
padding: 8px 0 10px 0;
border-bottom: 1px solid var(--border);
}
.table-filter-input {
width: 100%;
min-width: 90px;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 8px;
font-family: inherit;
font-size: 12px;
color: var(--ink);
background: #ffffff;
text-transform: none;
letter-spacing: 0;
}
.table-filter-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.meta {
color: var(--muted);
font-size: 12px;
}
.pagination {
margin-top: 14px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.pagination-summary {
color: var(--muted);
font-size: 12px;
}
.pagination-links {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.pagination-link,
.pagination-link-disabled,
.pagination-link-active,
.pagination-link-ellipsis {
padding: 5px 10px;
border-radius: 8px;
border: 1px solid var(--border);
font-size: 12px;
text-decoration: none;
background: #ffffff;
}
.pagination-link:hover {
border-color: var(--accent);
color: var(--accent);
}
.pagination-link-disabled {
color: #94a3b8;
background: #f8fafc;
}
.pagination-link-active {
border-color: var(--accent);
background: var(--accent-soft);
color: var(--accent);
font-weight: 700;
}
.pagination-link-ellipsis {
border-color: transparent;
background: transparent;
color: var(--muted);
padding: 5px 2px;
}
.timeline {
display: grid;
gap: 12px;
}
.timeline-panel {
display: grid;
gap: 12px;
}
.timeline-toolbar {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
padding: 12px;
border: 1px solid var(--border);
border-radius: 12px;
background: #f8fafc;
}
.timeline-toolbar .field {
gap: 4px;
}
.timeline-toolbar label {
font-size: 11px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: .06em;
}
.timeline-toolbar input,
.timeline-toolbar select {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: white;
font-family: inherit;
font-size: 13px;
}
.timeline-toolbar-actions {
display: flex;
gap: 8px;
align-items: end;
flex-wrap: wrap;
}
.timeline-day {
display: grid;
gap: 8px;
}
.timeline-day-title {
font-size: 12px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: .08em;
margin-top: 8px;
}
.timeline-card {
border: 1px solid var(--border);
border-radius: 14px;
padding: 12px 14px;
background: #fff;
display: grid;
gap: 8px;
cursor: pointer;
}
.timeline-card:hover {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.timeline-card-header {
display: flex;
justify-content: space-between;
align-items: start;
gap: 10px;
}
.timeline-card-title {
font-weight: 700;
font-size: 14px;
}
.timeline-card-time {
color: var(--muted);
font-size: 12px;
white-space: nowrap;
}
.timeline-card-badges {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.timeline-card-badge {
font-size: 11px;
border-radius: 999px;
padding: 3px 8px;
border: 1px solid var(--border);
background: #f8fafc;
color: var(--ink);
}
.timeline-card-badge-install {
border-color: #86efac;
background: #dcfce7;
color: #166534;
font-weight: 700;
}
.timeline-card-badge-remove {
border-color: #fecaca;
background: #fee2e2;
color: #991b1b;
font-weight: 700;
}
.timeline-card-badge-firmware {
border-color: #bfdbfe;
background: #dbeafe;
color: #1d4ed8;
font-weight: 700;
}
.timeline-card-badge-status-failed {
border-color: #fecaca;
background: #fee2e2;
color: #991b1b;
font-weight: 700;
}
.timeline-card-badge-status-warning {
border-color: #fde68a;
background: #fef3c7;
color: #92400e;
font-weight: 700;
}
.timeline-card-badge-status-ok {
border-color: #86efac;
background: #dcfce7;
color: #166534;
font-weight: 700;
font-weight: 700;
}
.timeline-card-accent-install {
border-color: #86efac;
background: linear-gradient(180deg, #f0fdf4 0%, #ffffff 100%);
}
.timeline-card-accent-remove {
border-color: #fecaca;
background: linear-gradient(180deg, #fff1f2 0%, #ffffff 100%);
}
.timeline-card-accent-firmware {
border-color: #bfdbfe;
background: linear-gradient(180deg, #eff6ff 0%, #ffffff 100%);
}
.timeline-card-accent-status-failed {
border-color: #fca5a5;
background: linear-gradient(180deg, #fff1f2 0%, #ffffff 100%);
}
.timeline-card-accent-status-warning {
border-color: #fcd34d;
background: linear-gradient(180deg, #fffbeb 0%, #ffffff 100%);
}
.timeline-card-accent-status-ok {
border-color: #86efac;
background: linear-gradient(180deg, #f0fdf4 0%, #ffffff 100%);
}
.timeline-card-context {
font-size: 13px;
color: var(--ink);
}
.timeline-card-context-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-top: 2px;
}
.timeline-card-context-col {
min-width: 0;
}
.timeline-card-context-col-label {
font-size: 11px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 4px;
}
.timeline-card-context-col-body {
font-size: 13px;
color: var(--ink);
line-height: 1.35;
word-break: break-word;
}
.timeline-card-lines {
display: grid;
gap: 2px;
}
.timeline-card-line {
display: block;
}
.timeline-chip-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: flex-start;
}
.timeline-chip {
display: inline-flex;
align-items: center;
max-width: 100%;
padding: 3px 8px;
border-radius: 999px;
border: 1px solid var(--border);
background: #f8fafc;
color: var(--ink);
font-size: 12px;
line-height: 1.25;
word-break: break-word;
}
.timeline-chip-install {
background: #ecfdf3;
border-color: #bbf7d0;
color: #166534;
}
.timeline-chip-remove {
background: #fef2f2;
border-color: #fecaca;
color: #991b1b;
}
.timeline-card-subline {
font-size: 12px;
color: var(--muted);
}
.timeline-empty {
border: 1px dashed var(--border);
border-radius: 12px;
padding: 16px;
color: var(--muted);
background: #f8fafc;
}
.timeline-load-more {
justify-self: start;
}
.timeline-detail-layout {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
.timeline-modal-list {
display: grid;
gap: 8px;
max-height: 520px;
overflow: auto;
padding-right: 4px;
}
.timeline-modal-item {
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px;
cursor: pointer;
background: #f8fafc;
}
.timeline-modal-item-accent-install {
border-color: #86efac;
background: #ecfdf3;
}
.timeline-modal-item-accent-remove {
border-color: #fca5a5;
background: #fef2f2;
}
.timeline-modal-item-accent-firmware {
border-color: #93c5fd;
background: #eff6ff;
}
.timeline-modal-item.active {
border-color: var(--accent);
box-shadow: inset 0 0 0 1px rgba(15, 118, 110, 0.3);
}
.timeline-modal-item-title {
font-size: 13px;
font-weight: 600;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.timeline-modal-item-title-static {
font-weight: 400;
}
.timeline-modal-item-title-var {
font-weight: 600;
}
.timeline-modal-item-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
}
.timeline-modal-item-head .timeline-modal-item-meta {
margin-top: 0;
white-space: nowrap;
}
.timeline-modal-item-meta {
font-size: 11px;
color: var(--muted);
margin-top: 4px;
}
.timeline-modal-item-line {
font-size: 12px;
color: var(--ink);
margin-top: 6px;
line-height: 1.35;
}
.timeline-modal-item-detail {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #d6dde7;
display: grid;
gap: 6px;
}
.timeline-modal-item-section {
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
overflow: hidden;
}
.timeline-modal-item-section-title {
padding: 6px 8px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.07em;
font-weight: 700;
color: #475569;
background: #f8fafc;
border-bottom: 1px solid #e5e7eb;
}
.timeline-modal-item-section-title.status-green {
background: #ecfdf3;
color: #166534;
border-bottom-color: #bbf7d0;
}
.timeline-modal-item-section-title.status-yellow {
background: #fffbeb;
color: #92400e;
border-bottom-color: #fde68a;
}
.timeline-modal-item-section-title.status-red {
background: #fef2f2;
color: #991b1b;
border-bottom-color: #fecaca;
}
.timeline-modal-item-detail-row {
display: grid;
grid-template-columns: 140px 1fr;
gap: 8px;
padding: 6px 8px;
border-bottom: 1px solid #eef2f7;
font-size: 12px;
line-height: 1.3;
}
.timeline-modal-item-detail-row:last-child {
border-bottom: none;
}
.timeline-modal-item-detail-key {
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 10px;
font-weight: 700;
}
.timeline-modal-item-detail-value {
color: var(--ink);
word-break: break-word;
}
.event {
display: grid;
grid-template-columns: 160px 1fr;
gap: 12px;
padding: 12px 16px;
border: 1px solid var(--border);
border-radius: 12px;
background: #f8fafc;
}
.event .time {
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.event .detail {
font-weight: 600;
font-size: 14px;
}
.event .meta {
color: var(--muted);
font-size: 12px;
margin-top: 4px;
}
.tickets {
display: grid;
gap: 10px;
}
.ticket {
border: 1px solid var(--border);
padding: 12px 16px;
border-radius: 12px;
background: #fff7ed;
}
.ticket .title {
font-size: 15px;
margin: 0 0 4px 0;
}
.ticket .meta {
color: var(--muted);
font-size: 12px;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.stat {
border: 1px solid var(--border);
border-radius: 14px;
padding: 14px 16px;
background: #f8fafc;
}
.stat span {
display: block;
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.stat strong {
font-size: 20px;
}
.split {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
.form {
display: grid;
gap: 12px;
}
.field {
display: grid;
gap: 6px;
}
.field label {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.input {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border);
font-family: inherit;
}
.button {
padding: 10px 14px;
border-radius: 10px;
border: none;
background: var(--accent);
color: white;
font-weight: 600;
cursor: pointer;
}
.button-secondary {
background: #e2e8f0;
color: var(--ink);
}
.button-danger {
background: #b91c1c;
color: #fff;
}
.button-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.modal {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
background: rgba(2, 6, 23, 0.45);
padding: 20px;
z-index: 200;
}
.modal.open {
display: flex;
}
.modal-card {
width: min(560px, 100%);
background: #fff;
border-radius: 16px;
border: 1px solid var(--border);
box-shadow: var(--shadow);
padding: 20px;
}
.modal-header {
margin: 0 0 12px 0;
font-size: 18px;
}
.field-value[hidden] {
display: none;
}
.field-input[hidden] {
display: none;
}
.error {
border: 1px solid #fecaca;
background: #fff1f2;
color: #9f1239;
padding: 10px 12px;
border-radius: 12px;
font-size: 13px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
}
.status-green {
background: #dcfce7;
color: #166534;
}
.status-gray {
background: #e5e7eb;
color: #374151;
}
.status-red {
background: #fee2e2;
color: #991b1b;
}
.status-yellow {
background: #fef3c7;
color: #92400e;
}
.breadcrumbs {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 32px;
background: white;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.breadcrumbs a {
text-decoration: none;
color: var(--muted);
transition: color 0.2s;
}
.breadcrumbs a:hover {
color: var(--accent);
}
.breadcrumbs .home-icon {
font-size: 16px;
line-height: 1;
}
.breadcrumbs .separator {
color: var(--muted);
opacity: 0.5;
font-size: 11px;
}
.breadcrumbs .current {
color: var(--ink);
font-weight: 600;
}
@media (max-width: 720px) {
.topbar {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.topbar-main,
.topbar-side {
min-height: 0;
}
.topbar-side {
align-items: stretch;
width: 100%;
}
.topbar-meta {
min-height: 0;
}
.nav {
flex-wrap: wrap;
}
.event {
grid-template-columns: 1fr;
}
.timeline-detail-layout {
grid-template-columns: 1fr;
}
.timeline-card-context-grid {
grid-template-columns: 1fr;
}
}
</style>
<script>
function navigateToRow(url) {
window.location.href = url;
}
// Улучшенная работа выпадающих меню
document.addEventListener('DOMContentLoaded', function() {
function formatLocalDate(date) {
return date.toLocaleDateString();
}
function formatLocalDateTime(date) {
return `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`;
}
function toLocalFromUTCText(value) {
const trimmed = String(value || '').trim();
if (!trimmed) return null;
const withUTC = trimmed.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2}) UTC$/);
if (withUTC) {
const d = new Date(Date.UTC(
Number(withUTC[1]),
Number(withUTC[2]) - 1,
Number(withUTC[3]),
Number(withUTC[4]),
Number(withUTC[5]),
Number(withUTC[6]),
));
if (!Number.isNaN(d.getTime())) return formatLocalDateTime(d);
}
if (/^\d{4}-\d{2}-\d{2}T/.test(trimmed)) {
const d = new Date(trimmed);
if (!Number.isNaN(d.getTime())) return formatLocalDateTime(d);
}
return null;
}
function localizeUTCTextNodes(root) {
const walker = document.createTreeWalker(root || document.body, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
if (!node || !node.nodeValue || !node.nodeValue.includes('UTC')) return NodeFilter.FILTER_REJECT;
const parent = node.parentElement;
if (!parent) return NodeFilter.FILTER_REJECT;
const tag = parent.tagName;
if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'TEXTAREA' || tag === 'PRE' || tag === 'CODE') {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
}
});
const nodes = [];
let current = walker.nextNode();
while (current) {
nodes.push(current);
current = walker.nextNode();
}
const utcPattern = /\b\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC\b/g;
nodes.forEach((node) => {
const original = node.nodeValue;
if (!original) return;
const replaced = original.replace(utcPattern, (match) => {
const localized = toLocalFromUTCText(match);
return localized || match;
});
if (replaced !== original) node.nodeValue = replaced;
});
}
function localizeDataUTCElements(root) {
const scope = root || document;
scope.querySelectorAll('[data-utc]').forEach((el) => {
const raw = el.getAttribute('data-utc');
if (!raw) return;
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return;
if (el.dataset.localDateOnly === '1') {
el.textContent = formatLocalDate(d);
} else if (el.dataset.localHourOnly === '1') {
el.textContent = `${d.toLocaleDateString()} ${d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
} else {
el.textContent = formatLocalDateTime(d);
}
el.setAttribute('title', formatLocalDateTime(d));
});
}
localizeDataUTCElements(document);
localizeUTCTextNodes(document.body);
const navGroups = document.querySelectorAll('.nav-group');
navGroups.forEach(group => {
let hideTimer;
group.addEventListener('mouseenter', function() {
clearTimeout(hideTimer);
this.querySelector('.nav-submenu').style.display = 'block';
});
group.addEventListener('mouseleave', function() {
const submenu = this.querySelector('.nav-submenu');
hideTimer = setTimeout(() => {
submenu.style.display = 'none';
}, 150);
});
});
const tables = document.querySelectorAll('table.table');
tables.forEach((table, tableIndex) => {
if (table.dataset.disableAutoFilters === 'true') {
return;
}
const thead = table.querySelector('thead');
const headerRow = thead ? thead.querySelector('tr') : null;
const tbody = table.querySelector('tbody');
if (!thead || !headerRow || !tbody) {
return;
}
const rows = Array.from(tbody.querySelectorAll('tr'));
if (rows.length === 0) {
return;
}
const headerCells = Array.from(headerRow.children);
if (headerCells.length === 0) {
return;
}
const filterRow = document.createElement('tr');
filterRow.className = 'table-filter-row';
const filterInputs = [];
headerCells.forEach((headerCell, columnIndex) => {
const filterCell = document.createElement('th');
const input = document.createElement('input');
const listId = 'table-filter-' + tableIndex + '-' + columnIndex;
input.className = 'table-filter-input';
input.type = 'text';
input.placeholder = 'Filter';
input.setAttribute('list', listId);
const datalist = document.createElement('datalist');
datalist.id = listId;
const uniqueValues = new Set();
rows.forEach((row) => {
const bodyCell = row.children[columnIndex];
if (!bodyCell) {
return;
}
const value = bodyCell.textContent.trim();
if (value !== '') {
uniqueValues.add(value);
}
});
Array.from(uniqueValues).sort((left, right) => left.localeCompare(right)).forEach((value) => {
const option = document.createElement('option');
option.value = value;
datalist.appendChild(option);
});
input.addEventListener('input', () => {
rows.forEach((row) => {
let visible = true;
filterInputs.forEach((activeFilter) => {
const query = activeFilter.input.value.trim().toLowerCase();
if (query === '') {
return;
}
const bodyCell = row.children[activeFilter.columnIndex];
const value = bodyCell ? bodyCell.textContent.trim().toLowerCase() : '';
if (!value.includes(query)) {
visible = false;
}
});
row.style.display = visible ? '' : 'none';
});
});
filterCell.appendChild(input);
filterCell.appendChild(datalist);
filterRow.appendChild(filterCell);
filterInputs.push({input, columnIndex});
});
thead.appendChild(filterRow);
});
});
function createElement(tag, className, text) {
const el = document.createElement(tag);
if (className) el.className = className;
if (typeof text === 'string') el.textContent = text;
return el;
}
function timelineFormatDateTime(value) {
if (!value) return '—';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
return d.toLocaleString();
}
function timelineActionLabel(action) {
const labels = {
component_installed: 'Installation',
component_removed: 'Removal',
firmware_changed: 'Firmware change',
firmware_installed: 'Firmware install',
status_ok: 'Status OK',
status_warning: 'Status warning',
status_failed: 'Status failed',
manual_edit: 'Manual edit',
registry_updated: 'Registry update',
rollback_applied: 'Rollback',
log_collected: 'Log collected'
};
return labels[action] || action || 'Event';
}
function timelineCardAccentClass(action) {
if (action === 'component_installed') return 'timeline-card-accent-install';
if (action === 'component_removed') return 'timeline-card-accent-remove';
if (action === 'firmware_changed' || action === 'firmware_installed') return 'timeline-card-accent-firmware';
if (action === 'status_failed') return 'timeline-card-accent-status-failed';
if (action === 'status_warning') return 'timeline-card-accent-status-warning';
if (action === 'status_ok') return 'timeline-card-accent-status-ok';
return '';
}
function timelineActionBadgeClass(action) {
if (action === 'component_installed') return 'timeline-card-badge-install';
if (action === 'component_removed') return 'timeline-card-badge-remove';
if (action === 'firmware_changed' || action === 'firmware_installed') return 'timeline-card-badge-firmware';
if (action === 'status_failed') return 'timeline-card-badge-status-failed';
if (action === 'status_warning') return 'timeline-card-badge-status-warning';
if (action === 'status_ok') return 'timeline-card-badge-status-ok';
return '';
}
function timelineEventTypeLabel(eventType) {
const key = String(eventType || '').trim().toUpperCase();
const labels = {
INSTALLED: 'Component installed',
REMOVED: 'Component removed',
COMPONENT_OK: 'Status OK',
COMPONENT_WARNING: 'Status warning',
COMPONENT_FAILED: 'Status failed',
FIRMWARE_CHANGED: 'Firmware changed',
FIRMWARE_INSTALLED: 'Firmware installed',
COMPONENT_UPDATED: 'Component updated',
ASSET_UPDATED: 'Server updated',
LOG_COLLECTED: 'Data collected'
};
return labels[key] || (eventType || 'Event');
}
function timelineEventSeverityClass(eventType) {
const key = String(eventType || '').trim().toUpperCase();
if (key === 'REMOVED' || key === 'COMPONENT_FAILED') return 'status-red';
if (key === 'COMPONENT_WARNING') return 'status-yellow';
if (key === 'INSTALLED' || key === 'COMPONENT_OK') return 'status-green';
return 'status-gray';
}
function timelineEventBlockAccentClass(eventType) {
const key = String(eventType || '').trim().toUpperCase();
if (key === 'REMOVED') return 'timeline-modal-item-accent-remove';
if (key === 'INSTALLED') return 'timeline-modal-item-accent-install';
if (key === 'FIRMWARE_CHANGED' || key === 'FIRMWARE_INSTALLED') return 'timeline-modal-item-accent-firmware';
return '';
}
function timelineFormatTimeRange(summary) {
if (!summary || !summary.last_event_at) return '—';
const first = new Date(summary.first_event_at);
const last = new Date(summary.last_event_at);
if (Number.isNaN(first.getTime()) || Number.isNaN(last.getTime())) return '';
const same = first.toDateString() === last.toDateString();
if (same) {
return `${first.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})} - ${last.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}`;
}
return `${first.toLocaleString()} - ${last.toLocaleString()}`;
}
function ensureTimelineModal() {
let modal = document.getElementById('timeline-drilldown-modal');
if (modal) return modal;
modal = createElement('div', 'modal');
modal.id = 'timeline-drilldown-modal';
modal.innerHTML = `
<div class="modal-card" style="width:min(980px,100%);">
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;">
<h3 class="modal-header" id="timeline-modal-title" style="margin:0;">Timeline details</h3>
<button type="button" class="button button-secondary" id="timeline-modal-close">Close</button>
</div>
<div class="meta" id="timeline-modal-meta"></div>
<div style="margin-top:10px;" class="field">
<label for="timeline-modal-search">Filter events in this card</label>
<input id="timeline-modal-search" class="input" type="search" placeholder="Search by serial, part number, slot, device..." />
</div>
<div class="timeline-detail-layout" style="margin-top:12px;">
<div>
<div class="timeline-modal-list" id="timeline-modal-list"></div>
</div>
</div>
</div>`;
document.body.appendChild(modal);
modal.addEventListener('click', (e) => {
if (e.target === modal) modal.classList.remove('open');
});
modal.querySelector('#timeline-modal-close').addEventListener('click', () => modal.classList.remove('open'));
return modal;
}
async function initTimelinePanel(config) {
const root = document.getElementById(config.rootId);
if (!root) return;
let nextCursor = null;
let loading = false;
const state = {
groups: [],
filters: null,
applied: {},
};
root.classList.add('timeline-panel');
root.innerHTML = '';
const toolbar = createElement('div', 'timeline-toolbar');
const cardsWrap = createElement('div', '');
const loadMoreBtn = createElement('button', 'button button-secondary timeline-load-more', 'Load more');
loadMoreBtn.type = 'button';
loadMoreBtn.hidden = true;
root.append(toolbar, cardsWrap, loadMoreBtn);
const controls = {};
function addField(key, label, type = 'text') {
const field = createElement('div', 'field');
const lab = createElement('label', '', label);
const input = type === 'select' ? createElement('select') : createElement('input');
if (type !== 'select') input.type = type;
field.append(lab, input);
toolbar.appendChild(field);
controls[key] = input;
}
addField('date_from', 'From', 'date');
addField('date_to', 'To', 'date');
addField('action', 'Action', 'select');
addField('source', 'Source', 'select');
addField('slot', 'Slot', 'text');
addField('device', 'Device', 'text');
addField('part_number', 'Part number / Model', 'text');
addField('serial', 'Serial', 'text');
const actionButtons = createElement('div', 'timeline-toolbar-actions');
const applyBtn = createElement('button', 'button', 'Apply filters');
applyBtn.type = 'button';
const resetBtn = createElement('button', 'button button-secondary', 'Reset');
resetBtn.type = 'button';
actionButtons.append(applyBtn, resetBtn);
toolbar.appendChild(actionButtons);
function fillSelect(select, values, anyLabel) {
const current = select.value;
select.innerHTML = '';
const anyOpt = createElement('option', '', anyLabel);
anyOpt.value = '';
select.appendChild(anyOpt);
(values || []).forEach((v) => {
const opt = createElement('option', '', v);
opt.value = v;
select.appendChild(opt);
});
if ([...select.options].some((o) => o.value === current)) {
select.value = current;
}
}
function buildQuery(cursor) {
const q = new URLSearchParams();
q.set('limit_cards', '20');
q.set('tz', Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC');
if (cursor) q.set('cursor', cursor);
['date_from','date_to','slot','device','part_number','serial'].forEach((k) => {
const v = (controls[k]?.value || '').trim();
if (v) q.set(k, v);
});
if ((controls.action?.value || '').trim()) q.append('action', controls.action.value.trim());
if ((controls.source?.value || '').trim()) q.append('source', controls.source.value.trim());
return q.toString();
}
function renderCard(card) {
const el = createElement('button', `timeline-card ${timelineCardAccentClass(card.visual_action)}`.trim());
el.type = 'button';
const header = createElement('div', 'timeline-card-header');
const left = createElement('div');
left.appendChild(createElement('div', 'timeline-card-title', card.title || card.visual_action));
const badges = createElement('div', 'timeline-card-badges');
// Action is already communicated by card title + color accent; badges carry metadata only.
const sourceLabel = (card.source && card.source.label) || 'Unknown';
if (sourceLabel !== 'Unknown') {
const sourceBadge = createElement('span', 'timeline-card-badge', sourceLabel);
badges.append(sourceBadge);
}
const isGroupedCard = card.kind === 'bulk' || card.kind === 'dedup';
const isMovementCard = card.visual_action === 'component_installed' || card.visual_action === 'component_removed';
const isFirmwareCard = card.visual_action === 'firmware_changed' || card.visual_action === 'firmware_installed';
const isStatusCard = card.visual_action === 'status_ok' || card.visual_action === 'status_warning' || card.visual_action === 'status_failed';
const isChipGridCard = isGroupedCard && (isMovementCard || isFirmwareCard || isStatusCard);
if (card.counts && card.counts.events > 1 && !isChipGridCard) {
badges.appendChild(createElement('span', 'timeline-card-badge', `${card.counts.events} events`));
}
if (card.counts && card.counts.affected_components > 1 && card.kind === 'bulk') {
badges.appendChild(createElement('span', 'timeline-card-badge', `${card.counts.affected_components} components`));
}
left.appendChild(badges);
const right = createElement('div', 'timeline-card-time', timelineFormatTimeRange(card.time_summary));
header.append(left, right);
el.appendChild(header);
const ctx = [];
if (card.context) {
if (!isStatusCard && card.context.asset_label) ctx.push(`Asset ${card.context.asset_label}`);
if (!isStatusCard && card.context.component_label) ctx.push(`Component ${card.context.component_label}`);
if (!isStatusCard && !isChipGridCard && !card.context.component_label && Array.isArray(card.context.component_serials_sample) && card.context.component_serials_sample.length > 0) {
const sample = card.context.component_serials_sample.join(', ');
ctx.push(`Components ${sample}${(card.counts && card.counts.affected_components > card.context.component_serials_sample.length) ? ' +' + (card.counts.affected_components - card.context.component_serials_sample.length) : ''}`);
}
if (!isChipGridCard && Array.isArray(card.context.part_numbers_sample) && card.context.part_numbers_sample.length > 0) {
ctx.push(`Models ${card.context.part_numbers_sample.join(', ')}`);
}
if (!isStatusCard && !isChipGridCard && card.context.slot) {
ctx.push(`Slot ${card.context.slot}`);
}
if (!isStatusCard && card.context.device) ctx.push(`Device ${card.context.device}`);
}
if (isChipGridCard && card.context) {
const grid = createElement('div', 'timeline-card-context-grid');
const modelCol = createElement('div', 'timeline-card-context-col');
modelCol.appendChild(createElement('div', 'timeline-card-context-col-label', 'Models'));
const modelBody = createElement('div', 'timeline-card-context-col-body');
const chipClass = card.visual_action === 'component_removed' ? 'timeline-chip timeline-chip-remove' : 'timeline-chip timeline-chip-install';
if (Array.isArray(card.context.part_number_counts) && card.context.part_number_counts.length > 0) {
const chips = createElement('div', 'timeline-chip-list');
card.context.part_number_counts.forEach((line) => chips.appendChild(createElement('span', chipClass, line)));
modelBody.appendChild(chips);
} else {
modelBody.textContent = '—';
}
modelCol.appendChild(modelBody);
const slotCol = createElement('div', 'timeline-card-context-col');
const slotBody = createElement('div', 'timeline-card-context-col-body');
if (isMovementCard) {
slotCol.appendChild(createElement('div', 'timeline-card-context-col-label', 'Slots'));
if (Array.isArray(card.context.slot_groups) && card.context.slot_groups.length > 0) {
const chips = createElement('div', 'timeline-chip-list');
card.context.slot_groups.forEach((line) => chips.appendChild(createElement('span', chipClass, line)));
slotBody.appendChild(chips);
} else if (Array.isArray(card.context.slot_samples) && card.context.slot_samples.length > 0) {
const chips = createElement('div', 'timeline-chip-list');
card.context.slot_samples.forEach((line) => chips.appendChild(createElement('span', chipClass, line)));
slotBody.appendChild(chips);
} else {
slotBody.textContent = '—';
}
} else if (isFirmwareCard) {
slotCol.appendChild(createElement('div', 'timeline-card-context-col-label', 'Firmwares'));
if (card.summary && Array.isArray(card.summary.firmware_versions) && card.summary.firmware_versions.length > 0) {
const chips = createElement('div', 'timeline-chip-list');
card.summary.firmware_versions.forEach((line) => chips.appendChild(createElement('span', chipClass, line)));
slotBody.appendChild(chips);
} else if (card.summary && card.summary.firmware_transition) {
const chips = createElement('div', 'timeline-chip-list');
chips.appendChild(createElement('span', chipClass, card.summary.firmware_transition));
slotBody.appendChild(chips);
} else {
slotBody.textContent = '—';
}
} else {
slotCol.appendChild(createElement('div', 'timeline-card-context-col-label', 'Status'));
const chips = createElement('div', 'timeline-chip-list');
const statusLabel = card.visual_action === 'status_failed' ? 'Failed' : (card.visual_action === 'status_warning' ? 'Warning' : 'OK');
chips.appendChild(createElement('span', chipClass, statusLabel));
slotBody.appendChild(chips);
}
slotCol.appendChild(slotBody);
grid.append(modelCol, slotCol);
el.appendChild(grid);
}
if (ctx.length > 0) {
el.appendChild(createElement('div', 'timeline-card-context', ctx.join(' · ')));
}
const sub = [];
if (card.summary && card.summary.firmware_transition) sub.push(`Firmware ${card.summary.firmware_transition}`);
if (!isChipGridCard && isFirmwareCard && card.summary && Array.isArray(card.summary.firmware_versions) && card.summary.firmware_versions.length > 0) {
sub.push(`Firmwares: ${card.summary.firmware_versions.join(', ')}`);
}
if (card.summary && Array.isArray(card.summary.changed_fields) && card.summary.changed_fields.length > 0) sub.push(`Fields: ${card.summary.changed_fields.join(', ')}`);
if (card.counts && card.kind === 'bulk') {
if (card.visual_action === 'component_installed') {
sub.push(`Installed ${card.counts.affected_components || 0} components`);
} else if (card.visual_action === 'component_removed') {
sub.push(`Removed ${card.counts.affected_components || 0} components`);
} else {
sub.push(`Affected: ${card.counts.affected_components || 0} components`);
}
}
if (sub.length > 0) {
el.appendChild(createElement('div', 'timeline-card-subline', sub.join(' · ')));
}
const handleOpen = (e) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
openTimelineCard(config, card);
};
el.addEventListener('click', handleOpen);
el.onclick = handleOpen;
return el;
}
function renderTimeline() {
cardsWrap.innerHTML = '';
if (!state.groups || state.groups.length === 0) {
cardsWrap.appendChild(createElement('div', 'timeline-empty', 'No timeline events for selected filters.'));
return;
}
state.groups.forEach((g) => {
const day = createElement('div', 'timeline-day');
day.appendChild(createElement('div', 'timeline-day-title', g.day));
(g.cards || []).forEach((card) => day.appendChild(renderCard(card)));
cardsWrap.appendChild(day);
});
}
function applyFacetVisibility(hidden) {
const hiddenSet = new Set(hidden || []);
const hideKeys = [];
if (hiddenSet.has('component_serial')) hideKeys.push('serial');
if (hiddenSet.has('part_number')) hideKeys.push('part_number');
hideKeys.forEach((key) => {
const field = controls[key] && controls[key].closest('.field');
if (field) field.style.display = 'none';
});
}
function hydrateFacets(resp) {
state.filters = resp.filters || null;
const available = (resp.filters && resp.filters.available) || {};
fillSelect(controls.action, available.actions || [], 'Any action');
fillSelect(controls.source, available.sources || [], 'Any source');
applyFacetVisibility((resp.filters && resp.filters.hidden_for_scope) || []);
}
async function fetchCards(append) {
if (loading) return;
loading = true;
try {
const qs = buildQuery(append ? nextCursor : null);
const resp = await fetch(`${config.apiBase}?${qs}`);
if (!resp.ok) throw new Error(`timeline load failed (${resp.status})`);
const payload = await resp.json();
if (append) {
state.groups = mergeTimelineGroups(state.groups, payload.groups || []);
} else {
state.groups = payload.groups || [];
hydrateFacets(payload);
}
nextCursor = payload.next_cursor || null;
loadMoreBtn.hidden = !nextCursor;
renderTimeline();
} catch (err) {
cardsWrap.innerHTML = '';
cardsWrap.appendChild(createElement('div', 'error', err.message || 'Failed to load timeline'));
} finally {
loading = false;
}
}
applyBtn.addEventListener('click', () => fetchCards(false));
resetBtn.addEventListener('click', () => {
Object.values(controls).forEach((el) => { if (el) el.value = ''; });
fetchCards(false);
});
loadMoreBtn.addEventListener('click', () => fetchCards(true));
fetchCards(false);
}
function mergeTimelineGroups(existing, incoming) {
const map = new Map();
(existing || []).forEach((g) => map.set(g.day, {day: g.day, cards: [...(g.cards || [])]}));
(incoming || []).forEach((g) => {
if (!map.has(g.day)) {
map.set(g.day, {day: g.day, cards: []});
}
map.get(g.day).cards.push(...(g.cards || []));
});
return [...map.values()].sort((a, b) => a.day < b.day ? 1 : -1);
}
async function initImportHistoryPanel(config) {
const root = document.getElementById(config.rootId);
if (!root) return;
let nextCursor = null;
let loading = false;
const state = { items: [], query: '' };
root.classList.add('timeline-panel');
root.innerHTML = '';
const controls = createElement('div', 'timeline-controls');
const searchField = createElement('div', 'field');
const searchInput = createElement('input', 'input');
searchInput.type = 'search';
searchInput.placeholder = 'Search import id, file, host, asset, source...';
searchField.style.minWidth = '280px';
searchField.appendChild(searchInput);
controls.appendChild(searchField);
const cardsWrap = createElement('div', '');
const loadMoreBtn = createElement('button', 'button button-secondary timeline-load-more', 'Load more');
loadMoreBtn.type = 'button';
loadMoreBtn.hidden = true;
root.append(controls, cardsWrap, loadMoreBtn);
function buildQuery(cursor) {
const q = new URLSearchParams();
q.set('limit', '20');
if (cursor) q.set('cursor', cursor);
return q.toString();
}
function renderImportCard(item) {
const el = createElement('button', 'timeline-card timeline-card-accent-firmware');
el.type = 'button';
el.style.textAlign = 'left';
el.style.width = '100%';
el.style.border = '';
const bundleId = (item.bundle_id || '').trim();
if (bundleId) {
el.style.cursor = 'pointer';
const openImport = () => { window.location.href = `/ui/ingest/history/${encodeURIComponent(bundleId)}`; };
el.addEventListener('click', openImport);
el.onclick = openImport;
} else {
el.style.cursor = 'default';
}
const header = createElement('div', 'timeline-card-header');
const left = createElement('div');
left.appendChild(createElement('div', 'timeline-card-title', `Import ${bundleId || ''}`.trim()));
const badges = createElement('div', 'timeline-card-badges');
const source = (item.source || '').trim();
if (source) badges.appendChild(createElement('span', 'timeline-card-badge', source));
const protocol = (item.protocol || '').trim();
if (protocol) badges.appendChild(createElement('span', 'timeline-card-badge', protocol));
if (bundleId) badges.appendChild(createElement('span', 'timeline-card-badge', 'Open'));
left.appendChild(badges);
header.append(left, createElement('div', 'timeline-card-time', timelineFormatDateTime(item.collected_at)));
el.appendChild(header);
const details = [];
if (item.filename) details.push(`File ${item.filename}`);
if (item.target_host) details.push(`Target ${item.target_host}`);
if (item.asset_name || item.asset_vendor_serial) {
const assetLabel = [item.asset_name, item.asset_vendor_serial].filter(Boolean).join(' · ');
if (assetLabel) details.push(`Asset ${assetLabel}`);
}
if (typeof item.observed_components === 'number') details.push(`Observed components ${item.observed_components}`);
if (details.length > 0) {
el.appendChild(createElement('div', 'timeline-card-context', details.join(' · ')));
}
const sub = [];
if (item.uploaded_at) sub.push(`Uploaded ${timelineFormatDateTime(item.uploaded_at)}`);
if (sub.length > 0) {
el.appendChild(createElement('div', 'timeline-card-subline', sub.join(' · ')));
}
return el;
}
function importSearchBlob(item) {
return [
item.bundle_id,
item.filename,
item.target_host,
item.protocol,
item.source,
item.asset_name,
item.asset_vendor_serial,
item.asset_id
].filter(Boolean).join(' ').toLowerCase();
}
function formatLocalDay(value) {
if (!value) return 'Unknown date';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return 'Unknown date';
return d.toLocaleDateString();
}
function renderTimeline() {
cardsWrap.innerHTML = '';
const query = (state.query || '').trim().toLowerCase();
const filteredItems = (state.items || []).filter((item) => {
if (!query) return true;
return importSearchBlob(item).includes(query);
});
if (filteredItems.length === 0) {
const emptyText = query ? 'No import history entries match search.' : 'No import history entries.';
cardsWrap.appendChild(createElement('div', 'timeline-empty', emptyText));
return;
}
if (!state.items || state.items.length === 0) {
cardsWrap.appendChild(createElement('div', 'timeline-empty', 'No import history entries.'));
return;
}
const groups = new Map();
filteredItems.forEach((item) => {
const day = formatLocalDay(item.collected_at || item.uploaded_at);
if (!groups.has(day)) groups.set(day, {label: day, sortTime: new Date(item.collected_at || item.uploaded_at).getTime(), items: []});
groups.get(day).items.push(item);
});
[...groups.values()]
.sort((a, b) => (Number.isFinite(a.sortTime) ? a.sortTime : 0) < (Number.isFinite(b.sortTime) ? b.sortTime : 0) ? 1 : -1)
.forEach((groupData) => {
const group = createElement('div', 'timeline-day');
group.appendChild(createElement('div', 'timeline-day-title', groupData.label));
groupData.items.forEach((item) => group.appendChild(renderImportCard(item)));
cardsWrap.appendChild(group);
});
}
async function fetchItems(append) {
if (loading) return;
loading = true;
try {
const resp = await fetch(`${config.apiBase}?${buildQuery(append ? nextCursor : null)}`);
if (!resp.ok) throw new Error(`import history load failed (${resp.status})`);
const payload = await resp.json();
if (append) {
state.items = [...state.items, ...(payload.items || [])];
} else {
state.items = payload.items || [];
}
nextCursor = payload.next_cursor || null;
loadMoreBtn.hidden = !nextCursor;
renderTimeline();
} catch (err) {
cardsWrap.innerHTML = '';
cardsWrap.appendChild(createElement('div', 'error', err.message || 'Failed to load import history'));
} finally {
loading = false;
}
}
loadMoreBtn.addEventListener('click', () => fetchItems(true));
searchInput.addEventListener('input', () => {
state.query = searchInput.value || '';
renderTimeline();
});
fetchItems(false);
}
function isIngestSourceType(sourceType) {
const normalized = String(sourceType || '').trim().toLowerCase();
return normalized === 'ingest_json' || normalized === 'ingest_csv';
}
function ingestBundleURL(sourceRef) {
const ref = String(sourceRef || '').trim();
if (!ref) return null;
return `/ui/ingest/history/${encodeURIComponent(ref)}`;
}
function appendIngestLink(container, sourceRef, label) {
const url = ingestBundleURL(sourceRef);
if (!url) return;
const link = document.createElement('a');
link.href = url;
link.textContent = label || 'Open ingest bundle';
link.style.display = 'inline-block';
link.style.marginTop = '8px';
link.addEventListener('click', (e) => e.stopPropagation());
container.appendChild(link);
}
function timelineStatusLabel(value) {
const normalized = String(value || '').trim().toUpperCase();
if (normalized === 'OK') return 'OK';
if (normalized === 'WARNING') return 'Warning';
if (normalized === 'FAILED') return 'Failed';
if (normalized === 'UNKNOWN') return 'Unknown';
return normalized || 'Unknown';
}
function timelinePathLabel(path) {
const p = String(path || '').trim();
if (p === '/runtime/health_status') return 'Status';
if (p === '/runtime/health_status_at') return 'Status timestamp';
if (p === '/runtime/firmware_version') return 'Firmware version';
if (p === '/metadata/last_collected_at') return 'Last collected at';
if (p === '/installation/current_machine_id') return 'Installed on server';
if (p === '/installation/installed_at') return 'Install time';
if (p === '/installation/slot_name') return 'Slot';
if (p === '/metadata/component_type') return 'Component type';
if (p.startsWith('/firmware/devices/')) return `Firmware on ${decodeURIComponent(p.slice('/firmware/devices/'.length))}`;
return p;
}
function timelineNormalizeStatus(value) {
const normalized = String(value || '').trim().toUpperCase();
if (normalized === 'OK') return 'OK';
if (normalized === 'WARNING') return 'WARNING';
if (normalized === 'FAILED') return 'FAILED';
if (normalized === 'UNKNOWN') return 'UNKNOWN';
return 'UNKNOWN';
}
function timelineStatusBadgeClass(status) {
const s = timelineNormalizeStatus(status);
if (s === 'OK') return 'status-green';
if (s === 'WARNING') return 'status-yellow';
if (s === 'FAILED') return 'status-red';
return 'status-gray';
}
function timelineStatusShort(status) {
const s = timelineNormalizeStatus(status);
if (s === 'WARNING') return 'WARNING';
if (s === 'FAILED') return 'FAILED';
if (s === 'OK') return 'OK';
return 'UNK';
}
function timelineExtractServerSerial(assetLabel) {
const label = String(assetLabel || '').trim();
if (!label) return '';
const m = label.match(/\(([^)]+)\)\s*$/);
if (m && m[1]) return m[1].trim();
return label;
}
function timelineStatusFromPayload(payload) {
if (payload && typeof payload.new_status === 'string' && String(payload.new_status).trim() !== '') {
return timelineNormalizeStatus(payload.new_status);
}
const historyEvent = payload && payload.history_event ? payload.history_event : null;
const patch = historyEvent && Array.isArray(historyEvent.patch_json) ? historyEvent.patch_json : [];
for (const op of patch) {
if (!op || typeof op !== 'object') continue;
if (op.path === '/runtime/health_status') return timelineNormalizeStatus(op.value);
}
return 'UNKNOWN';
}
function timelineMovementBeforeAfter(item, payload) {
const eventType = String(item && item.event_type || '').trim().toUpperCase();
let patchSlot = '';
const historyEvent = payload && payload.history_event ? payload.history_event : null;
const patch = historyEvent && Array.isArray(historyEvent.patch_json) ? historyEvent.patch_json : [];
patch.forEach((op) => {
if (!op || typeof op !== 'object') return;
if (op.path === '/installation/slot_name' && op.value != null) {
patchSlot = String(op.value).trim();
}
});
const slotFromItem = String(item && item.slot || '').trim();
const slot = slotFromItem || patchSlot;
let slotBefore = '';
let slotAfter = '';
if (eventType === 'INSTALLED') {
slotAfter = slot;
} else if (eventType === 'REMOVED') {
slotBefore = slot;
} else {
slotAfter = slot;
}
let statusBefore = '';
let statusAfter = '';
const hasPrev = payload && typeof payload.previous_status === 'string' && String(payload.previous_status).trim() !== '';
const hasNext = payload && typeof payload.new_status === 'string' && String(payload.new_status).trim() !== '';
if (hasPrev || hasNext) {
statusBefore = timelineNormalizeStatus(payload.previous_status);
statusAfter = timelineNormalizeStatus(payload.new_status);
} else {
const inferred = timelineStatusFromPayload(payload);
if (eventType === 'INSTALLED') {
statusBefore = 'UNKNOWN';
statusAfter = inferred;
} else if (eventType === 'REMOVED') {
statusBefore = inferred;
statusAfter = 'UNKNOWN';
} else {
statusBefore = 'UNKNOWN';
statusAfter = inferred;
}
}
return {
slotBefore: slotBefore || '—',
slotAfter: slotAfter || '—',
statusBefore: statusBefore || 'UNKNOWN',
statusAfter: statusAfter || 'UNKNOWN',
};
}
function renderTimelineTileDetail(item, payload, container) {
if (!container) return;
const historyEvent = payload && payload.history_event ? payload.history_event : null;
container.innerHTML = '';
const addSection = (rows, emptyText) => {
const section = createElement('div', 'timeline-modal-item-section');
if (!rows || rows.length === 0) {
const row = createElement('div', 'timeline-modal-item-detail-row');
row.appendChild(createElement('div', 'timeline-modal-item-detail-key', 'Info'));
row.appendChild(createElement('div', 'timeline-modal-item-detail-value', emptyText));
section.appendChild(row);
container.appendChild(section);
return;
}
rows.forEach((rowData) => {
const row = createElement('div', 'timeline-modal-item-detail-row');
row.appendChild(createElement('div', 'timeline-modal-item-detail-key', rowData.key));
const value = createElement('div', 'timeline-modal-item-detail-value');
if (rowData.valueNode) value.appendChild(rowData.valueNode);
else value.textContent = rowData.value == null ? '—' : String(rowData.value);
row.appendChild(value);
section.appendChild(row);
});
container.appendChild(section);
};
const detailRows = [];
if (historyEvent && historyEvent.source_ref) {
const node = createElement('span', 'button-row');
node.style.gap = '8px';
node.appendChild(createElement('span', 'badge status-gray', historyEvent.source_ref));
if (isIngestSourceType(historyEvent.source_type)) {
const link = document.createElement('a');
link.href = ingestBundleURL(historyEvent.source_ref) || '#';
link.textContent = 'Open ingest event';
link.addEventListener('click', (e) => e.stopPropagation());
node.appendChild(link);
}
detailRows.push({key: 'Evidence (ref)', valueNode: node});
}
if (typeof payload.previous_status === 'string' || typeof payload.new_status === 'string') {
const node = createElement('div', 'button-row');
node.style.gap = '8px';
node.appendChild(createElement('span', 'badge status-gray', timelineStatusLabel(payload.previous_status)));
node.appendChild(createElement('span', 'meta', '->'));
const next = String(payload.new_status || '').trim().toUpperCase();
const nextClass = next === 'FAILED' ? 'status-red' : (next === 'WARNING' ? 'status-yellow' : (next === 'OK' ? 'status-green' : 'status-gray'));
node.appendChild(createElement('span', `badge ${nextClass}`, timelineStatusLabel(payload.new_status)));
detailRows.push({key: 'Status', valueNode: node});
}
const patch = historyEvent && Array.isArray(historyEvent.patch_json) ? historyEvent.patch_json : [];
patch.forEach((op) => {
if (!op || typeof op !== 'object') return;
if (op.path === '/runtime/health_status') return;
const label = timelinePathLabel(op.path);
if (!label) return;
const rawValue = op.value == null ? '' : String(op.value).trim();
if (rawValue === '') return;
detailRows.push({key: label, value: rawValue});
});
addSection(detailRows, 'No additional details.');
}
async function openTimelineCard(config, card) {
const modal = ensureTimelineModal();
const isCollapsedMovement = (card.kind === 'bulk' || card.kind === 'dedup') &&
(card.visual_action === 'component_installed' || card.visual_action === 'component_removed');
modal.classList.add('open');
let modalTitle = card.title || 'Timeline details';
if (isCollapsedMovement && card.visual_action === 'component_installed') {
modalTitle = 'Installation';
} else if (isCollapsedMovement && card.visual_action === 'component_removed') {
modalTitle = 'Removal';
}
modal.querySelector('#timeline-modal-title').textContent = modalTitle;
const metaParts = [];
const sourceLabel = (card.source && card.source.label) || 'Unknown';
if (sourceLabel !== 'Unknown') metaParts.push(sourceLabel);
metaParts.push(timelineFormatDateTime(card.time_summary && card.time_summary.first_event_at));
if (isCollapsedMovement) {
const count = (card.counts && card.counts.affected_components) || 0;
metaParts.push(card.visual_action === 'component_installed' ? `Installed ${count} components` : `Removed ${count} components`);
} else {
metaParts.push(`${card.counts ? card.counts.events : 0} events`);
}
modal.querySelector('#timeline-modal-meta').textContent = metaParts.join(' · ');
const list = modal.querySelector('#timeline-modal-list');
const search = modal.querySelector('#timeline-modal-search');
list.innerHTML = '<div class="meta">Loading...</div>';
let items = [];
let activeId = null;
const isMovementCard = card.visual_action === 'component_installed' || card.visual_action === 'component_removed';
const rowByID = new Map();
const detailByID = new Map();
function renderList() {
const q = (search.value || '').trim().toLowerCase();
list.innerHTML = '';
rowByID.clear();
const filtered = items.filter((it) => {
if (!q) return true;
const hay = [it.event_type, it.component_serial, it.part_number, it.slot, it.device, it.asset_label, it.component_label, it.source_ref].filter(Boolean).join(' ').toLowerCase();
return hay.includes(q);
});
if (filtered.length === 0) {
list.appendChild(createElement('div', 'meta', 'No events match filter.'));
return;
}
if (isMovementCard) {
const groups = new Map();
filtered.forEach((it) => {
const model = String(it.part_number || '').trim() || 'Unknown model';
const evidence = String(it.source_ref || '').trim() || '—';
const serverSN = timelineExtractServerSerial(it.asset_label);
const groupKey = `${model}||${evidence}||${serverSN}`;
if (!groups.has(groupKey)) {
groups.set(groupKey, {model, evidence, serverSN, rows: [], firstTime: it.event_time || ''});
}
groups.get(groupKey).rows.push(it);
});
[...groups.values()].forEach((group) => {
const sample = group.rows[0];
const accentClass = timelineEventBlockAccentClass(sample && sample.event_type);
const row = createElement('div', `timeline-modal-item${accentClass ? ` ${accentClass}` : ''}`);
const actionKey = String(sample && sample.event_type || '').trim().toUpperCase();
let actionPhrase = 'updated on';
if (actionKey === 'REMOVED') actionPhrase = 'removed from';
if (actionKey === 'INSTALLED') actionPhrase = 'installed in';
const assetPart = group.serverSN || 'unknown asset';
const sourcePart = String(sample && sample.source_type || 'source').trim() || 'source';
const head = createElement('div', 'timeline-modal-item-head');
const title = createElement('div', 'timeline-modal-item-title');
title.appendChild(createElement('span', 'timeline-modal-item-title-var', group.model));
title.appendChild(createElement('span', 'timeline-modal-item-title-static', actionPhrase));
title.appendChild(createElement('span', 'timeline-modal-item-title-var', assetPart));
const hasEvidence = group.evidence && group.evidence !== '—';
const ingestURL = hasEvidence ? ingestBundleURL(group.evidence) : null;
if (isIngestSourceType(sample && sample.source_type)) {
title.appendChild(createElement('span', 'timeline-modal-item-title-static', 'based on ingest'));
if (ingestURL) {
const ingestChip = document.createElement('a');
ingestChip.href = ingestURL;
ingestChip.className = 'badge status-gray';
ingestChip.style.textDecoration = 'none';
ingestChip.textContent = group.evidence;
ingestChip.title = 'Open ingest event';
ingestChip.addEventListener('click', (e) => e.stopPropagation());
title.appendChild(ingestChip);
}
} else {
title.appendChild(createElement('span', 'timeline-modal-item-title-static', 'based on'));
title.appendChild(createElement('span', 'timeline-modal-item-title-var', sourcePart));
if (hasEvidence) title.appendChild(createElement('span', 'timeline-modal-item-title-var', group.evidence));
}
head.appendChild(title);
head.appendChild(createElement('div', 'timeline-modal-item-meta', timelineFormatDateTime(group.firstTime)));
row.appendChild(head);
const table = createElement('table', 'table');
table.style.marginTop = '8px';
table.setAttribute('data-disable-auto-filters', 'true');
table.innerHTML = `
<thead>
<tr>
<th>Component S/N</th>
<th>Before</th>
<th>After</th>
</tr>
</thead>
<tbody></tbody>
`;
const tbody = table.querySelector('tbody');
group.rows.sort((a, b) => String(a.component_serial || '').localeCompare(String(b.component_serial || '')));
group.rows.forEach((it) => {
rowByID.set(it.timeline_event_id, {item: it, detailWrap: null, row: null});
const tr = document.createElement('tr');
tr.className = 'clickable';
const serial = String(it.component_serial || '').trim() || '—';
const payload = detailByID.get(it.timeline_event_id);
const beforeAfter = timelineMovementBeforeAfter(it, payload);
const serialCell = document.createElement('td');
if (serial === '—') {
serialCell.textContent = '—';
} else {
const serialLink = document.createElement('a');
serialLink.href = `/ui/search?q=${encodeURIComponent(serial)}`;
serialLink.textContent = serial;
serialLink.addEventListener('click', (e) => e.stopPropagation());
serialCell.appendChild(serialLink);
}
tr.appendChild(serialCell);
const beforeCell = document.createElement('td');
const beforeWrap = createElement('div', 'button-row');
beforeWrap.style.gap = '6px';
beforeWrap.appendChild(createElement('span', 'badge status-gray', `SLOT: ${beforeAfter.slotBefore}`));
beforeWrap.appendChild(createElement('span', `badge ${timelineStatusBadgeClass(beforeAfter.statusBefore)}`, timelineStatusShort(beforeAfter.statusBefore)));
beforeCell.appendChild(beforeWrap);
tr.appendChild(beforeCell);
const afterCell = document.createElement('td');
const afterWrap = createElement('div', 'button-row');
afterWrap.style.gap = '6px';
afterWrap.appendChild(createElement('span', 'badge status-gray', `SLOT: ${beforeAfter.slotAfter}`));
afterWrap.appendChild(createElement('span', `badge ${timelineStatusBadgeClass(beforeAfter.statusAfter)}`, timelineStatusShort(beforeAfter.statusAfter)));
afterCell.appendChild(afterWrap);
tr.appendChild(afterCell);
tr.addEventListener('click', () => {
if (serial !== '—') {
window.location.assign(`/ui/search?q=${encodeURIComponent(serial)}`);
}
});
tbody.appendChild(tr);
});
row.appendChild(table);
list.appendChild(row);
});
return;
}
filtered.forEach((it) => {
const rowClass = ['timeline-modal-item'];
const accentClass = timelineEventBlockAccentClass(it.event_type);
if (accentClass) rowClass.push(accentClass);
if (activeId === it.timeline_event_id) rowClass.push('active');
const row = createElement('div', rowClass.join(' '));
const head = createElement('div', 'button-row');
head.style.justifyContent = 'space-between';
head.style.alignItems = 'center';
const left = createElement('div', 'button-row');
left.style.gap = '6px';
left.appendChild(createElement('span', `badge ${timelineEventSeverityClass(it.event_type)}`, timelineEventTypeLabel(it.event_type)));
if (it.source_type) left.appendChild(createElement('span', 'badge status-gray', it.source_type));
head.appendChild(left);
head.appendChild(createElement('div', 'meta', timelineFormatDateTime(it.event_time)));
row.appendChild(head);
const primaryLine = createElement('div', 'timeline-modal-item-line');
const primaryParts = [];
if (it.component_label) primaryParts.push(`Component: ${it.component_label}`);
if (it.asset_label) primaryParts.push(`Server: ${it.asset_label}`);
if (it.slot) primaryParts.push(`Slot: ${it.slot}`);
if (it.device) primaryParts.push(`Device: ${it.device}`);
if (it.firmware_version) primaryParts.push(`FW: ${it.firmware_version}`);
primaryLine.textContent = primaryParts.join(' · ');
row.appendChild(primaryLine);
const detailWrap = createElement('div', 'timeline-modal-item-detail');
const cached = detailByID.get(it.timeline_event_id);
if (cached) renderTimelineTileDetail(it, cached, detailWrap);
else detailWrap.innerHTML = '<div class="meta">Loading details...</div>';
row.appendChild(detailWrap);
row.addEventListener('click', () => {
activeId = it.timeline_event_id;
loadTimelineEventDetail(config, it, detailWrap, row, list, detailByID);
});
rowByID.set(it.timeline_event_id, {item: it, detailWrap: detailWrap, row: row});
list.appendChild(row);
});
}
search.oninput = renderList;
try {
const tz = encodeURIComponent(Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC');
const resp = await fetch(`${config.apiBase}/cards/${encodeURIComponent(card.card_id)}/events?tz=${tz}`);
if (!resp.ok) throw new Error(`drilldown load failed (${resp.status})`);
const payload = await resp.json();
items = payload.items || [];
renderList();
if (items.length > 0) {
activeId = items[0].timeline_event_id;
const jobs = items.map((it) => {
const pair = rowByID.get(it.timeline_event_id);
if (!pair) return loadTimelineEventDetail(config, it, null, null, list, detailByID, false);
return loadTimelineEventDetail(config, it, pair.detailWrap, pair.row, list, detailByID, false);
});
await Promise.all(jobs);
if (isMovementCard) {
renderList();
return;
}
const first = rowByID.get(items[0].timeline_event_id);
if (first && first.row) first.row.classList.add('active');
}
} catch (err) {
list.innerHTML = '';
list.appendChild(createElement('div', 'error', err.message || 'Failed to load card events'));
}
}
async function loadTimelineEventDetail(config, item, detailWrap, clickedRow, listEl, detailByID, activate = true) {
listEl.querySelectorAll('.timeline-modal-item').forEach((el) => el.classList.remove('active'));
if (activate && clickedRow) clickedRow.classList.add('active');
if (detailWrap) detailWrap.innerHTML = '<div class="meta">Loading details...</div>';
try {
let payload = detailByID.get(item.timeline_event_id);
if (!payload) {
const resp = await fetch(`${config.detailBase}/${encodeURIComponent(item.timeline_event_id)}/detail`);
if (!resp.ok) throw new Error(`event detail failed (${resp.status})`);
payload = await resp.json();
detailByID.set(item.timeline_event_id, payload);
}
renderTimelineTileDetail(item, payload, detailWrap);
} catch (err) {
if (detailWrap) detailWrap.innerHTML = `<div class="error">${err.message || 'Failed to load event details'}</div>`;
}
}
</script>
</head>
{{end}}
{{define "topbar"}}
<header class="topbar">
<div class="topbar-main">
<div class="brand">Reanimator</div>
<h1 class="title">{{.PageTitle}}</h1>
<div class="subtitle">{{if .PageSubtitle}}{{.PageSubtitle}}{{else}}Assets, components, failures{{end}}</div>
</div>
<div class="topbar-side">
<form class="topbar-search" method="get" action="/ui/search">
<input type="search" name="q" value="{{.SearchQuery}}" placeholder="Search assets, components, failures..." />
<button type="submit">Search</button>
</form>
{{if .HeroTag}}<div class="pill {{if .HeroTagClass}}{{.HeroTagClass}}{{else}}pill-neutral{{end}}">{{.HeroTag}}</div>{{end}}
</div>
</header>
<nav class="nav">
<div class="nav-item"><a href="/ui" class="{{if eq .ActiveNav "dashboard"}}active{{end}}">Dashboard</a></div>
<div class="nav-item"><a href="/ui/search" class="{{if eq .ActiveNav "search"}}active{{end}}">Search</a></div>
<div class="nav-item"><a href="/ui/asset" class="{{if eq .ActiveNav "assets"}}active{{end}}">Asset</a></div>
<div class="nav-group">
<div class="nav-group-label {{if or (eq .ActiveNav "components") (eq .ActiveNav "components-models") (eq .ActiveNav "components-uninstalled")}}active{{end}}">Components</div>
<div class="nav-submenu">
<a href="/ui/component" class="{{if eq .ActiveNav "components"}}active{{end}}">Component</a>
<a href="/ui/component/models" class="{{if eq .ActiveNav "components-models"}}active{{end}}">Model</a>
<a href="/ui/component/uninstalled" class="{{if eq .ActiveNav "components-uninstalled"}}active{{end}}">Uninstalled</a>
</div>
</div>
<div class="nav-item"><a href="/ui/failure" class="{{if eq .ActiveNav "failures"}}active{{end}}">Failure</a></div>
<div class="nav-group">
<div class="nav-group-label {{if or (eq .ActiveNav "ingest") (eq .ActiveNav "ingest-history")}}active{{end}}">Ingest</div>
<div class="nav-submenu">
<a href="/ui/ingest" class="{{if eq .ActiveNav "ingest"}}active{{end}}">Console</a>
<a href="/ui/ingest/history" class="{{if eq .ActiveNav "ingest-history"}}active{{end}}">History</a>
</div>
</div>
<div class="nav-item"><a href="/ui/history-admin" class="{{if eq .ActiveNav "history-admin"}}active{{end}}">Data Admin</a></div>
</nav>
{{end}}
{{define "pagination"}}
{{if gt .TotalItems 0}}
<nav class="pagination">
<div class="pagination-summary">Showing {{.FromItem}}{{.ToItem}} of {{.TotalItems}}</div>
{{if gt .TotalPages 1}}
<div class="pagination-links">
{{if .HasPrev}}<a class="pagination-link" href="{{.PrevURL}}">Prev</a>{{else}}<span class="pagination-link-disabled">Prev</span>{{end}}
{{range .Links}}
{{if .Ellipsis}}
<span class="pagination-link-ellipsis">{{.Label}}</span>
{{else if .Active}}
<span class="pagination-link-active">{{.Label}}</span>
{{else}}
<a class="pagination-link" href="{{.URL}}">{{.Label}}</a>
{{end}}
{{end}}
{{if .HasNext}}<a class="pagination-link" href="{{.NextURL}}">Next</a>{{else}}<span class="pagination-link-disabled">Next</span>{{end}}
</div>
{{end}}
</nav>
{{end}}
{{end}}
{{define "breadcrumbs"}}
{{if .Breadcrumbs}}
<nav class="breadcrumbs">
<a href="/ui" class="home-icon">🏥</a>
{{range $i, $crumb := .Breadcrumbs}}
<span class="separator"></span>
{{if $crumb.URL}}
<a href="{{$crumb.URL}}">{{$crumb.Label}}</a>
{{else}}
<span class="current">{{$crumb.Label}}</span>
{{end}}
{{end}}
</nav>
{{end}}
{{end}}