mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-05-15 14:38:03 +02:00
Improve error dashboard
This commit is contained in:
+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