Big changes

This commit is contained in:
Justin Visser 2026-02-21 17:33:05 +01:00
parent 4240ad38e2
commit e3f0d63fec
99 changed files with 8804 additions and 1731 deletions

View file

@ -0,0 +1,153 @@
import { apiRequest } from "@/lib/api/client";
export interface AdminDbIntegrityCheck {
name: string;
count: number;
severity: "warning" | "failure" | "metric";
message: string;
}
export interface AdminDbIntegrityReport {
status: "ok" | "warning" | "failed";
checked_at: string;
failures: string[];
warnings: string[];
checks: AdminDbIntegrityCheck[];
}
export interface AdminDbRepairJob {
id: number;
job_name: string;
requested_by: string | null;
dry_run: boolean;
status: string;
scope: Record<string, unknown>;
summary: Record<string, unknown>;
error_text: string | null;
started_at: string | null;
finished_at: string | null;
created_at: string;
updated_at: string;
}
export interface AdminPdfQueueItem {
publication_id: number;
title: string;
doi: string | null;
pdf_url: string | null;
status: string;
attempt_count: number;
last_failure_reason: string | null;
last_failure_detail: string | null;
last_source: string | null;
requested_by_user_id: number | null;
requested_by_email: string | null;
queued_at: string | null;
last_attempt_at: string | null;
resolved_at: string | null;
updated_at: string;
}
export interface AdminPdfQueuePage {
items: AdminPdfQueueItem[];
total_count: number;
page: number;
page_size: number;
has_next: boolean;
has_prev: boolean;
}
export interface AdminPdfQueueRequeueResult {
publication_id: number;
queued: boolean;
status: string;
message: string;
}
export interface AdminPdfQueueBulkEnqueueResult {
requested_count: number;
queued_count: number;
message: string;
}
export interface TriggerPublicationLinkRepairPayload {
scope_mode?: "single_user" | "all_users";
user_id?: number;
scholar_profile_ids?: number[];
dry_run?: boolean;
gc_orphan_publications?: boolean;
requested_by?: string;
confirmation_text?: string;
}
export interface TriggerPublicationLinkRepairResult {
job_id: number;
status: string;
scope: Record<string, unknown>;
summary: Record<string, unknown>;
}
export async function getAdminDbIntegrityReport(): Promise<AdminDbIntegrityReport> {
const response = await apiRequest<AdminDbIntegrityReport>("/admin/db/integrity", { method: "GET" });
return response.data;
}
export async function listAdminDbRepairJobs(limit = 30): Promise<AdminDbRepairJob[]> {
const parsedLimit = Number.isFinite(limit) ? Math.max(1, Math.min(200, Math.trunc(limit))) : 30;
const response = await apiRequest<{ jobs: AdminDbRepairJob[] }>(
`/admin/db/repair-jobs?limit=${parsedLimit}`,
{ method: "GET" },
);
return response.data.jobs;
}
export async function triggerPublicationLinkRepair(
payload: TriggerPublicationLinkRepairPayload,
): Promise<TriggerPublicationLinkRepairResult> {
const response = await apiRequest<TriggerPublicationLinkRepairResult>(
"/admin/db/repairs/publication-links",
{
method: "POST",
body: payload,
},
);
return response.data;
}
export async function listAdminPdfQueue(
page = 1,
pageSize = 100,
status: string | null = null,
): Promise<AdminPdfQueuePage> {
const parsedPage = Number.isFinite(page) ? Math.max(1, Math.trunc(page)) : 1;
const parsedPageSize = Number.isFinite(pageSize) ? Math.max(1, Math.min(500, Math.trunc(pageSize))) : 100;
const params = new URLSearchParams();
params.set("page", String(parsedPage));
params.set("page_size", String(parsedPageSize));
if (status && status.trim().length > 0) {
params.set("status", status.trim().toLowerCase());
}
const response = await apiRequest<AdminPdfQueuePage>(
`/admin/db/pdf-queue?${params.toString()}`,
{ method: "GET" },
);
return response.data;
}
export async function requeueAdminPdfLookup(publicationId: number): Promise<AdminPdfQueueRequeueResult> {
const id = Number.isFinite(publicationId) ? Math.trunc(publicationId) : 0;
const response = await apiRequest<AdminPdfQueueRequeueResult>(
`/admin/db/pdf-queue/${Math.max(1, id)}/requeue`,
{ method: "POST" },
);
return response.data;
}
export async function requeueAllAdminPdfLookups(limit = 1000): Promise<AdminPdfQueueBulkEnqueueResult> {
const parsedLimit = Number.isFinite(limit) ? Math.max(1, Math.min(5000, Math.trunc(limit))) : 1000;
const response = await apiRequest<AdminPdfQueueBulkEnqueueResult>(
`/admin/db/pdf-queue/requeue-all?limit=${parsedLimit}`,
{ method: "POST" },
);
return response.data;
}

