import { reactive } from "vue"; import { createTimers, createListeners } from "./state.js"; // Page setup functions run inside Vue effect scopes. export const repositoriesAdminController = 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.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: false, expired: false, removed: false, 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.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.statusCounts = res.data.statusCounts || []; state.totalSize = res.data.totalSize || 0; state.allSelected = false; }, (err) => { state.fetchError = (err && err.data && err.data.error) || "Failed to load repositories"; console.error(err); } ); } getRepositories(); let timeClear = null; state.watch( "query", () => { timers.timeout.cancel(timeClear); timeClear = timers.timeout(getRepositories, 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(); 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) => r.status === s).length; }; state.repoFiler = (repo) => { if (state.filters.status[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: false, expired: false, removed: false, 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); } ); } 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 }; state.query = { search: "", autoRefresh: true, }; state.filteredJobs = () => { return (state.jobs || []).filter((j) => state.stateFilter[j._state]); }; state.jobProgressPct = (job) => { if (job && job.progress && typeof job.progress === "object" && typeof job.progress.percent === "number") { return Math.max(0, Math.min(100, Math.round(job.progress.percent))); } if (typeof job.progress === "number") { return Math.max(0, Math.min(100, Math.round(job.progress))); } return null; }; state.jobDuration = (job) => { if (!job.processedOn) return "-"; const end = job.finishedOn || Date.now(); const ms = end - job.processedOn; if (ms < 1000) return ms + "ms"; return (ms / 1000).toFixed(1) + "s"; }; 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 = 50; var marginBottom = 20; var totalW = rect.width - 40; var totalH = 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 ? "#A7B2FF" : "#3B4AD6"; var completedFill = isDark ? "rgba(167,178,255,0.12)" : "rgba(59,74,214,0.08)"; var failedColor = isDark ? "#F08A82" : "#B42318"; var failedFill = isDark ? "rgba(240,138,130,0.08)" : "rgba(180,35,24,0.06)"; var execColor = isDark ? "#F5C842" : "#B8860B"; var pts = state.metricsPoints || []; if (pts.length === 0) { ctx.fillStyle = labelColor; ctx.font = "12px monospace"; 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; }); 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 = "10px monospace"; 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; 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; ctx.fillText(v >= 1000 ? (v / 1000).toFixed(1) + "s" : v + "ms", totalW - marginRight + 6, y); }); } // X-axis time labels using actual timestamps var now = Date.now(); var xLabelCount = Math.min(6, maxLen); ctx.textAlign = "center"; ctx.textBaseline = "top"; for (var i = 0; i < xLabelCount; i++) { var idx = Math.round((i / (xLabelCount - 1)) * (maxLen - 1)); var minsAgo = Math.round((now - pts[idx].ts) / 60000); var x = toX(idx); var lbl; if (minsAgo <= 0) lbl = "now"; else if (minsAgo < 60) lbl = minsAgo + "m"; else if (minsAgo < 1440) lbl = Math.round(minsAgo / 60) + "h"; else lbl = Math.round(minsAgo / 1440) + "d"; ctx.fillStyle = labelColor; ctx.fillText(lbl, x, h + 4); } 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 = Date.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 = '