SSR module: dark VITEC cockpit with live charts
Live band with 24 h availability strip, gauges for hit rate, response time, cache fill and client errors, traffic chart 24 h / 7 days from the health log counter deltas drawn with the core Chart.js of EXT:dashboard, warm progress ring with live polling, purge activity timeline, client error cards, link to the site settings. ssr-cockpit.js replaces ssr-warm-progress.js, whose deletion went into a9923e1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a9923e19e0
commit
67ded6e502
6 files changed
+1119
-342
No files matched your search
@@ -1,55 +1,229 @@
|
||||
/* VITEC SSR module (web_vitecssr): layout glue on top of TYPO3's own
|
||||
components - cards, badges, buttons and tables come from the core. */
|
||||
/* VITEC SSR cockpit (module web_vitecssr).
|
||||
Deliberately dark, in VITEC colours, independent of the backend theme
|
||||
(decision Harry, 2026-11-11). The TYPO3 frame stays core: doc header,
|
||||
flash messages, confirmation modals. Everything here is scoped to
|
||||
.vitec-cockpit or to this module's body.
|
||||
Layout: live band + availability / four gauges / traffic 8 + actions 4 /
|
||||
purge activity 7 + client errors 5; below 1200 px the wide pairs stack. */
|
||||
|
||||
.ssr-status {
|
||||
margin: 0;
|
||||
[data-module-name="web_vitecssr"] .module-body {
|
||||
--module-bg: #080c26;
|
||||
background-color: #080c26;
|
||||
}
|
||||
.ssr-status-row {
|
||||
display: grid;
|
||||
grid-template-columns: 9rem minmax(0, 1fr);
|
||||
gap: 0 1rem;
|
||||
padding: .75rem 0;
|
||||
border-bottom: 1px solid var(--typo3-component-border-color, #dee2e6);
|
||||
}
|
||||
.ssr-status-row:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
.ssr-status-row:last-child {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.ssr-status-row dt {
|
||||
font-weight: normal;
|
||||
color: var(--typo3-text-color-secondary, #6c757d);
|
||||
}
|
||||
.ssr-status-row dd {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
@media (max-width: 575.98px) {
|
||||
.ssr-status-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: .25rem 0;
|
||||
}
|
||||
[data-module-name="web_vitecssr"] .module-body-container {
|
||||
max-width: 1680px;
|
||||
}
|
||||
|
||||
.ssr-action + .ssr-action {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--typo3-component-border-color, #dee2e6);
|
||||
.vitec-cockpit {
|
||||
--vc-ink: #080c26; /* page: VITEC navy pushed to night */
|
||||
--vc-panel: #0f1640; /* card */
|
||||
--vc-panel-2: #151e52; /* raised inside a card */
|
||||
--vc-line: rgba(140, 156, 255, .14);
|
||||
--vc-line-strong: rgba(140, 156, 255, .28);
|
||||
--vc-navy: #26358c; /* VITEC blue */
|
||||
--vc-navy-hi: #4a5fd6; /* VITEC blue lifted for the dark ground */
|
||||
--vc-orange: #f47937; /* VITEC orange: actions and highlights */
|
||||
--vc-text: #e9ecfb;
|
||||
--vc-muted: #8e97c6;
|
||||
--vc-faint: #6a73a6;
|
||||
--vc-ok: #35d39a;
|
||||
--vc-warn: #ffc65c;
|
||||
--vc-bad: #ff5f73;
|
||||
--vc-sky: #6fc3ff;
|
||||
--vc-mono: var(--typo3-font-family-monospace, ui-monospace, SFMono-Regular, Consolas, monospace);
|
||||
--vc-r: 14px;
|
||||
color-scheme: dark;
|
||||
color: var(--vc-text);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.ssr-action-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 .5rem;
|
||||
.vitec-cockpit *,
|
||||
.vitec-cockpit *::before,
|
||||
.vitec-cockpit *::after { box-sizing: border-box; }
|
||||
.vitec-cockpit code { font-family: var(--vc-mono); font-size: 12px; color: #cdd4ff; background: none; padding: 0; }
|
||||
.vitec-cockpit h2, .vitec-cockpit h3 { color: inherit; }
|
||||
.vc-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
|
||||
.vc-grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 20px; }
|
||||
.vc-card { background: var(--vc-panel); border: 1px solid var(--vc-line); border-radius: var(--vc-r); padding: 20px; min-width: 0; }
|
||||
.vc-s12 { grid-column: span 12; } .vc-s8 { grid-column: span 8; } .vc-s4 { grid-column: span 4; }
|
||||
.vc-s7 { grid-column: span 7; } .vc-s5 { grid-column: span 5; } .vc-s3 { grid-column: span 3; }
|
||||
@media (max-width: 1199.98px) {
|
||||
.vc-s8, .vc-s4, .vc-s7, .vc-s5 { grid-column: span 12; }
|
||||
.vc-s3 { grid-column: span 6; }
|
||||
}
|
||||
@media (max-width: 619.98px) { .vc-s3 { grid-column: span 12; } .vc-grid { gap: 14px; } .vc-card { padding: 16px; } }
|
||||
|
||||
.vc-card-h { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.vc-card-t { font-size: 13px; font-weight: 700; line-height: 1.2; letter-spacing: .08em; text-transform: uppercase; color: var(--vc-muted) !important; margin: 0; display: flex; align-items: center; gap: 8px; }
|
||||
.vc-card-t svg { color: var(--vc-navy-hi); }
|
||||
.vc-dim { color: var(--vc-muted); font-size: 12px; }
|
||||
.vc-ok-text { color: var(--vc-ok); } .vc-bad-text { color: var(--vc-bad); } .vc-warn-text { color: var(--vc-warn); }
|
||||
.vc-link { color: var(--vc-sky); text-decoration: none; font-size: 12px; font-weight: 500; }
|
||||
.vc-link:hover, .vc-link:focus { color: var(--vc-sky); text-decoration: underline; }
|
||||
|
||||
/* Pills */
|
||||
.vc-pill { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; font-weight: 600; line-height: 1; letter-spacing: .04em; padding: 5px 9px; border-radius: 99px; white-space: nowrap; background: rgba(140, 156, 255, .12); color: #c4cbf0; }
|
||||
.vc-pill.is-ok { background: rgba(53, 211, 154, .14); color: var(--vc-ok); }
|
||||
.vc-pill.is-warn { background: rgba(255, 198, 92, .15); color: var(--vc-warn); }
|
||||
.vc-pill.is-bad { background: rgba(255, 95, 115, .15); color: var(--vc-bad); }
|
||||
.vc-pill.is-sky, .vc-pill.is-info { background: rgba(111, 195, 255, .14); color: var(--vc-sky); }
|
||||
.vc-pill.is-navy { background: rgba(74, 95, 214, .24); color: #b8c3ff; }
|
||||
.vc-pill.is-orange { background: rgba(244, 121, 55, .16); color: var(--vc-orange); }
|
||||
.vc-pill.is-muted { background: rgba(140, 156, 255, .1); color: var(--vc-muted); }
|
||||
|
||||
/* Live band */
|
||||
.vc-hero { padding: 24px 24px 20px; background:
|
||||
radial-gradient(120% 140% at 0% 0%, rgba(74, 95, 214, .35), transparent 55%),
|
||||
radial-gradient(80% 120% at 100% 0%, rgba(244, 121, 55, .12), transparent 50%),
|
||||
var(--vc-panel); }
|
||||
.vc-hero-top { display: flex; flex-wrap: wrap; align-items: center; gap: 18px 28px; }
|
||||
.vc-live { display: flex; align-items: center; gap: 14px; min-width: 0; }
|
||||
.vc-pulse { position: relative; width: 14px; height: 14px; border-radius: 50%; background: var(--vc-ok); flex: none; }
|
||||
.vc-pulse::after { content: ""; position: absolute; inset: -6px; border-radius: 50%; border: 2px solid currentColor; color: var(--vc-ok); opacity: 0; animation: vc-ping 2.4s ease-out infinite; }
|
||||
.vc-pulse.is-down { background: var(--vc-bad); }
|
||||
.vc-pulse.is-down::after { color: var(--vc-bad); }
|
||||
@keyframes vc-ping { 0% { transform: scale(.5); opacity: .8; } 100% { transform: scale(1.6); opacity: 0; } }
|
||||
.vc-live-title { font-size: 30px; font-weight: 800; line-height: 1.1; letter-spacing: -.01em; margin: 0; }
|
||||
.vc-live-sub { margin: 2px 0 0; color: var(--vc-muted); font-size: 13px; }
|
||||
.vc-facts { display: flex; flex-wrap: wrap; gap: 10px 28px; margin: 0 0 0 auto; }
|
||||
.vc-fact dt { font-size: 11px; font-weight: 400; letter-spacing: .08em; text-transform: uppercase; color: var(--vc-faint); }
|
||||
.vc-fact dd { margin: 2px 0 0; font-weight: 600; display: flex; align-items: center; gap: 8px; white-space: nowrap; }
|
||||
@media (max-width: 899.98px) { .vc-facts { margin-left: 0; } }
|
||||
|
||||
.vc-uptime { margin-top: 22px; }
|
||||
.vc-uptime-h { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; font-size: 12px; color: var(--vc-muted); margin-bottom: 8px; }
|
||||
.vc-uptime-h b { color: var(--vc-text); font-weight: 600; }
|
||||
.vc-bars { display: grid; grid-template-columns: repeat(96, minmax(0, 1fr)); gap: 2px; height: 30px; align-items: end; }
|
||||
.vc-bars i { display: block; height: 100%; border-radius: 2px; background: var(--vc-ok); opacity: .85; transition: opacity .15s, transform .15s; }
|
||||
.vc-bars i.is-slow { background: var(--vc-warn); }
|
||||
.vc-bars i.is-down { background: var(--vc-bad); }
|
||||
.vc-bars i.is-none { background: rgba(140, 156, 255, .14); height: 40%; }
|
||||
.vc-bars i:hover { opacity: 1; transform: scaleY(1.12); }
|
||||
@media (max-width: 619.98px) { .vc-bars { gap: 1px; } }
|
||||
.vc-uptime-f { display: flex; justify-content: space-between; font: 11px var(--vc-mono); color: var(--vc-faint); margin-top: 6px; }
|
||||
|
||||
/* Gauges */
|
||||
.vc-tile { display: flex; gap: 14px; align-items: center; }
|
||||
.vc-tile .vc-ring { width: 78px; height: 78px; }
|
||||
.vc-tile-body { min-width: 0; flex: 1; }
|
||||
.vc-label { font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: var(--vc-muted); font-weight: 600; }
|
||||
.vc-big { font-size: 32px; font-weight: 800; line-height: 1.05; margin: 6px 0 4px; font-variant-numeric: tabular-nums; letter-spacing: -.02em; white-space: nowrap; }
|
||||
.vc-big small { font-size: 15px; font-weight: 600; color: var(--vc-muted); margin-left: 4px; letter-spacing: 0; }
|
||||
.vc-sub { font-size: 12px; color: var(--vc-muted); }
|
||||
.vc-sub b { color: var(--vc-text); }
|
||||
.vc-sub .is-up { color: var(--vc-ok); } .vc-sub .is-down { color: var(--vc-bad); }
|
||||
.vc-ring { flex: none; }
|
||||
.vc-ring circle { fill: none; stroke-width: 9; }
|
||||
.vc-ring .vc-track { stroke: rgba(140, 156, 255, .12); }
|
||||
.vc-ring .vc-val { stroke-linecap: round; transform: rotate(-90deg); transform-origin: 50% 50%; stroke-dasharray: 251.33; stroke-dashoffset: 251.33; transition: stroke-dashoffset 1s cubic-bezier(.2, .8, .2, 1); }
|
||||
.vc-ring .vc-val.is-fast { transition-duration: .4s; }
|
||||
.vc-val.is-ok { stroke: var(--vc-ok); } .vc-val.is-sky { stroke: var(--vc-sky); } .vc-val.is-orange { stroke: var(--vc-orange); }
|
||||
.vc-spark { display: block; width: 100%; height: 46px; margin-top: 6px; }
|
||||
.vc-mini { display: grid; grid-template-columns: repeat(24, minmax(0, 1fr)); gap: 2px; align-items: end; height: 40px; margin-top: 8px; }
|
||||
.vc-mini i { display: block; background: var(--vc-navy-hi); border-radius: 2px 2px 0 0; min-height: 2px; }
|
||||
.vc-mini i.is-hot { background: var(--vc-bad); }
|
||||
|
||||
/* Segmented control */
|
||||
.vc-seg { display: inline-flex; background: var(--vc-panel-2); border: 1px solid var(--vc-line); border-radius: 99px; padding: 3px; }
|
||||
.vc-seg button, .vc-seg a { border: 0; background: transparent; color: var(--vc-muted); font-size: 12px; font-weight: 600; padding: 5px 12px; border-radius: 99px; cursor: pointer; text-decoration: none; line-height: 1.4; }
|
||||
.vc-seg button:hover, .vc-seg a:hover { color: var(--vc-text); text-decoration: none; }
|
||||
.vc-seg [aria-pressed="true"] { background: var(--vc-navy); color: #fff; }
|
||||
.vitec-cockpit :is(button, a, textarea, summary):focus-visible { outline: 2px solid var(--vc-orange); outline-offset: 2px; }
|
||||
|
||||
.vc-legend { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 12px; color: var(--vc-muted); }
|
||||
.vc-legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.vc-legend i { width: 10px; height: 10px; border-radius: 3px; display: inline-block; }
|
||||
.vc-legend i.is-navy { background: var(--vc-navy-hi); } .vc-legend i.is-sky { background: var(--vc-sky); }
|
||||
.vc-legend i.is-warn { background: var(--vc-warn); } .vc-legend i.is-orange { background: var(--vc-orange); }
|
||||
.vc-legend i.is-bad { background: var(--vc-bad); } .vc-legend i.is-ok { background: var(--vc-ok); }
|
||||
.vc-legend i.is-round { border-radius: 50%; }
|
||||
.vc-legend i.is-tri { border-radius: 0; clip-path: polygon(50% 0, 100% 100%, 0 100%); }
|
||||
.vc-legend-tl { margin: -6px 0 10px; }
|
||||
.vc-chart-card { display: flex; flex-direction: column; }
|
||||
.vc-chart { position: relative; flex: 1 1 auto; min-height: 320px; margin-top: 12px; overflow: hidden; }
|
||||
.vc-chart canvas { max-width: 100%; }
|
||||
@media (max-width: 619.98px) { .vc-chart { min-height: 260px; } }
|
||||
.vc-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 16px 0 0; padding-top: 16px; border-top: 1px solid var(--vc-line); }
|
||||
.vc-stats dt { font-size: 11px; font-weight: 400; color: var(--vc-faint); text-transform: uppercase; letter-spacing: .07em; }
|
||||
.vc-stats dd { margin: 2px 0 0; font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
@media (max-width: 619.98px) { .vc-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
.vc-empty { color: var(--vc-muted); font-size: 13px; margin: 12px 0 0; }
|
||||
|
||||
/* Actions */
|
||||
.vc-actions { display: flex; flex-direction: column; gap: 14px; }
|
||||
.vc-act { background: var(--vc-panel-2); border: 1px solid var(--vc-line); border-radius: 12px; padding: 16px; margin: 0; }
|
||||
.vc-act-h { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.vc-act-t { font-size: 15px; font-weight: 700; margin: 0; }
|
||||
.vc-help { margin: 8px 0 0; color: var(--vc-muted); font-size: 12.5px; }
|
||||
.vc-warm { display: flex; gap: 16px; align-items: center; margin-top: 12px; }
|
||||
.vc-warm-ring { position: relative; flex: none; width: 92px; height: 92px; }
|
||||
.vc-warm-pct { position: absolute; inset: 0; display: grid; place-items: center; font-size: 18px; font-weight: 800; font-variant-numeric: tabular-nums; }
|
||||
.vc-warm-meta { font-size: 12.5px; color: var(--vc-muted); min-width: 0; }
|
||||
.vc-warm-meta b { color: var(--vc-text); font-variant-numeric: tabular-nums; }
|
||||
.vc-btn-row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
|
||||
.vc-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; border-radius: 9px; border: 1px solid transparent; font-size: 13px; font-weight: 700; line-height: 1.2; padding: 9px 14px; cursor: pointer; transition: transform .1s, filter .15s, background-color .15s, border-color .15s; }
|
||||
.vc-btn:active { transform: scale(.98); }
|
||||
.vc-btn.is-primary { background: var(--vc-orange); color: #1b0b02; }
|
||||
.vc-btn.is-primary:hover { filter: brightness(1.08); }
|
||||
.vc-btn.is-primary[disabled] { background: rgba(244, 121, 55, .3); color: rgba(255, 255, 255, .65); cursor: progress; filter: none; }
|
||||
.vc-btn.is-ghost { background: transparent; color: var(--vc-text); border-color: var(--vc-line-strong); }
|
||||
.vc-btn.is-ghost:hover { border-color: var(--vc-navy-hi); background: rgba(74, 95, 214, .14); }
|
||||
.vc-btn.is-danger { background: transparent; color: var(--vc-bad); border-color: rgba(255, 95, 115, .45); }
|
||||
.vc-btn.is-danger:hover { background: rgba(255, 95, 115, .15); }
|
||||
.vc-lbl { display: block; font-size: 12px; color: var(--vc-muted); margin: 12px 0 6px; font-weight: 600; }
|
||||
.vc-input { width: 100%; resize: vertical; min-height: 84px; background: var(--vc-ink); color: var(--vc-text); border: 1px solid var(--vc-line-strong); border-radius: 9px; padding: 10px 12px; font: 12.5px/1.6 var(--vc-mono); }
|
||||
.vc-input::placeholder { color: var(--vc-faint); }
|
||||
.vc-input:focus { border-color: var(--vc-navy-hi); outline: none; }
|
||||
|
||||
/* Purge activity timeline */
|
||||
.vc-timeline { list-style: none; margin: 0; padding: 0; position: relative; }
|
||||
.vc-timeline::before { content: ""; position: absolute; left: 62px; top: 6px; bottom: 6px; width: 1px; background: var(--vc-line-strong); }
|
||||
.vc-ev { display: grid; grid-template-columns: 52px 22px minmax(0, 1fr) auto; gap: 0 10px; align-items: start; padding: 10px 0; }
|
||||
.vc-ev + .vc-ev { border-top: 1px dashed rgba(140, 156, 255, .08); }
|
||||
.vc-ev time { font: 500 12px var(--vc-mono); color: var(--vc-muted); text-align: right; padding-top: 2px; }
|
||||
.vc-dot { width: 11px; height: 11px; border-radius: 50%; margin: 5px auto 0; box-shadow: 0 0 0 4px var(--vc-panel); position: relative; z-index: 1; background: var(--vc-sky); }
|
||||
.vc-dot.is-ok { background: var(--vc-ok); } .vc-dot.is-orange { background: var(--vc-orange); }
|
||||
.vc-dot.is-navy { background: var(--vc-navy-hi); } .vc-dot.is-warn { background: var(--vc-warn); }
|
||||
.vc-ev-main { min-width: 0; }
|
||||
.vc-ev-line { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; }
|
||||
.vc-paths { margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px; align-items: flex-start; }
|
||||
.vc-path { font: 12px var(--vc-mono); background: rgba(140, 156, 255, .08); color: #cdd4ff; border-radius: 6px; padding: 2px 7px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.vc-more { font-size: 12px; }
|
||||
.vc-more summary { color: var(--vc-sky); cursor: pointer; list-style: none; padding: 2px 4px; }
|
||||
.vc-more summary::-webkit-details-marker { display: none; }
|
||||
.vc-more[open] { flex-basis: 100%; display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.vc-more[open] summary { flex-basis: 100%; }
|
||||
.vc-ev-r { text-align: right; font: 500 12px var(--vc-mono); color: var(--vc-muted); white-space: nowrap; display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
|
||||
.vc-day { font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--vc-faint); padding: 14px 0 4px 74px; }
|
||||
.vc-day:first-child { padding-top: 0; }
|
||||
@media (max-width: 619.98px) {
|
||||
.vc-timeline::before { left: 46px; }
|
||||
.vc-ev { grid-template-columns: 38px 18px minmax(0, 1fr); }
|
||||
.vc-ev-r { grid-column: 3; flex-direction: row; align-items: center; margin-top: 6px; }
|
||||
.vc-day { padding-left: 58px; }
|
||||
}
|
||||
|
||||
#ssr-warm-button .ssr-warm-busy {
|
||||
display: none;
|
||||
}
|
||||
#ssr-warm-button.is-running .ssr-warm-idle {
|
||||
display: none;
|
||||
}
|
||||
#ssr-warm-button.is-running .ssr-warm-busy {
|
||||
display: inline;
|
||||
/* Client errors */
|
||||
.vc-err-sum { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin: 0 0 16px; }
|
||||
.vc-err-sum div { background: var(--vc-panel-2); border-radius: 10px; padding: 10px 12px; min-width: 0; }
|
||||
.vc-err-sum dt { font-size: 11px; font-weight: 400; color: var(--vc-faint); text-transform: uppercase; letter-spacing: .07em; }
|
||||
.vc-err-sum dd { margin: 2px 0 0; font-size: 22px; font-weight: 800; font-variant-numeric: tabular-nums; }
|
||||
.vc-errs { display: flex; flex-direction: column; gap: 8px; }
|
||||
.vc-err { background: var(--vc-panel-2); border: 1px solid var(--vc-line); border-radius: 10px; }
|
||||
.vc-err[open] { border-color: var(--vc-line-strong); }
|
||||
.vc-err summary { list-style: none; cursor: pointer; padding: 12px 14px; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 10px; align-items: center; }
|
||||
.vc-err summary::-webkit-details-marker { display: none; }
|
||||
.vc-err-msg { min-width: 0; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.vc-err-msg small { display: block; color: var(--vc-muted); font: 11.5px var(--vc-mono); overflow: hidden; text-overflow: ellipsis; }
|
||||
.vc-err-t { font: 12px var(--vc-mono); color: var(--vc-muted); white-space: nowrap; }
|
||||
.vc-err-body { padding: 0 14px 14px; font-size: 12.5px; }
|
||||
.vc-kv { display: grid; grid-template-columns: 76px minmax(0, 1fr); gap: 6px 12px; margin: 0; }
|
||||
.vc-kv dt { color: var(--vc-faint); font-weight: 400; }
|
||||
.vc-kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||||
.vc-stack { margin: 10px 0 0; background: var(--vc-ink); border: 0; border-radius: 8px; padding: 10px 12px; font: 11.5px/1.55 var(--vc-mono); color: #b9c1ef; overflow: auto; max-height: 320px; white-space: pre; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vitec-cockpit *, .vitec-cockpit *::after { animation: none !important; transition: none !important; }
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* VITEC SSR cockpit (module web_vitecssr).
|
||||
* Draws the gauges, the availability strip, the traffic chart and the
|
||||
* sparkline from the JSON the controller puts on #ssr-cockpit
|
||||
* (SsrController::cockpitData), and keeps the live parts current by
|
||||
* polling the vitec_ssr_health ajax route (GET /__health of the node):
|
||||
* every 2 s while a warm run is active, every 15 s otherwise.
|
||||
* Chart.js is the copy TYPO3 ships with EXT:dashboard.
|
||||
*/
|
||||
import AjaxRequest from '@typo3/core/ajax/ajax-request.js';
|
||||
import { Chart, registerables } from '@typo3/dashboard/contrib/chartjs.js';
|
||||
|
||||
const root = document.getElementById('ssr-cockpit');
|
||||
if (root) {
|
||||
Chart.register(...registerables);
|
||||
|
||||
let data = {};
|
||||
try {
|
||||
data = JSON.parse(root.dataset.json || '{}');
|
||||
} catch (e) {
|
||||
data = {};
|
||||
}
|
||||
|
||||
const C = 2 * Math.PI * 40;
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const nf = new Intl.NumberFormat('en-GB');
|
||||
const num = (v) => (typeof v === 'number' && isFinite(v) ? v : 0);
|
||||
const hhmm = (ts) => new Date(ts * 1000).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
||||
const css = getComputedStyle(root);
|
||||
const tok = (name) => css.getPropertyValue(name).trim();
|
||||
const col = {
|
||||
navy: tok('--vc-navy-hi'), sky: tok('--vc-sky'), warn: tok('--vc-warn'), bad: tok('--vc-bad'),
|
||||
orange: tok('--vc-orange'), ok: tok('--vc-ok'), faint: tok('--vc-faint'), panel: tok('--vc-panel'),
|
||||
text: tok('--vc-text'), muted: tok('--vc-muted'),
|
||||
};
|
||||
|
||||
const setRing = (el, frac) => {
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
el.style.strokeDasharray = String(C);
|
||||
el.style.strokeDashoffset = String(C * (1 - Math.max(0, Math.min(1, frac))));
|
||||
};
|
||||
|
||||
/* Hit rate gauge */
|
||||
const hit = data.hitRate;
|
||||
if (typeof hit === 'number') {
|
||||
$('vc-hit').textContent = hit.toFixed(1);
|
||||
requestAnimationFrame(() => setRing($('vc-ring-hit'), hit / 100));
|
||||
if (typeof data.hitRatePrev === 'number') {
|
||||
const d = Math.round((hit - data.hitRatePrev) * 10) / 10;
|
||||
const cls = d >= 0 ? 'is-up' : 'is-down';
|
||||
$('vc-hit-trend').innerHTML = '<span class="' + cls + '">' + (d >= 0 ? '▲ ' : '▼ ') + Math.abs(d).toFixed(1)
|
||||
+ ' pts</span> vs. the 24 h before';
|
||||
}
|
||||
} else {
|
||||
$('vc-hit-trend').textContent = 'no traffic counters yet';
|
||||
}
|
||||
|
||||
/* Response time */
|
||||
if (data.msMedian !== null && data.msMedian !== undefined) {
|
||||
$('vc-ms-median').textContent = data.msMedian + ' ms';
|
||||
$('vc-ms-p95').textContent = data.msP95 + ' ms';
|
||||
}
|
||||
const drawSpark = () => {
|
||||
const sp = (data.spark || []).map((p) => p[1]);
|
||||
const el = $('vc-spark');
|
||||
if (!el || sp.length < 2) {
|
||||
return;
|
||||
}
|
||||
const w = 300;
|
||||
const h = 46;
|
||||
const max = Math.max(...sp) * 1.08;
|
||||
const min = Math.min(...sp) * 0.85;
|
||||
const span = Math.max(1, max - min);
|
||||
const pts = sp.map((v, i) => [(i / (sp.length - 1)) * w, h - ((v - min) / span) * h]);
|
||||
const d = pts.map((p, i) => (i ? 'L' : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' ');
|
||||
const last = pts[pts.length - 1];
|
||||
el.innerHTML = '<defs><linearGradient id="vc-sg" x1="0" y1="0" x2="0" y2="1">'
|
||||
+ '<stop offset="0" stop-color="' + col.orange + '" stop-opacity=".45"/>'
|
||||
+ '<stop offset="1" stop-color="' + col.orange + '" stop-opacity="0"/></linearGradient></defs>'
|
||||
+ '<path d="' + d + ' L' + w + ' ' + h + ' L0 ' + h + 'Z" fill="url(#vc-sg)"/>'
|
||||
+ '<path d="' + d + '" fill="none" stroke="' + col.orange + '" stroke-width="2" vector-effect="non-scaling-stroke"/>'
|
||||
+ '<circle cx="' + last[0].toFixed(1) + '" cy="' + last[1].toFixed(1) + '" r="3.5" fill="' + col.orange
|
||||
+ '" stroke="' + col.panel + '" stroke-width="2"/>';
|
||||
};
|
||||
drawSpark();
|
||||
|
||||
/* Cache fill */
|
||||
const renderCache = (pages, mb) => {
|
||||
const limit = num(data.cacheLimitMb);
|
||||
if (typeof pages === 'number') {
|
||||
$('vc-cache-pages').textContent = nf.format(pages);
|
||||
}
|
||||
if (typeof mb === 'number') {
|
||||
$('vc-cache-mb').textContent = nf.format(mb);
|
||||
if (limit > 0) {
|
||||
setRing($('vc-ring-cache'), mb / limit);
|
||||
$('vc-cache-pct').textContent = ' · ' + Math.round((mb / limit) * 100) + ' % used';
|
||||
}
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(() => renderCache(data.cachedPages, data.cacheMb));
|
||||
|
||||
/* Client errors per hour */
|
||||
const mini = $('vc-mini');
|
||||
const eh = data.errHours || [];
|
||||
const ehMax = Math.max(1, ...eh);
|
||||
eh.forEach((v, i) => {
|
||||
const b = document.createElement('i');
|
||||
b.style.height = (v ? Math.max(14, (v / ehMax) * 100) : 5) + '%';
|
||||
if (v > 0 && v >= ehMax && v >= 3) {
|
||||
b.className = 'is-hot';
|
||||
}
|
||||
b.title = (23 - i === 0 ? 'this hour' : (23 - i) + ' h ago') + ': ' + v + ' report' + (v === 1 ? '' : 's');
|
||||
mini.appendChild(b);
|
||||
});
|
||||
|
||||
/* Availability strip */
|
||||
const strip = data.strip || { w: [], end: data.now, slot: 900 };
|
||||
const bars = $('vc-bars');
|
||||
const stripStart = strip.end - 96 * strip.slot;
|
||||
(strip.w || []).forEach((w, i) => {
|
||||
const b = document.createElement('i');
|
||||
const t = stripStart + i * strip.slot;
|
||||
let label = 'no sample';
|
||||
if (w === null) {
|
||||
b.className = 'is-none';
|
||||
} else if (w.s === 2) {
|
||||
b.className = 'is-down';
|
||||
label = 'down';
|
||||
} else if (w.s === 1) {
|
||||
b.className = 'is-slow';
|
||||
label = 'slow, ' + w.ms + ' ms';
|
||||
} else {
|
||||
label = 'ok, ' + w.ms + ' ms';
|
||||
b.style.height = Math.round(78 + Math.min(22, (w.ms / 200) * 22)) + '%';
|
||||
}
|
||||
b.title = hhmm(t) + '–' + hhmm(t + strip.slot) + ' · ' + label;
|
||||
bars.appendChild(b);
|
||||
});
|
||||
const axis = $('vc-uptime-axis');
|
||||
[0, 24, 48, 72, 96].forEach((i, k) => {
|
||||
const s = document.createElement('span');
|
||||
s.textContent = k === 4 ? 'now' : hhmm(stripStart + i * strip.slot);
|
||||
axis.appendChild(s);
|
||||
});
|
||||
if (typeof data.uptimePct === 'number') {
|
||||
const slow = num(data.slowWindows);
|
||||
const down = num(data.downWindows);
|
||||
$('vc-uptime-sum').innerHTML = '<b>' + data.uptimePct.toFixed(1) + ' %</b> up · ' + slow + ' slow window'
|
||||
+ (slow === 1 ? '' : 's') + ' · ' + down + ' outage' + (down === 1 ? '' : 's');
|
||||
} else {
|
||||
$('vc-uptime-sum').textContent = 'no samples yet';
|
||||
}
|
||||
|
||||
/* Traffic chart */
|
||||
let chart = null;
|
||||
const label = (ts, size) => {
|
||||
const d = new Date(ts * 1000);
|
||||
const hm = d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
||||
return size >= 14400 ? d.toLocaleDateString('en-GB', { weekday: 'short' }) + ' ' + hm : hm;
|
||||
};
|
||||
const draw = (key) => {
|
||||
const range = (data.range || {})[key];
|
||||
if (!range) {
|
||||
return;
|
||||
}
|
||||
const b = range.b;
|
||||
const sum = (k) => b.reduce((a, x) => a + num(x[k]), 0);
|
||||
const h = sum('hit');
|
||||
const st = sum('stale');
|
||||
const m = sum('miss');
|
||||
const total = h + st + m;
|
||||
$('vc-st-req').textContent = total ? nf.format(total + sum('bypass')) : '–';
|
||||
$('vc-st-hit').textContent = total ? (((h + st) / total) * 100).toFixed(1) + ' %' : '–';
|
||||
$('vc-st-err').textContent = nf.format(sum('errors'));
|
||||
$('vc-st-purge').textContent = nf.format(sum('purges'));
|
||||
const hasMs = b.some((x) => x.ms !== null);
|
||||
$('vc-traffic-empty').hidden = total > 0 || hasMs;
|
||||
|
||||
const maxY = Math.max(1, ...b.map((x) => num(x.hit) + num(x.stale) + num(x.miss)));
|
||||
const maxMs = Math.max(100, ...b.map((x) => num(x.ms)));
|
||||
const tick = { color: col.faint, font: { size: 11 } };
|
||||
const cfg = {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: b.map((x) => label(x.t, range.size)),
|
||||
datasets: [
|
||||
{ label: 'Served from cache', data: b.map((x) => x.hit), backgroundColor: col.navy, stack: 'r', yAxisID: 'y', order: 3 },
|
||||
{ label: 'Stale, refreshed in background', data: b.map((x) => x.stale), backgroundColor: col.sky, stack: 'r', yAxisID: 'y', order: 3 },
|
||||
{ label: 'Rendered fresh', data: b.map((x) => x.miss), backgroundColor: col.warn, stack: 'r', borderRadius: { topLeft: 3, topRight: 3 }, yAxisID: 'y', order: 3 },
|
||||
{
|
||||
type: 'line', label: 'Response time', data: b.map((x) => x.ms), borderColor: col.orange, borderWidth: 2,
|
||||
pointRadius: 0, pointHoverRadius: 4, pointBackgroundColor: col.orange, tension: 0.35, spanGaps: true, yAxisID: 'y1', order: 1,
|
||||
},
|
||||
{
|
||||
type: 'line', label: 'Purge', data: b.map((x) => (x.purges ? maxY * 1.06 : null)), showLine: false,
|
||||
pointStyle: 'triangle', pointRadius: 7, pointHoverRadius: 9, pointBackgroundColor: col.bad, pointBorderColor: col.panel,
|
||||
yAxisID: 'y', order: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: { duration: 600 },
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: '#0b1131', borderColor: 'rgba(140,156,255,.3)', borderWidth: 1,
|
||||
titleColor: col.text, bodyColor: '#c4cbf0', padding: 10,
|
||||
filter: (it) => it.raw !== null,
|
||||
callbacks: {
|
||||
label: (c) => {
|
||||
if (c.dataset.label === 'Purge') {
|
||||
const x = b[c.dataIndex];
|
||||
return ' ' + x.purges + ' purge' + (x.purges === 1 ? '' : 's') + (x.purgeAll ? ', ' + x.purgeAll + ' of them everything' : '');
|
||||
}
|
||||
if (c.dataset.label === 'Response time') {
|
||||
return ' Response time: ' + c.raw + ' ms';
|
||||
}
|
||||
return ' ' + c.dataset.label + ': ' + nf.format(c.raw);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: { stacked: true, grid: { display: false }, ticks: { ...tick, maxRotation: 0, autoSkip: true, maxTicksLimit: 8 }, border: { color: 'rgba(140,156,255,.2)' } },
|
||||
y: {
|
||||
stacked: true, beginAtZero: true, suggestedMax: maxY * 1.12, grid: { color: 'rgba(140,156,255,.08)' }, ticks: tick,
|
||||
border: { display: false }, title: { display: true, text: 'requests', color: col.faint },
|
||||
},
|
||||
y1: {
|
||||
position: 'right', beginAtZero: true, suggestedMax: Math.ceil(maxMs * 1.3), grid: { display: false },
|
||||
ticks: { ...tick, color: col.orange, callback: (v) => v + ' ms' }, border: { display: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
}
|
||||
chart = new Chart($('vc-traffic'), cfg);
|
||||
};
|
||||
Chart.defaults.font.family = getComputedStyle(document.body).fontFamily;
|
||||
// Draw once the stylesheet has laid out the grid, and follow the box
|
||||
// afterwards: Chart.js alone kept the width it saw on first paint.
|
||||
const box = $('vc-traffic').parentElement;
|
||||
const start = () => {
|
||||
draw('24h');
|
||||
if (window.ResizeObserver) {
|
||||
let size = box.clientWidth + 'x' + box.clientHeight;
|
||||
new ResizeObserver(() => {
|
||||
const now = box.clientWidth + 'x' + box.clientHeight;
|
||||
if (chart && now !== size) {
|
||||
size = now;
|
||||
chart.resize();
|
||||
}
|
||||
}).observe(box);
|
||||
}
|
||||
};
|
||||
if (document.readyState === 'complete') {
|
||||
requestAnimationFrame(start);
|
||||
} else {
|
||||
window.addEventListener('load', () => requestAnimationFrame(start), { once: true });
|
||||
}
|
||||
root.querySelectorAll('.vc-seg button[data-range]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
root.querySelectorAll('.vc-seg button[data-range]').forEach((x) => x.setAttribute('aria-pressed', x === btn ? 'true' : 'false'));
|
||||
draw(btn.dataset.range);
|
||||
});
|
||||
});
|
||||
|
||||
/* Warm cache + live status */
|
||||
const warmBtn = $('ssr-warm-button');
|
||||
const warmLabel = $('vc-warm-label');
|
||||
const warmPct = $('vc-warm-pct');
|
||||
const warmMeta = $('vc-warm-meta');
|
||||
const warmPill = $('vc-warm-pill');
|
||||
const warmRing = $('vc-ring-warm');
|
||||
const pill = (cls, text) => {
|
||||
warmPill.hidden = !text;
|
||||
warmPill.className = 'vc-pill ' + cls;
|
||||
warmPill.textContent = text || '';
|
||||
};
|
||||
const renderWarm = (w) => {
|
||||
w = w || {};
|
||||
const queued = num(w.queued);
|
||||
const handled = num(w.done) + num(w.skipped) + num(w.failed);
|
||||
const failed = num(w.failed);
|
||||
const failedHtml = failed ? '<span class="vc-bad-text">' + failed + ' failed</span>' : '0 failed';
|
||||
if (w.running) {
|
||||
const f = queued > 0 ? handled / queued : 0;
|
||||
setRing(warmRing, f);
|
||||
warmPct.textContent = Math.round(f * 100) + ' %';
|
||||
warmMeta.innerHTML = '<b>' + nf.format(handled) + '</b> of ' + nf.format(queued) + ' pages<br>'
|
||||
+ num(w.skipped) + ' still fresh · ' + failedHtml + '<br>live, every 2 seconds';
|
||||
pill('is-sky', 'running');
|
||||
} else if (w.startedAt) {
|
||||
setRing(warmRing, queued > 0 ? handled / queued : 1);
|
||||
warmPct.textContent = failed || w.aborted ? '!' : '✓';
|
||||
const started = new Date(w.startedAt);
|
||||
const secs = w.finishedAt ? Math.round((new Date(w.finishedAt) - started) / 1000) : null;
|
||||
const took = secs === null ? '' : '<br>took ' + (secs >= 120 ? Math.round(secs / 60) + ' min' : secs + ' s');
|
||||
warmMeta.innerHTML = 'Last run ' + started.toLocaleString('en-GB', { weekday: 'short', hour: '2-digit', minute: '2-digit' })
|
||||
+ '<br><b>' + nf.format(num(w.done)) + '</b> of ' + nf.format(queued) + ' rendered · '
|
||||
+ num(w.skipped) + ' still fresh · ' + failedHtml + took;
|
||||
pill(w.aborted ? 'is-warn' : (failed ? 'is-bad' : 'is-ok'), w.aborted ? 'aborted by restart' : (failed ? 'with errors' : 'done'));
|
||||
} else {
|
||||
setRing(warmRing, 0);
|
||||
warmPct.textContent = '–';
|
||||
warmMeta.textContent = 'No run since the server started.';
|
||||
pill('', '');
|
||||
}
|
||||
if (warmBtn) {
|
||||
warmBtn.disabled = !!w.running;
|
||||
warmLabel.textContent = w.running ? 'Warming …' : 'Warm cache';
|
||||
}
|
||||
return !!w.running;
|
||||
};
|
||||
requestAnimationFrame(() => renderWarm(data.warming));
|
||||
|
||||
const renderOnline = (online) => {
|
||||
const p = $('vc-pulse');
|
||||
const t = $('vc-live-title');
|
||||
if (!p || !t) {
|
||||
return;
|
||||
}
|
||||
p.classList.toggle('is-down', !online);
|
||||
t.innerHTML = online ? 'SSR <span class="vc-ok-text">online</span>' : 'SSR <span class="vc-bad-text">not reachable</span>';
|
||||
};
|
||||
|
||||
let timer = null;
|
||||
const schedule = (ms) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(poll, ms);
|
||||
};
|
||||
const poll = () => {
|
||||
new AjaxRequest(TYPO3.settings.ajaxUrls.vitec_ssr_health).get()
|
||||
.then((response) => response.resolve())
|
||||
.then((res) => {
|
||||
const h = res && res.health;
|
||||
renderOnline(!!h);
|
||||
let running = false;
|
||||
if (h) {
|
||||
running = renderWarm(h.warming);
|
||||
renderCache(h.cachedPages, h.cacheMB);
|
||||
if (typeof h.responseMs === 'number') {
|
||||
$('vc-ms').textContent = h.responseMs;
|
||||
}
|
||||
}
|
||||
schedule(running ? 2000 : 15000);
|
||||
})
|
||||
.catch(() => schedule(15000));
|
||||
};
|
||||
schedule(data.warming && data.warming.running ? 1000 : 15000);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
clearTimeout(timer);
|
||||
} else {
|
||||
poll();
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user