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("
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 + "
";
}
);
};
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("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 + "
";
}
);
};
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 " 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 =
'' + timeLabel + '
' +
'● completed: ' + p.completed + '/min
' +
'● failed: ' + p.failed + '/min
';
if (p.avgMs > 0) {
var dur = duration(p.avgMs);
html += '● avg time: ' + dur + '
';
}
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();
}
// Billing: subscriptions, sales and webhook health. Amounts arrive in minor units per currency
// (Stripe's unit_amount) and are never added across currencies.
const ZERO_DECIMAL = ["bif", "clp", "djf", "gnf", "jpy", "kmf", "krw", "mga", "pyg", "rwf", "ugx", "vnd", "vuv", "xaf", "xof", "xpf"];
export const BILLING_FAMILIES = [
{ id: "subscription", label: "Subscriptions" },
{ id: "repository", label: "Repository purchases" },
{ id: "conference", label: "Conferences" },
];
// Names from src/core/billing/config.ts, used until the summary (which carries them) arrives.
const BILLING_PLAN_NAMES = { author_monthly: "Author monthly", author_annual: "Author annual", author_pass: "Live updates", repo_extend: "Extended expiration",
repo_never: "Never expire", repo_slot: "Extra repository", repo_pack: "Paper pack", conference_workshop: "Workshop", conference: "Conference", conference_large: "Large conference" };
export function moneyParts(amounts) {
return Object.entries(amounts || {}).filter(([, v]) => v).sort((a, b) => b[1] - a[1]).map(([currency, minor]) => {
const value = ZERO_DECIMAL.includes(currency) ? minor : minor / 100;
try { return new Intl.NumberFormat(undefined, { style: "currency", currency: currency.toUpperCase(), minimumFractionDigits: value % 1 ? 2 : 0, maximumFractionDigits: 2 }).format(value); }
catch (e) { return value + " " + currency.toUpperCase(); }
});
}
export const billingAdminController = function (state, http, location) {
const timers = createTimers();
state.Math = Math;
state.watch("user.status", () => { if (state.user == null) location.url("/"); });
if (state.user == null) { location.url("/"); return; }
state.families = BILLING_FAMILIES;
state.periods = [{ id: "7d", label: "7 days" }, { id: "30d", label: "30 days" }, { id: "90d", label: "90 days" }, { id: "12m", label: "12 months" }, { id: "all", label: "All time" }];
const savedPeriod = (() => { try { return localStorage.getItem("admin.billing.period"); } catch (e) { return null; } })();
state.period = state.periods.some((p) => p.id === savedPeriod) ? savedPeriod : "30d";
state.summary = null;
state.summaryError = null;
state.summaryLoading = true;
state.periodLabel = () => (state.periods.find((p) => p.id === state.period) || {}).label || "";
// "in 30 days" reads wrong for All time.
state.periodPhrase = () => state.period === "all" ? "all time" : "last " + state.periodLabel();
state.money = (amounts) => moneyParts(amounts);
state.moneyText = (amounts, empty = "—") => moneyParts(amounts).join(" · ") || empty;
state.revenueOn = () => !!state.summary?.revenue?.available;
state.planName = (id) => ((state.summary?.plans || []).find((p) => p.planId === id) || {}).name || BILLING_PLAN_NAMES[id] || id;
// Stacked sales chart: one column per bucket, scaled to a rounded axis maximum.
state.bucketTotal = (d) => d ? d.subscription + d.repository + d.conference : 0;
state.chartMax = () => {
const peak = Math.max(0, ...(state.summary?.series || []).map(state.bucketTotal));
if (peak <= 4) return 4;
const step = Math.pow(10, Math.floor(Math.log10(peak)));
// An even maximum keeps the midline label a whole number.
return [1, 1.2, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10].map((m) => m * step).find((v) => v >= peak && v % 2 === 0) || 10 * step;
};
state.segmentPct = (d, family) => d && d[family] ? (d[family] / state.chartMax()) * 100 : 0;
state.familyTotal = (family) => (state.summary?.series || []).reduce((n, d) => n + d[family], 0);
state.bucketLabel = (d) => {
if (!d) return "";
const date = new Date(d.date);
const opts = { timeZone: "UTC", day: "numeric", month: "short" };
if (state.summary?.bucket === "month") return date.toLocaleDateString(undefined, { timeZone: "UTC", month: "short", year: "numeric" });
if (state.period === "12m" || state.period === "all" || date.getUTCFullYear() !== new Date().getUTCFullYear()) opts.year = "numeric";
return (state.summary?.bucket === "week" ? "Week of " : "") + date.toLocaleDateString(undefined, opts);
};
state.bucketTip = (d) => {
const total = state.bucketTotal(d);
if (!total) return state.bucketLabel(d) + ": no sales";
const parts = BILLING_FAMILIES.filter((f) => d[f.id]).map((f) => d[f.id] + " " + f.label.toLowerCase());
return state.bucketLabel(d) + ": " + total + (total === 1 ? " sale" : " sales") + " (" + parts.join(", ") + ")";
};
state.bucketName = () => ({ day: "day", week: "week", month: "month" })[state.summary?.bucket] || "day";
state.seriesSales = () => (state.summary?.series || []).reduce((n, d) => n + state.bucketTotal(d), 0);
state.plansWithActivity = () => (state.summary?.plans || []).filter((p) => p.active || p.allTimeSales || p.sales);
state.planTotals = () => {
const sum = (key) => (state.summary?.plans || []).reduce((n, p) => n + (p[key] || 0), 0);
return { active: sum("active"), sales: sum("sales"), allTimeSales: sum("allTimeSales") };
};
state.webhookState = () => {
const w = state.summary?.webhooks;
if (!w) return "ok";
if (w.failed || w.reconciliationFailed) return "bad";
if (w.oldestPendingSeconds > 900 || w.unreviewedRevocations) return "warn";
return "ok";
};
state.age = (seconds) => seconds ? duration(seconds * 1000) : "—";
let summaryRequest = 0;
state.loadSummary = () => {
const request = ++summaryRequest;
state.summaryLoading = true;
state.summaryError = null;
http.get("/api/admin/billing/summary", { params: { period: state.period } }).then((res) => {
if (request !== summaryRequest) return;
state.summaryLoading = false;
const data = res.data;
if (!data || !data.subscriptions || !data.sales || !Array.isArray(data.plans)) { state.summaryError = "The billing summary had an unexpected format."; return; }
state.summary = data;
}, (err) => {
if (request !== summaryRequest) return;
state.summaryLoading = false;
state.summaryError = (err && err.data && err.data.error) || "Could not load billing figures.";
});
};
state.setPeriod = (id) => {
if (state.period === id) return;
state.period = id;
try { localStorage.setItem("admin.billing.period", id); } catch (e) { /* private mode */ }
state.loadSummary();
};
// -------- grant list --------
state.grants = [];
state.total = -1;
state.totalPage = 0;
state.stateCounts = {};
state.statuses = [];
state.grantsError = null;
state.filtersOpen = false;
state.query = { page: 1, limit: 25, search: "", plan: "", status: "", state: "", sort: "purchasedAt", direction: "desc" };
const urlParams = location.search();
if (urlParams.search) state.query.search = urlParams.search;
state.stateView = () => state.query.state || "all";
state.setStateView = (view) => { state.query.state = view === "all" ? "" : view; state.query.page = 1; };
state.stateCount = (view) => view === "all"
? ["active", "expired", "revoked"].reduce((n, k) => n + (state.stateCounts[k] || 0), 0)
: state.stateCounts[view] || 0;
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") : "";
state.chips = [];
const recomputeChips = () => {
const out = [];
if (state.query.plan) out.push({ key: "plan", label: "Plan", value: state.planName(state.query.plan) });
if (state.query.status) out.push({ key: "status", label: "Stripe status", value: state.query.status.replace(/_/g, " ") });
state.chips = out;
};
state.clearFilter = (key) => { state.query[key] = ""; state.query.page = 1; };
state.filterPlan = (planId) => {
Object.assign(state.query, { plan: planId, page: 1 });
const list = document.getElementById("bl-grants");
if (list && list.scrollIntoView) list.scrollIntoView({ behavior: "smooth", block: "start" });
};
state.clearFilters = () => Object.assign(state.query, { search: "", plan: "", status: "", state: "", page: 1 });
state.hasFilters = () => !!(state.query.search || state.query.plan || state.query.status || state.query.state);
state.statusText = (g) => (g.status || "unknown").replace(/_/g, " ");
state.revocationText = (reason) => reason === "dispute" ? "Disputed" : reason === "full_refund" ? "Refunded" : "Revoked";
state.scopeHref = (scope) => scope?.kind === "repo" ? "/admin/repositories?search=" + encodeURIComponent(scope.id)
: scope?.kind === "conference" ? "/conference/" + encodeURIComponent(scope.id) : null;
let grantsRequest = 0;
function getGrants() {
const request = ++grantsRequest;
state.grantsError = null;
const params = Object.fromEntries(Object.entries(state.query).filter(([, v]) => v !== "" && v != null));
http.get("/api/admin/billing/grants", { params }).then((res) => {
if (request !== grantsRequest) return;
state.total = res.data?.total ?? 0;
state.totalPage = Math.ceil(state.total / state.query.limit);
state.grants = res.data?.results || [];
state.stateCounts = res.data.stateCounts || {};
state.statuses = res.data.statuses || [];
}, (err) => {
if (request !== grantsRequest) return;
state.grantsError = (err && err.data && err.data.error) || "Could not load subscriptions and purchases.";
});
}
state.reloadGrants = getGrants;
state.loadSummary();
getGrants();
recomputeChips();
let timeClear = null;
let lastFilters = JSON.stringify({ ...state.query, page: 0 });
state.watch("query", () => {
// A filter change returns to page 1 before fetching.
const filters = JSON.stringify({ ...state.query, page: 0 });
if (filters !== lastFilters && state.query.page !== 1) { lastFilters = filters; state.query.page = 1; return; }
lastFilters = filters;
timers.timeout.cancel(timeClear);
timeClear = timers.timeout(getGrants, 300);
recomputeChips();
}, true);
};