after audit
This commit is contained in:
parent
e8e20637e6
commit
a5165dc3ba
80 changed files with 8489 additions and 7302 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,72 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import AdminIntegritySection from "./AdminIntegritySection.vue";
|
||||
|
||||
vi.mock("@/features/admin_dbops", () => ({
|
||||
getAdminDbIntegrityReport: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getAdminDbIntegrityReport, type AdminDbIntegrityReport } from "@/features/admin_dbops";
|
||||
|
||||
const mockedGetReport = vi.mocked(getAdminDbIntegrityReport);
|
||||
|
||||
function buildReport(overrides: Partial<AdminDbIntegrityReport> = {}): AdminDbIntegrityReport {
|
||||
return {
|
||||
status: "ok",
|
||||
warnings: [],
|
||||
failures: [],
|
||||
checked_at: "2025-01-15T12:00:00Z",
|
||||
checks: [
|
||||
{ name: "orphaned_publications", count: 0, severity: "metric", message: "No orphaned publications found." },
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AdminIntegritySection", () => {
|
||||
beforeEach(() => {
|
||||
mockedGetReport.mockReset();
|
||||
});
|
||||
|
||||
it("renders the section heading", () => {
|
||||
const wrapper = mount(AdminIntegritySection);
|
||||
expect(wrapper.text()).toContain("Integrity Report");
|
||||
});
|
||||
|
||||
it("exposes load function that fetches integrity report", async () => {
|
||||
mockedGetReport.mockResolvedValueOnce(buildReport());
|
||||
const wrapper = mount(AdminIntegritySection);
|
||||
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
|
||||
await vm.load();
|
||||
await flushPromises();
|
||||
expect(mockedGetReport).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders check results after loading", async () => {
|
||||
mockedGetReport.mockResolvedValueOnce(buildReport());
|
||||
const wrapper = mount(AdminIntegritySection);
|
||||
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
|
||||
await vm.load();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain("orphaned_publications");
|
||||
});
|
||||
|
||||
it("displays warning count when present", async () => {
|
||||
mockedGetReport.mockResolvedValueOnce(buildReport({ warnings: ["w1", "w2", "w3"] }));
|
||||
const wrapper = mount(AdminIntegritySection);
|
||||
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
|
||||
await vm.load();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain("3");
|
||||
});
|
||||
|
||||
it("renders status badge", async () => {
|
||||
mockedGetReport.mockResolvedValueOnce(buildReport({ status: "failed" }));
|
||||
const wrapper = mount(AdminIntegritySection);
|
||||
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
|
||||
await vm.load();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain("failed");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import AppBadge from "@/components/ui/AppBadge.vue";
|
||||
import AppCard from "@/components/ui/AppCard.vue";
|
||||
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||
import AppRefreshButton from "@/components/ui/AppRefreshButton.vue";
|
||||
import AppTable from "@/components/ui/AppTable.vue";
|
||||
import {
|
||||
getAdminDbIntegrityReport,
|
||||
type AdminDbIntegrityCheck,
|
||||
type AdminDbIntegrityReport,
|
||||
} from "@/features/admin_dbops";
|
||||
|
||||
const refreshingIntegrity = ref(false);
|
||||
const integrityReport = ref<AdminDbIntegrityReport | null>(null);
|
||||
|
||||
function formatTimestamp(value: string | null): string {
|
||||
if (!value) return "n/a";
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function statusTone(status: string): "success" | "warning" | "danger" | "info" | "neutral" {
|
||||
if (status === "ok" || status === "completed" || status === "resolved") return "success";
|
||||
if (status === "warning" || status === "running" || status === "queued") return "warning";
|
||||
if (status === "failed") return "danger";
|
||||
return "info";
|
||||
}
|
||||
|
||||
function checkTone(check: AdminDbIntegrityCheck): "warning" | "danger" | "neutral" | "info" {
|
||||
if (check.severity === "metric") return "info";
|
||||
if (check.count <= 0) return "neutral";
|
||||
return check.severity === "failure" ? "danger" : "warning";
|
||||
}
|
||||
|
||||
async function refreshIntegrity(): Promise<void> {
|
||||
refreshingIntegrity.value = true;
|
||||
try {
|
||||
integrityReport.value = await getAdminDbIntegrityReport();
|
||||
} finally {
|
||||
refreshingIntegrity.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
await refreshIntegrity();
|
||||
}
|
||||
|
||||
defineExpose({ load });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppCard class="space-y-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<h2 class="text-lg font-semibold text-ink-primary">Integrity Report</h2>
|
||||
<AppHelpHint text="Read-only checks for known corruption patterns and data drift." />
|
||||
</div>
|
||||
<AppRefreshButton variant="secondary" :loading="refreshingIntegrity" title="Refresh integrity report" loading-title="Refreshing integrity report" @click="refreshIntegrity" />
|
||||
</div>
|
||||
|
||||
<div v-if="integrityReport" class="flex flex-wrap items-center gap-2 text-xs">
|
||||
<AppBadge :tone="statusTone(integrityReport.status)">Status: {{ integrityReport.status }}</AppBadge>
|
||||
<AppBadge tone="warning">Warnings: {{ integrityReport.warnings.length }}</AppBadge>
|
||||
<AppBadge tone="danger">Failures: {{ integrityReport.failures.length }}</AppBadge>
|
||||
<span class="text-secondary">Checked: {{ formatTimestamp(integrityReport.checked_at) }}</span>
|
||||
</div>
|
||||
|
||||
<AppTable v-if="integrityReport" label="Integrity checks">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Check</th>
|
||||
<th scope="col">Count</th>
|
||||
<th scope="col">Severity</th>
|
||||
<th scope="col">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="check in integrityReport.checks" :key="check.name">
|
||||
<td>{{ check.name }}</td>
|
||||
<td>{{ check.count }}</td>
|
||||
<td><AppBadge :tone="checkTone(check)">{{ check.severity }}</AppBadge></td>
|
||||
<td>{{ check.message }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</AppTable>
|
||||
</AppCard>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import AdminPdfQueueSection from "./AdminPdfQueueSection.vue";
|
||||
|
||||
vi.mock("@/features/admin_dbops", () => ({
|
||||
listAdminPdfQueue: vi.fn(),
|
||||
requeueAdminPdfLookup: vi.fn(),
|
||||
requeueAllAdminPdfLookups: vi.fn(),
|
||||
}));
|
||||
|
||||
import { listAdminPdfQueue } from "@/features/admin_dbops";
|
||||
|
||||
const mockedListQueue = vi.mocked(listAdminPdfQueue);
|
||||
|
||||
function buildQueueItem(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
publication_id: 1,
|
||||
title: "Test Paper",
|
||||
display_identifier: null,
|
||||
pdf_url: null,
|
||||
status: "queued",
|
||||
attempt_count: 0,
|
||||
last_failure_reason: null,
|
||||
last_failure_detail: null,
|
||||
last_source: "unpaywall",
|
||||
requested_by_user_id: null,
|
||||
requested_by_email: null,
|
||||
queued_at: "2025-01-15T12:00:00Z",
|
||||
last_attempt_at: null,
|
||||
resolved_at: null,
|
||||
updated_at: "2025-01-15T12:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AdminPdfQueueSection", () => {
|
||||
beforeEach(() => {
|
||||
mockedListQueue.mockReset();
|
||||
});
|
||||
|
||||
it("renders the PDF queue heading", () => {
|
||||
const wrapper = mount(AdminPdfQueueSection);
|
||||
expect(wrapper.text()).toContain("PDF");
|
||||
});
|
||||
|
||||
it("exposes load function that fetches PDF queue", async () => {
|
||||
mockedListQueue.mockResolvedValueOnce({
|
||||
items: [buildQueueItem()],
|
||||
total_count: 1,
|
||||
has_next: false,
|
||||
has_prev: false,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
});
|
||||
const wrapper = mount(AdminPdfQueueSection);
|
||||
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
|
||||
await vm.load();
|
||||
await flushPromises();
|
||||
expect(mockedListQueue).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders queue items after loading", async () => {
|
||||
mockedListQueue.mockResolvedValueOnce({
|
||||
items: [buildQueueItem({ title: "My Paper" })],
|
||||
total_count: 1,
|
||||
has_next: false,
|
||||
has_prev: false,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
});
|
||||
const wrapper = mount(AdminPdfQueueSection);
|
||||
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
|
||||
await vm.load();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain("My Paper");
|
||||
});
|
||||
|
||||
it("renders status filter dropdown", () => {
|
||||
const wrapper = mount(AdminPdfQueueSection);
|
||||
expect(wrapper.text()).toContain("Status");
|
||||
});
|
||||
|
||||
it("renders queue all button", () => {
|
||||
const wrapper = mount(AdminPdfQueueSection);
|
||||
expect(wrapper.text()).toContain("Queue all");
|
||||
});
|
||||
|
||||
it("shows pagination summary after loading", async () => {
|
||||
mockedListQueue.mockResolvedValueOnce({
|
||||
items: Array.from({ length: 3 }, (_, i) => buildQueueItem({ publication_id: i + 1 })),
|
||||
total_count: 3,
|
||||
has_next: false,
|
||||
has_prev: false,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
});
|
||||
const wrapper = mount(AdminPdfQueueSection);
|
||||
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
|
||||
await vm.load();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain("3");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import AppBadge from "@/components/ui/AppBadge.vue";
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppCard from "@/components/ui/AppCard.vue";
|
||||
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||
import AppRefreshButton from "@/components/ui/AppRefreshButton.vue";
|
||||
import AppSelect from "@/components/ui/AppSelect.vue";
|
||||
import AppTable from "@/components/ui/AppTable.vue";
|
||||
import {
|
||||
listAdminPdfQueue,
|
||||
requeueAdminPdfLookup,
|
||||
requeueAllAdminPdfLookups,
|
||||
type AdminPdfQueueItem,
|
||||
} from "@/features/admin_dbops";
|
||||
import { useRequestState } from "@/composables/useRequestState";
|
||||
|
||||
const { clearAlerts, assignError, setSuccess } = useRequestState();
|
||||
|
||||
const refreshingPdfQueue = ref(false);
|
||||
const requeueingPublicationId = ref<number | null>(null);
|
||||
const requeueingAllPdfs = ref(false);
|
||||
const pdfQueueItems = ref<AdminPdfQueueItem[]>([]);
|
||||
const pdfQueueStatusFilter = ref("");
|
||||
const pdfQueuePage = ref(1);
|
||||
const pdfQueuePageSize = ref("50");
|
||||
const pdfQueueTotalCount = ref(0);
|
||||
const pdfQueueHasNext = ref(false);
|
||||
const pdfQueueHasPrev = ref(false);
|
||||
|
||||
const pdfQueuePageSizeValue = computed(() => {
|
||||
const parsed = Number(pdfQueuePageSize.value);
|
||||
if (!Number.isFinite(parsed)) return 50;
|
||||
return Math.max(1, Math.min(500, Math.trunc(parsed)));
|
||||
});
|
||||
const pdfQueueTotalPages = computed(() =>
|
||||
Math.max(1, Math.ceil(pdfQueueTotalCount.value / Math.max(pdfQueuePageSizeValue.value, 1))),
|
||||
);
|
||||
const pdfQueueSummary = computed(() =>
|
||||
`${pdfQueueTotalCount.value} item${pdfQueueTotalCount.value === 1 ? "" : "s"} total`,
|
||||
);
|
||||
|
||||
function statusTone(status: string): "success" | "warning" | "danger" | "info" | "neutral" {
|
||||
if (status === "ok" || status === "completed" || status === "resolved") return "success";
|
||||
if (status === "warning" || status === "running" || status === "queued") return "warning";
|
||||
if (status === "failed") return "danger";
|
||||
return "info";
|
||||
}
|
||||
|
||||
function formatTimestamp(value: string | null): string {
|
||||
if (!value) return "n/a";
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function canRequeuePdf(item: AdminPdfQueueItem): boolean {
|
||||
return item.status !== "queued" && item.status !== "running";
|
||||
}
|
||||
|
||||
async function refreshPdfQueue(): Promise<void> {
|
||||
refreshingPdfQueue.value = true;
|
||||
try {
|
||||
const status = pdfQueueStatusFilter.value.trim() || null;
|
||||
const page = await listAdminPdfQueue(pdfQueuePage.value, pdfQueuePageSizeValue.value, status);
|
||||
pdfQueueItems.value = page.items;
|
||||
pdfQueueTotalCount.value = page.total_count;
|
||||
pdfQueueHasNext.value = page.has_next;
|
||||
pdfQueueHasPrev.value = page.has_prev;
|
||||
pdfQueuePage.value = page.page;
|
||||
pdfQueuePageSize.value = String(page.page_size);
|
||||
} finally {
|
||||
refreshingPdfQueue.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onPdfQueueFilterChanged(): Promise<void> {
|
||||
pdfQueuePage.value = 1;
|
||||
await refreshPdfQueue();
|
||||
}
|
||||
|
||||
async function onPdfQueuePageSizeChanged(): Promise<void> {
|
||||
pdfQueuePage.value = 1;
|
||||
await refreshPdfQueue();
|
||||
}
|
||||
|
||||
async function onPdfQueuePrevPage(): Promise<void> {
|
||||
if (!pdfQueueHasPrev.value || pdfQueuePage.value <= 1) return;
|
||||
pdfQueuePage.value = Math.max(pdfQueuePage.value - 1, 1);
|
||||
await refreshPdfQueue();
|
||||
}
|
||||
|
||||
async function onPdfQueueNextPage(): Promise<void> {
|
||||
if (!pdfQueueHasNext.value) return;
|
||||
pdfQueuePage.value += 1;
|
||||
await refreshPdfQueue();
|
||||
}
|
||||
|
||||
async function onRequeuePdf(item: AdminPdfQueueItem): Promise<void> {
|
||||
requeueingPublicationId.value = item.publication_id;
|
||||
clearAlerts();
|
||||
try {
|
||||
const result = await requeueAdminPdfLookup(item.publication_id);
|
||||
setSuccess(result.message);
|
||||
await refreshPdfQueue();
|
||||
} catch (error) {
|
||||
assignError(error, "Unable to requeue PDF lookup.");
|
||||
} finally {
|
||||
requeueingPublicationId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function onRequeueAllPdfs(): Promise<void> {
|
||||
requeueingAllPdfs.value = true;
|
||||
clearAlerts();
|
||||
try {
|
||||
const result = await requeueAllAdminPdfLookups(5000);
|
||||
setSuccess(result.message);
|
||||
await refreshPdfQueue();
|
||||
} catch (error) {
|
||||
assignError(error, "Unable to queue missing PDF lookups.");
|
||||
} finally {
|
||||
requeueingAllPdfs.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
await refreshPdfQueue();
|
||||
}
|
||||
|
||||
defineExpose({ load });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppCard class="space-y-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<h2 class="text-lg font-semibold text-ink-primary">PDF Gathering Queue</h2>
|
||||
<AppHelpHint text="Live queue and outcome history for PDF acquisition across all publications." />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<AppSelect v-model="pdfQueueStatusFilter" class="min-w-[12rem] !py-1.5 !text-sm" @change="onPdfQueueFilterChanged">
|
||||
<option value="">All statuses</option>
|
||||
<option value="untracked">untracked</option>
|
||||
<option value="queued">queued</option>
|
||||
<option value="running">running</option>
|
||||
<option value="failed">failed</option>
|
||||
<option value="resolved">resolved</option>
|
||||
</AppSelect>
|
||||
<AppSelect v-model="pdfQueuePageSize" class="min-w-[10rem] !py-1.5 !text-sm" @change="onPdfQueuePageSizeChanged">
|
||||
<option value="25">25 / page</option>
|
||||
<option value="50">50 / page</option>
|
||||
<option value="100">100 / page</option>
|
||||
</AppSelect>
|
||||
<AppButton variant="secondary" class="!min-h-8 whitespace-nowrap !px-2.5 !py-1 !text-xs" :disabled="requeueingAllPdfs" title="Queue all missing PDFs" @click="onRequeueAllPdfs">
|
||||
{{ requeueingAllPdfs ? "Queueing..." : "Queue all" }}
|
||||
</AppButton>
|
||||
<AppRefreshButton variant="secondary" size="sm" :loading="refreshingPdfQueue" title="Refresh PDF queue" loading-title="Refreshing PDF queue" @click="refreshPdfQueue" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AppTable label="PDF queue table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Publication</th><th scope="col">Status</th><th scope="col">Attempts</th>
|
||||
<th scope="col">Failure reason</th><th scope="col">Source</th><th scope="col">Requested by</th>
|
||||
<th scope="col">Queued</th><th scope="col">Last attempt</th><th scope="col">Resolved</th><th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in pdfQueueItems" :key="item.publication_id">
|
||||
<td>
|
||||
<div class="grid gap-1">
|
||||
<span class="font-medium text-ink-primary">{{ item.title }}</span>
|
||||
<a v-if="item.display_identifier?.url" :href="item.display_identifier.url" target="_blank" rel="noreferrer" class="link-inline text-xs">{{ item.display_identifier.label }}</a>
|
||||
</div>
|
||||
</td>
|
||||
<td><AppBadge :tone="statusTone(item.status)">{{ item.status }}</AppBadge></td>
|
||||
<td>{{ item.attempt_count }}</td>
|
||||
<td>{{ item.last_failure_reason || "n/a" }}</td>
|
||||
<td>{{ item.last_source || "n/a" }}</td>
|
||||
<td>{{ item.requested_by_email || "n/a" }}</td>
|
||||
<td>{{ formatTimestamp(item.queued_at) }}</td>
|
||||
<td>{{ formatTimestamp(item.last_attempt_at) }}</td>
|
||||
<td>{{ formatTimestamp(item.resolved_at) }}</td>
|
||||
<td>
|
||||
<AppButton variant="ghost" :disabled="requeueingPublicationId === item.publication_id || !canRequeuePdf(item)" @click="onRequeuePdf(item)">
|
||||
{{ requeueingPublicationId === item.publication_id ? "Requeueing..." : "Requeue" }}
|
||||
</AppButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</AppTable>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-secondary">
|
||||
<span>{{ pdfQueueSummary }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Page {{ pdfQueuePage }} / {{ pdfQueueTotalPages }}</span>
|
||||
<AppButton variant="ghost" :disabled="!pdfQueueHasPrev" @click="onPdfQueuePrevPage">Prev</AppButton>
|
||||
<AppButton variant="ghost" :disabled="!pdfQueueHasNext" @click="onPdfQueueNextPage">Next</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import AdminRepairsSection from "./AdminRepairsSection.vue";
|
||||
|
||||
vi.mock("@/features/admin_dbops", () => ({
|
||||
listAdminDbRepairJobs: vi.fn(),
|
||||
triggerPublicationLinkRepair: vi.fn(),
|
||||
triggerPublicationNearDuplicateRepair: vi.fn(),
|
||||
dropAllPublications: vi.fn(),
|
||||
}));
|
||||
|
||||
import { listAdminDbRepairJobs, dropAllPublications } from "@/features/admin_dbops";
|
||||
|
||||
const mockedListJobs = vi.mocked(listAdminDbRepairJobs);
|
||||
const mockedDrop = vi.mocked(dropAllPublications);
|
||||
|
||||
function buildUser(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 1,
|
||||
email: "admin@test.com",
|
||||
is_admin: true,
|
||||
is_active: true,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AdminRepairsSection", () => {
|
||||
beforeEach(() => {
|
||||
mockedListJobs.mockReset();
|
||||
mockedDrop.mockReset();
|
||||
});
|
||||
|
||||
it("renders the repair sections", () => {
|
||||
const wrapper = mount(AdminRepairsSection, { props: { users: [buildUser()] } });
|
||||
expect(wrapper.text()).toContain("Publication Link Repair");
|
||||
expect(wrapper.text()).toContain("Near-Duplicate");
|
||||
expect(wrapper.text()).toContain("Drop All Publications");
|
||||
});
|
||||
|
||||
it("exposes load function that fetches repair jobs", async () => {
|
||||
mockedListJobs.mockResolvedValueOnce([]);
|
||||
const wrapper = mount(AdminRepairsSection, { props: { users: [buildUser()] } });
|
||||
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
|
||||
await vm.load();
|
||||
await flushPromises();
|
||||
expect(mockedListJobs).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders repair job rows after loading", async () => {
|
||||
mockedListJobs.mockResolvedValueOnce([
|
||||
{
|
||||
id: 1,
|
||||
job_name: "publication_link_repair",
|
||||
status: "completed",
|
||||
dry_run: true,
|
||||
requested_by: "admin@test.com",
|
||||
scope: {},
|
||||
summary: { links_in_scope: 100, links_deleted: 5 },
|
||||
error_text: null,
|
||||
started_at: "2025-01-15T12:00:00Z",
|
||||
finished_at: "2025-01-15T12:01:00Z",
|
||||
created_at: "2025-01-15T12:00:00Z",
|
||||
updated_at: "2025-01-15T12:01:00Z",
|
||||
},
|
||||
]);
|
||||
const wrapper = mount(AdminRepairsSection, { props: { users: [buildUser()] } });
|
||||
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
|
||||
await vm.load();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain("publication_link_repair");
|
||||
});
|
||||
|
||||
it("has typed confirmation guard for drop all publications", () => {
|
||||
const wrapper = mount(AdminRepairsSection, { props: { users: [buildUser()] } });
|
||||
const dropButton = wrapper.findAll("button").find((b) => b.text().includes("Drop all publications"));
|
||||
expect(dropButton?.attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders dry-run toggle in repair form", () => {
|
||||
const wrapper = mount(AdminRepairsSection, { props: { users: [buildUser()] } });
|
||||
expect(wrapper.text()).toContain("Dry-run");
|
||||
});
|
||||
|
||||
it("renders scope mode selector", () => {
|
||||
const wrapper = mount(AdminRepairsSection, { props: { users: [buildUser()] } });
|
||||
expect(wrapper.text()).toContain("Scope");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,372 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import AppBadge from "@/components/ui/AppBadge.vue";
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppCard from "@/components/ui/AppCard.vue";
|
||||
import AppCheckbox from "@/components/ui/AppCheckbox.vue";
|
||||
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||
import AppInput from "@/components/ui/AppInput.vue";
|
||||
import AppRefreshButton from "@/components/ui/AppRefreshButton.vue";
|
||||
import AppSelect from "@/components/ui/AppSelect.vue";
|
||||
import AppTable from "@/components/ui/AppTable.vue";
|
||||
import {
|
||||
dropAllPublications,
|
||||
listAdminDbRepairJobs,
|
||||
triggerPublicationLinkRepair,
|
||||
triggerPublicationNearDuplicateRepair,
|
||||
type AdminDbRepairJob,
|
||||
type NearDuplicateCluster,
|
||||
type TriggerPublicationLinkRepairResult,
|
||||
type TriggerPublicationNearDuplicateRepairResult,
|
||||
} from "@/features/admin_dbops";
|
||||
import type { AdminUser } from "@/features/admin_users";
|
||||
import { useRequestState } from "@/composables/useRequestState";
|
||||
|
||||
const props = defineProps<{
|
||||
users: AdminUser[];
|
||||
}>();
|
||||
|
||||
const SCOPE_SINGLE_USER = "single_user";
|
||||
const SCOPE_ALL_USERS = "all_users";
|
||||
const APPLY_ALL_USERS_CONFIRM_TEXT = "REPAIR ALL USERS";
|
||||
const APPLY_NEAR_DUPLICATES_CONFIRM_TEXT = "MERGE SELECTED DUPLICATES";
|
||||
const DROP_PUBLICATIONS_CONFIRM_TEXT = "DROP ALL PUBLICATIONS";
|
||||
|
||||
type RepairScopeMode = typeof SCOPE_SINGLE_USER | typeof SCOPE_ALL_USERS;
|
||||
|
||||
const { clearAlerts, assignError, setSuccess } = useRequestState();
|
||||
|
||||
const refreshingJobs = ref(false);
|
||||
const runningRepair = ref(false);
|
||||
const repairJobs = ref<AdminDbRepairJob[]>([]);
|
||||
const lastRepairResult = ref<TriggerPublicationLinkRepairResult | null>(null);
|
||||
const repairScopeMode = ref<RepairScopeMode>(SCOPE_SINGLE_USER);
|
||||
const repairUserId = ref("");
|
||||
const repairScholarIds = ref("");
|
||||
const repairRequestedBy = ref("");
|
||||
const repairDryRun = ref(true);
|
||||
const repairGcOrphans = ref(false);
|
||||
const repairConfirmationText = ref("");
|
||||
|
||||
const runningNearDuplicateScan = ref(false);
|
||||
const applyingNearDuplicateRepair = ref(false);
|
||||
const nearDuplicateRequestedBy = ref("");
|
||||
const nearDuplicateSimilarityThreshold = ref("0.78");
|
||||
const nearDuplicateMinSharedTokens = ref("3");
|
||||
const nearDuplicateMaxYearDelta = ref("1");
|
||||
const nearDuplicateMaxClusters = ref("25");
|
||||
const nearDuplicateConfirmationText = ref("");
|
||||
const nearDuplicateSelectedClusterKeys = ref<Set<string>>(new Set());
|
||||
const nearDuplicateClusters = ref<NearDuplicateCluster[]>([]);
|
||||
const lastNearDuplicateResult = ref<TriggerPublicationNearDuplicateRepairResult | null>(null);
|
||||
|
||||
const droppingPublications = ref(false);
|
||||
const dropConfirmationText = ref("");
|
||||
const dropResult = ref<{ deleted_count: number; message: string } | null>(null);
|
||||
const dropConfirmationValid = computed(() => dropConfirmationText.value.trim() === DROP_PUBLICATIONS_CONFIRM_TEXT);
|
||||
|
||||
const typedConfirmationRequired = computed(
|
||||
() => repairScopeMode.value === SCOPE_ALL_USERS && !repairDryRun.value,
|
||||
);
|
||||
const nearDuplicateApplyEnabled = computed(() => nearDuplicateSelectedClusterKeys.value.size > 0);
|
||||
|
||||
function statusTone(status: string): "success" | "warning" | "danger" | "info" | "neutral" {
|
||||
if (status === "ok" || status === "completed" || status === "resolved") return "success";
|
||||
if (status === "warning" || status === "running" || status === "queued") return "warning";
|
||||
if (status === "failed") return "danger";
|
||||
return "info";
|
||||
}
|
||||
|
||||
function formatTimestamp(value: string | null): string {
|
||||
if (!value) return "n/a";
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function parseScholarIds(raw: string): number[] {
|
||||
const tokens = raw.split(/[,\s]+/).map((t) => t.trim()).filter((t) => t.length > 0);
|
||||
const deduped = new Set<number>();
|
||||
for (const token of tokens) {
|
||||
const parsed = Number(token);
|
||||
if (!Number.isInteger(parsed) || parsed < 1) throw new Error("Scholar profile IDs must be positive integers.");
|
||||
deduped.add(parsed);
|
||||
}
|
||||
return [...deduped];
|
||||
}
|
||||
|
||||
function parseRepairUserIdOrThrow(raw: string): number {
|
||||
const parsed = Number((raw || "").trim());
|
||||
if (!Number.isInteger(parsed) || parsed < 1) throw new Error("Select a valid target user.");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function validateTypedConfirmation(): string {
|
||||
const normalized = repairConfirmationText.value.trim();
|
||||
if (typedConfirmationRequired.value && normalized !== APPLY_ALL_USERS_CONFIRM_TEXT) {
|
||||
throw new Error(`Type '${APPLY_ALL_USERS_CONFIRM_TEXT}' to apply repair for all users.`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parseBoundedNumber(raw: string, opts: { minimum: number; maximum: number; fallback: number }): number {
|
||||
const parsed = Number(raw.trim());
|
||||
if (!Number.isFinite(parsed)) return opts.fallback;
|
||||
return Math.max(opts.minimum, Math.min(opts.maximum, parsed));
|
||||
}
|
||||
|
||||
function nearDuplicatePayloadBase() {
|
||||
return {
|
||||
similarity_threshold: parseBoundedNumber(nearDuplicateSimilarityThreshold.value, { minimum: 0.5, maximum: 1.0, fallback: 0.78 }),
|
||||
min_shared_tokens: Math.trunc(parseBoundedNumber(nearDuplicateMinSharedTokens.value, { minimum: 1, maximum: 8, fallback: 3 })),
|
||||
max_year_delta: Math.trunc(parseBoundedNumber(nearDuplicateMaxYearDelta.value, { minimum: 0, maximum: 5, fallback: 1 })),
|
||||
max_clusters: Math.trunc(parseBoundedNumber(nearDuplicateMaxClusters.value, { minimum: 1, maximum: 200, fallback: 25 })),
|
||||
requested_by: nearDuplicateRequestedBy.value.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function checkboxEventChecked(event: Event): boolean {
|
||||
return event.target instanceof HTMLInputElement ? event.target.checked : false;
|
||||
}
|
||||
|
||||
function summaryCount(job: AdminDbRepairJob, key: string): string {
|
||||
const value = job.summary[key];
|
||||
return typeof value === "number" ? String(value) : "n/a";
|
||||
}
|
||||
|
||||
function ensureRepairUserSelected(): void {
|
||||
if (repairScopeMode.value !== SCOPE_SINGLE_USER || repairUserId.value || props.users.length === 0) return;
|
||||
repairUserId.value = String(props.users[0].id);
|
||||
}
|
||||
|
||||
async function refreshRepairJobs(): Promise<void> {
|
||||
refreshingJobs.value = true;
|
||||
try { repairJobs.value = await listAdminDbRepairJobs(30); } finally { refreshingJobs.value = false; }
|
||||
}
|
||||
|
||||
async function onRunRepair(): Promise<void> {
|
||||
runningRepair.value = true;
|
||||
clearAlerts();
|
||||
try {
|
||||
const payload = {
|
||||
scope_mode: repairScopeMode.value,
|
||||
scholar_profile_ids: parseScholarIds(repairScholarIds.value),
|
||||
dry_run: repairDryRun.value,
|
||||
gc_orphan_publications: repairGcOrphans.value,
|
||||
requested_by: repairRequestedBy.value.trim() || undefined,
|
||||
confirmation_text: validateTypedConfirmation() || undefined,
|
||||
user_id: repairScopeMode.value === SCOPE_SINGLE_USER ? parseRepairUserIdOrThrow(repairUserId.value) : undefined,
|
||||
};
|
||||
const result = await triggerPublicationLinkRepair(payload);
|
||||
lastRepairResult.value = result;
|
||||
setSuccess(`Repair job #${result.job_id} completed (${result.status}).`);
|
||||
repairConfirmationText.value = "";
|
||||
await refreshRepairJobs();
|
||||
} catch (error) {
|
||||
assignError(error, "Unable to run publication link repair.");
|
||||
} finally {
|
||||
runningRepair.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onToggleNearDuplicateClusterSelection(clusterKey: string, checked: boolean): void {
|
||||
const next = new Set(nearDuplicateSelectedClusterKeys.value);
|
||||
if (checked) { next.add(clusterKey); } else { next.delete(clusterKey); }
|
||||
nearDuplicateSelectedClusterKeys.value = next;
|
||||
}
|
||||
|
||||
async function onRunNearDuplicateScan(): Promise<void> {
|
||||
runningNearDuplicateScan.value = true;
|
||||
clearAlerts();
|
||||
try {
|
||||
const result = await triggerPublicationNearDuplicateRepair({ dry_run: true, ...nearDuplicatePayloadBase() });
|
||||
nearDuplicateClusters.value = result.clusters;
|
||||
nearDuplicateSelectedClusterKeys.value = new Set();
|
||||
nearDuplicateConfirmationText.value = "";
|
||||
lastNearDuplicateResult.value = result;
|
||||
setSuccess(`Near-duplicate scan completed (job #${result.job_id}).`);
|
||||
await refreshRepairJobs();
|
||||
} catch (error) {
|
||||
assignError(error, "Unable to scan for near-duplicate publications.");
|
||||
} finally {
|
||||
runningNearDuplicateScan.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onApplyNearDuplicateRepair(): Promise<void> {
|
||||
applyingNearDuplicateRepair.value = true;
|
||||
clearAlerts();
|
||||
try {
|
||||
const selectedKeys = [...nearDuplicateSelectedClusterKeys.value].sort((a, b) => a.localeCompare(b));
|
||||
if (selectedKeys.length === 0) throw new Error("Select at least one near-duplicate cluster before applying.");
|
||||
if (nearDuplicateConfirmationText.value.trim() !== APPLY_NEAR_DUPLICATES_CONFIRM_TEXT) {
|
||||
throw new Error(`Type '${APPLY_NEAR_DUPLICATES_CONFIRM_TEXT}' to confirm merge.`);
|
||||
}
|
||||
const result = await triggerPublicationNearDuplicateRepair({
|
||||
dry_run: false,
|
||||
selected_cluster_keys: selectedKeys,
|
||||
confirmation_text: nearDuplicateConfirmationText.value.trim(),
|
||||
...nearDuplicatePayloadBase(),
|
||||
});
|
||||
nearDuplicateClusters.value = result.clusters;
|
||||
nearDuplicateSelectedClusterKeys.value = new Set();
|
||||
nearDuplicateConfirmationText.value = "";
|
||||
lastNearDuplicateResult.value = result;
|
||||
setSuccess(`Merged selected near-duplicate clusters (job #${result.job_id}).`);
|
||||
await refreshRepairJobs();
|
||||
} catch (error) {
|
||||
assignError(error, "Unable to apply near-duplicate merge.");
|
||||
} finally {
|
||||
applyingNearDuplicateRepair.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onDropAllPublications(): Promise<void> {
|
||||
droppingPublications.value = true;
|
||||
clearAlerts();
|
||||
dropResult.value = null;
|
||||
try {
|
||||
const result = await dropAllPublications(dropConfirmationText.value.trim());
|
||||
dropResult.value = result;
|
||||
setSuccess(result.message);
|
||||
dropConfirmationText.value = "";
|
||||
} catch (error) {
|
||||
assignError(error, "Unable to drop publications.");
|
||||
} finally {
|
||||
droppingPublications.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
await refreshRepairJobs();
|
||||
ensureRepairUserSelected();
|
||||
}
|
||||
|
||||
defineExpose({ load });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="grid gap-4">
|
||||
<AppCard class="space-y-3">
|
||||
<div class="flex items-center gap-1">
|
||||
<h2 class="text-lg font-semibold text-ink-primary">Publication Link Repair</h2>
|
||||
<AppHelpHint text="Dry-run first. For all-users apply mode, typed confirmation is required." />
|
||||
</div>
|
||||
<form class="grid gap-3 md:grid-cols-2" @submit.prevent="onRunRepair">
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
|
||||
<span>Scope</span>
|
||||
<AppSelect v-model="repairScopeMode" @change="ensureRepairUserSelected">
|
||||
<option :value="SCOPE_SINGLE_USER">Single user</option>
|
||||
<option :value="SCOPE_ALL_USERS">All users</option>
|
||||
</AppSelect>
|
||||
</label>
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
|
||||
<span>Target user</span>
|
||||
<AppSelect v-model="repairUserId" :disabled="repairScopeMode === SCOPE_ALL_USERS || users.length === 0">
|
||||
<option value="" disabled>Select user</option>
|
||||
<option v-for="user in users" :key="user.id" :value="String(user.id)">{{ user.email }} (ID {{ user.id }})</option>
|
||||
</AppSelect>
|
||||
</label>
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary md:col-span-2">
|
||||
<span>Scholar profile IDs (optional)</span>
|
||||
<AppInput v-model="repairScholarIds" placeholder="e.g. 12,13,14" />
|
||||
</label>
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary md:col-span-2">
|
||||
<span>Requested by (optional)</span>
|
||||
<AppInput v-model="repairRequestedBy" placeholder="email/name/ticket id" />
|
||||
</label>
|
||||
<div class="flex flex-wrap items-center gap-4 md:col-span-2">
|
||||
<AppCheckbox id="repair-dry-run" v-model="repairDryRun" label="Dry-run (no writes)" />
|
||||
<AppCheckbox id="repair-gc-orphans" v-model="repairGcOrphans" label="Delete orphan publications" />
|
||||
</div>
|
||||
<label v-if="typedConfirmationRequired" class="grid gap-1 text-sm font-medium text-ink-secondary md:col-span-2">
|
||||
<span>Type '{{ APPLY_ALL_USERS_CONFIRM_TEXT }}' to confirm</span>
|
||||
<AppInput v-model="repairConfirmationText" :placeholder="APPLY_ALL_USERS_CONFIRM_TEXT" />
|
||||
</label>
|
||||
<p v-if="repairScopeMode === SCOPE_ALL_USERS" class="text-xs text-secondary md:col-span-2">All-users scope includes every scholar profile across all accounts.</p>
|
||||
<div class="md:col-span-2">
|
||||
<AppButton type="submit" :disabled="runningRepair">{{ runningRepair ? "Running..." : "Run repair job" }}</AppButton>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="lastRepairResult" class="rounded-lg border border-stroke-default bg-surface-card-muted p-3 text-xs">
|
||||
<div class="mb-2 flex flex-wrap items-center gap-2">
|
||||
<AppBadge :tone="statusTone(lastRepairResult.status)">Job #{{ lastRepairResult.job_id }}</AppBadge>
|
||||
<span class="text-secondary">Status: {{ lastRepairResult.status }}</span>
|
||||
</div>
|
||||
<pre class="overflow-x-auto text-secondary">{{ JSON.stringify(lastRepairResult.summary, null, 2) }}</pre>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard class="space-y-3">
|
||||
<div class="flex items-center gap-1">
|
||||
<h2 class="text-lg font-semibold text-ink-primary">Near-Duplicate Publication Repair</h2>
|
||||
<AppHelpHint text="Run a dry-run scan first, verify candidate clusters, then merge only selected clusters." />
|
||||
</div>
|
||||
<form class="grid gap-3 md:grid-cols-2" @submit.prevent="onRunNearDuplicateScan">
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary"><span>Similarity threshold</span><AppInput v-model="nearDuplicateSimilarityThreshold" placeholder="0.78" /></label>
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary"><span>Min shared tokens</span><AppInput v-model="nearDuplicateMinSharedTokens" placeholder="3" /></label>
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary"><span>Max year delta</span><AppInput v-model="nearDuplicateMaxYearDelta" placeholder="1" /></label>
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary"><span>Max preview clusters</span><AppInput v-model="nearDuplicateMaxClusters" placeholder="25" /></label>
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary md:col-span-2"><span>Requested by (optional)</span><AppInput v-model="nearDuplicateRequestedBy" placeholder="email/name/ticket id" /></label>
|
||||
<div class="md:col-span-2">
|
||||
<AppButton type="submit" :disabled="runningNearDuplicateScan">{{ runningNearDuplicateScan ? "Scanning..." : "Scan near-duplicate clusters" }}</AppButton>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="nearDuplicateClusters.length > 0" class="space-y-3">
|
||||
<AppTable label="Near duplicate clusters table">
|
||||
<thead><tr><th scope="col">Select</th><th scope="col">Cluster</th><th scope="col">Winner</th><th scope="col">Members</th><th scope="col">Similarity</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="cluster in nearDuplicateClusters" :key="cluster.cluster_key">
|
||||
<td><input :id="`near-dup-${cluster.cluster_key}`" class="h-4 w-4 rounded border-stroke-default bg-surface-card" type="checkbox" :checked="nearDuplicateSelectedClusterKeys.has(cluster.cluster_key)" @change="onToggleNearDuplicateClusterSelection(cluster.cluster_key, checkboxEventChecked($event))" /></td>
|
||||
<td class="font-mono text-xs">{{ cluster.cluster_key }}</td>
|
||||
<td>#{{ cluster.winner_publication_id }}</td>
|
||||
<td><div class="grid gap-1"><span v-for="member in cluster.members" :key="member.publication_id" class="text-xs text-secondary">#{{ member.publication_id }} · {{ member.title }}</span></div></td>
|
||||
<td>{{ cluster.similarity_score.toFixed(2) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</AppTable>
|
||||
<form class="grid gap-3 md:grid-cols-2" @submit.prevent="onApplyNearDuplicateRepair">
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary md:col-span-2"><span>Type '{{ APPLY_NEAR_DUPLICATES_CONFIRM_TEXT }}' to merge selected clusters</span><AppInput v-model="nearDuplicateConfirmationText" :placeholder="APPLY_NEAR_DUPLICATES_CONFIRM_TEXT" /></label>
|
||||
<div class="md:col-span-2"><AppButton type="submit" :disabled="applyingNearDuplicateRepair || !nearDuplicateApplyEnabled">{{ applyingNearDuplicateRepair ? "Merging..." : "Merge selected clusters" }}</AppButton></div>
|
||||
</form>
|
||||
</div>
|
||||
<div v-if="lastNearDuplicateResult" class="rounded-lg border border-stroke-default bg-surface-card-muted p-3 text-xs">
|
||||
<div class="mb-2 flex flex-wrap items-center gap-2"><AppBadge :tone="statusTone(lastNearDuplicateResult.status)">Job #{{ lastNearDuplicateResult.job_id }}</AppBadge><span class="text-secondary">Status: {{ lastNearDuplicateResult.status }}</span></div>
|
||||
<pre class="overflow-x-auto text-secondary">{{ JSON.stringify(lastNearDuplicateResult.summary, null, 2) }}</pre>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard class="space-y-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1"><h2 class="text-lg font-semibold text-ink-primary">Recent Repair Jobs</h2><AppHelpHint text="Audit history and summary counters for each repair job." /></div>
|
||||
<AppRefreshButton variant="secondary" :loading="refreshingJobs" title="Refresh repair jobs" loading-title="Refreshing repair jobs" @click="refreshRepairJobs" />
|
||||
</div>
|
||||
<AppTable label="Repair jobs table">
|
||||
<thead><tr><th scope="col">Job</th><th scope="col">Status</th><th scope="col">Mode</th><th scope="col">Requested by</th><th scope="col">Created</th><th scope="col">Links in scope</th><th scope="col">Links deleted</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="job in repairJobs" :key="job.id">
|
||||
<td>#{{ job.id }} · {{ job.job_name }}</td>
|
||||
<td><AppBadge :tone="statusTone(job.status)">{{ job.status }}</AppBadge></td>
|
||||
<td>{{ job.dry_run ? "dry-run" : "apply" }}</td>
|
||||
<td>{{ job.requested_by || "n/a" }}</td>
|
||||
<td>{{ formatTimestamp(job.created_at) }}</td>
|
||||
<td>{{ summaryCount(job, "links_in_scope") }}</td>
|
||||
<td>{{ summaryCount(job, "links_deleted") }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</AppTable>
|
||||
</AppCard>
|
||||
|
||||
<AppCard class="space-y-3 border-state-danger-border">
|
||||
<div class="flex items-center gap-1"><h2 class="text-lg font-semibold text-state-danger-text">Drop All Publications</h2><AppHelpHint text="Permanently delete ALL publications, links, identifiers, and PDF jobs. Scholar baselines will be reset so the next run re-discovers everything." /></div>
|
||||
<p class="text-sm text-secondary">This action is <strong>irreversible</strong>. It deletes every publication record across all users. The next ingestion run will re-populate all data from scratch.</p>
|
||||
<form class="grid gap-3" @submit.prevent="onDropAllPublications">
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary"><span>Type '{{ DROP_PUBLICATIONS_CONFIRM_TEXT }}' to confirm</span><AppInput v-model="dropConfirmationText" :placeholder="DROP_PUBLICATIONS_CONFIRM_TEXT" autocomplete="off" /></label>
|
||||
<div><AppButton type="submit" variant="danger" :disabled="droppingPublications || !dropConfirmationValid">{{ droppingPublications ? "Dropping..." : "Drop all publications" }}</AppButton></div>
|
||||
</form>
|
||||
<div v-if="dropResult" class="rounded-lg border border-stroke-default bg-surface-card-muted p-3 text-xs">
|
||||
<div class="mb-1 flex items-center gap-2"><AppBadge tone="danger">Deleted: {{ dropResult.deleted_count }}</AppBadge></div>
|
||||
<p class="text-secondary">{{ dropResult.message }}</p>
|
||||
</div>
|
||||
</AppCard>
|
||||
</section>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import AdminUsersSection from "./AdminUsersSection.vue";
|
||||
|
||||
vi.mock("@/features/admin_users", () => ({
|
||||
listAdminUsers: vi.fn(),
|
||||
createAdminUser: vi.fn(),
|
||||
setAdminUserActive: vi.fn(),
|
||||
resetAdminUserPassword: vi.fn(),
|
||||
}));
|
||||
|
||||
import { listAdminUsers, createAdminUser } from "@/features/admin_users";
|
||||
|
||||
const mockedListUsers = vi.mocked(listAdminUsers);
|
||||
const mockedCreateUser = vi.mocked(createAdminUser);
|
||||
|
||||
function buildUser(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 1,
|
||||
email: "user@example.com",
|
||||
is_admin: false,
|
||||
is_active: true,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AdminUsersSection", () => {
|
||||
beforeEach(() => {
|
||||
mockedListUsers.mockReset();
|
||||
mockedCreateUser.mockReset();
|
||||
});
|
||||
|
||||
it("renders the create user form", () => {
|
||||
const wrapper = mount(AdminUsersSection);
|
||||
expect(wrapper.text()).toContain("Create user");
|
||||
});
|
||||
|
||||
it("exposes load function that calls listAdminUsers", async () => {
|
||||
mockedListUsers.mockResolvedValueOnce([buildUser()]);
|
||||
const wrapper = mount(AdminUsersSection);
|
||||
const vm = wrapper.vm as unknown as { load: () => Promise<void>; users: unknown[] };
|
||||
await vm.load();
|
||||
await flushPromises();
|
||||
expect(mockedListUsers).toHaveBeenCalled();
|
||||
expect(vm.users).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("renders user table after loading", async () => {
|
||||
mockedListUsers.mockResolvedValueOnce([buildUser({ email: "alice@test.com" })]);
|
||||
const wrapper = mount(AdminUsersSection);
|
||||
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
|
||||
await vm.load();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain("alice@test.com");
|
||||
});
|
||||
|
||||
it("propagates error when load fails", async () => {
|
||||
mockedListUsers.mockRejectedValueOnce(new Error("Network error"));
|
||||
const wrapper = mount(AdminUsersSection);
|
||||
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
|
||||
await expect(vm.load()).rejects.toThrow("Network error");
|
||||
});
|
||||
|
||||
it("emits user creation via form submit", async () => {
|
||||
mockedCreateUser.mockResolvedValueOnce(buildUser({ email: "new@test.com" }));
|
||||
mockedListUsers.mockResolvedValue([]);
|
||||
const wrapper = mount(AdminUsersSection);
|
||||
|
||||
const inputs = wrapper.findAll("input");
|
||||
const emailInput = inputs.find((i) => (i.element as HTMLInputElement).type === "email");
|
||||
const passwordInput = inputs.find((i) => (i.element as HTMLInputElement).type === "password");
|
||||
|
||||
if (emailInput && passwordInput) {
|
||||
await emailInput.setValue("new@test.com");
|
||||
await passwordInput.setValue("securepassword123");
|
||||
await wrapper.find("form").trigger("submit");
|
||||
await flushPromises();
|
||||
expect(mockedCreateUser).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
211
frontend/src/features/settings/components/AdminUsersSection.vue
Normal file
211
frontend/src/features/settings/components/AdminUsersSection.vue
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppCard from "@/components/ui/AppCard.vue";
|
||||
import AppCheckbox from "@/components/ui/AppCheckbox.vue";
|
||||
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||
import AppInput from "@/components/ui/AppInput.vue";
|
||||
import AppModal from "@/components/ui/AppModal.vue";
|
||||
import AppRefreshButton from "@/components/ui/AppRefreshButton.vue";
|
||||
import AppTable from "@/components/ui/AppTable.vue";
|
||||
import {
|
||||
createAdminUser,
|
||||
listAdminUsers,
|
||||
resetAdminUserPassword,
|
||||
setAdminUserActive,
|
||||
type AdminUser,
|
||||
} from "@/features/admin_users";
|
||||
import { useRequestState } from "@/composables/useRequestState";
|
||||
|
||||
const { errorMessage, errorRequestId, successMessage, clearAlerts, assignError, setSuccess } = useRequestState();
|
||||
|
||||
const refreshingUsers = ref(false);
|
||||
const creating = ref(false);
|
||||
const togglingUserId = ref<number | null>(null);
|
||||
const resettingPassword = ref(false);
|
||||
|
||||
const users = ref<AdminUser[]>([]);
|
||||
const email = ref("");
|
||||
const password = ref("");
|
||||
const createIsAdmin = ref(false);
|
||||
const activeUserId = ref<number | null>(null);
|
||||
const resetPassword = ref("");
|
||||
|
||||
const activeUser = computed(() => users.value.find((u) => u.id === activeUserId.value) ?? null);
|
||||
|
||||
function userRoleLabel(user: AdminUser): string {
|
||||
return user.is_admin ? "Admin" : "User";
|
||||
}
|
||||
|
||||
function statusDotClass(user: AdminUser): string {
|
||||
return user.is_active ? "bg-success-500 ring-success-200" : "bg-ink-muted/70 ring-stroke-default";
|
||||
}
|
||||
|
||||
function formatTimestamp(value: string | null): string {
|
||||
if (!value) return "n/a";
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function openUserModal(user: AdminUser): void {
|
||||
activeUserId.value = user.id;
|
||||
resetPassword.value = "";
|
||||
}
|
||||
|
||||
function closeUserModal(): void {
|
||||
activeUserId.value = null;
|
||||
resetPassword.value = "";
|
||||
}
|
||||
|
||||
async function refreshUsers(): Promise<void> {
|
||||
refreshingUsers.value = true;
|
||||
try {
|
||||
users.value = await listAdminUsers();
|
||||
if (activeUserId.value !== null && !users.value.some((item) => item.id === activeUserId.value)) {
|
||||
closeUserModal();
|
||||
}
|
||||
} finally {
|
||||
refreshingUsers.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onCreateUser(): Promise<void> {
|
||||
creating.value = true;
|
||||
clearAlerts();
|
||||
try {
|
||||
if (!email.value.trim() || !password.value) throw new Error("Email and password are required.");
|
||||
const created = await createAdminUser({ email: email.value.trim(), password: password.value, is_admin: createIsAdmin.value });
|
||||
email.value = "";
|
||||
password.value = "";
|
||||
createIsAdmin.value = false;
|
||||
setSuccess(`User created: ${created.email}`);
|
||||
await refreshUsers();
|
||||
} catch (error) {
|
||||
assignError(error, "Unable to create user.");
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggleUser(user: AdminUser): Promise<void> {
|
||||
togglingUserId.value = user.id;
|
||||
clearAlerts();
|
||||
try {
|
||||
const updated = await setAdminUserActive(user.id, !user.is_active);
|
||||
setSuccess(`${updated.email} is now ${updated.is_active ? "active" : "inactive"}.`);
|
||||
await refreshUsers();
|
||||
} catch (error) {
|
||||
assignError(error, "Unable to update user.");
|
||||
} finally {
|
||||
togglingUserId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function onResetPassword(): Promise<void> {
|
||||
const user = activeUser.value;
|
||||
if (!user) return;
|
||||
resettingPassword.value = true;
|
||||
clearAlerts();
|
||||
try {
|
||||
const candidate = resetPassword.value.trim();
|
||||
if (candidate.length < 12) throw new Error("New password must be at least 12 characters.");
|
||||
const result = await resetAdminUserPassword(user.id, candidate);
|
||||
resetPassword.value = "";
|
||||
setSuccess(result.message || `Password reset for ${user.email}.`);
|
||||
} catch (error) {
|
||||
assignError(error, "Unable to reset password.");
|
||||
} finally {
|
||||
resettingPassword.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
await refreshUsers();
|
||||
}
|
||||
|
||||
defineExpose({ load, errorMessage, errorRequestId, successMessage, users });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppCard class="space-y-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<h2 class="text-lg font-semibold text-ink-primary">Users</h2>
|
||||
<AppHelpHint text="Create accounts, toggle active status, and reset passwords." />
|
||||
</div>
|
||||
<AppRefreshButton variant="secondary" :loading="refreshingUsers" title="Refresh users" loading-title="Refreshing users" @click="refreshUsers" />
|
||||
</div>
|
||||
|
||||
<form class="grid gap-3 md:grid-cols-3" @submit.prevent="onCreateUser">
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
|
||||
<span>Email</span>
|
||||
<AppInput v-model="email" type="email" autocomplete="off" />
|
||||
</label>
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
|
||||
<span>Password</span>
|
||||
<AppInput v-model="password" type="password" autocomplete="new-password" />
|
||||
</label>
|
||||
<div class="grid gap-2 text-sm font-medium text-ink-secondary">
|
||||
<span>Role</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<AppCheckbox id="admin-create-user-is-admin" v-model="createIsAdmin" label="Grant admin" />
|
||||
<AppButton type="submit" :disabled="creating">{{ creating ? "Creating..." : "Create user" }}</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<AppTable label="Users table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Email</th>
|
||||
<th scope="col">Role</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in users" :key="user.id">
|
||||
<td class="align-middle">
|
||||
<button type="button" class="group inline-flex items-center gap-2 rounded-md px-1 py-0.5 text-left text-ink-primary transition hover:bg-surface-card-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset" @click="openUserModal(user)">
|
||||
<span :class="statusDotClass(user)" class="h-2.5 w-2.5 rounded-full ring-2" :aria-label="user.is_active ? 'Active user' : 'Inactive user'" />
|
||||
<span class="underline-offset-2 group-hover:underline">{{ user.email }}</span>
|
||||
</button>
|
||||
</td>
|
||||
<td>{{ userRoleLabel(user) }}</td>
|
||||
<td>{{ user.is_active ? "active" : "inactive" }}</td>
|
||||
<td class="flex flex-wrap items-center gap-2">
|
||||
<AppButton variant="secondary" :disabled="togglingUserId === user.id" @click="onToggleUser(user)">
|
||||
{{ togglingUserId === user.id ? "Updating..." : user.is_active ? "Deactivate" : "Activate" }}
|
||||
</AppButton>
|
||||
<AppButton variant="ghost" @click="openUserModal(user)">Manage</AppButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</AppTable>
|
||||
</AppCard>
|
||||
|
||||
<AppModal :open="activeUser !== null" title="User settings" @close="closeUserModal">
|
||||
<div v-if="activeUser" class="grid gap-4">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="statusDotClass(activeUser)" class="h-2.5 w-2.5 rounded-full ring-2" :aria-label="activeUser.is_active ? 'Active user' : 'Inactive user'" />
|
||||
<p class="truncate text-sm font-semibold text-ink-primary">{{ activeUser.email }}</p>
|
||||
</div>
|
||||
<p class="text-sm text-secondary">Role: {{ userRoleLabel(activeUser) }}</p>
|
||||
<p class="text-xs text-secondary">Last updated: {{ formatTimestamp(activeUser.updated_at) }}</p>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
|
||||
<span>New password</span>
|
||||
<AppInput id="admin-reset-password" v-model="resetPassword" type="password" autocomplete="new-password" placeholder="At least 12 characters" />
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<AppButton :disabled="resettingPassword" @click="onResetPassword">{{ resettingPassword ? "Resetting..." : "Reset password" }}</AppButton>
|
||||
<AppButton variant="secondary" :disabled="togglingUserId === activeUser.id" @click="onToggleUser(activeUser)">
|
||||
{{ togglingUserId === activeUser.id ? "Updating..." : activeUser.is_active ? "Deactivate user" : "Activate user" }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppModal>
|
||||
</template>
|
||||
Loading…
Add table
Add a link
Reference in a new issue