arden scrape safety telemetry and polish run state UX
This commit is contained in:
parent
110e246a1c
commit
d7404abf18
33 changed files with 2074 additions and 96 deletions
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
|
||||
import { fetchDashboardSnapshot, type DashboardSnapshot } from "@/features/dashboard";
|
||||
import { ApiRequestError } from "@/lib/api/errors";
|
||||
|
|
@ -8,22 +8,51 @@ import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
|
|||
import QueueHealthBadge from "@/components/patterns/QueueHealthBadge.vue";
|
||||
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
|
||||
import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue";
|
||||
import ScrapeSafetyBanner from "@/components/patterns/ScrapeSafetyBanner.vue";
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppCard from "@/components/ui/AppCard.vue";
|
||||
import AppEmptyState from "@/components/ui/AppEmptyState.vue";
|
||||
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useRunStatusStore } from "@/stores/run_status";
|
||||
import { useUserSettingsStore } from "@/stores/user_settings";
|
||||
|
||||
const loading = ref(true);
|
||||
const errorMessage = ref<string | null>(null);
|
||||
const errorRequestId = ref<string | null>(null);
|
||||
const successMessage = ref<string | null>(null);
|
||||
const snapshot = ref<DashboardSnapshot | null>(null);
|
||||
const refreshingAfterCompletion = ref(false);
|
||||
const auth = useAuthStore();
|
||||
const runStatus = useRunStatusStore();
|
||||
const userSettings = useUserSettingsStore();
|
||||
|
||||
const isStartBlocked = computed(
|
||||
() =>
|
||||
runStatus.isRunActive ||
|
||||
!userSettings.manualRunAllowed ||
|
||||
runStatus.safetyState.cooldown_active,
|
||||
);
|
||||
const startCheckDisabledReason = computed(() => {
|
||||
if (!userSettings.manualRunAllowed) {
|
||||
return "Manual checks are disabled by server policy.";
|
||||
}
|
||||
if (runStatus.safetyState.cooldown_active) {
|
||||
return runStatus.safetyState.cooldown_reason_label || "Safety cooldown is active.";
|
||||
}
|
||||
if (runStatus.isRunActive) {
|
||||
return "A check is already in progress.";
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const startCheckLabel = computed(() => {
|
||||
if (!userSettings.manualRunAllowed) {
|
||||
return "Manual checks disabled";
|
||||
}
|
||||
if (runStatus.safetyState.cooldown_active) {
|
||||
return "Safety cooldown";
|
||||
}
|
||||
if (runStatus.isLikelyRunning) {
|
||||
return "Check in progress";
|
||||
}
|
||||
|
|
@ -32,6 +61,9 @@ const startCheckLabel = computed(() => {
|
|||
}
|
||||
return "Start check";
|
||||
});
|
||||
const isStartCheckAnimating = computed(
|
||||
() => runStatus.isSubmitting || runStatus.isLikelyRunning,
|
||||
);
|
||||
|
||||
const displayedLatestRun = computed(() => {
|
||||
const snapshotLatest = snapshot.value?.latestRun ?? null;
|
||||
|
|
@ -95,6 +127,7 @@ async function loadSnapshot(): Promise<void> {
|
|||
const dashboardSnapshot = await fetchDashboardSnapshot();
|
||||
snapshot.value = dashboardSnapshot;
|
||||
runStatus.setLatestRun(dashboardSnapshot.latestRun);
|
||||
runStatus.setSafetyState(dashboardSnapshot.safetyState);
|
||||
} catch (error) {
|
||||
snapshot.value = null;
|
||||
if (error instanceof ApiRequestError) {
|
||||
|
|
@ -140,6 +173,29 @@ async function onTriggerRun(): Promise<void> {
|
|||
onMounted(() => {
|
||||
void loadSnapshot();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => runStatus.latestRun,
|
||||
(nextRun, previousRun) => {
|
||||
if (refreshingAfterCompletion.value) {
|
||||
return;
|
||||
}
|
||||
if (!nextRun || !previousRun) {
|
||||
return;
|
||||
}
|
||||
if (nextRun.id !== previousRun.id) {
|
||||
return;
|
||||
}
|
||||
if (previousRun.status !== "running" || nextRun.status === "running") {
|
||||
return;
|
||||
}
|
||||
|
||||
refreshingAfterCompletion.value = true;
|
||||
void loadSnapshot().finally(() => {
|
||||
refreshingAfterCompletion.value = false;
|
||||
});
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -156,6 +212,10 @@ onMounted(() => {
|
|||
error-title="Dashboard request failed"
|
||||
@dismiss-success="successMessage = null"
|
||||
/>
|
||||
<ScrapeSafetyBanner
|
||||
:safety-state="runStatus.safetyState"
|
||||
:manual-run-allowed="userSettings.manualRunAllowed"
|
||||
/>
|
||||
|
||||
<div class="h-0 min-h-0 flex-1 xl:overflow-hidden">
|
||||
<AsyncStateGate :loading="loading" :loading-lines="6" :show-empty="false">
|
||||
|
|
@ -254,10 +314,20 @@ onMounted(() => {
|
|||
<div class="flex items-center gap-2">
|
||||
<AppButton
|
||||
v-if="auth.isAdmin"
|
||||
:disabled="runStatus.isRunActive"
|
||||
:disabled="isStartBlocked"
|
||||
:title="startCheckDisabledReason || undefined"
|
||||
:class="isStartCheckAnimating ? 'shadow-[0_0_0_1px_var(--color-state-info-border)]' : ''"
|
||||
@click="onTriggerRun"
|
||||
>
|
||||
{{ startCheckLabel }}
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span v-if="isStartCheckAnimating" class="relative inline-flex h-2.5 w-2.5">
|
||||
<span
|
||||
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-60"
|
||||
/>
|
||||
<span class="relative inline-flex h-2.5 w-2.5 rounded-full bg-current" />
|
||||
</span>
|
||||
{{ startCheckLabel }}
|
||||
</span>
|
||||
</AppButton>
|
||||
<RouterLink
|
||||
v-if="auth.isAdmin"
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ type PublicationSortKey = "title" | "scholar" | "year" | "citations" | "status"
|
|||
const loading = ref(true);
|
||||
const publishingAll = ref(false);
|
||||
const publishingSelected = ref(false);
|
||||
const mode = ref<PublicationMode>("new");
|
||||
const mode = ref<PublicationMode>("all");
|
||||
const selectedScholarFilter = ref("");
|
||||
const searchQuery = ref("");
|
||||
const sortKey = ref<PublicationSortKey>("first_seen");
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
|
|||
import QueueHealthBadge from "@/components/patterns/QueueHealthBadge.vue";
|
||||
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
|
||||
import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue";
|
||||
import ScrapeSafetyBanner from "@/components/patterns/ScrapeSafetyBanner.vue";
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppCard from "@/components/ui/AppCard.vue";
|
||||
import AppEmptyState from "@/components/ui/AppEmptyState.vue";
|
||||
|
|
@ -22,6 +23,7 @@ import {
|
|||
} from "@/features/runs";
|
||||
import { ApiRequestError } from "@/lib/api/errors";
|
||||
import { useRunStatusStore } from "@/stores/run_status";
|
||||
import { useUserSettingsStore } from "@/stores/user_settings";
|
||||
|
||||
const loading = ref(true);
|
||||
const runs = ref<RunListItem[]>([]);
|
||||
|
|
@ -31,6 +33,7 @@ const errorRequestId = ref<string | null>(null);
|
|||
const successMessage = ref<string | null>(null);
|
||||
const activeQueueItemId = ref<number | null>(null);
|
||||
const runStatus = useRunStatusStore();
|
||||
const userSettings = useUserSettingsStore();
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value) {
|
||||
|
|
@ -59,7 +62,31 @@ function queueHealth() {
|
|||
|
||||
const queueCounts = computed(() => queueHealth());
|
||||
const activeRunId = computed(() => runStatus.latestRun?.status === "running" ? runStatus.latestRun.id : null);
|
||||
const isStartBlocked = computed(
|
||||
() =>
|
||||
runStatus.isRunActive ||
|
||||
!userSettings.manualRunAllowed ||
|
||||
runStatus.safetyState.cooldown_active,
|
||||
);
|
||||
const startCheckDisabledReason = computed(() => {
|
||||
if (!userSettings.manualRunAllowed) {
|
||||
return "Manual checks are disabled by server policy.";
|
||||
}
|
||||
if (runStatus.safetyState.cooldown_active) {
|
||||
return runStatus.safetyState.cooldown_reason_label || "Safety cooldown is active.";
|
||||
}
|
||||
if (runStatus.isRunActive) {
|
||||
return "A check is already in progress.";
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const runButtonLabel = computed(() => {
|
||||
if (!userSettings.manualRunAllowed) {
|
||||
return "Manual checks disabled";
|
||||
}
|
||||
if (runStatus.safetyState.cooldown_active) {
|
||||
return "Safety cooldown";
|
||||
}
|
||||
if (runStatus.isLikelyRunning) {
|
||||
return "Check in progress";
|
||||
}
|
||||
|
|
@ -75,10 +102,11 @@ async function loadData(): Promise<void> {
|
|||
errorRequestId.value = null;
|
||||
|
||||
try {
|
||||
const [loadedRuns, loadedQueue] = await Promise.all([listRuns({ limit: 100 }), listQueueItems(200)]);
|
||||
runs.value = loadedRuns;
|
||||
const [loadedRunsPayload, loadedQueue] = await Promise.all([listRuns({ limit: 100 }), listQueueItems(200)]);
|
||||
runs.value = loadedRunsPayload.runs;
|
||||
queueItems.value = loadedQueue;
|
||||
runStatus.setLatestRun(loadedRuns[0] ?? null);
|
||||
runStatus.setLatestRun(loadedRunsPayload.runs[0] ?? null);
|
||||
runStatus.setSafetyState(loadedRunsPayload.safety_state);
|
||||
} catch (error) {
|
||||
runs.value = [];
|
||||
queueItems.value = [];
|
||||
|
|
@ -174,8 +202,12 @@ onMounted(() => {
|
|||
:dropped="queueCounts.dropped"
|
||||
/>
|
||||
</div>
|
||||
<ScrapeSafetyBanner
|
||||
:safety-state="runStatus.safetyState"
|
||||
:manual-run-allowed="userSettings.manualRunAllowed"
|
||||
/>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<AppButton :disabled="runStatus.isRunActive" @click="onTriggerManualRun">
|
||||
<AppButton :disabled="isStartBlocked" :title="startCheckDisabledReason || undefined" @click="onTriggerManualRun">
|
||||
{{ runButtonLabel }}
|
||||
</AppButton>
|
||||
<AppButton variant="secondary" :disabled="loading" @click="loadData">
|
||||
|
|
|
|||
|
|
@ -14,10 +14,12 @@ import {
|
|||
changePassword,
|
||||
fetchSettings,
|
||||
type UserSettings,
|
||||
type UserSettingsUpdate,
|
||||
updateSettings,
|
||||
} from "@/features/settings";
|
||||
import { ApiRequestError } from "@/lib/api/errors";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useRunStatusStore } from "@/stores/run_status";
|
||||
import { normalizeUserNavVisiblePages, useUserSettingsStore } from "@/stores/user_settings";
|
||||
|
||||
interface NavPageOption {
|
||||
|
|
@ -78,6 +80,7 @@ const NAV_PAGE_OPTIONS: NavPageOption[] = [
|
|||
|
||||
const auth = useAuthStore();
|
||||
const userSettings = useUserSettingsStore();
|
||||
const runStatus = useRunStatusStore();
|
||||
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
|
|
@ -98,8 +101,14 @@ const successMessage = ref<string | null>(null);
|
|||
const showIngestionModal = ref(false);
|
||||
const showPasswordModal = ref(false);
|
||||
const showNavigationModal = ref(false);
|
||||
const MIN_CHECK_INTERVAL_MINUTES = 15;
|
||||
const MIN_REQUEST_DELAY_SECONDS = 2;
|
||||
const minCheckIntervalMinutes = ref(15);
|
||||
const minRequestDelaySeconds = ref(2);
|
||||
const automationAllowed = ref(true);
|
||||
const manualRunAllowed = ref(true);
|
||||
const blockedFailureThreshold = ref(1);
|
||||
const networkFailureThreshold = ref(2);
|
||||
const cooldownBlockedSeconds = ref(1800);
|
||||
const cooldownNetworkSeconds = ref(900);
|
||||
|
||||
const visibleNavOptions = computed(() =>
|
||||
NAV_PAGE_OPTIONS.filter((option) => !option.adminOnly || auth.isAdmin),
|
||||
|
|
@ -111,11 +120,35 @@ const visibleNavLabels = computed(() =>
|
|||
);
|
||||
|
||||
function hydrateSettings(settings: UserSettings): void {
|
||||
autoRunEnabled.value = settings.auto_run_enabled;
|
||||
const parsedMinRunInterval = Number(settings.policy?.min_run_interval_minutes);
|
||||
minCheckIntervalMinutes.value = Number.isFinite(parsedMinRunInterval)
|
||||
? Math.max(15, parsedMinRunInterval)
|
||||
: 15;
|
||||
const parsedMinRequestDelay = Number(settings.policy?.min_request_delay_seconds);
|
||||
minRequestDelaySeconds.value = Number.isFinite(parsedMinRequestDelay)
|
||||
? Math.max(2, parsedMinRequestDelay)
|
||||
: 2;
|
||||
automationAllowed.value = Boolean(settings.policy?.automation_allowed ?? true);
|
||||
manualRunAllowed.value = Boolean(settings.policy?.manual_run_allowed ?? true);
|
||||
blockedFailureThreshold.value = Number.isFinite(settings.policy?.blocked_failure_threshold)
|
||||
? Math.max(1, settings.policy.blocked_failure_threshold)
|
||||
: 1;
|
||||
networkFailureThreshold.value = Number.isFinite(settings.policy?.network_failure_threshold)
|
||||
? Math.max(1, settings.policy.network_failure_threshold)
|
||||
: 2;
|
||||
cooldownBlockedSeconds.value = Number.isFinite(settings.policy?.cooldown_blocked_seconds)
|
||||
? Math.max(60, settings.policy.cooldown_blocked_seconds)
|
||||
: 1800;
|
||||
cooldownNetworkSeconds.value = Number.isFinite(settings.policy?.cooldown_network_seconds)
|
||||
? Math.max(60, settings.policy.cooldown_network_seconds)
|
||||
: 900;
|
||||
|
||||
autoRunEnabled.value = Boolean(settings.auto_run_enabled) && automationAllowed.value;
|
||||
runIntervalMinutes.value = String(settings.run_interval_minutes);
|
||||
requestDelaySeconds.value = String(settings.request_delay_seconds);
|
||||
navVisiblePages.value = normalizeUserNavVisiblePages(settings.nav_visible_pages);
|
||||
userSettings.applySettings(settings);
|
||||
runStatus.setSafetyState(settings.safety_state);
|
||||
}
|
||||
|
||||
function parseBoundedInteger(value: string, label: string, minimum: number): number {
|
||||
|
|
@ -176,17 +209,17 @@ async function onSaveSettings(): Promise<void> {
|
|||
successMessage.value = null;
|
||||
|
||||
try {
|
||||
const payload: UserSettings = {
|
||||
const payload: UserSettingsUpdate = {
|
||||
auto_run_enabled: autoRunEnabled.value,
|
||||
run_interval_minutes: parseBoundedInteger(
|
||||
runIntervalMinutes.value,
|
||||
"Check interval (minutes)",
|
||||
MIN_CHECK_INTERVAL_MINUTES,
|
||||
minCheckIntervalMinutes.value,
|
||||
),
|
||||
request_delay_seconds: parseBoundedInteger(
|
||||
requestDelaySeconds.value,
|
||||
"Delay between requests (seconds)",
|
||||
MIN_REQUEST_DELAY_SECONDS,
|
||||
minRequestDelaySeconds.value,
|
||||
),
|
||||
nav_visible_pages: normalizeUserNavVisiblePages(navVisiblePages.value),
|
||||
};
|
||||
|
|
@ -268,29 +301,13 @@ onMounted(() => {
|
|||
<AsyncStateGate :loading="loading" :loading-lines="7" :show-empty="false">
|
||||
<section class="grid gap-4 xl:grid-cols-3">
|
||||
<AppCard class="flex h-full flex-col gap-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<h2 class="text-lg font-semibold text-ink-primary">Automatic Checking</h2>
|
||||
<AppHelpHint text="Controls when Scholarr runs automatic profile checks and how cautiously it scrapes." />
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<h2 class="text-lg font-semibold text-ink-primary">Automatic Checking</h2>
|
||||
<AppHelpHint text="Controls when Scholarr runs automatic profile checks and how cautiously it scrapes." />
|
||||
</div>
|
||||
<p class="text-sm text-secondary">
|
||||
Configure the background checker that looks for new publications on your tracked profiles.
|
||||
</p>
|
||||
<dl class="grid gap-2 text-sm text-secondary">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<dt>Automatic checks</dt>
|
||||
<dd class="font-semibold text-ink-primary">
|
||||
{{ autoRunEnabled ? "Enabled" : "Disabled" }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<dt>Check interval</dt>
|
||||
<dd class="font-semibold text-ink-primary">Every {{ runIntervalMinutes }} min</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<dt>Delay between requests</dt>
|
||||
<dd class="font-semibold text-ink-primary">{{ requestDelaySeconds }} sec</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<AppButton variant="secondary" class="mt-auto self-start" @click="showIngestionModal = true">
|
||||
Edit checking rules
|
||||
</AppButton>
|
||||
|
|
@ -330,15 +347,23 @@ onMounted(() => {
|
|||
@close="showIngestionModal = false"
|
||||
>
|
||||
<form class="grid gap-3" @submit.prevent="onSaveSettings">
|
||||
<AppCheckbox id="auto-run-enabled" v-model="autoRunEnabled" label="Enable automatic background checks" />
|
||||
<AppCheckbox
|
||||
id="auto-run-enabled"
|
||||
v-model="autoRunEnabled"
|
||||
:disabled="!automationAllowed"
|
||||
label="Enable automatic background checks"
|
||||
/>
|
||||
<p v-if="!automationAllowed" class="text-xs text-secondary">
|
||||
Automatic checks are disabled by server safety policy.
|
||||
</p>
|
||||
|
||||
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
Check interval (minutes)
|
||||
<AppHelpHint text="How often Scholarr starts a background update check." />
|
||||
</span>
|
||||
<AppInput id="run-interval" v-model="runIntervalMinutes" type="number" :min="MIN_CHECK_INTERVAL_MINUTES" />
|
||||
<span class="text-xs text-secondary">Minimum: {{ MIN_CHECK_INTERVAL_MINUTES }} minutes.</span>
|
||||
<AppInput id="run-interval" v-model="runIntervalMinutes" type="number" :min="minCheckIntervalMinutes" />
|
||||
<span class="text-xs text-secondary">Minimum: {{ minCheckIntervalMinutes }} minutes.</span>
|
||||
</label>
|
||||
|
||||
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
|
||||
|
|
@ -350,11 +375,18 @@ onMounted(() => {
|
|||
id="request-delay"
|
||||
v-model="requestDelaySeconds"
|
||||
type="number"
|
||||
:min="MIN_REQUEST_DELAY_SECONDS"
|
||||
:min="minRequestDelaySeconds"
|
||||
/>
|
||||
<span class="text-xs text-secondary">Minimum: {{ MIN_REQUEST_DELAY_SECONDS }} seconds.</span>
|
||||
<span class="text-xs text-secondary">Minimum: {{ minRequestDelaySeconds }} seconds.</span>
|
||||
</label>
|
||||
|
||||
<div class="grid gap-1 rounded-lg border border-stroke-default bg-surface-card-muted px-3 py-2 text-xs text-secondary">
|
||||
<p class="font-medium text-ink-primary">Server-enforced scrape safety policy</p>
|
||||
<p>Blocked failures trigger cooldown at {{ blockedFailureThreshold }} failures.</p>
|
||||
<p>Network failures trigger cooldown at {{ networkFailureThreshold }} failures.</p>
|
||||
<p>Blocked cooldown: {{ cooldownBlockedSeconds }}s. Network cooldown: {{ cooldownNetworkSeconds }}s.</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex flex-wrap justify-end gap-2">
|
||||
<AppButton
|
||||
variant="ghost"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue