Files
anonymous_github/public/script/admin.js
T
2026-05-06 16:45:22 +03:00

1282 lines
43 KiB
JavaScript

angular
.module("admin", [])
.controller("repositoriesAdminController", [
"$scope",
"$http",
"$location",
function ($scope, $http, $location) {
$scope.Math = Math;
$scope.$watch("user.status", () => {
if ($scope.user == null) {
$location.url("/");
}
});
if ($scope.user == null) {
$location.url("/");
}
$scope.repositories = [];
$scope.total = -1;
$scope.totalPage = 0;
$scope.statusCounts = [];
$scope.totalSize = 0;
$scope.selected = {};
$scope.allSelected = false;
// Slash-to-focus the search input
const searchKeyHandler = (e) => {
if (e.key === "/" && !["INPUT","TEXTAREA","SELECT"].includes(document.activeElement?.tagName)) {
e.preventDefault();
const el = document.querySelector('.admin-filter-toolbar input[type="search"]');
el && el.focus();
}
};
document.addEventListener("keydown", searchKeyHandler);
$scope.$on("$destroy", () => document.removeEventListener("keydown", searchKeyHandler));
$scope.clearFilter = (key) => {
if (key === "dateRange") { $scope.query.dateFrom = ""; $scope.query.dateTo = ""; }
else $scope.query[key] = "";
$scope.query.page = 1;
};
$scope.chips = [];
const recomputeChips = () => {
const out = [];
if ($scope.query.owner) out.push({ key: "owner", label: "Owner", value: $scope.query.owner });
if ($scope.query.conference) out.push({ key: "conference", label: "Conference", value: $scope.query.conference });
$scope.chips = out;
};
$scope.showStatusMessage = (repo) => {
const msg = repo.statusMessage || "(no message)";
window.prompt(`Status message for ${repo.repoId} (${repo.status}):`, msg);
};
$scope.fetchGithubInfo = (repo) => {
const w = window.open("", "_blank");
if (w) w.document.write("<pre>Loading GitHub info for " + repo.repoId + "...</pre>");
$http.get("/api/admin/repos/" + repo.repoId + "/github").then(
(res) => {
if (w) {
w.document.open();
w.document.write(
"<pre style=\"font:13px monospace;padding:16px;white-space:pre-wrap\">" +
JSON.stringify(res.data, null, 2).replace(/[<>]/g, (c) => c === "<" ? "&lt;" : "&gt;") +
"</pre>"
);
w.document.close();
}
},
(err) => {
const msg = err && err.data ? JSON.stringify(err.data, null, 2) : String(err);
if (w) w.document.body.innerHTML = "<pre style=\"color:#B42318;padding:16px\">" + msg + "</pre>";
}
);
};
$scope.statusCountFor = (s) => {
const row = ($scope.statusCounts || []).find((c) => c._id === s);
return row ? row.count : 0;
};
$scope.statusStorageFor = (s) => {
const row = ($scope.statusCounts || []).find((c) => c._id === s);
return row ? row.storage : 0;
};
$scope.isErrorsOnly = () =>
$scope.query &&
$scope.query.error && !$scope.query.ready && !$scope.query.preparing &&
!$scope.query.expired && !$scope.query.removed;
$scope.toggleErrorsOnly = () => {
if ($scope.isErrorsOnly()) {
Object.assign($scope.query, { ready: false, preparing: true, expired: false, removed: false, error: true });
} else {
Object.assign($scope.query, { ready: false, preparing: false, expired: false, removed: false, error: true });
}
$scope.query.page = 1;
};
$scope.toggleSortDirection = () => {
$scope.query.direction = $scope.query.direction === "asc" ? "desc" : "asc";
};
$scope.sortBy = (field) => {
if ($scope.query.sort === field) {
$scope.query.direction = $scope.query.direction === "asc" ? "desc" : "asc";
} else {
$scope.query.sort = field;
$scope.query.direction = "desc";
}
$scope.query.page = 1;
};
$scope.sortIcon = (field) =>
$scope.query.sort === field
? ($scope.query.direction === "asc" ? "fa-arrow-up" : "fa-arrow-down")
: "";
const reposAdminPrefsKey = "admin.repos.filterPrefs";
const reposAdminDefaults = {
page: 1,
limit: 25,
sort: "lastView",
direction: "desc",
search: "",
owner: "",
conference: "",
dateFrom: "",
dateTo: "",
ready: false,
expired: false,
removed: false,
error: true,
preparing: true,
};
const savedReposAdminPrefs = loadFilterPrefs(reposAdminPrefsKey) || {};
$scope.query = Object.assign({}, reposAdminDefaults, savedReposAdminPrefs, {
page: 1,
search: "",
});
// pre-fill owner / conference from URL ?owner= / ?conference=
const params = new URLSearchParams(window.location.search);
if (params.get("owner")) $scope.query.owner = params.get("owner");
if (params.get("conference")) $scope.query.conference = params.get("conference");
// -------- presets --------
const presetsKey = "admin.repos.presets";
$scope.presets = JSON.parse(localStorage.getItem(presetsKey) || "[]");
$scope.savePreset = () => {
const name = window.prompt("Preset name:");
if (!name) return;
const snapshot = Object.assign({}, $scope.query);
delete snapshot.page;
$scope.presets = ($scope.presets || []).filter((p) => p.name !== name);
$scope.presets.push({ name, query: snapshot });
localStorage.setItem(presetsKey, JSON.stringify($scope.presets));
};
$scope.applyPreset = (p) => {
Object.assign($scope.query, p.query, { page: 1 });
};
$scope.deletePreset = (p) => {
$scope.presets = ($scope.presets || []).filter((x) => x.name !== p.name);
localStorage.setItem(presetsKey, JSON.stringify($scope.presets));
};
// -------- selection / bulk --------
$scope.selectAllOnPage = () => {
$scope.allSelected = !$scope.allSelected;
$scope.repositories.forEach((r) => {
$scope.selected[r.repoId] = $scope.allSelected;
});
};
$scope.selectedCount = () =>
Object.values($scope.selected || {}).filter(Boolean).length;
$scope.selectedRepos = () =>
$scope.repositories.filter((r) => $scope.selected[r.repoId]);
$scope.bulkRefresh = () => {
const repos = $scope.selectedRepos();
if (!repos.length) return;
if (!confirm(`Force refresh ${repos.length} repositories?`)) return;
repos.forEach((r) => $scope.updateRepository(r));
};
$scope.bulkRemoveCache = () => {
const repos = $scope.selectedRepos();
if (!repos.length) return;
if (!confirm(`Purge cache for ${repos.length} repositories?`)) return;
repos.forEach((r) => $scope.removeCache(r));
};
$scope.clearSelection = () => {
$scope.selected = {};
$scope.allSelected = false;
};
// -------- export --------
$scope.exportCsv = () => {
const params = new URLSearchParams(
Object.entries($scope.query).filter(([, v]) => v !== "" && v !== false && v != null)
);
params.set("format", "csv");
params.set("limit", "10000");
window.open("/api/admin/repos?" + params.toString(), "_blank");
};
$scope.removeCache = (repo) => {
$http.delete("/api/admin/repos/" + repo.repoId).then(
(res) => {
$scope.$apply();
},
(err) => {
console.error(err);
}
);
};
$scope.updateRepository = (repo) => {
const toast = {
title: `Refreshing ${repo.repoId}...`,
date: new Date(),
body: `The repository ${repo.repoId} is going to be refreshed.`,
};
$scope.toasts.push(toast);
$http.post(`/api/repo/${repo.repoId}/refresh`).then(
(res) => {
if (res.data.status == "ready") {
toast.title = `${repo.repoId} is refreshed.`;
} else {
toast.title = `Refreshing of ${repo.repoId}.`;
}
},
(error) => {
toast.title = `Error during the refresh of ${repo.repoId}.`;
toast.body = error.body;
}
);
};
$scope.fetchError = null;
function getRepositories() {
$scope.fetchError = null;
$http.get("/api/admin/repos", { params: $scope.query }).then(
(res) => {
$scope.total = res.data.total;
$scope.totalPage = Math.ceil(res.data.total / $scope.query.limit);
$scope.repositories = res.data.results;
$scope.statusCounts = res.data.statusCounts || [];
$scope.totalSize = res.data.totalSize || 0;
$scope.allSelected = false;
},
(err) => {
$scope.fetchError = (err && err.data && err.data.error) || "Failed to load repositories";
console.error(err);
}
);
}
getRepositories();
let timeClear = null;
$scope.$watch(
"query",
() => {
clearTimeout(timeClear);
timeClear = setTimeout(getRepositories, 500);
const { page, search, ...persisted } = $scope.query;
saveFilterPrefs(reposAdminPrefsKey, persisted);
recomputeChips();
},
true
);
recomputeChips();
},
])
.controller("usersAdminController", [
"$scope",
"$http",
"$location",
function ($scope, $http, $location) {
$scope.Math = Math;
$scope.$watch("user.status", () => {
if ($scope.user == null) {
$location.url("/");
}
});
if ($scope.user == null) {
$location.url("/");
}
$scope.users = [];
$scope.total = -1;
$scope.totalPage = 0;
$scope.statusCounts = [];
$scope.selected = {};
$scope.allSelected = false;
const searchKeyHandler = (e) => {
if (e.key === "/" && !["INPUT","TEXTAREA","SELECT"].includes(document.activeElement?.tagName)) {
e.preventDefault();
const el = document.querySelector('.admin-filter-toolbar input[type="search"]');
el && el.focus();
}
};
document.addEventListener("keydown", searchKeyHandler);
$scope.$on("$destroy", () => document.removeEventListener("keydown", searchKeyHandler));
$scope.clearFilter = (key) => {
if (key === "dateRange") { $scope.query.dateFrom = ""; $scope.query.dateTo = ""; }
else $scope.query[key] = "";
$scope.query.page = 1;
};
$scope.chips = [];
const recomputeChipsUsers = () => {
const out = [];
if ($scope.query.role) out.push({ key: "role", label: "Role", value: $scope.query.role });
$scope.chips = out;
};
$scope.statusCountFor = (s) => {
const row = ($scope.statusCounts || []).find((c) => c._id === s);
return row ? row.count : 0;
};
$scope.toggleSortDirection = () => {
$scope.query.direction = $scope.query.direction === "asc" ? "desc" : "asc";
};
$scope.sortBy = (field) => {
if ($scope.query.sort === field) {
$scope.query.direction = $scope.query.direction === "asc" ? "desc" : "asc";
} else {
$scope.query.sort = field;
$scope.query.direction = "desc";
}
$scope.query.page = 1;
};
$scope.sortIcon = (field) =>
$scope.query.sort === field
? ($scope.query.direction === "asc" ? "fa-arrow-up" : "fa-arrow-down")
: "";
const usersAdminPrefsKey = "admin.users.filterPrefs";
const usersAdminDefaults = {
page: 1,
limit: 25,
sort: "username",
direction: "asc",
search: "",
status: "",
role: "",
dateFrom: "",
dateTo: "",
};
const savedUsersAdminPrefs = loadFilterPrefs(usersAdminPrefsKey) || {};
$scope.query = Object.assign({}, usersAdminDefaults, savedUsersAdminPrefs, {
page: 1,
search: "",
});
$scope.selectAllOnPage = () => {
$scope.allSelected = !$scope.allSelected;
$scope.users.forEach((u) => {
$scope.selected[u.username] = $scope.allSelected;
});
};
$scope.selectedCount = () =>
Object.values($scope.selected || {}).filter(Boolean).length;
$scope.selectedUsers = () =>
$scope.users.filter((u) => $scope.selected[u.username]);
$scope.banUser = (u) => {
if (!confirm(`Ban user ${u.username}?`)) return;
$http
.post(`/api/admin/users/${u.username}/ban`)
.then(getUsers, (err) => console.error(err));
};
$scope.activateUser = (u) => {
$http
.post(`/api/admin/users/${u.username}/activate`)
.then(getUsers, (err) => console.error(err));
};
$scope.bulkBan = () => {
const users = $scope.selectedUsers();
if (!users.length) return;
if (!confirm(`Ban ${users.length} users?`)) return;
users.forEach((u) => $scope.banUser(u));
};
$scope.exportCsv = () => {
const params = new URLSearchParams(
Object.entries($scope.query).filter(([, v]) => v !== "" && v !== false && v != null)
);
params.set("format", "csv");
params.set("limit", "10000");
window.open("/api/admin/users?" + params.toString(), "_blank");
};
$scope.fetchError = null;
function getUsers() {
$scope.fetchError = null;
$http.get("/api/admin/users", { params: $scope.query }).then(
(res) => {
$scope.total = res.data.total;
$scope.totalPage = Math.ceil(res.data.total / $scope.query.limit);
$scope.users = res.data.results;
$scope.statusCounts = res.data.statusCounts || [];
$scope.allSelected = false;
$scope.$apply();
},
(err) => {
$scope.fetchError = (err && err.data && err.data.error) || "Failed to load users";
console.error(err);
}
);
}
getUsers();
let timeClear = null;
$scope.$watch(
"query",
() => {
clearTimeout(timeClear);
timeClear = setTimeout(getUsers, 500);
const { page, search, ...persisted } = $scope.query;
saveFilterPrefs(usersAdminPrefsKey, persisted);
recomputeChipsUsers();
},
true
);
recomputeChipsUsers();
},
])
.controller("userAdminController", [
"$scope",
"$http",
"$location",
"$routeParams",
function ($scope, $http, $location, $routeParams) {
$scope.$watch("user.status", () => {
if ($scope.user == null) {
$location.url("/");
}
});
if ($scope.user == null) {
$location.url("/");
}
$scope.userInfo;
$scope.repositories = [];
$scope.search = "";
const adminUserPrefsKey = "admin.user.filterPrefs";
const adminUserDefaults = {
filters: { status: { ready: true, expired: true, removed: true, error: true, preparing: true } },
orderBy: "-anonymizeDate",
};
const savedAdminUserPrefs = loadFilterPrefs(adminUserPrefsKey) || {};
$scope.filters = {
status: Object.assign(
{},
adminUserDefaults.filters.status,
(savedAdminUserPrefs.filters && savedAdminUserPrefs.filters.status) || {}
),
};
$scope.orderBy = savedAdminUserPrefs.orderBy || adminUserDefaults.orderBy;
$scope.$watch("orderBy", () => {
saveFilterPrefs(adminUserPrefsKey, {
filters: $scope.filters,
orderBy: $scope.orderBy,
});
});
$scope.$watch(
"filters",
() => {
saveFilterPrefs(adminUserPrefsKey, {
filters: $scope.filters,
orderBy: $scope.orderBy,
});
},
true
);
$scope.repoFiler = (repo) => {
if ($scope.filters.status[repo.status] == false) return false;
if ($scope.search.trim().length == 0) return true;
if (repo.source.fullName.indexOf($scope.search) > -1) return true;
if (repo.repoId.indexOf($scope.search) > -1) return true;
return false;
};
function getUserRepositories(username) {
$http.get("/api/admin/users/" + username + "/repos", {}).then(
(res) => {
$scope.repositories = res.data;
},
(err) => {
console.error(err);
}
);
}
function getUser(username) {
$http.get("/api/admin/users/" + username, {}).then(
(res) => {
$scope.userInfo = res.data;
},
(err) => {
console.error(err);
}
);
}
getUser($routeParams.username);
getUserRepositories($routeParams.username);
$scope.tokens = [];
$scope.tokenForm = { name: "", plaintext: null };
function loadTokens() {
$http.get("/api/admin/tokens").then(
(res) => {
$scope.tokens = res.data || [];
},
(err) => {
if (err.status !== 401 && err.status !== 403) console.error(err);
}
);
}
loadTokens();
$scope.createToken = () => {
if (!$scope.tokenForm.name) return;
$http
.post("/api/admin/tokens", { name: $scope.tokenForm.name })
.then(
(res) => {
$scope.tokenForm.plaintext = res.data.token;
$scope.tokenForm.name = "";
loadTokens();
},
(err) => console.error(err)
);
};
$scope.revokeToken = (t) => {
if (!confirm(`Revoke token "${t.name}"?`)) return;
$http.delete("/api/admin/tokens/" + t.id).then(
() => loadTokens(),
(err) => console.error(err)
);
};
$scope.removeCache = (repo) => {
$http.delete("/api/admin/repos/" + repo.repoId).then(
(res) => {
$scope.$apply();
},
(err) => {
console.error(err);
}
);
};
$scope.updateRepository = (repo) => {
const toast = {
title: `Refreshing ${repo.repoId}...`,
date: new Date(),
body: `The repository ${repo.repoId} is going to be refreshed.`,
};
$scope.toasts.push(toast);
$http.post(`/api/repo/${repo.repoId}/refresh`).then(
(res) => {
if (res.data.status == "ready") {
toast.title = `${repo.repoId} is refreshed.`;
} else {
toast.title = `Refreshing of ${repo.repoId}.`;
}
},
(error) => {
toast.title = `Error during the refresh of ${repo.repoId}.`;
toast.body = error.body;
}
);
};
$scope.getGitHubRepositories = (force) => {
$http
.get(`/api/user/${$scope.userInfo.username}/all_repositories`, {
params: { force: "1" },
})
.then((res) => {
$scope.userInfo.repositories = res.data;
});
};
let timeClear = null;
$scope.$watch(
"query",
() => {
clearTimeout(timeClear);
timeClear = setTimeout(() => {
getUserRepositories($routeParams.username);
}, 500);
},
true
);
},
])
.controller("conferencesAdminController", [
"$scope",
"$http",
"$location",
function ($scope, $http, $location) {
$scope.Math = Math;
$scope.$watch("user.status", () => {
if ($scope.user == null) {
$location.url("/");
}
});
if ($scope.user == null) {
$location.url("/");
}
$scope.conferences = [];
$scope.total = -1;
$scope.totalPage = 0;
$scope.statusCounts = [];
const searchKeyHandler = (e) => {
if (e.key === "/" && !["INPUT","TEXTAREA","SELECT"].includes(document.activeElement?.tagName)) {
e.preventDefault();
const el = document.querySelector('.admin-filter-toolbar input[type="search"]');
el && el.focus();
}
};
document.addEventListener("keydown", searchKeyHandler);
$scope.$on("$destroy", () => document.removeEventListener("keydown", searchKeyHandler));
$scope.clearFilter = (key) => {
if (key === "dateRange") { $scope.query.dateFrom = ""; $scope.query.dateTo = ""; }
else $scope.query[key] = "";
$scope.query.page = 1;
};
$scope.chips = [];
const recomputeChipsConf = () => {
$scope.chips = [];
};
$scope.statusCountFor = (s) => {
const row = ($scope.statusCounts || []).find((c) => c._id === s);
return row ? row.count : 0;
};
$scope.toggleSortDirection = () => {
$scope.query.direction = $scope.query.direction === "asc" ? "desc" : "asc";
};
$scope.sortBy = (field) => {
if ($scope.query.sort === field) {
$scope.query.direction = $scope.query.direction === "asc" ? "desc" : "asc";
} else {
$scope.query.sort = field;
$scope.query.direction = "desc";
}
$scope.query.page = 1;
};
$scope.sortIcon = (field) =>
$scope.query.sort === field
? ($scope.query.direction === "asc" ? "fa-arrow-up" : "fa-arrow-down")
: "";
const confAdminPrefsKey = "admin.conferences.filterPrefs";
const confAdminDefaults = {
page: 1,
limit: 25,
sort: "name",
direction: "asc",
search: "",
status: "",
dateFrom: "",
dateTo: "",
};
const savedConfAdminPrefs = loadFilterPrefs(confAdminPrefsKey) || {};
$scope.query = Object.assign({}, confAdminDefaults, savedConfAdminPrefs, {
page: 1,
search: "",
});
$scope.exportCsv = () => {
const params = new URLSearchParams(
Object.entries($scope.query).filter(([, v]) => v !== "" && v !== false && v != null)
);
params.set("format", "csv");
params.set("limit", "10000");
window.open("/api/admin/conferences?" + params.toString(), "_blank");
};
$scope.fetchError = null;
function getConferences() {
$scope.fetchError = null;
$http.get("/api/admin/conferences", { params: $scope.query }).then(
(res) => {
$scope.total = res.data.total;
$scope.totalPage = Math.ceil(res.data.total / $scope.query.limit);
$scope.conferences = res.data.results;
$scope.statusCounts = res.data.statusCounts || [];
$scope.$apply();
},
(err) => {
$scope.fetchError = (err && err.data && err.data.error) || "Failed to load conferences";
console.error(err);
}
);
}
getConferences();
let timeClear = null;
$scope.$watch(
"query",
() => {
clearTimeout(timeClear);
timeClear = setTimeout(getConferences, 500);
const { page, search, ...persisted } = $scope.query;
saveFilterPrefs(confAdminPrefsKey, persisted);
recomputeChipsConf();
},
true
);
recomputeChipsConf();
},
])
.controller("queuesAdminController", [
"$scope",
"$http",
"$location",
"$interval",
function ($scope, $http, $location, $interval) {
$scope.$watch("user.status", () => {
if ($scope.user == null) {
$location.url("/");
}
});
if ($scope.user == null) {
$location.url("/");
}
$scope.downloadJobs = [];
$scope.removeJobs = [];
$scope.removeCaches = [];
$scope.counts = { download: {}, remove: {}, cache: {} };
$scope.query = {
search: "",
state: "",
autoRefresh: true,
};
$scope.jobMatchesState = (job) => {
if (!$scope.query.state) return true;
const finished = !!job.finishedOn;
const failed = (job.stacktrace || []).length > 0 || job.failedReason;
const map = {
completed: finished && !failed,
failed: failed,
active: job.processedOn && !finished,
waiting: !job.processedOn,
};
return !!map[$scope.query.state];
};
$scope.jobProgressPct = (job) => {
if (job && job.progress && typeof job.progress === "object" && typeof job.progress.percent === "number") {
return Math.max(0, Math.min(100, Math.round(job.progress.percent)));
}
if (typeof job.progress === "number") {
return Math.max(0, Math.min(100, Math.round(job.progress)));
}
return null;
};
$scope.bulkRetryFailed = (queue) => {
if (!confirm(`Retry all failed jobs in the ${queue} queue?`)) return;
$http.post(`/api/admin/queue/${queue}/retry-failed`).then(getQueues, (err) => console.error(err));
};
$scope.bulkDrain = (queue) => {
if (!confirm(`Drain (clear waiting+delayed) the ${queue} queue?`)) return;
$http.post(`/api/admin/queue/${queue}/drain`).then(getQueues, (err) => console.error(err));
};
function getQueues() {
$http.get("/api/admin/queues", { params: $scope.query }).then(
(res) => {
$scope.downloadJobs = res.data.downloadQueue;
$scope.removeJobs = res.data.removeQueue;
$scope.removeCaches = res.data.cacheQueue;
$scope.counts = res.data.counts || $scope.counts;
},
(err) => {
console.error(err);
}
);
}
getQueues();
// auto-refresh every 5 seconds while autoRefresh is on
const stop = $interval(() => {
if ($scope.query.autoRefresh) getQueues();
}, 5000);
$scope.$on("$destroy", () => $interval.cancel(stop));
$scope.refreshNow = getQueues;
$scope.removeJob = function (queue, job) {
$http
.delete(`/api/admin/queue/${queue}/${job.id}`, {
params: $scope.query,
})
.then(
(res) => {
getQueues();
},
(err) => {
console.error(err);
}
);
};
$scope.retryJob = function (queue, job) {
$http
.post(`/api/admin/queue/${queue}/${job.id}`, {
params: $scope.query,
})
.then(
(res) => {
getQueues();
},
(err) => {
console.error(err);
}
);
};
let searchClear = null;
$scope.$watch(
"query.search",
() => {
clearTimeout(searchClear);
searchClear = setTimeout(getQueues, 350);
}
);
$scope.$watch("query.state", getQueues);
},
])
.controller("errorsAdminController", [
"$scope",
"$http",
"$location",
"$interval",
function ($scope, $http, $location, $interval) {
$scope.$watch("user.status", () => {
if ($scope.user == null) {
$location.url("/");
}
});
if ($scope.user == null) {
$location.url("/");
}
$scope.entries = [];
$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: "",
bucket: "",
sort: "recent",
group: "code",
autoRefresh: true,
};
$scope.relTime = (iso) => {
if (!iso) return "";
const t = new Date(iso).getTime();
if (isNaN(t)) return iso;
const diff = Math.max(0, Date.now() - t);
const s = Math.floor(diff / 1000);
if (s < 5) return "just now";
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
if (d < 7) return `${d}d ago`;
return new Date(iso).toLocaleDateString();
};
$scope.absTime = (iso) => {
if (!iso) return "";
const d = new Date(iso);
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. Pre-computing
// avoids returning new arrays from template functions each digest
// cycle (which trips Angular's $rootScope:infdig).
// snake_case-ish identifier looking like an error key. Accepts both
// pure lowercase ("repo_not_found") and the mixed-case style this
// codebase uses ("repoId_already_used", "invalid_repoId").
const errorKeyRe = /^[a-zA-Z][a-zA-Z0-9]*(?:_[a-zA-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 detail = (e.raw || []).find(
(a) => a && typeof a === "object" && !Array.isArray(a)
);
if (detail) {
if (detail.message && errorKeyRe.test(detail.message)) {
e.displayMessage = detail.message;
e.displayContext = e.message;
} else if (detail.code && errorKeyRe.test(String(detail.code))) {
e.displayMessage = String(detail.code);
e.displayContext = e.message;
} else if (
detail.name &&
detail.name !== "AnonymousError" &&
detail.name !== "Error"
) {
// Plain JS errors (SyntaxError, TypeError, RangeError, ...) — use
// the class name as the visible code; the original message is
// shown as italic context.
e.displayMessage = detail.name;
e.displayContext = detail.message || e.message;
} else {
e.displayMessage = e.message;
}
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;
// Walk into `cause` to surface the deepest stack — for unhandled
// errors the inner cause is usually the actual JS error frame.
let s = typeof detail.stack === "string" ? detail.stack : null;
let c = detail.cause;
while (!s && c && typeof c === "object") {
if (typeof c.stack === "string") s = c.stack;
c = c.cause;
}
e._stack = s;
} else {
e.displayMessage = e.message;
e._status = null;
e._url = null;
e._stack = null;
}
e._bucket = bucketFor(detail, e.level);
e._detailJson = renderDisplayPayload(e, detail);
return e;
}
// 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));
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);
// AnonymousError.detail() can return a JSON-encoded string for
// structured payloads (e.g. {"repoId":"...","terms":[],"fullName":...}).
// Try to parse it so the renderer can pretty-print it multi-line
// instead of dumping an unreadable escape-soup blob.
let detailValue = detail && detail.detail;
if (typeof detailValue === "string") {
const trimmed = detailValue.trim();
if (trimmed[0] === "{" || trimmed[0] === "[") {
try {
detailValue = JSON.parse(detailValue);
} catch {
/* leave as string */
}
}
}
push("detail", detailValue);
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 prefix = ` ${key} `;
const comma = i < fields.length - 1 ? "," : "";
let val;
if (v && typeof v === "object") {
// Indent continuation lines under the value column so the nested
// object reads like a column instead of breaking flow.
const pad = " ".repeat(prefix.length);
val = JSON.stringify(v, null, 2)
.split("\n")
.map((ln, idx) => (idx === 0 ? ln : pad + ln))
.join("\n");
} else if (typeof v === "number" || typeof v === "boolean") {
val = String(v);
} else {
val = JSON.stringify(v);
}
lines.push(`${prefix}${val}${comma}`);
});
lines.push("}");
return lines.join("\n");
}
// 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) {
// On auto-refresh after the user has paginated ("Load older"),
// request the SAME-sized window from the head so we don't blow away
// their loaded tail. Newer entries take the top, the oldest visible
// ones drop off naturally as the redis list rotates.
const offset = append ? $scope.entries.length : 0;
const limit = append
? $scope.pageSize
: Math.max($scope.pageSize, $scope.entries.length || $scope.pageSize);
$http
.get("/api/admin/errors", { params: { offset, limit } })
.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) => {
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(() => {
if ($scope.query.autoRefresh) load();
}, 5000);
$scope.$on("$destroy", () => $interval.cancel(stop));
$scope.$watch("query.search", recompute);
$scope.$watch("query.bucket", recompute);
$scope.$watch("query.sort", recompute);
$scope.$watch("query.group", recompute);
},
]);