angular .module("admin", []) .controller("repositoriesAdminController", [ "$scope", "$http", "$location", function ($scope, $http, $location) { $scope.Math = Math; $scope.$watch("user.status", () => { if ($scope.user == null) { $location.url("/"); } }); if ($scope.user == null) { $location.url("/"); } $scope.repositories = []; $scope.total = -1; $scope.totalPage = 0; $scope.statusCounts = []; $scope.totalSize = 0; $scope.selected = {}; $scope.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(); } }; document.addEventListener("keydown", searchKeyHandler); $scope.$on("$destroy", () => document.removeEventListener("keydown", searchKeyHandler)); $scope.clearFilter = (key) => { if (key === "dateRange") { $scope.query.dateFrom = ""; $scope.query.dateTo = ""; } else $scope.query[key] = ""; $scope.query.page = 1; }; $scope.chips = []; const recomputeChips = () => { const out = []; if ($scope.query.owner) out.push({ key: "owner", label: "Owner", value: $scope.query.owner }); if ($scope.query.conference) out.push({ key: "conference", label: "Conference", value: $scope.query.conference }); $scope.chips = out; }; $scope.showStatusMessage = (repo) => { const msg = repo.statusMessage || "(no message)"; window.prompt(`Status message for ${repo.repoId} (${repo.status}):`, msg); }; $scope.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 + ""; } ); }; $scope.statusCountFor = (s) => { const row = ($scope.statusCounts || []).find((c) => c._id === s); return row ? row.count : 0; }; $scope.statusStorageFor = (s) => { const row = ($scope.statusCounts || []).find((c) => c._id === s); return row ? row.storage : 0; }; $scope.isErrorsOnly = () => $scope.query && $scope.query.error && !$scope.query.ready && !$scope.query.preparing && !$scope.query.expired && !$scope.query.removed; $scope.toggleErrorsOnly = () => { if ($scope.isErrorsOnly()) { Object.assign($scope.query, { ready: false, preparing: true, expired: false, removed: false, error: true }); } else { Object.assign($scope.query, { ready: false, preparing: false, expired: false, removed: false, error: true }); } $scope.query.page = 1; }; $scope.toggleSortDirection = () => { $scope.query.direction = $scope.query.direction === "asc" ? "desc" : "asc"; }; $scope.sortBy = (field) => { if ($scope.query.sort === field) { $scope.query.direction = $scope.query.direction === "asc" ? "desc" : "asc"; } else { $scope.query.sort = field; $scope.query.direction = "desc"; } $scope.query.page = 1; }; $scope.sortIcon = (field) => $scope.query.sort === field ? ($scope.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) || {}; $scope.query = Object.assign({}, reposAdminDefaults, savedReposAdminPrefs, { page: 1, search: "", }); // pre-fill filters from URL ?owner= / ?conference= / ?search= const urlParams = $location.search(); if (urlParams.owner) $scope.query.owner = urlParams.owner; if (urlParams.conference) $scope.query.conference = urlParams.conference; if (urlParams.search) $scope.query.search = urlParams.search; // -------- presets -------- const presetsKey = "admin.repos.presets"; $scope.presets = JSON.parse(localStorage.getItem(presetsKey) || "[]"); $scope.savePreset = () => { const name = window.prompt("Preset name:"); if (!name) return; const snapshot = Object.assign({}, $scope.query); delete snapshot.page; $scope.presets = ($scope.presets || []).filter((p) => p.name !== name); $scope.presets.push({ name, query: snapshot }); localStorage.setItem(presetsKey, JSON.stringify($scope.presets)); }; $scope.applyPreset = (p) => { Object.assign($scope.query, p.query, { page: 1 }); }; $scope.deletePreset = (p) => { $scope.presets = ($scope.presets || []).filter((x) => x.name !== p.name); localStorage.setItem(presetsKey, JSON.stringify($scope.presets)); }; // -------- selection / bulk -------- $scope.selectAllOnPage = () => { $scope.allSelected = !$scope.allSelected; $scope.repositories.forEach((r) => { $scope.selected[r.repoId] = $scope.allSelected; }); }; $scope.selectedCount = () => Object.values($scope.selected || {}).filter(Boolean).length; $scope.selectedRepos = () => $scope.repositories.filter((r) => $scope.selected[r.repoId]); $scope.bulkRefresh = () => { const repos = $scope.selectedRepos(); if (!repos.length) return; if (!confirm(`Force refresh ${repos.length} repositories?`)) return; repos.forEach((r) => $scope.updateRepository(r)); }; $scope.bulkRemoveCache = () => { const repos = $scope.selectedRepos(); if (!repos.length) return; if (!confirm(`Purge cache for ${repos.length} repositories?`)) return; repos.forEach((r) => $scope.removeCache(r)); }; $scope.clearSelection = () => { $scope.selected = {}; $scope.allSelected = false; }; // -------- export -------- $scope.exportCsv = () => { const params = new URLSearchParams( Object.entries($scope.query).filter(([, v]) => v !== "" && v !== false && v != null) ); params.set("format", "csv"); params.set("limit", "10000"); window.open("/api/admin/repos?" + params.toString(), "_blank"); }; $scope.removeCache = (repo) => { if (!confirm("Remove cached files for " + repo.repoId + "?")) return; $http.delete("/api/admin/repos/" + repo.repoId).then( () => getRepositories(), (err) => console.error(err) ); }; $scope.removeRepository = (repo) => { if (!confirm("Remove repository " + repo.repoId + "?")) return; $http.delete("/api/repo/" + repo.repoId + "/").then( () => getRepositories(), (err) => console.error(err) ); }; $scope.updateRepository = (repo) => { const toast = { title: `Refreshing ${repo.repoId}...`, date: new Date(), body: `The repository ${repo.repoId} is going to be refreshed.`, }; $scope.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; } ); }; $scope.fetchError = null; function getRepositories() { $scope.fetchError = null; $http.get("/api/admin/repos", { params: $scope.query }).then( (res) => { $scope.total = res.data.total; $scope.totalPage = Math.ceil(res.data.total / $scope.query.limit); $scope.repositories = res.data.results; $scope.statusCounts = res.data.statusCounts || []; $scope.totalSize = res.data.totalSize || 0; $scope.allSelected = false; }, (err) => { $scope.fetchError = (err && err.data && err.data.error) || "Failed to load repositories"; console.error(err); } ); } getRepositories(); let timeClear = null; $scope.$watch( "query", () => { clearTimeout(timeClear); timeClear = setTimeout(getRepositories, 500); const { page, search, ...persisted } = $scope.query; saveFilterPrefs(reposAdminPrefsKey, persisted); recomputeChips(); }, true ); recomputeChips(); }, ]) .controller("usersAdminController", [ "$scope", "$http", "$location", function ($scope, $http, $location) { $scope.Math = Math; $scope.$watch("user.status", () => { if ($scope.user == null) { $location.url("/"); } }); if ($scope.user == null) { $location.url("/"); } $scope.users = []; $scope.total = -1; $scope.totalPage = 0; $scope.statusCounts = []; $scope.selected = {}; $scope.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(); } }; document.addEventListener("keydown", searchKeyHandler); $scope.$on("$destroy", () => document.removeEventListener("keydown", searchKeyHandler)); $scope.clearFilter = (key) => { if (key === "dateRange") { $scope.query.dateFrom = ""; $scope.query.dateTo = ""; } else $scope.query[key] = ""; $scope.query.page = 1; }; $scope.chips = []; const recomputeChipsUsers = () => { const out = []; if ($scope.query.role) out.push({ key: "role", label: "Role", value: $scope.query.role }); $scope.chips = out; }; $scope.statusCountFor = (s) => { const row = ($scope.statusCounts || []).find((c) => c._id === s); return row ? row.count : 0; }; $scope.toggleSortDirection = () => { $scope.query.direction = $scope.query.direction === "asc" ? "desc" : "asc"; }; $scope.sortBy = (field) => { if ($scope.query.sort === field) { $scope.query.direction = $scope.query.direction === "asc" ? "desc" : "asc"; } else { $scope.query.sort = field; $scope.query.direction = "desc"; } $scope.query.page = 1; }; $scope.sortIcon = (field) => $scope.query.sort === field ? ($scope.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) || {}; $scope.query = Object.assign({}, usersAdminDefaults, savedUsersAdminPrefs, { page: 1, search: "", }); $scope.selectAllOnPage = () => { $scope.allSelected = !$scope.allSelected; $scope.users.forEach((u) => { $scope.selected[u.username] = $scope.allSelected; }); }; $scope.selectedCount = () => Object.values($scope.selected || {}).filter(Boolean).length; $scope.selectedUsers = () => $scope.users.filter((u) => $scope.selected[u.username]); $scope.banUser = (u) => { if (!confirm(`Ban user ${u.username}?`)) return; $http .post(`/api/admin/users/${u.username}/ban`) .then(getUsers, (err) => console.error(err)); }; $scope.activateUser = (u) => { $http .post(`/api/admin/users/${u.username}/activate`) .then(getUsers, (err) => console.error(err)); }; $scope.bulkBan = () => { const users = $scope.selectedUsers(); if (!users.length) return; if (!confirm(`Ban ${users.length} users?`)) return; users.forEach((u) => $scope.banUser(u)); }; $scope.exportCsv = () => { const params = new URLSearchParams( Object.entries($scope.query).filter(([, v]) => v !== "" && v !== false && v != null) ); params.set("format", "csv"); params.set("limit", "10000"); window.open("/api/admin/users?" + params.toString(), "_blank"); }; $scope.fetchError = null; function getUsers() { $scope.fetchError = null; $http.get("/api/admin/users", { params: $scope.query }).then( (res) => { $scope.total = res.data.total; $scope.totalPage = Math.ceil(res.data.total / $scope.query.limit); $scope.users = res.data.results; $scope.statusCounts = res.data.statusCounts || []; $scope.allSelected = false; $scope.$apply(); }, (err) => { $scope.fetchError = (err && err.data && err.data.error) || "Failed to load users"; console.error(err); } ); } getUsers(); let timeClear = null; $scope.$watch( "query", () => { clearTimeout(timeClear); timeClear = setTimeout(getUsers, 500); const { page, search, ...persisted } = $scope.query; saveFilterPrefs(usersAdminPrefsKey, persisted); recomputeChipsUsers(); }, true ); recomputeChipsUsers(); }, ]) .controller("userAdminController", [ "$scope", "$http", "$location", "$routeParams", function ($scope, $http, $location, $routeParams) { $scope.$watch("user.status", () => { if ($scope.user == null) { $location.url("/"); } }); if ($scope.user == null) { $location.url("/"); } $scope.userInfo; $scope.repositories = []; $scope.search = ""; $scope.selected = {}; $scope.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) || {}; $scope.filters = { status: Object.assign( {}, adminUserDefaults.filters.status, (savedAdminUserPrefs.filters && savedAdminUserPrefs.filters.status) || {} ), }; $scope.query = { sort: savedAdminUserPrefs.sort || adminUserDefaults.sort, direction: savedAdminUserPrefs.direction || adminUserDefaults.direction, }; $scope.orderBy = ($scope.query.direction === "asc" ? "" : "-") + $scope.query.sort; $scope.sortBy = (field) => { if ($scope.query.sort === field) { $scope.query.direction = $scope.query.direction === "asc" ? "desc" : "asc"; } else { $scope.query.sort = field; $scope.query.direction = "desc"; } $scope.orderBy = ($scope.query.direction === "asc" ? "" : "-") + $scope.query.sort; }; $scope.sortIcon = (field) => $scope.query.sort === field ? ($scope.query.direction === "asc" ? "fa-arrow-up" : "fa-arrow-down") : ""; $scope.$watch("query", () => { saveFilterPrefs(adminUserPrefsKey, { filters: $scope.filters, sort: $scope.query.sort, direction: $scope.query.direction, }); }, true); $scope.$watch( "filters", () => { saveFilterPrefs(adminUserPrefsKey, { filters: $scope.filters, sort: $scope.query.sort, direction: $scope.query.direction, }); }, true ); $scope.statusCountFor = (s) => { return ($scope.repositories || []).filter((r) => r.status === s).length; }; $scope.repoFiler = (repo) => { if ($scope.filters.status[repo.status] == false) return false; if ($scope.search.trim().length == 0) return true; if (repo.source.fullName.indexOf($scope.search) > -1) return true; if (repo.repoId.indexOf($scope.search) > -1) return true; if (repo.statusMessage && repo.statusMessage.indexOf($scope.search) > -1) return true; if (repo.conference && repo.conference.indexOf($scope.search) > -1) return true; return false; }; // -------- selection / bulk -------- $scope.selectAllOnPage = () => { $scope.allSelected = !$scope.allSelected; ($scope.filteredRepositories || $scope.repositories).forEach((r) => { $scope.selected[r.repoId] = $scope.allSelected; }); }; $scope.selectedCount = () => Object.values($scope.selected || {}).filter(Boolean).length; $scope.selectedRepos = () => $scope.repositories.filter((r) => $scope.selected[r.repoId]); $scope.bulkRefresh = () => { const repos = $scope.selectedRepos(); if (!repos.length) return; if (!confirm(`Force refresh ${repos.length} repositories?`)) return; repos.forEach((r) => $scope.updateRepository(r)); }; $scope.bulkRemoveCache = () => { const repos = $scope.selectedRepos(); if (!repos.length) return; if (!confirm(`Purge cache for ${repos.length} repositories?`)) return; repos.forEach((r) => $scope.removeCache(r)); }; $scope.clearSelection = () => { $scope.selected = {}; $scope.allSelected = false; }; // -------- export -------- $scope.exportCsv = () => { const filtered = ($scope.filteredRepositories || $scope.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 = $routeParams.username + "-repositories.csv"; a.click(); }; $scope.showStatusMessage = (repo) => { const msg = repo.statusMessage || "(no message)"; window.prompt(`Status message for ${repo.repoId} (${repo.status}):`, msg); }; $scope.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) => { $scope.repositories = res.data; }, (err) => { console.error(err); } ); } function getUser(username) { $http.get("/api/admin/users/" + username, {}).then( (res) => { $scope.userInfo = res.data; }, (err) => { console.error(err); } ); } getUser($routeParams.username); getUserRepositories($routeParams.username); $scope.banUser = () => { if (!confirm(`Ban user ${$routeParams.username}?`)) return; $http .post(`/api/admin/users/${$routeParams.username}/ban`) .then(() => getUser($routeParams.username), (err) => console.error(err)); }; $scope.activateUser = () => { $http .post(`/api/admin/users/${$routeParams.username}/activate`) .then(() => getUser($routeParams.username), (err) => console.error(err)); }; $scope.promoteUser = () => { if (!confirm(`Promote ${$routeParams.username} to admin?`)) return; $http .post(`/api/admin/users/${$routeParams.username}/promote`) .then(() => getUser($routeParams.username), (err) => console.error(err)); }; $scope.demoteUser = () => { if (!confirm(`Remove admin privileges from ${$routeParams.username}?`)) return; $http .post(`/api/admin/users/${$routeParams.username}/demote`) .then(() => getUser($routeParams.username), (err) => console.error(err)); }; $scope.tokens = []; $scope.tokenForm = { name: "", plaintext: null }; function loadTokens() { $http.get("/api/admin/tokens").then( (res) => { $scope.tokens = res.data || []; }, (err) => { if (err.status !== 401 && err.status !== 403) console.error(err); } ); } loadTokens(); $scope.createToken = () => { if (!$scope.tokenForm.name) return; $http .post("/api/admin/tokens", { name: $scope.tokenForm.name }) .then( (res) => { $scope.tokenForm.plaintext = res.data.token; $scope.tokenForm.name = ""; loadTokens(); }, (err) => console.error(err) ); }; $scope.revokeToken = (t) => { if (!confirm(`Revoke token "${t.name}"?`)) return; $http.delete("/api/admin/tokens/" + t.id).then( () => loadTokens(), (err) => console.error(err) ); }; $scope.removeCache = (repo) => { if (!confirm("Remove cached files for " + repo.repoId + "?")) return; $http.delete("/api/admin/repos/" + repo.repoId).then( () => getUserRepositories($routeParams.username), (err) => console.error(err) ); }; $scope.removeRepository = (repo) => { if (!confirm("Remove repository " + repo.repoId + "?")) return; $http.delete("/api/repo/" + repo.repoId + "/").then( () => getUserRepositories($routeParams.username), (err) => console.error(err) ); }; $scope.updateRepository = (repo) => { const toast = { title: `Refreshing ${repo.repoId}...`, date: new Date(), body: `The repository ${repo.repoId} is going to be refreshed.`, }; $scope.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; } ); }; $scope.getGitHubRepositories = (force) => { $http .get(`/api/user/${$scope.userInfo.username}/all_repositories`, { params: { force: "1" }, }) .then((res) => { $scope.userInfo.repositories = res.data; }); }; let timeClear = null; $scope.$watch( "query", () => { clearTimeout(timeClear); timeClear = setTimeout(() => { getUserRepositories($routeParams.username); }, 500); }, true ); }, ]) .controller("conferencesAdminController", [ "$scope", "$http", "$location", function ($scope, $http, $location) { $scope.Math = Math; $scope.$watch("user.status", () => { if ($scope.user == null) { $location.url("/"); } }); if ($scope.user == null) { $location.url("/"); } $scope.conferences = []; $scope.total = -1; $scope.totalPage = 0; $scope.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(); } }; document.addEventListener("keydown", searchKeyHandler); $scope.$on("$destroy", () => document.removeEventListener("keydown", searchKeyHandler)); $scope.clearFilter = (key) => { if (key === "dateRange") { $scope.query.dateFrom = ""; $scope.query.dateTo = ""; } else $scope.query[key] = ""; $scope.query.page = 1; }; $scope.chips = []; const recomputeChipsConf = () => { const out = []; if ($scope.query.dateFrom || $scope.query.dateTo) out.push({ key: "dateRange", label: "Date", value: ($scope.query.dateFrom || "…") + " – " + ($scope.query.dateTo || "…") }); $scope.chips = out; }; $scope.statusCountFor = (s) => { const row = ($scope.statusCounts || []).find((c) => c._id === s); return row ? row.count : 0; }; $scope.toggleSortDirection = () => { $scope.query.direction = $scope.query.direction === "asc" ? "desc" : "asc"; }; $scope.sortBy = (field) => { if ($scope.query.sort === field) { $scope.query.direction = $scope.query.direction === "asc" ? "desc" : "asc"; } else { $scope.query.sort = field; $scope.query.direction = "desc"; } $scope.query.page = 1; }; $scope.sortIcon = (field) => $scope.query.sort === field ? ($scope.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) || {}; $scope.query = Object.assign({}, confAdminDefaults, savedConfAdminPrefs, { page: 1, search: "", }); // pre-fill filters from URL ?search= const urlParams = $location.search(); if (urlParams.search) $scope.query.search = urlParams.search; // -------- presets -------- const confPresetsKey = "admin.conferences.presets"; $scope.presets = JSON.parse(localStorage.getItem(confPresetsKey) || "[]"); $scope.savePreset = () => { const name = window.prompt("Preset name:"); if (!name) return; const snapshot = Object.assign({}, $scope.query); delete snapshot.page; $scope.presets = ($scope.presets || []).filter((p) => p.name !== name); $scope.presets.push({ name, query: snapshot }); localStorage.setItem(confPresetsKey, JSON.stringify($scope.presets)); }; $scope.applyPreset = (p) => { Object.assign($scope.query, p.query, { page: 1 }); }; $scope.deletePreset = (p) => { $scope.presets = ($scope.presets || []).filter((x) => x.name !== p.name); localStorage.setItem(confPresetsKey, JSON.stringify($scope.presets)); }; $scope.removeConference = (conference) => { if (!confirm("Remove conference " + conference.conferenceID + "?")) return; $http.delete("/api/admin/conferences/" + conference.conferenceID).then( () => getConferences(), (err) => console.error(err) ); }; $scope.exportCsv = () => { const params = new URLSearchParams( Object.entries($scope.query).filter(([, v]) => v !== "" && v !== false && v != null) ); params.set("format", "csv"); params.set("limit", "10000"); window.open("/api/admin/conferences?" + params.toString(), "_blank"); }; $scope.fetchError = null; function getConferences() { $scope.fetchError = null; $http.get("/api/admin/conferences", { params: $scope.query }).then( (res) => { $scope.total = res.data.total; $scope.totalPage = Math.ceil(res.data.total / $scope.query.limit); $scope.conferences = res.data.results; $scope.statusCounts = res.data.statusCounts || []; }, (err) => { $scope.fetchError = (err && err.data && err.data.error) || "Failed to load conferences"; console.error(err); } ); } getConferences(); let timeClear = null; $scope.$watch( "query", () => { clearTimeout(timeClear); timeClear = setTimeout(getConferences, 500); const { page, search, ...persisted } = $scope.query; saveFilterPrefs(confAdminPrefsKey, persisted); recomputeChipsConf(); }, true ); recomputeChipsConf(); }, ]) .controller("queuesAdminController", [ "$scope", "$http", "$location", "$interval", "$timeout", function ($scope, $http, $location, $interval, $timeout) { $scope.$watch("user.status", () => { if ($scope.user == null) $location.url("/"); }); if ($scope.user == null) $location.url("/"); $scope.queueList = []; $scope.jobs = []; $scope.selectedQueue = "download"; $scope.selectedStats = null; $scope.range = "1h"; $scope.allStates = ["active", "waiting", "delayed", "failed", "completed"]; $scope.stateFilter = { active: true, waiting: true, delayed: true, failed: true, completed: true }; $scope.query = { search: "", autoRefresh: true, }; $scope.filteredJobs = () => { return ($scope.jobs || []).filter((j) => $scope.stateFilter[j._state]); }; $scope.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; }; $scope.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"; }; $scope.metricsPoints = []; $scope.selectQueue = (key) => { $scope.selectedQueue = key; getQueues(); getMetrics(); }; $scope.setRange = (r) => { $scope.range = r; getMetrics(); }; function getQueues() { const params = { queue: $scope.selectedQueue, search: $scope.query.search, }; $http.get("/api/admin/queues", { params }).then( (res) => { $scope.queueList = res.data.queues || []; $scope.jobs = res.data.jobs || []; $scope.selectedStats = $scope.queueList.find((q) => q.key === $scope.selectedQueue) || $scope.queueList[0] || null; }, (err) => console.error(err) ); } function getMetrics() { $http.get("/api/admin/queues/metrics", { params: { queue: $scope.selectedQueue, range: $scope.range } }).then( (res) => { $scope.metricsPoints = res.data.points || []; $timeout(drawChart, 0); }, (err) => console.error(err) ); } getQueues(); getMetrics(); const stop = $interval(() => { if ($scope.query.autoRefresh) { getQueues(); getMetrics(); } }, 5000); $scope.$on("$destroy", () => $interval.cancel(stop)); $scope.refreshNow = function () { getQueues(); getMetrics(); }; function apiError(err) { const msg = (err && err.data && (err.data.message || err.data.error)) || "Request failed"; $scope.actionError = msg; $timeout(() => { $scope.actionError = null; }, 5000); console.error(err); } $scope.actionError = null; $scope.removeJob = (job) => { $http.delete(`/api/admin/queue/${$scope.selectedQueue}/${job.id}`).then(getQueues, apiError); }; $scope.retryJob = (job) => { $http.post(`/api/admin/queue/${$scope.selectedQueue}/${job.id}`).then(getQueues, apiError); }; $scope.retryFailed = () => { if (!confirm(`Retry all failed jobs in ${$scope.selectedQueue}?`)) return; $http.post(`/api/admin/queue/${$scope.selectedQueue}/retry-failed`).then(getQueues, (err) => console.error(err)); }; $scope.drainSelected = () => { if (!confirm(`Drain the ${$scope.selectedQueue} queue?`)) return; $http.post(`/api/admin/queue/${$scope.selectedQueue}/drain`).then(getQueues, (err) => console.error(err)); }; $scope.togglePause = () => { const action = $scope.selectedStats && $scope.selectedStats.paused ? "resume" : "pause"; $http.post(`/api/admin/queue/${$scope.selectedQueue}/${action}`).then(getQueues, (err) => console.error(err)); }; $scope.emptyQueue = () => { if (!confirm(`Empty the ${$scope.selectedQueue} queue? This removes ALL jobs.`)) return; $http.post(`/api/admin/queue/${$scope.selectedQueue}/empty`).then(getQueues, (err) => console.error(err)); }; $scope.pauseAll = () => { if (!confirm("Pause all queues?")) return; $http.post("/api/admin/queues/pause-all").then(getQueues, (err) => console.error(err)); }; let searchClear = null; $scope.$watch("query.search", () => { clearTimeout(searchClear); searchClear = setTimeout(getQueues, 350); }); $scope.expanded = {}; $scope.toggleJob = (job) => { $scope.expanded[job.id] = !$scope.expanded[job.id]; }; $scope.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" }); }; $scope.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 = $scope.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 = '