feat(metadata): add runtime provider throttle status in settings

This commit is contained in:
neon
2026-03-19 05:32:17 -06:00
parent 0cf8eff3b4
commit 6557a2b9e1
13 changed files with 331 additions and 12 deletions
@@ -1,25 +1,32 @@
<script setup lang="ts">
import { onMounted, watch } from 'vue'
import { onMounted, onUnmounted, watch } from 'vue'
import type { FieldPreference, MetadataField, ProviderConfigurations } from '@projectx/types'
import { useLibraries } from '@/features/library/composables/useLibraries'
import ProviderConfigPanel from './components/ProviderConfigPanel.vue'
import GlobalPreferencePanel from './components/GlobalPreferencePanel.vue'
import LibraryPreferencePanel from './components/LibraryPreferencePanel.vue'
import { useProviderConfig } from './composables/useProviderConfig'
import { useProviderThrottleRuntime } from './composables/useProviderThrottleRuntime'
import { useMetadataPreferences } from './composables/useMetadataPreferences'
import { Info } from 'lucide-vue-next'
const { config, statuses, saving: savingProviders, fetchConfig, saveConfig } = useProviderConfig()
const { runtimeByKey, startPolling, stopPolling } = useProviderThrottleRuntime()
const { globalPrefs, libraryPrefs, savingGlobal, savingField, fetchGlobal, saveGlobal, fetchLibrary, saveFieldOverride, resetLibrary } =
useMetadataPreferences()
const { libraries, fetchLibraries } = useLibraries()
onMounted(async () => {
await Promise.all([fetchConfig(), fetchGlobal(), fetchLibraries()])
onMounted(() => {
startPolling()
void Promise.all([fetchConfig(), fetchGlobal(), fetchLibraries()]).catch(() => undefined)
})
const fetchedLibraryIds = new Set<number>()
onUnmounted(() => {
stopPolling()
})
watch(libraries, async (libs) => {
const newLibs = libs.filter((lib) => !fetchedLibraryIds.has(lib.id))
newLibs.forEach((lib) => fetchedLibraryIds.add(lib.id))
@@ -43,6 +50,7 @@ async function onResetLibrary(libraryId: number) {
<ProviderConfigPanel
:config="config"
:statuses="statuses"
:runtime-by-key="runtimeByKey"
:saving="savingProviders"
@save="saveConfig($event as Partial<ProviderConfigurations>)"
/>
@@ -1,18 +1,33 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { Loader2, Save } from 'lucide-vue-next'
import type { ProviderConfigurations, ProviderStatus } from '@projectx/types'
import type { MetadataProviderKey, ProviderConfigurations, ProviderStatus, ProviderThrottleRuntimeState } from '@projectx/types'
import { Badge } from '@/components/ui/badge'
const props = defineProps<{
config: ProviderConfigurations | null
statuses: ProviderStatus[]
runtimeByKey?: Partial<Record<MetadataProviderKey, ProviderThrottleRuntimeState>>
saving: boolean
}>()
const emit = defineEmits<{ save: [patch: Partial<ProviderConfigurations>] }>()
const draft = ref<ProviderConfigurations | null>(null)
const nowMs = ref(Date.now())
let nowTicker: ReturnType<typeof setInterval> | null = null
onMounted(() => {
nowTicker = setInterval(() => {
nowMs.value = Date.now()
}, 1000)
})
onUnmounted(() => {
if (!nowTicker) return
clearInterval(nowTicker)
nowTicker = null
})
watch(
() => props.config,
@@ -77,6 +92,41 @@ function statusFor(key: string) {
return props.statuses.find((s) => s.key === key)
}
function runtimeFor(key: string): ProviderThrottleRuntimeState | undefined {
return props.runtimeByKey?.[key as MetadataProviderKey]
}
function throttleSecondsLeft(key: string): number | null {
const state = runtimeFor(key)
if (!state?.throttled || !state.throttledUntil) return null
const remaining = Math.ceil((Date.parse(state.throttledUntil) - nowMs.value) / 1000)
return remaining > 0 ? remaining : null
}
function isThrottled(key: string): boolean {
return throttleSecondsLeft(key) !== null
}
function throttleMessage(key: string): string | null {
const seconds = throttleSecondsLeft(key)
if (seconds === null) return null
return `Retry in ${formatDuration(seconds)}`
}
function formatDuration(totalSeconds: number): string {
if (totalSeconds >= 3600) {
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`
}
if (totalSeconds >= 60) {
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return `${minutes}m ${seconds}s`
}
return `${totalSeconds}s`
}
function save() {
if (!draft.value) return
emit('save', draft.value)
@@ -111,6 +161,13 @@ function save() {
<Badge v-if="!statusFor(row.key)?.configured" variant="destructive" class="h-4 px-1.5 text-[9px] font-bold uppercase tracking-wide">
Setup Required
</Badge>
<Badge
v-else-if="isThrottled(row.key)"
variant="outline"
class="h-4 px-1.5 text-[9px] font-bold uppercase tracking-wide text-amber-600 border-amber-500/30 bg-amber-500/5"
>
Throttled
</Badge>
<Badge
v-else
variant="outline"
@@ -123,6 +180,9 @@ function save() {
<p v-if="row.hint" class="settings-hint max-w-sm">
{{ row.hint }}
</p>
<p v-if="throttleMessage(row.key)" class="text-xs text-amber-600">
{{ throttleMessage(row.key) }}
</p>
</div>
<!-- Right: Config & Toggle -->
@@ -0,0 +1,61 @@
import { ref } from 'vue'
import type { MetadataProviderKey, ProviderThrottleRuntimeSnapshot, ProviderThrottleRuntimeState } from '@projectx/types'
import { api } from '@/lib/api'
export function useProviderThrottleRuntime() {
const snapshot = ref<ProviderThrottleRuntimeSnapshot | null>(null)
const runtimeByKey = ref<Partial<Record<MetadataProviderKey, ProviderThrottleRuntimeState>>>({})
const loading = ref(false)
let timer: ReturnType<typeof setInterval> | null = null
let inFlight: Promise<void> | null = null
function applySnapshot(next: ProviderThrottleRuntimeSnapshot) {
snapshot.value = next
runtimeByKey.value = Object.fromEntries(next.providers.map((p) => [p.key, p])) as Partial<
Record<MetadataProviderKey, ProviderThrottleRuntimeState>
>
}
async function fetchRuntime() {
if (inFlight) return inFlight
inFlight = (async () => {
loading.value = snapshot.value === null
try {
const res = await api('/api/v1/metadata-fetch/providers/runtime')
if (!res.ok) return
const data = (await res.json()) as ProviderThrottleRuntimeSnapshot
applySnapshot(data)
} catch {
// Best-effort runtime indicator; fail silently and retry on next poll.
} finally {
loading.value = false
inFlight = null
}
})()
return inFlight
}
function startPolling(intervalMs = 15_000) {
stopPolling()
void fetchRuntime()
timer = setInterval(() => {
void fetchRuntime()
}, intervalMs)
}
function stopPolling() {
if (!timer) return
clearInterval(timer)
timer = null
}
return {
snapshot,
runtimeByKey,
loading,
fetchRuntime,
startPolling,
stopPolling,
}
}