Remove the auth.isAdmin guard from the Start check and Cancel check buttons in the Activity Monitor. Admin-only route links (check history, diagnostics) are unchanged. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
542 lines
22 KiB
Vue
542 lines
22 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
|
|
|
import { fetchDashboardSnapshot, type DashboardSnapshot } from "@/features/dashboard";
|
|
import { formatCooldownCountdown } from "@/features/safety";
|
|
import { ApiRequestError } from "@/lib/api/errors";
|
|
import AppPage from "@/components/layout/AppPage.vue";
|
|
import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
|
|
import QueueHealthBadge from "@/components/patterns/QueueHealthBadge.vue";
|
|
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
|
|
import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue";
|
|
import ScrapeSafetyBanner from "@/components/patterns/ScrapeSafetyBanner.vue";
|
|
import AppButton from "@/components/ui/AppButton.vue";
|
|
import AppCard from "@/components/ui/AppCard.vue";
|
|
import AppEmptyState from "@/components/ui/AppEmptyState.vue";
|
|
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
|
import { useAuthStore } from "@/stores/auth";
|
|
import { useRunStatusStore } from "@/stores/run_status";
|
|
import { useUserSettingsStore } from "@/stores/user_settings";
|
|
|
|
const loading = ref(true);
|
|
const errorMessage = ref<string | null>(null);
|
|
const errorRequestId = ref<string | null>(null);
|
|
const successMessage = ref<string | null>(null);
|
|
const snapshot = ref<DashboardSnapshot | null>(null);
|
|
const refreshingAfterCompletion = ref(false);
|
|
const auth = useAuthStore();
|
|
const runStatus = useRunStatusStore();
|
|
const userSettings = useUserSettingsStore();
|
|
const DASHBOARD_RUN_STATUS_SYNC_INTERVAL_MS = 5000;
|
|
let runStatusSyncTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
const isStartBlocked = computed(
|
|
() =>
|
|
runStatus.isRunActive ||
|
|
!userSettings.manualRunAllowed ||
|
|
runStatus.safetyState.cooldown_active,
|
|
);
|
|
const startCheckDisabledReason = computed(() => {
|
|
if (!userSettings.manualRunAllowed) {
|
|
return "Manual checks are disabled by server policy.";
|
|
}
|
|
if (runStatus.safetyState.cooldown_active) {
|
|
return runStatus.safetyState.cooldown_reason_label || "Safety cooldown is active.";
|
|
}
|
|
if (runStatus.isRunActive) {
|
|
return "A check is already in progress.";
|
|
}
|
|
return null;
|
|
});
|
|
|
|
const startCheckLabel = computed(() => {
|
|
if (!userSettings.manualRunAllowed) {
|
|
return "Manual checks disabled";
|
|
}
|
|
if (runStatus.safetyState.cooldown_active) {
|
|
const remaining = runStatus.safetyState.cooldown_remaining_seconds;
|
|
if (remaining > 0) {
|
|
return `Cooldown (${formatCooldownCountdown(remaining)})`;
|
|
}
|
|
return "Safety cooldown";
|
|
}
|
|
if (runStatus.isLikelyRunning) {
|
|
return "Check in progress";
|
|
}
|
|
if (runStatus.isSubmitting) {
|
|
return "Starting...";
|
|
}
|
|
return "Start check";
|
|
});
|
|
const isStartCheckAnimating = computed(
|
|
() => runStatus.isSubmitting || runStatus.isLikelyRunning,
|
|
);
|
|
const isCancelAnimating = ref(false);
|
|
|
|
const displayedLatestRun = computed(() => {
|
|
const snapshotLatest = snapshot.value?.latestRun ?? null;
|
|
const sharedLatest = runStatus.latestRun;
|
|
|
|
if (!snapshotLatest) {
|
|
return sharedLatest;
|
|
}
|
|
if (!sharedLatest) {
|
|
return snapshotLatest;
|
|
}
|
|
if (sharedLatest.id > snapshotLatest.id) {
|
|
return sharedLatest;
|
|
}
|
|
if (sharedLatest.id < snapshotLatest.id) {
|
|
return snapshotLatest;
|
|
}
|
|
return sharedLatest;
|
|
});
|
|
|
|
const recentRuns = computed(() => {
|
|
const baseRuns = snapshot.value?.recentRuns ?? [];
|
|
const mergedRuns = [...baseRuns];
|
|
const latest = displayedLatestRun.value;
|
|
|
|
if (!latest) {
|
|
return mergedRuns;
|
|
}
|
|
|
|
const existingIndex = mergedRuns.findIndex((run) => run.id === latest.id);
|
|
if (existingIndex === -1) {
|
|
mergedRuns.unshift(latest);
|
|
return mergedRuns;
|
|
}
|
|
|
|
mergedRuns.splice(existingIndex, 1, latest);
|
|
return mergedRuns;
|
|
});
|
|
|
|
const recentPublications = computed(() => {
|
|
const stream = runStatus.livePublications;
|
|
// DashboardSnapshot's recentPublications is compatible with PublicationItem,
|
|
// but let's cast it slightly to guarantee iteration.
|
|
const base = snapshot.value?.recentPublications ?? [];
|
|
const merged = [...stream, ...base];
|
|
|
|
const seenIds = new Set();
|
|
const deduped: any[] = [];
|
|
for (const item of merged) {
|
|
if (!seenIds.has(item.publication_id)) {
|
|
seenIds.add(item.publication_id);
|
|
deduped.push(item);
|
|
}
|
|
}
|
|
return deduped;
|
|
});
|
|
|
|
const activeRunId = computed(() => runStatus.latestRun?.status === "running" ? runStatus.latestRun.id : null);
|
|
const showRunningHint = computed(
|
|
() => runStatus.isLikelyRunning && displayedLatestRun.value?.status !== "running",
|
|
);
|
|
|
|
function formatDate(value: string | null): string {
|
|
if (!value) {
|
|
return "n/a";
|
|
}
|
|
const asDate = new Date(value);
|
|
if (Number.isNaN(asDate.getTime())) {
|
|
return value;
|
|
}
|
|
return asDate.toLocaleString();
|
|
}
|
|
|
|
function shouldRefreshAfterRunChange(
|
|
nextRun: typeof runStatus.latestRun,
|
|
previousRun: typeof runStatus.latestRun,
|
|
): boolean {
|
|
if (!nextRun || !previousRun) {
|
|
return false;
|
|
}
|
|
if (nextRun.id !== previousRun.id) {
|
|
return true;
|
|
}
|
|
if (nextRun.status === previousRun.status) {
|
|
return false;
|
|
}
|
|
const nextActive = nextRun.status === "running" || nextRun.status === "resolving";
|
|
const previousActive = previousRun.status === "running" || previousRun.status === "resolving";
|
|
return nextActive || previousActive;
|
|
}
|
|
|
|
function startRunStatusSyncLoop(): void {
|
|
if (runStatusSyncTimer !== null) {
|
|
return;
|
|
}
|
|
runStatusSyncTimer = setInterval(() => {
|
|
if (runStatus.isRunActive) {
|
|
return;
|
|
}
|
|
void runStatus.syncLatest();
|
|
}, DASHBOARD_RUN_STATUS_SYNC_INTERVAL_MS);
|
|
}
|
|
|
|
function stopRunStatusSyncLoop(): void {
|
|
if (runStatusSyncTimer === null) {
|
|
return;
|
|
}
|
|
clearInterval(runStatusSyncTimer);
|
|
runStatusSyncTimer = null;
|
|
}
|
|
|
|
async function loadSnapshot(): Promise<void> {
|
|
loading.value = true;
|
|
errorMessage.value = null;
|
|
errorRequestId.value = null;
|
|
|
|
try {
|
|
const dashboardSnapshot = await fetchDashboardSnapshot();
|
|
snapshot.value = dashboardSnapshot;
|
|
runStatus.setLatestRun(dashboardSnapshot.latestRun);
|
|
runStatus.setSafetyState(dashboardSnapshot.safetyState);
|
|
} catch (error) {
|
|
snapshot.value = null;
|
|
if (error instanceof ApiRequestError) {
|
|
errorMessage.value = error.message;
|
|
errorRequestId.value = error.requestId;
|
|
} else {
|
|
errorMessage.value = "Unable to load dashboard data.";
|
|
}
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function onTriggerRun(): Promise<void> {
|
|
errorMessage.value = null;
|
|
errorRequestId.value = null;
|
|
successMessage.value = null;
|
|
|
|
try {
|
|
const result = await runStatus.startManualCheck();
|
|
if (result.kind === "started") {
|
|
successMessage.value = `Update check #${result.runId} started successfully.`;
|
|
await loadSnapshot();
|
|
return;
|
|
}
|
|
|
|
if (result.kind === "already_running") {
|
|
successMessage.value = result.runId
|
|
? `Update check #${result.runId} is already in progress.`
|
|
: "An update check is already in progress.";
|
|
await loadSnapshot();
|
|
return;
|
|
}
|
|
|
|
errorRequestId.value = result.requestId;
|
|
} catch {
|
|
errorMessage.value = "Unable to start an update check.";
|
|
errorRequestId.value = null;
|
|
}
|
|
}
|
|
|
|
async function onCancelRun(): Promise<void> {
|
|
if (!activeRunId.value) return;
|
|
errorMessage.value = null;
|
|
errorRequestId.value = null;
|
|
successMessage.value = null;
|
|
isCancelAnimating.value = true;
|
|
|
|
try {
|
|
const result = await runStatus.cancelActiveCheck();
|
|
if (result.kind === "success") {
|
|
successMessage.value = "Update check canceled successfully.";
|
|
await loadSnapshot();
|
|
} else {
|
|
errorMessage.value = result.message;
|
|
}
|
|
} catch {
|
|
errorMessage.value = "Unable to cancel the update check.";
|
|
} finally {
|
|
isCancelAnimating.value = false;
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
startRunStatusSyncLoop();
|
|
void loadSnapshot();
|
|
void runStatus.syncLatest();
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
stopRunStatusSyncLoop();
|
|
});
|
|
|
|
watch(
|
|
() => runStatus.latestRun,
|
|
(nextRun, previousRun) => {
|
|
if (refreshingAfterCompletion.value) {
|
|
return;
|
|
}
|
|
if (!shouldRefreshAfterRunChange(nextRun, previousRun)) {
|
|
return;
|
|
}
|
|
|
|
refreshingAfterCompletion.value = true;
|
|
void loadSnapshot().finally(() => {
|
|
refreshingAfterCompletion.value = false;
|
|
});
|
|
},
|
|
);
|
|
</script>
|
|
|
|
<template>
|
|
<AppPage
|
|
title="Dashboard"
|
|
subtitle="Track recent publication updates and monitor background profile checks."
|
|
fill
|
|
>
|
|
<div class="flex min-h-0 flex-1 flex-col gap-4 xl:overflow-hidden">
|
|
<RequestStateAlerts
|
|
:success-message="successMessage"
|
|
:error-message="errorMessage"
|
|
:error-request-id="errorRequestId"
|
|
error-title="Dashboard request failed"
|
|
@dismiss-success="successMessage = null"
|
|
/>
|
|
<ScrapeSafetyBanner
|
|
:safety-state="runStatus.safetyState"
|
|
:manual-run-allowed="userSettings.manualRunAllowed"
|
|
/>
|
|
|
|
<div class="h-0 min-h-0 flex-1 xl:overflow-hidden">
|
|
<AsyncStateGate :loading="loading" :loading-lines="6" :show-empty="false">
|
|
<template v-if="snapshot">
|
|
<section class="grid min-h-0 gap-4 xl:h-full xl:grid-cols-2 xl:grid-rows-[minmax(0,1fr)] xl:overflow-hidden">
|
|
<AppCard class="flex min-h-0 flex-col gap-4 xl:h-full xl:overflow-hidden">
|
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
<div class="space-y-1">
|
|
<div class="flex items-center gap-1">
|
|
<h2 class="text-lg font-semibold text-ink-primary">Recent Publications</h2>
|
|
<AppHelpHint
|
|
text="Recent publications are paper records discovered from tracked scholar profile checks."
|
|
/>
|
|
</div>
|
|
<p class="text-sm text-secondary">Newest papers found while checking your tracked scholar profiles.</p>
|
|
</div>
|
|
<RouterLink
|
|
to="/publications"
|
|
class="inline-flex min-h-10 items-center justify-center rounded-lg border border-action-secondary-border bg-action-secondary-bg px-3 py-2 text-sm font-semibold text-action-secondary-text shadow-sm transition hover:border-action-secondary-hover-border hover:bg-action-secondary-hover-bg hover:text-action-secondary-hover-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
|
|
>
|
|
view all
|
|
</RouterLink>
|
|
</div>
|
|
|
|
<AppEmptyState
|
|
v-if="recentPublications.length === 0"
|
|
title="No new publications"
|
|
body="When a completed update check discovers changes, they will appear here."
|
|
/>
|
|
|
|
<ul v-else class="grid min-h-0 flex-1 content-start gap-3 overflow-y-scroll overscroll-contain pr-1">
|
|
<li
|
|
v-for="item in recentPublications.slice(0, 20)"
|
|
:key="item.publication_id"
|
|
class="grid gap-1 rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2"
|
|
>
|
|
<a
|
|
v-if="item.pub_url"
|
|
:href="item.pub_url"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
class="link-inline text-sm font-semibold text-ink-primary"
|
|
>
|
|
{{ item.title }}
|
|
</a>
|
|
<span v-else class="text-sm font-semibold text-ink-primary">{{ item.title }}</span>
|
|
<div class="flex flex-wrap items-center gap-3 text-xs text-secondary">
|
|
<RouterLink
|
|
:to="{ name: 'publications', query: { scholar: String(item.scholar_profile_id) } }"
|
|
class="link-inline"
|
|
>
|
|
{{ item.scholar_label }}
|
|
</RouterLink>
|
|
<span>First seen: {{ formatDate(item.first_seen_at) }}</span>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
</AppCard>
|
|
|
|
<div class="grid min-h-0 gap-4 xl:h-full xl:grid-rows-[auto_minmax(0,1fr)] xl:overflow-hidden">
|
|
<AppCard class="flex min-h-0 flex-col gap-4">
|
|
<div class="flex items-center gap-1">
|
|
<h2 class="text-lg font-semibold text-ink-primary">Overview</h2>
|
|
<AppHelpHint text="Shared summary metrics for publication coverage and processing pressure." />
|
|
</div>
|
|
<div class="grid gap-3 sm:grid-cols-2">
|
|
<article class="rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2">
|
|
<p class="text-xs uppercase tracking-wide text-ink-muted">Tracked publications</p>
|
|
<p class="mt-1 text-xl font-semibold text-ink-primary">{{ snapshot.totalCount }}</p>
|
|
</article>
|
|
<article class="rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2">
|
|
<p class="text-xs uppercase tracking-wide text-ink-muted">New from latest check</p>
|
|
<p class="mt-1 text-xl font-semibold text-ink-primary">{{ snapshot.newCount }}</p>
|
|
</article>
|
|
<article class="rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2">
|
|
<p class="text-xs uppercase tracking-wide text-ink-muted">Scholars processed</p>
|
|
<p class="mt-1 text-xl font-semibold text-ink-primary">{{ displayedLatestRun?.scholar_count ?? 0 }}</p>
|
|
</article>
|
|
<article class="rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2">
|
|
<p class="text-xs uppercase tracking-wide text-ink-muted">Queue pressure</p>
|
|
<p class="mt-1 text-xl font-semibold text-ink-primary">
|
|
{{ snapshot.queue.queued + snapshot.queue.retrying + snapshot.queue.dropped }}
|
|
</p>
|
|
</article>
|
|
</div>
|
|
<QueueHealthBadge
|
|
:queued="snapshot.queue.queued"
|
|
:retrying="snapshot.queue.retrying"
|
|
:dropped="snapshot.queue.dropped"
|
|
/>
|
|
</AppCard>
|
|
|
|
<AppCard class="flex min-h-0 flex-col gap-4 xl:overflow-hidden">
|
|
<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">Activity Monitor</h2>
|
|
<AppHelpHint
|
|
text="Combines latest profile-check details with recent check history so you can spot issues quickly."
|
|
/>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<AppButton
|
|
v-if="runStatus.isLikelyRunning"
|
|
variant="danger"
|
|
:disabled="!activeRunId || isCancelAnimating"
|
|
@click="onCancelRun"
|
|
>
|
|
<span class="inline-flex items-center gap-2">
|
|
<span v-if="isCancelAnimating" class="relative inline-flex h-2.5 w-2.5">
|
|
<span
|
|
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-60"
|
|
/>
|
|
<span class="relative inline-flex h-2.5 w-2.5 rounded-full bg-current" />
|
|
</span>
|
|
Cancel check
|
|
</span>
|
|
</AppButton>
|
|
<AppButton
|
|
:disabled="isStartBlocked"
|
|
:title="startCheckDisabledReason || undefined"
|
|
:class="isStartCheckAnimating ? 'shadow-[0_0_0_1px_var(--color-state-info-border)]' : ''"
|
|
@click="onTriggerRun"
|
|
>
|
|
<span class="inline-flex items-center gap-2">
|
|
<span v-if="isStartCheckAnimating" class="relative inline-flex h-2.5 w-2.5">
|
|
<span
|
|
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-60"
|
|
/>
|
|
<span class="relative inline-flex h-2.5 w-2.5 rounded-full bg-current" />
|
|
</span>
|
|
{{ startCheckLabel }}
|
|
</span>
|
|
</AppButton>
|
|
<RouterLink
|
|
v-if="auth.isAdmin"
|
|
to="/admin/runs"
|
|
class="link-inline text-sm"
|
|
>
|
|
Open check history
|
|
</RouterLink>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="showRunningHint"
|
|
class="rounded-xl border border-state-info-border bg-state-info-bg px-3 py-2"
|
|
>
|
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
<div class="flex items-center gap-2">
|
|
<RunStatusBadge status="running" />
|
|
<span class="text-sm font-semibold text-state-info-text">Check in progress</span>
|
|
</div>
|
|
<RouterLink
|
|
v-if="auth.isAdmin && activeRunId"
|
|
:to="`/admin/runs/${activeRunId}`"
|
|
class="link-inline text-xs"
|
|
>
|
|
View live check details
|
|
</RouterLink>
|
|
</div>
|
|
<p class="mt-1 text-sm text-state-info-text">
|
|
A publication check has started. Results and counts update after it finishes.
|
|
</p>
|
|
</div>
|
|
|
|
<div
|
|
v-if="displayedLatestRun"
|
|
class="rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2"
|
|
>
|
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
<div class="flex items-center gap-2">
|
|
<RunStatusBadge :status="displayedLatestRun.status" />
|
|
<span class="text-sm font-semibold text-ink-primary">Latest check #{{ displayedLatestRun.id }}</span>
|
|
</div>
|
|
<RouterLink
|
|
v-if="auth.isAdmin"
|
|
:to="`/admin/runs/${displayedLatestRun.id}`"
|
|
class="link-inline text-xs"
|
|
>
|
|
View diagnostics
|
|
</RouterLink>
|
|
</div>
|
|
<p class="mt-2 text-sm text-secondary">
|
|
Started {{ formatDate(displayedLatestRun.start_dt) }}.
|
|
<template v-if="runStatus.isLikelyRunning">
|
|
{{ runStatus.scholarProgress?.visited ?? 0 }} / {{ runStatus.scholarProgress?.total ?? '…' }} visited
|
|
· {{ runStatus.scholarProgress?.finished ?? 0 }} finished
|
|
· {{ displayedLatestRun.new_publication_count }} new publications.
|
|
</template>
|
|
<template v-else>
|
|
Processed {{ displayedLatestRun.scholar_count }} scholars and discovered
|
|
{{ displayedLatestRun.new_publication_count }} new publications.
|
|
</template>
|
|
</p>
|
|
</div>
|
|
<AppEmptyState
|
|
v-else
|
|
title="No checks recorded"
|
|
body="Start an update check to begin monitoring activity."
|
|
/>
|
|
|
|
<div class="flex min-h-0 flex-1 flex-col gap-2 xl:overflow-hidden">
|
|
<p class="text-xs font-semibold uppercase tracking-wide text-muted">Recent checks</p>
|
|
<AppEmptyState
|
|
v-if="recentRuns.length === 0"
|
|
title="No checks yet"
|
|
body="Check history will appear here."
|
|
/>
|
|
<ul v-else class="grid min-h-0 flex-1 content-start gap-2 overflow-y-auto overscroll-contain pr-1">
|
|
<li
|
|
v-for="run in recentRuns"
|
|
:key="run.id"
|
|
class="grid gap-1 rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2"
|
|
>
|
|
<div class="flex items-center justify-between gap-2">
|
|
<div class="flex items-center gap-2">
|
|
<RunStatusBadge :status="run.status" />
|
|
<span class="text-sm text-secondary">Check #{{ run.id }}</span>
|
|
</div>
|
|
<RouterLink v-if="auth.isAdmin" :to="`/admin/runs/${run.id}`" class="link-inline text-xs">
|
|
Details
|
|
</RouterLink>
|
|
</div>
|
|
<span class="text-xs text-secondary">
|
|
{{ formatDate(run.start_dt) }} • {{ run.new_publication_count }} new
|
|
</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</AppCard>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
</AsyncStateGate>
|
|
</div>
|
|
</div>
|
|
</AppPage>
|
|
</template>
|