- 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>
2250 lines
80 KiB
Cheetah
2250 lines
80 KiB
Cheetah
{{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}}
|