import { reactive, onScopeDispose } from "vue"; import { createTimers, createListeners } from "./state.js"; import { duration, statusMsg } from "./formatters.js"; // One status filter model for every admin list (and the conference page): // a single-select segmented control. One rule everywhere: "All N" is every // record in the list, removed ones included, and selecting it shows exactly // those N rows. The status chips partition All, so their counts add up to it. // `flags()` returns the boolean status map the list already filters on. export const statusGroup = (status) => ["preparing", "download", "downloading", "downloaded", "queue", "anonymizing"].includes(status) ? "preparing" : status === "expiring" ? "expired" : status === "removing" ? "removed" : status; export function installStatusSegments(state, { flags, keys, count, onChange = () => {} }) { state.segView = () => { const f = flags() || {}; if (keys.every((k) => !!f[k])) return "all"; const on = keys.filter((k) => !!f[k]); return on.length === 1 ? on[0] : "custom"; }; state.setSegView = (view) => { const f = flags(); if (!f) return; for (const k of keys) f[k] = view === "all" ? true : k === view; onChange(); }; state.segCount = (view) => view === "all" ? keys.reduce((n, k) => n + count(k), 0) : count(view); // Saved preferences from the old "All hides removed" model come back as a // mixed selection; fold them into All so one chip is always selected. if (flags() && state.segView() === "custom") for (const k of keys) flags()[k] = true; } // Page setup functions run inside Vue effect scopes. export const repositoriesAdminController = function (state, http, location) { const timers = createTimers(); const listen = createListeners(); watchMetaLines(timers, listen); state.Math = Math; state.watch("user.status", () => { if (state.user == null) { location.url("/"); } }); if (state.user == null) { location.url("/"); } state.repositories = []; state.total = -1; state.totalPage = 0; state.statusCounts = []; state.totalSize = 0; state.selected = {}; state.allSelected = false; // Slash-to-focus the search input const searchKeyHandler = (e) => { if (e.key === "/" && !["INPUT","TEXTAREA","SELECT"].includes(document.activeElement?.tagName)) { e.preventDefault(); const el = document.querySelector('.admin-filter-toolbar input[type="search"]'); el && el.focus(); } }; listen(document, "keydown", searchKeyHandler); state.on("dispose", () => document.removeEventListener("keydown", searchKeyHandler)); state.clearFilter = (key) => { if (key === "dateRange") { state.query.dateFrom = ""; state.query.dateTo = ""; } else state.query[key] = ""; state.query.page = 1; }; state.chips = []; const recomputeChips = () => { const out = []; if (state.query.owner) out.push({ key: "owner", label: "Owner", value: state.query.owner }); if (state.query.conference) out.push({ key: "conference", label: "Conference", value: state.query.conference }); state.chips = out; }; state.showStatusMessage = (repo) => { const msg = repo.statusMessage || "(no message)"; window.prompt(`Status message for ${repo.repoId} (${repo.status}):`, msg); }; state.fetchGithubInfo = (repo) => { const w = window.open("", "_blank"); if (w) w.document.write("
Loading GitHub info for " + repo.repoId + "..."); http.get("/api/admin/repos/" + repo.repoId + "/github").then( (res) => { if (w) { w.document.open(); w.document.write( "
" +
JSON.stringify(res.data, null, 2).replace(/[<>]/g, (c) => c === "<" ? "<" : ">") +
""
);
w.document.close();
}
},
(err) => {
const msg = err && err.data ? JSON.stringify(err.data, null, 2) : String(err);
if (w) w.document.body.innerHTML = "" + msg + ""; } ); }; state.statusCountFor = (s) => { const row = (state.statusCounts || []).find((c) => c._id === s); return row ? row.count : 0; }; state.statusStorageFor = (s) => { const row = (state.statusCounts || []).find((c) => c._id === s); return row ? row.storage : 0; }; state.isErrorsOnly = () => state.query && state.query.error && !state.query.ready && !state.query.preparing && !state.query.expired && !state.query.removed; state.toggleErrorsOnly = () => { if (state.isErrorsOnly()) { Object.assign(state.query, { ready: false, preparing: true, expired: false, removed: false, error: true }); } else { Object.assign(state.query, { ready: false, preparing: false, expired: false, removed: false, error: true }); } state.query.page = 1; }; state.toggleSortDirection = () => { state.query.direction = state.query.direction === "asc" ? "desc" : "asc"; }; state.sortBy = (field) => { if (state.query.sort === field) { state.query.direction = state.query.direction === "asc" ? "desc" : "asc"; } else { state.query.sort = field; state.query.direction = "desc"; } state.query.page = 1; }; state.sortIcon = (field) => state.query.sort === field ? (state.query.direction === "asc" ? "fa-arrow-up" : "fa-arrow-down") : ""; const reposAdminPrefsKey = "admin.repos.filterPrefs"; const reposAdminDefaults = { page: 1, limit: 25, sort: "lastView", direction: "desc", search: "", owner: "", conference: "", dateFrom: "", dateTo: "", ready: true, expired: true, removed: true, error: true, preparing: true, }; const savedReposAdminPrefs = loadFilterPrefs(reposAdminPrefsKey) || {}; state.query = Object.assign({}, reposAdminDefaults, savedReposAdminPrefs, { page: 1, search: "", }); // pre-fill filters from URL ?owner= / ?conference= / ?search= const urlParams = location.search(); if (urlParams.owner) state.query.owner = urlParams.owner; if (urlParams.conference) state.query.conference = urlParams.conference; if (urlParams.search) state.query.search = urlParams.search; // -------- presets -------- const presetsKey = "admin.repos.presets"; state.presets = JSON.parse(localStorage.getItem(presetsKey) || "[]"); state.savePreset = () => { const name = window.prompt("Preset name:"); if (!name) return; const snapshot = Object.assign({}, state.query); delete snapshot.page; state.presets = (state.presets || []).filter((p) => p.name !== name); state.presets.push({ name, query: snapshot }); localStorage.setItem(presetsKey, JSON.stringify(state.presets)); }; state.applyPreset = (p) => { Object.assign(state.query, p.query, { page: 1 }); }; state.deletePreset = (p) => { state.presets = (state.presets || []).filter((x) => x.name !== p.name); localStorage.setItem(presetsKey, JSON.stringify(state.presets)); }; // -------- selection / bulk -------- state.selectAllOnPage = () => { state.allSelected = !state.allSelected; state.repositories.forEach((r) => { state.selected[r.repoId] = state.allSelected; }); }; state.selectedCount = () => Object.values(state.selected || {}).filter(Boolean).length; state.selectedRepos = () => state.repositories.filter((r) => state.selected[r.repoId]); state.bulkRefresh = () => { const repos = state.selectedRepos(); if (!repos.length) return; if (!confirm(`Force refresh ${repos.length} repositories?`)) return; repos.forEach((r) => state.updateRepository(r)); }; state.bulkRemoveCache = () => { const repos = state.selectedRepos(); if (!repos.length) return; if (!confirm(`Purge cache for ${repos.length} repositories?`)) return; repos.forEach((r) => state.removeCache(r)); }; state.clearSelection = () => { state.selected = {}; state.allSelected = false; }; // -------- export -------- state.exportCsv = () => { const params = new URLSearchParams( Object.entries(state.query).filter(([, v]) => v !== "" && v !== false && v != null) ); params.set("format", "csv"); params.set("limit", "10000"); window.open("/api/admin/repos?" + params.toString(), "_blank"); }; state.removeCache = (repo) => { if (!confirm("Remove cached files for " + repo.repoId + "?")) return; http.delete("/api/admin/repos/" + repo.repoId).then( () => getRepositories(), (err) => console.error(err) ); }; state.removeRepository = (repo) => { if (!confirm("Remove repository " + repo.repoId + "?")) return; http.delete("/api/repo/" + repo.repoId + "/").then( () => getRepositories(), (err) => console.error(err) ); }; state.updateRepository = (repo) => { const toast = reactive({ title: `Refreshing ${repo.repoId}...`, date: new Date(), body: `The repository ${repo.repoId} is going to be refreshed.`, }); state.toasts.push(toast); http.post(`/api/repo/${repo.repoId}/refresh`).then( (res) => { if (res.data.status == "ready") { toast.title = `${repo.repoId} is refreshed.`; } else { toast.title = `Refreshing of ${repo.repoId}.`; } }, (error) => { toast.title = `Error during the refresh of ${repo.repoId}.`; toast.body = error.body; } ); }; state.filtersOpen = false; state.fetchError = null; function getRepositories() { state.fetchError = null; http.get("/api/admin/repos", { params: state.query }).then( (res) => { state.total = res.data.total; state.totalPage = Math.ceil(res.data.total / state.query.limit); state.repositories = res.data.results; state.allSelected = false; }, (err) => { state.fetchError = (err && err.data && err.data.error) || "Failed to load repositories"; console.error(err); } ); } // Status chips always show real totals: the list endpoint counts only within the // active status filter, so ask again with every status enabled (one row). function getStatusCounts() { const params = Object.assign({}, state.query, { page: 1, limit: 1, ready: true, expired: true, removed: true, error: true, preparing: true }); http.get("/api/admin/repos", { params }).then((res) => { state.statusCounts = res.data.statusCounts || []; state.totalSize = res.data.totalSize || 0; }, () => {}); } state.showAllStatuses = () => Object.assign(state.query, { ready: true, expired: true, removed: true, error: true, preparing: true, page: 1 }); installStatusSegments(state, { flags: () => state.query, keys: ["ready", "preparing", "error", "expired", "removed"], count: (k) => k === "preparing" ? state.statusCountFor("preparing") + state.statusCountFor("download") : k === "expired" ? state.statusCountFor("expired") + state.statusCountFor("expiring") : k === "removed" ? state.statusCountFor("removed") + state.statusCountFor("removing") : state.statusCountFor(k), onChange: () => { state.query.page = 1; }, }); getRepositories(); getStatusCounts(); let timeClear = null; state.watch( "query", () => { timers.timeout.cancel(timeClear); timeClear = timers.timeout(() => { getRepositories(); getStatusCounts(); }, 500); const { page, search, ...persisted } = state.query; saveFilterPrefs(reposAdminPrefsKey, persisted); recomputeChips(); }, true ); recomputeChips(); }; export const usersAdminController = function (state, http, location) { const timers = createTimers(); const listen = createListeners(); state.Math = Math; state.watch("user.status", () => { if (state.user == null) { location.url("/"); } }); if (state.user == null) { location.url("/"); } state.users = []; state.total = -1; state.totalPage = 0; state.statusCounts = []; state.selected = {}; state.allSelected = false; const searchKeyHandler = (e) => { if (e.key === "/" && !["INPUT","TEXTAREA","SELECT"].includes(document.activeElement?.tagName)) { e.preventDefault(); const el = document.querySelector('.admin-filter-toolbar input[type="search"]'); el && el.focus(); } }; listen(document, "keydown", searchKeyHandler); state.on("dispose", () => document.removeEventListener("keydown", searchKeyHandler)); state.clearFilter = (key) => { if (key === "dateRange") { state.query.dateFrom = ""; state.query.dateTo = ""; } else state.query[key] = ""; state.query.page = 1; }; state.chips = []; const recomputeChipsUsers = () => { const out = []; if (state.query.role) out.push({ key: "role", label: "Role", value: state.query.role }); state.chips = out; }; state.statusCountFor = (s) => { const row = (state.statusCounts || []).find((c) => c._id === s); return row ? row.count : 0; }; state.toggleSortDirection = () => { state.query.direction = state.query.direction === "asc" ? "desc" : "asc"; }; state.sortBy = (field) => { if (state.query.sort === field) { state.query.direction = state.query.direction === "asc" ? "desc" : "asc"; } else { state.query.sort = field; state.query.direction = "desc"; } state.query.page = 1; }; state.sortIcon = (field) => state.query.sort === field ? (state.query.direction === "asc" ? "fa-arrow-up" : "fa-arrow-down") : ""; const usersAdminPrefsKey = "admin.users.filterPrefs"; const usersAdminDefaults = { page: 1, limit: 25, sort: "username", direction: "asc", search: "", status: "", role: "", dateFrom: "", dateTo: "", }; const savedUsersAdminPrefs = loadFilterPrefs(usersAdminPrefsKey) || {}; state.query = Object.assign({}, usersAdminDefaults, savedUsersAdminPrefs, { page: 1, search: "", }); state.selectAllOnPage = () => { state.allSelected = !state.allSelected; state.users.forEach((u) => { state.selected[u.username] = state.allSelected; }); }; state.selectedCount = () => Object.values(state.selected || {}).filter(Boolean).length; state.selectedUsers = () => state.users.filter((u) => state.selected[u.username]); state.banUser = (u) => { if (!confirm(`Ban user ${u.username}?`)) return; http .post(`/api/admin/users/${u.username}/ban`) .then(getUsers, (err) => console.error(err)); }; state.activateUser = (u) => { http .post(`/api/admin/users/${u.username}/activate`) .then(getUsers, (err) => console.error(err)); }; state.bulkBan = () => { const users = state.selectedUsers(); if (!users.length) return; if (!confirm(`Ban ${users.length} users?`)) return; users.forEach((u) => state.banUser(u)); }; state.exportCsv = () => { const params = new URLSearchParams( Object.entries(state.query).filter(([, v]) => v !== "" && v !== false && v != null) ); params.set("format", "csv"); params.set("limit", "10000"); window.open("/api/admin/users?" + params.toString(), "_blank"); }; state.fetchError = null; function getUsers() { state.fetchError = null; http.get("/api/admin/users", { params: state.query }).then( (res) => { state.total = res.data.total; state.totalPage = Math.ceil(res.data.total / state.query.limit); state.users = res.data.results; state.statusCounts = res.data.statusCounts || []; state.allSelected = false; }, (err) => { state.fetchError = (err && err.data && err.data.error) || "Failed to load users"; console.error(err); } ); } getUsers(); let timeClear = null; state.watch( "query", () => { timers.timeout.cancel(timeClear); timeClear = timers.timeout(getUsers, 500); const { page, search, ...persisted } = state.query; saveFilterPrefs(usersAdminPrefsKey, persisted); recomputeChipsUsers(); }, true ); recomputeChipsUsers(); }; export const userAdminController = function (state, http, location, params) { const timers = createTimers(); watchMetaLines(timers, createListeners()); state.watch("user.status", () => { if (state.user == null) { location.url("/"); } }); if (state.user == null) { location.url("/"); } state.userInfo; state.repositories = []; state.search = ""; state.selected = {}; state.allSelected = false; const adminUserPrefsKey = "admin.user.filterPrefs"; const adminUserDefaults = { filters: { status: { ready: true, expired: true, removed: true, error: true, preparing: true } }, sort: "anonymizeDate", direction: "desc", }; const savedAdminUserPrefs = loadFilterPrefs(adminUserPrefsKey) || {}; state.filters = { status: Object.assign( {}, adminUserDefaults.filters.status, (savedAdminUserPrefs.filters && savedAdminUserPrefs.filters.status) || {} ), }; state.query = { sort: savedAdminUserPrefs.sort || adminUserDefaults.sort, direction: savedAdminUserPrefs.direction || adminUserDefaults.direction, }; state.orderBy = (state.query.direction === "asc" ? "" : "-") + state.query.sort; state.sortBy = (field) => { if (state.query.sort === field) { state.query.direction = state.query.direction === "asc" ? "desc" : "asc"; } else { state.query.sort = field; state.query.direction = "desc"; } state.orderBy = (state.query.direction === "asc" ? "" : "-") + state.query.sort; }; state.sortIcon = (field) => state.query.sort === field ? (state.query.direction === "asc" ? "fa-arrow-up" : "fa-arrow-down") : ""; state.watch("query", () => { saveFilterPrefs(adminUserPrefsKey, { filters: state.filters, sort: state.query.sort, direction: state.query.direction, }); }, true); state.watch( "filters", () => { saveFilterPrefs(adminUserPrefsKey, { filters: state.filters, sort: state.query.sort, direction: state.query.direction, }); }, true ); state.statusCountFor = (s) => { return (state.repositories || []).filter((r) => statusGroup(r.status) === s).length; }; installStatusSegments(state, { flags: () => state.filters.status, keys: ["ready", "preparing", "error", "expired", "removed"], count: (k) => state.statusCountFor(k), }); state.repoFiler = (repo) => { if (state.filters.status[statusGroup(repo.status)] == false) return false; if (state.search.trim().length == 0) return true; if (repo.source.fullName.indexOf(state.search) > -1) return true; if (repo.repoId.indexOf(state.search) > -1) return true; if (repo.statusMessage && repo.statusMessage.indexOf(state.search) > -1) return true; if (repo.conference && repo.conference.indexOf(state.search) > -1) return true; return false; }; // -------- selection / bulk -------- state.selectAllOnPage = () => { state.allSelected = !state.allSelected; (state.filteredRepositories || state.repositories).forEach((r) => { state.selected[r.repoId] = state.allSelected; }); }; state.selectedCount = () => Object.values(state.selected || {}).filter(Boolean).length; state.selectedRepos = () => state.repositories.filter((r) => state.selected[r.repoId]); state.bulkRefresh = () => { const repos = state.selectedRepos(); if (!repos.length) return; if (!confirm(`Force refresh ${repos.length} repositories?`)) return; repos.forEach((r) => state.updateRepository(r)); }; state.bulkRemoveCache = () => { const repos = state.selectedRepos(); if (!repos.length) return; if (!confirm(`Purge cache for ${repos.length} repositories?`)) return; repos.forEach((r) => state.removeCache(r)); }; state.clearSelection = () => { state.selected = {}; state.allSelected = false; }; // -------- export -------- state.exportCsv = () => { const filtered = (state.filteredRepositories || state.repositories); const columns = ["repoId", "status", "statusMessage", "pageView", "anonymizeDate", "source.fullName", "conference", "size.storage"]; const header = columns.join(","); const rows = filtered.map((r) => [r.repoId, r.status, r.statusMessage || "", r.pageView || 0, r.anonymizeDate || "", (r.source && r.source.fullName) || "", r.conference || "", (r.size && r.size.storage) || 0] .map((v) => { const s = String(v == null ? "" : v); return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }) .join(",") ); const blob = new Blob([header + "\n" + rows.join("\n")], { type: "text/csv" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = params.username + "-repositories.csv"; a.click(); }; state.showStatusMessage = (repo) => { const msg = repo.statusMessage || "(no message)"; window.prompt(`Status message for ${repo.repoId} (${repo.status}):`, msg); }; state.fetchGithubInfo = (repo) => { const w = window.open("", "_blank"); if (w) w.document.write("
Loading GitHub info for " + repo.repoId + "..."); http.get("/api/admin/repos/" + repo.repoId + "/github").then( (res) => { if (w) { w.document.open(); w.document.write( "
" +
JSON.stringify(res.data, null, 2).replace(/[<>]/g, (c) => c === "<" ? "<" : ">") +
""
);
w.document.close();
}
},
(err) => {
const msg = err && err.data ? JSON.stringify(err.data, null, 2) : String(err);
if (w) w.document.body.innerHTML = "" + msg + ""; } ); }; function getUserRepositories(username) { http.get("/api/admin/users/" + username + "/repos", {}).then( (res) => { state.repositories = res.data; }, (err) => { console.error(err); } ); } function getUser(username) { http.get("/api/admin/users/" + username, {}).then( (res) => { state.userInfo = res.data; }, (err) => { console.error(err); } ); } getUser(params.username); getUserRepositories(params.username); state.banUser = () => { if (!confirm(`Ban user ${params.username}?`)) return; http .post(`/api/admin/users/${params.username}/ban`) .then(() => getUser(params.username), (err) => console.error(err)); }; state.activateUser = () => { http .post(`/api/admin/users/${params.username}/activate`) .then(() => getUser(params.username), (err) => console.error(err)); }; state.promoteUser = () => { if (!confirm(`Promote ${params.username} to admin?`)) return; http .post(`/api/admin/users/${params.username}/promote`) .then(() => getUser(params.username), (err) => console.error(err)); }; state.demoteUser = () => { if (!confirm(`Remove admin privileges from ${params.username}?`)) return; http .post(`/api/admin/users/${params.username}/demote`) .then(() => getUser(params.username), (err) => console.error(err)); }; state.tokens = []; state.tokenForm = { name: "", plaintext: null }; function loadTokens() { http.get("/api/admin/tokens").then( (res) => { state.tokens = res.data || []; }, (err) => { if (err.status !== 401 && err.status !== 403) console.error(err); } ); } loadTokens(); state.createToken = () => { if (!state.tokenForm.name) return; http .post("/api/admin/tokens", { name: state.tokenForm.name }) .then( (res) => { state.tokenForm.plaintext = res.data.token; state.tokenForm.name = ""; loadTokens(); }, (err) => console.error(err) ); }; state.revokeToken = (t) => { if (!confirm(`Revoke token "${t.name}"?`)) return; http.delete("/api/admin/tokens/" + t.id).then( () => loadTokens(), (err) => console.error(err) ); }; state.removeCache = (repo) => { if (!confirm("Remove cached files for " + repo.repoId + "?")) return; http.delete("/api/admin/repos/" + repo.repoId).then( () => getUserRepositories(params.username), (err) => console.error(err) ); }; state.removeRepository = (repo) => { if (!confirm("Remove repository " + repo.repoId + "?")) return; http.delete("/api/repo/" + repo.repoId + "/").then( () => getUserRepositories(params.username), (err) => console.error(err) ); }; state.updateRepository = (repo) => { const toast = reactive({ title: `Refreshing ${repo.repoId}...`, date: new Date(), body: `The repository ${repo.repoId} is going to be refreshed.`, }); state.toasts.push(toast); http.post(`/api/repo/${repo.repoId}/refresh`).then( (res) => { if (res.data.status == "ready") { toast.title = `${repo.repoId} is refreshed.`; } else { toast.title = `Refreshing of ${repo.repoId}.`; } }, (error) => { toast.title = `Error during the refresh of ${repo.repoId}.`; toast.body = error.body; } ); }; state.getGitHubRepositories = (force) => { http .get(`/api/user/${state.userInfo.username}/all_repositories`, { params: { force: "1" }, }) .then((res) => { state.userInfo.repositories = res.data; }); }; let timeClear = null; state.watch( "query", () => { timers.timeout.cancel(timeClear); timeClear = timers.timeout(() => { getUserRepositories(params.username); }, 500); }, true ); }; export const conferencesAdminController = function (state, http, location) { const timers = createTimers(); const listen = createListeners(); state.Math = Math; state.watch("user.status", () => { if (state.user == null) { location.url("/"); } }); if (state.user == null) { location.url("/"); } state.conferences = []; state.total = -1; state.totalPage = 0; state.statusCounts = []; const searchKeyHandler = (e) => { if (e.key === "/" && !["INPUT","TEXTAREA","SELECT"].includes(document.activeElement?.tagName)) { e.preventDefault(); const el = document.querySelector('.admin-filter-toolbar input[type="search"]'); el && el.focus(); } }; listen(document, "keydown", searchKeyHandler); state.on("dispose", () => document.removeEventListener("keydown", searchKeyHandler)); state.clearFilter = (key) => { if (key === "dateRange") { state.query.dateFrom = ""; state.query.dateTo = ""; } else state.query[key] = ""; state.query.page = 1; }; state.chips = []; const recomputeChipsConf = () => { const out = []; if (state.query.dateFrom || state.query.dateTo) out.push({ key: "dateRange", label: "Date", value: (state.query.dateFrom || "…") + " – " + (state.query.dateTo || "…") }); state.chips = out; }; state.statusCountFor = (s) => { const row = (state.statusCounts || []).find((c) => c._id === s); return row ? row.count : 0; }; state.toggleSortDirection = () => { state.query.direction = state.query.direction === "asc" ? "desc" : "asc"; }; state.sortBy = (field) => { if (state.query.sort === field) { state.query.direction = state.query.direction === "asc" ? "desc" : "asc"; } else { state.query.sort = field; state.query.direction = "desc"; } state.query.page = 1; }; state.sortIcon = (field) => state.query.sort === field ? (state.query.direction === "asc" ? "fa-arrow-up" : "fa-arrow-down") : ""; const confAdminPrefsKey = "admin.conferences.filterPrefs"; const confAdminDefaults = { page: 1, limit: 25, sort: "name", direction: "asc", search: "", dateFrom: "", dateTo: "", ready: true, expired: true, removed: true, error: true, preparing: true, }; const savedConfAdminPrefs = loadFilterPrefs(confAdminPrefsKey) || {}; state.query = Object.assign({}, confAdminDefaults, savedConfAdminPrefs, { page: 1, search: "", }); // pre-fill filters from URL ?search= const urlParams = location.search(); if (urlParams.search) state.query.search = urlParams.search; // -------- presets -------- const confPresetsKey = "admin.conferences.presets"; state.presets = JSON.parse(localStorage.getItem(confPresetsKey) || "[]"); state.savePreset = () => { const name = window.prompt("Preset name:"); if (!name) return; const snapshot = Object.assign({}, state.query); delete snapshot.page; state.presets = (state.presets || []).filter((p) => p.name !== name); state.presets.push({ name, query: snapshot }); localStorage.setItem(confPresetsKey, JSON.stringify(state.presets)); }; state.applyPreset = (p) => { Object.assign(state.query, p.query, { page: 1 }); }; state.deletePreset = (p) => { state.presets = (state.presets || []).filter((x) => x.name !== p.name); localStorage.setItem(confPresetsKey, JSON.stringify(state.presets)); }; state.removeConference = (conference) => { if (!confirm("Remove conference " + conference.conferenceID + "?")) return; http.delete("/api/admin/conferences/" + conference.conferenceID).then( () => getConferences(), (err) => console.error(err) ); }; state.exportCsv = () => { const params = new URLSearchParams( Object.entries(state.query).filter(([, v]) => v !== "" && v !== false && v != null) ); params.set("format", "csv"); params.set("limit", "10000"); window.open("/api/admin/conferences?" + params.toString(), "_blank"); }; state.fetchError = null; function getConferences() { state.fetchError = null; http.get("/api/admin/conferences", { params: state.query }).then( (res) => { state.total = res.data.total; state.totalPage = Math.ceil(res.data.total / state.query.limit); state.conferences = res.data.results; state.statusCounts = res.data.statusCounts || []; }, (err) => { state.fetchError = (err && err.data && err.data.error) || "Failed to load conferences"; console.error(err); } ); } installStatusSegments(state, { flags: () => state.query, keys: ["ready", "preparing", "error", "expired", "removed"], count: (k) => state.statusCountFor(k), onChange: () => { state.query.page = 1; }, }); getConferences(); let timeClear = null; state.watch( "query", () => { timers.timeout.cancel(timeClear); timeClear = timers.timeout(getConferences, 500); const { page, search, ...persisted } = state.query; saveFilterPrefs(confAdminPrefsKey, persisted); recomputeChipsConf(); }, true ); recomputeChipsConf(); }; export const queuesAdminController = function (state, http, location, interval, timeout) { const timers = createTimers(); state.watch("user.status", () => { if (state.user == null) location.url("/"); }); if (state.user == null) location.url("/"); state.queueList = []; state.jobs = []; state.selectedQueue = "download"; state.selectedStats = null; state.range = "1h"; state.allStates = ["active", "waiting", "delayed", "failed", "completed"]; state.stateFilter = { active: true, waiting: true, delayed: true, failed: true, completed: true }; installStatusSegments(state, { flags: () => state.stateFilter, keys: state.allStates, count: (k) => (state.jobs || []).filter((j) => j._state === k).length, }); state.query = { search: "", autoRefresh: true, }; state.filteredJobs = () => { return (state.jobs || []).filter((j) => state.stateFilter[j._state]); }; state.jobProgressPct = (job) => { // A completed job is done whatever its last reported progress was (an empty bar beside "Completed" reads as stalled). if (job?._state === "completed") return 100; if (job && job.progress && typeof job.progress === "object" && typeof job.progress.percent === "number") { return Math.max(0, Math.min(100, Math.round(job.progress.percent))); } if (typeof job.progress === "number") { return Math.max(0, Math.min(100, Math.round(job.progress))); } return null; }; // The Detail column: the stage an active job reports, or why a failed job failed; a dash otherwise. const JOB_STAGES = { get_files: "Fetching files", download: "Downloading", downloading: "Downloading", anonymize: "Anonymizing", anonymizing: "Anonymizing", ready: "Ready", removing: "Removing", preparing: "Preparing" }; state.jobDetail = (job) => { if (job?._state === "failed") return statusMsg(job.failedReason || "") || "Failed"; const stage = job?.progress && typeof job.progress === "object" ? job.progress.status : null; if (job?._state === "active" && stage) return JOB_STAGES[stage] || statusMsg(stage); return "—"; }; state.jobDuration = (job) => { if (!job.processedOn) return "—"; const end = job.finishedOn || Date.now(); return duration(end - job.processedOn); }; state.metricsPoints = []; state.selectQueue = (key) => { state.selectedQueue = key; getQueues(); getMetrics(); }; state.setRange = (r) => { state.range = r; getMetrics(); }; function getQueues() { const params = { queue: state.selectedQueue, search: state.query.search, }; http.get("/api/admin/queues", { params }).then( (res) => { state.queueList = res.data.queues || []; state.jobs = res.data.jobs || []; state.selectedStats = state.queueList.find((q) => q.key === state.selectedQueue) || state.queueList[0] || null; }, (err) => console.error(err) ); } function getMetrics() { http.get("/api/admin/queues/metrics", { params: { queue: state.selectedQueue, range: state.range } }).then( (res) => { state.metricsPoints = res.data.points || []; timeout(drawChart, 0); }, (err) => console.error(err) ); } getQueues(); getMetrics(); const stop = interval(() => { if (state.query.autoRefresh) { getQueues(); getMetrics(); } }, 15000); state.on("dispose", () => interval.cancel(stop)); state.refreshNow = function () { getQueues(); getMetrics(); }; function apiError(err) { const msg = (err && err.data && (err.data.message || err.data.error)) || "Request failed"; state.actionError = msg; timeout(() => { state.actionError = null; }, 5000); console.error(err); } state.actionError = null; state.removeJob = (job) => { http.delete(`/api/admin/queue/${state.selectedQueue}/${job.id}`).then(getQueues, apiError); }; state.retryJob = (job) => { http.post(`/api/admin/queue/${state.selectedQueue}/${job.id}`).then(getQueues, apiError); }; state.retryFailed = () => { if (!confirm(`Retry all failed jobs in ${state.selectedQueue}?`)) return; http.post(`/api/admin/queue/${state.selectedQueue}/retry-failed`).then(getQueues, (err) => console.error(err)); }; state.drainSelected = () => { if (!confirm(`Drain the ${state.selectedQueue} queue?`)) return; http.post(`/api/admin/queue/${state.selectedQueue}/drain`).then(getQueues, (err) => console.error(err)); }; state.togglePause = () => { const action = state.selectedStats && state.selectedStats.paused ? "resume" : "pause"; http.post(`/api/admin/queue/${state.selectedQueue}/${action}`).then(getQueues, (err) => console.error(err)); }; state.emptyQueue = () => { if (!confirm(`Empty the ${state.selectedQueue} queue? This removes ALL jobs.`)) return; http.post(`/api/admin/queue/${state.selectedQueue}/empty`).then(getQueues, (err) => console.error(err)); }; state.pauseAll = () => { if (!confirm("Pause all queues?")) return; http.post("/api/admin/queues/pause-all").then(getQueues, (err) => console.error(err)); }; let searchClear = null; state.watch("query.search", () => { timers.timeout.cancel(searchClear); searchClear = timers.timeout(getQueues, 350); }); state.expanded = {}; state.toggleJob = (job) => { state.expanded[job.id] = !state.expanded[job.id]; }; state.humanTime = (ts) => { if (!ts) return ""; const d = new Date(ts); return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }) + " " + d.toLocaleDateString([], { month: "short", day: "numeric" }); }; state.delayCountdown = (ts) => { if (!ts) return ""; var remaining = Math.max(0, Math.ceil((ts - Date.now()) / 1000)); if (remaining <= 0) return "resuming soon"; var min = Math.floor(remaining / 60); var sec = remaining % 60; return "in " + (min > 0 ? min + "m " + sec + "s" : sec + "s"); }; function niceScale(max) { if (max <= 0) return { ticks: [0], niceMax: 1 }; const mag = Math.pow(10, Math.floor(Math.log10(max))); let step = mag; if (max / step < 2) step = mag / 2; else if (max / step > 5) step = mag * 2; const niceMax = Math.ceil(max / step) * step; const ticks = []; for (let v = 0; v <= niceMax; v += step) ticks.push(v); return { ticks, niceMax }; } function drawChart() { var canvas = document.getElementById("q-throughput-chart"); if (!canvas) return; var ctx = canvas.getContext("2d"); var dpr = window.devicePixelRatio || 1; var rect = canvas.parentElement.getBoundingClientRect(); var marginLeft = 44; var marginRight = 44; var marginBottom = 22; // The plot fills its card, which stretches to the height of the stats panel beside it. var totalW = Math.max(200, Math.round(rect.width)); var totalH = Math.max(180, Math.min(420, Math.round(rect.height) || 180)); var w = totalW - marginLeft - marginRight; var h = totalH - marginBottom; canvas.width = totalW * dpr; canvas.height = totalH * dpr; canvas.style.width = totalW + "px"; canvas.style.height = totalH + "px"; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); var isDark = document.body.classList.contains("dark-mode"); var labelColor = "#8A857C"; var gridColor = isDark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.06)"; var completedColor = isDark ? "#6FBF8A" : "#2F7D4F"; var completedFill = isDark ? "rgba(111,191,138,0.12)" : "rgba(47,125,79,0.08)"; var failedColor = isDark ? "#F08A82" : "#B42318"; var failedFill = isDark ? "rgba(240,138,130,0.08)" : "rgba(180,35,24,0.06)"; // One token drives the time line, its seconds axis and its legend key (style.css: --q-time-series). var execColor = getComputedStyle(document.body).getPropertyValue("--q-time-series").trim() || (isDark ? "#E0A94A" : "#946512"); var pts = state.metricsPoints || []; if (pts.length === 0) { ctx.fillStyle = labelColor; ctx.font = "13px Inter, system-ui, sans-serif"; ctx.textAlign = "center"; ctx.fillText("No metrics data yet", totalW / 2, totalH / 2); chartState = null; return; } // Data is oldest→newest from the API; chart shows newest on the right var completedPts = pts.map(function (p) { return p.completed; }); var failedPts = pts.map(function (p) { return p.failed; }); var execPts = pts.map(function (p) { return p.avgMs; }); if (!completedPts.some(Boolean) && !failedPts.some(Boolean)) { ctx.strokeStyle = isDark ? "rgba(255,255,255,0.14)" : "rgba(0,0,0,0.12)"; ctx.beginPath(); ctx.moveTo(0, totalH - 18.5); ctx.lineTo(totalW, totalH - 18.5); ctx.stroke(); ctx.fillStyle = isDark ? "#CFC8BD" : "#4F4A42"; ctx.font = "500 14px Inter, system-ui, sans-serif"; ctx.textAlign = "center"; ctx.fillText("No jobs processed in this window", totalW / 2, totalH / 2 - 6); ctx.fillStyle = labelColor; ctx.font = "12.5px Inter, system-ui, sans-serif"; ctx.fillText("Completed and failed jobs will chart here", totalW / 2, totalH / 2 + 16); chartState = null; return; } var maxLen = pts.length; var step = w / (maxLen - 1 || 1); // Left Y-axis: jobs/min var rawMax = Math.max(1, Math.max.apply(null, completedPts), Math.max.apply(null, failedPts)); var left = niceScale(rawMax); // Right Y-axis: avg exec time (ms) var execMax = Math.max.apply(null, execPts); var right = execMax > 0 ? niceScale(execMax) : { ticks: [0], niceMax: 1 }; var toY = function (v) { return h - (v / left.niceMax) * (h - 10); }; var toYr = function (v) { return h - (v / right.niceMax) * (h - 10); }; var toX = function (i) { return marginLeft + i * step; }; // Grid + left Y-axis labels (jobs/min) ctx.textAlign = "right"; ctx.textBaseline = "middle"; ctx.font = "11px Inter, system-ui, sans-serif"; left.ticks.forEach(function (v) { var y = toY(v); ctx.strokeStyle = gridColor; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(marginLeft, y); ctx.lineTo(totalW - marginRight, y); ctx.stroke(); ctx.fillStyle = labelColor; // The origin is implied by the baseline; a "0" here would collide with the first time label. if (v !== 0) ctx.fillText(v >= 1000 ? (v / 1000).toFixed(1) + "k" : String(v), marginLeft - 6, y); }); // Right Y-axis labels (ms) if (execMax > 0) { ctx.textAlign = "left"; right.ticks.forEach(function (v) { var y = toYr(v); ctx.fillStyle = execColor; if (v !== 0) ctx.fillText(duration(v), totalW - marginRight + 6, y); }); } // X-axis time labels: "now" under the newest point, then whole steps back (1, 2, 5, 10, 15, 30 min, 1, 2, 3, // 6, 12 h, 1 d) labelled "