feat: add Vue monitor web sentinel dashboard
This commit is contained in:
@@ -0,0 +1,782 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f5f7f8;
|
||||
--panel: #ffffff;
|
||||
--panel-soft: #f9fbfb;
|
||||
--text: #17201f;
|
||||
--muted: #5e6b68;
|
||||
--line: #dce3e1;
|
||||
--line-strong: #b8c4c0;
|
||||
--red: #d43c35;
|
||||
--red-soft: #ffe8e5;
|
||||
--amber: #b7791f;
|
||||
--amber-soft: #fff2d6;
|
||||
--green: #1f8a5b;
|
||||
--green-soft: #e4f6ed;
|
||||
--blue: #2a6fbb;
|
||||
--ink: #203330;
|
||||
--shadow: 0 10px 28px rgba(32, 51, 48, 0.08);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#monitor-web-root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button,
|
||||
select,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.monitor-shell {
|
||||
display: flex;
|
||||
height: 100dvh;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
overflow: hidden;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.status-strip,
|
||||
.entry-strip,
|
||||
.trend-stage,
|
||||
.workspace-grid {
|
||||
width: min(100%, 1760px);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex: 0 0 auto;
|
||||
min-height: 58px;
|
||||
}
|
||||
|
||||
.title-block {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mark {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 2px solid var(--ink);
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #e7f1ef 100%);
|
||||
box-shadow: inset 0 -8px 0 rgba(31, 138, 91, 0.16);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
line-height: 1.15;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
code,
|
||||
.mono {
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
.pill,
|
||||
.toolbar-button,
|
||||
.entry-link,
|
||||
.legend-item,
|
||||
.metric,
|
||||
.timeline-item,
|
||||
.run-row,
|
||||
.finding-card,
|
||||
.check-chip {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-button,
|
||||
select {
|
||||
min-height: 34px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toolbar-button {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
select {
|
||||
padding: 0 28px 0 10px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pill::before {
|
||||
content: "";
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.pill.blocked,
|
||||
.pill.degraded {
|
||||
border-color: #efb3ad;
|
||||
background: var(--red-soft);
|
||||
color: #8b1f1a;
|
||||
}
|
||||
|
||||
.pill.blocked::before,
|
||||
.pill.degraded::before {
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
.pill.warning {
|
||||
border-color: #e5c06b;
|
||||
background: var(--amber-soft);
|
||||
color: #73500f;
|
||||
}
|
||||
|
||||
.pill.warning::before {
|
||||
background: var(--amber);
|
||||
}
|
||||
|
||||
.pill.healthy,
|
||||
.pill.idle {
|
||||
border-color: #b9ddc9;
|
||||
background: var(--green-soft);
|
||||
color: #17633f;
|
||||
}
|
||||
|
||||
.pill.healthy::before,
|
||||
.pill.idle::before {
|
||||
background: var(--green);
|
||||
}
|
||||
|
||||
.entry-strip {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 240px) minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
flex: 0 0 auto;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.entry-copy {
|
||||
display: flex;
|
||||
min-height: 52px;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.entry-copy strong {
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.entry-links {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.entry-link {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(130px, 1fr) auto;
|
||||
min-width: 220px;
|
||||
max-width: 360px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 9px 10px;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.entry-link.current {
|
||||
border-color: var(--green);
|
||||
box-shadow: inset 3px 0 0 var(--green);
|
||||
}
|
||||
|
||||
.entry-link.disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.entry-link span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.entry-link small {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.trend-stage {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(420px, 1.55fr) minmax(300px, 0.9fr);
|
||||
gap: 10px;
|
||||
flex: 0 0 auto;
|
||||
min-height: 212px;
|
||||
}
|
||||
|
||||
.trend-panel,
|
||||
.timeline-panel,
|
||||
.pane,
|
||||
.status-strip {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.trend-panel,
|
||||
.timeline-panel {
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
font-size: 15px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.panel-header p,
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.trend-chart-wrap {
|
||||
position: relative;
|
||||
min-height: 142px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f7faf9 100%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.trend-chart {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 142px;
|
||||
}
|
||||
|
||||
.trend-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.trend-red {
|
||||
fill: none;
|
||||
stroke: var(--red);
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.trend-warning {
|
||||
fill: none;
|
||||
stroke: var(--amber);
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.trend-total {
|
||||
fill: none;
|
||||
stroke: var(--blue);
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 5 6;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.trend-grid-line {
|
||||
stroke: #e5ecea;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.trend-dot-red {
|
||||
fill: var(--red);
|
||||
}
|
||||
|
||||
.trend-dot-warning {
|
||||
fill: var(--amber);
|
||||
}
|
||||
|
||||
.trend-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 28px;
|
||||
padding: 0 9px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.legend-swatch {
|
||||
width: 18px;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.legend-swatch.red {
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
.legend-swatch.warning {
|
||||
background: var(--amber);
|
||||
}
|
||||
|
||||
.legend-swatch.total {
|
||||
background: var(--blue);
|
||||
}
|
||||
|
||||
.timeline-panel {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.timeline-list {
|
||||
display: grid;
|
||||
max-height: 150px;
|
||||
gap: 7px;
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
display: grid;
|
||||
grid-template-columns: 78px minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 7px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.timeline-item strong,
|
||||
.run-row strong,
|
||||
.finding-card strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timeline-marker {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 999px;
|
||||
background: var(--green);
|
||||
}
|
||||
|
||||
.timeline-item.red .timeline-marker,
|
||||
.run-row.red .severity-dot,
|
||||
.finding-card.red .severity-dot {
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
.timeline-item.warning .timeline-marker,
|
||||
.run-row.warning .severity-dot,
|
||||
.finding-card.warning .severity-dot {
|
||||
background: var(--amber);
|
||||
}
|
||||
|
||||
.timeline-item.info .timeline-marker,
|
||||
.run-row.info .severity-dot,
|
||||
.finding-card.info .severity-dot {
|
||||
background: var(--blue);
|
||||
}
|
||||
|
||||
.status-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(120px, 1fr));
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px 10px;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.metric span {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.metric strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.metric.warning {
|
||||
border-color: #e5c06b;
|
||||
background: var(--amber-soft);
|
||||
}
|
||||
|
||||
.workspace-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 0.82fr) minmax(420px, 1.34fr) minmax(300px, 1fr);
|
||||
gap: 10px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pane {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.pane-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin: -12px -12px 10px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
|
||||
.pane-header h2 {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.filter-row input,
|
||||
.filter-row select {
|
||||
min-width: 0;
|
||||
min-height: 32px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 8px;
|
||||
padding: 0 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.run-list,
|
||||
.finding-list,
|
||||
.detail-stack {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.run-row,
|
||||
.finding-card {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.run-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.run-row.selected {
|
||||
border-color: var(--blue);
|
||||
box-shadow: inset 3px 0 0 var(--blue);
|
||||
}
|
||||
|
||||
.row-line {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.severity-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.severity-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
background: var(--green);
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
max-width: 100%;
|
||||
border-radius: 999px;
|
||||
background: #eef3f1;
|
||||
color: var(--muted);
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tag.red {
|
||||
background: var(--red-soft);
|
||||
color: #8b1f1a;
|
||||
}
|
||||
|
||||
.tag.warning {
|
||||
background: var(--amber-soft);
|
||||
color: #73500f;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel-soft);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.detail-card h3 {
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detail-grid .metric {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
pre {
|
||||
max-height: 240px;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #10211e;
|
||||
color: #eef8f4;
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.check-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.check-chip {
|
||||
min-height: 26px;
|
||||
padding: 5px 8px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.check-chip.ok {
|
||||
border-color: #b9ddc9;
|
||||
background: var(--green-soft);
|
||||
color: #17633f;
|
||||
}
|
||||
|
||||
.check-chip.bad {
|
||||
border-color: #efb3ad;
|
||||
background: var(--red-soft);
|
||||
color: #8b1f1a;
|
||||
}
|
||||
|
||||
.banner {
|
||||
width: min(100%, 1760px);
|
||||
margin: 0 auto;
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.banner.error {
|
||||
border-color: #efb3ad;
|
||||
background: var(--red-soft);
|
||||
color: #8b1f1a;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: grid;
|
||||
min-height: 120px;
|
||||
place-items: center;
|
||||
border: 1px dashed var(--line-strong);
|
||||
border-radius: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
body {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.monitor-shell {
|
||||
height: auto;
|
||||
min-height: 100dvh;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.trend-stage,
|
||||
.workspace-grid,
|
||||
.entry-strip {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.workspace-grid {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.pane {
|
||||
max-height: 72dvh;
|
||||
}
|
||||
|
||||
.status-strip {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.monitor-shell {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.toolbar-button,
|
||||
.toolbar select {
|
||||
flex: 1 1 96px;
|
||||
}
|
||||
|
||||
.entry-link {
|
||||
min-width: 190px;
|
||||
}
|
||||
|
||||
.status-strip,
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
grid-template-columns: 62px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.timeline-item .tag {
|
||||
grid-column: 2;
|
||||
width: max-content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
import { createApp, computed, onMounted, ref } from "./vendor/vue.runtime.esm-browser.prod.js";
|
||||
|
||||
const bootstrap = readBootstrap();
|
||||
|
||||
createApp({
|
||||
setup() {
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const overview = ref(null);
|
||||
const runs = ref([]);
|
||||
const findings = ref([]);
|
||||
const selectedRunId = ref("");
|
||||
const selectedDetail = ref(null);
|
||||
const runFilter = ref("");
|
||||
const severityFilter = ref("");
|
||||
const findingFilter = ref("");
|
||||
const autoRefresh = ref(true);
|
||||
const refreshSeconds = ref(30);
|
||||
const lastLoadedAt = ref("");
|
||||
let lastAutoRefreshAt = 0;
|
||||
|
||||
const sentinels = computed(() => {
|
||||
const rows = Array.isArray(bootstrap.sentinels) ? bootstrap.sentinels : [];
|
||||
return rows.length > 0 ? rows : [{ id: bootstrap.sentinelId, enabled: true }];
|
||||
});
|
||||
|
||||
const currentStatus = computed(() => String(overview.value?.status || "idle"));
|
||||
const latestRun = computed(() => overview.value?.latestRun || null);
|
||||
const severityTotals = computed(() => overview.value?.severityCounts || {});
|
||||
const filteredRuns = computed(() => {
|
||||
const needle = runFilter.value.trim().toLowerCase();
|
||||
return runs.value.filter((run) => {
|
||||
if (severityFilter.value && severityClass(run) !== severityFilter.value) return false;
|
||||
if (!needle) return true;
|
||||
return [run.id, run.scenarioId, run.status, run.observerId].some((item) => String(item || "").toLowerCase().includes(needle));
|
||||
});
|
||||
});
|
||||
const selectedRun = computed(() => runs.value.find((run) => run.id === selectedRunId.value) || latestRun.value);
|
||||
const trendRows = computed(() => runs.value.slice().sort((a, b) => Date.parse(a.updatedAt || a.createdAt || "") - Date.parse(b.updatedAt || b.createdAt || "")).slice(-48));
|
||||
const trendMax = computed(() => Math.max(1, ...trendRows.value.flatMap((run) => [redCount(run), warningCount(run), findingCount(run)])));
|
||||
const trendPolylines = computed(() => ({
|
||||
red: trendPolyline((run) => redCount(run)),
|
||||
warning: trendPolyline((run) => warningCount(run)),
|
||||
total: trendPolyline((run) => findingCount(run)),
|
||||
}));
|
||||
const trendDots = computed(() => trendRows.value.map((run, index) => ({
|
||||
id: run.id || String(index),
|
||||
x: trendX(index, trendRows.value.length),
|
||||
redY: trendY(redCount(run)),
|
||||
warningY: trendY(warningCount(run)),
|
||||
severity: severityClass(run),
|
||||
title: `${shortId(run.id)} ${formatDate(run.updatedAt || run.createdAt)}`,
|
||||
})));
|
||||
const timelineRuns = computed(() => runs.value.slice(0, 16));
|
||||
const rootCauseFindings = computed(() => {
|
||||
const rows = findings.value.filter((item) => item.rootCause || item.nextAction || ["red", "warning"].includes(severityClass(item)));
|
||||
return rows.slice(0, 14);
|
||||
});
|
||||
const cadence = computed(() => {
|
||||
const intervalMs = Number(overview.value?.scheduler?.intervalMs || 0);
|
||||
const latestAge = Number(overview.value?.freshness?.latestRunAgeSeconds ?? -1);
|
||||
const heartbeatAge = Number(overview.value?.freshness?.schedulerHeartbeatAgeSeconds ?? -1);
|
||||
const intervalSeconds = intervalMs > 0 ? Math.round(intervalMs / 1000) : 0;
|
||||
const stale = intervalSeconds > 0 && latestAge > intervalSeconds * 2;
|
||||
return {
|
||||
intervalSeconds,
|
||||
latestAge,
|
||||
heartbeatAge,
|
||||
stale,
|
||||
label: intervalSeconds > 0 ? `${formatDuration(intervalSeconds)} 间隔` : "未配置",
|
||||
alert: stale ? `最近运行 ${formatDuration(latestAge)} 前,超过预设间隔 2 倍;按 SPEC 作为非阻塞报警展示。` : "运行新鲜度在预设窗口内",
|
||||
};
|
||||
});
|
||||
const healthChecks = computed(() => {
|
||||
const checks = overview.value?.health?.checks || {};
|
||||
return Object.entries(checks).map(([key, value]) => ({
|
||||
key,
|
||||
ok: value?.ok !== false,
|
||||
text: `${key} ${value?.ok === false ? "异常" : "ok"}`,
|
||||
}));
|
||||
});
|
||||
|
||||
async function loadAll(options = {}) {
|
||||
if (!options.silent) loading.value = true;
|
||||
error.value = "";
|
||||
setReady(false);
|
||||
try {
|
||||
const [overviewPayload, runsPayload, findingsPayload] = await Promise.all([
|
||||
fetchJson("/api/overview"),
|
||||
fetchJson("/api/runs?limit=80&sort=updated"),
|
||||
fetchJson("/api/findings?limit=80&window=24h"),
|
||||
]);
|
||||
overview.value = overviewPayload;
|
||||
runs.value = Array.isArray(runsPayload.runs) ? runsPayload.runs : Array.isArray(runsPayload.items) ? runsPayload.items : [];
|
||||
findings.value = Array.isArray(findingsPayload.findings) ? findingsPayload.findings : Array.isArray(findingsPayload.groups) ? findingsPayload.groups : [];
|
||||
lastLoadedAt.value = new Date().toISOString();
|
||||
lastAutoRefreshAt = Date.now();
|
||||
const keepSelected = runs.value.find((run) => run.id === selectedRunId.value);
|
||||
const nextRun = keepSelected || runs.value[0] || latestRun.value;
|
||||
if (nextRun?.id) await selectRun(nextRun, true);
|
||||
} catch (cause) {
|
||||
error.value = String(cause?.message || cause);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
setReady(true);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectRun(run, silent = false) {
|
||||
const runId = typeof run === "string" ? run : run?.id;
|
||||
if (!runId) return;
|
||||
selectedRunId.value = runId;
|
||||
if (!silent) selectedDetail.value = null;
|
||||
try {
|
||||
selectedDetail.value = await fetchJson(`/api/runs/${encodeURIComponent(runId)}`);
|
||||
} catch (cause) {
|
||||
selectedDetail.value = { ok: false, error: String(cause?.message || cause), runId };
|
||||
}
|
||||
}
|
||||
|
||||
function refreshNow() {
|
||||
void loadAll();
|
||||
}
|
||||
|
||||
function currentHref(item) {
|
||||
if (!item || item.id === bootstrap.sentinelId) return bootstrap.basePath || "/";
|
||||
if (item.id === "workbench-dsflash-go-tool-call-10x") return "/";
|
||||
return `/sentinels/${encodeURIComponent(item.id)}/`;
|
||||
}
|
||||
|
||||
function trendPolyline(accessor) {
|
||||
if (trendRows.value.length < 2) return "";
|
||||
return trendRows.value.map((run, index) => `${trendX(index, trendRows.value.length)},${trendY(accessor(run))}`).join(" ");
|
||||
}
|
||||
|
||||
function trendX(index, total) {
|
||||
if (total <= 1) return 24;
|
||||
return Math.round(24 + index * (672 / (total - 1)));
|
||||
}
|
||||
|
||||
function trendY(value) {
|
||||
return Math.round(126 - (Number(value || 0) / trendMax.value) * 102);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadAll();
|
||||
window.setInterval(() => {
|
||||
if (!autoRefresh.value) return;
|
||||
if (Date.now() - lastAutoRefreshAt >= Math.max(5, Number(refreshSeconds.value || 30)) * 1000) void loadAll({ silent: true });
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
return {
|
||||
bootstrap,
|
||||
loading,
|
||||
error,
|
||||
overview,
|
||||
runs,
|
||||
findings,
|
||||
selectedRunId,
|
||||
selectedDetail,
|
||||
runFilter,
|
||||
severityFilter,
|
||||
findingFilter,
|
||||
autoRefresh,
|
||||
refreshSeconds,
|
||||
lastLoadedAt,
|
||||
sentinels,
|
||||
currentStatus,
|
||||
latestRun,
|
||||
severityTotals,
|
||||
filteredRuns,
|
||||
selectedRun,
|
||||
trendRows,
|
||||
trendPolylines,
|
||||
trendDots,
|
||||
timelineRuns,
|
||||
rootCauseFindings,
|
||||
cadence,
|
||||
healthChecks,
|
||||
loadAll,
|
||||
selectRun,
|
||||
refreshNow,
|
||||
currentHref,
|
||||
redCount,
|
||||
warningCount,
|
||||
findingCount,
|
||||
severityClass,
|
||||
formatDate,
|
||||
formatDuration,
|
||||
shortId,
|
||||
rootCauseText,
|
||||
findingTitle,
|
||||
detailSummaryText,
|
||||
commandSummary,
|
||||
statusLabel,
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<div class="monitor-shell" data-monitor-shell="true" :data-monitor-status="currentStatus">
|
||||
<header class="topbar">
|
||||
<div class="title-block">
|
||||
<div class="mark" aria-hidden="true"></div>
|
||||
<div>
|
||||
<h1>HWLAB Web哨兵</h1>
|
||||
<p class="subtitle">
|
||||
<span>{{ bootstrap.node }} / {{ bootstrap.lane }}</span>
|
||||
<span class="mono">{{ bootstrap.sentinelId }}</span>
|
||||
<span>Vue monitor-web</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar" aria-label="观察面板控制">
|
||||
<span class="pill" :class="currentStatus">{{ statusLabel(currentStatus) }}</span>
|
||||
<select v-model.number="refreshSeconds" aria-label="刷新间隔">
|
||||
<option :value="5">5s</option>
|
||||
<option :value="10">10s</option>
|
||||
<option :value="30">30s</option>
|
||||
</select>
|
||||
<button class="toolbar-button" type="button" @click="autoRefresh = !autoRefresh">{{ autoRefresh ? "自动" : "手动" }}</button>
|
||||
<button class="toolbar-button" type="button" @click="refreshNow">刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="entry-strip" aria-label="哨兵入口">
|
||||
<div class="entry-copy">
|
||||
<strong>哨兵入口</strong>
|
||||
<span>{{ sentinels.length }} 个实例,当前入口固定在第一屏</span>
|
||||
</div>
|
||||
<div class="entry-links">
|
||||
<a
|
||||
v-for="item in sentinels"
|
||||
:key="item.id"
|
||||
class="entry-link"
|
||||
:class="{ current: item.id === bootstrap.sentinelId, disabled: item.enabled === false }"
|
||||
:href="currentHref(item)"
|
||||
>
|
||||
<span>{{ item.id }}</span>
|
||||
<small>{{ item.id === bootstrap.sentinelId ? "当前" : item.enabled === false ? "停用" : "查看" }}</small>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="trend-stage" aria-label="运行趋势与时间线">
|
||||
<section class="trend-panel" aria-labelledby="trend-heading">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2 id="trend-heading">红色 / 警告数量曲线</h2>
|
||||
<p>按运行更新时间展示最近 {{ trendRows.length }} 次变化</p>
|
||||
</div>
|
||||
<span class="pill" :class="cadence.stale ? 'warning' : 'healthy'">{{ cadence.stale ? "非阻塞报警" : "新鲜" }}</span>
|
||||
</div>
|
||||
<div class="trend-chart-wrap">
|
||||
<svg class="trend-chart" viewBox="0 0 720 142" role="img" aria-label="红色和警告发现数量趋势" data-monitor-trend-curve="true">
|
||||
<line class="trend-grid-line" x1="24" x2="696" y1="24" y2="24"></line>
|
||||
<line class="trend-grid-line" x1="24" x2="696" y1="75" y2="75"></line>
|
||||
<line class="trend-grid-line" x1="24" x2="696" y1="126" y2="126"></line>
|
||||
<polyline v-if="trendPolylines.total" class="trend-total" :points="trendPolylines.total"></polyline>
|
||||
<polyline v-if="trendPolylines.warning" class="trend-warning" :points="trendPolylines.warning"></polyline>
|
||||
<polyline v-if="trendPolylines.red" class="trend-red" :points="trendPolylines.red"></polyline>
|
||||
<g v-for="dot in trendDots" :key="dot.id">
|
||||
<circle class="trend-dot-warning" :cx="dot.x" :cy="dot.warningY" r="3">
|
||||
<title>{{ dot.title }}</title>
|
||||
</circle>
|
||||
<circle class="trend-dot-red" :cx="dot.x" :cy="dot.redY" r="3">
|
||||
<title>{{ dot.title }}</title>
|
||||
</circle>
|
||||
</g>
|
||||
</svg>
|
||||
<div v-if="trendRows.length === 0" class="trend-empty">暂无运行数据</div>
|
||||
</div>
|
||||
<div class="trend-legend">
|
||||
<span class="legend-item"><span class="legend-swatch red"></span>红色 {{ redCount({ severityCounts: severityTotals }) }}</span>
|
||||
<span class="legend-item"><span class="legend-swatch warning"></span>警告 {{ warningCount({ severityCounts: severityTotals }) }}</span>
|
||||
<span class="legend-item"><span class="legend-swatch total"></span>发现总量 {{ findingCount({ findingCount: overview?.latestRun?.findingCount, severityCounts: severityTotals }) }}</span>
|
||||
<span class="legend-item">{{ cadence.alert }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="timeline-panel" aria-labelledby="timeline-heading" data-monitor-timeline="true">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2 id="timeline-heading">运行时间线</h2>
|
||||
<p>位于哨兵入口下方,方便首屏确认最近运行</p>
|
||||
</div>
|
||||
<span class="tag">{{ cadence.label }}</span>
|
||||
</div>
|
||||
<div class="timeline-list">
|
||||
<button
|
||||
v-for="run in timelineRuns"
|
||||
:key="run.id"
|
||||
class="timeline-item"
|
||||
:class="severityClass(run)"
|
||||
type="button"
|
||||
@click="selectRun(run)"
|
||||
>
|
||||
<span class="muted">{{ formatDate(run.updatedAt || run.createdAt) }}</span>
|
||||
<span class="severity-line"><span class="timeline-marker"></span><strong>{{ run.scenarioId || shortId(run.id) }}</strong></span>
|
||||
<span class="tag" :class="severityClass(run)">{{ findingCount(run) }} 项</span>
|
||||
</button>
|
||||
<div v-if="timelineRuns.length === 0" class="empty">暂无时间线记录</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="status-strip" aria-label="状态指标">
|
||||
<div class="metric">
|
||||
<span>最近运行</span>
|
||||
<strong>{{ latestRun ? formatDate(latestRun.updatedAt || latestRun.createdAt) : "-" }}</strong>
|
||||
</div>
|
||||
<div class="metric" :class="{ warning: cadence.stale }">
|
||||
<span>调度新鲜度</span>
|
||||
<strong>{{ cadence.latestAge >= 0 ? formatDuration(cadence.latestAge) : "-" }}</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span>红色</span>
|
||||
<strong>{{ redCount({ severityCounts: severityTotals }) }}</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span>警告</span>
|
||||
<strong>{{ warningCount({ severityCounts: severityTotals }) }}</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span>最后刷新</span>
|
||||
<strong>{{ lastLoadedAt ? formatDate(lastLoadedAt) : "-" }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="error" class="banner error" id="monitor-web-error">{{ error }}</section>
|
||||
|
||||
<section class="workspace-grid" aria-label="哨兵工作区" data-monitor-independent-scroll="true">
|
||||
<aside class="pane pane-runs" aria-labelledby="runs-heading">
|
||||
<div class="pane-header">
|
||||
<div>
|
||||
<h2 id="runs-heading">运行记录</h2>
|
||||
<p class="muted">{{ filteredRuns.length }} / {{ runs.length }}</p>
|
||||
</div>
|
||||
<span v-if="loading" class="tag">加载中</span>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<input v-model="runFilter" type="search" placeholder="搜索 run / scenario" aria-label="搜索运行">
|
||||
<select v-model="severityFilter" aria-label="严重级别筛选">
|
||||
<option value="">全部</option>
|
||||
<option value="red">红色</option>
|
||||
<option value="warning">警告</option>
|
||||
<option value="info">信息</option>
|
||||
<option value="healthy">正常</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="run-list">
|
||||
<button
|
||||
v-for="run in filteredRuns"
|
||||
:key="run.id"
|
||||
class="run-row"
|
||||
:class="[severityClass(run), { selected: run.id === selectedRunId }]"
|
||||
type="button"
|
||||
@click="selectRun(run)"
|
||||
>
|
||||
<span class="row-line">
|
||||
<span class="severity-line"><span class="severity-dot"></span><strong>{{ run.scenarioId || shortId(run.id) }}</strong></span>
|
||||
<span class="tag" :class="severityClass(run)">{{ findingCount(run) }}</span>
|
||||
</span>
|
||||
<span class="row-line muted">
|
||||
<span>{{ run.status || "-" }}</span>
|
||||
<span>{{ formatDate(run.updatedAt || run.createdAt) }}</span>
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="filteredRuns.length === 0" class="empty">没有匹配的运行记录</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="pane pane-detail" aria-labelledby="detail-heading">
|
||||
<div class="pane-header">
|
||||
<div>
|
||||
<h2 id="detail-heading">运行详情</h2>
|
||||
<p class="muted">{{ selectedRun ? selectedRun.id : "未选择" }}</p>
|
||||
</div>
|
||||
<span v-if="selectedRun" class="tag" :class="severityClass(selectedRun)">{{ severityClass(selectedRun) }}</span>
|
||||
</div>
|
||||
<div v-if="selectedRun" class="detail-stack">
|
||||
<section class="detail-card">
|
||||
<h3>摘要</h3>
|
||||
<div class="detail-grid">
|
||||
<div class="metric"><span>状态</span><strong>{{ selectedRun.status || "-" }}</strong></div>
|
||||
<div class="metric"><span>发现数</span><strong>{{ findingCount(selectedRun) }}</strong></div>
|
||||
<div class="metric"><span>Observer</span><strong>{{ selectedRun.observerId || "-" }}</strong></div>
|
||||
<div class="metric"><span>更新时间</span><strong>{{ formatDate(selectedRun.updatedAt || selectedRun.createdAt) }}</strong></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="detail-card">
|
||||
<h3>健康检查</h3>
|
||||
<div class="check-grid">
|
||||
<span v-for="check in healthChecks" :key="check.key" class="check-chip" :class="check.ok ? 'ok' : 'bad'">{{ check.text }}</span>
|
||||
<span v-if="healthChecks.length === 0" class="check-chip">无检查数据</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="detail-card">
|
||||
<h3>报告摘要</h3>
|
||||
<pre>{{ detailSummaryText(selectedDetail) }}</pre>
|
||||
</section>
|
||||
<section class="detail-card">
|
||||
<h3>复现命令</h3>
|
||||
<pre>{{ commandSummary(selectedDetail) }}</pre>
|
||||
</section>
|
||||
</div>
|
||||
<div v-else class="empty">选择一条运行记录查看详情</div>
|
||||
</main>
|
||||
|
||||
<aside class="pane pane-findings" aria-labelledby="findings-heading">
|
||||
<div class="pane-header">
|
||||
<div>
|
||||
<h2 id="findings-heading">根因与发现</h2>
|
||||
<p class="muted">优先展示 OTel/报告中已归因线索</p>
|
||||
</div>
|
||||
<span class="tag">{{ findings.length }} 项</span>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<input v-model="findingFilter" type="search" placeholder="搜索 finding / root cause" aria-label="搜索发现">
|
||||
</div>
|
||||
<div class="finding-list">
|
||||
<article
|
||||
v-for="item in rootCauseFindings.filter((finding) => !findingFilter || JSON.stringify(finding).toLowerCase().includes(findingFilter.toLowerCase())).slice(0, 24)"
|
||||
:key="item.code || item.findingId || item.latestRunId"
|
||||
class="finding-card"
|
||||
:class="severityClass(item)"
|
||||
>
|
||||
<span class="row-line">
|
||||
<span class="severity-line"><span class="severity-dot"></span><strong>{{ findingTitle(item) }}</strong></span>
|
||||
<span class="tag" :class="severityClass(item)">{{ item.runCount || item.count || 1 }}</span>
|
||||
</span>
|
||||
<p class="muted">{{ rootCauseText(item) }}</p>
|
||||
<p v-if="item.nextAction" class="muted">方案: {{ item.nextAction }}</p>
|
||||
</article>
|
||||
<div v-if="rootCauseFindings.length === 0" class="empty">暂无已归因发现</div>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
`,
|
||||
}).mount("#monitor-web-root");
|
||||
|
||||
function readBootstrap() {
|
||||
const root = document.querySelector("#monitor-web-root");
|
||||
const script = document.querySelector("#monitor-web-bootstrap");
|
||||
let parsed = {};
|
||||
try {
|
||||
parsed = script?.textContent ? JSON.parse(script.textContent) : {};
|
||||
} catch {
|
||||
parsed = {};
|
||||
}
|
||||
return {
|
||||
node: root?.getAttribute("data-node") || parsed.node || "",
|
||||
lane: root?.getAttribute("data-lane") || parsed.lane || "",
|
||||
sentinelId: root?.getAttribute("data-sentinel-id") || parsed.sentinelId || "",
|
||||
basePath: root?.getAttribute("data-base-path") || parsed.basePath || "",
|
||||
publicOrigin: root?.getAttribute("data-public-origin") || parsed.publicOrigin || "",
|
||||
contractVersion: root?.getAttribute("data-contract-version") || parsed.contractVersion || "",
|
||||
sentinels: Array.isArray(parsed.sentinels) ? parsed.sentinels : [],
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchJson(path) {
|
||||
const response = await fetch(apiUrl(path), { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`${path} HTTP ${response.status}`);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
function apiUrl(path) {
|
||||
const prefix = bootstrap.basePath || "";
|
||||
const suffix = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${prefix}${suffix}`;
|
||||
}
|
||||
|
||||
function setReady(value) {
|
||||
document.querySelector("#monitor-web-root")?.setAttribute("data-monitor-ready", value ? "true" : "false");
|
||||
}
|
||||
|
||||
function redCount(item) {
|
||||
const counts = item?.severityCounts || item || {};
|
||||
return number(counts.red) + number(counts.critical) + number(counts.error);
|
||||
}
|
||||
|
||||
function warningCount(item) {
|
||||
const counts = item?.severityCounts || item || {};
|
||||
return number(counts.warning) + number(counts.warn) + number(counts.amber);
|
||||
}
|
||||
|
||||
function findingCount(item) {
|
||||
if (Number.isFinite(Number(item?.findingCount))) return Number(item.findingCount);
|
||||
if (Number.isFinite(Number(item?.finding_count))) return Number(item.finding_count);
|
||||
if (Number.isFinite(Number(item?.count))) return Number(item.count);
|
||||
const counts = item?.severityCounts || item || {};
|
||||
return Object.values(counts).reduce((sum, value) => sum + number(value), 0);
|
||||
}
|
||||
|
||||
function severityClass(item) {
|
||||
const explicit = String(item?.maxSeverity || item?.severity || "").toLowerCase();
|
||||
if (["red", "critical", "error", "blocked"].includes(explicit)) return "red";
|
||||
if (["warning", "warn", "amber"].includes(explicit)) return "warning";
|
||||
if (["info", "notice"].includes(explicit)) return "info";
|
||||
if (redCount(item) > 0) return "red";
|
||||
if (warningCount(item) > 0) return "warning";
|
||||
return "healthy";
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return String(value);
|
||||
const now = Date.now();
|
||||
const seconds = Math.max(0, Math.round((now - date.getTime()) / 1000));
|
||||
if (seconds < 90) return `${seconds}s前`;
|
||||
if (seconds < 7200) return `${Math.round(seconds / 60)}m前`;
|
||||
if (seconds < 172800) return `${Math.round(seconds / 3600)}h前`;
|
||||
return date.toISOString().slice(5, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const value = Math.max(0, Number(seconds || 0));
|
||||
if (value < 90) return `${Math.round(value)}s`;
|
||||
if (value < 7200) return `${Math.round(value / 60)}m`;
|
||||
if (value < 172800) return `${Math.round(value / 3600)}h`;
|
||||
return `${Math.round(value / 86400)}d`;
|
||||
}
|
||||
|
||||
function shortId(value) {
|
||||
const text = String(value || "");
|
||||
return text.length > 18 ? `${text.slice(0, 10)}...${text.slice(-6)}` : text || "-";
|
||||
}
|
||||
|
||||
function rootCauseText(item) {
|
||||
return item?.rootCause || item?.evidenceSummary || item?.summary || "尚未记录根因,等待下一次 OTel/报告归因。";
|
||||
}
|
||||
|
||||
function findingTitle(item) {
|
||||
return item?.code || item?.findingId || item?.scenarioId || item?.latestRunId || "finding";
|
||||
}
|
||||
|
||||
function detailSummaryText(detail) {
|
||||
if (!detail) return "加载详情中";
|
||||
if (detail.ok === false) return detail.error || "详情不可用";
|
||||
const summary = detail.summary && Object.keys(detail.summary).length > 0 ? detail.summary : detail.run;
|
||||
return JSON.stringify(summary || {}, null, 2);
|
||||
}
|
||||
|
||||
function commandSummary(detail) {
|
||||
const commands = detail?.commands || {};
|
||||
const lines = Object.entries(commands).map(([key, value]) => `${key}: ${value}`);
|
||||
return lines.length > 0 ? lines.join("\n") : "暂无命令";
|
||||
}
|
||||
|
||||
function statusLabel(status) {
|
||||
const value = String(status || "");
|
||||
if (value === "blocked") return "阻塞";
|
||||
if (value === "degraded") return "降级";
|
||||
if (value === "warning") return "警告";
|
||||
if (value === "healthy") return "健康";
|
||||
return "空闲";
|
||||
}
|
||||
|
||||
function number(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2018-present, Yuxi (Evan) You
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
+7
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user