temp commit
This commit is contained in:
parent
8760f27b51
commit
0e9e49df16
193 changed files with 23228 additions and 935 deletions
|
|
@ -39,6 +39,10 @@ const navRunStateStatus = computed(() => {
|
|||
if (runStatus.isSubmitting && !runStatus.isLikelyRunning) {
|
||||
return "starting";
|
||||
}
|
||||
const s = runStatus.latestRun?.status;
|
||||
if (s === "resolving") {
|
||||
return "resolving";
|
||||
}
|
||||
if (runStatus.isLikelyRunning) {
|
||||
return "running";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,9 +20,15 @@ const toneClass = computed(() => {
|
|||
if (props.status === "running") {
|
||||
return "border-state-info-border bg-state-info-bg text-state-info-text";
|
||||
}
|
||||
if (props.status === "resolving") {
|
||||
return "border-state-info-border bg-state-info-bg text-state-info-text";
|
||||
}
|
||||
if (props.status === "starting") {
|
||||
return "border-state-info-border bg-state-info-bg text-state-info-text";
|
||||
}
|
||||
if (props.status === "canceled") {
|
||||
return "border-state-warning-border bg-state-warning-bg text-state-warning-text";
|
||||
}
|
||||
return "border-stroke-default bg-surface-card-muted text-ink-secondary";
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ export interface AdminDbRepairJob {
|
|||
export interface AdminPdfQueueItem {
|
||||
publication_id: number;
|
||||
title: string;
|
||||
doi: string | null;
|
||||
display_identifier: DisplayIdentifier | null;
|
||||
pdf_url: string | null;
|
||||
status: string;
|
||||
|
|
@ -160,3 +159,19 @@ export async function requeueAllAdminPdfLookups(limit = 1000): Promise<AdminPdfQ
|
|||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export interface DropAllPublicationsResult {
|
||||
deleted_count: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export async function dropAllPublications(confirmationText: string): Promise<DropAllPublicationsResult> {
|
||||
const response = await apiRequest<DropAllPublicationsResult>(
|
||||
"/admin/db/drop-all-publications",
|
||||
{
|
||||
method: "POST",
|
||||
body: { confirmation_text: confirmationText },
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ export interface PublicationItem {
|
|||
citation_count: number;
|
||||
venue_text: string | null;
|
||||
pub_url: string | null;
|
||||
doi: string | null;
|
||||
display_identifier: DisplayIdentifier | null;
|
||||
pdf_url: string | null;
|
||||
pdf_status: "untracked" | "queued" | "running" | "resolved" | "failed";
|
||||
|
|
@ -44,6 +43,7 @@ export interface PublicationsResult {
|
|||
total_count: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
snapshot: string;
|
||||
has_next: boolean;
|
||||
has_prev: boolean;
|
||||
publications: PublicationItem[];
|
||||
|
|
@ -53,8 +53,12 @@ export interface PublicationsQuery {
|
|||
mode?: PublicationMode;
|
||||
favoriteOnly?: boolean;
|
||||
scholarProfileId?: number;
|
||||
search?: string;
|
||||
sortBy?: string;
|
||||
sortDir?: "asc" | "desc";
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
snapshot?: string;
|
||||
}
|
||||
|
||||
export interface PublicationSelection {
|
||||
|
|
@ -74,12 +78,24 @@ export async function listPublications(query: PublicationsQuery = {}): Promise<P
|
|||
if (query.scholarProfileId) {
|
||||
params.set("scholar_profile_id", String(query.scholarProfileId));
|
||||
}
|
||||
if (query.search && query.search.trim().length > 0) {
|
||||
params.set("search", query.search.trim());
|
||||
}
|
||||
if (query.sortBy) {
|
||||
params.set("sort_by", query.sortBy);
|
||||
}
|
||||
if (query.sortDir) {
|
||||
params.set("sort_dir", query.sortDir);
|
||||
}
|
||||
const parsedPage = Number.isFinite(query.page) ? Math.max(1, Math.trunc(Number(query.page))) : 1;
|
||||
const parsedPageSize = Number.isFinite(query.pageSize)
|
||||
? Math.max(1, Math.min(500, Math.trunc(Number(query.pageSize))))
|
||||
: 100;
|
||||
params.set("page", String(parsedPage));
|
||||
params.set("page_size", String(parsedPageSize));
|
||||
if (query.snapshot && query.snapshot.trim().length > 0) {
|
||||
params.set("snapshot", query.snapshot.trim());
|
||||
}
|
||||
|
||||
const suffix = params.toString();
|
||||
const response = await apiRequest<PublicationsResult>(
|
||||
|
|
|
|||
|
|
@ -145,6 +145,13 @@ export async function triggerManualRun(): Promise<{
|
|||
return response.data;
|
||||
}
|
||||
|
||||
export async function cancelRun(runId: number): Promise<RunDetail> {
|
||||
const response = await apiRequest<RunDetail>(`/runs/${runId}/cancel`, {
|
||||
method: "POST",
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function listQueueItems(limit = 200): Promise<QueueItem[]> {
|
||||
const response = await apiRequest<QueueListData>(`/runs/queue/items?limit=${limit}`, {
|
||||
method: "GET",
|
||||
|
|
|
|||
|
|
@ -54,7 +54,6 @@ export interface PublicationExportItem {
|
|||
author_text: string | null;
|
||||
venue_text: string | null;
|
||||
pub_url: string | null;
|
||||
doi: string | null;
|
||||
pdf_url: string | null;
|
||||
is_read: boolean;
|
||||
}
|
||||
|
|
@ -86,7 +85,7 @@ interface ScholarsListData {
|
|||
scholars: ScholarProfile[];
|
||||
}
|
||||
|
||||
interface ScholarSearchData extends ScholarSearchResult {}
|
||||
interface ScholarSearchData extends ScholarSearchResult { }
|
||||
|
||||
export async function listScholars(): Promise<ScholarProfile[]> {
|
||||
const response = await apiRequest<ScholarsListData>("/scholars", { method: "GET" });
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import AppRefreshButton from "@/components/ui/AppRefreshButton.vue";
|
|||
import AppSelect from "@/components/ui/AppSelect.vue";
|
||||
import AppTable from "@/components/ui/AppTable.vue";
|
||||
import {
|
||||
dropAllPublications,
|
||||
getAdminDbIntegrityReport,
|
||||
listAdminDbRepairJobs,
|
||||
listAdminPdfQueue,
|
||||
|
|
@ -42,6 +43,7 @@ const SECTION_PDF = "pdf";
|
|||
const SCOPE_SINGLE_USER = "single_user";
|
||||
const SCOPE_ALL_USERS = "all_users";
|
||||
const APPLY_ALL_USERS_CONFIRM_TEXT = "REPAIR ALL USERS";
|
||||
const DROP_PUBLICATIONS_CONFIRM_TEXT = "DROP ALL PUBLICATIONS";
|
||||
|
||||
type RepairScopeMode = typeof SCOPE_SINGLE_USER | typeof SCOPE_ALL_USERS;
|
||||
|
||||
|
|
@ -81,6 +83,13 @@ const repairDryRun = ref(true);
|
|||
const repairGcOrphans = ref(false);
|
||||
const repairConfirmationText = ref("");
|
||||
|
||||
const droppingPublications = ref(false);
|
||||
const dropConfirmationText = ref("");
|
||||
const dropResult = ref<{ deleted_count: number; message: string } | null>(null);
|
||||
const dropConfirmationValid = computed(
|
||||
() => dropConfirmationText.value.trim() === DROP_PUBLICATIONS_CONFIRM_TEXT,
|
||||
);
|
||||
|
||||
const refreshingPdfQueue = ref(false);
|
||||
const requeueingPublicationId = ref<number | null>(null);
|
||||
const requeueingAllPdfs = ref(false);
|
||||
|
|
@ -433,6 +442,22 @@ async function onRequeueAllPdfs(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
async function onDropAllPublications(): Promise<void> {
|
||||
droppingPublications.value = true;
|
||||
clearAlerts();
|
||||
dropResult.value = null;
|
||||
try {
|
||||
const result = await dropAllPublications(dropConfirmationText.value.trim());
|
||||
dropResult.value = result;
|
||||
successMessage.value = result.message;
|
||||
dropConfirmationText.value = "";
|
||||
} catch (error) {
|
||||
assignError(error, "Unable to drop publications.");
|
||||
} finally {
|
||||
droppingPublications.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
clearAlerts();
|
||||
|
|
@ -686,6 +711,45 @@ watch(
|
|||
</tbody>
|
||||
</AppTable>
|
||||
</AppCard>
|
||||
|
||||
<AppCard class="space-y-3 border-danger-300 dark:border-danger-700">
|
||||
<div class="flex items-center gap-1">
|
||||
<h2 class="text-lg font-semibold text-danger-600 dark:text-danger-400">Drop All Publications</h2>
|
||||
<AppHelpHint text="Permanently delete ALL publications, links, identifiers, and PDF jobs. Scholar baselines will be reset so the next run re-discovers everything." />
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-secondary">
|
||||
This action is <strong>irreversible</strong>. It deletes every publication record across all users.
|
||||
The next ingestion run will re-populate all data from scratch.
|
||||
</p>
|
||||
|
||||
<form class="grid gap-3" @submit.prevent="onDropAllPublications">
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
|
||||
<span>Type '{{ DROP_PUBLICATIONS_CONFIRM_TEXT }}' to confirm</span>
|
||||
<AppInput
|
||||
v-model="dropConfirmationText"
|
||||
:placeholder="DROP_PUBLICATIONS_CONFIRM_TEXT"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<AppButton
|
||||
type="submit"
|
||||
variant="danger"
|
||||
:disabled="droppingPublications || !dropConfirmationValid"
|
||||
>
|
||||
{{ droppingPublications ? "Dropping..." : "Drop all publications" }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="dropResult" class="rounded-lg border border-stroke-default bg-surface-card-muted p-3 text-xs">
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<AppBadge tone="danger">Deleted: {{ dropResult.deleted_count }}</AppBadge>
|
||||
</div>
|
||||
<p class="text-secondary">{{ dropResult.message }}</p>
|
||||
</div>
|
||||
</AppCard>
|
||||
</section>
|
||||
|
||||
<AppCard v-if="props.section === SECTION_PDF" class="space-y-3">
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ export interface UserSettings {
|
|||
nav_visible_pages: string[];
|
||||
policy: UserSettingsPolicy;
|
||||
safety_state: ScrapeSafetyState;
|
||||
openalex_api_key: string | null;
|
||||
crossref_api_token: string | null;
|
||||
crossref_api_mailto: string | null;
|
||||
}
|
||||
|
||||
export interface UserSettingsUpdate {
|
||||
|
|
@ -26,6 +29,9 @@ export interface UserSettingsUpdate {
|
|||
run_interval_minutes: number;
|
||||
request_delay_seconds: number;
|
||||
nav_visible_pages: string[];
|
||||
openalex_api_key: string | null;
|
||||
crossref_api_token: string | null;
|
||||
crossref_api_mailto: string | null;
|
||||
}
|
||||
|
||||
export interface ChangePasswordPayload {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ const startCheckLabel = computed(() => {
|
|||
const isStartCheckAnimating = computed(
|
||||
() => runStatus.isSubmitting || runStatus.isLikelyRunning,
|
||||
);
|
||||
const isCancelAnimating = ref(false);
|
||||
|
||||
const displayedLatestRun = computed(() => {
|
||||
const snapshotLatest = snapshot.value?.latestRun ?? null;
|
||||
|
|
@ -102,6 +103,25 @@ const recentRuns = computed(() => {
|
|||
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",
|
||||
|
|
@ -178,7 +198,6 @@ async function onTriggerRun(): Promise<void> {
|
|||
return;
|
||||
}
|
||||
|
||||
errorMessage.value = result.message;
|
||||
errorRequestId.value = result.requestId;
|
||||
} catch {
|
||||
errorMessage.value = "Unable to start an update check.";
|
||||
|
|
@ -186,6 +205,28 @@ async function onTriggerRun(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
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(() => {
|
||||
void loadSnapshot();
|
||||
void runStatus.syncLatest();
|
||||
|
|
@ -252,14 +293,14 @@ watch(
|
|||
</div>
|
||||
|
||||
<AppEmptyState
|
||||
v-if="snapshot.recentPublications.length === 0"
|
||||
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 snapshot.recentPublications.slice(0, 20)"
|
||||
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"
|
||||
>
|
||||
|
|
@ -328,6 +369,22 @@ watch(
|
|||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<AppButton
|
||||
v-if="auth.isAdmin && runStatus.isLikelyRunning"
|
||||
variant="danger"
|
||||
:disabled="!activeRunId || isCancelAnimating.value"
|
||||
@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
|
||||
v-if="auth.isAdmin"
|
||||
:disabled="isStartBlocked"
|
||||
|
|
|
|||
|
|
@ -30,11 +30,9 @@ import { useUserSettingsStore } from "@/stores/user_settings";
|
|||
|
||||
type PublicationSortKey =
|
||||
| "title"
|
||||
| "favorite"
|
||||
| "scholar"
|
||||
| "year"
|
||||
| "citations"
|
||||
| "status"
|
||||
| "first_seen";
|
||||
|
||||
type BulkAction =
|
||||
|
|
@ -56,6 +54,7 @@ const sortKey = ref<PublicationSortKey>("first_seen");
|
|||
const sortDirection = ref<"asc" | "desc">("desc");
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref("50");
|
||||
const publicationSnapshot = ref<string | null>(null);
|
||||
|
||||
const scholars = ref<ScholarProfile[]>([]);
|
||||
const listState = ref<PublicationsResult | null>(null);
|
||||
|
|
@ -190,27 +189,42 @@ const selectedScholarName = computed(() => {
|
|||
});
|
||||
|
||||
const filteredPublications = computed(() => {
|
||||
let stream = [...runStatus.livePublications];
|
||||
if (favoriteOnly.value) {
|
||||
stream = stream.filter((p) => p.is_favorite);
|
||||
}
|
||||
if (mode.value === "unread") {
|
||||
stream = stream.filter((p) => !p.is_read);
|
||||
}
|
||||
const selectedScholarId = Number(selectedScholarFilter.value);
|
||||
if (Number.isInteger(selectedScholarId) && selectedScholarId > 0) {
|
||||
stream = stream.filter((p) => p.scholar_profile_id === selectedScholarId);
|
||||
}
|
||||
|
||||
const base = listState.value?.publications ?? [];
|
||||
const merged = [...stream, ...base];
|
||||
const seenIds = new Set();
|
||||
const deduped: typeof base = [];
|
||||
for (const item of merged) {
|
||||
if (!seenIds.has(item.publication_id)) {
|
||||
seenIds.add(item.publication_id);
|
||||
deduped.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = searchQuery.value.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return base;
|
||||
return deduped;
|
||||
}
|
||||
return base.filter((item) => {
|
||||
const year = item.year === null ? "" : String(item.year);
|
||||
return [item.title, item.scholar_label, item.venue_text || "", year]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(normalized);
|
||||
});
|
||||
// Client-side fallback: filter live-discovered publications that haven't been
|
||||
// server-round-tripped yet. The main server query already filters by search.
|
||||
return deduped;
|
||||
});
|
||||
|
||||
function publicationSortValue(item: PublicationItem, key: PublicationSortKey): number | string {
|
||||
if (key === "title") {
|
||||
return item.title;
|
||||
}
|
||||
if (key === "favorite") {
|
||||
return item.is_favorite ? 1 : 0;
|
||||
}
|
||||
if (key === "scholar") {
|
||||
return item.scholar_label;
|
||||
}
|
||||
|
|
@ -220,38 +234,14 @@ function publicationSortValue(item: PublicationItem, key: PublicationSortKey): n
|
|||
if (key === "citations") {
|
||||
return item.citation_count;
|
||||
}
|
||||
if (key === "status") {
|
||||
if (item.is_read) {
|
||||
return 2;
|
||||
}
|
||||
if (item.is_new_in_latest_run) {
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
const timestamp = Date.parse(item.first_seen_at);
|
||||
return Number.isNaN(timestamp) ? 0 : timestamp;
|
||||
}
|
||||
|
||||
const sortedPublications = computed(() => {
|
||||
const sorted = [...filteredPublications.value];
|
||||
sorted.sort((a, b) => {
|
||||
const left = publicationSortValue(a, sortKey.value);
|
||||
const right = publicationSortValue(b, sortKey.value);
|
||||
|
||||
let comparison: number;
|
||||
if (typeof left === "string" && typeof right === "string") {
|
||||
comparison = textCollator.compare(left, right);
|
||||
} else {
|
||||
comparison = Number(left) - Number(right);
|
||||
}
|
||||
|
||||
if (comparison === 0) {
|
||||
comparison = textCollator.compare(a.title, b.title);
|
||||
}
|
||||
return sortDirection.value === "asc" ? comparison : comparison * -1;
|
||||
});
|
||||
return sorted;
|
||||
// Server already returns data in the correct sort order.
|
||||
// We just pass through the filtered/merged list without client-side re-sorting.
|
||||
return filteredPublications.value;
|
||||
});
|
||||
|
||||
const visibleUnreadKeys = computed(() => {
|
||||
|
|
@ -437,13 +427,16 @@ watch(hasSelection, (nextHasSelection) => {
|
|||
bulkAction.value = nextHasSelection ? "mark_selected_read" : "mark_all_unread_read";
|
||||
});
|
||||
|
||||
function toggleSort(nextKey: PublicationSortKey): void {
|
||||
async function toggleSort(nextKey: PublicationSortKey): Promise<void> {
|
||||
if (sortKey.value === nextKey) {
|
||||
sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
|
||||
return;
|
||||
} else {
|
||||
sortKey.value = nextKey;
|
||||
sortDirection.value = nextKey === "first_seen" ? "desc" : "asc";
|
||||
}
|
||||
sortKey.value = nextKey;
|
||||
sortDirection.value = nextKey === "first_seen" ? "desc" : "asc";
|
||||
currentPage.value = 1;
|
||||
publicationSnapshot.value = null;
|
||||
await loadPublications();
|
||||
}
|
||||
|
||||
function sortMarker(key: PublicationSortKey): string {
|
||||
|
|
@ -476,9 +469,14 @@ async function loadPublications(): Promise<void> {
|
|||
mode: mode.value,
|
||||
favoriteOnly: favoriteOnly.value,
|
||||
scholarProfileId: selectedScholarId(),
|
||||
search: searchQuery.value.trim() || undefined,
|
||||
sortBy: sortKey.value,
|
||||
sortDir: sortDirection.value,
|
||||
page: currentPage.value,
|
||||
pageSize: pageSizeValue.value,
|
||||
snapshot: publicationSnapshot.value ?? undefined,
|
||||
});
|
||||
publicationSnapshot.value = listState.value.snapshot;
|
||||
currentPage.value = listState.value.page;
|
||||
pageSize.value = String(listState.value.page_size);
|
||||
selectedPublicationKeys.value = new Set();
|
||||
|
|
@ -498,12 +496,14 @@ async function loadPublications(): Promise<void> {
|
|||
|
||||
async function onModeChanged(): Promise<void> {
|
||||
currentPage.value = 1;
|
||||
publicationSnapshot.value = null;
|
||||
await syncFiltersToRoute();
|
||||
await loadPublications();
|
||||
}
|
||||
|
||||
async function onScholarFilterChanged(): Promise<void> {
|
||||
currentPage.value = 1;
|
||||
publicationSnapshot.value = null;
|
||||
await syncFiltersToRoute();
|
||||
await loadPublications();
|
||||
}
|
||||
|
|
@ -511,12 +511,14 @@ async function onScholarFilterChanged(): Promise<void> {
|
|||
async function onFavoriteOnlyChanged(): Promise<void> {
|
||||
favoriteOnly.value = !favoriteOnly.value;
|
||||
currentPage.value = 1;
|
||||
publicationSnapshot.value = null;
|
||||
await syncFiltersToRoute();
|
||||
await loadPublications();
|
||||
}
|
||||
|
||||
async function onPageSizeChanged(): Promise<void> {
|
||||
currentPage.value = 1;
|
||||
publicationSnapshot.value = null;
|
||||
await syncFiltersToRoute();
|
||||
await loadPublications();
|
||||
}
|
||||
|
|
@ -605,15 +607,15 @@ function canRetryPublicationPdf(item: PublicationItem): boolean {
|
|||
|
||||
function pdfPendingLabel(item: PublicationItem): string {
|
||||
if (item.pdf_status === "queued") {
|
||||
return "queued";
|
||||
return "Queued";
|
||||
}
|
||||
if (item.pdf_status === "running") {
|
||||
return "resolving";
|
||||
return "Resolving...";
|
||||
}
|
||||
if (item.pdf_status === "failed") {
|
||||
return "failed";
|
||||
return "Missing";
|
||||
}
|
||||
return "untracked";
|
||||
return "Untracked";
|
||||
}
|
||||
|
||||
function replacePublication(updated: PublicationItem): void {
|
||||
|
|
@ -784,6 +786,18 @@ function resetSearchQuery(): void {
|
|||
searchQuery.value = "";
|
||||
}
|
||||
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
watch(searchQuery, () => {
|
||||
if (searchDebounceTimer !== null) {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
}
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
currentPage.value = 1;
|
||||
publicationSnapshot.value = null;
|
||||
void loadPublications();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
syncFiltersFromRoute();
|
||||
void Promise.all([loadScholarFilters(), loadPublications(), runStatus.syncLatest()]);
|
||||
|
|
@ -792,10 +806,18 @@ onMounted(() => {
|
|||
watch(
|
||||
() => [route.query.scholar, route.query.favorite, route.query.page],
|
||||
async () => {
|
||||
const previousScholar = selectedScholarFilter.value;
|
||||
const previousFavorite = favoriteOnly.value;
|
||||
const changed = syncFiltersFromRoute();
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
selectedScholarFilter.value !== previousScholar
|
||||
|| favoriteOnly.value !== previousFavorite
|
||||
) {
|
||||
publicationSnapshot.value = null;
|
||||
}
|
||||
await loadPublications();
|
||||
},
|
||||
);
|
||||
|
|
@ -843,7 +865,7 @@ watch(
|
|||
<label class="grid gap-1 text-xs text-secondary" for="publications-search-input">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
Search
|
||||
<AppHelpHint text="Searches title, scholar, venue, and year within currently loaded results." />
|
||||
<AppHelpHint text="Searches title, scholar name, and venue." />
|
||||
</span>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<AppInput
|
||||
|
|
@ -975,11 +997,7 @@ watch(
|
|||
@change="onToggleAllVisible"
|
||||
/>
|
||||
</th>
|
||||
<th scope="col" class="w-12">
|
||||
<button type="button" class="table-sort" @click="toggleSort('favorite')">
|
||||
★ <span aria-hidden="true" class="sort-marker">{{ sortMarker('favorite') }}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" class="w-12 text-left font-semibold text-ink-primary">★</th>
|
||||
<th scope="col" class="w-[44%] min-w-[24rem]">
|
||||
<button type="button" class="table-sort" @click="toggleSort('title')">
|
||||
Title <span aria-hidden="true" class="sort-marker">{{ sortMarker('title') }}</span>
|
||||
|
|
@ -990,7 +1008,7 @@ watch(
|
|||
Scholar <span aria-hidden="true" class="sort-marker">{{ sortMarker('scholar') }}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" class="w-[8.5rem] whitespace-nowrap">PDF</th>
|
||||
<th scope="col" class="w-[8.5rem] whitespace-nowrap text-left font-semibold text-ink-primary">PDF</th>
|
||||
<th scope="col" class="w-16 whitespace-nowrap">
|
||||
<button type="button" class="table-sort" @click="toggleSort('year')">
|
||||
Year <span aria-hidden="true" class="sort-marker">{{ sortMarker('year') }}</span>
|
||||
|
|
@ -1001,11 +1019,7 @@ watch(
|
|||
Citations <span aria-hidden="true" class="sort-marker">{{ sortMarker('citations') }}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" class="w-44 whitespace-nowrap">
|
||||
<button type="button" class="table-sort" @click="toggleSort('status')">
|
||||
Read status <span aria-hidden="true" class="sort-marker">{{ sortMarker('status') }}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" class="w-44 whitespace-nowrap text-left font-semibold text-ink-primary">Read status</th>
|
||||
<th scope="col" class="w-32 whitespace-nowrap">
|
||||
<button type="button" class="table-sort" @click="toggleSort('first_seen')">
|
||||
First seen <span aria-hidden="true" class="sort-marker">{{ sortMarker('first_seen') }}</span>
|
||||
|
|
@ -1073,8 +1087,12 @@ watch(
|
|||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="pdf-link-button"
|
||||
title="Open PDF"
|
||||
>
|
||||
PDF
|
||||
<svg class="mr-1 h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
Available
|
||||
</a>
|
||||
<button
|
||||
v-else-if="canRetryPublicationPdf(item)"
|
||||
|
|
@ -1083,9 +1101,19 @@ watch(
|
|||
:disabled="isRetryingPublication(item)"
|
||||
@click="onRetryPdf(item)"
|
||||
>
|
||||
{{ isRetryingPublication(item) ? "..." : "retry" }}
|
||||
<svg v-if="isRetryingPublication(item)" class="mr-1 h-3 w-3 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ isRetryingPublication(item) ? "Retrying..." : "Missing (Retry)" }}
|
||||
</button>
|
||||
<span v-else class="pdf-state-label">{{ pdfPendingLabel(item) }}</span>
|
||||
<span v-else class="pdf-state-label" :class="{ 'bg-surface-accent-muted border-accent-300 text-accent-700': item.pdf_status === 'running' || item.pdf_status === 'queued' }">
|
||||
<svg v-if="item.pdf_status === 'running'" class="mr-1 h-3 w-3 animate-spin text-accent-600" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ pdfPendingLabel(item) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap">{{ item.year ?? "n/a" }}</td>
|
||||
<td class="whitespace-nowrap">{{ item.citation_count }}</td>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
type RunListItem,
|
||||
} from "@/features/runs";
|
||||
import { ApiRequestError } from "@/lib/api/errors";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useRunStatusStore } from "@/stores/run_status";
|
||||
import { useUserSettingsStore } from "@/stores/user_settings";
|
||||
|
||||
|
|
@ -33,8 +34,10 @@ const errorMessage = ref<string | null>(null);
|
|||
const errorRequestId = ref<string | null>(null);
|
||||
const successMessage = ref<string | null>(null);
|
||||
const activeQueueItemId = ref<number | null>(null);
|
||||
const auth = useAuthStore();
|
||||
const runStatus = useRunStatusStore();
|
||||
const userSettings = useUserSettingsStore();
|
||||
const isCancelAnimating = ref(false);
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value) {
|
||||
|
|
@ -62,7 +65,7 @@ function queueHealth() {
|
|||
}
|
||||
|
||||
const queueCounts = computed(() => queueHealth());
|
||||
const activeRunId = computed(() => runStatus.latestRun?.status === "running" ? runStatus.latestRun.id : null);
|
||||
const activeRunId = computed(() => runStatus.isRunActive && runStatus.latestRun ? runStatus.latestRun.id : null);
|
||||
const isStartBlocked = computed(
|
||||
() =>
|
||||
runStatus.isRunActive ||
|
||||
|
|
@ -151,6 +154,28 @@ async function onTriggerManualRun(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
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 loadData();
|
||||
} else {
|
||||
errorMessage.value = result.message;
|
||||
}
|
||||
} catch {
|
||||
errorMessage.value = "Unable to cancel the update check.";
|
||||
} finally {
|
||||
isCancelAnimating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runQueueAction(itemId: number, action: "retry" | "drop" | "clear"): Promise<void> {
|
||||
activeQueueItemId.value = itemId;
|
||||
successMessage.value = null;
|
||||
|
|
@ -208,6 +233,22 @@ onMounted(() => {
|
|||
:manual-run-allowed="userSettings.manualRunAllowed"
|
||||
/>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<AppButton
|
||||
v-if="runStatus.isRunActive"
|
||||
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" @click="onTriggerManualRun">
|
||||
{{ runButtonLabel }}
|
||||
</AppButton>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ const TAB_ADMIN_USERS = "admin-users";
|
|||
const TAB_ADMIN_INTEGRITY = "admin-integrity";
|
||||
const TAB_ADMIN_REPAIRS = "admin-repairs";
|
||||
const TAB_ADMIN_PDF = "admin-pdf";
|
||||
const TAB_ADMIN_INTEGRATIONS = "admin-integrations";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const userSettings = useUserSettingsStore();
|
||||
|
|
@ -45,6 +46,9 @@ const autoRunEnabled = ref(false);
|
|||
const runIntervalMinutes = ref("60");
|
||||
const requestDelaySeconds = ref("2");
|
||||
const navVisiblePages = ref<string[]>([]);
|
||||
const openalexApiKey = ref("");
|
||||
const crossrefApiToken = ref("");
|
||||
const crossrefApiMailto = ref("");
|
||||
|
||||
const currentPassword = ref("");
|
||||
const newPassword = ref("");
|
||||
|
|
@ -72,6 +76,7 @@ const tabItems = computed<AppTabItem[]>(() => {
|
|||
{ id: TAB_ADMIN_INTEGRITY, label: "Integrity" },
|
||||
{ id: TAB_ADMIN_REPAIRS, label: "Repairs" },
|
||||
{ id: TAB_ADMIN_PDF, label: "PDF Queue" },
|
||||
{ id: TAB_ADMIN_INTEGRATIONS, label: "Integrations" },
|
||||
);
|
||||
}
|
||||
return tabs;
|
||||
|
|
@ -129,6 +134,10 @@ function hydrateSettings(settings: UserSettings): void {
|
|||
requestDelaySeconds.value = String(settings.request_delay_seconds);
|
||||
navVisiblePages.value = normalizeUserNavVisiblePages(settings.nav_visible_pages);
|
||||
|
||||
openalexApiKey.value = settings.openalex_api_key ?? "";
|
||||
crossrefApiToken.value = settings.crossref_api_token ?? "";
|
||||
crossrefApiMailto.value = settings.crossref_api_mailto ?? "";
|
||||
|
||||
userSettings.applySettings(settings);
|
||||
runStatus.setSafetyState(settings.safety_state);
|
||||
}
|
||||
|
|
@ -183,6 +192,9 @@ async function onSaveSettings(): Promise<void> {
|
|||
minRequestDelaySeconds.value,
|
||||
),
|
||||
nav_visible_pages: normalizeUserNavVisiblePages(navVisiblePages.value),
|
||||
openalex_api_key: openalexApiKey.value.trim() || null,
|
||||
crossref_api_token: crossrefApiToken.value.trim() || null,
|
||||
crossref_api_mailto: crossrefApiMailto.value.trim() || null,
|
||||
};
|
||||
|
||||
const saved = await updateSettings(payload);
|
||||
|
|
@ -357,6 +369,50 @@ onMounted(async () => {
|
|||
</form>
|
||||
</section>
|
||||
|
||||
<section v-if="activeTab === TAB_ADMIN_INTEGRATIONS" class="grid gap-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<h2 class="text-lg font-semibold text-ink-primary">API Integrations</h2>
|
||||
<AppHelpHint text="Configure API keys for external services like OpenAlex and Crossref. These are global system settings." />
|
||||
</div>
|
||||
<p class="text-sm text-secondary">If no keys are provided, the system will gracefully fall back to free unauthenticated tiers where available.</p>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<div class="grid gap-2 rounded-lg border border-stroke-default bg-surface-card-muted p-3">
|
||||
<h3 class="text-sm font-semibold text-ink-secondary">OpenAlex</h3>
|
||||
<p class="text-xs text-secondary mb-2">
|
||||
OpenAlex is a free index of the world's research. Providing a key unlocks a higher rate limit.
|
||||
Values returned from the backend might be 'SET' to hide the raw key for security.
|
||||
</p>
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
|
||||
<span>API Key</span>
|
||||
<AppInput v-model="openalexApiKey" placeholder="e.g. iBo3Ye2q322zKYkEyI..." autocomplete="off" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2 rounded-lg border border-stroke-default bg-surface-card-muted p-3">
|
||||
<h3 class="text-sm font-semibold text-ink-secondary">Crossref</h3>
|
||||
<p class="text-xs text-secondary mb-2">
|
||||
Crossref metadata search for DOIs. A "mailto" address puts you in their "Polite Pool" for better performance.
|
||||
A Plus API token unlocks the fastest tier.
|
||||
</p>
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
|
||||
<span>API Token (Plus)</span>
|
||||
<AppInput v-model="crossrefApiToken" placeholder="Usually empty unless you have an enterprise Plus account" autocomplete="off" />
|
||||
</label>
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary mt-2">
|
||||
<span>Mailto (Polite Pool)</span>
|
||||
<AppInput v-model="crossrefApiMailto" placeholder="e.g. admin@yourdomain.com" type="email" autocomplete="off" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<AppButton :disabled="saving" @click="onSaveSettings">
|
||||
{{ saving ? "Saving..." : "Save integrations" }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<SettingsAdminPanel v-if="activeAdminSection" :section="activeAdminSection" />
|
||||
</AppCard>
|
||||
</AsyncStateGate>
|
||||
|
|
|
|||
|
|
@ -7,9 +7,10 @@ import { createDefaultSafetyState } from "@/features/safety";
|
|||
vi.mock("@/features/runs", () => ({
|
||||
listRuns: vi.fn(),
|
||||
triggerManualRun: vi.fn(),
|
||||
cancelRun: vi.fn(),
|
||||
}));
|
||||
|
||||
import { listRuns, triggerManualRun } from "@/features/runs";
|
||||
import { cancelRun, listRuns, triggerManualRun } from "@/features/runs";
|
||||
import {
|
||||
RUN_STATUS_POLL_INTERVAL_MS,
|
||||
RUN_STATUS_STARTING_PHASE_MS,
|
||||
|
|
@ -51,11 +52,13 @@ function buildRunsPayload(runs: ReturnType<typeof buildRun>[]) {
|
|||
describe("run status store", () => {
|
||||
const mockedListRuns = vi.mocked(listRuns);
|
||||
const mockedTriggerManualRun = vi.mocked(triggerManualRun);
|
||||
const mockedCancelRun = vi.mocked(cancelRun);
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
mockedListRuns.mockReset();
|
||||
mockedTriggerManualRun.mockReset();
|
||||
mockedCancelRun.mockReset();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
|
|
@ -223,4 +226,87 @@ describe("run status store", () => {
|
|||
await startPromise;
|
||||
expect(store.isRunActive).toBe(true);
|
||||
});
|
||||
|
||||
it("cancels an active check and transitions to canceled state", async () => {
|
||||
mockedCancelRun.mockResolvedValueOnce({
|
||||
run: buildRun({ id: 50, status: "canceled" }),
|
||||
summary: {
|
||||
succeeded_count: 0,
|
||||
failed_count: 0,
|
||||
partial_count: 0,
|
||||
failed_state_counts: {},
|
||||
failed_reason_counts: {},
|
||||
scrape_failure_counts: {},
|
||||
retry_counts: {
|
||||
retries_scheduled_count: 0,
|
||||
scholars_with_retries_count: 0,
|
||||
retry_exhausted_count: 0,
|
||||
},
|
||||
alert_thresholds: {},
|
||||
alert_flags: {},
|
||||
},
|
||||
scholar_results: [],
|
||||
safety_state: createDefaultSafetyState(),
|
||||
} as any);
|
||||
|
||||
const store = useRunStatusStore();
|
||||
store.setLatestRun(buildRun({ id: 50, status: "running", end_dt: null }));
|
||||
expect(store.isRunActive).toBe(true);
|
||||
|
||||
const result = await store.cancelActiveCheck();
|
||||
|
||||
expect(result.kind).toBe("success");
|
||||
expect(store.latestRun?.status).toBe("canceled");
|
||||
expect(store.isRunActive).toBe(false);
|
||||
expect(store.isPolling).toBe(false);
|
||||
});
|
||||
|
||||
it("cancels a resolving run using server status as source of truth", async () => {
|
||||
mockedCancelRun.mockResolvedValueOnce({
|
||||
run: buildRun({ id: 72, status: "failed" }),
|
||||
summary: {
|
||||
succeeded_count: 0,
|
||||
failed_count: 1,
|
||||
partial_count: 0,
|
||||
failed_state_counts: {},
|
||||
failed_reason_counts: {},
|
||||
scrape_failure_counts: {},
|
||||
retry_counts: {
|
||||
retries_scheduled_count: 0,
|
||||
scholars_with_retries_count: 0,
|
||||
retry_exhausted_count: 0,
|
||||
},
|
||||
alert_thresholds: {},
|
||||
alert_flags: {},
|
||||
},
|
||||
scholar_results: [],
|
||||
safety_state: createDefaultSafetyState(),
|
||||
} as any);
|
||||
|
||||
const store = useRunStatusStore();
|
||||
store.setLatestRun(buildRun({ id: 72, status: "resolving", end_dt: null }));
|
||||
|
||||
const result = await store.cancelActiveCheck();
|
||||
|
||||
expect(result.kind).toBe("success");
|
||||
expect(store.latestRun?.status).toBe("failed");
|
||||
expect(store.isRunActive).toBe(false);
|
||||
});
|
||||
|
||||
it("reconciles poll responses without regressing publication counters", async () => {
|
||||
mockedListRuns.mockResolvedValueOnce(
|
||||
buildRunsPayload([buildRun({ id: 99, status: "running", new_publication_count: 1, end_dt: null })]),
|
||||
);
|
||||
|
||||
const store = useRunStatusStore();
|
||||
await store.syncLatest();
|
||||
store.latestRun = buildRun({ id: 99, status: "running", new_publication_count: 5, end_dt: null });
|
||||
|
||||
mockedListRuns.mockResolvedValueOnce(
|
||||
buildRunsPayload([buildRun({ id: 99, status: "running", new_publication_count: 3, end_dt: null })]),
|
||||
);
|
||||
await store.syncLatest();
|
||||
|
||||
expect(store.latestRun?.new_publication_count).toBe(5);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { defineStore } from "pinia";
|
||||
|
||||
import { listRuns, triggerManualRun, type RunListItem } from "@/features/runs";
|
||||
import { listRuns, triggerManualRun, cancelRun, type RunListItem } from "@/features/runs";
|
||||
import {
|
||||
createDefaultSafetyState,
|
||||
normalizeSafetyState,
|
||||
type ScrapeSafetyState,
|
||||
} from "@/features/safety";
|
||||
import { type PublicationItem } from "@/features/publications";
|
||||
import { ApiRequestError } from "@/lib/api/errors";
|
||||
|
||||
export const RUN_STATUS_POLL_INTERVAL_MS = 5000;
|
||||
|
|
@ -13,24 +14,31 @@ export const RUN_STATUS_STARTING_PHASE_MS = 1500;
|
|||
|
||||
export type StartManualCheckResult =
|
||||
| {
|
||||
kind: "started";
|
||||
runId: number;
|
||||
reusedExistingRun: boolean;
|
||||
}
|
||||
kind: "started";
|
||||
runId: number;
|
||||
reusedExistingRun: boolean;
|
||||
}
|
||||
| {
|
||||
kind: "already_running";
|
||||
runId: number | null;
|
||||
requestId: string | null;
|
||||
}
|
||||
kind: "already_running";
|
||||
runId: number | null;
|
||||
requestId: string | null;
|
||||
}
|
||||
| {
|
||||
kind: "error";
|
||||
message: string;
|
||||
requestId: string | null;
|
||||
};
|
||||
kind: "error";
|
||||
message: string;
|
||||
requestId: string | null;
|
||||
};
|
||||
|
||||
export type CancelCheckResult =
|
||||
| { kind: "success" }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let syncPromise: Promise<void> | null = null;
|
||||
let submittingPhaseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let eventSource: EventSource | null = null;
|
||||
let activeStreamRunId: number | null = null;
|
||||
const ACTIVE_STATUSES = new Set(["running", "resolving"]);
|
||||
|
||||
function parseRunId(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
|
|
@ -67,6 +75,32 @@ function extractSafetyStateFromDetails(details: unknown): ScrapeSafetyState | nu
|
|||
return normalizeSafetyState(candidate);
|
||||
}
|
||||
|
||||
function isActiveStatus(value: string | null | undefined): boolean {
|
||||
return value !== undefined && value !== null && ACTIVE_STATUSES.has(value);
|
||||
}
|
||||
|
||||
function parsePublicationCount(value: unknown, fallback: number): number {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return Math.max(Math.trunc(value), 0);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function reconcileRunCounters(previous: RunListItem | null, next: RunListItem | null): RunListItem | null {
|
||||
if (previous === null || next === null) {
|
||||
return next;
|
||||
}
|
||||
if (previous.id !== next.id) {
|
||||
return next;
|
||||
}
|
||||
const previousCount = parsePublicationCount(previous.new_publication_count, 0);
|
||||
const nextCount = parsePublicationCount(next.new_publication_count, previousCount);
|
||||
return {
|
||||
...next,
|
||||
new_publication_count: Math.max(previousCount, nextCount),
|
||||
};
|
||||
}
|
||||
|
||||
function buildPlaceholderRunningRun(runId: number): RunListItem {
|
||||
return {
|
||||
id: runId,
|
||||
|
|
@ -91,13 +125,16 @@ export const useRunStatusStore = defineStore("runStatus", {
|
|||
lastErrorMessage: null as string | null,
|
||||
lastErrorRequestId: null as string | null,
|
||||
lastSyncAt: null as number | null,
|
||||
livePublications: [] as Array<PublicationItem>,
|
||||
}),
|
||||
getters: {
|
||||
isRunActive(state): boolean {
|
||||
return state.isSubmitting || state.latestRun?.status === "running";
|
||||
const s = state.latestRun?.status;
|
||||
return state.isSubmitting || s === "running" || s === "resolving";
|
||||
},
|
||||
isLikelyRunning(state): boolean {
|
||||
return state.latestRun?.status === "running" || state.assumeRunningFromSubmission;
|
||||
const s = state.latestRun?.status;
|
||||
return s === "running" || s === "resolving" || state.assumeRunningFromSubmission;
|
||||
},
|
||||
canStart(): boolean {
|
||||
return !this.isRunActive && !this.safetyState.cooldown_active;
|
||||
|
|
@ -125,7 +162,7 @@ export const useRunStatusStore = defineStore("runStatus", {
|
|||
this.lastSyncAt = Date.now();
|
||||
this.lastErrorMessage = null;
|
||||
this.lastErrorRequestId = null;
|
||||
if (run?.status === "running") {
|
||||
if (isActiveStatus(run?.status)) {
|
||||
this.assumeRunningFromSubmission = true;
|
||||
} else if (!this.isSubmitting) {
|
||||
this.assumeRunningFromSubmission = false;
|
||||
|
|
@ -156,9 +193,69 @@ export const useRunStatusStore = defineStore("runStatus", {
|
|||
updatePolling(): void {
|
||||
if (this.isRunActive) {
|
||||
this.startPolling();
|
||||
this.updateEventSource();
|
||||
return;
|
||||
}
|
||||
this.stopPolling();
|
||||
this.updateEventSource();
|
||||
},
|
||||
updateEventSource(): void {
|
||||
const targetRunId = this.latestRun?.status === "running" ? this.latestRun.id : null;
|
||||
if (activeStreamRunId === targetRunId) {
|
||||
return;
|
||||
}
|
||||
if (eventSource !== null) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
activeStreamRunId = null;
|
||||
}
|
||||
if (targetRunId !== null) {
|
||||
if (typeof EventSource === "undefined") {
|
||||
return;
|
||||
}
|
||||
activeStreamRunId = targetRunId;
|
||||
this.livePublications = [];
|
||||
eventSource = new EventSource(`/api/v1/runs/${targetRunId}/stream`);
|
||||
eventSource.addEventListener("publication_discovered", (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
if (this.latestRun && this.latestRun.id === targetRunId) {
|
||||
const baseline = parsePublicationCount(this.latestRun.new_publication_count, 0);
|
||||
const payloadCount = parsePublicationCount(data?.new_publication_count, baseline + 1);
|
||||
this.latestRun.new_publication_count = Math.max(baseline, payloadCount);
|
||||
}
|
||||
this.livePublications.unshift({
|
||||
publication_id: data.publication_id,
|
||||
scholar_profile_id: data.scholar_profile_id,
|
||||
scholar_label: data.scholar_label,
|
||||
title: data.title,
|
||||
pub_url: data.pub_url,
|
||||
first_seen_at: data.first_seen_at,
|
||||
year: null,
|
||||
citation_count: 0,
|
||||
venue_text: null,
|
||||
display_identifier: null,
|
||||
pdf_url: null,
|
||||
pdf_status: "untracked",
|
||||
pdf_attempt_count: 0,
|
||||
pdf_failure_reason: null,
|
||||
pdf_failure_detail: null,
|
||||
is_read: false,
|
||||
is_favorite: false,
|
||||
is_new_in_latest_run: true,
|
||||
});
|
||||
if (this.livePublications.length > 50) {
|
||||
this.livePublications.pop();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to parse SSE event", err);
|
||||
}
|
||||
});
|
||||
eventSource.onerror = () => {
|
||||
// Reconnecting is handled automatically by EventSource,
|
||||
// but if it's permanently closed, we could do something here.
|
||||
};
|
||||
}
|
||||
},
|
||||
async syncLatest(): Promise<void> {
|
||||
if (syncPromise) {
|
||||
|
|
@ -169,13 +266,13 @@ export const useRunStatusStore = defineStore("runStatus", {
|
|||
syncPromise = (async () => {
|
||||
try {
|
||||
const payload = await listRuns({ limit: 1 });
|
||||
const latest = payload.runs[0] ?? null;
|
||||
const latest = reconcileRunCounters(this.latestRun, payload.runs[0] ?? null);
|
||||
this.latestRun = latest;
|
||||
this.safetyState = normalizeSafetyState(payload.safety_state);
|
||||
this.lastSyncAt = Date.now();
|
||||
this.lastErrorMessage = null;
|
||||
this.lastErrorRequestId = null;
|
||||
if (latest?.status === "running") {
|
||||
if (isActiveStatus(latest?.status)) {
|
||||
this.assumeRunningFromSubmission = true;
|
||||
} else if (!this.isSubmitting) {
|
||||
this.assumeRunningFromSubmission = false;
|
||||
|
|
@ -294,14 +391,36 @@ export const useRunStatusStore = defineStore("runStatus", {
|
|||
} finally {
|
||||
this.isSubmitting = false;
|
||||
this.clearSubmittingPhaseTimer();
|
||||
if (this.latestRun?.status !== "running") {
|
||||
if (!isActiveStatus(this.latestRun?.status)) {
|
||||
this.assumeRunningFromSubmission = false;
|
||||
}
|
||||
this.updatePolling();
|
||||
}
|
||||
},
|
||||
async cancelActiveCheck(): Promise<{ kind: "success" } | { kind: "error"; message: string }> {
|
||||
if (!this.latestRun || !isActiveStatus(this.latestRun.status)) {
|
||||
return { kind: "error", message: "No active run to cancel." };
|
||||
}
|
||||
try {
|
||||
const response = await cancelRun(this.latestRun.id);
|
||||
this.setLatestRun(reconcileRunCounters(this.latestRun, response.run));
|
||||
this.setSafetyState(response.safety_state);
|
||||
return { kind: "success" };
|
||||
} catch (error) {
|
||||
let errMessage = "Failed to cancel check.";
|
||||
if (error instanceof ApiRequestError) {
|
||||
errMessage = error.message;
|
||||
}
|
||||
return { kind: "error", message: errMessage };
|
||||
}
|
||||
},
|
||||
reset(): void {
|
||||
this.stopPolling();
|
||||
if (eventSource !== null) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
activeStreamRunId = null;
|
||||
}
|
||||
this.clearSubmittingPhaseTimer();
|
||||
this.latestRun = null;
|
||||
this.isSubmitting = false;
|
||||
|
|
|
|||
|
|
@ -43,6 +43,9 @@ describe("user settings store", () => {
|
|||
last_evaluated_run_id: null,
|
||||
},
|
||||
},
|
||||
openalex_api_key: null,
|
||||
crossref_api_token: null,
|
||||
crossref_api_mailto: null,
|
||||
});
|
||||
|
||||
expect(store.networkFailureThreshold).toBe(2);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue