diff --git a/frontend/src/composables/useApi.js b/frontend/src/composables/useApi.js new file mode 100644 index 0000000..cc88c0d --- /dev/null +++ b/frontend/src/composables/useApi.js @@ -0,0 +1,50 @@ +// Thin fetch wrapper. Centralized so every call has consistent error handling +// and json parsing. Errors throw an ApiError with the response status + body. + +export class ApiError extends Error { + constructor(message, status, body) { + super(message) + this.name = 'ApiError' + this.status = status + this.body = body + } +} + +async function request(method, url, { body, params, signal } = {}) { + let fullUrl = url + if (params) { + const search = new URLSearchParams() + for (const [k, v] of Object.entries(params)) { + if (v !== undefined && v !== null) search.set(k, String(v)) + } + const qs = search.toString() + if (qs) fullUrl += (url.includes('?') ? '&' : '?') + qs + } + + const headers = {} + const init = { method, headers, signal } + if (body !== undefined) { + headers['Content-Type'] = 'application/json' + init.body = JSON.stringify(body) + } + + const r = await fetch(fullUrl, init) + const text = await r.text() + let parsed + try { parsed = text ? JSON.parse(text) : null } catch { parsed = text } + + if (!r.ok) { + const msg = (parsed && parsed.error) || `${r.status} ${r.statusText}` + throw new ApiError(msg, r.status, parsed) + } + return parsed +} + +export function useApi() { + return { + get: (url, opts) => request('GET', url, opts), + post: (url, opts) => request('POST', url, opts), + patch: (url, opts) => request('PATCH', url, opts), + delete: (url, opts) => request('DELETE', url, opts) + } +} diff --git a/frontend/src/stores/import.js b/frontend/src/stores/import.js new file mode 100644 index 0000000..1cd7ccc --- /dev/null +++ b/frontend/src/stores/import.js @@ -0,0 +1,104 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { useApi } from '../composables/useApi.js' + +export const useImportStore = defineStore('import', () => { + const api = useApi() + + const settings = ref(null) + const settingsLoading = ref(false) + const settingsError = ref(null) + + const activeBatch = ref(null) + const statusLoading = ref(false) + + const tasks = ref([]) + const tasksNextCursor = ref(null) + const tasksLoading = ref(false) + const tasksFilter = ref({ status: null, limit: 50 }) + + const triggerError = ref(null) + + async function loadSettings() { + settingsLoading.value = true + settingsError.value = null + try { + settings.value = await api.get('/api/settings/import') + } catch (e) { + settingsError.value = e.message + } finally { + settingsLoading.value = false + } + } + + async function patchSettings(patch) { + settingsError.value = null + try { + settings.value = await api.patch('/api/settings/import', { body: patch }) + } catch (e) { + settingsError.value = e.message + throw e + } + } + + async function refreshStatus() { + statusLoading.value = true + try { + const body = await api.get('/api/import/status') + activeBatch.value = body.active_batch + } finally { + statusLoading.value = false + } + } + + async function triggerScan() { + triggerError.value = null + try { + await api.post('/api/import/trigger', { body: { mode: 'quick' } }) + await refreshStatus() + } catch (e) { + triggerError.value = e.message + throw e + } + } + + async function loadTasks(reset = true) { + tasksLoading.value = true + try { + const params = { limit: tasksFilter.value.limit } + if (tasksFilter.value.status) params.status = tasksFilter.value.status + if (!reset && tasksNextCursor.value) params.cursor = tasksNextCursor.value + const body = await api.get('/api/import/tasks', { params }) + tasks.value = reset ? body.tasks : [...tasks.value, ...body.tasks] + tasksNextCursor.value = body.next_cursor + } finally { + tasksLoading.value = false + } + } + + function setStatusFilter(status) { + tasksFilter.value.status = status + } + + async function retryFailed() { + await api.post('/api/import/retry-failed') + await loadTasks(true) + } + + async function clearCompleted(ageDays = 0) { + await api.post('/api/import/clear-completed', { body: { age_days: ageDays } }) + await loadTasks(true) + } + + const hasMore = computed(() => tasksNextCursor.value !== null) + + return { + settings, settingsLoading, settingsError, + activeBatch, statusLoading, + tasks, tasksLoading, tasksFilter, hasMore, + triggerError, + loadSettings, patchSettings, + refreshStatus, triggerScan, + loadTasks, setStatusFilter, retryFailed, clearCompleted + } +}) diff --git a/frontend/src/stores/system.js b/frontend/src/stores/system.js index 4412640..b3f772f 100644 --- a/frontend/src/stores/system.js +++ b/frontend/src/stores/system.js @@ -1,18 +1,30 @@ import { defineStore } from 'pinia' import { ref } from 'vue' +import { useApi } from '../composables/useApi.js' export const useSystemStore = defineStore('system', () => { + const api = useApi() const healthy = ref(null) // null=unknown, true=ok, false=down + const stats = ref(null) + const statsLoading = ref(false) async function refreshHealth() { try { - const r = await fetch('/api/health') - const body = await r.json() - healthy.value = r.ok && body.status === 'ok' + const body = await api.get('/api/health') + healthy.value = body.status === 'ok' } catch { healthy.value = false } } - return { healthy, refreshHealth } + async function refreshStats() { + statsLoading.value = true + try { + stats.value = await api.get('/api/system/stats') + } finally { + statsLoading.value = false + } + } + + return { healthy, stats, statsLoading, refreshHealth, refreshStats } })