mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-05-15 14:38:03 +02:00
Improve error dashboard
This commit is contained in:
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+410
-24
@@ -537,26 +537,118 @@ a:hover {
|
|||||||
.paper-stats-inner {
|
.paper-stats-inner {
|
||||||
max-width: 1100px;
|
max-width: 1100px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 36px 32px;
|
padding: 28px 32px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-stats-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-stats-meta-left {
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-stats-meta-right {
|
||||||
|
text-transform: lowercase;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-stats-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #1f9d55;
|
||||||
|
box-shadow: 0 0 0 3px rgba(31, 157, 85, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-stats-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 24px;
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.paper-stats-grid { grid-template-columns: repeat(2, 1fr); gap: 28px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-stat-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paper-stat-value {
|
.paper-stat-value {
|
||||||
font-family: var(--font-serif);
|
font-family: var(--font-serif);
|
||||||
font-size: 42px;
|
font-size: 56px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: var(--color);
|
color: var(--color);
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paper-stat-label {
|
.paper-stat-label {
|
||||||
margin-top: 8px;
|
margin-top: 10px;
|
||||||
font-size: 12.5px;
|
font-size: 13px;
|
||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.paper-stat-bars {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 36px;
|
||||||
|
margin-top: 16px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-stat-bars rect {
|
||||||
|
fill: var(--ink-muted);
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-stat-bars rect.is-latest {
|
||||||
|
fill: #2937e3;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .paper-stat-bars rect.is-latest {
|
||||||
|
fill: #6c7bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-stat-delta {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-stat-sep {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-stat-pct.is-up {
|
||||||
|
color: #1f9d55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-stat-pct.is-down {
|
||||||
|
color: #b54137;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-stat-arrow {
|
||||||
|
font-size: 10px;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
/* How it works — editorial numbered steps */
|
/* How it works — editorial numbered steps */
|
||||||
.paper-how {
|
.paper-how {
|
||||||
max-width: 1100px;
|
max-width: 1100px;
|
||||||
@@ -4703,22 +4795,316 @@ textarea::selection {
|
|||||||
.file.folder.truncated > a {
|
.file.folder.truncated > a {
|
||||||
color: #d39e00;
|
color: #d39e00;
|
||||||
}
|
}
|
||||||
/* Errors admin */
|
|
||||||
.errors-table .error-when time { font-variant-numeric: tabular-nums; color: #555; cursor: help; }
|
/* ===== Errors admin page =====
|
||||||
.errors-table .error-msg-line { display: flex; flex-wrap: wrap; gap: 6px; align-items: baseline; }
|
Uses the existing design tokens (--paper-card, --border-color, --ink-muted,
|
||||||
.errors-table .error-chip {
|
--primary-bg, etc.) so light + dark themes are picked up automatically.
|
||||||
display: inline-flex; align-items: center; gap: 4px;
|
Bucket colors are exposed as their own tokens with light/dark overrides
|
||||||
font-size: 0.78rem; padding: 1px 6px; border-radius: 999px;
|
below so status pills stay legible against the dark canvas. */
|
||||||
background: #eef0f3; color: #333; border: 1px solid #dde0e4;
|
body {
|
||||||
max-width: 36em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
--bucket-error-bg: #FCEDED;
|
||||||
|
--bucket-error-fg: #8A1F1F;
|
||||||
|
--bucket-error-bd: #F0C4C4;
|
||||||
|
--bucket-error-dot: #B53737;
|
||||||
|
--bucket-warn-bg: #FFF3DF;
|
||||||
|
--bucket-warn-fg: #7A4D00;
|
||||||
|
--bucket-warn-bd: #F0D8A0;
|
||||||
|
--bucket-warn-dot: #B07A2F;
|
||||||
|
--bucket-info-bg: #EAF2EC;
|
||||||
|
--bucket-info-fg: #2C5D3A;
|
||||||
|
--bucket-info-bd: #C5DCCD;
|
||||||
|
--bucket-info-dot: #5B8D6B;
|
||||||
|
/* Subtle elevation tokens — gives cards weight without losing the paper
|
||||||
|
palette. Single soft shadow + a 1px hairline to keep the editorial feel. */
|
||||||
|
--card-shadow: 0 1px 2px rgba(26, 24, 21, 0.04), 0 4px 14px rgba(26, 24, 21, 0.05);
|
||||||
|
--card-shadow-hover: 0 1px 2px rgba(26, 24, 21, 0.06), 0 8px 24px rgba(26, 24, 21, 0.08);
|
||||||
}
|
}
|
||||||
.errors-table .error-chip .chip-label { color: #777; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.03em; }
|
.dark-mode {
|
||||||
.errors-table .error-chip.chip-err { background: #fdecec; border-color: #f5c2c2; color: #8a1f1f; }
|
--bucket-error-bg: rgba(255, 139, 123, 0.10);
|
||||||
.errors-table .error-chip.chip-warn { background: #fff5e1; border-color: #f3d9a4; color: #7a4d00; }
|
--bucket-error-fg: #FF8B7B;
|
||||||
.errors-table .error-chip.chip-ok { background: #e9f6ec; border-color: #b8dfc1; color: #1f6b32; }
|
--bucket-error-bd: rgba(255, 139, 123, 0.28);
|
||||||
.errors-table .error-chip.chip-mono .chip-value { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.78rem; }
|
--bucket-error-dot: #FF8B7B;
|
||||||
.errors-table .pill-module { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.78rem; background: #eef0f3; color: #333; padding: 1px 6px; border-radius: 4px; }
|
--bucket-warn-bg: rgba(255, 211, 122, 0.10);
|
||||||
.errors-table .error-details { margin-top: 6px; }
|
--bucket-warn-fg: #FFD37A;
|
||||||
.errors-table .error-details summary { cursor: pointer; color: #666; font-size: 0.82rem; }
|
--bucket-warn-bd: rgba(255, 211, 122, 0.28);
|
||||||
.errors-table .error-details pre { background: #fafafa; border: 1px solid #ececec; border-radius: 4px; padding: 8px; font-size: 0.78rem; max-height: 18em; overflow: auto; }
|
--bucket-warn-dot: #FFD37A;
|
||||||
.errors-table .error-context { color: #888; font-size: 0.78rem; font-style: italic; margin-left: 4px; }
|
--bucket-info-bg: rgba(152, 200, 168, 0.10);
|
||||||
|
--bucket-info-fg: #98C8A8;
|
||||||
|
--bucket-info-bd: rgba(152, 200, 168, 0.28);
|
||||||
|
--bucket-info-dot: #98C8A8;
|
||||||
|
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 6px 18px rgba(0, 0, 0, 0.30);
|
||||||
|
--card-shadow-hover: 0 2px 4px rgba(0, 0, 0, 0.45), 0 12px 28px rgba(0, 0, 0, 0.40);
|
||||||
|
}
|
||||||
|
|
||||||
|
.errors-page .errors-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin: 4px 0 8px; }
|
||||||
|
.errors-page .errors-actions { display: flex; gap: 6px; }
|
||||||
|
|
||||||
|
.errors-page .kpi-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin: 14px 0 18px;
|
||||||
|
}
|
||||||
|
.errors-page .kpi-card {
|
||||||
|
background: var(--paper-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
transition: box-shadow 160ms ease, transform 160ms ease;
|
||||||
|
}
|
||||||
|
.errors-page .kpi-card:hover { box-shadow: var(--card-shadow-hover); }
|
||||||
|
.errors-page .kpi-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
.errors-page .kpi-value {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: 2.6rem;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--color);
|
||||||
|
}
|
||||||
|
.errors-page .kpi-card.kpi-error .kpi-value { color: var(--bucket-error-fg); }
|
||||||
|
.errors-page .kpi-card.kpi-warn .kpi-value { color: var(--bucket-warn-fg); }
|
||||||
|
.errors-page .kpi-card.kpi-info .kpi-value { color: var(--bucket-info-fg); }
|
||||||
|
.errors-page .kpi-sub { font-size: 0.78rem; color: var(--ink-muted); font-style: italic; }
|
||||||
|
.errors-page .kpi-sub.up { color: var(--bucket-error-fg); font-style: normal; }
|
||||||
|
.errors-page .kpi-sub.down { color: var(--bucket-info-fg); font-style: normal; }
|
||||||
|
.errors-page .dropped-warn { color: var(--bucket-error-fg); font-weight: 600; font-style: normal; }
|
||||||
|
|
||||||
|
.errors-page .volume-chart {
|
||||||
|
background: var(--paper-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 18px 22px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
}
|
||||||
|
.errors-page .volume-head {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.7rem; color: var(--ink-muted);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.errors-page .volume-legend { display: flex; gap: 14px; align-items: center; text-transform: none; letter-spacing: 0; font-family: var(--font-sans); }
|
||||||
|
.errors-page .volume-legend .dot { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 5px; vertical-align: middle; }
|
||||||
|
.errors-page .dot.dot-error { background: var(--bucket-error-dot); }
|
||||||
|
.errors-page .dot.dot-warn { background: var(--bucket-warn-dot); }
|
||||||
|
.errors-page .dot.dot-info { background: var(--bucket-info-dot); }
|
||||||
|
.errors-page .volume-bars { display: flex; align-items: flex-end; gap: 4px; height: 80px; }
|
||||||
|
.errors-page .volume-bar { display: flex; flex-direction: column-reverse; flex: 1 1 0; min-width: 6px; height: 100%; }
|
||||||
|
.errors-page .volume-bar .seg { display: block; width: 100%; }
|
||||||
|
.errors-page .seg.seg-error { background: var(--bucket-error-dot); }
|
||||||
|
.errors-page .seg.seg-warn { background: var(--bucket-warn-dot); }
|
||||||
|
.errors-page .seg.seg-info { background: var(--bucket-info-dot); opacity: 0.7; }
|
||||||
|
|
||||||
|
.errors-page .errors-toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 12px; }
|
||||||
|
.errors-page .seg-tabs {
|
||||||
|
display: inline-flex;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--paper-card);
|
||||||
|
}
|
||||||
|
.errors-page .seg-tabs button {
|
||||||
|
background: transparent; border: none;
|
||||||
|
padding: 6px 14px; font-size: 0.85rem; cursor: pointer;
|
||||||
|
color: var(--color); font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
.errors-page .seg-tabs button + button { border-left: 1px solid var(--border-color); }
|
||||||
|
.errors-page .seg-tabs button.active { background: var(--primary-bg); color: var(--primary-color); }
|
||||||
|
.errors-page .errors-toolbar .search-wrap { position: relative; flex: 1 1 320px; min-width: 220px; }
|
||||||
|
.errors-page .errors-toolbar .search-wrap input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 10px 6px 32px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: var(--font-mono); font-size: 0.82rem;
|
||||||
|
background: var(--input-bg);
|
||||||
|
color: var(--input-color);
|
||||||
|
}
|
||||||
|
.errors-page .errors-toolbar .search-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--ink-muted); font-size: 0.85rem; }
|
||||||
|
.errors-page .errors-toolbar .filter-count { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); font-size: 0.72rem; color: var(--ink-muted); }
|
||||||
|
.errors-page .errors-toolbar .select-wrap { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.errors-page .errors-toolbar .select-wrap label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
color: var(--ink-muted); margin: 0;
|
||||||
|
}
|
||||||
|
.errors-page .errors-toolbar .select-wrap select {
|
||||||
|
background: var(--input-bg); color: var(--input-color);
|
||||||
|
border: 1px solid var(--border-color); border-radius: 8px;
|
||||||
|
}
|
||||||
|
.errors-page .errors-toolbar .autoref { display: inline-flex; gap: 6px; align-items: center; font-size: 0.82rem; color: var(--color); cursor: pointer; margin: 0; }
|
||||||
|
.errors-page .btn-icon { width: 32px; padding: 4px 0; }
|
||||||
|
|
||||||
|
.errors-page .errors-list {
|
||||||
|
background: var(--paper-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
}
|
||||||
|
.errors-page .errors-list-head,
|
||||||
|
.errors-page .errors-row-main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 110px 100px 100px 1fr 60px 70px;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.errors-page .errors-list-head { padding: 12px 20px; align-items: center; }
|
||||||
|
.errors-page .errors-list-head {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--paper-bg-alt);
|
||||||
|
}
|
||||||
|
.errors-page .errors-row { border-bottom: 1px solid var(--border-soft); }
|
||||||
|
.errors-page .errors-row:last-child { border-bottom: none; }
|
||||||
|
.errors-page .errors-row.open { background: var(--paper-bg-alt); }
|
||||||
|
.errors-page .errors-row-main { cursor: pointer; }
|
||||||
|
.errors-page .errors-row-main:hover { background: var(--hover-bg-color); }
|
||||||
|
.errors-page .col-when .when-rel { font-size: 0.95rem; color: var(--color); font-variant-numeric: tabular-nums; }
|
||||||
|
.errors-page .col-when .when-abs { font-family: var(--font-mono); font-size: 0.78rem; color: var(--ink-muted); font-variant-numeric: tabular-nums; margin-top: 2px; }
|
||||||
|
.errors-page .col-sev { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.errors-page .sev-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||||||
|
.errors-page .sev-dot.sev-error { background: var(--bucket-error-dot); }
|
||||||
|
.errors-page .sev-dot.sev-warn { background: var(--bucket-warn-dot); }
|
||||||
|
.errors-page .sev-dot.sev-info { background: var(--bucket-info-dot); }
|
||||||
|
.errors-page .sev-label { font-family: var(--font-mono); font-size: 0.74rem; letter-spacing: 0.08em; color: var(--color); }
|
||||||
|
|
||||||
|
.errors-page .pill-module {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: var(--font-mono); font-size: 0.78rem;
|
||||||
|
background: var(--paper-bg-alt); color: var(--ink-soft);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 3px 10px; border-radius: 6px;
|
||||||
|
}
|
||||||
|
.errors-page .col-msg .msg-code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--color);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.errors-page .col-msg .msg-context { color: var(--ink-muted); font-style: italic; font-size: 0.88rem; margin-left: 10px; }
|
||||||
|
.errors-page .col-msg .msg-detail {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
.errors-page .col-msg .msg-url {
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-top: 6px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errors-page .count-pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px; border-radius: 4px;
|
||||||
|
background: var(--primary-bg); color: var(--primary-color);
|
||||||
|
font-family: var(--font-mono); font-size: 0.78rem; font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.errors-page .count-pill.count-pill-muted {
|
||||||
|
background: transparent; color: var(--ink-muted);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.errors-page .status-pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px; border-radius: 4px;
|
||||||
|
font-family: var(--font-mono); font-size: 0.8rem; font-variant-numeric: tabular-nums;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.errors-page .status-pill.status-error { background: var(--bucket-error-bg); color: var(--bucket-error-fg); border-color: var(--bucket-error-bd); }
|
||||||
|
.errors-page .status-pill.status-warn { background: var(--bucket-warn-bg); color: var(--bucket-warn-fg); border-color: var(--bucket-warn-bd); }
|
||||||
|
.errors-page .status-pill.status-info { background: var(--bucket-info-bg); color: var(--bucket-info-fg); border-color: var(--bucket-info-bd); }
|
||||||
|
|
||||||
|
.errors-page .errors-row-detail { padding: 0 16px 16px; }
|
||||||
|
.errors-page .detail-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border-color); margin-bottom: 12px; }
|
||||||
|
.errors-page .detail-tabs button {
|
||||||
|
background: transparent; border: 0;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
padding: 8px 12px; font-size: 0.85rem; cursor: pointer;
|
||||||
|
color: var(--ink-muted); font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
.errors-page .detail-tabs button.active { color: var(--color); border-bottom-color: var(--color); }
|
||||||
|
.errors-page .detail-body { display: grid; grid-template-columns: 1fr 260px; gap: 20px; }
|
||||||
|
.errors-page .detail-main pre {
|
||||||
|
background: var(--paper-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 18px 22px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--color);
|
||||||
|
max-height: 26em;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre;
|
||||||
|
margin: 0;
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
}
|
||||||
|
.errors-page .related-list {
|
||||||
|
background: var(--paper-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
max-height: 22em; overflow: auto;
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
}
|
||||||
|
.errors-page .related-row {
|
||||||
|
display: flex; gap: 10px; padding: 6px 12px; align-items: center;
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.errors-page .related-row:last-child { border-bottom: none; }
|
||||||
|
.errors-page .detail-actions { display: flex; gap: 6px; align-items: center; margin-top: 10px; }
|
||||||
|
.errors-page .copy-hint { font-size: 0.78rem; color: var(--bucket-info-fg); }
|
||||||
|
|
||||||
|
.errors-page .detail-aside {
|
||||||
|
background: var(--paper-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 18px;
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
}
|
||||||
|
.errors-page .aside-block + .aside-block { margin-top: 16px; }
|
||||||
|
.errors-page .aside-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
color: var(--ink-muted); margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.errors-page .aside-value {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: var(--color);
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.errors-page .aside-value.mono { font-family: var(--font-mono); font-size: 0.82rem; }
|
||||||
|
.errors-page .aside-sub { color: var(--ink-muted); font-style: italic; font-size: 0.8rem; font-family: var(--font-sans); }
|
||||||
|
|
||||||
|
.errors-page .errors-pager { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; color: var(--ink-muted); font-size: 0.85rem; }
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.errors-page .errors-list-head,
|
||||||
|
.errors-page .errors-row-main { grid-template-columns: 80px 80px 1fr 60px; }
|
||||||
|
.errors-page .errors-list-head .col-mod,
|
||||||
|
.errors-page .errors-row-main .col-mod,
|
||||||
|
.errors-page .errors-list-head .col-sev,
|
||||||
|
.errors-page .errors-row-main .col-sev { display: none; }
|
||||||
|
.errors-page .detail-body { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
<div class="container paper-page admin-page">
|
<div class="container paper-page admin-page errors-page">
|
||||||
<div class="paper-crumbs">Admin / <span class="here">Errors</span></div>
|
<div class="paper-crumbs">Admin / <span class="here">Errors</span></div>
|
||||||
<h1 class="paper-page-title">Errors</h1>
|
|
||||||
|
<header class="errors-header">
|
||||||
|
<h1 class="paper-page-title">Errors</h1>
|
||||||
|
<div class="errors-actions">
|
||||||
|
<button class="btn btn-sm" type="button" ng-click="exportCsv()"><i class="fas fa-file-export"></i> Export CSV</button>
|
||||||
|
<button class="btn btn-sm btn-danger" type="button" ng-click="clearAll()"><i class="fas fa-trash"></i> Clear all</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<nav class="admin-nav">
|
<nav class="admin-nav">
|
||||||
<a href="/admin/"><i class="fas fa-code-branch"></i> Repositories</a>
|
<a href="/admin/"><i class="fas fa-code-branch"></i> Repositories</a>
|
||||||
@@ -10,68 +17,179 @@
|
|||||||
<a href="/admin/errors" class="active"><i class="fas fa-bug"></i> Errors</a>
|
<a href="/admin/errors" class="active"><i class="fas fa-bug"></i> Errors</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="admin-summary">
|
<section class="kpi-grid">
|
||||||
<span class="summary-pill error">{{filtered.length}} shown</span>
|
<div class="kpi-card">
|
||||||
<span class="summary-pill">{{entries.length}} captured</span>
|
<div class="kpi-label">Last 24h</div>
|
||||||
<span class="summary-pill" ng-if="!available">redis sink unavailable</span>
|
<div class="kpi-value">{{stats.last24h}}</div>
|
||||||
</div>
|
<div class="kpi-sub" ng-class="{up: stats.delta > 0, down: stats.delta < 0}">
|
||||||
|
<span ng-if="stats.prev24h">{{stats.delta > 0 ? '+' : ''}}{{stats.delta}}% vs yesterday</span>
|
||||||
<form class="w-100 admin-filter-toolbar" aria-label="Error filters">
|
<span ng-if="!stats.prev24h">no prior baseline</span>
|
||||||
<div class="admin-filter-row">
|
|
||||||
<div class="search-wrap">
|
|
||||||
<input type="search" class="form-control" placeholder="Search message, module, or url…" ng-model="query.search" autocomplete="off" />
|
|
||||||
</div>
|
</div>
|
||||||
<span class="admin-filter-inline">
|
|
||||||
<label>Module</label>
|
|
||||||
<select class="form-control form-control-sm" ng-model="query.module">
|
|
||||||
<option value="">Any</option>
|
|
||||||
<option ng-repeat="m in modules" value="{{m}}">{{m}}</option>
|
|
||||||
</select>
|
|
||||||
</span>
|
|
||||||
<span class="admin-filter-spacer"></span>
|
|
||||||
<label class="admin-filter-inline" style="cursor:pointer;">
|
|
||||||
<input type="checkbox" ng-model="query.autoRefresh" />
|
|
||||||
Auto-refresh
|
|
||||||
</label>
|
|
||||||
<button class="btn btn-sm" type="button" ng-click="refreshNow()" title="Refresh now"><i class="fas fa-sync"></i></button>
|
|
||||||
<button class="btn btn-sm btn-danger" type="button" ng-click="clearAll()" title="Clear all errors"><i class="fas fa-trash"></i> Clear</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="kpi-card kpi-error">
|
||||||
|
<div class="kpi-label">Errors (5xx)</div>
|
||||||
|
<div class="kpi-value">{{stats.severity.error}}</div>
|
||||||
|
<div class="kpi-sub">{{stats.unique.error}} unique</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card kpi-warn">
|
||||||
|
<div class="kpi-label">Warnings (4xx)</div>
|
||||||
|
<div class="kpi-value">{{stats.severity.warn}}</div>
|
||||||
|
<div class="kpi-sub">{{stats.unique.warn}} unique</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card kpi-info">
|
||||||
|
<div class="kpi-label">Info (auth, 404)</div>
|
||||||
|
<div class="kpi-value">{{stats.severity.info}}</div>
|
||||||
|
<div class="kpi-sub">{{stats.unique.info}} unique</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card" ng-class="{'kpi-error': stats.dropped > 0}">
|
||||||
|
<div class="kpi-label">Captured</div>
|
||||||
|
<div class="kpi-value">{{total}}</div>
|
||||||
|
<div class="kpi-sub">
|
||||||
|
cap {{cap}} · {{available ? 'live' : 'redis off'}}
|
||||||
|
<span ng-if="stats.dropped > 0" class="dropped-warn"> · {{stats.dropped}} dropped</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="volume-chart">
|
||||||
|
<div class="volume-head">
|
||||||
|
<span class="volume-title">Volume · 24h · 1h buckets</span>
|
||||||
|
<span class="volume-legend">
|
||||||
|
<span class="dot dot-error"></span>error
|
||||||
|
<span class="dot dot-warn"></span>warn
|
||||||
|
<span class="dot dot-info"></span>info
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="volume-bars">
|
||||||
|
<div class="volume-bar" ng-repeat="b in stats.buckets track by $index" title="{{bucketTitle(b)}}">
|
||||||
|
<span class="seg seg-error" ng-style="{height: barPx(b, 'error') + 'px'}"></span>
|
||||||
|
<span class="seg seg-warn" ng-style="{height: barPx(b, 'warn') + 'px'}"></span>
|
||||||
|
<span class="seg seg-info" ng-style="{height: barPx(b, 'info') + 'px'}"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form class="errors-toolbar" aria-label="Error filters">
|
||||||
|
<div class="seg-tabs">
|
||||||
|
<button type="button" ng-class="{active: query.bucket === ''}" ng-click="setBucket('')">All</button>
|
||||||
|
<button type="button" ng-class="{active: query.bucket === 'error'}" ng-click="setBucket('error')">5xx</button>
|
||||||
|
<button type="button" ng-class="{active: query.bucket === 'warn'}" ng-click="setBucket('warn')">4xx</button>
|
||||||
|
<button type="button" ng-class="{active: query.bucket === 'info'}" ng-click="setBucket('info')">Info</button>
|
||||||
|
</div>
|
||||||
|
<div class="search-wrap">
|
||||||
|
<i class="fas fa-search search-icon"></i>
|
||||||
|
<input type="search" class="form-control" placeholder="code:repo_not_found module:route status:>=400" ng-model="query.search" autocomplete="off" />
|
||||||
|
<span class="filter-count" ng-if="parsedFilterCount">{{parsedFilterCount}} filter{{parsedFilterCount > 1 ? 's' : ''}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="select-wrap">
|
||||||
|
<label>Sort</label>
|
||||||
|
<select class="form-control form-control-sm" ng-model="query.sort">
|
||||||
|
<option value="recent">Most recent</option>
|
||||||
|
<option value="count">Most frequent</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="select-wrap">
|
||||||
|
<label>Group</label>
|
||||||
|
<select class="form-control form-control-sm" ng-model="query.group">
|
||||||
|
<option value="">Off</option>
|
||||||
|
<option value="code">By code</option>
|
||||||
|
<option value="module">By module</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<label class="autoref">
|
||||||
|
<input type="checkbox" ng-model="query.autoRefresh" />
|
||||||
|
Auto-refresh
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-sm btn-icon" type="button" ng-click="refreshNow()" title="Refresh now"><i class="fas fa-sync"></i></button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div ng-if="!filtered.length" class="admin-empty">No errors captured.</div>
|
<div ng-if="!visible.length" class="admin-empty">No errors captured.</div>
|
||||||
|
<div ng-if="canLoadMore() && visible.length" class="errors-pager">
|
||||||
|
<span>Showing {{entries.length}} of {{total}} captured</span>
|
||||||
|
<button class="btn btn-sm" type="button" ng-click="loadMore()">Load older</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<table class="table errors-table" ng-if="filtered.length">
|
<div class="errors-list" ng-if="visible.length">
|
||||||
<thead>
|
<div class="errors-list-head">
|
||||||
<tr>
|
<span class="col-when">When</span>
|
||||||
<th style="width: 9em;">When</th>
|
<span class="col-sev">Severity</span>
|
||||||
<th style="width: 9em;">Module</th>
|
<span class="col-mod">Module</span>
|
||||||
<th>Message</th>
|
<span class="col-msg">Message</span>
|
||||||
</tr>
|
<span class="col-count">Count</span>
|
||||||
</thead>
|
<span class="col-status">Status</span>
|
||||||
<tbody>
|
</div>
|
||||||
<tr ng-repeat="e in filtered track by $index">
|
|
||||||
<td class="error-when">
|
<div class="errors-row" ng-repeat="row in visible track by row._key" ng-class="{open: expanded[row._key]}">
|
||||||
<time title="{{absTime(e.ts)}}">{{relTime(e.ts)}}</time>
|
<div class="errors-row-main" ng-click="toggle(row)">
|
||||||
</td>
|
<div class="col-when">
|
||||||
<td><span class="pill pill-module">{{e.module}}</span></td>
|
<div class="when-rel">{{relTime(row.ts)}}</div>
|
||||||
<td class="error-msg">
|
<div class="when-abs">{{absTimeShort(row.ts)}}</div>
|
||||||
<div class="error-msg-line">
|
</div>
|
||||||
<strong>{{e.displayMessage}}</strong>
|
<div class="col-sev">
|
||||||
<span class="error-context" ng-if="e.displayContext && e.displayContext !== e.displayMessage">{{e.displayContext}}</span>
|
<span class="sev-dot" ng-class="'sev-' + row._bucket"></span>
|
||||||
<span class="error-chip"
|
<span class="sev-label">{{row._bucket | uppercase}}</span>
|
||||||
ng-repeat="c in e._chips track by $index"
|
</div>
|
||||||
ng-class="{'chip-err': c.kind === 'err', 'chip-warn': c.kind === 'warn', 'chip-ok': c.kind === 'ok', 'chip-mono': c.mono}"
|
<div class="col-mod"><span class="pill pill-module">{{row.module}}</span></div>
|
||||||
title="{{c.label}}: {{c.value}}">
|
<div class="col-msg">
|
||||||
<span class="chip-label">{{c.label}}</span>
|
<strong class="msg-code">{{row.displayMessage}}</strong>
|
||||||
<span class="chip-value">{{c.value}}</span>
|
<span class="msg-context" ng-if="row.displayContext && row.displayContext !== row.displayMessage">{{row.displayContext}}</span>
|
||||||
</span>
|
<span class="msg-detail" ng-if="row._detail">{{row._detail}}</span>
|
||||||
|
<div class="msg-url" ng-if="row._url">{{row._url}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-count">
|
||||||
|
<span class="count-pill" ng-if="row.count > 1">×{{row.count}}</span>
|
||||||
|
<span class="count-pill count-pill-muted" ng-if="row.count === 1">×1</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-status">
|
||||||
|
<span class="status-pill" ng-if="row._status" ng-class="'status-' + row._bucket">{{row._status}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="errors-row-detail" ng-if="expanded[row._key]">
|
||||||
|
<div class="detail-tabs">
|
||||||
|
<button type="button" ng-class="{active: detailTab[row._key] === 'raw' || !detailTab[row._key]}" ng-click="detailTab[row._key] = 'raw'">Raw</button>
|
||||||
|
<button type="button" ng-class="{active: detailTab[row._key] === 'related'}" ng-click="detailTab[row._key] = 'related'" ng-if="row.count > 1">Related ({{row.count}})</button>
|
||||||
|
</div>
|
||||||
|
<div class="detail-body">
|
||||||
|
<div class="detail-main">
|
||||||
|
<pre ng-if="(detailTab[row._key] || 'raw') === 'raw'">{{row._detailJson}}</pre>
|
||||||
|
<div ng-if="detailTab[row._key] === 'related'" class="related-list">
|
||||||
|
<div class="related-row" ng-repeat="r in row._related track by $index">
|
||||||
|
<span class="when-abs">{{absTimeShort(r.ts)}}</span>
|
||||||
|
<span class="msg-url">{{r._url}}</span>
|
||||||
|
<span class="status-pill" ng-if="r._status" ng-class="'status-' + r._bucket">{{r._status}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-actions">
|
||||||
|
<button class="btn btn-sm" type="button" ng-click="copyCurl(row)" title="Copy a curl that reproduces the request"><i class="fas fa-terminal"></i> Copy curl</button>
|
||||||
|
<button class="btn btn-sm" type="button" ng-click="copyJson(row)"><i class="fas fa-clipboard"></i> Copy JSON</button>
|
||||||
|
<span class="copy-hint" ng-if="copyHint">{{copyHint}}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<details ng-if="e._detailJson" class="error-details">
|
<aside class="detail-aside">
|
||||||
<summary>raw</summary>
|
<div class="aside-block">
|
||||||
<pre>{{e._detailJson}}</pre>
|
<div class="aside-label">First seen</div>
|
||||||
</details>
|
<div class="aside-value" title="{{absTime(row._firstSeen)}}">{{relTime(row._firstSeen)}}</div>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
<div class="aside-block">
|
||||||
</tbody>
|
<div class="aside-label">Last seen</div>
|
||||||
</table>
|
<div class="aside-value" title="{{absTime(row.ts)}}">{{relTime(row.ts)}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="aside-block">
|
||||||
|
<div class="aside-label">Occurrences</div>
|
||||||
|
<div class="aside-value">{{row.count}}<span class="aside-sub" ng-if="row._lastHourCount"> · {{row._lastHourCount}} this hour</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="aside-block" ng-if="row._repoId">
|
||||||
|
<div class="aside-label">Repository</div>
|
||||||
|
<a class="aside-value" ng-href="/admin/?search={{row._repoId}}">{{row._repoId}}</a>
|
||||||
|
</div>
|
||||||
|
<div class="aside-block" ng-if="row._url">
|
||||||
|
<div class="aside-label">URL</div>
|
||||||
|
<div class="aside-value mono">{{row._url}}</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+28
-10
@@ -61,17 +61,35 @@
|
|||||||
<!-- Stats strip -->
|
<!-- Stats strip -->
|
||||||
<section class="paper-stats" id="metrics">
|
<section class="paper-stats" id="metrics">
|
||||||
<div class="paper-stats-inner">
|
<div class="paper-stats-inner">
|
||||||
<div>
|
<div class="paper-stats-meta">
|
||||||
<div class="paper-stat-value">{{stat.nbRepositories | number}}</div>
|
<div class="paper-stats-meta-left">LIVE · LAST 60 DAYS</div>
|
||||||
<div class="paper-stat-label">repositories anonymized</div>
|
<div class="paper-stats-meta-right">
|
||||||
|
updated daily ·
|
||||||
|
<span class="paper-stats-dot"></span> in sync
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="paper-stats-grid">
|
||||||
<div class="paper-stat-value">{{stat.nbUsers | number}}</div>
|
<div class="paper-stat-card" ng-repeat="card in cards track by card.key">
|
||||||
<div class="paper-stat-label">researchers</div>
|
<div class="paper-stat-value">{{card.total | bigNum}}</div>
|
||||||
</div>
|
<div class="paper-stat-label">{{card.label}}</div>
|
||||||
<div>
|
<svg class="paper-stat-bars" preserveAspectRatio="none"
|
||||||
<div class="paper-stat-value">{{stat.nbPageViews | number}}</div>
|
ng-attr-viewBox="0 0 {{history[card.key].viewW}} 36"
|
||||||
<div class="paper-stat-label">page views</div>
|
ng-if="history[card.key].bars.length > 1">
|
||||||
|
<rect ng-repeat="b in history[card.key].bars track by $index"
|
||||||
|
ng-attr-x="{{b.x}}" ng-attr-y="{{b.y}}"
|
||||||
|
ng-attr-width="{{b.w}}" ng-attr-height="{{b.h}}"
|
||||||
|
ng-class="{'is-latest': $last}"/>
|
||||||
|
</svg>
|
||||||
|
<div class="paper-stat-delta" ng-if="history[card.key].bars.length > 1">
|
||||||
|
<span class="paper-stat-today">+{{history[card.key].deltaToday | number}} today</span>
|
||||||
|
<span class="paper-stat-sep">·</span>
|
||||||
|
<span class="paper-stat-pct"
|
||||||
|
ng-class="{'is-up': history[card.key].isUp, 'is-down': !history[card.key].isUp}">
|
||||||
|
<span class="paper-stat-arrow">{{history[card.key].isUp ? '▲' : '▼'}}</span>
|
||||||
|
{{history[card.key].pctAbs}}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+284
-57
@@ -866,12 +866,21 @@ angular
|
|||||||
}
|
}
|
||||||
|
|
||||||
$scope.entries = [];
|
$scope.entries = [];
|
||||||
$scope.filtered = [];
|
$scope.visible = [];
|
||||||
$scope.modules = [];
|
|
||||||
$scope.available = true;
|
$scope.available = true;
|
||||||
|
$scope.cap = 1000;
|
||||||
|
$scope.total = 0;
|
||||||
|
$scope.pageSize = 250;
|
||||||
|
$scope.expanded = {};
|
||||||
|
$scope.detailTab = {};
|
||||||
|
$scope.copyHint = "";
|
||||||
|
$scope.parsedFilterCount = 0;
|
||||||
|
$scope.stats = { last24h: 0, prev24h: 0, delta: 0, severity: { error: 0, warn: 0, info: 0 }, unique: { error: 0, warn: 0, info: 0 }, buckets: [], dropped: 0 };
|
||||||
$scope.query = {
|
$scope.query = {
|
||||||
search: "",
|
search: "",
|
||||||
module: "",
|
bucket: "",
|
||||||
|
sort: "recent",
|
||||||
|
group: "code",
|
||||||
autoRefresh: true,
|
autoRefresh: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -897,27 +906,34 @@ angular
|
|||||||
if (isNaN(d.getTime())) return iso;
|
if (isNaN(d.getTime())) return iso;
|
||||||
return d.toLocaleString();
|
return d.toLocaleString();
|
||||||
};
|
};
|
||||||
|
$scope.absTimeShort = (iso) => {
|
||||||
|
if (!iso) return "";
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return iso;
|
||||||
|
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false });
|
||||||
|
};
|
||||||
|
|
||||||
// Decorate each entry once with derived display fields (chips + json).
|
// Decorate each entry once with derived display fields. Pre-computing
|
||||||
// Returning a fresh array from a template-bound function each digest
|
// avoids returning new arrays from template functions each digest
|
||||||
// cycle triggers Angular's $rootScope:infdig — so we precompute on load.
|
// cycle (which trips Angular's $rootScope:infdig).
|
||||||
function statusKind(s) {
|
|
||||||
const n = parseInt(s, 10);
|
|
||||||
if (!n) return "";
|
|
||||||
if (n >= 500) return "err";
|
|
||||||
if (n >= 400) return "warn";
|
|
||||||
return "ok";
|
|
||||||
}
|
|
||||||
// snake_case identifier looking like an error key (e.g. "repo_not_found").
|
|
||||||
const errorKeyRe = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$/;
|
const errorKeyRe = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$/;
|
||||||
|
function bucketFor(detail, level) {
|
||||||
|
const s =
|
||||||
|
(detail && (detail.httpStatus || detail.status)) || null;
|
||||||
|
if (typeof s === "number") {
|
||||||
|
if (s >= 500) return "error";
|
||||||
|
if (s === 401 || s === 403 || s === 404) return "info";
|
||||||
|
if (s >= 400) return "warn";
|
||||||
|
}
|
||||||
|
if (level === "error") return "error";
|
||||||
|
if (level === "warn") return "warn";
|
||||||
|
return "info";
|
||||||
|
}
|
||||||
function decorate(e) {
|
function decorate(e) {
|
||||||
const chips = [];
|
|
||||||
const detail = (e.raw || []).find(
|
const detail = (e.raw || []).find(
|
||||||
(a) => a && typeof a === "object" && !Array.isArray(a)
|
(a) => a && typeof a === "object" && !Array.isArray(a)
|
||||||
);
|
);
|
||||||
if (detail) {
|
if (detail) {
|
||||||
// Prefer the structured error key (e.g. "pull_request_not_found")
|
|
||||||
// over the generic logger message ("anonymous error", "http error").
|
|
||||||
if (detail.message && errorKeyRe.test(detail.message)) {
|
if (detail.message && errorKeyRe.test(detail.message)) {
|
||||||
e.displayMessage = detail.message;
|
e.displayMessage = detail.message;
|
||||||
e.displayContext = e.message;
|
e.displayContext = e.message;
|
||||||
@@ -927,64 +943,273 @@ angular
|
|||||||
} else {
|
} else {
|
||||||
e.displayMessage = e.message;
|
e.displayMessage = e.message;
|
||||||
}
|
}
|
||||||
if (detail.httpStatus) chips.push({ label: "status", value: detail.httpStatus, kind: statusKind(detail.httpStatus) });
|
e._status = detail.httpStatus || detail.status || null;
|
||||||
else if (detail.status) chips.push({ label: "status", value: detail.status, kind: statusKind(detail.status) });
|
e._url = detail.url || null;
|
||||||
if (detail.method) chips.push({ label: "method", value: detail.method });
|
e._method = detail.method || null;
|
||||||
if (detail.url) chips.push({ label: "url", value: detail.url, mono: true });
|
e._repoId = detail.repoId || detail.detail || null;
|
||||||
if (detail.repoId) chips.push({ label: "repo", value: detail.repoId, mono: true });
|
e._detail = detail.detail && detail.detail !== e._repoId ? detail.detail : null;
|
||||||
if (detail.code && detail.code !== detail.message && detail.code !== e.displayMessage) {
|
|
||||||
chips.push({ label: "code", value: detail.code });
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
e.displayMessage = e.message;
|
e.displayMessage = e.message;
|
||||||
|
e._status = null;
|
||||||
|
e._url = null;
|
||||||
}
|
}
|
||||||
const tail = (e.raw || []).slice(1);
|
e._bucket = bucketFor(detail, e.level);
|
||||||
const detailJson = !tail.length
|
e._detailJson = renderDisplayPayload(e, detail);
|
||||||
? ""
|
|
||||||
: tail.length === 1
|
|
||||||
? JSON.stringify(tail[0], null, 2)
|
|
||||||
: JSON.stringify(tail, null, 2);
|
|
||||||
e._chips = chips;
|
|
||||||
e._detailJson = detailJson;
|
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyFilter() {
|
// Build a curated, column-aligned JSON payload for the Raw tab. Mirrors
|
||||||
const q = ($scope.query.search || "").toLowerCase();
|
// the reference admin design: name / code / kind / httpStatus / module /
|
||||||
const mod = $scope.query.module || "";
|
// detail / url / ts on aligned colons. We can't just JSON.stringify the
|
||||||
$scope.filtered = $scope.entries.filter((e) => {
|
// raw entry because it includes the human "anonymous error" wrapper
|
||||||
if (mod && e.module !== mod) return false;
|
// arg and the keys aren't column-aligned.
|
||||||
if (!q) return true;
|
function renderDisplayPayload(entry, detail) {
|
||||||
const hay = (
|
const fields = [];
|
||||||
(e.displayMessage || e.message || "") +
|
const push = (k, v) => {
|
||||||
" " +
|
if (v === undefined || v === null || v === "") return;
|
||||||
e.module +
|
fields.push([k, v]);
|
||||||
" " +
|
};
|
||||||
JSON.stringify(e.raw || [])
|
push("name", detail && detail.name);
|
||||||
).toLowerCase();
|
push("code", entry.displayMessage || (detail && detail.message));
|
||||||
return hay.indexOf(q) > -1;
|
// "kind" is a friendly grouping; only emit if we know the bucket.
|
||||||
|
if (entry._bucket) push("kind", entry._bucket);
|
||||||
|
push("httpStatus", detail && detail.httpStatus);
|
||||||
|
if (detail && detail.status && !(detail.httpStatus)) push("status", detail.status);
|
||||||
|
push("module", entry.module);
|
||||||
|
push("detail", detail && detail.detail);
|
||||||
|
push("url", entry._url);
|
||||||
|
push("ts", entry.ts);
|
||||||
|
if (!fields.length) return JSON.stringify(entry, null, 2);
|
||||||
|
const keyW = fields.reduce((w, f) => Math.max(w, f[0].length), 0);
|
||||||
|
const lines = ["{"];
|
||||||
|
fields.forEach(([k, v], i) => {
|
||||||
|
const key = `"${k}":`.padEnd(keyW + 3, " ");
|
||||||
|
const val = typeof v === "number" || typeof v === "boolean"
|
||||||
|
? String(v)
|
||||||
|
: JSON.stringify(v);
|
||||||
|
const comma = i < fields.length - 1 ? "," : "";
|
||||||
|
lines.push(` ${key} ${val}${comma}`);
|
||||||
});
|
});
|
||||||
|
lines.push("}");
|
||||||
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function load() {
|
// Lightweight filter parser. Pulls `key:value` and `status:>=400` style
|
||||||
$http.get("/api/admin/errors").then(
|
// tokens out of the search box; everything else falls back to a free
|
||||||
|
// text contains-match against the rendered fields.
|
||||||
|
function parseFilter(input) {
|
||||||
|
const filters = [];
|
||||||
|
let free = "";
|
||||||
|
const re = /(\w+):(>=|<=|!=|>|<|=)?([^\s]+)/g;
|
||||||
|
let lastEnd = 0;
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(input))) {
|
||||||
|
free += input.slice(lastEnd, m.index);
|
||||||
|
lastEnd = re.lastIndex;
|
||||||
|
filters.push({ key: m[1], op: m[2] || "=", val: m[3] });
|
||||||
|
}
|
||||||
|
free += input.slice(lastEnd);
|
||||||
|
return { filters, free: free.trim().toLowerCase() };
|
||||||
|
}
|
||||||
|
function matchFilter(row, parsed) {
|
||||||
|
for (const f of parsed.filters) {
|
||||||
|
const cmp = (a, b, op) => {
|
||||||
|
const an = parseFloat(a);
|
||||||
|
const bn = parseFloat(b);
|
||||||
|
if (op === "=") return String(a) === String(b);
|
||||||
|
if (op === "!=") return String(a) !== String(b);
|
||||||
|
if (op === ">=") return an >= bn;
|
||||||
|
if (op === "<=") return an <= bn;
|
||||||
|
if (op === ">") return an > bn;
|
||||||
|
if (op === "<") return an < bn;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
let v;
|
||||||
|
if (f.key === "code") v = row.displayMessage;
|
||||||
|
else if (f.key === "module") v = row.module;
|
||||||
|
else if (f.key === "status") v = row._status;
|
||||||
|
else if (f.key === "url") v = row._url;
|
||||||
|
else if (f.key === "repo") v = row._repoId;
|
||||||
|
else if (f.key === "level") v = row.level;
|
||||||
|
else continue;
|
||||||
|
if (v == null) return false;
|
||||||
|
if (!cmp(v, f.val, f.op)) return false;
|
||||||
|
}
|
||||||
|
if (parsed.free) {
|
||||||
|
const hay = (
|
||||||
|
(row.displayMessage || "") + " " +
|
||||||
|
(row.module || "") + " " +
|
||||||
|
(row._url || "") + " " +
|
||||||
|
JSON.stringify(row.raw || [])
|
||||||
|
).toLowerCase();
|
||||||
|
if (hay.indexOf(parsed.free) === -1) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recompute() {
|
||||||
|
const parsed = parseFilter($scope.query.search || "");
|
||||||
|
$scope.parsedFilterCount = parsed.filters.length;
|
||||||
|
const bucket = $scope.query.bucket;
|
||||||
|
let rows = $scope.entries.filter((e) => {
|
||||||
|
if (bucket && e._bucket !== bucket) return false;
|
||||||
|
return matchFilter(e, parsed);
|
||||||
|
});
|
||||||
|
|
||||||
|
const group = $scope.query.group;
|
||||||
|
if (group) {
|
||||||
|
const keyOf = (r) =>
|
||||||
|
group === "module" ? r.module : (r.displayMessage || r.message || "_");
|
||||||
|
const map = new Map();
|
||||||
|
for (const r of rows) {
|
||||||
|
const k = keyOf(r);
|
||||||
|
if (!map.has(k)) {
|
||||||
|
const seed = Object.assign({}, r);
|
||||||
|
seed._key = `${group}:${k}`;
|
||||||
|
seed._related = [r];
|
||||||
|
seed._firstSeen = r.ts;
|
||||||
|
seed._lastHourCount = 0;
|
||||||
|
seed.count = 1;
|
||||||
|
map.set(k, seed);
|
||||||
|
} else {
|
||||||
|
const g = map.get(k);
|
||||||
|
g.count++;
|
||||||
|
g._related.push(r);
|
||||||
|
if (new Date(r.ts) > new Date(g.ts)) {
|
||||||
|
g.ts = r.ts;
|
||||||
|
g._url = r._url;
|
||||||
|
g._status = r._status;
|
||||||
|
}
|
||||||
|
if (new Date(r.ts) < new Date(g._firstSeen)) g._firstSeen = r.ts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// count "this hour"
|
||||||
|
const cutoffH = Date.now() - 3600 * 1000;
|
||||||
|
for (const g of map.values()) {
|
||||||
|
g._lastHourCount = g._related.filter((r) => new Date(r.ts).getTime() >= cutoffH).length;
|
||||||
|
}
|
||||||
|
rows = Array.from(map.values());
|
||||||
|
} else {
|
||||||
|
rows = rows.map((r, i) => {
|
||||||
|
r._key = "row:" + i + ":" + r.ts;
|
||||||
|
r._related = [r];
|
||||||
|
r._firstSeen = r.ts;
|
||||||
|
r._lastHourCount = 0;
|
||||||
|
r.count = 1;
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($scope.query.sort === "count") {
|
||||||
|
rows.sort((a, b) => b.count - a.count || new Date(b.ts) - new Date(a.ts));
|
||||||
|
} else {
|
||||||
|
rows.sort((a, b) => new Date(b.ts) - new Date(a.ts));
|
||||||
|
}
|
||||||
|
$scope.visible = rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadEntries(append) {
|
||||||
|
const offset = append ? $scope.entries.length : 0;
|
||||||
|
$http
|
||||||
|
.get("/api/admin/errors", { params: { offset, limit: $scope.pageSize } })
|
||||||
|
.then(
|
||||||
|
(res) => {
|
||||||
|
const next = (res.data.entries || []).map(decorate);
|
||||||
|
$scope.entries = append ? $scope.entries.concat(next) : next;
|
||||||
|
$scope.available = !!res.data.available;
|
||||||
|
$scope.cap = res.data.max || $scope.cap;
|
||||||
|
$scope.total = res.data.total || $scope.entries.length;
|
||||||
|
recompute();
|
||||||
|
},
|
||||||
|
(err) => console.error(err)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$scope.loadMore = () => loadEntries(true);
|
||||||
|
$scope.canLoadMore = () => $scope.entries.length < $scope.total;
|
||||||
|
function loadStats() {
|
||||||
|
$http.get("/api/admin/errors/stats").then(
|
||||||
(res) => {
|
(res) => {
|
||||||
$scope.entries = (res.data.entries || []).map(decorate);
|
const s = res.data || {};
|
||||||
$scope.available = !!res.data.available;
|
const delta = s.prev24h ? Math.round(((s.last24h - s.prev24h) / s.prev24h) * 100) : 0;
|
||||||
const set = new Set();
|
$scope.stats = {
|
||||||
$scope.entries.forEach((e) => e.module && set.add(e.module));
|
last24h: s.last24h || 0,
|
||||||
$scope.modules = Array.from(set).sort();
|
prev24h: s.prev24h || 0,
|
||||||
applyFilter();
|
delta,
|
||||||
|
severity: s.severity || { error: 0, warn: 0, info: 0 },
|
||||||
|
unique: s.unique || { error: 0, warn: 0, info: 0 },
|
||||||
|
buckets: s.buckets || [],
|
||||||
|
dropped: s.dropped || 0,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
(err) => console.error(err)
|
(err) => console.error(err)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
function load() {
|
||||||
|
loadEntries();
|
||||||
|
loadStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
// For the volume chart: scale tallest bucket-total to a fixed pixel max.
|
||||||
|
$scope.barPx = (b, key) => {
|
||||||
|
const all = $scope.stats.buckets || [];
|
||||||
|
let max = 0;
|
||||||
|
for (const x of all) max = Math.max(max, (x.error || 0) + (x.warn || 0) + (x.info || 0));
|
||||||
|
if (!max) return 0;
|
||||||
|
const total = (b.error || 0) + (b.warn || 0) + (b.info || 0);
|
||||||
|
if (!total) return 0;
|
||||||
|
const targetTotal = Math.round((total / max) * 60); // 60px max
|
||||||
|
const part = b[key] || 0;
|
||||||
|
return Math.round((part / total) * targetTotal);
|
||||||
|
};
|
||||||
|
$scope.bucketTitle = (b) => {
|
||||||
|
const t = new Date(b.hour);
|
||||||
|
return `${t.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} · ${b.error || 0} err · ${b.warn || 0} warn · ${b.info || 0} info`;
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.toggle = (row) => {
|
||||||
|
$scope.expanded[row._key] = !$scope.expanded[row._key];
|
||||||
|
};
|
||||||
|
$scope.setBucket = (b) => {
|
||||||
|
$scope.query.bucket = b;
|
||||||
|
};
|
||||||
|
|
||||||
$scope.refreshNow = load;
|
$scope.refreshNow = load;
|
||||||
$scope.clearAll = () => {
|
$scope.clearAll = () => {
|
||||||
if (!confirm("Clear all captured errors?")) return;
|
if (!confirm("Clear all captured errors?")) return;
|
||||||
$http.delete("/api/admin/errors").then(load, (err) => console.error(err));
|
$http.delete("/api/admin/errors").then(load, (err) => console.error(err));
|
||||||
};
|
};
|
||||||
|
$scope.exportCsv = () => {
|
||||||
|
const cols = ["ts", "level", "module", "displayMessage", "_status", "_url", "_repoId"];
|
||||||
|
const lines = [cols.join(",")];
|
||||||
|
for (const r of $scope.visible) {
|
||||||
|
lines.push(cols.map((c) => {
|
||||||
|
const v = r[c] == null ? "" : String(r[c]);
|
||||||
|
return /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
|
||||||
|
}).join(","));
|
||||||
|
}
|
||||||
|
const blob = new Blob([lines.join("\n")], { type: "text/csv;charset=utf-8" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `errors-${new Date().toISOString().slice(0, 19)}.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
function flashCopy(label) {
|
||||||
|
$scope.copyHint = `${label} copied`;
|
||||||
|
setTimeout(() => { $scope.copyHint = ""; $scope.$apply(); }, 1500);
|
||||||
|
}
|
||||||
|
$scope.copyJson = (row) => {
|
||||||
|
navigator.clipboard.writeText(row._detailJson).then(() => flashCopy("JSON"));
|
||||||
|
};
|
||||||
|
$scope.copyCurl = (row) => {
|
||||||
|
if (!row._url) return;
|
||||||
|
const method = row._method || "GET";
|
||||||
|
const cmd = `curl -X ${method} '${window.location.origin}${row._url}'`;
|
||||||
|
navigator.clipboard.writeText(cmd).then(() => flashCopy("curl"));
|
||||||
|
};
|
||||||
|
|
||||||
load();
|
load();
|
||||||
const stop = $interval(() => {
|
const stop = $interval(() => {
|
||||||
@@ -992,7 +1217,9 @@ angular
|
|||||||
}, 5000);
|
}, 5000);
|
||||||
$scope.$on("$destroy", () => $interval.cancel(stop));
|
$scope.$on("$destroy", () => $interval.cancel(stop));
|
||||||
|
|
||||||
$scope.$watch("query.search", applyFilter);
|
$scope.$watch("query.search", recompute);
|
||||||
$scope.$watch("query.module", applyFilter);
|
$scope.$watch("query.bucket", recompute);
|
||||||
|
$scope.$watch("query.sort", recompute);
|
||||||
|
$scope.$watch("query.group", recompute);
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -156,6 +156,17 @@ angular
|
|||||||
.filter("humanFileSize", function () {
|
.filter("humanFileSize", function () {
|
||||||
return humanFileSize;
|
return humanFileSize;
|
||||||
})
|
})
|
||||||
|
.filter("bigNum", function () {
|
||||||
|
return function bigNum(v) {
|
||||||
|
const n = Number(v) || 0;
|
||||||
|
const abs = Math.abs(n);
|
||||||
|
if (abs < 1000) return String(n);
|
||||||
|
if (abs < 10000) return (n / 1000).toFixed(1).replace(/\.0$/, "") + "k";
|
||||||
|
if (abs < 1000000) return Math.round(n / 1000) + "k";
|
||||||
|
if (abs < 10000000) return (n / 1000000).toFixed(1).replace(/\.0$/, "") + "M";
|
||||||
|
return Math.round(n / 1000000) + "M";
|
||||||
|
};
|
||||||
|
})
|
||||||
.filter("humanTime", function () {
|
.filter("humanTime", function () {
|
||||||
return function humanTime(seconds) {
|
return function humanTime(seconds) {
|
||||||
if (!seconds) {
|
if (!seconds) {
|
||||||
@@ -942,12 +953,86 @@ angular
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$scope.cards = [
|
||||||
|
{ key: "repositories", total: 0, label: "repositories anonymized" },
|
||||||
|
{ key: "users", total: 0, label: "researchers" },
|
||||||
|
{ key: "pageViews", total: 0, label: "page views" },
|
||||||
|
{ key: "pullRequests", total: 0, label: "pull requests" },
|
||||||
|
];
|
||||||
function getStat() {
|
function getStat() {
|
||||||
$http.get("/api/stat/").then((res) => {
|
$http.get("/api/stat/").then((res) => {
|
||||||
$scope.stat = res.data;
|
$scope.stat = res.data;
|
||||||
|
$scope.cards[0].total = res.data.nbRepositories;
|
||||||
|
$scope.cards[1].total = res.data.nbUsers;
|
||||||
|
$scope.cards[2].total = res.data.nbPageViews;
|
||||||
|
$scope.cards[3].total = res.data.nbPullRequests;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
getStat();
|
getStat();
|
||||||
|
|
||||||
|
function buildSeriesView(series) {
|
||||||
|
const view = {
|
||||||
|
series: series,
|
||||||
|
bars: [],
|
||||||
|
viewW: 100,
|
||||||
|
deltaToday: 0,
|
||||||
|
pctChange: 0,
|
||||||
|
pctAbs: 0,
|
||||||
|
isUp: true,
|
||||||
|
};
|
||||||
|
if (!series || series.length < 2) return view;
|
||||||
|
// Bars represent the *daily increment* (today - yesterday), not the
|
||||||
|
// cumulative total. The big number above the chart shows the total.
|
||||||
|
const deltas = new Array(series.length - 1);
|
||||||
|
for (let i = 1; i < series.length; i++) {
|
||||||
|
deltas[i - 1] = series[i] - series[i - 1];
|
||||||
|
}
|
||||||
|
const n = deltas.length;
|
||||||
|
const max = Math.max.apply(null, deltas);
|
||||||
|
const min = Math.min.apply(null, deltas);
|
||||||
|
// Anchor scale to zero so visually small days look small even when all
|
||||||
|
// deltas are positive; only fall back to min when there are negatives.
|
||||||
|
const base = Math.min(0, min);
|
||||||
|
const range = max - base || 1;
|
||||||
|
view.viewW = n * 2;
|
||||||
|
view.bars = new Array(n);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const norm = (deltas[i] - base) / range;
|
||||||
|
const h = Math.max(1.5, norm * 34);
|
||||||
|
view.bars[i] = {
|
||||||
|
x: (i * 2 + 0.25).toFixed(2),
|
||||||
|
y: (36 - h).toFixed(2),
|
||||||
|
w: "1.5",
|
||||||
|
h: h.toFixed(2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
view.deltaToday = deltas[n - 1];
|
||||||
|
if (n >= 2) {
|
||||||
|
const prior = deltas[n - 2];
|
||||||
|
if (prior) {
|
||||||
|
view.pctChange = ((view.deltaToday - prior) / prior) * 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
view.pctAbs = Math.round(Math.abs(view.pctChange));
|
||||||
|
view.isUp = view.pctChange >= 0;
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scope.history = {
|
||||||
|
repositories: buildSeriesView([]),
|
||||||
|
users: buildSeriesView([]),
|
||||||
|
pageViews: buildSeriesView([]),
|
||||||
|
pullRequests: buildSeriesView([]),
|
||||||
|
};
|
||||||
|
$http.get("/api/stat/history?days=60").then((res) => {
|
||||||
|
const rows = res.data || [];
|
||||||
|
$scope.history = {
|
||||||
|
repositories: buildSeriesView(rows.map((r) => r.nbRepositories || 0)),
|
||||||
|
users: buildSeriesView(rows.map((r) => r.nbUsers || 0)),
|
||||||
|
pageViews: buildSeriesView(rows.map((r) => r.nbPageViews || 0)),
|
||||||
|
pullRequests: buildSeriesView(rows.map((r) => r.nbPullRequests || 0)),
|
||||||
|
};
|
||||||
|
});
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
.controller("unifiedDashboardController", [
|
.controller("unifiedDashboardController", [
|
||||||
|
|||||||
Vendored
+116
-116
File diff suppressed because one or more lines are too long
@@ -27,10 +27,9 @@ export default class AnonymousError extends CustomError {
|
|||||||
this.cause = opt?.cause;
|
this.cause = opt?.cause;
|
||||||
}
|
}
|
||||||
|
|
||||||
detail(): string | undefined {
|
url(): string | undefined {
|
||||||
if (this.value == null) return undefined;
|
if (this.value == null) return undefined;
|
||||||
try {
|
try {
|
||||||
if (this.value instanceof Repository) return this.value.repoId;
|
|
||||||
if (this.value instanceof AnonymizedFile) {
|
if (this.value instanceof AnonymizedFile) {
|
||||||
const repoId = this.value.repository?.repoId;
|
const repoId = this.value.repository?.repoId;
|
||||||
// anonymizedPath getter can throw if the file isn't initialized;
|
// anonymizedPath getter can throw if the file isn't initialized;
|
||||||
@@ -43,6 +42,17 @@ export default class AnonymousError extends CustomError {
|
|||||||
}
|
}
|
||||||
return repoId ? `/r/${repoId}/${p ?? ""}` : p;
|
return repoId ? `/r/${repoId}/${p ?? ""}` : p;
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
detail(): string | undefined {
|
||||||
|
if (this.value == null) return undefined;
|
||||||
|
try {
|
||||||
|
if (this.value instanceof Repository) return this.value.repoId;
|
||||||
|
if (this.value instanceof AnonymizedFile) return undefined;
|
||||||
if (this.value instanceof GitHubRepository) return this.value.fullName;
|
if (this.value instanceof GitHubRepository) return this.value.fullName;
|
||||||
if (this.value instanceof User) return this.value.username;
|
if (this.value instanceof User) return this.value.username;
|
||||||
if (this.value instanceof GitHubBase) {
|
if (this.value instanceof GitHubBase) {
|
||||||
@@ -57,9 +67,9 @@ export default class AnonymousError extends CustomError {
|
|||||||
|
|
||||||
toString(): string {
|
toString(): string {
|
||||||
let out = this.message;
|
let out = this.message;
|
||||||
const detail = this.detail();
|
const info = this.url() ?? this.detail();
|
||||||
if (detail) {
|
if (info) {
|
||||||
out += `: ${detail}`;
|
out += `: ${info}`;
|
||||||
}
|
}
|
||||||
if (this.cause) {
|
if (this.cause) {
|
||||||
out += `\n\tCause by ${this.cause}\n${this.cause.stack}`;
|
out += `\n\tCause by ${this.cause}\n${this.cause.stack}`;
|
||||||
|
|||||||
@@ -465,9 +465,13 @@ export default class Repository {
|
|||||||
async removeCache() {
|
async removeCache() {
|
||||||
await storage.rm(this.repoId);
|
await storage.rm(this.repoId);
|
||||||
this.model.isReseted = true;
|
this.model.isReseted = true;
|
||||||
|
this.model.size = { storage: 0, file: 0 };
|
||||||
if (isConnected) {
|
if (isConnected) {
|
||||||
try {
|
try {
|
||||||
await this.model.save();
|
await AnonymizedRepositoryModel.updateOne(
|
||||||
|
{ _id: this._model._id },
|
||||||
|
{ $set: { isReseted: true, size: this._model.size } }
|
||||||
|
).exec();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("removeCache save failed", serializeError(error));
|
logger.error("removeCache save failed", serializeError(error));
|
||||||
}
|
}
|
||||||
|
|||||||
+134
-4
@@ -3,6 +3,15 @@ import config from "../config";
|
|||||||
|
|
||||||
export const ERROR_LOG_KEY = "admin:errors";
|
export const ERROR_LOG_KEY = "admin:errors";
|
||||||
export const ERROR_LOG_MAX = 1000;
|
export const ERROR_LOG_MAX = 1000;
|
||||||
|
export const ERROR_LOG_HOURLY_PREFIX = "admin:errors:hourly:";
|
||||||
|
export const ERROR_LOG_DROPPED_KEY = "admin:errors:dropped";
|
||||||
|
// 48h retention on the hourly counters: stats endpoint reads "last 24h" and
|
||||||
|
// "previous 24h" buckets — anything older has nothing to compare against.
|
||||||
|
export const ERROR_LOG_HOURLY_TTL = 48 * 60 * 60;
|
||||||
|
// Hard cap on the JSON payload stored per entry. The recent detail() change
|
||||||
|
// (commit 6f418d6) can produce kilobyte payloads; without a cap the read
|
||||||
|
// path pulls multiple MB on every poll.
|
||||||
|
const MAX_PAYLOAD_BYTES = 4096;
|
||||||
|
|
||||||
export type Logger = {
|
export type Logger = {
|
||||||
debug: (...args: unknown[]) => void;
|
debug: (...args: unknown[]) => void;
|
||||||
@@ -77,21 +86,132 @@ function getRedis(): RedisClientType | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In-process counter for entries that couldn't be persisted (no Redis client,
|
||||||
|
// disconnected, or Redis-side rejection). Mirrors `admin:errors:dropped` once
|
||||||
|
// Redis is back. Read by /admin/errors/stats so the admin page surfaces
|
||||||
|
// "you're losing logs" instead of silently rendering an empty table.
|
||||||
|
let droppedInProcess = 0;
|
||||||
|
export function getInProcessDropped(): number {
|
||||||
|
return droppedInProcess;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimStack(s: unknown): unknown {
|
||||||
|
if (typeof s === "string" && s.length > 800) {
|
||||||
|
return s.slice(0, 800) + "…[truncated]";
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
function trimRawArg(a: unknown): unknown {
|
||||||
|
if (!a || typeof a !== "object") return a;
|
||||||
|
const o = a as Record<string, unknown>;
|
||||||
|
if (typeof o.stack === "string") {
|
||||||
|
return { ...o, stack: trimStack(o.stack) };
|
||||||
|
}
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampPayload(entry: {
|
||||||
|
ts: string;
|
||||||
|
level: "warn" | "error";
|
||||||
|
module: string;
|
||||||
|
message: string;
|
||||||
|
raw: unknown[];
|
||||||
|
}): string {
|
||||||
|
// Cap raw to first 3 args and trim long stacks before stringifying.
|
||||||
|
if (entry.raw.length > 3) entry.raw = entry.raw.slice(0, 3);
|
||||||
|
entry.raw = entry.raw.map(trimRawArg);
|
||||||
|
let s = JSON.stringify(entry);
|
||||||
|
if (s.length <= MAX_PAYLOAD_BYTES) return s;
|
||||||
|
// Step 1: keep just the first arg (typically the human message + the
|
||||||
|
// structured detail object).
|
||||||
|
entry.raw = entry.raw.slice(0, 1);
|
||||||
|
s = JSON.stringify(entry);
|
||||||
|
if (s.length <= MAX_PAYLOAD_BYTES) return s;
|
||||||
|
// Step 2: replace the payload with a placeholder so the entry still shows
|
||||||
|
// up in the list but doesn't blow the cap.
|
||||||
|
entry.raw = [{ truncated: true, originalBytes: s.length }];
|
||||||
|
return JSON.stringify(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map a logged entry to the bucket the admin UI uses. Mirrors the inline
|
||||||
|
// logic in /errors/stats so server and client agree on what "5xx / 4xx /
|
||||||
|
// info" means.
|
||||||
|
function bucketFor(
|
||||||
|
detail: Record<string, unknown> | undefined,
|
||||||
|
level: "warn" | "error"
|
||||||
|
): "error" | "warn" | "info" {
|
||||||
|
const s =
|
||||||
|
detail && typeof detail.httpStatus === "number"
|
||||||
|
? (detail.httpStatus as number)
|
||||||
|
: detail && typeof detail.status === "number"
|
||||||
|
? (detail.status as number)
|
||||||
|
: null;
|
||||||
|
if (typeof s === "number") {
|
||||||
|
if (s >= 500) return "error";
|
||||||
|
if (s === 401 || s === 403 || s === 404) return "info";
|
||||||
|
if (s >= 400) return "warn";
|
||||||
|
}
|
||||||
|
return level === "error" ? "error" : "warn";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hourKey(ts: string): string {
|
||||||
|
// YYYYMMDDHH in UTC — sortable, lexicographically aligns with time.
|
||||||
|
const d = new Date(ts);
|
||||||
|
const y = d.getUTCFullYear();
|
||||||
|
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(d.getUTCDate()).padStart(2, "0");
|
||||||
|
const h = String(d.getUTCHours()).padStart(2, "0");
|
||||||
|
return `${ERROR_LOG_HOURLY_PREFIX}${y}${m}${day}${h}`;
|
||||||
|
}
|
||||||
|
|
||||||
function persistError(entry: {
|
function persistError(entry: {
|
||||||
ts: string;
|
ts: string;
|
||||||
|
level: "warn" | "error";
|
||||||
module: string;
|
module: string;
|
||||||
message: string;
|
message: string;
|
||||||
raw: unknown[];
|
raw: unknown[];
|
||||||
}) {
|
}) {
|
||||||
const client = getRedis();
|
const client = getRedis();
|
||||||
if (!client || !client.isOpen) return;
|
if (!client || !client.isOpen) {
|
||||||
const payload = JSON.stringify(entry);
|
droppedInProcess++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = clampPayload(entry);
|
||||||
|
// Pre-compute the structured fields the stats endpoint needs so the read
|
||||||
|
// path doesn't have to parse the JSON list at all.
|
||||||
|
const detail = entry.raw.find(
|
||||||
|
(a) => a && typeof a === "object" && !Array.isArray(a)
|
||||||
|
) as Record<string, unknown> | undefined;
|
||||||
|
const bucket = bucketFor(detail, entry.level);
|
||||||
|
const code =
|
||||||
|
(detail && typeof detail.message === "string"
|
||||||
|
? (detail.message as string)
|
||||||
|
: "") ||
|
||||||
|
(detail && typeof detail.code === "string"
|
||||||
|
? (detail.code as string)
|
||||||
|
: "") ||
|
||||||
|
"_";
|
||||||
|
const hKey = hourKey(entry.ts);
|
||||||
client
|
client
|
||||||
.multi()
|
.multi()
|
||||||
.lPush(ERROR_LOG_KEY, payload)
|
.lPush(ERROR_LOG_KEY, payload)
|
||||||
.lTrim(ERROR_LOG_KEY, 0, ERROR_LOG_MAX - 1)
|
.lTrim(ERROR_LOG_KEY, 0, ERROR_LOG_MAX - 1)
|
||||||
|
.hIncrBy(hKey, "total", 1)
|
||||||
|
.hIncrBy(hKey, `bucket:${bucket}`, 1)
|
||||||
|
.hIncrBy(hKey, `level:${entry.level}`, 1)
|
||||||
|
.hIncrBy(hKey, `module:${entry.module}`, 1)
|
||||||
|
.hIncrBy(hKey, `cb:${bucket}:${code}`, 1)
|
||||||
|
.expire(hKey, ERROR_LOG_HOURLY_TTL)
|
||||||
.exec()
|
.exec()
|
||||||
.catch(() => undefined);
|
.catch(() => {
|
||||||
|
droppedInProcess++;
|
||||||
|
// Best-effort flush of the in-process counter to redis so the admin UI
|
||||||
|
// sees the same number across processes.
|
||||||
|
const c = getRedis();
|
||||||
|
if (c && c.isOpen) {
|
||||||
|
c.incr(ERROR_LOG_DROPPED_KEY).catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function emit(level: Level, module: string, args: unknown[]) {
|
function emit(level: Level, module: string, args: unknown[]) {
|
||||||
@@ -108,9 +228,10 @@ function emit(level: Level, module: string, args: unknown[]) {
|
|||||||
? console.debug
|
? console.debug
|
||||||
: console.log;
|
: console.log;
|
||||||
sink(line);
|
sink(line);
|
||||||
if (level === "error") {
|
if (level === "error" || level === "warn") {
|
||||||
persistError({
|
persistError({
|
||||||
ts,
|
ts,
|
||||||
|
level,
|
||||||
module,
|
module,
|
||||||
message: typeof args[0] === "string" ? args[0] : "",
|
message: typeof args[0] === "string" ? args[0] : "",
|
||||||
raw: args.map((a) => {
|
raw: args.map((a) => {
|
||||||
@@ -141,6 +262,7 @@ type ErrorLike = {
|
|||||||
request?: { url?: string; method?: string };
|
request?: { url?: string; method?: string };
|
||||||
response?: { url?: string; status?: number };
|
response?: { url?: string; status?: number };
|
||||||
detail?: () => string | undefined;
|
detail?: () => string | undefined;
|
||||||
|
url?: string | (() => string | undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
export function serializeError(err: unknown): Record<string, unknown> {
|
export function serializeError(err: unknown): Record<string, unknown> {
|
||||||
@@ -162,6 +284,14 @@ export function serializeError(err: unknown): Record<string, unknown> {
|
|||||||
// AnonymousError carries an httpStatus and an inner cause.
|
// AnonymousError carries an httpStatus and an inner cause.
|
||||||
if (typeof e.httpStatus === "number") out.httpStatus = e.httpStatus;
|
if (typeof e.httpStatus === "number") out.httpStatus = e.httpStatus;
|
||||||
if (e.code !== undefined && e.code !== e.message) out.code = e.code;
|
if (e.code !== undefined && e.code !== e.message) out.code = e.code;
|
||||||
|
if (typeof e.url === "function") {
|
||||||
|
try {
|
||||||
|
const u = e.url();
|
||||||
|
if (u) out.url = u;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
if (typeof e.detail === "function") {
|
if (typeof e.detail === "function") {
|
||||||
try {
|
try {
|
||||||
const d = e.detail();
|
const d = e.detail();
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { model } from "mongoose";
|
||||||
|
|
||||||
|
import { IDailyStatsDocument, IDailyStatsModel } from "./dailyStats.types";
|
||||||
|
import DailyStatsSchema from "./dailyStats.schema";
|
||||||
|
|
||||||
|
const DailyStatsModel = model<IDailyStatsDocument>(
|
||||||
|
"DailyStats",
|
||||||
|
DailyStatsSchema
|
||||||
|
) as IDailyStatsModel;
|
||||||
|
|
||||||
|
export default DailyStatsModel;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Schema } from "mongoose";
|
||||||
|
|
||||||
|
const DailyStatsSchema = new Schema({
|
||||||
|
date: { type: Date, unique: true, index: true },
|
||||||
|
nbRepositories: { type: Number, default: 0 },
|
||||||
|
nbUsers: { type: Number, default: 0 },
|
||||||
|
nbPageViews: { type: Number, default: 0 },
|
||||||
|
nbPullRequests: { type: Number, default: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default DailyStatsSchema;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Document, Model } from "mongoose";
|
||||||
|
|
||||||
|
export interface IDailyStats {
|
||||||
|
date: Date;
|
||||||
|
nbRepositories: number;
|
||||||
|
nbUsers: number;
|
||||||
|
nbPageViews: number;
|
||||||
|
nbPullRequests: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDailyStatsDocument extends IDailyStats, Document {}
|
||||||
|
export interface IDailyStatsModel extends Model<IDailyStatsDocument> {}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import AnonymizedRepositoryModel from "../core/model/anonymizedRepositories/anonymizedRepositories.model";
|
||||||
|
import AnonymizedPullRequestModel from "../core/model/anonymizedPullRequests/anonymizedPullRequests.model";
|
||||||
|
import DailyStatsModel from "../core/model/dailyStats/dailyStats.model";
|
||||||
|
import { createLogger, serializeError } from "../core/logger";
|
||||||
|
|
||||||
|
const logger = createLogger("dailyStats");
|
||||||
|
|
||||||
|
export interface HomeStats {
|
||||||
|
nbRepositories: number;
|
||||||
|
nbUsers: number;
|
||||||
|
nbPageViews: number;
|
||||||
|
nbPullRequests: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function computeStats(): Promise<HomeStats> {
|
||||||
|
const [nbRepositories, nbUsersAgg, nbPageViews, nbPullRequests] =
|
||||||
|
await Promise.all([
|
||||||
|
AnonymizedRepositoryModel.estimatedDocumentCount(),
|
||||||
|
AnonymizedRepositoryModel.collection
|
||||||
|
.aggregate([{ $group: { _id: "$owner" } }, { $count: "n" }])
|
||||||
|
.toArray(),
|
||||||
|
AnonymizedRepositoryModel.collection
|
||||||
|
.aggregate([{ $group: { _id: null, total: { $sum: "$pageView" } } }])
|
||||||
|
.toArray(),
|
||||||
|
AnonymizedPullRequestModel.estimatedDocumentCount(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
nbRepositories,
|
||||||
|
nbUsers: (nbUsersAgg[0] as { n?: number } | undefined)?.n || 0,
|
||||||
|
nbPageViews:
|
||||||
|
(nbPageViews[0] as { total?: number } | undefined)?.total || 0,
|
||||||
|
nbPullRequests,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function utcMidnight(d: Date = new Date()): Date {
|
||||||
|
return new Date(
|
||||||
|
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function computeAndStoreDailyStats(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const stats = await computeStats();
|
||||||
|
const date = utcMidnight();
|
||||||
|
await DailyStatsModel.updateOne(
|
||||||
|
{ date },
|
||||||
|
{ $set: { ...stats, date } },
|
||||||
|
{ upsert: true }
|
||||||
|
);
|
||||||
|
logger.info("daily stats snapshot stored", { date, ...stats });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("daily stats snapshot failed", serializeError(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureTodaySnapshot(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const date = utcMidnight();
|
||||||
|
const existing = await DailyStatsModel.findOne({ date }).lean();
|
||||||
|
if (!existing) {
|
||||||
|
await computeAndStoreDailyStats();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("ensureTodaySnapshot failed", serializeError(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
-31
@@ -14,10 +14,17 @@ import { connect } from "./database";
|
|||||||
import { initSession, router as connectionRouter } from "./routes/connection";
|
import { initSession, router as connectionRouter } from "./routes/connection";
|
||||||
import { bearerTokenAuth } from "./routes/token-auth";
|
import { bearerTokenAuth } from "./routes/token-auth";
|
||||||
import router from "./routes";
|
import router from "./routes";
|
||||||
import AnonymizedRepositoryModel from "../core/model/anonymizedRepositories/anonymizedRepositories.model";
|
import {
|
||||||
import { conferenceStatusCheck, repositoryStatusCheck } from "./schedule";
|
conferenceStatusCheck,
|
||||||
|
repositoryStatusCheck,
|
||||||
|
dailyStatsSnapshot,
|
||||||
|
} from "./schedule";
|
||||||
import { startWorker, recoverStuckPreparing } from "../queue";
|
import { startWorker, recoverStuckPreparing } from "../queue";
|
||||||
import AnonymizedPullRequestModel from "../core/model/anonymizedPullRequests/anonymizedPullRequests.model";
|
import {
|
||||||
|
computeStats,
|
||||||
|
ensureTodaySnapshot,
|
||||||
|
} from "./dailyStatsSnapshot";
|
||||||
|
import DailyStatsModel from "../core/model/dailyStats/dailyStats.model";
|
||||||
import { getUser } from "./routes/route-utils";
|
import { getUser } from "./routes/route-utils";
|
||||||
import config from "../config";
|
import config from "../config";
|
||||||
import { createLogger, serializeError } from "../core/logger";
|
import { createLogger, serializeError } from "../core/logger";
|
||||||
@@ -186,9 +193,13 @@ export default async function start() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let stat: Record<string, unknown> = {};
|
let stat: Record<string, unknown> = {};
|
||||||
|
let history: Array<Record<string, unknown>> | null = null;
|
||||||
|
let historyKey: number | null = null;
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
stat = {};
|
stat = {};
|
||||||
|
history = null;
|
||||||
|
historyKey = null;
|
||||||
}, 1000 * 60 * 60);
|
}, 1000 * 60 * 60);
|
||||||
|
|
||||||
apiRouter.get("/healthcheck", async (_, res) => {
|
apiRouter.get("/healthcheck", async (_, res) => {
|
||||||
@@ -199,37 +210,36 @@ export default async function start() {
|
|||||||
res.json(stat);
|
res.json(stat);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const [nbRepositories, nbUsersAgg, nbPageViews, nbPullRequests] =
|
stat = { ...(await computeStats()) };
|
||||||
await Promise.all([
|
|
||||||
AnonymizedRepositoryModel.estimatedDocumentCount(),
|
|
||||||
// Count distinct owners server-side instead of materializing the full
|
|
||||||
// list of ObjectIds with `.distinct("owner")` only to take its length.
|
|
||||||
AnonymizedRepositoryModel.collection
|
|
||||||
.aggregate([
|
|
||||||
{ $group: { _id: "$owner" } },
|
|
||||||
{ $count: "n" },
|
|
||||||
])
|
|
||||||
.toArray(),
|
|
||||||
AnonymizedRepositoryModel.collection
|
|
||||||
.aggregate([
|
|
||||||
{
|
|
||||||
$group: { _id: null, total: { $sum: "$pageView" } },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
.toArray(),
|
|
||||||
AnonymizedPullRequestModel.estimatedDocumentCount(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
stat = {
|
|
||||||
nbRepositories,
|
|
||||||
nbUsers: (nbUsersAgg[0] as { n?: number } | undefined)?.n || 0,
|
|
||||||
nbPageViews: nbPageViews[0]?.total || 0,
|
|
||||||
nbPullRequests,
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json(stat);
|
res.json(stat);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
apiRouter.get("/stat/history", async (req, res) => {
|
||||||
|
const days = Math.min(
|
||||||
|
Math.max(parseInt(req.query.days as string) || 30, 1),
|
||||||
|
365
|
||||||
|
);
|
||||||
|
if (history && historyKey === days) {
|
||||||
|
res.json(history);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const since = new Date();
|
||||||
|
since.setUTCDate(since.getUTCDate() - days + 1);
|
||||||
|
since.setUTCHours(0, 0, 0, 0);
|
||||||
|
const docs = await DailyStatsModel.find({ date: { $gte: since } })
|
||||||
|
.sort({ date: 1 })
|
||||||
|
.lean();
|
||||||
|
history = docs.map((d) => ({
|
||||||
|
date: d.date,
|
||||||
|
nbRepositories: d.nbRepositories,
|
||||||
|
nbUsers: d.nbUsers,
|
||||||
|
nbPageViews: d.nbPageViews,
|
||||||
|
nbPullRequests: d.nbPullRequests,
|
||||||
|
}));
|
||||||
|
historyKey = days;
|
||||||
|
res.json(history);
|
||||||
|
});
|
||||||
|
|
||||||
// web view
|
// web view
|
||||||
app.use("/w/", rate, webViewSpeedLimiter, router.webview);
|
app.use("/w/", rate, webViewSpeedLimiter, router.webview);
|
||||||
|
|
||||||
@@ -253,10 +263,14 @@ export default async function start() {
|
|||||||
// start schedules
|
// start schedules
|
||||||
conferenceStatusCheck();
|
conferenceStatusCheck();
|
||||||
repositoryStatusCheck();
|
repositoryStatusCheck();
|
||||||
|
dailyStatsSnapshot();
|
||||||
|
|
||||||
await connect();
|
await connect();
|
||||||
app.listen(config.PORT);
|
app.listen(config.PORT);
|
||||||
logger.info("server started", { port: config.PORT });
|
logger.info("server started", { port: config.PORT });
|
||||||
|
ensureTodaySnapshot().catch((err) =>
|
||||||
|
logger.error("ensureTodaySnapshot failed", { err })
|
||||||
|
);
|
||||||
recoverStuckPreparing().catch((err) =>
|
recoverStuckPreparing().catch((err) =>
|
||||||
logger.error("recoverStuckPreparing failed", { err })
|
logger.error("recoverStuckPreparing failed", { err })
|
||||||
);
|
);
|
||||||
|
|||||||
+183
-7
@@ -10,7 +10,15 @@ import { ensureAuthenticated } from "./connection";
|
|||||||
import { handleError, getUser, isOwnerOrAdmin, getRepo } from "./route-utils";
|
import { handleError, getUser, isOwnerOrAdmin, getRepo } from "./route-utils";
|
||||||
import adminTokensRouter from "./admin-tokens";
|
import adminTokensRouter from "./admin-tokens";
|
||||||
import { octokit, getToken } from "../../core/GitHubUtils";
|
import { octokit, getToken } from "../../core/GitHubUtils";
|
||||||
import { createLogger, serializeError, ERROR_LOG_KEY, ERROR_LOG_MAX } from "../../core/logger";
|
import {
|
||||||
|
createLogger,
|
||||||
|
serializeError,
|
||||||
|
ERROR_LOG_KEY,
|
||||||
|
ERROR_LOG_MAX,
|
||||||
|
ERROR_LOG_HOURLY_PREFIX,
|
||||||
|
ERROR_LOG_DROPPED_KEY,
|
||||||
|
getInProcessDropped,
|
||||||
|
} from "../../core/logger";
|
||||||
import { createClient, RedisClientType } from "redis";
|
import { createClient, RedisClientType } from "redis";
|
||||||
import config from "../../config";
|
import config from "../../config";
|
||||||
|
|
||||||
@@ -227,14 +235,32 @@ router.get("/queues", async (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Errors captured by the logger sink (last ERROR_LOG_MAX entries).
|
// Errors captured by the logger sink. Server-paginated to avoid pulling
|
||||||
|
// the full ERROR_LOG_MAX entries on every poll — payloads can be a few KB
|
||||||
|
// each once detail() enrichment is included.
|
||||||
router.get("/errors", async (req, res) => {
|
router.get("/errors", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const client = await getErrorLogClient();
|
const client = await getErrorLogClient();
|
||||||
if (!client) {
|
if (!client) {
|
||||||
return res.json({ entries: [], max: ERROR_LOG_MAX, available: false });
|
return res.json({
|
||||||
|
entries: [],
|
||||||
|
offset: 0,
|
||||||
|
limit: 0,
|
||||||
|
total: 0,
|
||||||
|
max: ERROR_LOG_MAX,
|
||||||
|
available: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const raw = await client.lRange(ERROR_LOG_KEY, 0, ERROR_LOG_MAX - 1);
|
const offset = Math.max(0, parseInt(String(req.query.offset || "0"), 10) || 0);
|
||||||
|
const limit = Math.min(
|
||||||
|
ERROR_LOG_MAX,
|
||||||
|
Math.max(1, parseInt(String(req.query.limit || "250"), 10) || 250)
|
||||||
|
);
|
||||||
|
const stop = offset + limit - 1;
|
||||||
|
const [raw, total] = await Promise.all([
|
||||||
|
client.lRange(ERROR_LOG_KEY, offset, stop),
|
||||||
|
client.lLen(ERROR_LOG_KEY),
|
||||||
|
]);
|
||||||
const entries = raw.map((s) => {
|
const entries = raw.map((s) => {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(s);
|
return JSON.parse(s);
|
||||||
@@ -242,7 +268,141 @@ router.get("/errors", async (req, res) => {
|
|||||||
return { ts: null, module: null, message: s, raw: [] };
|
return { ts: null, module: null, message: s, raw: [] };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
res.json({ entries, max: ERROR_LOG_MAX, available: true });
|
res.json({
|
||||||
|
entries,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
max: ERROR_LOG_MAX,
|
||||||
|
available: true,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, res, req);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Aggregated stats from the precomputed hourly counters (HINCRBY on each
|
||||||
|
// persistError). No JSON parsing of stored entries — O(48 small HGETALLs).
|
||||||
|
router.get("/errors/stats", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const client = await getErrorLogClient();
|
||||||
|
if (!client) {
|
||||||
|
return res.json({
|
||||||
|
available: false,
|
||||||
|
last24h: 0,
|
||||||
|
prev24h: 0,
|
||||||
|
severity: { error: 0, warn: 0, info: 0 },
|
||||||
|
unique: { error: 0, warn: 0, info: 0 },
|
||||||
|
buckets: [],
|
||||||
|
dropped: getInProcessDropped(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const now = new Date();
|
||||||
|
// Build the 48 hour keys to fetch (24 for current window + 24 for prev).
|
||||||
|
function hourKey(d: Date) {
|
||||||
|
const y = d.getUTCFullYear();
|
||||||
|
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(d.getUTCDate()).padStart(2, "0");
|
||||||
|
const h = String(d.getUTCHours()).padStart(2, "0");
|
||||||
|
return `${ERROR_LOG_HOURLY_PREFIX}${y}${m}${day}${h}`;
|
||||||
|
}
|
||||||
|
const currentKeys: string[] = [];
|
||||||
|
const prevKeys: string[] = [];
|
||||||
|
const bucketHourTs: number[] = [];
|
||||||
|
for (let i = 23; i >= 0; i--) {
|
||||||
|
const d = new Date(now.getTime() - i * 3600 * 1000);
|
||||||
|
// Anchor each bar at the end of its hour so a "9s ago" event lands in
|
||||||
|
// the rightmost bar.
|
||||||
|
const anchor = new Date(
|
||||||
|
Date.UTC(
|
||||||
|
d.getUTCFullYear(),
|
||||||
|
d.getUTCMonth(),
|
||||||
|
d.getUTCDate(),
|
||||||
|
d.getUTCHours()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
currentKeys.push(hourKey(anchor));
|
||||||
|
bucketHourTs.push(anchor.getTime() + 3600 * 1000);
|
||||||
|
}
|
||||||
|
for (let i = 47; i >= 24; i--) {
|
||||||
|
const d = new Date(now.getTime() - i * 3600 * 1000);
|
||||||
|
const anchor = new Date(
|
||||||
|
Date.UTC(
|
||||||
|
d.getUTCFullYear(),
|
||||||
|
d.getUTCMonth(),
|
||||||
|
d.getUTCDate(),
|
||||||
|
d.getUTCHours()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
prevKeys.push(hourKey(anchor));
|
||||||
|
}
|
||||||
|
const pipe = client.multi();
|
||||||
|
for (const k of currentKeys) pipe.hGetAll(k);
|
||||||
|
for (const k of prevKeys) pipe.hGetAll(k);
|
||||||
|
pipe.get(ERROR_LOG_DROPPED_KEY);
|
||||||
|
const results = (await pipe.exec()) as unknown[];
|
||||||
|
const currentHashes = results.slice(0, currentKeys.length) as Record<
|
||||||
|
string,
|
||||||
|
string
|
||||||
|
>[];
|
||||||
|
const prevHashes = results.slice(
|
||||||
|
currentKeys.length,
|
||||||
|
currentKeys.length + prevKeys.length
|
||||||
|
) as Record<string, string>[];
|
||||||
|
const droppedRedis =
|
||||||
|
parseInt(String(results[results.length - 1] || "0"), 10) || 0;
|
||||||
|
|
||||||
|
const buckets: {
|
||||||
|
hour: number;
|
||||||
|
error: number;
|
||||||
|
warn: number;
|
||||||
|
info: number;
|
||||||
|
}[] = [];
|
||||||
|
const sev = { error: 0, warn: 0, info: 0 };
|
||||||
|
const uniqueCodes: Record<"error" | "warn" | "info", Set<string>> = {
|
||||||
|
error: new Set(),
|
||||||
|
warn: new Set(),
|
||||||
|
info: new Set(),
|
||||||
|
};
|
||||||
|
let last24h = 0;
|
||||||
|
currentHashes.forEach((h, i) => {
|
||||||
|
const flat = h || {};
|
||||||
|
const e = parseInt(flat["bucket:error"] || "0", 10) || 0;
|
||||||
|
const w = parseInt(flat["bucket:warn"] || "0", 10) || 0;
|
||||||
|
const inf = parseInt(flat["bucket:info"] || "0", 10) || 0;
|
||||||
|
buckets.push({ hour: bucketHourTs[i], error: e, warn: w, info: inf });
|
||||||
|
sev.error += e;
|
||||||
|
sev.warn += w;
|
||||||
|
sev.info += inf;
|
||||||
|
last24h += parseInt(flat.total || "0", 10) || 0;
|
||||||
|
// cb:<bucket>:<code> fields → unique code sets.
|
||||||
|
for (const k of Object.keys(flat)) {
|
||||||
|
if (!k.startsWith("cb:")) continue;
|
||||||
|
const sep = k.indexOf(":", 3);
|
||||||
|
if (sep < 0) continue;
|
||||||
|
const b = k.slice(3, sep) as "error" | "warn" | "info";
|
||||||
|
const code = k.slice(sep + 1);
|
||||||
|
if (b in uniqueCodes) uniqueCodes[b].add(code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let prev24h = 0;
|
||||||
|
for (const h of prevHashes) {
|
||||||
|
prev24h += parseInt((h || {}).total || "0", 10) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
available: true,
|
||||||
|
last24h,
|
||||||
|
prev24h,
|
||||||
|
severity: sev,
|
||||||
|
unique: {
|
||||||
|
error: uniqueCodes.error.size,
|
||||||
|
warn: uniqueCodes.warn.size,
|
||||||
|
info: uniqueCodes.info.size,
|
||||||
|
},
|
||||||
|
buckets,
|
||||||
|
dropped: droppedRedis + getInProcessDropped(),
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, res, req);
|
handleError(error, res, req);
|
||||||
}
|
}
|
||||||
@@ -253,8 +413,24 @@ router.delete("/errors", async (req, res) => {
|
|||||||
const client = await getErrorLogClient();
|
const client = await getErrorLogClient();
|
||||||
if (!client) return res.json({ ok: true, cleared: 0 });
|
if (!client) return res.json({ ok: true, cleared: 0 });
|
||||||
const len = await client.lLen(ERROR_LOG_KEY);
|
const len = await client.lLen(ERROR_LOG_KEY);
|
||||||
await client.del(ERROR_LOG_KEY);
|
// SCAN the hourly counter keys and del them along with the list and
|
||||||
res.json({ ok: true, cleared: len });
|
// dropped counter so the admin page comes back to a clean slate.
|
||||||
|
const hourlyKeys: string[] = [];
|
||||||
|
let cursor = 0;
|
||||||
|
do {
|
||||||
|
const reply = await client.scan(cursor, {
|
||||||
|
MATCH: `${ERROR_LOG_HOURLY_PREFIX}*`,
|
||||||
|
COUNT: 100,
|
||||||
|
});
|
||||||
|
cursor = Number(reply.cursor);
|
||||||
|
for (const k of reply.keys) hourlyKeys.push(k);
|
||||||
|
} while (cursor !== 0);
|
||||||
|
const pipe = client.multi();
|
||||||
|
pipe.del(ERROR_LOG_KEY);
|
||||||
|
pipe.del(ERROR_LOG_DROPPED_KEY);
|
||||||
|
if (hourlyKeys.length) pipe.del(hourlyKeys);
|
||||||
|
await pipe.exec();
|
||||||
|
res.json({ ok: true, cleared: len, hourlyCleared: hourlyKeys.length });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, res, req);
|
handleError(error, res, req);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,10 +118,19 @@ export function isOwnerCoauthorOrAdmin(repo: Repository, user: User) {
|
|||||||
function printError(error: any, req?: express.Request) {
|
function printError(error: any, req?: express.Request) {
|
||||||
if (error instanceof AnonymousError) {
|
if (error instanceof AnonymousError) {
|
||||||
if (req?.originalUrl === "/api/repo/undefined/options") return;
|
if (req?.originalUrl === "/api/repo/undefined/options") return;
|
||||||
logger.error("anonymous error", {
|
const payload = {
|
||||||
...serializeError(error),
|
...serializeError(error),
|
||||||
url: req?.originalUrl,
|
url: req?.originalUrl,
|
||||||
});
|
};
|
||||||
|
// 4xx are expected client errors (not_found, expired, not_connected) —
|
||||||
|
// route them to warn so the admin Errors page can split server faults
|
||||||
|
// (5xx) from client misuse (4xx) cleanly.
|
||||||
|
const status = error.httpStatus;
|
||||||
|
if (typeof status === "number" && status >= 400 && status < 500) {
|
||||||
|
logger.warn("anonymous error", payload);
|
||||||
|
} else {
|
||||||
|
logger.error("anonymous error", payload);
|
||||||
|
}
|
||||||
} else if (error instanceof HTTPError) {
|
} else if (error instanceof HTTPError) {
|
||||||
logger.error("http error", {
|
logger.error("http error", {
|
||||||
code: error.code,
|
code: error.code,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import AnonymizedRepositoryModel from "../core/model/anonymizedRepositories/anon
|
|||||||
import ConferenceModel from "../core/model/conference/conferences.model";
|
import ConferenceModel from "../core/model/conference/conferences.model";
|
||||||
import Repository from "../core/Repository";
|
import Repository from "../core/Repository";
|
||||||
import { createLogger, serializeError } from "../core/logger";
|
import { createLogger, serializeError } from "../core/logger";
|
||||||
|
import { computeAndStoreDailyStats } from "./dailyStatsSnapshot";
|
||||||
|
|
||||||
const logger = createLogger("schedule");
|
const logger = createLogger("schedule");
|
||||||
|
|
||||||
@@ -54,3 +55,11 @@ export function repositoryStatusCheck() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function dailyStatsSnapshot() {
|
||||||
|
// snapshot home-page stats once per day at 00:05 UTC
|
||||||
|
schedule.scheduleJob("5 0 * * *", async () => {
|
||||||
|
logger.info("running daily stats snapshot");
|
||||||
|
await computeAndStoreDailyStats();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user