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("
Loading GitHub info for " + repo.repoId + "...
"); $http.get("/api/admin/repos/" + repo.repoId + "/github").then( (res) => { if (w) { w.document.open(); w.document.write( "
" +
                  JSON.stringify(res.data, null, 2).replace(/[<>]/g, (c) => c === "<" ? "<" : ">") +
                  "
" ); w.document.close(); } }, (err) => { const msg = err && err.data ? JSON.stringify(err.data, null, 2) : String(err); if (w) w.document.body.innerHTML = "
" + msg + "
"; } ); }; $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); }, ]);