Files
anonymous_github/public/script/admin.js
T
Thomas DurieuxandClaude Opus 5.5 c044b83541 feat(ui): one consistent design across author, reviewer and admin screens
The UI was uneven: the home page looked finished, but app pages used three
different display typefaces, the anonymize flow changed frame between steps,
and the dashboard, explorer and admin screens each had their own controls,
status colours and edge-case handling.

Make every screen follow one system: shared page header, grid, buttons,
filters and status colours; a repository picker and step trail on anonymize;
a "What reviewers will see" preview of the actual reviewer page; consistent
file headers and viewers for every file type; truthful admin charts and
tables. Fold the accumulated override blocks back into style.css (22%
smaller, no visual change).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 02:22:45 +00:00

2248 lines
89 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { reactive, onScopeDispose } from "vue";
import { createTimers, createListeners } from "./state.js";
import { duration, statusMsg } from "./formatters.js";
// One status filter model for every admin list (and the conference page):
// a single-select segmented control. One rule everywhere: "All N" is every
// record in the list, removed ones included, and selecting it shows exactly
// those N rows. The status chips partition All, so their counts add up to it.
// `flags()` returns the boolean status map the list already filters on.
export const statusGroup = (status) =>
["preparing", "download", "downloading", "downloaded", "queue", "anonymizing"].includes(status) ? "preparing"
: status === "expiring" ? "expired"
: status === "removing" ? "removed"
: status;
export function installStatusSegments(state, { flags, keys, count, onChange = () => {} }) {
state.segView = () => {
const f = flags() || {};
if (keys.every((k) => !!f[k])) return "all";
const on = keys.filter((k) => !!f[k]);
return on.length === 1 ? on[0] : "custom";
};
state.setSegView = (view) => {
const f = flags();
if (!f) return;
for (const k of keys) f[k] = view === "all" ? true : k === view;
onChange();
};
state.segCount = (view) => view === "all" ? keys.reduce((n, k) => n + count(k), 0) : count(view);
// Saved preferences from the old "All hides removed" model come back as a
// mixed selection; fold them into All so one chip is always selected.
if (flags() && state.segView() === "custom") for (const k of keys) flags()[k] = true;
}
// Page setup functions run inside Vue effect scopes.
export const repositoriesAdminController = function (state, http, location) {
const timers = createTimers();
const listen = createListeners();
watchMetaLines(timers, listen);
state.Math = Math;
state.watch("user.status", () => {
if (state.user == null) {
location.url("/");
}
});
if (state.user == null) {
location.url("/");
}
state.repositories = [];
state.total = -1;
state.totalPage = 0;
state.statusCounts = [];
state.totalSize = 0;
state.selected = {};
state.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();
}
};
listen(document, "keydown", searchKeyHandler);
state.on("dispose", () => document.removeEventListener("keydown", searchKeyHandler));
state.clearFilter = (key) => {
if (key === "dateRange") { state.query.dateFrom = ""; state.query.dateTo = ""; }
else state.query[key] = "";
state.query.page = 1;
};
state.chips = [];
const recomputeChips = () => {
const out = [];
if (state.query.owner) out.push({ key: "owner", label: "Owner", value: state.query.owner });
if (state.query.conference) out.push({ key: "conference", label: "Conference", value: state.query.conference });
state.chips = out;
};
state.showStatusMessage = (repo) => {
const msg = repo.statusMessage || "(no message)";
window.prompt(`Status message for ${repo.repoId} (${repo.status}):`, msg);
};
state.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>";
}
);
};
state.statusCountFor = (s) => {
const row = (state.statusCounts || []).find((c) => c._id === s);
return row ? row.count : 0;
};
state.statusStorageFor = (s) => {
const row = (state.statusCounts || []).find((c) => c._id === s);
return row ? row.storage : 0;
};
state.isErrorsOnly = () =>
state.query &&
state.query.error && !state.query.ready && !state.query.preparing &&
!state.query.expired && !state.query.removed;
state.toggleErrorsOnly = () => {
if (state.isErrorsOnly()) {
Object.assign(state.query, { ready: false, preparing: true, expired: false, removed: false, error: true });
} else {
Object.assign(state.query, { ready: false, preparing: false, expired: false, removed: false, error: true });
}
state.query.page = 1;
};
state.toggleSortDirection = () => {
state.query.direction = state.query.direction === "asc" ? "desc" : "asc";
};
state.sortBy = (field) => {
if (state.query.sort === field) {
state.query.direction = state.query.direction === "asc" ? "desc" : "asc";
} else {
state.query.sort = field;
state.query.direction = "desc";
}
state.query.page = 1;
};
state.sortIcon = (field) =>
state.query.sort === field
? (state.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: true,
expired: true,
removed: true,
error: true,
preparing: true,
};
const savedReposAdminPrefs = loadFilterPrefs(reposAdminPrefsKey) || {};
state.query = Object.assign({}, reposAdminDefaults, savedReposAdminPrefs, {
page: 1,
search: "",
});
// pre-fill filters from URL ?owner= / ?conference= / ?search=
const urlParams = location.search();
if (urlParams.owner) state.query.owner = urlParams.owner;
if (urlParams.conference) state.query.conference = urlParams.conference;
if (urlParams.search) state.query.search = urlParams.search;
// -------- presets --------
const presetsKey = "admin.repos.presets";
state.presets = JSON.parse(localStorage.getItem(presetsKey) || "[]");
state.savePreset = () => {
const name = window.prompt("Preset name:");
if (!name) return;
const snapshot = Object.assign({}, state.query);
delete snapshot.page;
state.presets = (state.presets || []).filter((p) => p.name !== name);
state.presets.push({ name, query: snapshot });
localStorage.setItem(presetsKey, JSON.stringify(state.presets));
};
state.applyPreset = (p) => {
Object.assign(state.query, p.query, { page: 1 });
};
state.deletePreset = (p) => {
state.presets = (state.presets || []).filter((x) => x.name !== p.name);
localStorage.setItem(presetsKey, JSON.stringify(state.presets));
};
// -------- selection / bulk --------
state.selectAllOnPage = () => {
state.allSelected = !state.allSelected;
state.repositories.forEach((r) => {
state.selected[r.repoId] = state.allSelected;
});
};
state.selectedCount = () =>
Object.values(state.selected || {}).filter(Boolean).length;
state.selectedRepos = () =>
state.repositories.filter((r) => state.selected[r.repoId]);
state.bulkRefresh = () => {
const repos = state.selectedRepos();
if (!repos.length) return;
if (!confirm(`Force refresh ${repos.length} repositories?`)) return;
repos.forEach((r) => state.updateRepository(r));
};
state.bulkRemoveCache = () => {
const repos = state.selectedRepos();
if (!repos.length) return;
if (!confirm(`Purge cache for ${repos.length} repositories?`)) return;
repos.forEach((r) => state.removeCache(r));
};
state.clearSelection = () => {
state.selected = {};
state.allSelected = false;
};
// -------- export --------
state.exportCsv = () => {
const params = new URLSearchParams(
Object.entries(state.query).filter(([, v]) => v !== "" && v !== false && v != null)
);
params.set("format", "csv");
params.set("limit", "10000");
window.open("/api/admin/repos?" + params.toString(), "_blank");
};
state.removeCache = (repo) => {
if (!confirm("Remove cached files for " + repo.repoId + "?")) return;
http.delete("/api/admin/repos/" + repo.repoId).then(
() => getRepositories(),
(err) => console.error(err)
);
};
state.removeRepository = (repo) => {
if (!confirm("Remove repository " + repo.repoId + "?")) return;
http.delete("/api/repo/" + repo.repoId + "/").then(
() => getRepositories(),
(err) => console.error(err)
);
};
state.updateRepository = (repo) => {
const toast = reactive({
title: `Refreshing ${repo.repoId}...`,
date: new Date(),
body: `The repository ${repo.repoId} is going to be refreshed.`,
});
state.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;
}
);
};
state.filtersOpen = false;
state.fetchError = null;
function getRepositories() {
state.fetchError = null;
http.get("/api/admin/repos", { params: state.query }).then(
(res) => {
state.total = res.data.total;
state.totalPage = Math.ceil(res.data.total / state.query.limit);
state.repositories = res.data.results;
state.allSelected = false;
},
(err) => {
state.fetchError = (err && err.data && err.data.error) || "Failed to load repositories";
console.error(err);
}
);
}
// Status chips always show real totals: the list endpoint counts only within the
// active status filter, so ask again with every status enabled (one row).
function getStatusCounts() {
const params = Object.assign({}, state.query, { page: 1, limit: 1, ready: true, expired: true, removed: true, error: true, preparing: true });
http.get("/api/admin/repos", { params }).then((res) => {
state.statusCounts = res.data.statusCounts || [];
state.totalSize = res.data.totalSize || 0;
}, () => {});
}
state.showAllStatuses = () => Object.assign(state.query, { ready: true, expired: true, removed: true, error: true, preparing: true, page: 1 });
installStatusSegments(state, {
flags: () => state.query,
keys: ["ready", "preparing", "error", "expired", "removed"],
count: (k) => k === "preparing" ? state.statusCountFor("preparing") + state.statusCountFor("download")
: k === "expired" ? state.statusCountFor("expired") + state.statusCountFor("expiring")
: k === "removed" ? state.statusCountFor("removed") + state.statusCountFor("removing")
: state.statusCountFor(k),
onChange: () => { state.query.page = 1; },
});
getRepositories();
getStatusCounts();
let timeClear = null;
state.watch(
"query",
() => {
timers.timeout.cancel(timeClear);
timeClear = timers.timeout(() => { getRepositories(); getStatusCounts(); }, 500);
const { page, search, ...persisted } = state.query;
saveFilterPrefs(reposAdminPrefsKey, persisted);
recomputeChips();
},
true
);
recomputeChips();
};
export const usersAdminController = function (state, http, location) {
const timers = createTimers();
const listen = createListeners();
state.Math = Math;
state.watch("user.status", () => {
if (state.user == null) {
location.url("/");
}
});
if (state.user == null) {
location.url("/");
}
state.users = [];
state.total = -1;
state.totalPage = 0;
state.statusCounts = [];
state.selected = {};
state.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();
}
};
listen(document, "keydown", searchKeyHandler);
state.on("dispose", () => document.removeEventListener("keydown", searchKeyHandler));
state.clearFilter = (key) => {
if (key === "dateRange") { state.query.dateFrom = ""; state.query.dateTo = ""; }
else state.query[key] = "";
state.query.page = 1;
};
state.chips = [];
const recomputeChipsUsers = () => {
const out = [];
if (state.query.role) out.push({ key: "role", label: "Role", value: state.query.role });
state.chips = out;
};
state.statusCountFor = (s) => {
const row = (state.statusCounts || []).find((c) => c._id === s);
return row ? row.count : 0;
};
state.toggleSortDirection = () => {
state.query.direction = state.query.direction === "asc" ? "desc" : "asc";
};
state.sortBy = (field) => {
if (state.query.sort === field) {
state.query.direction = state.query.direction === "asc" ? "desc" : "asc";
} else {
state.query.sort = field;
state.query.direction = "desc";
}
state.query.page = 1;
};
state.sortIcon = (field) =>
state.query.sort === field
? (state.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) || {};
state.query = Object.assign({}, usersAdminDefaults, savedUsersAdminPrefs, {
page: 1,
search: "",
});
state.selectAllOnPage = () => {
state.allSelected = !state.allSelected;
state.users.forEach((u) => {
state.selected[u.username] = state.allSelected;
});
};
state.selectedCount = () =>
Object.values(state.selected || {}).filter(Boolean).length;
state.selectedUsers = () =>
state.users.filter((u) => state.selected[u.username]);
state.banUser = (u) => {
if (!confirm(`Ban user ${u.username}?`)) return;
http
.post(`/api/admin/users/${u.username}/ban`)
.then(getUsers, (err) => console.error(err));
};
state.activateUser = (u) => {
http
.post(`/api/admin/users/${u.username}/activate`)
.then(getUsers, (err) => console.error(err));
};
state.bulkBan = () => {
const users = state.selectedUsers();
if (!users.length) return;
if (!confirm(`Ban ${users.length} users?`)) return;
users.forEach((u) => state.banUser(u));
};
state.exportCsv = () => {
const params = new URLSearchParams(
Object.entries(state.query).filter(([, v]) => v !== "" && v !== false && v != null)
);
params.set("format", "csv");
params.set("limit", "10000");
window.open("/api/admin/users?" + params.toString(), "_blank");
};
state.fetchError = null;
function getUsers() {
state.fetchError = null;
http.get("/api/admin/users", { params: state.query }).then(
(res) => {
state.total = res.data.total;
state.totalPage = Math.ceil(res.data.total / state.query.limit);
state.users = res.data.results;
state.statusCounts = res.data.statusCounts || [];
state.allSelected = false;
},
(err) => {
state.fetchError = (err && err.data && err.data.error) || "Failed to load users";
console.error(err);
}
);
}
getUsers();
let timeClear = null;
state.watch(
"query",
() => {
timers.timeout.cancel(timeClear);
timeClear = timers.timeout(getUsers, 500);
const { page, search, ...persisted } = state.query;
saveFilterPrefs(usersAdminPrefsKey, persisted);
recomputeChipsUsers();
},
true
);
recomputeChipsUsers();
};
export const userAdminController = function (state, http, location, params) {
const timers = createTimers();
watchMetaLines(timers, createListeners());
state.watch("user.status", () => {
if (state.user == null) {
location.url("/");
}
});
if (state.user == null) {
location.url("/");
}
state.userInfo;
state.repositories = [];
state.search = "";
state.selected = {};
state.allSelected = false;
const adminUserPrefsKey = "admin.user.filterPrefs";
const adminUserDefaults = {
filters: { status: { ready: true, expired: true, removed: true, error: true, preparing: true } },
sort: "anonymizeDate",
direction: "desc",
};
const savedAdminUserPrefs = loadFilterPrefs(adminUserPrefsKey) || {};
state.filters = {
status: Object.assign(
{},
adminUserDefaults.filters.status,
(savedAdminUserPrefs.filters && savedAdminUserPrefs.filters.status) || {}
),
};
state.query = {
sort: savedAdminUserPrefs.sort || adminUserDefaults.sort,
direction: savedAdminUserPrefs.direction || adminUserDefaults.direction,
};
state.orderBy = (state.query.direction === "asc" ? "" : "-") + state.query.sort;
state.sortBy = (field) => {
if (state.query.sort === field) {
state.query.direction = state.query.direction === "asc" ? "desc" : "asc";
} else {
state.query.sort = field;
state.query.direction = "desc";
}
state.orderBy = (state.query.direction === "asc" ? "" : "-") + state.query.sort;
};
state.sortIcon = (field) =>
state.query.sort === field
? (state.query.direction === "asc" ? "fa-arrow-up" : "fa-arrow-down")
: "";
state.watch("query", () => {
saveFilterPrefs(adminUserPrefsKey, {
filters: state.filters,
sort: state.query.sort,
direction: state.query.direction,
});
}, true);
state.watch(
"filters",
() => {
saveFilterPrefs(adminUserPrefsKey, {
filters: state.filters,
sort: state.query.sort,
direction: state.query.direction,
});
},
true
);
state.statusCountFor = (s) => {
return (state.repositories || []).filter((r) => statusGroup(r.status) === s).length;
};
installStatusSegments(state, {
flags: () => state.filters.status,
keys: ["ready", "preparing", "error", "expired", "removed"],
count: (k) => state.statusCountFor(k),
});
state.repoFiler = (repo) => {
if (state.filters.status[statusGroup(repo.status)] == false) return false;
if (state.search.trim().length == 0) return true;
if (repo.source.fullName.indexOf(state.search) > -1) return true;
if (repo.repoId.indexOf(state.search) > -1) return true;
if (repo.statusMessage && repo.statusMessage.indexOf(state.search) > -1) return true;
if (repo.conference && repo.conference.indexOf(state.search) > -1) return true;
return false;
};
// -------- selection / bulk --------
state.selectAllOnPage = () => {
state.allSelected = !state.allSelected;
(state.filteredRepositories || state.repositories).forEach((r) => {
state.selected[r.repoId] = state.allSelected;
});
};
state.selectedCount = () =>
Object.values(state.selected || {}).filter(Boolean).length;
state.selectedRepos = () =>
state.repositories.filter((r) => state.selected[r.repoId]);
state.bulkRefresh = () => {
const repos = state.selectedRepos();
if (!repos.length) return;
if (!confirm(`Force refresh ${repos.length} repositories?`)) return;
repos.forEach((r) => state.updateRepository(r));
};
state.bulkRemoveCache = () => {
const repos = state.selectedRepos();
if (!repos.length) return;
if (!confirm(`Purge cache for ${repos.length} repositories?`)) return;
repos.forEach((r) => state.removeCache(r));
};
state.clearSelection = () => {
state.selected = {};
state.allSelected = false;
};
// -------- export --------
state.exportCsv = () => {
const filtered = (state.filteredRepositories || state.repositories);
const columns = ["repoId", "status", "statusMessage", "pageView", "anonymizeDate", "source.fullName", "conference", "size.storage"];
const header = columns.join(",");
const rows = filtered.map((r) =>
[r.repoId, r.status, r.statusMessage || "", r.pageView || 0, r.anonymizeDate || "", (r.source && r.source.fullName) || "", r.conference || "", (r.size && r.size.storage) || 0]
.map((v) => { const s = String(v == null ? "" : v); return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; })
.join(",")
);
const blob = new Blob([header + "\n" + rows.join("\n")], { type: "text/csv" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = params.username + "-repositories.csv";
a.click();
};
state.showStatusMessage = (repo) => {
const msg = repo.statusMessage || "(no message)";
window.prompt(`Status message for ${repo.repoId} (${repo.status}):`, msg);
};
state.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>";
}
);
};
function getUserRepositories(username) {
http.get("/api/admin/users/" + username + "/repos", {}).then(
(res) => {
state.repositories = res.data;
},
(err) => {
console.error(err);
}
);
}
function getUser(username) {
http.get("/api/admin/users/" + username, {}).then(
(res) => {
state.userInfo = res.data;
},
(err) => {
console.error(err);
}
);
}
getUser(params.username);
getUserRepositories(params.username);
state.banUser = () => {
if (!confirm(`Ban user ${params.username}?`)) return;
http
.post(`/api/admin/users/${params.username}/ban`)
.then(() => getUser(params.username), (err) => console.error(err));
};
state.activateUser = () => {
http
.post(`/api/admin/users/${params.username}/activate`)
.then(() => getUser(params.username), (err) => console.error(err));
};
state.promoteUser = () => {
if (!confirm(`Promote ${params.username} to admin?`)) return;
http
.post(`/api/admin/users/${params.username}/promote`)
.then(() => getUser(params.username), (err) => console.error(err));
};
state.demoteUser = () => {
if (!confirm(`Remove admin privileges from ${params.username}?`)) return;
http
.post(`/api/admin/users/${params.username}/demote`)
.then(() => getUser(params.username), (err) => console.error(err));
};
state.tokens = [];
state.tokenForm = { name: "", plaintext: null };
function loadTokens() {
http.get("/api/admin/tokens").then(
(res) => {
state.tokens = res.data || [];
},
(err) => {
if (err.status !== 401 && err.status !== 403) console.error(err);
}
);
}
loadTokens();
state.createToken = () => {
if (!state.tokenForm.name) return;
http
.post("/api/admin/tokens", { name: state.tokenForm.name })
.then(
(res) => {
state.tokenForm.plaintext = res.data.token;
state.tokenForm.name = "";
loadTokens();
},
(err) => console.error(err)
);
};
state.revokeToken = (t) => {
if (!confirm(`Revoke token "${t.name}"?`)) return;
http.delete("/api/admin/tokens/" + t.id).then(
() => loadTokens(),
(err) => console.error(err)
);
};
state.removeCache = (repo) => {
if (!confirm("Remove cached files for " + repo.repoId + "?")) return;
http.delete("/api/admin/repos/" + repo.repoId).then(
() => getUserRepositories(params.username),
(err) => console.error(err)
);
};
state.removeRepository = (repo) => {
if (!confirm("Remove repository " + repo.repoId + "?")) return;
http.delete("/api/repo/" + repo.repoId + "/").then(
() => getUserRepositories(params.username),
(err) => console.error(err)
);
};
state.updateRepository = (repo) => {
const toast = reactive({
title: `Refreshing ${repo.repoId}...`,
date: new Date(),
body: `The repository ${repo.repoId} is going to be refreshed.`,
});
state.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;
}
);
};
state.getGitHubRepositories = (force) => {
http
.get(`/api/user/${state.userInfo.username}/all_repositories`, {
params: { force: "1" },
})
.then((res) => {
state.userInfo.repositories = res.data;
});
};
let timeClear = null;
state.watch(
"query",
() => {
timers.timeout.cancel(timeClear);
timeClear = timers.timeout(() => {
getUserRepositories(params.username);
}, 500);
},
true
);
};
export const conferencesAdminController = function (state, http, location) {
const timers = createTimers();
const listen = createListeners();
state.Math = Math;
state.watch("user.status", () => {
if (state.user == null) {
location.url("/");
}
});
if (state.user == null) {
location.url("/");
}
state.conferences = [];
state.total = -1;
state.totalPage = 0;
state.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();
}
};
listen(document, "keydown", searchKeyHandler);
state.on("dispose", () => document.removeEventListener("keydown", searchKeyHandler));
state.clearFilter = (key) => {
if (key === "dateRange") { state.query.dateFrom = ""; state.query.dateTo = ""; }
else state.query[key] = "";
state.query.page = 1;
};
state.chips = [];
const recomputeChipsConf = () => {
const out = [];
if (state.query.dateFrom || state.query.dateTo) out.push({ key: "dateRange", label: "Date", value: (state.query.dateFrom || "…") + " – " + (state.query.dateTo || "…") });
state.chips = out;
};
state.statusCountFor = (s) => {
const row = (state.statusCounts || []).find((c) => c._id === s);
return row ? row.count : 0;
};
state.toggleSortDirection = () => {
state.query.direction = state.query.direction === "asc" ? "desc" : "asc";
};
state.sortBy = (field) => {
if (state.query.sort === field) {
state.query.direction = state.query.direction === "asc" ? "desc" : "asc";
} else {
state.query.sort = field;
state.query.direction = "desc";
}
state.query.page = 1;
};
state.sortIcon = (field) =>
state.query.sort === field
? (state.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: "",
dateFrom: "",
dateTo: "",
ready: true,
expired: true,
removed: true,
error: true,
preparing: true,
};
const savedConfAdminPrefs = loadFilterPrefs(confAdminPrefsKey) || {};
state.query = Object.assign({}, confAdminDefaults, savedConfAdminPrefs, {
page: 1,
search: "",
});
// pre-fill filters from URL ?search=
const urlParams = location.search();
if (urlParams.search) state.query.search = urlParams.search;
// -------- presets --------
const confPresetsKey = "admin.conferences.presets";
state.presets = JSON.parse(localStorage.getItem(confPresetsKey) || "[]");
state.savePreset = () => {
const name = window.prompt("Preset name:");
if (!name) return;
const snapshot = Object.assign({}, state.query);
delete snapshot.page;
state.presets = (state.presets || []).filter((p) => p.name !== name);
state.presets.push({ name, query: snapshot });
localStorage.setItem(confPresetsKey, JSON.stringify(state.presets));
};
state.applyPreset = (p) => {
Object.assign(state.query, p.query, { page: 1 });
};
state.deletePreset = (p) => {
state.presets = (state.presets || []).filter((x) => x.name !== p.name);
localStorage.setItem(confPresetsKey, JSON.stringify(state.presets));
};
state.removeConference = (conference) => {
if (!confirm("Remove conference " + conference.conferenceID + "?")) return;
http.delete("/api/admin/conferences/" + conference.conferenceID).then(
() => getConferences(),
(err) => console.error(err)
);
};
state.exportCsv = () => {
const params = new URLSearchParams(
Object.entries(state.query).filter(([, v]) => v !== "" && v !== false && v != null)
);
params.set("format", "csv");
params.set("limit", "10000");
window.open("/api/admin/conferences?" + params.toString(), "_blank");
};
state.fetchError = null;
function getConferences() {
state.fetchError = null;
http.get("/api/admin/conferences", { params: state.query }).then(
(res) => {
state.total = res.data.total;
state.totalPage = Math.ceil(res.data.total / state.query.limit);
state.conferences = res.data.results;
state.statusCounts = res.data.statusCounts || [];
},
(err) => {
state.fetchError = (err && err.data && err.data.error) || "Failed to load conferences";
console.error(err);
}
);
}
installStatusSegments(state, {
flags: () => state.query,
keys: ["ready", "preparing", "error", "expired", "removed"],
count: (k) => state.statusCountFor(k),
onChange: () => { state.query.page = 1; },
});
getConferences();
let timeClear = null;
state.watch(
"query",
() => {
timers.timeout.cancel(timeClear);
timeClear = timers.timeout(getConferences, 500);
const { page, search, ...persisted } = state.query;
saveFilterPrefs(confAdminPrefsKey, persisted);
recomputeChipsConf();
},
true
);
recomputeChipsConf();
};
export const queuesAdminController = function (state, http, location, interval, timeout) {
const timers = createTimers();
state.watch("user.status", () => {
if (state.user == null) location.url("/");
});
if (state.user == null) location.url("/");
state.queueList = [];
state.jobs = [];
state.selectedQueue = "download";
state.selectedStats = null;
state.range = "1h";
state.allStates = ["active", "waiting", "delayed", "failed", "completed"];
state.stateFilter = { active: true, waiting: true, delayed: true, failed: true, completed: true };
installStatusSegments(state, {
flags: () => state.stateFilter,
keys: state.allStates,
count: (k) => (state.jobs || []).filter((j) => j._state === k).length,
});
state.query = {
search: "",
autoRefresh: true,
};
state.filteredJobs = () => {
return (state.jobs || []).filter((j) => state.stateFilter[j._state]);
};
state.jobProgressPct = (job) => {
// A completed job is done whatever its last reported progress was (an empty bar beside "Completed" reads as stalled).
if (job?._state === "completed") return 100;
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;
};
// The Detail column: the stage an active job reports, or why a failed job failed; a dash otherwise.
const JOB_STAGES = { get_files: "Fetching files", download: "Downloading", downloading: "Downloading", anonymize: "Anonymizing", anonymizing: "Anonymizing", ready: "Ready", removing: "Removing", preparing: "Preparing" };
state.jobDetail = (job) => {
if (job?._state === "failed") return statusMsg(job.failedReason || "") || "Failed";
const stage = job?.progress && typeof job.progress === "object" ? job.progress.status : null;
if (job?._state === "active" && stage) return JOB_STAGES[stage] || statusMsg(stage);
return "—";
};
state.jobDuration = (job) => {
if (!job.processedOn) return "—";
const end = job.finishedOn || Date.now();
return duration(end - job.processedOn);
};
state.metricsPoints = [];
state.selectQueue = (key) => {
state.selectedQueue = key;
getQueues();
getMetrics();
};
state.setRange = (r) => {
state.range = r;
getMetrics();
};
function getQueues() {
const params = {
queue: state.selectedQueue,
search: state.query.search,
};
http.get("/api/admin/queues", { params }).then(
(res) => {
state.queueList = res.data.queues || [];
state.jobs = res.data.jobs || [];
state.selectedStats = state.queueList.find((q) => q.key === state.selectedQueue) || state.queueList[0] || null;
},
(err) => console.error(err)
);
}
function getMetrics() {
http.get("/api/admin/queues/metrics", {
params: { queue: state.selectedQueue, range: state.range }
}).then(
(res) => {
state.metricsPoints = res.data.points || [];
timeout(drawChart, 0);
},
(err) => console.error(err)
);
}
getQueues();
getMetrics();
const stop = interval(() => {
if (state.query.autoRefresh) {
getQueues();
getMetrics();
}
}, 15000);
state.on("dispose", () => interval.cancel(stop));
state.refreshNow = function () { getQueues(); getMetrics(); };
function apiError(err) {
const msg = (err && err.data && (err.data.message || err.data.error)) || "Request failed";
state.actionError = msg;
timeout(() => { state.actionError = null; }, 5000);
console.error(err);
}
state.actionError = null;
state.removeJob = (job) => {
http.delete(`/api/admin/queue/${state.selectedQueue}/${job.id}`).then(getQueues, apiError);
};
state.retryJob = (job) => {
http.post(`/api/admin/queue/${state.selectedQueue}/${job.id}`).then(getQueues, apiError);
};
state.retryFailed = () => {
if (!confirm(`Retry all failed jobs in ${state.selectedQueue}?`)) return;
http.post(`/api/admin/queue/${state.selectedQueue}/retry-failed`).then(getQueues, (err) => console.error(err));
};
state.drainSelected = () => {
if (!confirm(`Drain the ${state.selectedQueue} queue?`)) return;
http.post(`/api/admin/queue/${state.selectedQueue}/drain`).then(getQueues, (err) => console.error(err));
};
state.togglePause = () => {
const action = state.selectedStats && state.selectedStats.paused ? "resume" : "pause";
http.post(`/api/admin/queue/${state.selectedQueue}/${action}`).then(getQueues, (err) => console.error(err));
};
state.emptyQueue = () => {
if (!confirm(`Empty the ${state.selectedQueue} queue? This removes ALL jobs.`)) return;
http.post(`/api/admin/queue/${state.selectedQueue}/empty`).then(getQueues, (err) => console.error(err));
};
state.pauseAll = () => {
if (!confirm("Pause all queues?")) return;
http.post("/api/admin/queues/pause-all").then(getQueues, (err) => console.error(err));
};
let searchClear = null;
state.watch("query.search", () => {
timers.timeout.cancel(searchClear);
searchClear = timers.timeout(getQueues, 350);
});
state.expanded = {};
state.toggleJob = (job) => {
state.expanded[job.id] = !state.expanded[job.id];
};
state.humanTime = (ts) => {
if (!ts) return "";
const d = new Date(ts);
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false })
+ " " + d.toLocaleDateString([], { month: "short", day: "numeric" });
};
state.delayCountdown = (ts) => {
if (!ts) return "";
var remaining = Math.max(0, Math.ceil((ts - Date.now()) / 1000));
if (remaining <= 0) return "resuming soon";
var min = Math.floor(remaining / 60);
var sec = remaining % 60;
return "in " + (min > 0 ? min + "m " + sec + "s" : sec + "s");
};
function niceScale(max) {
if (max <= 0) return { ticks: [0], niceMax: 1 };
const mag = Math.pow(10, Math.floor(Math.log10(max)));
let step = mag;
if (max / step < 2) step = mag / 2;
else if (max / step > 5) step = mag * 2;
const niceMax = Math.ceil(max / step) * step;
const ticks = [];
for (let v = 0; v <= niceMax; v += step) ticks.push(v);
return { ticks, niceMax };
}
function drawChart() {
var canvas = document.getElementById("q-throughput-chart");
if (!canvas) return;
var ctx = canvas.getContext("2d");
var dpr = window.devicePixelRatio || 1;
var rect = canvas.parentElement.getBoundingClientRect();
var marginLeft = 44;
var marginRight = 44;
var marginBottom = 22;
// The plot fills its card, which stretches to the height of the stats panel beside it.
var totalW = Math.max(200, Math.round(rect.width));
var totalH = Math.max(180, Math.min(420, Math.round(rect.height) || 180));
var w = totalW - marginLeft - marginRight;
var h = totalH - marginBottom;
canvas.width = totalW * dpr;
canvas.height = totalH * dpr;
canvas.style.width = totalW + "px";
canvas.style.height = totalH + "px";
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
var isDark = document.body.classList.contains("dark-mode");
var labelColor = "#8A857C";
var gridColor = isDark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.06)";
var completedColor = isDark ? "#6FBF8A" : "#2F7D4F";
var completedFill = isDark ? "rgba(111,191,138,0.12)" : "rgba(47,125,79,0.08)";
var failedColor = isDark ? "#F08A82" : "#B42318";
var failedFill = isDark ? "rgba(240,138,130,0.08)" : "rgba(180,35,24,0.06)";
// One token drives the time line, its seconds axis and its legend key (style.css: --q-time-series).
var execColor = getComputedStyle(document.body).getPropertyValue("--q-time-series").trim() || (isDark ? "#E0A94A" : "#946512");
var pts = state.metricsPoints || [];
if (pts.length === 0) {
ctx.fillStyle = labelColor;
ctx.font = "13px Inter, system-ui, sans-serif";
ctx.textAlign = "center";
ctx.fillText("No metrics data yet", totalW / 2, totalH / 2);
chartState = null;
return;
}
// Data is oldest→newest from the API; chart shows newest on the right
var completedPts = pts.map(function (p) { return p.completed; });
var failedPts = pts.map(function (p) { return p.failed; });
var execPts = pts.map(function (p) { return p.avgMs; });
if (!completedPts.some(Boolean) && !failedPts.some(Boolean)) {
ctx.strokeStyle = isDark ? "rgba(255,255,255,0.14)" : "rgba(0,0,0,0.12)";
ctx.beginPath(); ctx.moveTo(0, totalH - 18.5); ctx.lineTo(totalW, totalH - 18.5); ctx.stroke();
ctx.fillStyle = isDark ? "#CFC8BD" : "#4F4A42";
ctx.font = "500 14px Inter, system-ui, sans-serif";
ctx.textAlign = "center";
ctx.fillText("No jobs processed in this window", totalW / 2, totalH / 2 - 6);
ctx.fillStyle = labelColor;
ctx.font = "12.5px Inter, system-ui, sans-serif";
ctx.fillText("Completed and failed jobs will chart here", totalW / 2, totalH / 2 + 16);
chartState = null;
return;
}
var maxLen = pts.length;
var step = w / (maxLen - 1 || 1);
// Left Y-axis: jobs/min
var rawMax = Math.max(1, Math.max.apply(null, completedPts), Math.max.apply(null, failedPts));
var left = niceScale(rawMax);
// Right Y-axis: avg exec time (ms)
var execMax = Math.max.apply(null, execPts);
var right = execMax > 0 ? niceScale(execMax) : { ticks: [0], niceMax: 1 };
var toY = function (v) { return h - (v / left.niceMax) * (h - 10); };
var toYr = function (v) { return h - (v / right.niceMax) * (h - 10); };
var toX = function (i) { return marginLeft + i * step; };
// Grid + left Y-axis labels (jobs/min)
ctx.textAlign = "right";
ctx.textBaseline = "middle";
ctx.font = "11px Inter, system-ui, sans-serif";
left.ticks.forEach(function (v) {
var y = toY(v);
ctx.strokeStyle = gridColor;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(marginLeft, y);
ctx.lineTo(totalW - marginRight, y);
ctx.stroke();
ctx.fillStyle = labelColor;
// The origin is implied by the baseline; a "0" here would collide with the first time label.
if (v !== 0) ctx.fillText(v >= 1000 ? (v / 1000).toFixed(1) + "k" : String(v), marginLeft - 6, y);
});
// Right Y-axis labels (ms)
if (execMax > 0) {
ctx.textAlign = "left";
right.ticks.forEach(function (v) {
var y = toYr(v);
ctx.fillStyle = execColor;
if (v !== 0) ctx.fillText(duration(v), totalW - marginRight + 6, y);
});
}
// X-axis time labels: "now" under the newest point, then whole steps back (1, 2, 5, 10, 15, 30 min, 1, 2, 3,
// 6, 12 h, 1 d) labelled "<n> ago". Each tick sits on the data point nearest its time, and its label is that
// point's real age rounded to the step, so a label never claims a time the line doesn't reach.
var tEnd = pts[maxLen - 1].ts, tStart = pts[0].ts;
var spanMin = Math.max(0, (tEnd - tStart) / 60000);
var niceSteps = [1, 2, 5, 10, 15, 30, 60, 120, 180, 360, 720, 1440, 2880, 10080];
var stepMin = niceSteps.find(function (m) { return spanMin / m <= 4; }) || niceSteps[niceSteps.length - 1];
var fmtSpan = function (m) { return m < 60 ? m + "m" : m < 1440 ? (m / 60) + "h" : (m / 1440) + "d"; };
var nearestIndex = function (t) {
var best = 0;
for (var j = 1; j < maxLen; j++) if (Math.abs(pts[j].ts - t) < Math.abs(pts[best].ts - t)) best = j;
return best;
};
var tolMs = Math.max((tEnd - tStart) / Math.max(1, maxLen - 1) / 2, (tEnd - tStart) * 0.03);
ctx.textBaseline = "top";
ctx.fillStyle = labelColor;
var lastX = Infinity;
for (var k = 0; k * stepMin * 60000 <= (tEnd - tStart) + tolMs && k < 8; k++) {
var m = k * stepMin;
var idx = nearestIndex(tEnd - m * 60000);
if (k > 0 && Math.abs(pts[idx].ts - (tEnd - m * 60000)) > tolMs + 1) continue;
var x = toX(idx);
var label = m === 0 ? "now" : fmtSpan(m) + " ago";
var width = ctx.measureText(label).width;
var align = k === 0 ? "right" : idx === 0 ? "left" : "center";
var labelRight = align === "right" ? x : align === "left" ? x + width : x + width / 2;
if (labelRight > lastX - 12) continue;
ctx.textAlign = align;
ctx.fillText(label, x, h + 6);
lastX = align === "right" ? x - width : align === "left" ? x : x - width / 2;
}
function drawArea(data, yFn, fillStyle, strokeStyle) {
if (data.length === 0) return;
ctx.beginPath();
ctx.moveTo(toX(0), h);
data.forEach(function (v, i) {
var x = toX(i), y = yFn(v);
if (i === 0) ctx.lineTo(x, y);
else {
var cx = (toX(i - 1) + x) / 2;
ctx.bezierCurveTo(cx, yFn(data[i - 1]), cx, y, x, y);
}
});
ctx.lineTo(toX(data.length - 1), h);
ctx.closePath();
ctx.fillStyle = fillStyle;
ctx.fill();
ctx.beginPath();
data.forEach(function (v, i) {
var x = toX(i), y = yFn(v);
if (i === 0) ctx.moveTo(x, y);
else {
var cx = (toX(i - 1) + x) / 2;
ctx.bezierCurveTo(cx, yFn(data[i - 1]), cx, y, x, y);
}
});
ctx.strokeStyle = strokeStyle;
ctx.lineWidth = 1.5;
ctx.stroke();
}
drawArea(completedPts, toY, completedFill, completedColor);
drawArea(failedPts, toY, failedFill, failedColor);
// Exec time as a line only (no fill) on the right axis
if (execMax > 0) {
ctx.beginPath();
execPts.forEach(function (v, i) {
var x = toX(i), y = toYr(v);
if (i === 0) ctx.moveTo(x, y);
else {
var cx = (toX(i - 1) + x) / 2;
ctx.bezierCurveTo(cx, toYr(execPts[i - 1]), cx, y, x, y);
}
});
ctx.strokeStyle = execColor;
ctx.lineWidth = 1;
ctx.setLineDash([4, 3]);
ctx.stroke();
ctx.setLineDash([]);
}
chartState = { pts: pts, maxLen: maxLen, marginLeft: marginLeft, step: step, totalW: totalW, toX: toX };
}
var chartState = null;
function setupTooltip() {
var canvas = document.getElementById("q-throughput-chart");
if (!canvas || canvas._tipBound) return;
canvas._tipBound = true;
var tooltip = document.getElementById("q-chart-tooltip");
var crosshair = document.getElementById("q-chart-crosshair");
canvas.addEventListener("mousemove", function (e) {
if (!chartState || !tooltip || !crosshair) return;
var cs = chartState;
var rect = canvas.getBoundingClientRect();
var mx = e.clientX - rect.left;
var idx = Math.round((mx - cs.marginLeft) / cs.step);
if (idx < 0 || idx >= cs.maxLen) {
tooltip.style.display = "none";
crosshair.style.display = "none";
return;
}
var p = cs.pts[idx];
var now = cs.pts[cs.pts.length - 1].ts; // same origin as the axis: the series end is "now"
var minsAgo = Math.round((now - p.ts) / 60000);
var timeLabel;
if (minsAgo <= 0) timeLabel = "now";
else if (minsAgo < 60) timeLabel = minsAgo + "m ago";
else if (minsAgo < 1440) {
var hrs = Math.floor(minsAgo / 60);
var mins = minsAgo % 60;
timeLabel = hrs + "h" + (mins ? " " + mins + "m" : "") + " ago";
} else timeLabel = Math.round(minsAgo / 1440) + "d ago";
var html =
'<div class="q-tip-time">' + timeLabel + '</div>' +
'<div class="q-tip-completed">&#9679; completed: ' + p.completed + '/min</div>' +
'<div class="q-tip-failed">&#9679; failed: ' + p.failed + '/min</div>';
if (p.avgMs > 0) {
var dur = duration(p.avgMs);
html += '<div class="q-tip-exec">&#9679; avg time: ' + dur + '</div>';
}
tooltip.innerHTML = html;
var xPos = cs.toX(idx);
var tipW = tooltip.offsetWidth;
var tipLeft = xPos + 10;
if (tipLeft + tipW > cs.totalW) tipLeft = xPos - tipW - 10;
tooltip.style.display = "block";
tooltip.style.left = tipLeft + "px";
tooltip.style.top = "8px";
crosshair.style.display = "block";
crosshair.style.left = xPos + "px";
});
canvas.addEventListener("mouseleave", function () {
if (tooltip) tooltip.style.display = "none";
if (crosshair) crosshair.style.display = "none";
});
}
state.watch("metricsPoints", function () {
timeout(setupTooltip, 50);
});
};
export const errorsAdminController = function (state, http, location, interval) {
const timers = createTimers();
state.watch("user.status", () => {
if (state.user == null) {
location.url("/");
}
});
if (state.user == null) {
location.url("/");
}
state.entries = [];
state.visible = [];
state.available = true;
state.cap = 1000;
state.total = 0;
state.pageSize = 250;
state.expanded = {};
state.detailTab = {};
state.copyHint = "";
state.parsedFilterCount = 0;
state.stats = { last24h: 0, prev24h: 0, delta: 0, severity: { error: 0, warn: 0, info: 0 }, unique: { error: 0, warn: 0, info: 0 }, buckets: [], dropped: 0 };
state.query = {
search: "",
bucket: "",
sort: "recent",
group: "code",
autoRefresh: true,
};
state.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(undefined, { year: "numeric", month: "short", day: "numeric" });
};
state.absTime = (iso) => {
if (!iso) return "";
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleString(undefined, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
};
state.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` (and `err` for streamer-style entries) to
// surface the deepest stack.
let s = typeof detail.stack === "string" ? detail.stack : null;
var roots = [detail.cause, detail.err].filter(Boolean);
for (var ri = 0; !s && ri < roots.length; ri++) {
var c = roots[ri];
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("repoId", detail && detail.repoId);
push("filePath", detail && detail.filePath);
push("upstreamStatus", detail && detail.upstreamStatus);
push("upstreamBody", detail && detail.upstreamBody);
push("url", entry._url);
push("err", detail && detail.err);
push("cause", detail && !detail.err && detail.cause);
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(state.query.search || "");
state.parsedFilterCount = parsed.filters.length;
const bucket = state.query.bucket;
let rows = state.entries.filter((e) => {
if (bucket && e._bucket !== bucket) return false;
return matchFilter(e, parsed);
});
const group = state.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;
});
}
// Errors surface first, then warnings, then routine info; within a level, the chosen order.
const rank = (r) => ({ error: 0, warn: 1, info: 2 })[r._bucket] ?? 1;
if (state.query.sort === "count") {
rows.sort((a, b) => rank(a) - rank(b) || b.count - a.count || new Date(b.ts) - new Date(a.ts));
} else {
rows.sort((a, b) => rank(a) - rank(b) || new Date(b.ts) - new Date(a.ts));
}
state.visible = rows;
// A filter that matches nothing in the loaded window keeps reading older pages
// (bounded) so a 5xx counted in the tiles is not reported as "No errors captured".
const filtering = !!(state.query.bucket || (state.query.search || "").trim());
// The default view also reads on until the server errors counted in the tiles appear.
const errorsHidden = !filtering && (state.stats?.severity?.error || 0) > 0 && !rows.some((r) => r._bucket === "error") && state.entries.length < 1500;
if (((filtering && !rows.length) || errorsHidden) && state.canLoadMore?.() && !state._autoLoading && state.entries.length < 2500) {
state._autoLoading = true;
loadEntries(true);
}
}
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 ? state.entries.length : 0;
const limit = append
? state.pageSize
: Math.max(state.pageSize, state.entries.length || state.pageSize);
http
.get("/api/admin/errors", { params: { offset, limit } })
.then(
(res) => {
const next = (res.data.entries || []).map(decorate);
state.entries = append ? state.entries.concat(next) : next;
state.available = !!res.data.available;
state.cap = res.data.max || state.cap;
state.total = res.data.total || state.entries.length;
state._autoLoading = false;
recompute();
},
(err) => { state._autoLoading = false; console.error(err); }
);
}
state.loadMore = () => loadEntries(true);
state.canLoadMore = () => state.entries.length < state.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;
state.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,
};
recompute();
},
(err) => console.error(err)
);
}
function load() {
loadEntries();
loadStats();
}
// Volume chart: errors and warnings carry the height (scaled to their own peak, up to 44px);
// routine info traffic is a faint band on top, capped at 16px. Exact counts are in the tooltip.
state.barPx = (b, key) => {
const all = state.stats.buckets || [];
let maxEW = 0, maxInfo = 0;
for (const x of all) { maxEW = Math.max(maxEW, (x.error || 0) + (x.warn || 0)); maxInfo = Math.max(maxInfo, x.info || 0); }
const part = b[key] || 0;
if (!part) return 0;
if (key === "info") return maxInfo ? Math.max(2, Math.round((part / maxInfo) * (maxEW ? 16 : 60))) : 0;
const ew = (b.error || 0) + (b.warn || 0);
const h = Math.round((ew / maxEW) * 44);
return Math.max(4, Math.round((part / ew) * h));
};
state.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`;
};
state.toggle = (row) => {
state.expanded[row._key] = !state.expanded[row._key];
};
state.setBucket = (b) => {
state.query.bucket = b;
};
state.refreshNow = load;
state.clearAll = () => {
if (!confirm("Clear all captured errors?")) return;
http.delete("/api/admin/errors").then(load, (err) => console.error(err));
};
state.exportCsv = () => {
const cols = ["ts", "level", "module", "displayMessage", "_status", "_url", "_repoId"];
const lines = [cols.join(",")];
for (const r of state.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) {
state.copyHint = `${label} copied`;
timers.timeout(() => { state.copyHint = ""; }, 1500);
}
state.copyJson = (row) => {
navigator.clipboard.writeText(row._detailJson).then(() => flashCopy("JSON"));
};
state.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 (state.query.autoRefresh) load();
}, 15000);
state.on("dispose", () => interval.cancel(stop));
state.watch("query.search", recompute);
state.watch("query.bucket", recompute);
state.watch("query.sort", recompute);
state.watch("query.group", recompute);
};
export const overviewAdminController = function (state, http, location, interval) {
state.Math = Math;
state.watch("user.status", () => {
if (state.user == null) location.url("/");
});
if (state.user == null) { location.url("/"); return; }
state.data = null;
state.loading = true;
state.error = null;
state.performance = null;
state.performanceMinutes = 15;
state.performanceTab = "routes";
state.cacheReport = null;
state.cacheError = null;
state.cacheMinutes = 60;
state.cacheTraffic = "all";
// The overview is split into views; the hash keeps the chosen one on reload.
state.ovView = ["performance", "cache"].includes((window.location.hash || "").slice(1)) ? window.location.hash.slice(1) : "summary";
state.setOvView = function (view) {
state.ovView = view;
try { window.history.replaceState(null, "", view === "summary" ? window.location.pathname : "#" + view); } catch (e) { /* ignore */ }
};
var cacheLoading = false;
state.loadCache = function () {
if (cacheLoading) return;
cacheLoading = true;
var minutes = state.cacheMinutes;
http.get("/api/admin/cache", { params: { minutes: minutes } }).then(function (r) {
if (minutes !== state.cacheMinutes) return;
state.cacheReport = r.data;
state.cacheError = r.data.available ? null : (r.data.reason || "Cache monitoring is unavailable.");
}, function () {
state.cacheError = "Could not refresh cache monitoring. Displayed values may be stale.";
}).finally(function () {
cacheLoading = false;
if (minutes !== state.cacheMinutes) state.loadCache();
});
};
state.cacheRows = function () {
return (state.cacheReport?.rows || []).filter(function (row) {
return state.cacheTraffic === "all" || row.traffic === state.cacheTraffic;
});
};
state.cacheRate = function (prefix, field) {
var rows = state.cacheRows();
var sum = function (name) { return rows.filter(function (r) { return r.name === name; }).reduce(function (n, r) { return n + r[field]; }, 0); };
var hits = sum(prefix + "_hit"), misses = sum(prefix + "_miss");
return hits + misses ? (100 * hits / (hits + misses)).toFixed(1) + "%" : "No samples";
};
state.cacheEventName = function (name) { return name.replaceAll("_", " "); };
state.cacheP95 = function (row) {
var count = 0, bounds = state.cacheReport?.latencyBucketsMs || [];
if (!row.count) return "No samples";
if (!row.ms) return "Not timed";
for (var i = 0; i <= bounds.length; i++) {
count += row["b" + i] || 0;
if (count >= Math.ceil(row.count * 0.95)) return i < bounds.length ? "≤ " + duration(bounds[i]) : "> 10\u202Fs";
}
return "No samples";
};
// The alert names only the metrics that are actually flagged (> 90%).
state.healthAlert = function () {
const sys = (state.data && state.data.system) || {};
const hot = [["Memory", sys.memPercent], ["Disk", sys.diskPercent]].filter(([, v]) => v > 90);
if (!hot.length) return "";
const list = hot.map(([n, v]) => n + " is at " + v + "%").join(" and ");
return list + ". New anonymizations may fail until " + (hot.length > 1 ? "they drop." : "it drops.");
};
state.performanceError = null;
// 18400 ms reads better as 18.4 s once a value passes one second.
state.formatMs = function (ms) {
if (ms == null) return "";
return duration(ms);
};
state.latencyBound = function (row, field) {
if (!row.count) return "No samples";
return row[field] == null ? "> 60\u202Fs" : "\u2264 " + state.formatMs(row[field]);
};
state.firstByteBound = function (row) {
return state.latencyBound({ count: row.firstByteCount, p95UpperMs: row.firstByteP95UpperMs }, "p95UpperMs");
};
state.sampleAge = function (sample) { return Math.max(0, Math.round((Date.now() - sample.sampledAt) / 1000)); };
state.peakRss = function (sample) {
return (state.performance.runtimeSeries || []).filter(function (point) { return point.instance === sample.instance; })
.reduce(function (peak, point) { return Math.max(peak, point.rss); }, sample.memory.rss);
};
state.performanceWarning = function (sample) {
return state.sampleAge(sample) > 45 || sample.droppedBatches || sample.droppedMetrics ||
sample.sockets?.closeWait > 0 || sample.eventLoop?.p95Ms > 100 ||
sample.memoryLimitBytes && sample.memory.rss > sample.memoryLimitBytes * 0.8;
};
var performanceLoading = false;
state.loadPerformance = function () {
var minutes = state.performanceMinutes;
if (state.performance?.windowMinutes && state.performance.windowMinutes !== minutes) state.performance = null;
if (performanceLoading) return;
performanceLoading = true;
http.get("/api/admin/performance", { params: { minutes: minutes } }).then(function (r) {
if (minutes !== state.performanceMinutes) return;
state.performance = r.data;
state.performanceError = r.data.available ? null : "Performance monitoring is unavailable. Request history may be incomplete.";
}, function () {
if (minutes !== state.performanceMinutes) return;
state.performanceError = "Could not refresh performance monitoring. Displayed samples may be stale.";
}).finally(function () {
performanceLoading = false;
if (minutes !== state.performanceMinutes) state.loadPerformance();
});
};
function humanBytes(b) {
if (b == null) return "—";
var units = ["B","KB","MB","GB","TB"];
var i = 0;
var v = b;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return v.toFixed(i > 0 ? 1 : 0).replace(/\.0$/, "") + " " + units[i];
}
state.humanBytes = humanBytes;
function humanDuration(seconds) {
if (!seconds) return "—";
var d = Math.floor(seconds / 86400);
var h = Math.floor((seconds % 86400) / 3600);
var m = Math.floor((seconds % 3600) / 60);
if (d > 0) return d + "d " + (h < 10 ? "0" : "") + h + "h";
if (h > 0) return h + "h " + (m < 10 ? "0" : "") + m + "m";
return m + "m";
}
state.humanDuration = humanDuration;
function humanNum(n) {
if (n == null) return "—";
if (n >= 1000000) return (n / 1000000).toFixed(1) + "M";
if (n >= 1000) return (n / 1000).toFixed(1) + "K";
return String(n);
}
state.humanNum = humanNum;
state.queueTotal = function (q) {
if (!q) return 0;
return (q.waiting || 0) + (q.active || 0) + (q.delayed || 0) + (q.failed || 0);
};
state.statusCount = function (status) {
if (!state.data || !state.data.repos) return 0;
var bd = state.data.repos.statusBreakdown || [];
for (var i = 0; i < bd.length; i++) {
if (bd[i]._id === status) return bd[i].count;
}
return 0;
};
state.barPct = function (status) {
var total = state.data && state.data.repos ? state.data.repos.total : 0;
if (!total) return 0;
var names = [status];
if (status === "expired") names.push("expiring");
if (status === "removed") names.push("removing");
if (status === "preparing") names.push("download");
var sum = 0;
names.forEach(function (n) { sum += state.statusCount(n); });
// A zero count draws no segment; a tiny non-zero count still gets a visible sliver.
return sum ? Math.max(0.4, (sum / total) * 100) : 0;
};
// Real errors are 5xx + 4xx; Info entries are logged but never counted as errors.
state.realErrors = function () {
var sev = state.data && state.data.errors && state.data.errors.severity;
if (!sev) return 0;
return (sev.error || 0) + (sev.warn || 0);
};
// Each bar is its share of the real errors.
state.errPct = function (key) {
if (!state.data || !state.data.errors) return 0;
var total = state.realErrors();
return total ? ((state.data.errors.severity[key] || 0) / total) * 100 : 0;
};
// Hourly real errors (5xx + 4xx) for the last 24 hours, from /api/admin/errors/stats.
state.errorHours = function () {
var rows = (state.errorStats && state.errorStats.buckets) || [];
var max = rows.reduce(function (m, b) { return Math.max(m, (b.error || 0) + (b.warn || 0)); }, 0);
return rows.map(function (b) {
var n = (b.error || 0) + (b.warn || 0);
var h = new Date(b.hour - 3600 * 1000);
return {
key: b.hour,
pct: max && n ? Math.max(6, (n / max) * 100) : 0,
errPct: max ? ((b.error || 0) / max) * 100 : 0,
warnPct: max ? ((b.warn || 0) / max) * 100 : 0,
bad: (b.error || 0) > 0,
zero: !n,
title: String(h.getHours()).padStart(2, "0") + ":00 · " + (b.error || 0) + " 5xx, " + (b.warn || 0) + " 4xx",
};
});
};
state.errorHoursPeak = function () {
var rows = (state.errorStats && state.errorStats.buckets) || [];
return rows.reduce(function (m, b) { return Math.max(m, (b.error || 0) + (b.warn || 0)); }, 0);
};
function computeDailyHistory(history) {
var rows = history || [];
return rows.map(function (d, i) {
var previous = rows[i - 1] || {};
var row = Object.assign({}, d);
row.dailyRepositories = i ? Math.max(0, (d.nbRepositories || 0) - (previous.nbRepositories || 0)) : 0;
row.dailyUsers = i ? Math.max(0, (d.nbUsers || 0) - (previous.nbUsers || 0)) : 0;
row.dailyPageViews = i ? Math.max(0, (d.nbPageViews || 0) - (previous.nbPageViews || 0)) : 0;
return row;
});
}
function todayDailyStats(history) {
var latest = history && history.length ? history[history.length - 1] : {};
return {
repositories: latest.dailyRepositories || 0,
users: latest.dailyUsers || 0,
pageViews: latest.dailyPageViews || 0,
};
}
// Always show a full 30-day axis; days before data exists are empty slots.
function padHistory(rows, days) {
if (!rows || !rows.length) return rows;
var last = new Date(rows[rows.length - 1].date);
if (isNaN(last)) return rows;
var byDay = {};
rows.forEach(function (r) { byDay[new Date(r.date).toISOString().slice(0, 10)] = r; });
var out = [];
for (var i = days - 1; i >= 0; i--) {
var dt = new Date(last.getTime() - i * 86400000);
var key = dt.toISOString().slice(0, 10);
out.push(byDay[key] || { date: dt.toISOString(), _empty: true, dailyPageViews: 0, dailyRepositories: 0, dailyUsers: 0, nbUsers: 0 });
}
return out;
}
var historyMaxes = {};
state.historyMax = function (field) { return historyMaxes[field] || 0; };
var historyMins = {};
// Top of the value scale: a round number at or above the peak whose half is an integer
// (2 -> 2, 7 -> 8, 22 -> 30, 161 -> 200), so the 0 / mid / max ticks are whole numbers.
state.axisMax = function (field) {
var max = historyMaxes[field] || 0;
if (max <= 2) return 2;
if (max <= 10) return Math.ceil(max / 2) * 2;
var unit = Math.pow(10, Math.floor(Math.log10(max)));
return Math.ceil(max / unit) * unit;
};
state.historyBarH = function (d, field) {
if (!d || !historyMaxes[field]) return 0;
// Zero (or missing) is 0px: the baseline is drawn separately. A non-zero day keeps at least 1px.
if (!d[field]) return 0;
return Math.max(1, Math.round((d[field] / state.axisMax(field)) * 140));
};
state.historyRange = function (field) {
return { min: historyMins[field], max: historyMaxes[field] };
};
var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
state.historyLabel = function (d) {
if (!d || !d.date) return "";
var dt = new Date(d.date);
return MONTHS[dt.getUTCMonth()] + " " + dt.getUTCDate();
};
state.historyHasMissing = function () {
return !!(state.data && state.data.history || []).some(function (d) { return d && d._empty; });
};
state.firstDataIndex = function () {
var rows = (state.data && state.data.history) || [];
for (var i = 0; i < rows.length; i++) if (!rows[i]._empty) return i;
return rows.length;
};
state.usersFirst = function () {
var rows = (state.data && state.data.history) || [];
var i = state.firstDataIndex();
return rows[i] ? rows[i].nbUsers || 0 : 0;
};
state.usersGained = function () {
var rows = (state.data && state.data.history) || [];
return rows.reduce(function (sum, d) { return sum + (d._empty ? 0 : d.dailyUsers || 0); }, 0);
};
// A cumulative total under 10 users reads better as a number than as a chart.
state.usersTiny = function () { return (historyMaxes.nbUsers || 0) < 10; };
// Cumulative users as a step line / area anchored at 0, in a viewBox of (days x 140).
// A cumulative series on a 0-based axis is a flat slab; zoom the axis to the 30-day range
// (two "nice" steps that contain every value) so the growth is visible.
state.userAxis = function () {
var rows = ((state.data && state.data.history) || []).filter(function (d) { return d && !d._empty && d.nbUsers != null; });
if (!rows.length) return { lo: 0, hi: 1 };
var lo = Math.min.apply(null, rows.map(function (d) { return d.nbUsers; }));
var hi = Math.max.apply(null, rows.map(function (d) { return d.nbUsers; }));
var span = Math.max(hi - lo, 1);
var p = Math.pow(10, Math.floor(Math.log10(span / 2)));
var steps = [1, 2, 2.5, 5, 10, 20, 25, 50, 100].map(function (m) { return m * p; });
for (var i = 0; i < steps.length; i++) {
var a = Math.floor(lo / steps[i]) * steps[i];
if (a + 2 * steps[i] >= hi) return { lo: a, hi: a + 2 * steps[i] };
}
return { lo: 0, hi: state.axisMax("nbUsers") };
};
state.userStepPath = function (area) {
var rows = (state.data && state.data.history) || [];
var ax = state.userAxis();
var max = historyMaxes.nbUsers ? ax.hi - ax.lo : 0;
var start = state.firstDataIndex();
if (!max || start >= rows.length) return "";
var y = function (v) { return (140 - (((v || 0) - ax.lo) / max) * 140).toFixed(2); };
var d = area ? "M" + start + " 140" : "";
for (var i = start; i < rows.length; i++) {
var yy = y(rows[i].nbUsers);
d += (d ? " L" : "M") + i + " " + yy + " L" + (i + 1) + " " + yy;
}
if (area) d += " L" + rows.length + " 140 Z";
return d;
};
function load() {
state.loadPerformance();
state.loadCache();
http.get("/api/admin/errors/stats").then(function (r) { state.errorStats = r.data; }, function () { state.errorStats = null; });
http.get("/api/admin/overview").then(function (r) {
r.data.history = computeDailyHistory(r.data.history);
r.data.daily = {
today: todayDailyStats(r.data.history),
};
r.data.history = padHistory(r.data.history, 30);
r.data.daily = {
today: todayDailyStats(r.data.history),
};
state.data = r.data;
state.loading = false;
state.error = null;
historyMaxes = {};
historyMins = {};
(r.data.history || []).forEach(function (d) {
["dailyPageViews", "dailyRepositories", "dailyUsers", "nbUsers"].forEach(function (k) {
if (!historyMaxes[k] || d[k] > historyMaxes[k]) historyMaxes[k] = d[k];
if (historyMins[k] == null || d[k] < historyMins[k]) historyMins[k] = d[k];
});
});
}, function (err) {
state.loading = false;
state.error = (err.data && err.data.error) || "Failed to load overview";
});
}
load();
var stop = interval(load, 30000);
state.on("dispose", function () { interval.cancel(stop); });
};
// Admin repository meta lines ([data-meta-fit]): one fixed order (source · ref · conference · size · terms). Items
// that don't fit on the line are hidden from the end, and a "+N" chip says how many, its tooltip listing them.
function fitMetaLine(line) {
const chip = line.querySelector(":scope > .meta-more");
const items = [...line.querySelectorAll(":scope > .meta-item")];
items.forEach((el) => { el.removeAttribute("data-meta-hidden"); });
if (chip) { chip.hidden = true; chip.textContent = ""; chip.removeAttribute("title"); chip.removeAttribute("aria-label"); }
if (!line.clientWidth) return;
// Measure with the source at its natural width, so the tail yields before the repository name does.
line.classList.add("is-fitting");
if (line.scrollWidth <= line.clientWidth + 1) { line.classList.remove("is-fitting"); return; }
const hidden = [];
for (let i = items.length - 1; i >= 0 && line.scrollWidth > line.clientWidth + 1; i--) {
items[i].setAttribute("data-meta-hidden", "");
hidden.unshift(items[i]);
if (chip) {
const names = hidden.map((el) => el.dataset.metaLabel || el.textContent.trim().replace(/\s+/g, " "));
chip.hidden = false;
chip.textContent = "+" + hidden.length;
chip.title = names.join(" · ");
chip.setAttribute("aria-label", hidden.length + " more: " + names.join(", "));
}
}
line.classList.remove("is-fitting");
}
// Fits every meta line in the document while the calling controller's scope lives: on render changes, resize and
// font load. The observer and listener are released with the scope (no work after the view is gone).
function watchMetaLines(timers, listen) {
if (typeof document === "undefined" || typeof MutationObserver === "undefined" || !document.body) return;
let queued = false;
let observer = null;
const fitAll = () => {
queued = false;
if (!observer) return;
document.querySelectorAll("[data-meta-fit]").forEach(fitMetaLine);
observer.takeRecords();
};
const queue = () => { if (!queued && observer) { queued = true; timers.timeout(fitAll, 30); } };
observer = new MutationObserver((records) => {
if (records.some((r) => !(r.target.closest && r.target.closest(".meta-more")))) queue();
});
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
onScopeDispose(() => { observer.disconnect(); observer = null; });
listen(window, "resize", queue);
if (document.fonts?.ready) document.fonts.ready.then(queue);
queue();
}