mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-05-15 14:38:03 +02:00
1282 lines
43 KiB
JavaScript
1282 lines
43 KiB
JavaScript
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("<pre>Loading GitHub info for " + repo.repoId + "...</pre>");
|
|
$http.get("/api/admin/repos/" + repo.repoId + "/github").then(
|
|
(res) => {
|
|
if (w) {
|
|
w.document.open();
|
|
w.document.write(
|
|
"<pre style=\"font:13px monospace;padding:16px;white-space:pre-wrap\">" +
|
|
JSON.stringify(res.data, null, 2).replace(/[<>]/g, (c) => c === "<" ? "<" : ">") +
|
|
"</pre>"
|
|
);
|
|
w.document.close();
|
|
}
|
|
},
|
|
(err) => {
|
|
const msg = err && err.data ? JSON.stringify(err.data, null, 2) : String(err);
|
|
if (w) w.document.body.innerHTML = "<pre style=\"color:#B42318;padding:16px\">" + msg + "</pre>";
|
|
}
|
|
);
|
|
};
|
|
|
|
$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 owner / conference from URL ?owner= / ?conference=
|
|
const params = new URLSearchParams(window.location.search);
|
|
if (params.get("owner")) $scope.query.owner = params.get("owner");
|
|
if (params.get("conference")) $scope.query.conference = params.get("conference");
|
|
|
|
// -------- 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) => {
|
|
$http.delete("/api/admin/repos/" + repo.repoId).then(
|
|
(res) => {
|
|
$scope.$apply();
|
|
},
|
|
(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 = "";
|
|
|
|
const adminUserPrefsKey = "admin.user.filterPrefs";
|
|
const adminUserDefaults = {
|
|
filters: { status: { ready: true, expired: true, removed: true, error: true, preparing: true } },
|
|
orderBy: "-anonymizeDate",
|
|
};
|
|
const savedAdminUserPrefs = loadFilterPrefs(adminUserPrefsKey) || {};
|
|
$scope.filters = {
|
|
status: Object.assign(
|
|
{},
|
|
adminUserDefaults.filters.status,
|
|
(savedAdminUserPrefs.filters && savedAdminUserPrefs.filters.status) || {}
|
|
),
|
|
};
|
|
$scope.orderBy = savedAdminUserPrefs.orderBy || adminUserDefaults.orderBy;
|
|
|
|
$scope.$watch("orderBy", () => {
|
|
saveFilterPrefs(adminUserPrefsKey, {
|
|
filters: $scope.filters,
|
|
orderBy: $scope.orderBy,
|
|
});
|
|
});
|
|
$scope.$watch(
|
|
"filters",
|
|
() => {
|
|
saveFilterPrefs(adminUserPrefsKey, {
|
|
filters: $scope.filters,
|
|
orderBy: $scope.orderBy,
|
|
});
|
|
},
|
|
true
|
|
);
|
|
|
|
$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;
|
|
|
|
return false;
|
|
};
|
|
|
|
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.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) => {
|
|
$http.delete("/api/admin/repos/" + repo.repoId).then(
|
|
(res) => {
|
|
$scope.$apply();
|
|
},
|
|
(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 = () => {
|
|
$scope.chips = [];
|
|
};
|
|
|
|
$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: "",
|
|
status: "",
|
|
dateFrom: "",
|
|
dateTo: "",
|
|
};
|
|
const savedConfAdminPrefs = loadFilterPrefs(confAdminPrefsKey) || {};
|
|
$scope.query = Object.assign({}, confAdminDefaults, savedConfAdminPrefs, {
|
|
page: 1,
|
|
search: "",
|
|
});
|
|
|
|
$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 || [];
|
|
$scope.$apply();
|
|
},
|
|
(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",
|
|
function ($scope, $http, $location, $interval) {
|
|
$scope.$watch("user.status", () => {
|
|
if ($scope.user == null) {
|
|
$location.url("/");
|
|
}
|
|
});
|
|
if ($scope.user == null) {
|
|
$location.url("/");
|
|
}
|
|
|
|
$scope.downloadJobs = [];
|
|
$scope.removeJobs = [];
|
|
$scope.removeCaches = [];
|
|
$scope.counts = { download: {}, remove: {}, cache: {} };
|
|
$scope.query = {
|
|
search: "",
|
|
state: "",
|
|
autoRefresh: true,
|
|
};
|
|
|
|
$scope.jobMatchesState = (job) => {
|
|
if (!$scope.query.state) return true;
|
|
const finished = !!job.finishedOn;
|
|
const failed = (job.stacktrace || []).length > 0 || job.failedReason;
|
|
const map = {
|
|
completed: finished && !failed,
|
|
failed: failed,
|
|
active: job.processedOn && !finished,
|
|
waiting: !job.processedOn,
|
|
};
|
|
return !!map[$scope.query.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.bulkRetryFailed = (queue) => {
|
|
if (!confirm(`Retry all failed jobs in the ${queue} queue?`)) return;
|
|
$http.post(`/api/admin/queue/${queue}/retry-failed`).then(getQueues, (err) => console.error(err));
|
|
};
|
|
$scope.bulkDrain = (queue) => {
|
|
if (!confirm(`Drain (clear waiting+delayed) the ${queue} queue?`)) return;
|
|
$http.post(`/api/admin/queue/${queue}/drain`).then(getQueues, (err) => console.error(err));
|
|
};
|
|
|
|
function getQueues() {
|
|
$http.get("/api/admin/queues", { params: $scope.query }).then(
|
|
(res) => {
|
|
$scope.downloadJobs = res.data.downloadQueue;
|
|
$scope.removeJobs = res.data.removeQueue;
|
|
$scope.removeCaches = res.data.cacheQueue;
|
|
$scope.counts = res.data.counts || $scope.counts;
|
|
},
|
|
(err) => {
|
|
console.error(err);
|
|
}
|
|
);
|
|
}
|
|
getQueues();
|
|
|
|
// auto-refresh every 5 seconds while autoRefresh is on
|
|
const stop = $interval(() => {
|
|
if ($scope.query.autoRefresh) getQueues();
|
|
}, 5000);
|
|
$scope.$on("$destroy", () => $interval.cancel(stop));
|
|
|
|
$scope.refreshNow = getQueues;
|
|
|
|
$scope.removeJob = function (queue, job) {
|
|
$http
|
|
.delete(`/api/admin/queue/${queue}/${job.id}`, {
|
|
params: $scope.query,
|
|
})
|
|
.then(
|
|
(res) => {
|
|
getQueues();
|
|
},
|
|
(err) => {
|
|
console.error(err);
|
|
}
|
|
);
|
|
};
|
|
|
|
$scope.retryJob = function (queue, job) {
|
|
$http
|
|
.post(`/api/admin/queue/${queue}/${job.id}`, {
|
|
params: $scope.query,
|
|
})
|
|
.then(
|
|
(res) => {
|
|
getQueues();
|
|
},
|
|
(err) => {
|
|
console.error(err);
|
|
}
|
|
);
|
|
};
|
|
|
|
let searchClear = null;
|
|
$scope.$watch(
|
|
"query.search",
|
|
() => {
|
|
clearTimeout(searchClear);
|
|
searchClear = setTimeout(getQueues, 350);
|
|
}
|
|
);
|
|
$scope.$watch("query.state", getQueues);
|
|
},
|
|
])
|
|
.controller("errorsAdminController", [
|
|
"$scope",
|
|
"$http",
|
|
"$location",
|
|
"$interval",
|
|
function ($scope, $http, $location, $interval) {
|
|
$scope.$watch("user.status", () => {
|
|
if ($scope.user == null) {
|
|
$location.url("/");
|
|
}
|
|
});
|
|
if ($scope.user == null) {
|
|
$location.url("/");
|
|
}
|
|
|
|
$scope.entries = [];
|
|
$scope.visible = [];
|
|
$scope.available = true;
|
|
$scope.cap = 1000;
|
|
$scope.total = 0;
|
|
$scope.pageSize = 250;
|
|
$scope.expanded = {};
|
|
$scope.detailTab = {};
|
|
$scope.copyHint = "";
|
|
$scope.parsedFilterCount = 0;
|
|
$scope.stats = { last24h: 0, prev24h: 0, delta: 0, severity: { error: 0, warn: 0, info: 0 }, unique: { error: 0, warn: 0, info: 0 }, buckets: [], dropped: 0 };
|
|
$scope.query = {
|
|
search: "",
|
|
bucket: "",
|
|
sort: "recent",
|
|
group: "code",
|
|
autoRefresh: true,
|
|
};
|
|
|
|
$scope.relTime = (iso) => {
|
|
if (!iso) return "";
|
|
const t = new Date(iso).getTime();
|
|
if (isNaN(t)) return iso;
|
|
const diff = Math.max(0, Date.now() - t);
|
|
const s = Math.floor(diff / 1000);
|
|
if (s < 5) return "just now";
|
|
if (s < 60) return `${s}s ago`;
|
|
const m = Math.floor(s / 60);
|
|
if (m < 60) return `${m}m ago`;
|
|
const h = Math.floor(m / 60);
|
|
if (h < 24) return `${h}h ago`;
|
|
const d = Math.floor(h / 24);
|
|
if (d < 7) return `${d}d ago`;
|
|
return new Date(iso).toLocaleDateString();
|
|
};
|
|
$scope.absTime = (iso) => {
|
|
if (!iso) return "";
|
|
const d = new Date(iso);
|
|
if (isNaN(d.getTime())) return iso;
|
|
return d.toLocaleString();
|
|
};
|
|
$scope.absTimeShort = (iso) => {
|
|
if (!iso) return "";
|
|
const d = new Date(iso);
|
|
if (isNaN(d.getTime())) return iso;
|
|
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false });
|
|
};
|
|
|
|
// Decorate each entry once with derived display fields. Pre-computing
|
|
// avoids returning new arrays from template functions each digest
|
|
// cycle (which trips Angular's $rootScope:infdig).
|
|
// snake_case-ish identifier looking like an error key. Accepts both
|
|
// pure lowercase ("repo_not_found") and the mixed-case style this
|
|
// codebase uses ("repoId_already_used", "invalid_repoId").
|
|
const errorKeyRe = /^[a-zA-Z][a-zA-Z0-9]*(?:_[a-zA-Z0-9]+)+$/;
|
|
function bucketFor(detail, level) {
|
|
const s =
|
|
(detail && (detail.httpStatus || detail.status)) || null;
|
|
if (typeof s === "number") {
|
|
if (s >= 500) return "error";
|
|
if (s === 401 || s === 403 || s === 404) return "info";
|
|
if (s >= 400) return "warn";
|
|
}
|
|
if (level === "error") return "error";
|
|
if (level === "warn") return "warn";
|
|
return "info";
|
|
}
|
|
function decorate(e) {
|
|
const detail = (e.raw || []).find(
|
|
(a) => a && typeof a === "object" && !Array.isArray(a)
|
|
);
|
|
if (detail) {
|
|
if (detail.message && errorKeyRe.test(detail.message)) {
|
|
e.displayMessage = detail.message;
|
|
e.displayContext = e.message;
|
|
} else if (detail.code && errorKeyRe.test(String(detail.code))) {
|
|
e.displayMessage = String(detail.code);
|
|
e.displayContext = e.message;
|
|
} else if (
|
|
detail.name &&
|
|
detail.name !== "AnonymousError" &&
|
|
detail.name !== "Error"
|
|
) {
|
|
// Plain JS errors (SyntaxError, TypeError, RangeError, ...) — use
|
|
// the class name as the visible code; the original message is
|
|
// shown as italic context.
|
|
e.displayMessage = detail.name;
|
|
e.displayContext = detail.message || e.message;
|
|
} else {
|
|
e.displayMessage = e.message;
|
|
}
|
|
e._status = detail.httpStatus || detail.status || null;
|
|
e._url = detail.url || null;
|
|
e._method = detail.method || null;
|
|
e._repoId = detail.repoId || detail.detail || null;
|
|
e._detail = detail.detail && detail.detail !== e._repoId ? detail.detail : null;
|
|
// Walk into `cause` to surface the deepest stack — for unhandled
|
|
// errors the inner cause is usually the actual JS error frame.
|
|
let s = typeof detail.stack === "string" ? detail.stack : null;
|
|
let c = detail.cause;
|
|
while (!s && c && typeof c === "object") {
|
|
if (typeof c.stack === "string") s = c.stack;
|
|
c = c.cause;
|
|
}
|
|
e._stack = s;
|
|
} else {
|
|
e.displayMessage = e.message;
|
|
e._status = null;
|
|
e._url = null;
|
|
e._stack = null;
|
|
}
|
|
e._bucket = bucketFor(detail, e.level);
|
|
e._detailJson = renderDisplayPayload(e, detail);
|
|
return e;
|
|
}
|
|
|
|
// Build a curated, column-aligned JSON payload for the Raw tab. Mirrors
|
|
// the reference admin design: name / code / kind / httpStatus / module /
|
|
// detail / url / ts on aligned colons. We can't just JSON.stringify the
|
|
// raw entry because it includes the human "anonymous error" wrapper
|
|
// arg and the keys aren't column-aligned.
|
|
function renderDisplayPayload(entry, detail) {
|
|
const fields = [];
|
|
const push = (k, v) => {
|
|
if (v === undefined || v === null || v === "") return;
|
|
fields.push([k, v]);
|
|
};
|
|
push("name", detail && detail.name);
|
|
push("code", entry.displayMessage || (detail && detail.message));
|
|
if (entry._bucket) push("kind", entry._bucket);
|
|
push("httpStatus", detail && detail.httpStatus);
|
|
if (detail && detail.status && !(detail.httpStatus)) push("status", detail.status);
|
|
push("module", entry.module);
|
|
// AnonymousError.detail() can return a JSON-encoded string for
|
|
// structured payloads (e.g. {"repoId":"...","terms":[],"fullName":...}).
|
|
// Try to parse it so the renderer can pretty-print it multi-line
|
|
// instead of dumping an unreadable escape-soup blob.
|
|
let detailValue = detail && detail.detail;
|
|
if (typeof detailValue === "string") {
|
|
const trimmed = detailValue.trim();
|
|
if (trimmed[0] === "{" || trimmed[0] === "[") {
|
|
try {
|
|
detailValue = JSON.parse(detailValue);
|
|
} catch {
|
|
/* leave as string */
|
|
}
|
|
}
|
|
}
|
|
push("detail", detailValue);
|
|
push("url", entry._url);
|
|
push("ts", entry.ts);
|
|
if (!fields.length) return JSON.stringify(entry, null, 2);
|
|
const keyW = fields.reduce((w, f) => Math.max(w, f[0].length), 0);
|
|
const lines = ["{"];
|
|
fields.forEach(([k, v], i) => {
|
|
const key = `"${k}":`.padEnd(keyW + 3, " ");
|
|
const prefix = ` ${key} `;
|
|
const comma = i < fields.length - 1 ? "," : "";
|
|
let val;
|
|
if (v && typeof v === "object") {
|
|
// Indent continuation lines under the value column so the nested
|
|
// object reads like a column instead of breaking flow.
|
|
const pad = " ".repeat(prefix.length);
|
|
val = JSON.stringify(v, null, 2)
|
|
.split("\n")
|
|
.map((ln, idx) => (idx === 0 ? ln : pad + ln))
|
|
.join("\n");
|
|
} else if (typeof v === "number" || typeof v === "boolean") {
|
|
val = String(v);
|
|
} else {
|
|
val = JSON.stringify(v);
|
|
}
|
|
lines.push(`${prefix}${val}${comma}`);
|
|
});
|
|
lines.push("}");
|
|
return lines.join("\n");
|
|
}
|
|
|
|
// Lightweight filter parser. Pulls `key:value` and `status:>=400` style
|
|
// tokens out of the search box; everything else falls back to a free
|
|
// text contains-match against the rendered fields.
|
|
function parseFilter(input) {
|
|
const filters = [];
|
|
let free = "";
|
|
const re = /(\w+):(>=|<=|!=|>|<|=)?([^\s]+)/g;
|
|
let lastEnd = 0;
|
|
let m;
|
|
while ((m = re.exec(input))) {
|
|
free += input.slice(lastEnd, m.index);
|
|
lastEnd = re.lastIndex;
|
|
filters.push({ key: m[1], op: m[2] || "=", val: m[3] });
|
|
}
|
|
free += input.slice(lastEnd);
|
|
return { filters, free: free.trim().toLowerCase() };
|
|
}
|
|
function matchFilter(row, parsed) {
|
|
for (const f of parsed.filters) {
|
|
const cmp = (a, b, op) => {
|
|
const an = parseFloat(a);
|
|
const bn = parseFloat(b);
|
|
if (op === "=") return String(a) === String(b);
|
|
if (op === "!=") return String(a) !== String(b);
|
|
if (op === ">=") return an >= bn;
|
|
if (op === "<=") return an <= bn;
|
|
if (op === ">") return an > bn;
|
|
if (op === "<") return an < bn;
|
|
return true;
|
|
};
|
|
let v;
|
|
if (f.key === "code") v = row.displayMessage;
|
|
else if (f.key === "module") v = row.module;
|
|
else if (f.key === "status") v = row._status;
|
|
else if (f.key === "url") v = row._url;
|
|
else if (f.key === "repo") v = row._repoId;
|
|
else if (f.key === "level") v = row.level;
|
|
else continue;
|
|
if (v == null) return false;
|
|
if (!cmp(v, f.val, f.op)) return false;
|
|
}
|
|
if (parsed.free) {
|
|
const hay = (
|
|
(row.displayMessage || "") + " " +
|
|
(row.module || "") + " " +
|
|
(row._url || "") + " " +
|
|
JSON.stringify(row.raw || [])
|
|
).toLowerCase();
|
|
if (hay.indexOf(parsed.free) === -1) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function recompute() {
|
|
const parsed = parseFilter($scope.query.search || "");
|
|
$scope.parsedFilterCount = parsed.filters.length;
|
|
const bucket = $scope.query.bucket;
|
|
let rows = $scope.entries.filter((e) => {
|
|
if (bucket && e._bucket !== bucket) return false;
|
|
return matchFilter(e, parsed);
|
|
});
|
|
|
|
const group = $scope.query.group;
|
|
if (group) {
|
|
const keyOf = (r) =>
|
|
group === "module" ? r.module : (r.displayMessage || r.message || "_");
|
|
const map = new Map();
|
|
for (const r of rows) {
|
|
const k = keyOf(r);
|
|
if (!map.has(k)) {
|
|
const seed = Object.assign({}, r);
|
|
seed._key = `${group}:${k}`;
|
|
seed._related = [r];
|
|
seed._firstSeen = r.ts;
|
|
seed._lastHourCount = 0;
|
|
seed.count = 1;
|
|
map.set(k, seed);
|
|
} else {
|
|
const g = map.get(k);
|
|
g.count++;
|
|
g._related.push(r);
|
|
if (new Date(r.ts) > new Date(g.ts)) {
|
|
g.ts = r.ts;
|
|
g._url = r._url;
|
|
g._status = r._status;
|
|
}
|
|
if (new Date(r.ts) < new Date(g._firstSeen)) g._firstSeen = r.ts;
|
|
}
|
|
}
|
|
// count "this hour"
|
|
const cutoffH = Date.now() - 3600 * 1000;
|
|
for (const g of map.values()) {
|
|
g._lastHourCount = g._related.filter((r) => new Date(r.ts).getTime() >= cutoffH).length;
|
|
}
|
|
rows = Array.from(map.values());
|
|
} else {
|
|
rows = rows.map((r, i) => {
|
|
r._key = "row:" + i + ":" + r.ts;
|
|
r._related = [r];
|
|
r._firstSeen = r.ts;
|
|
r._lastHourCount = 0;
|
|
r.count = 1;
|
|
return r;
|
|
});
|
|
}
|
|
|
|
if ($scope.query.sort === "count") {
|
|
rows.sort((a, b) => b.count - a.count || new Date(b.ts) - new Date(a.ts));
|
|
} else {
|
|
rows.sort((a, b) => new Date(b.ts) - new Date(a.ts));
|
|
}
|
|
$scope.visible = rows;
|
|
}
|
|
|
|
function loadEntries(append) {
|
|
// On auto-refresh after the user has paginated ("Load older"),
|
|
// request the SAME-sized window from the head so we don't blow away
|
|
// their loaded tail. Newer entries take the top, the oldest visible
|
|
// ones drop off naturally as the redis list rotates.
|
|
const offset = append ? $scope.entries.length : 0;
|
|
const limit = append
|
|
? $scope.pageSize
|
|
: Math.max($scope.pageSize, $scope.entries.length || $scope.pageSize);
|
|
$http
|
|
.get("/api/admin/errors", { params: { offset, limit } })
|
|
.then(
|
|
(res) => {
|
|
const next = (res.data.entries || []).map(decorate);
|
|
$scope.entries = append ? $scope.entries.concat(next) : next;
|
|
$scope.available = !!res.data.available;
|
|
$scope.cap = res.data.max || $scope.cap;
|
|
$scope.total = res.data.total || $scope.entries.length;
|
|
recompute();
|
|
},
|
|
(err) => console.error(err)
|
|
);
|
|
}
|
|
$scope.loadMore = () => loadEntries(true);
|
|
$scope.canLoadMore = () => $scope.entries.length < $scope.total;
|
|
function loadStats() {
|
|
$http.get("/api/admin/errors/stats").then(
|
|
(res) => {
|
|
const s = res.data || {};
|
|
const delta = s.prev24h ? Math.round(((s.last24h - s.prev24h) / s.prev24h) * 100) : 0;
|
|
$scope.stats = {
|
|
last24h: s.last24h || 0,
|
|
prev24h: s.prev24h || 0,
|
|
delta,
|
|
severity: s.severity || { error: 0, warn: 0, info: 0 },
|
|
unique: s.unique || { error: 0, warn: 0, info: 0 },
|
|
buckets: s.buckets || [],
|
|
dropped: s.dropped || 0,
|
|
};
|
|
},
|
|
(err) => console.error(err)
|
|
);
|
|
}
|
|
function load() {
|
|
loadEntries();
|
|
loadStats();
|
|
}
|
|
|
|
// For the volume chart: scale tallest bucket-total to a fixed pixel max.
|
|
$scope.barPx = (b, key) => {
|
|
const all = $scope.stats.buckets || [];
|
|
let max = 0;
|
|
for (const x of all) max = Math.max(max, (x.error || 0) + (x.warn || 0) + (x.info || 0));
|
|
if (!max) return 0;
|
|
const total = (b.error || 0) + (b.warn || 0) + (b.info || 0);
|
|
if (!total) return 0;
|
|
const targetTotal = Math.round((total / max) * 60); // 60px max
|
|
const part = b[key] || 0;
|
|
return Math.round((part / total) * targetTotal);
|
|
};
|
|
$scope.bucketTitle = (b) => {
|
|
const t = new Date(b.hour);
|
|
return `${t.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} · ${b.error || 0} err · ${b.warn || 0} warn · ${b.info || 0} info`;
|
|
};
|
|
|
|
$scope.toggle = (row) => {
|
|
$scope.expanded[row._key] = !$scope.expanded[row._key];
|
|
};
|
|
$scope.setBucket = (b) => {
|
|
$scope.query.bucket = b;
|
|
};
|
|
|
|
$scope.refreshNow = load;
|
|
$scope.clearAll = () => {
|
|
if (!confirm("Clear all captured errors?")) return;
|
|
$http.delete("/api/admin/errors").then(load, (err) => console.error(err));
|
|
};
|
|
$scope.exportCsv = () => {
|
|
const cols = ["ts", "level", "module", "displayMessage", "_status", "_url", "_repoId"];
|
|
const lines = [cols.join(",")];
|
|
for (const r of $scope.visible) {
|
|
lines.push(cols.map((c) => {
|
|
const v = r[c] == null ? "" : String(r[c]);
|
|
return /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
|
|
}).join(","));
|
|
}
|
|
const blob = new Blob([lines.join("\n")], { type: "text/csv;charset=utf-8" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `errors-${new Date().toISOString().slice(0, 19)}.csv`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
function flashCopy(label) {
|
|
$scope.copyHint = `${label} copied`;
|
|
setTimeout(() => { $scope.copyHint = ""; $scope.$apply(); }, 1500);
|
|
}
|
|
$scope.copyJson = (row) => {
|
|
navigator.clipboard.writeText(row._detailJson).then(() => flashCopy("JSON"));
|
|
};
|
|
$scope.copyCurl = (row) => {
|
|
if (!row._url) return;
|
|
const method = row._method || "GET";
|
|
const cmd = `curl -X ${method} '${window.location.origin}${row._url}'`;
|
|
navigator.clipboard.writeText(cmd).then(() => flashCopy("curl"));
|
|
};
|
|
|
|
load();
|
|
const stop = $interval(() => {
|
|
if ($scope.query.autoRefresh) load();
|
|
}, 5000);
|
|
$scope.$on("$destroy", () => $interval.cancel(stop));
|
|
|
|
$scope.$watch("query.search", recompute);
|
|
$scope.$watch("query.bucket", recompute);
|
|
$scope.$watch("query.sort", recompute);
|
|
$scope.$watch("query.group", recompute);
|
|
},
|
|
]);
|