scholarr/frontend/src/components/patterns/ScrapeSafetyBanner.vue
Justin Visser c85fa08b7b fix(frontend): add cooldown tooltips and fix settings tab initial load
Add AppHelpHint tooltips to ScrapeSafetyBadge and ScrapeSafetyBanner
showing rate-limit explanation, cooldown reason, and recommended action.
When no cooldown is active, show a simple "ready" message.

Fix settings tabs not loading on initial navigation by deferring
onMounted load via nextTick, and using flush:'post' on the section
watcher so template refs are attached before load() runs.
2026-03-07 16:50:35 +01:00

112 lines
3 KiB
Vue

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import AppAlert from "@/components/ui/AppAlert.vue";
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import {
formatCooldownCountdown,
type ScrapeSafetyState,
} from "@/features/safety";
const props = withDefaults(
defineProps<{
safetyState: ScrapeSafetyState;
manualRunAllowed?: boolean;
}>(),
{
manualRunAllowed: true,
},
);
const now = ref(Date.now());
let timer: ReturnType<typeof setInterval> | null = null;
const cooldownUntilMs = computed(() => {
if (!props.safetyState.cooldown_until) {
return null;
}
const parsed = new Date(props.safetyState.cooldown_until).getTime();
if (Number.isNaN(parsed)) {
return null;
}
return parsed;
});
const cooldownRemainingSeconds = computed(() => {
if (!props.safetyState.cooldown_active) {
return 0;
}
if (cooldownUntilMs.value !== null) {
return Math.max(0, Math.floor((cooldownUntilMs.value - now.value) / 1000));
}
return Math.max(0, props.safetyState.cooldown_remaining_seconds || 0);
});
const isCooldownBlocked = computed(() => props.safetyState.cooldown_active && cooldownRemainingSeconds.value > 0);
const isPolicyBlocked = computed(() => !props.manualRunAllowed);
const isVisible = computed(() => isPolicyBlocked.value || isCooldownBlocked.value);
const tone = computed(() => {
if (isPolicyBlocked.value) {
return "warning" as const;
}
if (props.safetyState.cooldown_reason === "blocked_failure_threshold_exceeded") {
return "danger" as const;
}
return "warning" as const;
});
const title = computed(() => {
if (isPolicyBlocked.value) {
return "Manual checks disabled by server policy";
}
return props.safetyState.cooldown_reason_label || "Safety cooldown active";
});
const detailText = computed(() => {
if (isPolicyBlocked.value) {
return "This server currently disallows manual checks. Automatic checks may still run if enabled by policy.";
}
const countdown = formatCooldownCountdown(cooldownRemainingSeconds.value);
return `Manual and scheduled checks are paused for ${countdown}.`;
});
const actionText = computed(() => {
if (isPolicyBlocked.value) {
return "Ask an admin to enable manual checks in server environment policy.";
}
return props.safetyState.recommended_action;
});
const bannerHintText = computed(() => {
return "Google Scholar rate-limits automated requests. The cooldown pauses scraping to avoid your IP being blocked.";
});
onMounted(() => {
timer = setInterval(() => {
now.value = Date.now();
}, 1000);
});
onBeforeUnmount(() => {
if (timer !== null) {
clearInterval(timer);
timer = null;
}
});
</script>
<template>
<AppAlert v-if="isVisible" :tone="tone">
<template #title>
<span class="inline-flex items-center gap-1">
{{ title }}
<AppHelpHint :text="bannerHintText" />
</span>
</template>
<p>{{ detailText }}</p>
<p v-if="actionText" class="text-secondary">{{ actionText }}</p>
</AppAlert>
</template>