View file

@ -41,7 +41,7 @@ function countQueueStatuses(statuses: string[]): QueueHealth {
export async function fetchDashboardSnapshot(): Promise<DashboardSnapshot> {
const [publications, runsPayload, queueItems] = await Promise.all([
listPublications({ mode: "latest", limit: 20 }),
listPublications({ mode: "latest", page: 1, pageSize: 20 }),
listRuns({ limit: 20 }),
listQueueItems(200),
]);

View file

@ -13,26 +13,39 @@ export interface PublicationItem {
pub_url: string | null;
doi: string | null;
pdf_url: string | null;
pdf_status: "untracked" | "queued" | "running" | "resolved" | "failed";
pdf_attempt_count: number;
pdf_failure_reason: string | null;
pdf_failure_detail: string | null;
is_read: boolean;
is_favorite: boolean;
first_seen_at: string;
is_new_in_latest_run: boolean;
}
export interface PublicationsResult {
mode: PublicationMode;
favorite_only: boolean;
selected_scholar_profile_id: number | null;
unread_count: number;
favorites_count: number;
latest_count: number;
// Compatibility alias for latest_count; retained while API clients migrate.
new_count: number;
total_count: number;
page: number;
page_size: number;
has_next: boolean;
has_prev: boolean;
publications: PublicationItem[];
}
export interface PublicationsQuery {
mode?: PublicationMode;
favoriteOnly?: boolean;
scholarProfileId?: number;
limit?: number;
page?: number;
pageSize?: number;
}
export interface PublicationSelection {
@ -46,12 +59,18 @@ export async function listPublications(query: PublicationsQuery = {}): Promise<P
if (query.mode) {
params.set("mode", query.mode);
}
if (query.favoriteOnly) {
params.set("favorite_only", "true");
}
if (query.scholarProfileId) {
params.set("scholar_profile_id", String(query.scholarProfileId));
}
if (query.limit) {
params.set("limit", String(query.limit));
}
const parsedPage = Number.isFinite(query.page) ? Math.max(1, Math.trunc(Number(query.page))) : 1;
const parsedPageSize = Number.isFinite(query.pageSize)
? Math.max(1, Math.min(500, Math.trunc(Number(query.pageSize))))
: 100;
params.set("page", String(parsedPage));
params.set("page_size", String(parsedPageSize));
const suffix = params.toString();
const response = await apiRequest<PublicationsResult>(
@ -87,10 +106,16 @@ export async function markSelectedRead(selections: PublicationSelection[]): Prom
export interface RetryPublicationPdfResult {
message: string;
queued: boolean;
resolved_pdf: boolean;
publication: PublicationItem;
}
export interface TogglePublicationFavoriteResult {
message: string;
publication: PublicationItem;
}
export async function retryPublicationPdf(
publicationId: number,
scholarProfileId: number,
@ -104,3 +129,21 @@ export async function retryPublicationPdf(
);
return response.data;
}
export async function togglePublicationFavorite(
publicationId: number,
scholarProfileId: number,
isFavorite: boolean,
): Promise<TogglePublicationFavoriteResult> {
const response = await apiRequest<TogglePublicationFavoriteResult>(
`/publications/${publicationId}/favorite`,
{
method: "POST",
body: {
scholar_profile_id: scholarProfileId,
is_favorite: isFavorite,
},
},
);
return response.data;
}

View file

@ -0,0 +1,826 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.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 AppModal from "@/components/ui/AppModal.vue";
import AppRefreshButton from "@/components/ui/AppRefreshButton.vue";
import AppSelect from "@/components/ui/AppSelect.vue";
import AppTable from "@/components/ui/AppTable.vue";
import {
getAdminDbIntegrityReport,
listAdminDbRepairJobs,
listAdminPdfQueue,
requeueAdminPdfLookup,
requeueAllAdminPdfLookups,
triggerPublicationLinkRepair,
type AdminDbIntegrityCheck,
type AdminDbIntegrityReport,
type AdminDbRepairJob,
type AdminPdfQueueItem,
type TriggerPublicationLinkRepairResult,
} from "@/features/admin_dbops";
import {
createAdminUser,
listAdminUsers,
resetAdminUserPassword,
setAdminUserActive,
type AdminUser,
} from "@/features/admin_users";
import { ApiRequestError } from "@/lib/api/errors";
const SECTION_USERS = "users";
const SECTION_INTEGRITY = "integrity";
const SECTION_REPAIRS = "repairs";
const SECTION_PDF = "pdf";
const SCOPE_SINGLE_USER = "single_user";
const SCOPE_ALL_USERS = "all_users";
const APPLY_ALL_USERS_CONFIRM_TEXT = "REPAIR ALL USERS";
type RepairScopeMode = typeof SCOPE_SINGLE_USER | typeof SCOPE_ALL_USERS;
const props = defineProps<{
section: "users" | "integrity" | "repairs" | "pdf";
}>();
const loading = ref(true);
const errorMessage = ref<string | null>(null);
const errorRequestId = ref<string | null>(null);
const successMessage = ref<string | null>(null);
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 refreshingIntegrity = ref(false);
const integrityReport = ref<AdminDbIntegrityReport | null>(null);
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 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 activeUser = computed(() => users.value.find((user) => user.id === activeUserId.value) ?? null);
const typedConfirmationRequired = computed(
() => repairScopeMode.value === SCOPE_ALL_USERS && !repairDryRun.value,
);
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 clearAlerts(): void {
errorMessage.value = null;
errorRequestId.value = null;
successMessage.value = 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";
}
function userRoleLabel(user: AdminUser): string {
return user.is_admin ? "Admin" : "User";
}
function canRequeuePdf(item: AdminPdfQueueItem): boolean {
return item.status !== "queued" && item.status !== "running";
}
function statusDotClass(user: AdminUser): string {
return user.is_active ? "bg-success-500 ring-success-200" : "bg-ink-muted/70 ring-stroke-default";
}
function assignError(error: unknown, fallback: string): void {
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
errorRequestId.value = error.requestId;
return;
}
if (error instanceof Error && error.message) {
errorMessage.value = error.message;
return;
}
errorMessage.value = fallback;
}
function parseScholarIds(raw: string): number[] {
const tokens = raw
.split(/[,\s]+/)
.map((token) => token.trim())
.filter((token) => token.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 summaryCount(job: AdminDbRepairJob, key: string): string {
const value = job.summary[key];
return typeof value === "number" ? String(value) : "n/a";
}
function openUserModal(user: AdminUser): void {
activeUserId.value = user.id;
resetPassword.value = "";
}
function closeUserModal(): void {
activeUserId.value = null;
resetPassword.value = "";
}
function ensureRepairUserSelected(): void {
if (repairScopeMode.value !== SCOPE_SINGLE_USER || repairUserId.value || users.value.length === 0) {
return;
}
repairUserId.value = String(users.value[0].id);
}
async function refreshUsers(): Promise<void> {
refreshingUsers.value = true;
try {
users.value = await listAdminUsers();
ensureRepairUserSelected();
if (activeUserId.value !== null && !users.value.some((item) => item.id === activeUserId.value)) {
closeUserModal();
}
} finally {
refreshingUsers.value = false;
}
}
async function refreshIntegrity(): Promise<void> {
refreshingIntegrity.value = true;
try {
integrityReport.value = await getAdminDbIntegrityReport();
} finally {
refreshingIntegrity.value = false;
}
}
async function refreshRepairJobs(): Promise<void> {
refreshingJobs.value = true;
try {
repairJobs.value = await listAdminDbRepairJobs(30);
} finally {
refreshingJobs.value = false;
}
}
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 refreshForSection(): Promise<void> {
if (props.section === SECTION_USERS) {
await refreshUsers();
return;
}
if (props.section === SECTION_INTEGRITY) {
await refreshIntegrity();
return;
}
if (props.section === SECTION_REPAIRS) {
await Promise.all([refreshUsers(), refreshRepairJobs()]);
return;
}
await refreshPdfQueue();
}
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;
successMessage.value = `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);
successMessage.value = `${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 = "";
successMessage.value = result.message || `Password reset for ${user.email}.`;
} catch (error) {
assignError(error, "Unable to reset password.");
} finally {
resettingPassword.value = false;
}
}
async function onRunRepair(): Promise<void> {
runningRepair.value = true;
clearAlerts();
try {
const scopeMode = repairScopeMode.value;
const payload = {
scope_mode: scopeMode,
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: scopeMode === SCOPE_SINGLE_USER ? parseRepairUserIdOrThrow(repairUserId.value) : undefined,
};
const result = await triggerPublicationLinkRepair(payload);
lastRepairResult.value = result;
successMessage.value = `Repair job #${result.job_id} completed (${result.status}).`;
repairConfirmationText.value = "";
await Promise.all([refreshIntegrity(), refreshRepairJobs(), refreshPdfQueue()]);
} catch (error) {
assignError(error, "Unable to run publication link repair.");
} finally {
runningRepair.value = false;
}
}
function onScopeModeChange(): void {
ensureRepairUserSelected();
}
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);
successMessage.value = 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);
successMessage.value = result.message;
await refreshPdfQueue();
} catch (error) {
assignError(error, "Unable to queue missing PDF lookups.");
} finally {
requeueingAllPdfs.value = false;
}
}
onMounted(async () => {
loading.value = true;
clearAlerts();
try {
await refreshForSection();
} catch (error) {
assignError(error, "Unable to load admin data.");
} finally {
loading.value = false;
}
});
watch(
() => props.section,
async () => {
loading.value = true;
clearAlerts();
try {
await refreshForSection();
} catch (error) {
assignError(error, "Unable to load admin data.");
} finally {
loading.value = false;
}
},
);
</script>
<template>
<section class="grid gap-4">
<RequestStateAlerts
:success-message="successMessage"
:error-message="errorMessage"
:error-request-id="errorRequestId"
success-title="Operation complete"
error-title="Operation failed"
@dismiss-success="successMessage = null"
/>
<AsyncStateGate :loading="loading" :loading-lines="10" :show-empty="false">
<AppCard v-if="props.section === SECTION_USERS" 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>
<AppCard v-if="props.section === SECTION_INTEGRITY" 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>
<section v-if="props.section === SECTION_REPAIRS" 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="onScopeModeChange">
<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 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>
</section>
<AppCard v-if="props.section === SECTION_PDF" 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.doi" :href="`https://doi.org/${item.doi}`" target="_blank" rel="noreferrer" class="link-inline text-xs">
DOI: {{ item.doi }}
</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>
</AsyncStateGate>
<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>
</section>
</template>