mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-11-14 00:07:00 +01:00
* feat(admin): add a Billing tab for subscriptions and sales
The admin can now follow subscriptions and sales without opening Stripe:
- Summary tiles: active subscriptions (monthly/annual), MRR with annual
plans counted as 1/12, sales and revenue for 7d/30d/90d/12m/all time,
all-time sales, subscriptions cancelling at period end, and refunds
and disputes.
- A stacked chart of sales per day, week or month by plan family.
- A by-plan table: active grants, period and all-time sales and revenue.
- A paginated list of grants with plan, Stripe status, active/expired/
revoked and user filters, linked to the admin user, repository or
conference.
- One row of webhook health: pending, failed, oldest pending event, last
error and unreviewed payment reversals.
A sale is one checkout or subscription, so a paper pack's three grants
count once. Revenue is each sale's Stripe list price, resolved once per
price and cached in memory. Currencies are never added together. Without
Stripe the page shows counts and marks revenue as unavailable.
GET /api/admin/billing/summary and /grants sit behind the admin check.
Grants get a { purchasedAt, _id } index for the list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix: preserve payment history in admin billing totals
* fix: scope billing history and preserve original sale prices
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2432 lines
99 KiB
JavaScript
2432 lines
99 KiB
JavaScript
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 === "<" ? "<" : ">") +
|
||
"</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 === "<" ? "<" : ">") +
|
||
"</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">● completed: ' + p.completed + '/min</div>' +
|
||
'<div class="q-tip-failed">● failed: ' + p.failed + '/min</div>';
|
||
if (p.avgMs > 0) {
|
||
var dur = duration(p.avgMs);
|
||
html += '<div class="q-tip-exec">● 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();
|
||
}
|
||
|
||
// 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);
|
||
};
|