Merge pull request #6 from JustinZeus/feature/run-state-ui
Added run state front end support
This commit is contained in:
commit
bd33e797a2
10 changed files with 1095 additions and 284 deletions
11
README.md
11
README.md
|
|
@ -72,6 +72,17 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml down
|
||||||
- `CONTRIBUTING.md`: contribution policy and merge checklist.
|
- `CONTRIBUTING.md`: contribution policy and merge checklist.
|
||||||
- `scripts/check_no_generated_artifacts.sh`: tracked-artifact guard used by CI.
|
- `scripts/check_no_generated_artifacts.sh`: tracked-artifact guard used by CI.
|
||||||
|
|
||||||
|
## Data Model Notes
|
||||||
|
|
||||||
|
- Scholar tracking is user-scoped: each account can track the same Scholar ID independently.
|
||||||
|
- Publications are shared/global records deduplicated by Scholar cluster ID and normalized fingerprint.
|
||||||
|
- Per-account visibility and read state is stored on scholar-publication links, not on the global publication row.
|
||||||
|
|
||||||
|
## Name Search Status
|
||||||
|
|
||||||
|
- Scholar name search is intentionally WIP in the UI.
|
||||||
|
- Current Google Scholar behavior often redirects name-search traffic to login/challenge flows, so production onboarding should use direct Scholar ID/profile URL adds.
|
||||||
|
|
||||||
## Environment Variables (Complete Reference)
|
## Environment Variables (Complete Reference)
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { setCsrfTokenProvider } from "@/lib/api/client";
|
import { setCsrfTokenProvider } from "@/lib/api/client";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
import { useRunStatusStore } from "@/stores/run_status";
|
||||||
import { useThemeStore } from "@/stores/theme";
|
import { useThemeStore } from "@/stores/theme";
|
||||||
import { useUserSettingsStore } from "@/stores/user_settings";
|
import { useUserSettingsStore } from "@/stores/user_settings";
|
||||||
|
|
||||||
|
|
@ -12,9 +13,12 @@ export async function bootstrapAppProviders(): Promise<void> {
|
||||||
await auth.bootstrapSession();
|
await auth.bootstrapSession();
|
||||||
|
|
||||||
const userSettings = useUserSettingsStore();
|
const userSettings = useUserSettingsStore();
|
||||||
|
const runStatus = useRunStatusStore();
|
||||||
if (auth.isAuthenticated) {
|
if (auth.isAuthenticated) {
|
||||||
await userSettings.bootstrap();
|
await userSettings.bootstrap();
|
||||||
|
await runStatus.bootstrap();
|
||||||
} else {
|
} else {
|
||||||
userSettings.reset();
|
userSettings.reset();
|
||||||
|
runStatus.reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,14 @@
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
|
|
||||||
|
import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue";
|
||||||
import AppButton from "@/components/ui/AppButton.vue";
|
import AppButton from "@/components/ui/AppButton.vue";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
import { useRunStatusStore } from "@/stores/run_status";
|
||||||
import { useUserSettingsStore } from "@/stores/user_settings";
|
import { useUserSettingsStore } from "@/stores/user_settings";
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
const runStatus = useRunStatusStore();
|
||||||
const userSettings = useUserSettingsStore();
|
const userSettings = useUserSettingsStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|
@ -28,6 +31,21 @@ const links = computed(() => {
|
||||||
return userSettings.isPageVisible(item.id);
|
return userSettings.isPageVisible(item.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
const navRunStatus = computed(() => {
|
||||||
|
if (runStatus.isLikelyRunning) {
|
||||||
|
return "running";
|
||||||
|
}
|
||||||
|
return "idle";
|
||||||
|
});
|
||||||
|
const navRunText = computed(() => {
|
||||||
|
const label = navRunStatus.value
|
||||||
|
.replaceAll("_", " ")
|
||||||
|
.split(" ")
|
||||||
|
.filter((segment) => segment.length > 0)
|
||||||
|
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
return `State: ${label}`;
|
||||||
|
});
|
||||||
|
|
||||||
async function onLogout(): Promise<void> {
|
async function onLogout(): Promise<void> {
|
||||||
await auth.logout();
|
await auth.logout();
|
||||||
|
|
@ -53,7 +71,13 @@ async function onLogout(): Promise<void> {
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="mt-auto grid gap-2 border-t border-stroke-subtle pt-3">
|
<div class="mt-auto grid gap-2 border-t border-stroke-subtle pt-3">
|
||||||
<p class="truncate text-xs text-ink-muted">{{ auth.user?.email }}</p>
|
<div
|
||||||
|
v-if="auth.isAdmin"
|
||||||
|
class="flex items-center justify-between gap-2 rounded-lg border border-stroke-default bg-surface-card-muted px-2.5 py-2 text-xs text-secondary"
|
||||||
|
>
|
||||||
|
<span class="truncate">{{ navRunText }}</span>
|
||||||
|
<RunStatusBadge :status="navRunStatus" />
|
||||||
|
</div>
|
||||||
<AppButton variant="ghost" class="w-full justify-start" @click="onLogout">Logout</AppButton>
|
<AppButton variant="ghost" class="w-full justify-start" @click="onLogout">Logout</AppButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
|
||||||
import { fetchDashboardSnapshot, type DashboardSnapshot } from "@/features/dashboard";
|
import { fetchDashboardSnapshot, type DashboardSnapshot } from "@/features/dashboard";
|
||||||
import { triggerManualRun } from "@/features/runs";
|
|
||||||
import { ApiRequestError } from "@/lib/api/errors";
|
import { ApiRequestError } from "@/lib/api/errors";
|
||||||
import AppPage from "@/components/layout/AppPage.vue";
|
import AppPage from "@/components/layout/AppPage.vue";
|
||||||
import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
|
import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
|
||||||
|
|
@ -14,14 +13,67 @@ import AppCard from "@/components/ui/AppCard.vue";
|
||||||
import AppEmptyState from "@/components/ui/AppEmptyState.vue";
|
import AppEmptyState from "@/components/ui/AppEmptyState.vue";
|
||||||
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
import { useRunStatusStore } from "@/stores/run_status";
|
||||||
|
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const pendingRun = ref(false);
|
|
||||||
const errorMessage = ref<string | null>(null);
|
const errorMessage = ref<string | null>(null);
|
||||||
const errorRequestId = ref<string | null>(null);
|
const errorRequestId = ref<string | null>(null);
|
||||||
const successMessage = ref<string | null>(null);
|
const successMessage = ref<string | null>(null);
|
||||||
const snapshot = ref<DashboardSnapshot | null>(null);
|
const snapshot = ref<DashboardSnapshot | null>(null);
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
const runStatus = useRunStatusStore();
|
||||||
|
|
||||||
|
const startCheckLabel = computed(() => {
|
||||||
|
if (runStatus.isLikelyRunning) {
|
||||||
|
return "Check in progress";
|
||||||
|
}
|
||||||
|
if (runStatus.isSubmitting) {
|
||||||
|
return "Starting...";
|
||||||
|
}
|
||||||
|
return "Start check";
|
||||||
|
});
|
||||||
|
|
||||||
|
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 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 {
|
function formatDate(value: string | null): string {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
|
|
@ -40,7 +92,9 @@ async function loadSnapshot(): Promise<void> {
|
||||||
errorRequestId.value = null;
|
errorRequestId.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
snapshot.value = await fetchDashboardSnapshot();
|
const dashboardSnapshot = await fetchDashboardSnapshot();
|
||||||
|
snapshot.value = dashboardSnapshot;
|
||||||
|
runStatus.setLatestRun(dashboardSnapshot.latestRun);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
snapshot.value = null;
|
snapshot.value = null;
|
||||||
if (error instanceof ApiRequestError) {
|
if (error instanceof ApiRequestError) {
|
||||||
|
|
@ -55,24 +109,31 @@ async function loadSnapshot(): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onTriggerRun(): Promise<void> {
|
async function onTriggerRun(): Promise<void> {
|
||||||
pendingRun.value = true;
|
|
||||||
errorMessage.value = null;
|
errorMessage.value = null;
|
||||||
errorRequestId.value = null;
|
errorRequestId.value = null;
|
||||||
successMessage.value = null;
|
successMessage.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await triggerManualRun();
|
const result = await runStatus.startManualCheck();
|
||||||
successMessage.value = `Update check #${result.run_id} started successfully.`;
|
if (result.kind === "started") {
|
||||||
await loadSnapshot();
|
successMessage.value = `Update check #${result.runId} started successfully.`;
|
||||||
} catch (error) {
|
await loadSnapshot();
|
||||||
if (error instanceof ApiRequestError) {
|
return;
|
||||||
errorMessage.value = error.message;
|
|
||||||
errorRequestId.value = error.requestId;
|
|
||||||
} else {
|
|
||||||
errorMessage.value = "Unable to start an update check.";
|
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
pendingRun.value = false;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorMessage.value = result.message;
|
||||||
|
errorRequestId.value = result.requestId;
|
||||||
|
} catch {
|
||||||
|
errorMessage.value = "Unable to start an update check.";
|
||||||
|
errorRequestId.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -166,7 +227,7 @@ onMounted(() => {
|
||||||
</article>
|
</article>
|
||||||
<article class="rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-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">Scholars processed</p>
|
<p class="text-xs uppercase tracking-wide text-ink-muted">Scholars processed</p>
|
||||||
<p class="mt-1 text-xl font-semibold text-ink-primary">{{ snapshot.latestRun?.scholar_count ?? 0 }}</p>
|
<p class="mt-1 text-xl font-semibold text-ink-primary">{{ displayedLatestRun?.scholar_count ?? 0 }}</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-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">Queue pressure</p>
|
<p class="text-xs uppercase tracking-wide text-ink-muted">Queue pressure</p>
|
||||||
|
|
@ -193,10 +254,10 @@ onMounted(() => {
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<AppButton
|
<AppButton
|
||||||
v-if="auth.isAdmin"
|
v-if="auth.isAdmin"
|
||||||
:disabled="pendingRun"
|
:disabled="runStatus.isRunActive"
|
||||||
@click="onTriggerRun"
|
@click="onTriggerRun"
|
||||||
>
|
>
|
||||||
{{ pendingRun ? "Starting..." : "Start check" }}
|
{{ startCheckLabel }}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-if="auth.isAdmin"
|
v-if="auth.isAdmin"
|
||||||
|
|
@ -209,26 +270,48 @@ onMounted(() => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="snapshot.latestRun"
|
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"
|
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 flex-wrap items-center justify-between gap-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<RunStatusBadge :status="snapshot.latestRun.status" />
|
<RunStatusBadge :status="displayedLatestRun.status" />
|
||||||
<span class="text-sm font-semibold text-ink-primary">Latest check #{{ snapshot.latestRun.id }}</span>
|
<span class="text-sm font-semibold text-ink-primary">Latest check #{{ displayedLatestRun.id }}</span>
|
||||||
</div>
|
</div>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-if="auth.isAdmin"
|
v-if="auth.isAdmin"
|
||||||
:to="`/admin/runs/${snapshot.latestRun.id}`"
|
:to="`/admin/runs/${displayedLatestRun.id}`"
|
||||||
class="link-inline text-xs"
|
class="link-inline text-xs"
|
||||||
>
|
>
|
||||||
View diagnostics
|
View diagnostics
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-sm text-secondary">
|
<p class="mt-2 text-sm text-secondary">
|
||||||
Started {{ formatDate(snapshot.latestRun.start_dt) }}. Processed
|
Started {{ formatDate(displayedLatestRun.start_dt) }}. Processed
|
||||||
{{ snapshot.latestRun.scholar_count }} scholars and discovered
|
{{ displayedLatestRun.scholar_count }} scholars and discovered
|
||||||
{{ snapshot.latestRun.new_publication_count }} new publications.
|
{{ displayedLatestRun.new_publication_count }} new publications.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<AppEmptyState
|
<AppEmptyState
|
||||||
|
|
@ -240,13 +323,13 @@ onMounted(() => {
|
||||||
<div class="flex min-h-0 flex-1 flex-col gap-2 xl:overflow-hidden">
|
<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>
|
<p class="text-xs font-semibold uppercase tracking-wide text-muted">Recent checks</p>
|
||||||
<AppEmptyState
|
<AppEmptyState
|
||||||
v-if="snapshot.recentRuns.length === 0"
|
v-if="recentRuns.length === 0"
|
||||||
title="No checks yet"
|
title="No checks yet"
|
||||||
body="Check history will appear here."
|
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">
|
<ul v-else class="grid min-h-0 flex-1 content-start gap-2 overflow-y-auto overscroll-contain pr-1">
|
||||||
<li
|
<li
|
||||||
v-for="run in snapshot.recentRuns"
|
v-for="run in recentRuns"
|
||||||
:key="run.id"
|
:key="run.id"
|
||||||
class="grid gap-1 rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2"
|
class="grid gap-1 rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -17,20 +17,20 @@ import {
|
||||||
listQueueItems,
|
listQueueItems,
|
||||||
listRuns,
|
listRuns,
|
||||||
retryQueueItem,
|
retryQueueItem,
|
||||||
triggerManualRun,
|
|
||||||
type QueueItem,
|
type QueueItem,
|
||||||
type RunListItem,
|
type RunListItem,
|
||||||
} from "@/features/runs";
|
} from "@/features/runs";
|
||||||
import { ApiRequestError } from "@/lib/api/errors";
|
import { ApiRequestError } from "@/lib/api/errors";
|
||||||
|
import { useRunStatusStore } from "@/stores/run_status";
|
||||||
|
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const pendingRun = ref(false);
|
|
||||||
const runs = ref<RunListItem[]>([]);
|
const runs = ref<RunListItem[]>([]);
|
||||||
const queueItems = ref<QueueItem[]>([]);
|
const queueItems = ref<QueueItem[]>([]);
|
||||||
const errorMessage = ref<string | null>(null);
|
const errorMessage = ref<string | null>(null);
|
||||||
const errorRequestId = ref<string | null>(null);
|
const errorRequestId = ref<string | null>(null);
|
||||||
const successMessage = ref<string | null>(null);
|
const successMessage = ref<string | null>(null);
|
||||||
const activeQueueItemId = ref<number | null>(null);
|
const activeQueueItemId = ref<number | null>(null);
|
||||||
|
const runStatus = useRunStatusStore();
|
||||||
|
|
||||||
function formatDate(value: string | null): string {
|
function formatDate(value: string | null): string {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
|
|
@ -58,6 +58,16 @@ function queueHealth() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const queueCounts = computed(() => queueHealth());
|
const queueCounts = computed(() => queueHealth());
|
||||||
|
const activeRunId = computed(() => runStatus.latestRun?.status === "running" ? runStatus.latestRun.id : null);
|
||||||
|
const runButtonLabel = computed(() => {
|
||||||
|
if (runStatus.isLikelyRunning) {
|
||||||
|
return "Check in progress";
|
||||||
|
}
|
||||||
|
if (runStatus.isSubmitting) {
|
||||||
|
return "Starting...";
|
||||||
|
}
|
||||||
|
return "Run now";
|
||||||
|
});
|
||||||
|
|
||||||
async function loadData(): Promise<void> {
|
async function loadData(): Promise<void> {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
@ -68,6 +78,7 @@ async function loadData(): Promise<void> {
|
||||||
const [loadedRuns, loadedQueue] = await Promise.all([listRuns({ limit: 100 }), listQueueItems(200)]);
|
const [loadedRuns, loadedQueue] = await Promise.all([listRuns({ limit: 100 }), listQueueItems(200)]);
|
||||||
runs.value = loadedRuns;
|
runs.value = loadedRuns;
|
||||||
queueItems.value = loadedQueue;
|
queueItems.value = loadedQueue;
|
||||||
|
runStatus.setLatestRun(loadedRuns[0] ?? null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
runs.value = [];
|
runs.value = [];
|
||||||
queueItems.value = [];
|
queueItems.value = [];
|
||||||
|
|
@ -83,22 +94,31 @@ async function loadData(): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onTriggerManualRun(): Promise<void> {
|
async function onTriggerManualRun(): Promise<void> {
|
||||||
pendingRun.value = true;
|
|
||||||
successMessage.value = null;
|
successMessage.value = null;
|
||||||
|
errorMessage.value = null;
|
||||||
|
errorRequestId.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await triggerManualRun();
|
const result = await runStatus.startManualCheck();
|
||||||
successMessage.value = `Manual run queued as #${result.run_id} (${result.status}).`;
|
if (result.kind === "started") {
|
||||||
await loadData();
|
successMessage.value = `Update check #${result.runId} started successfully.`;
|
||||||
} catch (error) {
|
await loadData();
|
||||||
if (error instanceof ApiRequestError) {
|
return;
|
||||||
errorMessage.value = error.message;
|
|
||||||
errorRequestId.value = error.requestId;
|
|
||||||
} else {
|
|
||||||
errorMessage.value = "Unable to trigger manual run.";
|
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
pendingRun.value = false;
|
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 loadData();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorMessage.value = result.message;
|
||||||
|
errorRequestId.value = result.requestId;
|
||||||
|
} catch {
|
||||||
|
errorMessage.value = "Unable to start an update check.";
|
||||||
|
errorRequestId.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -155,13 +175,23 @@ onMounted(() => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<AppButton :disabled="pendingRun" @click="onTriggerManualRun">
|
<AppButton :disabled="runStatus.isRunActive" @click="onTriggerManualRun">
|
||||||
{{ pendingRun ? "Triggering..." : "Run now" }}
|
{{ runButtonLabel }}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton variant="secondary" :disabled="loading" @click="loadData">
|
<AppButton variant="secondary" :disabled="loading" @click="loadData">
|
||||||
{{ loading ? "Refreshing..." : "Refresh" }}
|
{{ loading ? "Refreshing..." : "Refresh" }}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="runStatus.isLikelyRunning"
|
||||||
|
class="flex flex-wrap items-center gap-2 rounded-xl border border-state-info-border bg-state-info-bg px-3 py-2 text-sm text-state-info-text"
|
||||||
|
>
|
||||||
|
<RunStatusBadge status="running" />
|
||||||
|
<span class="font-medium">A publication check is currently running.</span>
|
||||||
|
<RouterLink v-if="activeRunId" :to="`/admin/runs/${activeRunId}`" class="link-inline text-xs">
|
||||||
|
View live check details
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
|
|
||||||
<RequestStateAlerts
|
<RequestStateAlerts
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import AppCard from "@/components/ui/AppCard.vue";
|
||||||
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||||
import AppInput from "@/components/ui/AppInput.vue";
|
import AppInput from "@/components/ui/AppInput.vue";
|
||||||
import AppModal from "@/components/ui/AppModal.vue";
|
import AppModal from "@/components/ui/AppModal.vue";
|
||||||
|
import AppSelect from "@/components/ui/AppSelect.vue";
|
||||||
import AppTable from "@/components/ui/AppTable.vue";
|
import AppTable from "@/components/ui/AppTable.vue";
|
||||||
import {
|
import {
|
||||||
clearScholarImage,
|
clearScholarImage,
|
||||||
|
|
@ -42,6 +43,9 @@ const imageUrlDraftByScholarId = ref<Record<number, string>>({});
|
||||||
|
|
||||||
const scholarBatchInput = ref("");
|
const scholarBatchInput = ref("");
|
||||||
const searchQuery = ref("");
|
const searchQuery = ref("");
|
||||||
|
const trackedFilterQuery = ref("");
|
||||||
|
type TrackedScholarSort = "recent" | "name_asc" | "name_desc" | "enabled_first";
|
||||||
|
const trackedSort = ref<TrackedScholarSort>("recent");
|
||||||
const searchResult = ref<ScholarSearchResult | null>(null);
|
const searchResult = ref<ScholarSearchResult | null>(null);
|
||||||
const searchErrorMessage = ref<string | null>(null);
|
const searchErrorMessage = ref<string | null>(null);
|
||||||
const searchErrorRequestId = ref<string | null>(null);
|
const searchErrorRequestId = ref<string | null>(null);
|
||||||
|
|
@ -58,6 +62,7 @@ const trackedScholarIds = computed(() => new Set(scholars.value.map((item) => it
|
||||||
const activeScholarSettings = computed(
|
const activeScholarSettings = computed(
|
||||||
() => scholars.value.find((item) => item.id === activeScholarSettingsId.value) ?? null,
|
() => scholars.value.find((item) => item.id === activeScholarSettingsId.value) ?? null,
|
||||||
);
|
);
|
||||||
|
const hasTrackedScholars = computed(() => scholars.value.length > 0);
|
||||||
const parsedBatchCount = computed(() => parseScholarIds(scholarBatchInput.value).length);
|
const parsedBatchCount = computed(() => parseScholarIds(scholarBatchInput.value).length);
|
||||||
const searchHasRun = computed(() => searchResult.value !== null);
|
const searchHasRun = computed(() => searchResult.value !== null);
|
||||||
const searchIsDegraded = computed(() => {
|
const searchIsDegraded = computed(() => {
|
||||||
|
|
@ -86,6 +91,50 @@ const emptySearchCandidates = computed(() => {
|
||||||
}
|
}
|
||||||
return result.candidates.length === 0;
|
return result.candidates.length === 0;
|
||||||
});
|
});
|
||||||
|
const normalizedTrackedFilter = computed(() => trackedFilterQuery.value.trim().toLocaleLowerCase());
|
||||||
|
const visibleScholars = computed(() => {
|
||||||
|
const filter = normalizedTrackedFilter.value;
|
||||||
|
const filtered = scholars.value.filter((item) => {
|
||||||
|
if (!filter) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const label = scholarLabel(item).toLocaleLowerCase();
|
||||||
|
const scholarId = item.scholar_id.toLocaleLowerCase();
|
||||||
|
return label.includes(filter) || scholarId.includes(filter);
|
||||||
|
});
|
||||||
|
|
||||||
|
const byName = (a: ScholarProfile, b: ScholarProfile) =>
|
||||||
|
scholarLabel(a).localeCompare(scholarLabel(b), undefined, { sensitivity: "base" });
|
||||||
|
|
||||||
|
const sorted = [...filtered];
|
||||||
|
if (trackedSort.value === "name_asc") {
|
||||||
|
sorted.sort(byName);
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
if (trackedSort.value === "name_desc") {
|
||||||
|
sorted.sort((a, b) => byName(b, a));
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
if (trackedSort.value === "enabled_first") {
|
||||||
|
sorted.sort((a, b) => {
|
||||||
|
if (a.is_enabled !== b.is_enabled) {
|
||||||
|
return a.is_enabled ? -1 : 1;
|
||||||
|
}
|
||||||
|
return byName(a, b);
|
||||||
|
});
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
sorted.sort((a, b) => b.id - a.id);
|
||||||
|
return sorted;
|
||||||
|
});
|
||||||
|
const hasVisibleScholars = computed(() => visibleScholars.value.length > 0);
|
||||||
|
const trackedCountLabel = computed(() => {
|
||||||
|
if (!normalizedTrackedFilter.value) {
|
||||||
|
return `${scholars.value.length} tracked`;
|
||||||
|
}
|
||||||
|
return `${visibleScholars.value.length} of ${scholars.value.length}`;
|
||||||
|
});
|
||||||
|
|
||||||
function scholarProfileUrl(scholarId: string): string {
|
function scholarProfileUrl(scholarId: string): string {
|
||||||
return `https://scholar.google.com/citations?hl=en&user=${encodeURIComponent(scholarId)}`;
|
return `https://scholar.google.com/citations?hl=en&user=${encodeURIComponent(scholarId)}`;
|
||||||
|
|
@ -464,251 +513,254 @@ onMounted(() => {
|
||||||
<AppPage
|
<AppPage
|
||||||
title="Scholars"
|
title="Scholars"
|
||||||
subtitle="Add and maintain the Google Scholar profiles you want Scholarr to monitor."
|
subtitle="Add and maintain the Google Scholar profiles you want Scholarr to monitor."
|
||||||
|
fill
|
||||||
>
|
>
|
||||||
<RequestStateAlerts
|
<div class="flex min-h-0 flex-1 flex-col gap-4 xl:overflow-hidden">
|
||||||
:success-message="successMessage"
|
<RequestStateAlerts
|
||||||
success-title="Scholar update complete"
|
:success-message="successMessage"
|
||||||
:error-message="errorMessage"
|
success-title="Scholar update complete"
|
||||||
:error-request-id="errorRequestId"
|
:error-message="errorMessage"
|
||||||
error-title="Scholar request failed"
|
:error-request-id="errorRequestId"
|
||||||
@dismiss-success="successMessage = null"
|
error-title="Scholar request failed"
|
||||||
/>
|
@dismiss-success="successMessage = null"
|
||||||
|
/>
|
||||||
|
|
||||||
<section class="grid gap-4 xl:grid-cols-2">
|
<section class="grid min-h-0 flex-1 gap-4 xl:h-full xl:grid-cols-2 xl:grid-rows-[minmax(0,1fr)] xl:overflow-hidden">
|
||||||
<div class="grid content-start gap-4">
|
<div class="grid content-start gap-4 xl:order-2 xl:h-full xl:min-h-0 xl:grid-rows-[minmax(0,1fr)_minmax(0,1fr)] xl:overflow-hidden">
|
||||||
<AppCard class="space-y-4">
|
<AppCard class="space-y-4 xl:flex xl:min-h-0 xl:flex-col">
|
||||||
<div class="space-y-1">
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<h2 class="text-lg font-semibold text-ink-primary">Add Scholar Profiles</h2>
|
|
||||||
<AppHelpHint
|
|
||||||
text="A scholar profile is a Google Scholar author page that Scholarr will monitor for publication changes."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-secondary">Paste one or more Scholar IDs or profile URLs and add them in one action.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form class="grid gap-3" @submit.prevent="onAddScholars">
|
|
||||||
<label class="grid gap-2 text-sm font-medium text-ink-secondary" for="scholar-batch-input">
|
|
||||||
<span>Scholar IDs or profile URLs</span>
|
|
||||||
<textarea
|
|
||||||
id="scholar-batch-input"
|
|
||||||
v-model="scholarBatchInput"
|
|
||||||
rows="5"
|
|
||||||
placeholder="A-UbBTPM15wL\nhttps://scholar.google.com/citations?hl=en&user=A-UbBTPM15wL"
|
|
||||||
class="w-full rounded-lg border border-stroke-interactive bg-surface-input px-3 py-2 text-sm text-ink-primary outline-none ring-focus-ring transition placeholder:text-ink-muted focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<p class="text-xs text-secondary">
|
|
||||||
Parsed IDs: <strong class="text-ink-primary">{{ parsedBatchCount }}</strong>
|
|
||||||
</p>
|
|
||||||
<AppButton type="submit" :disabled="saving || loading">
|
|
||||||
{{ saving ? "Adding..." : "Add scholars" }}
|
|
||||||
</AppButton>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</AppCard>
|
|
||||||
|
|
||||||
<AppCard class="relative space-y-4 select-none">
|
|
||||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<h2 class="text-lg font-semibold text-ink-primary">Search by Name</h2>
|
<h2 class="text-lg font-semibold text-ink-primary">Add Scholar Profiles</h2>
|
||||||
<AppHelpHint text="This workflow is currently paused while anti-block reliability changes are finalized." />
|
<AppHelpHint
|
||||||
|
text="A scholar profile is a Google Scholar author page that Scholarr will monitor for publication changes."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-secondary">Paste one or more Scholar IDs or profile URLs and add them in one action.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="grid gap-3" @submit.prevent="onAddScholars">
|
||||||
|
<label class="grid gap-2 text-sm font-medium text-ink-secondary" for="scholar-batch-input">
|
||||||
|
<span>Scholar IDs or profile URLs</span>
|
||||||
|
<textarea
|
||||||
|
id="scholar-batch-input"
|
||||||
|
v-model="scholarBatchInput"
|
||||||
|
rows="5"
|
||||||
|
placeholder="A-UbBTPM15wL\nhttps://scholar.google.com/citations?hl=en&user=A-UbBTPM15wL"
|
||||||
|
class="w-full rounded-lg border border-stroke-interactive bg-surface-input px-3 py-2 text-sm text-ink-primary outline-none ring-focus-ring transition placeholder:text-ink-muted focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<p class="text-xs text-secondary">
|
||||||
|
Parsed IDs: <strong class="text-ink-primary">{{ parsedBatchCount }}</strong>
|
||||||
|
</p>
|
||||||
|
<AppButton type="submit" :disabled="saving || loading">
|
||||||
|
{{ saving ? "Adding..." : "Add scholars" }}
|
||||||
|
</AppButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
|
<AppCard class="relative space-y-4 select-none xl:flex xl:min-h-0 xl:flex-col xl:overflow-hidden">
|
||||||
|
<div class="flex flex-wrap items-start 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">Search by Name</h2>
|
||||||
|
<AppHelpHint text="Google Scholar now challenges this endpoint with account login and anti-bot checks, so this workflow stays disabled." />
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-secondary">
|
||||||
|
This helper remains paused because Google Scholar currently requires account login for reliable name search access.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<AppHelpHint text="Name search is kept as WIP. Use direct Scholar ID or profile URL adds for production tracking.">
|
||||||
|
<template #trigger>
|
||||||
|
<AppBadge tone="warning">WIP</AppBadge>
|
||||||
|
</template>
|
||||||
|
</AppHelpHint>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="pointer-events-none flex flex-wrap items-center gap-2 opacity-60">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<AppInput
|
||||||
|
id="scholar-search-name"
|
||||||
|
v-model="searchQuery"
|
||||||
|
placeholder="e.g. Geoffrey Hinton"
|
||||||
|
:disabled="nameSearchWip"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<AppButton type="button" :disabled="nameSearchWip">
|
||||||
|
Search
|
||||||
|
</AppButton>
|
||||||
|
</form>
|
||||||
|
<p class="text-xs text-secondary">
|
||||||
|
Direct Scholar ID/URL adds above are the supported path until name search can run without login challenges.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="min-h-0 xl:flex-1 xl:overflow-y-auto xl:pr-1">
|
||||||
|
<RequestStateAlerts
|
||||||
|
:error-message="searchErrorMessage"
|
||||||
|
:error-request-id="searchErrorRequestId"
|
||||||
|
error-title="Search request failed"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AsyncStateGate :loading="searchingByName" :loading-lines="4" :show-empty="false">
|
||||||
|
<template v-if="searchResult">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<p class="text-sm text-secondary">
|
||||||
|
{{ searchResult.candidates.length }} candidate{{ searchResult.candidates.length === 1 ? "" : "s" }}
|
||||||
|
for <strong class="text-ink-primary">{{ searchResult.query }}</strong>
|
||||||
|
</p>
|
||||||
|
<AppBadge :tone="searchStateTone">{{ searchResult.state }}</AppBadge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="searchResult.state !== 'ok' || searchResult.warnings.length > 0" class="text-xs text-secondary">
|
||||||
|
<span>State reason: <code>{{ searchResult.state_reason }}</code></span>
|
||||||
|
<span v-if="searchResult.action_hint">. {{ searchResult.action_hint }}</span>
|
||||||
|
<span v-if="searchResult.warnings.length > 0">. Warnings: {{ searchResult.warnings.join(", ") }}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<AppAlert v-if="searchIsDegraded" tone="warning">
|
||||||
|
<template #title>Name search is degraded</template>
|
||||||
|
<p>
|
||||||
|
This endpoint throttles aggressively to avoid blocks. Use Scholar URL/ID adds for dependable tracking.
|
||||||
|
</p>
|
||||||
|
</AppAlert>
|
||||||
|
|
||||||
|
<AsyncStateGate
|
||||||
|
:loading="false"
|
||||||
|
:show-empty="true"
|
||||||
|
:empty="emptySearchCandidates"
|
||||||
|
empty-title="No scholar matches returned"
|
||||||
|
empty-body="Try another query later or add directly by Scholar URL/ID."
|
||||||
|
>
|
||||||
|
<ul class="grid gap-3">
|
||||||
|
<li
|
||||||
|
v-for="candidate in searchResult.candidates"
|
||||||
|
:key="candidate.scholar_id"
|
||||||
|
class="rounded-xl border border-stroke-default bg-surface-card-muted/70 p-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<ScholarAvatar
|
||||||
|
size="sm"
|
||||||
|
:label="candidate.display_name"
|
||||||
|
:scholar-id="candidate.scholar_id"
|
||||||
|
:image-url="candidate.profile_image_url"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="min-w-0 flex-1 space-y-1">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<strong class="text-sm text-ink-primary">{{ candidate.display_name }}</strong>
|
||||||
|
<code class="text-xs text-secondary">{{ candidate.scholar_id }}</code>
|
||||||
|
<AppBadge v-if="trackedScholarIds.has(candidate.scholar_id)" tone="success">Tracked</AppBadge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="truncate text-xs text-secondary">{{ candidate.affiliation || "No affiliation provided" }}</p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-xs text-secondary">
|
||||||
|
<span v-if="candidate.email_domain">Email: {{ candidate.email_domain }}</span>
|
||||||
|
<span v-if="candidate.cited_by_count !== null">Cited by: {{ candidate.cited_by_count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid shrink-0 gap-2">
|
||||||
|
<AppButton
|
||||||
|
:disabled="trackedScholarIds.has(candidate.scholar_id) || addingCandidateScholarId === candidate.scholar_id"
|
||||||
|
@click="onAddCandidate(candidate)"
|
||||||
|
>
|
||||||
|
{{ addingCandidateScholarId === candidate.scholar_id ? "Adding..." : "Add" }}
|
||||||
|
</AppButton>
|
||||||
|
<a :href="candidate.profile_url" target="_blank" rel="noreferrer" class="link-inline text-xs text-center">
|
||||||
|
Open profile
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</AsyncStateGate>
|
||||||
|
</template>
|
||||||
|
<p v-else-if="!searchErrorMessage && !searchHasRun" class="text-sm text-secondary">
|
||||||
|
Name search remains disabled while login-gated responses are unresolved.
|
||||||
|
</p>
|
||||||
|
</AsyncStateGate>
|
||||||
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AppCard class="flex min-h-0 flex-col gap-4 xl:order-1 xl:h-full xl:overflow-hidden">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<h2 class="text-lg font-semibold text-ink-primary">Tracked Scholars</h2>
|
||||||
|
<AppHelpHint
|
||||||
|
text="Tracked scholars are active profile sources. Open Manage to control status, image overrides, and removal."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-secondary">Review tracked profiles, filter quickly, and open per-scholar settings when needed.</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-3 rounded-xl border border-stroke-default bg-surface-card-muted/70 px-3 py-2">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p class="text-xs font-medium uppercase tracking-wide text-secondary">Tracking status</p>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="inline-flex min-h-10 items-center rounded-lg border border-state-info-border bg-state-info-bg px-3 text-sm font-semibold text-state-info-text"
|
||||||
|
>
|
||||||
|
{{ trackedCountLabel }}
|
||||||
|
</span>
|
||||||
|
<AppButton variant="secondary" :disabled="loading || saving" @click="loadScholars">
|
||||||
|
{{ loading ? "Refreshing..." : "Refresh" }}
|
||||||
|
</AppButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2 sm:grid-cols-[minmax(0,1fr)_12rem]">
|
||||||
|
<label class="grid gap-1 text-xs font-medium uppercase tracking-wide text-secondary" for="tracked-scholar-filter">
|
||||||
|
<span>Filter tracked scholars</span>
|
||||||
|
<AppInput
|
||||||
|
id="tracked-scholar-filter"
|
||||||
|
v-model="trackedFilterQuery"
|
||||||
|
placeholder="Filter by name or scholar ID"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="grid gap-1 text-xs font-medium uppercase tracking-wide text-secondary" for="tracked-scholar-sort">
|
||||||
|
<span>Sort</span>
|
||||||
|
<AppSelect id="tracked-scholar-sort" v-model="trackedSort">
|
||||||
|
<option value="recent">Recently added</option>
|
||||||
|
<option value="name_asc">Name (A-Z)</option>
|
||||||
|
<option value="name_desc">Name (Z-A)</option>
|
||||||
|
<option value="enabled_first">Enabled first</option>
|
||||||
|
</AppSelect>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-secondary">This helper is paused while reliability hardening is completed.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<AppHelpHint text="Name search is not currently supported in production. It is on the roadmap and will return after reliability hardening.">
|
|
||||||
<template #trigger>
|
|
||||||
<AppBadge tone="warning">WIP</AppBadge>
|
|
||||||
</template>
|
|
||||||
</AppHelpHint>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="pointer-events-none flex flex-wrap items-center gap-2 opacity-60">
|
<div class="min-h-0 flex-1 xl:overflow-hidden">
|
||||||
<div class="min-w-0 flex-1">
|
<AsyncStateGate
|
||||||
<AppInput
|
:loading="loading"
|
||||||
id="scholar-search-name"
|
:loading-lines="6"
|
||||||
v-model="searchQuery"
|
:empty="!hasTrackedScholars"
|
||||||
placeholder="e.g. Geoffrey Hinton"
|
:show-empty="!errorMessage"
|
||||||
:disabled="nameSearchWip"
|
empty-title="No scholars tracked"
|
||||||
|
empty-body="Add a Scholar ID or URL to start ingestion tracking."
|
||||||
|
>
|
||||||
|
<AppEmptyState
|
||||||
|
v-if="!hasVisibleScholars"
|
||||||
|
title="No scholars match this filter"
|
||||||
|
body="Clear or adjust the filter to see tracked scholars."
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<AppButton type="button" :disabled="nameSearchWip">
|
|
||||||
Search
|
|
||||||
</AppButton>
|
|
||||||
</form>
|
|
||||||
<p class="text-xs text-secondary">
|
|
||||||
Direct Scholar ID/URL adds above remain the dependable path while this feature is in progress.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<RequestStateAlerts
|
<div v-else class="space-y-3 xl:flex xl:h-full xl:min-h-0 xl:flex-col xl:space-y-0">
|
||||||
:error-message="searchErrorMessage"
|
<ul class="grid gap-3 lg:hidden">
|
||||||
:error-request-id="searchErrorRequestId"
|
|
||||||
error-title="Search request failed"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AsyncStateGate :loading="searchingByName" :loading-lines="4" :show-empty="false">
|
|
||||||
<template v-if="searchResult">
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<p class="text-sm text-secondary">
|
|
||||||
{{ searchResult.candidates.length }} candidate{{ searchResult.candidates.length === 1 ? "" : "s" }}
|
|
||||||
for <strong class="text-ink-primary">{{ searchResult.query }}</strong>
|
|
||||||
</p>
|
|
||||||
<AppBadge :tone="searchStateTone">{{ searchResult.state }}</AppBadge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="searchResult.state !== 'ok' || searchResult.warnings.length > 0" class="text-xs text-secondary">
|
|
||||||
<span>State reason: <code>{{ searchResult.state_reason }}</code></span>
|
|
||||||
<span v-if="searchResult.action_hint">. {{ searchResult.action_hint }}</span>
|
|
||||||
<span v-if="searchResult.warnings.length > 0">. Warnings: {{ searchResult.warnings.join(", ") }}</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<AppAlert v-if="searchIsDegraded" tone="warning">
|
|
||||||
<template #title>Name search is degraded</template>
|
|
||||||
<p>
|
|
||||||
This endpoint throttles aggressively to avoid blocks. Use Scholar URL/ID adds for dependable tracking.
|
|
||||||
</p>
|
|
||||||
</AppAlert>
|
|
||||||
|
|
||||||
<AsyncStateGate
|
|
||||||
:loading="false"
|
|
||||||
:show-empty="true"
|
|
||||||
:empty="emptySearchCandidates"
|
|
||||||
empty-title="No scholar matches returned"
|
|
||||||
empty-body="Try another query later or add directly by Scholar URL/ID."
|
|
||||||
>
|
|
||||||
<ul class="grid gap-3">
|
|
||||||
<li
|
<li
|
||||||
v-for="candidate in searchResult.candidates"
|
v-for="item in visibleScholars"
|
||||||
:key="candidate.scholar_id"
|
:key="item.id"
|
||||||
class="rounded-xl border border-stroke-default bg-surface-card-muted/70 p-3"
|
class="rounded-xl border border-stroke-default bg-surface-card-muted/70 p-3"
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<ScholarAvatar
|
|
||||||
size="sm"
|
|
||||||
:label="candidate.display_name"
|
|
||||||
:scholar-id="candidate.scholar_id"
|
|
||||||
:image-url="candidate.profile_image_url"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="min-w-0 flex-1 space-y-1">
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<strong class="text-sm text-ink-primary">{{ candidate.display_name }}</strong>
|
|
||||||
<code class="text-xs text-secondary">{{ candidate.scholar_id }}</code>
|
|
||||||
<AppBadge v-if="trackedScholarIds.has(candidate.scholar_id)" tone="success">Tracked</AppBadge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="truncate text-xs text-secondary">{{ candidate.affiliation || "No affiliation provided" }}</p>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2 text-xs text-secondary">
|
|
||||||
<span v-if="candidate.email_domain">Email: {{ candidate.email_domain }}</span>
|
|
||||||
<span v-if="candidate.cited_by_count !== null">Cited by: {{ candidate.cited_by_count }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid shrink-0 gap-2">
|
|
||||||
<AppButton
|
|
||||||
:disabled="trackedScholarIds.has(candidate.scholar_id) || addingCandidateScholarId === candidate.scholar_id"
|
|
||||||
@click="onAddCandidate(candidate)"
|
|
||||||
>
|
|
||||||
{{ addingCandidateScholarId === candidate.scholar_id ? "Adding..." : "Add" }}
|
|
||||||
</AppButton>
|
|
||||||
<a :href="candidate.profile_url" target="_blank" rel="noreferrer" class="link-inline text-xs text-center">
|
|
||||||
Open profile
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</AsyncStateGate>
|
|
||||||
</template>
|
|
||||||
<p v-else-if="!searchErrorMessage && !searchHasRun" class="text-sm text-secondary">
|
|
||||||
Run a name search to get candidates with one-click add actions.
|
|
||||||
</p>
|
|
||||||
</AsyncStateGate>
|
|
||||||
</AppCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AppCard class="space-y-4">
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<h2 class="text-lg font-semibold text-ink-primary">Tracked Scholars</h2>
|
|
||||||
<AppHelpHint
|
|
||||||
text="Tracked scholars are active profile sources. Open Manage to control status, image overrides, and removal."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-secondary">Review tracked profiles and open per-scholar settings when needed.</p>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-stroke-default bg-surface-card-muted/70 px-3 py-2"
|
|
||||||
>
|
|
||||||
<p class="text-xs font-medium uppercase tracking-wide text-secondary">Tracking status</p>
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<span
|
|
||||||
class="inline-flex min-h-10 items-center rounded-lg border border-state-info-border bg-state-info-bg px-3 text-sm font-semibold text-state-info-text"
|
|
||||||
>
|
|
||||||
{{ scholars.length }} tracked
|
|
||||||
</span>
|
|
||||||
<AppButton variant="secondary" :disabled="loading || saving" @click="loadScholars">
|
|
||||||
{{ loading ? "Refreshing..." : "Refresh" }}
|
|
||||||
</AppButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AsyncStateGate
|
|
||||||
:loading="loading"
|
|
||||||
:loading-lines="6"
|
|
||||||
:empty="scholars.length === 0"
|
|
||||||
:show-empty="!errorMessage"
|
|
||||||
empty-title="No scholars tracked"
|
|
||||||
empty-body="Add a Scholar ID or URL to start ingestion tracking."
|
|
||||||
>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<ul class="grid gap-3 lg:hidden">
|
|
||||||
<li
|
|
||||||
v-for="item in scholars"
|
|
||||||
:key="item.id"
|
|
||||||
class="rounded-xl border border-stroke-default bg-surface-card-muted/70 p-3"
|
|
||||||
>
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<ScholarAvatar :label="item.display_name" :scholar-id="item.scholar_id" :image-url="item.profile_image_url" />
|
|
||||||
|
|
||||||
<div class="min-w-0 flex-1 space-y-1">
|
|
||||||
<p class="truncate text-sm font-semibold text-ink-primary">{{ scholarLabel(item) }}</p>
|
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
|
||||||
<RouterLink :to="scholarPublicationsRoute(item)" class="link-inline text-xs">
|
|
||||||
Publications
|
|
||||||
</RouterLink>
|
|
||||||
<a :href="scholarProfileUrl(item.scholar_id)" target="_blank" rel="noreferrer" class="link-inline text-xs">
|
|
||||||
Open profile
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AppButton variant="secondary" :disabled="saving" @click="openScholarSettings(item)">Manage</AppButton>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<AppTable class="hidden lg:block" label="Tracked scholars table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">Scholar</th>
|
|
||||||
<th scope="col" class="w-[11rem]">Manage</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="item in scholars" :key="item.id">
|
|
||||||
<td>
|
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<ScholarAvatar :label="item.display_name" :scholar-id="item.scholar_id" :image-url="item.profile_image_url" />
|
<ScholarAvatar :label="item.display_name" :scholar-id="item.scholar_id" :image-url="item.profile_image_url" />
|
||||||
|
|
||||||
<div class="grid min-w-0 gap-1">
|
<div class="min-w-0 flex-1 space-y-1">
|
||||||
<strong class="truncate text-ink-primary">{{ scholarLabel(item) }}</strong>
|
<p class="truncate text-sm font-semibold text-ink-primary">{{ scholarLabel(item) }}</p>
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<RouterLink :to="scholarPublicationsRoute(item)" class="link-inline text-xs">
|
<RouterLink :to="scholarPublicationsRoute(item)" class="link-inline text-xs">
|
||||||
Publications
|
Publications
|
||||||
|
|
@ -718,18 +770,52 @@ onMounted(() => {
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AppButton variant="secondary" :disabled="saving" @click="openScholarSettings(item)">Manage</AppButton>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</li>
|
||||||
<td>
|
</ul>
|
||||||
<AppButton variant="secondary" :disabled="saving" @click="openScholarSettings(item)">Manage</AppButton>
|
|
||||||
</td>
|
<div class="hidden min-h-0 flex-1 lg:block">
|
||||||
</tr>
|
<AppTable class="max-h-full overflow-y-auto" label="Tracked scholars table">
|
||||||
</tbody>
|
<thead>
|
||||||
</AppTable>
|
<tr>
|
||||||
|
<th scope="col">Scholar</th>
|
||||||
|
<th scope="col" class="w-[11rem]">Manage</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in visibleScholars" :key="item.id">
|
||||||
|
<td>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<ScholarAvatar :label="item.display_name" :scholar-id="item.scholar_id" :image-url="item.profile_image_url" />
|
||||||
|
|
||||||
|
<div class="grid min-w-0 gap-1">
|
||||||
|
<strong class="truncate text-ink-primary">{{ scholarLabel(item) }}</strong>
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<RouterLink :to="scholarPublicationsRoute(item)" class="link-inline text-xs">
|
||||||
|
Publications
|
||||||
|
</RouterLink>
|
||||||
|
<a :href="scholarProfileUrl(item.scholar_id)" target="_blank" rel="noreferrer" class="link-inline text-xs">
|
||||||
|
Open profile
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<AppButton variant="secondary" :disabled="saving" @click="openScholarSettings(item)">Manage</AppButton>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</AppTable>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AsyncStateGate>
|
||||||
</div>
|
</div>
|
||||||
</AsyncStateGate>
|
</AppCard>
|
||||||
</AppCard>
|
</section>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
<AppModal :open="activeScholarSettings !== null" title="Scholar settings" @close="closeScholarSettings">
|
<AppModal :open="activeScholarSettings !== null" title="Scholar settings" @close="closeScholarSettings">
|
||||||
<div v-if="activeScholarSettings" class="grid gap-4">
|
<div v-if="activeScholarSettings" class="grid gap-4">
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { defineStore } from "pinia";
|
||||||
import { ApiRequestError } from "@/lib/api/errors";
|
import { ApiRequestError } from "@/lib/api/errors";
|
||||||
import { fetchCsrfBootstrap } from "@/lib/api/csrf";
|
import { fetchCsrfBootstrap } from "@/lib/api/csrf";
|
||||||
import { fetchMe, loginSession, logoutSession, type SessionUser } from "@/lib/auth/session";
|
import { fetchMe, loginSession, logoutSession, type SessionUser } from "@/lib/auth/session";
|
||||||
|
import { useRunStatusStore } from "@/stores/run_status";
|
||||||
import { useUiStore } from "@/stores/ui";
|
import { useUiStore } from "@/stores/ui";
|
||||||
import { useUserSettingsStore } from "@/stores/user_settings";
|
import { useUserSettingsStore } from "@/stores/user_settings";
|
||||||
|
|
||||||
|
|
@ -55,7 +56,9 @@ export const useAuthStore = defineStore("auth", {
|
||||||
this.user = response.data.user;
|
this.user = response.data.user;
|
||||||
this.csrfToken = response.data.csrf_token;
|
this.csrfToken = response.data.csrf_token;
|
||||||
const userSettings = useUserSettingsStore();
|
const userSettings = useUserSettingsStore();
|
||||||
|
const runStatus = useRunStatusStore();
|
||||||
await userSettings.bootstrap();
|
await userSettings.bootstrap();
|
||||||
|
await runStatus.bootstrap();
|
||||||
},
|
},
|
||||||
async logout(): Promise<void> {
|
async logout(): Promise<void> {
|
||||||
await logoutSession();
|
await logoutSession();
|
||||||
|
|
@ -63,7 +66,9 @@ export const useAuthStore = defineStore("auth", {
|
||||||
this.user = null;
|
this.user = null;
|
||||||
this.csrfToken = null;
|
this.csrfToken = null;
|
||||||
const userSettings = useUserSettingsStore();
|
const userSettings = useUserSettingsStore();
|
||||||
|
const runStatus = useRunStatusStore();
|
||||||
userSettings.reset();
|
userSettings.reset();
|
||||||
|
runStatus.reset();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const csrf = await fetchCsrfBootstrap();
|
const csrf = await fetchCsrfBootstrap();
|
||||||
|
|
|
||||||
176
frontend/src/stores/run_status.test.ts
Normal file
176
frontend/src/stores/run_status.test.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createPinia, setActivePinia } from "pinia";
|
||||||
|
|
||||||
|
import { ApiRequestError } from "@/lib/api/errors";
|
||||||
|
|
||||||
|
vi.mock("@/features/runs", () => ({
|
||||||
|
listRuns: vi.fn(),
|
||||||
|
triggerManualRun: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { listRuns, triggerManualRun } from "@/features/runs";
|
||||||
|
import {
|
||||||
|
RUN_STATUS_POLL_INTERVAL_MS,
|
||||||
|
RUN_STATUS_STARTING_PHASE_MS,
|
||||||
|
useRunStatusStore,
|
||||||
|
} from "@/stores/run_status";
|
||||||
|
|
||||||
|
function buildRun(overrides: Partial<{
|
||||||
|
id: number;
|
||||||
|
trigger_type: string;
|
||||||
|
status: string;
|
||||||
|
start_dt: string;
|
||||||
|
end_dt: string | null;
|
||||||
|
scholar_count: number;
|
||||||
|
new_publication_count: number;
|
||||||
|
failed_count: number;
|
||||||
|
partial_count: number;
|
||||||
|
}> = {}) {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
trigger_type: "manual",
|
||||||
|
status: "success",
|
||||||
|
start_dt: "2026-02-19T12:00:00Z",
|
||||||
|
end_dt: "2026-02-19T12:01:00Z",
|
||||||
|
scholar_count: 3,
|
||||||
|
new_publication_count: 2,
|
||||||
|
failed_count: 0,
|
||||||
|
partial_count: 0,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("run status store", () => {
|
||||||
|
const mockedListRuns = vi.mocked(listRuns);
|
||||||
|
const mockedTriggerManualRun = vi.mocked(triggerManualRun);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
mockedListRuns.mockReset();
|
||||||
|
mockedTriggerManualRun.mockReset();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
useRunStatusStore().reset();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bootstraps from latest run and exposes idle start state", async () => {
|
||||||
|
mockedListRuns.mockResolvedValueOnce([buildRun({ id: 11, status: "success" })]);
|
||||||
|
|
||||||
|
const store = useRunStatusStore();
|
||||||
|
await store.bootstrap();
|
||||||
|
|
||||||
|
expect(mockedListRuns).toHaveBeenCalledWith({ limit: 1 });
|
||||||
|
expect(store.latestRun?.id).toBe(11);
|
||||||
|
expect(store.canStart).toBe(true);
|
||||||
|
expect(store.isRunActive).toBe(false);
|
||||||
|
expect(store.isPolling).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts manual checks and marks active state", async () => {
|
||||||
|
mockedTriggerManualRun.mockResolvedValueOnce({
|
||||||
|
run_id: 25,
|
||||||
|
status: "running",
|
||||||
|
scholar_count: 0,
|
||||||
|
succeeded_count: 0,
|
||||||
|
failed_count: 0,
|
||||||
|
partial_count: 0,
|
||||||
|
new_publication_count: 0,
|
||||||
|
reused_existing_run: false,
|
||||||
|
idempotency_key: "abc",
|
||||||
|
});
|
||||||
|
mockedListRuns.mockResolvedValueOnce([buildRun({ id: 25, status: "running", end_dt: null })]);
|
||||||
|
|
||||||
|
const store = useRunStatusStore();
|
||||||
|
const result = await store.startManualCheck();
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
kind: "started",
|
||||||
|
runId: 25,
|
||||||
|
reusedExistingRun: false,
|
||||||
|
});
|
||||||
|
expect(store.latestRun?.id).toBe(25);
|
||||||
|
expect(store.isRunActive).toBe(true);
|
||||||
|
expect(store.isPolling).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes run_in_progress responses into already_running state", async () => {
|
||||||
|
mockedTriggerManualRun.mockRejectedValueOnce(
|
||||||
|
new ApiRequestError({
|
||||||
|
status: 409,
|
||||||
|
code: "run_in_progress",
|
||||||
|
message: "A run is already in progress for this account.",
|
||||||
|
details: { run_id: 42 },
|
||||||
|
requestId: "req_123",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mockedListRuns.mockResolvedValueOnce([buildRun({ id: 42, status: "running", end_dt: null })]);
|
||||||
|
|
||||||
|
const store = useRunStatusStore();
|
||||||
|
const result = await store.startManualCheck();
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
kind: "already_running",
|
||||||
|
runId: 42,
|
||||||
|
requestId: "req_123",
|
||||||
|
});
|
||||||
|
expect(store.latestRun?.id).toBe(42);
|
||||||
|
expect(store.isRunActive).toBe(true);
|
||||||
|
expect(store.lastErrorMessage).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("polls while a run is active and stops when it completes", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
mockedListRuns
|
||||||
|
.mockResolvedValueOnce([buildRun({ id: 90, status: "running", end_dt: null })])
|
||||||
|
.mockResolvedValueOnce([buildRun({ id: 90, status: "success" })]);
|
||||||
|
|
||||||
|
const store = useRunStatusStore();
|
||||||
|
await store.syncLatest();
|
||||||
|
expect(store.isPolling).toBe(true);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(RUN_STATUS_POLL_INTERVAL_MS + 10);
|
||||||
|
|
||||||
|
expect(mockedListRuns).toHaveBeenCalledTimes(2);
|
||||||
|
expect(store.latestRun?.status).toBe("success");
|
||||||
|
expect(store.isPolling).toBe(false);
|
||||||
|
expect(store.isRunActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches from starting to in-progress when trigger request remains open", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
mockedTriggerManualRun.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve({
|
||||||
|
run_id: 77,
|
||||||
|
status: "running",
|
||||||
|
scholar_count: 0,
|
||||||
|
succeeded_count: 0,
|
||||||
|
failed_count: 0,
|
||||||
|
partial_count: 0,
|
||||||
|
new_publication_count: 0,
|
||||||
|
reused_existing_run: false,
|
||||||
|
idempotency_key: "x",
|
||||||
|
});
|
||||||
|
}, RUN_STATUS_STARTING_PHASE_MS + 500);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mockedListRuns.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
const store = useRunStatusStore();
|
||||||
|
const startPromise = store.startManualCheck();
|
||||||
|
|
||||||
|
expect(store.isSubmitting).toBe(true);
|
||||||
|
expect(store.isLikelyRunning).toBe(false);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(RUN_STATUS_STARTING_PHASE_MS + 20);
|
||||||
|
expect(store.isLikelyRunning).toBe(true);
|
||||||
|
await vi.advanceTimersByTimeAsync(520);
|
||||||
|
await startPromise;
|
||||||
|
expect(store.isRunActive).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
264
frontend/src/stores/run_status.ts
Normal file
264
frontend/src/stores/run_status.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
|
||||||
|
import { listRuns, triggerManualRun, type RunListItem } from "@/features/runs";
|
||||||
|
import { ApiRequestError } from "@/lib/api/errors";
|
||||||
|
|
||||||
|
export const RUN_STATUS_POLL_INTERVAL_MS = 5000;
|
||||||
|
export const RUN_STATUS_STARTING_PHASE_MS = 1500;
|
||||||
|
|
||||||
|
export type StartManualCheckResult =
|
||||||
|
| {
|
||||||
|
kind: "started";
|
||||||
|
runId: number;
|
||||||
|
reusedExistingRun: boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "already_running";
|
||||||
|
runId: number | null;
|
||||||
|
requestId: string | null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "error";
|
||||||
|
message: string;
|
||||||
|
requestId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let syncPromise: Promise<void> | null = null;
|
||||||
|
let submittingPhaseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function parseRunId(value: unknown): number | null {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "string" && value.trim().length > 0) {
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractRunIdFromDetails(details: unknown): number | null {
|
||||||
|
if (!details || typeof details !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runIdCandidate = (details as Record<string, unknown>).run_id;
|
||||||
|
return parseRunId(runIdCandidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlaceholderRunningRun(runId: number): RunListItem {
|
||||||
|
return {
|
||||||
|
id: runId,
|
||||||
|
trigger_type: "manual",
|
||||||
|
status: "running",
|
||||||
|
start_dt: new Date().toISOString(),
|
||||||
|
end_dt: null,
|
||||||
|
scholar_count: 0,
|
||||||
|
new_publication_count: 0,
|
||||||
|
failed_count: 0,
|
||||||
|
partial_count: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useRunStatusStore = defineStore("runStatus", {
|
||||||
|
state: () => ({
|
||||||
|
latestRun: null as RunListItem | null,
|
||||||
|
isSubmitting: false,
|
||||||
|
assumeRunningFromSubmission: false,
|
||||||
|
isPolling: false,
|
||||||
|
lastErrorMessage: null as string | null,
|
||||||
|
lastErrorRequestId: null as string | null,
|
||||||
|
lastSyncAt: null as number | null,
|
||||||
|
}),
|
||||||
|
getters: {
|
||||||
|
isRunActive(state): boolean {
|
||||||
|
return state.isSubmitting || state.latestRun?.status === "running";
|
||||||
|
},
|
||||||
|
isLikelyRunning(state): boolean {
|
||||||
|
return state.latestRun?.status === "running" || state.assumeRunningFromSubmission;
|
||||||
|
},
|
||||||
|
canStart(): boolean {
|
||||||
|
return !this.isRunActive;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
clearSubmittingPhaseTimer(): void {
|
||||||
|
if (submittingPhaseTimer !== null) {
|
||||||
|
clearTimeout(submittingPhaseTimer);
|
||||||
|
submittingPhaseTimer = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beginSubmittingPhaseTracking(): void {
|
||||||
|
this.assumeRunningFromSubmission = false;
|
||||||
|
this.clearSubmittingPhaseTimer();
|
||||||
|
submittingPhaseTimer = setTimeout(() => {
|
||||||
|
if (!this.isSubmitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.assumeRunningFromSubmission = true;
|
||||||
|
}, RUN_STATUS_STARTING_PHASE_MS);
|
||||||
|
},
|
||||||
|
setLatestRun(run: RunListItem | null): void {
|
||||||
|
this.latestRun = run;
|
||||||
|
this.lastSyncAt = Date.now();
|
||||||
|
this.lastErrorMessage = null;
|
||||||
|
this.lastErrorRequestId = null;
|
||||||
|
if (run?.status === "running") {
|
||||||
|
this.assumeRunningFromSubmission = true;
|
||||||
|
} else if (!this.isSubmitting) {
|
||||||
|
this.assumeRunningFromSubmission = false;
|
||||||
|
}
|
||||||
|
this.updatePolling();
|
||||||
|
},
|
||||||
|
startPolling(): void {
|
||||||
|
if (pollTimer !== null) {
|
||||||
|
this.isPolling = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isPolling = true;
|
||||||
|
pollTimer = setInterval(() => {
|
||||||
|
void this.syncLatest();
|
||||||
|
}, RUN_STATUS_POLL_INTERVAL_MS);
|
||||||
|
},
|
||||||
|
stopPolling(): void {
|
||||||
|
if (pollTimer !== null) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
this.isPolling = false;
|
||||||
|
},
|
||||||
|
updatePolling(): void {
|
||||||
|
if (this.isRunActive) {
|
||||||
|
this.startPolling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.stopPolling();
|
||||||
|
},
|
||||||
|
async syncLatest(): Promise<void> {
|
||||||
|
if (syncPromise) {
|
||||||
|
await syncPromise;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const runs = await listRuns({ limit: 1 });
|
||||||
|
const latest = runs[0] ?? null;
|
||||||
|
this.latestRun = latest;
|
||||||
|
this.lastSyncAt = Date.now();
|
||||||
|
this.lastErrorMessage = null;
|
||||||
|
this.lastErrorRequestId = null;
|
||||||
|
if (latest?.status === "running") {
|
||||||
|
this.assumeRunningFromSubmission = true;
|
||||||
|
} else if (!this.isSubmitting) {
|
||||||
|
this.assumeRunningFromSubmission = false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiRequestError) {
|
||||||
|
this.lastErrorMessage = error.message;
|
||||||
|
this.lastErrorRequestId = error.requestId;
|
||||||
|
} else {
|
||||||
|
this.lastErrorMessage = "Unable to refresh check status.";
|
||||||
|
this.lastErrorRequestId = null;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
syncPromise = null;
|
||||||
|
this.updatePolling();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
await syncPromise;
|
||||||
|
},
|
||||||
|
async bootstrap(): Promise<void> {
|
||||||
|
await this.syncLatest();
|
||||||
|
},
|
||||||
|
async startManualCheck(): Promise<StartManualCheckResult> {
|
||||||
|
if (this.isRunActive) {
|
||||||
|
return {
|
||||||
|
kind: "already_running",
|
||||||
|
runId: this.latestRun?.id ?? null,
|
||||||
|
requestId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isSubmitting = true;
|
||||||
|
this.lastErrorMessage = null;
|
||||||
|
this.lastErrorRequestId = null;
|
||||||
|
this.beginSubmittingPhaseTracking();
|
||||||
|
this.updatePolling();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await triggerManualRun();
|
||||||
|
await this.syncLatest();
|
||||||
|
|
||||||
|
if (!this.latestRun || this.latestRun.id !== result.run_id) {
|
||||||
|
this.latestRun = buildPlaceholderRunningRun(result.run_id);
|
||||||
|
this.lastSyncAt = Date.now();
|
||||||
|
this.assumeRunningFromSubmission = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "started",
|
||||||
|
runId: result.run_id,
|
||||||
|
reusedExistingRun: result.reused_existing_run,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiRequestError && error.code === "run_in_progress") {
|
||||||
|
const runId = extractRunIdFromDetails(error.details);
|
||||||
|
if (runId !== null) {
|
||||||
|
this.latestRun = buildPlaceholderRunningRun(runId);
|
||||||
|
this.lastSyncAt = Date.now();
|
||||||
|
this.assumeRunningFromSubmission = true;
|
||||||
|
}
|
||||||
|
await this.syncLatest();
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "already_running",
|
||||||
|
runId: this.latestRun?.id ?? runId,
|
||||||
|
requestId: error.requestId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof ApiRequestError) {
|
||||||
|
this.lastErrorMessage = error.message;
|
||||||
|
this.lastErrorRequestId = error.requestId;
|
||||||
|
return {
|
||||||
|
kind: "error",
|
||||||
|
message: error.message,
|
||||||
|
requestId: error.requestId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackMessage = "Unable to start an update check.";
|
||||||
|
this.lastErrorMessage = fallbackMessage;
|
||||||
|
this.lastErrorRequestId = null;
|
||||||
|
return {
|
||||||
|
kind: "error",
|
||||||
|
message: fallbackMessage,
|
||||||
|
requestId: null,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
this.isSubmitting = false;
|
||||||
|
this.clearSubmittingPhaseTimer();
|
||||||
|
if (this.latestRun?.status !== "running") {
|
||||||
|
this.assumeRunningFromSubmission = false;
|
||||||
|
}
|
||||||
|
this.updatePolling();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
reset(): void {
|
||||||
|
this.stopPolling();
|
||||||
|
this.clearSubmittingPhaseTimer();
|
||||||
|
this.latestRun = null;
|
||||||
|
this.isSubmitting = false;
|
||||||
|
this.assumeRunningFromSubmission = false;
|
||||||
|
this.lastErrorMessage = null;
|
||||||
|
this.lastErrorRequestId = null;
|
||||||
|
this.lastSyncAt = null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -239,3 +239,131 @@ async def test_read_state_is_isolated_across_users(db_session: AsyncSession) ->
|
||||||
rows = result.all()
|
rows = result.all()
|
||||||
assert rows == [(user_a, False), (user_b, True)]
|
assert rows == [(user_a, False), (user_b, True)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.db
|
||||||
|
@pytest.mark.schema
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_publication_records_are_shared_across_accounts(db_session: AsyncSession) -> None:
|
||||||
|
user_a = await _insert_user(db_session, "shared-a@example.com")
|
||||||
|
user_b = await _insert_user(db_session, "shared-b@example.com")
|
||||||
|
|
||||||
|
scholar_a = await db_session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO scholar_profiles (user_id, scholar_id, display_name)
|
||||||
|
VALUES (:user_id, :scholar_id, :display_name)
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"user_id": user_a, "scholar_id": "sharedAAA1111", "display_name": "Shared A"},
|
||||||
|
)
|
||||||
|
scholar_a_id = int(scholar_a.scalar_one())
|
||||||
|
|
||||||
|
scholar_b = await db_session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO scholar_profiles (user_id, scholar_id, display_name)
|
||||||
|
VALUES (:user_id, :scholar_id, :display_name)
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"user_id": user_b, "scholar_id": "sharedBBB2222", "display_name": "Shared B"},
|
||||||
|
)
|
||||||
|
scholar_b_id = int(scholar_b.scalar_one())
|
||||||
|
|
||||||
|
publication = await db_session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, year)
|
||||||
|
VALUES (:fingerprint_sha256, :title_raw, :title_normalized, :year)
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"fingerprint_sha256": "a" * 64,
|
||||||
|
"title_raw": "Shared Record",
|
||||||
|
"title_normalized": "shared record",
|
||||||
|
"year": 2025,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
publication_id = int(publication.scalar_one())
|
||||||
|
|
||||||
|
run_a = await db_session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO crawl_runs (user_id, trigger_type, status)
|
||||||
|
VALUES (:user_id, :trigger_type, :status)
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"user_id": user_a, "trigger_type": "manual", "status": "success"},
|
||||||
|
)
|
||||||
|
run_a_id = int(run_a.scalar_one())
|
||||||
|
|
||||||
|
run_b = await db_session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO crawl_runs (user_id, trigger_type, status)
|
||||||
|
VALUES (:user_id, :trigger_type, :status)
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"user_id": user_b, "trigger_type": "manual", "status": "success"},
|
||||||
|
)
|
||||||
|
run_b_id = int(run_b.scalar_one())
|
||||||
|
|
||||||
|
await db_session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read, first_seen_run_id)
|
||||||
|
VALUES (:scholar_profile_id, :publication_id, :is_read, :first_seen_run_id)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"scholar_profile_id": scholar_a_id,
|
||||||
|
"publication_id": publication_id,
|
||||||
|
"is_read": False,
|
||||||
|
"first_seen_run_id": run_a_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await db_session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read, first_seen_run_id)
|
||||||
|
VALUES (:scholar_profile_id, :publication_id, :is_read, :first_seen_run_id)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"scholar_profile_id": scholar_b_id,
|
||||||
|
"publication_id": publication_id,
|
||||||
|
"is_read": False,
|
||||||
|
"first_seen_run_id": run_b_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
publication_row_count = await db_session.execute(
|
||||||
|
text("SELECT COUNT(*) FROM publications WHERE id = :publication_id"),
|
||||||
|
{"publication_id": publication_id},
|
||||||
|
)
|
||||||
|
assert int(publication_row_count.scalar_one()) == 1
|
||||||
|
|
||||||
|
link_count = await db_session.execute(
|
||||||
|
text("SELECT COUNT(*) FROM scholar_publications WHERE publication_id = :publication_id"),
|
||||||
|
{"publication_id": publication_id},
|
||||||
|
)
|
||||||
|
assert int(link_count.scalar_one()) == 2
|
||||||
|
|
||||||
|
owner_count = await db_session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(DISTINCT sp.user_id)
|
||||||
|
FROM scholar_publications spp
|
||||||
|
JOIN scholar_profiles sp ON sp.id = spp.scholar_profile_id
|
||||||
|
WHERE spp.publication_id = :publication_id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"publication_id": publication_id},
|
||||||
|
)
|
||||||
|
assert int(owner_count.scalar_one()) == 2
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue