mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-06-10 01:24:08 +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 {
|
||||
max-width: 1100px;
|
||||
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;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
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 {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 42px;
|
||||
font-size: 56px;
|
||||
line-height: 1;
|
||||
color: var(--color);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.paper-stat-label {
|
||||
margin-top: 8px;
|
||||
font-size: 12.5px;
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
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 */
|
||||
.paper-how {
|
||||
max-width: 1100px;
|
||||
@@ -4703,22 +4795,316 @@ textarea::selection {
|
||||
.file.folder.truncated > a {
|
||||
color: #d39e00;
|
||||
}
|
||||
/* Errors admin */
|
||||
.errors-table .error-when time { font-variant-numeric: tabular-nums; color: #555; cursor: help; }
|
||||
.errors-table .error-msg-line { display: flex; flex-wrap: wrap; gap: 6px; align-items: baseline; }
|
||||
.errors-table .error-chip {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
font-size: 0.78rem; padding: 1px 6px; border-radius: 999px;
|
||||
background: #eef0f3; color: #333; border: 1px solid #dde0e4;
|
||||
max-width: 36em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
|
||||
/* ===== Errors admin page =====
|
||||
Uses the existing design tokens (--paper-card, --border-color, --ink-muted,
|
||||
--primary-bg, etc.) so light + dark themes are picked up automatically.
|
||||
Bucket colors are exposed as their own tokens with light/dark overrides
|
||||
below so status pills stay legible against the dark canvas. */
|
||||
body {
|
||||
--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; }
|
||||
.errors-table .error-chip.chip-err { background: #fdecec; border-color: #f5c2c2; color: #8a1f1f; }
|
||||
.errors-table .error-chip.chip-warn { background: #fff5e1; border-color: #f3d9a4; color: #7a4d00; }
|
||||
.errors-table .error-chip.chip-ok { background: #e9f6ec; border-color: #b8dfc1; color: #1f6b32; }
|
||||
.errors-table .error-chip.chip-mono .chip-value { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.78rem; }
|
||||
.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; }
|
||||
.errors-table .error-details { margin-top: 6px; }
|
||||
.errors-table .error-details summary { cursor: pointer; color: #666; font-size: 0.82rem; }
|
||||
.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; }
|
||||
.errors-table .error-context { color: #888; font-size: 0.78rem; font-style: italic; margin-left: 4px; }
|
||||
.dark-mode {
|
||||
--bucket-error-bg: rgba(255, 139, 123, 0.10);
|
||||
--bucket-error-fg: #FF8B7B;
|
||||
--bucket-error-bd: rgba(255, 139, 123, 0.28);
|
||||
--bucket-error-dot: #FF8B7B;
|
||||
--bucket-warn-bg: rgba(255, 211, 122, 0.10);
|
||||
--bucket-warn-fg: #FFD37A;
|
||||
--bucket-warn-bd: rgba(255, 211, 122, 0.28);
|
||||
--bucket-warn-dot: #FFD37A;
|
||||
--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>
|
||||
<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">
|
||||
<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>
|
||||
</nav>
|
||||
|
||||
<div class="admin-summary">
|
||||
<span class="summary-pill error">{{filtered.length}} shown</span>
|
||||
<span class="summary-pill">{{entries.length}} captured</span>
|
||||
<span class="summary-pill" ng-if="!available">redis sink unavailable</span>
|
||||
</div>
|
||||
|
||||
<form class="w-100 admin-filter-toolbar" aria-label="Error filters">
|
||||
<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" />
|
||||
<section class="kpi-grid">
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">Last 24h</div>
|
||||
<div class="kpi-value">{{stats.last24h}}</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>
|
||||
<span ng-if="!stats.prev24h">no prior baseline</span>
|
||||
</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 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>
|
||||
|
||||
<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">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 9em;">When</th>
|
||||
<th style="width: 9em;">Module</th>
|
||||
<th>Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr ng-repeat="e in filtered track by $index">
|
||||
<td class="error-when">
|
||||
<time title="{{absTime(e.ts)}}">{{relTime(e.ts)}}</time>
|
||||
</td>
|
||||
<td><span class="pill pill-module">{{e.module}}</span></td>
|
||||
<td class="error-msg">
|
||||
<div class="error-msg-line">
|
||||
<strong>{{e.displayMessage}}</strong>
|
||||
<span class="error-context" ng-if="e.displayContext && e.displayContext !== e.displayMessage">{{e.displayContext}}</span>
|
||||
<span class="error-chip"
|
||||
ng-repeat="c in e._chips track by $index"
|
||||
ng-class="{'chip-err': c.kind === 'err', 'chip-warn': c.kind === 'warn', 'chip-ok': c.kind === 'ok', 'chip-mono': c.mono}"
|
||||
title="{{c.label}}: {{c.value}}">
|
||||
<span class="chip-label">{{c.label}}</span>
|
||||
<span class="chip-value">{{c.value}}</span>
|
||||
</span>
|
||||
<div class="errors-list" ng-if="visible.length">
|
||||
<div class="errors-list-head">
|
||||
<span class="col-when">When</span>
|
||||
<span class="col-sev">Severity</span>
|
||||
<span class="col-mod">Module</span>
|
||||
<span class="col-msg">Message</span>
|
||||
<span class="col-count">Count</span>
|
||||
<span class="col-status">Status</span>
|
||||
</div>
|
||||
|
||||
<div class="errors-row" ng-repeat="row in visible track by row._key" ng-class="{open: expanded[row._key]}">
|
||||
<div class="errors-row-main" ng-click="toggle(row)">
|
||||
<div class="col-when">
|
||||
<div class="when-rel">{{relTime(row.ts)}}</div>
|
||||
<div class="when-abs">{{absTimeShort(row.ts)}}</div>
|
||||
</div>
|
||||
<div class="col-sev">
|
||||
<span class="sev-dot" ng-class="'sev-' + row._bucket"></span>
|
||||
<span class="sev-label">{{row._bucket | uppercase}}</span>
|
||||
</div>
|
||||
<div class="col-mod"><span class="pill pill-module">{{row.module}}</span></div>
|
||||
<div class="col-msg">
|
||||
<strong class="msg-code">{{row.displayMessage}}</strong>
|
||||
<span class="msg-context" ng-if="row.displayContext && row.displayContext !== row.displayMessage">{{row.displayContext}}</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>
|
||||
<details ng-if="e._detailJson" class="error-details">
|
||||
<summary>raw</summary>
|
||||
<pre>{{e._detailJson}}</pre>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<aside class="detail-aside">
|
||||
<div class="aside-block">
|
||||
<div class="aside-label">First seen</div>
|
||||
<div class="aside-value" title="{{absTime(row._firstSeen)}}">{{relTime(row._firstSeen)}}</div>
|
||||
</div>
|
||||
<div class="aside-block">
|
||||
<div class="aside-label">Last seen</div>
|
||||
<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>
|
||||
|
||||
+28
-10
@@ -61,17 +61,35 @@
|
||||
<!-- Stats strip -->
|
||||
<section class="paper-stats" id="metrics">
|
||||
<div class="paper-stats-inner">
|
||||
<div>
|
||||
<div class="paper-stat-value">{{stat.nbRepositories | number}}</div>
|
||||
<div class="paper-stat-label">repositories anonymized</div>
|
||||
<div class="paper-stats-meta">
|
||||
<div class="paper-stats-meta-left">LIVE · LAST 60 DAYS</div>
|
||||
<div class="paper-stats-meta-right">
|
||||
updated daily ·
|
||||
<span class="paper-stats-dot"></span> in sync
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="paper-stat-value">{{stat.nbUsers | number}}</div>
|
||||
<div class="paper-stat-label">researchers</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="paper-stat-value">{{stat.nbPageViews | number}}</div>
|
||||
<div class="paper-stat-label">page views</div>
|
||||
<div class="paper-stats-grid">
|
||||
<div class="paper-stat-card" ng-repeat="card in cards track by card.key">
|
||||
<div class="paper-stat-value">{{card.total | bigNum}}</div>
|
||||
<div class="paper-stat-label">{{card.label}}</div>
|
||||
<svg class="paper-stat-bars" preserveAspectRatio="none"
|
||||
ng-attr-viewBox="0 0 {{history[card.key].viewW}} 36"
|
||||
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>
|
||||
</section>
|
||||
|
||||
+284
-57
@@ -866,12 +866,21 @@ angular
|
||||
}
|
||||
|
||||
$scope.entries = [];
|
||||
$scope.filtered = [];
|
||||
$scope.modules = [];
|
||||
$scope.visible = [];
|
||||
$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 = {
|
||||
search: "",
|
||||
module: "",
|
||||
bucket: "",
|
||||
sort: "recent",
|
||||
group: "code",
|
||||
autoRefresh: true,
|
||||
};
|
||||
|
||||
@@ -897,27 +906,34 @@ angular
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
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).
|
||||
// Returning a fresh array from a template-bound function each digest
|
||||
// cycle triggers Angular's $rootScope:infdig — so we precompute on load.
|
||||
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").
|
||||
// Decorate each entry once with derived display fields. Pre-computing
|
||||
// avoids returning new arrays from template functions each digest
|
||||
// cycle (which trips Angular's $rootScope:infdig).
|
||||
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) {
|
||||
const chips = [];
|
||||
const detail = (e.raw || []).find(
|
||||
(a) => a && typeof a === "object" && !Array.isArray(a)
|
||||
);
|
||||
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)) {
|
||||
e.displayMessage = detail.message;
|
||||
e.displayContext = e.message;
|
||||
@@ -927,64 +943,273 @@ angular
|
||||
} else {
|
||||
e.displayMessage = e.message;
|
||||
}
|
||||
if (detail.httpStatus) chips.push({ label: "status", value: detail.httpStatus, kind: statusKind(detail.httpStatus) });
|
||||
else if (detail.status) chips.push({ label: "status", value: detail.status, kind: statusKind(detail.status) });
|
||||
if (detail.method) chips.push({ label: "method", value: detail.method });
|
||||
if (detail.url) chips.push({ label: "url", value: detail.url, mono: true });
|
||||
if (detail.repoId) chips.push({ label: "repo", value: detail.repoId, mono: true });
|
||||
if (detail.code && detail.code !== detail.message && detail.code !== e.displayMessage) {
|
||||
chips.push({ label: "code", value: detail.code });
|
||||
}
|
||||
e._status = detail.httpStatus || detail.status || null;
|
||||
e._url = detail.url || null;
|
||||
e._method = detail.method || null;
|
||||
e._repoId = detail.repoId || detail.detail || null;
|
||||
e._detail = detail.detail && detail.detail !== e._repoId ? detail.detail : null;
|
||||
} else {
|
||||
e.displayMessage = e.message;
|
||||
e._status = null;
|
||||
e._url = null;
|
||||
}
|
||||
const tail = (e.raw || []).slice(1);
|
||||
const detailJson = !tail.length
|
||||
? ""
|
||||
: tail.length === 1
|
||||
? JSON.stringify(tail[0], null, 2)
|
||||
: JSON.stringify(tail, null, 2);
|
||||
e._chips = chips;
|
||||
e._detailJson = detailJson;
|
||||
e._bucket = bucketFor(detail, e.level);
|
||||
e._detailJson = renderDisplayPayload(e, detail);
|
||||
return e;
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
const q = ($scope.query.search || "").toLowerCase();
|
||||
const mod = $scope.query.module || "";
|
||||
$scope.filtered = $scope.entries.filter((e) => {
|
||||
if (mod && e.module !== mod) return false;
|
||||
if (!q) return true;
|
||||
const hay = (
|
||||
(e.displayMessage || e.message || "") +
|
||||
" " +
|
||||
e.module +
|
||||
" " +
|
||||
JSON.stringify(e.raw || [])
|
||||
).toLowerCase();
|
||||
return hay.indexOf(q) > -1;
|
||||
// Build a curated, column-aligned JSON payload for the Raw tab. Mirrors
|
||||
// the reference admin design: name / code / kind / httpStatus / module /
|
||||
// detail / url / ts on aligned colons. We can't just JSON.stringify the
|
||||
// raw entry because it includes the human "anonymous error" wrapper
|
||||
// arg and the keys aren't column-aligned.
|
||||
function renderDisplayPayload(entry, detail) {
|
||||
const fields = [];
|
||||
const push = (k, v) => {
|
||||
if (v === undefined || v === null || v === "") return;
|
||||
fields.push([k, v]);
|
||||
};
|
||||
push("name", detail && detail.name);
|
||||
push("code", entry.displayMessage || (detail && detail.message));
|
||||
// "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() {
|
||||
$http.get("/api/admin/errors").then(
|
||||
// Lightweight filter parser. Pulls `key:value` and `status:>=400` style
|
||||
// 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) => {
|
||||
$scope.entries = (res.data.entries || []).map(decorate);
|
||||
$scope.available = !!res.data.available;
|
||||
const set = new Set();
|
||||
$scope.entries.forEach((e) => e.module && set.add(e.module));
|
||||
$scope.modules = Array.from(set).sort();
|
||||
applyFilter();
|
||||
const s = res.data || {};
|
||||
const delta = s.prev24h ? Math.round(((s.last24h - s.prev24h) / s.prev24h) * 100) : 0;
|
||||
$scope.stats = {
|
||||
last24h: s.last24h || 0,
|
||||
prev24h: s.prev24h || 0,
|
||||
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)
|
||||
);
|
||||
}
|
||||
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.clearAll = () => {
|
||||
if (!confirm("Clear all captured errors?")) return;
|
||||
$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();
|
||||
const stop = $interval(() => {
|
||||
@@ -992,7 +1217,9 @@ angular
|
||||
}, 5000);
|
||||
$scope.$on("$destroy", () => $interval.cancel(stop));
|
||||
|
||||
$scope.$watch("query.search", applyFilter);
|
||||
$scope.$watch("query.module", applyFilter);
|
||||
$scope.$watch("query.search", recompute);
|
||||
$scope.$watch("query.bucket", recompute);
|
||||
$scope.$watch("query.sort", recompute);
|
||||
$scope.$watch("query.group", recompute);
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -156,6 +156,17 @@ angular
|
||||
.filter("humanFileSize", function () {
|
||||
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 () {
|
||||
return function humanTime(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() {
|
||||
$http.get("/api/stat/").then((res) => {
|
||||
$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();
|
||||
|
||||
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", [
|
||||
|
||||
Vendored
+116
-116
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user