Improve error dashboard

This commit is contained in:
tdurieux
2026-05-06 16:12:37 +03:00
parent 6f418d6332
commit 873c910dd3
18 changed files with 1606 additions and 318 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+410 -24
View File
@@ -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; }
}
+178 -60
View File
@@ -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 &nbsp;/&nbsp; <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
View File
@@ -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 &middot; LAST 60 DAYS</div>
<div class="paper-stats-meta-right">
updated daily &middot;
<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">&middot;</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
View File
@@ -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);
},
]);
+85
View File
@@ -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", [
+116 -116
View File
File diff suppressed because one or more lines are too long