Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import Workflows from './pages/Workflows'
import { ThemeProvider } from './components/ThemeContext'
import { ToastProvider, ToastContainer } from './components/ToastContext'
import { I18nProvider } from './components/I18nContext'
import { OfflineQueueProvider } from './components/OfflineQueueContext'
import { routes } from './routes'

export function AppRoutes() {
Expand All @@ -39,11 +40,13 @@ export default function App() {
<ThemeProvider>
<I18nProvider>
<ToastProvider>
<OfflineQueueProvider>
<Router>
<AppShell>
<AppRoutes />
</AppShell>
</Router>
</OfflineQueueProvider>
</ToastProvider>
</I18nProvider>
</ThemeProvider>
Expand Down
49 changes: 48 additions & 1 deletion frontend/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import * as offlineQueue from './services/offlineQueue'

function resolveApiBase(): string {
const configured = (import.meta as any).env.VITE_API_BASE
if (configured) return configured
Expand Down Expand Up @@ -83,7 +85,27 @@ export interface TaskStartResponse {
stream_url: string
}

async function request<T>(path: string, init?: RequestInit): Promise<T> {
interface RequestOptions extends RequestInit {
retryable?: boolean
label?: string
}

async function request<T>(path: string, init?: RequestOptions): Promise<T> {
const method = (init?.method || 'GET').toUpperCase()
const isSafe = method === 'GET' || method === 'HEAD'

if (!offlineQueue.isOnline() && !isSafe && init?.retryable) {
offlineQueue.enqueue({
url: `${API_BASE}${path}`,
method,
headers: init?.headers as Record<string, string> | undefined,
body: init?.body as string | undefined,
maxRetries: 3,
label: init?.label || `${method} ${path}`,
})
throw new OfflineQueueError(`Queued for replay when online: ${method} ${path}`)
}

const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), 10000)

Expand All @@ -98,6 +120,13 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
return response.json()
}

export class OfflineQueueError extends Error {
constructor(message: string) {
super(message)
this.name = 'OfflineQueueError'
}
}

export function getHealth() {
return request('/health')
}
Expand Down Expand Up @@ -142,12 +171,16 @@ export function startTask(plugin_id: string, inputs: Record<string, unknown>, co
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plugin_id, inputs, consent_granted, preset }),
retryable: true,
label: 'Start Scan',
})
}

export function deleteTask(taskId: string) {
return request<{ task_id: string; deleted: boolean }>(`/task/${taskId}`, {
method: 'DELETE',
retryable: true,
label: 'Delete Task',
})
}

Expand All @@ -156,19 +189,25 @@ export function bulkDeleteTasks(taskIds: string[]) {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(taskIds),
retryable: true,
label: 'Bulk Delete Tasks',
})
}

export function clearAllTasks() {
return request<{ cleared: boolean; message: string }>('/tasks/clear', {
method: 'DELETE',
retryable: true,
label: 'Clear All Tasks',
})
}

export function cancelTask(taskId: string) {
return request(`/task/${taskId}/cancel`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
retryable: true,
label: 'Cancel Task',
})
}

Expand Down Expand Up @@ -218,13 +257,17 @@ export function createWorkflow(data: WorkflowCreatePayload): Promise<Workflow> {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
retryable: true,
label: 'Create Workflow',
})
}

export function runWorkflow(workflowId: string): Promise<{ queued_task_ids: string[] }> {
return request<{ queued_task_ids: string[] }>(`/workflows/${workflowId}/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
retryable: true,
label: 'Run Workflow',
})
}

Expand All @@ -233,11 +276,15 @@ export function updateWorkflow(workflowId: string, data: WorkflowUpdatePayload):
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
retryable: true,
label: 'Update Workflow',
})
}

export function deleteWorkflow(workflowId: string): Promise<{ deleted: boolean }> {
return request<{ deleted: boolean }>(`/workflows/${workflowId}`, {
method: 'DELETE',
retryable: true,
label: 'Delete Workflow',
})
}
71 changes: 71 additions & 0 deletions frontend/src/components/OfflineQueueContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'
import * as offlineQueue from '../services/offlineQueue'

interface OfflineQueueContextType {
isOnline: boolean
pendingCount: number
queue: offlineQueue.QueuedAction[]
enqueue: (action: Omit<offlineQueue.QueuedAction, 'id' | 'timestamp' | 'retryCount'>) => offlineQueue.QueuedAction
retryAll: () => Promise<number>
retry: (id: string) => Promise<boolean>
remove: (id: string) => void
clear: () => void
}

const OfflineQueueContext = createContext<OfflineQueueContextType | undefined>(undefined)

export function useOfflineQueue() {
const context = useContext(OfflineQueueContext)
if (!context) throw new Error('useOfflineQueue must be used within OfflineQueueProvider')
return context
}

export function OfflineQueueProvider({ children }: { children: ReactNode }) {
const [isOnline, setIsOnline] = useState(offlineQueue.isOnline())
const [, setTick] = useState(0)

useEffect(() => {
const onOnline = () => setIsOnline(true)
const onOffline = () => setIsOnline(false)
window.addEventListener('online', onOnline)
window.addEventListener('offline', onOffline)
return () => {
window.removeEventListener('online', onOnline)
window.removeEventListener('offline', onOffline)
}
}, [])

useEffect(() => {
const unsub = offlineQueue.subscribe(() => setTick((t) => t + 1))
return unsub
}, [])

const enqueue = useCallback(
(action: Omit<offlineQueue.QueuedAction, 'id' | 'timestamp' | 'retryCount'>) => {
return offlineQueue.enqueue(action)
},
[],
)

const retryAll = useCallback(() => offlineQueue.retryAll(), [])
const retry = useCallback((id: string) => offlineQueue.retry(id), [])
const remove = useCallback((id: string) => offlineQueue.remove(id), [])
const clear = useCallback(() => offlineQueue.clear(), [])

return (
<OfflineQueueContext.Provider
value={{
isOnline,
pendingCount: offlineQueue.getQueue().length,
queue: offlineQueue.getQueue(),
enqueue,
retryAll,
retry,
remove,
clear,
}}
>
{children}
</OfflineQueueContext.Provider>
)
}
76 changes: 76 additions & 0 deletions frontend/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'
import { NavLink } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import { routes } from '../routes'
import { useOfflineQueue } from './OfflineQueueContext'

interface NavItemProps {
to: string;
Expand Down Expand Up @@ -172,6 +173,7 @@ export default function Sidebar() {

{/* Bottom Actions */}
<div className="p-4 mt-auto border-t border-accent-silver/5 bg-bg-primary/30 backdrop-blur-md">
<OfflineQueueIndicator isExpanded={isExpanded} />
<NavItem to={routes.settings} icon="settings" label="Settings" isExpanded={isExpanded} />
<button
onClick={(e) => {
Expand All @@ -188,3 +190,77 @@ export default function Sidebar() {
</motion.aside>
)
}

function OfflineQueueIndicator({ isExpanded }: { isExpanded: boolean }) {
const { isOnline, pendingCount, queue, retryAll, remove } = useOfflineQueue()
const [showDropdown, setShowDropdown] = useState(false)

if (isOnline && pendingCount === 0) return null

return (
<div className="relative mb-2">
<button
type="button"
onClick={(e) => { e.stopPropagation(); setShowDropdown(!showDropdown) }}
className={`flex items-center w-full transition-all duration-300 group ${
isExpanded ? 'gap-3 px-5 py-2.5 mx-2 rounded-lg' : 'justify-center py-3 px-2 mx-2 rounded-lg'
} ${!isOnline ? 'text-rag-amber' : 'text-rag-blue'}`}
title={!isExpanded ? `${pendingCount} pending` : undefined}
>
<span className="material-symbols-outlined text-[20px] shrink-0 z-10 relative">
{!isOnline ? 'cloud_off' : 'cloud_sync'}
{pendingCount > 0 && (
<span className="absolute -top-1 -right-1 w-2 h-2 bg-rag-amber rounded-full animate-pulse" />
)}
</span>
<AnimatePresence>
{isExpanded && (
<motion.span
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
className="text-[11px] font-bold tracking-[0.15em] uppercase whitespace-nowrap z-10 flex-1 text-left"
>
{!isOnline ? 'Offline' : `${pendingCount} Pending`}
</motion.span>
)}
</AnimatePresence>
{isExpanded && pendingCount > 0 && (
<span className="text-[9px] font-black text-rag-amber bg-rag-amber/10 px-1.5 py-0.5 rounded z-10">
{pendingCount}
</span>
)}
</button>

{showDropdown && pendingCount > 0 && (
<div
className="absolute bottom-full left-0 right-0 mb-1 mx-2 bg-secondary border border-accent-silver/10 rounded-lg shadow-xl z-50 max-h-48 overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="p-2 space-y-1">
{queue.map((action) => (
<div key={action.id} className="flex items-center justify-between gap-2 px-2 py-1 text-[10px] text-silver/80">
<span className="truncate flex-1">{action.label || action.url}</span>
<button
type="button"
onClick={() => remove(action.id)}
className="text-silver/40 hover:text-rag-red"
aria-label={`Remove ${action.label || 'action'} from queue`}
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
</div>
))}
<button
type="button"
onClick={(e) => { e.stopPropagation(); retryAll() }}
className="w-full mt-1 px-2 py-1.5 text-[10px] font-bold uppercase tracking-wider bg-rag-blue/20 text-rag-blue hover:bg-rag-blue/30 rounded"
>
Retry All
</button>
</div>
</div>
)}
</div>
)
}
Loading
Loading