Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
94b73e0
feat(frontend): offline-aware API queue for retryable actions (#241)
Shikhar-404exe May 26, 2026
f584ce9
fix(offline-queue): disable auto-replay, require explicit user confir…
Shikhar-404exe May 29, 2026
1f81f15
fix(test): remove schedule_interval from WorkflowCreatePayload to mat…
Shikhar-404exe May 29, 2026
e5103c5
fix(offline-queue): add pre-flight conflict checks before replaying m…
Shikhar-404exe May 31, 2026
ef17ee4
fix(offline-queue): add safety boundaries, persist fallback, reconnec…
Shikhar-404exe Jun 1, 2026
039cbda
fix(offline-queue): remove startTask from retryable actions (#241)
Shikhar-404exe Jun 2, 2026
b6d487b
fix: resolve merge conflicts for App.tsx and api.ts with main
Shikhar-404exe Jun 2, 2026
d7df14e
Merge upstream/main into 241, resolve api.ts and App.tsx conflicts
Shikhar-404exe Jun 2, 2026
2d76cc7
Merge branch 'main' into 241
utksh1 Jun 2, 2026
c71e9d4
Merge branch 'main' into 241
Shikhar-404exe Jun 5, 2026
736d7a8
fix(api): remove duplicate fetch block that broke production build
Shikhar-404exe Jun 5, 2026
605684f
fix: reset autoReplay in beforeEach to prevent cross-test leakage
Shikhar-404exe Jun 5, 2026
87c55f5
fix(test): add vi.clearAllMocks and defensive clear/setAutoReplay
Shikhar-404exe Jun 5, 2026
58a4d35
fix: store X-Api-Key in queued actions and pass it on replay
Shikhar-404exe Jun 6, 2026
1b0b0b0
Merge branch 'main' into 241
Shikhar-404exe Jun 6, 2026
03842e1
fix: update tests and add missing dep from upstream merge
Shikhar-404exe Jun 6, 2026
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
20 changes: 19 additions & 1 deletion frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@
"e2e:ui": "playwright test --ui"
},
"dependencies": {
"@tanstack/react-virtual": "^3.14.2",
"@hugeicons/core-free-icons": "^4.1.4",
"@hugeicons/react": "^1.1.6",
"@tanstack/react-virtual": "^3.14.2",
"framer-motion": "^12.38.0",
"html2canvas": "^1.4.1",
"jspdf": "^4.2.1",
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import ApiKeySetupScreen from './components/ApiKeySetupScreen'
import { ThemeProvider } from './components/ThemeContext'
import { ToastProvider } from './components/ToastContext'
import { I18nProvider } from './components/I18nContext'
import { OfflineQueueProvider } from './components/OfflineQueueContext'
import { routes } from './routes'
import { AUTH_REQUIRED_EVENT, getStoredApiKey } from './api'

Expand Down Expand Up @@ -55,15 +56,15 @@ export default function App() {
<I18nProvider>
<ToastProvider>
{needsKey ? (
// Render ONLY the setup screen — no page routes are mounted, so no
// API calls can fire and spam 401 failures before the key is saved.
<ApiKeySetupScreen onSaved={() => setNeedsKey(false)} />
) : (
<OfflineQueueProvider>
<Router>
<AppShell>
<AppRoutes />
</AppShell>
</Router>
</OfflineQueueProvider>
)}
</ToastProvider>
</I18nProvider>
Expand Down
52 changes: 43 additions & 9 deletions 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 All @@ -6,11 +8,9 @@ function resolveApiBase(): string {
const isLocalHost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
const isViteDevServer = window.location.port === '5173'

// For localhost preview/static modes (e.g. :8080), call backend directly.
if (isLocalHost && !isViteDevServer) return 'http://127.0.0.1:8000/api/v1'
}

// Default for Vite dev server where /api is proxied to backend.
return '/api/v1'
}

Expand Down Expand Up @@ -42,6 +42,7 @@ export interface PluginFieldSchema {
options?: PluginFieldOption[]
validation?: Record<string, unknown>
}

export interface PluginAvailability {
runnable: boolean
missing_binaries: string[]
Expand Down Expand Up @@ -310,13 +311,43 @@ function getApiKey(): string | null {
/** Fired on the window when any API request receives HTTP 401. */
export const AUTH_REQUIRED_EVENT = 'secuscan:auth-required'

async function request<T>(path: string, init?: RequestInit): Promise<T> {
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), 10000)
interface RequestOptions extends RequestInit {
retryable?: boolean
label?: string
actionType?: offlineQueue.ActionType
}

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

async function request<T>(path: string, init?: RequestOptions): Promise<T> {
const method = (init?.method || 'GET').toUpperCase()
const isSafe = method === 'GET' || method === 'HEAD'
const apiKey = getApiKey()
const authHeaders: Record<string, string> = apiKey ? { 'X-Api-Key': apiKey } : {}

if (!offlineQueue.isOnline() && !isSafe && init?.retryable) {
offlineQueue.enqueue({
url: `${API_BASE}${path}`,
method,
headers: {
...authHeaders,
...(init?.headers as Record<string, string> | undefined),
},
body: init?.body as string | undefined,
maxRetries: 3,
label: init?.label || `${method} ${path}`,
actionType: init?.actionType,
})
throw new OfflineQueueError(`Queued for replay when online: ${method} ${path}`)
}
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), 10000)

try {
const response = await fetch(`${API_BASE}${path}`, {
...init,
Expand All @@ -328,8 +359,6 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
})

if (response.status === 401) {
// Notify the app so it can show the API-key setup UI without every
// caller needing to handle auth independently.
window.dispatchEvent(new CustomEvent(AUTH_REQUIRED_EVENT))
throw new Error('AUTH_REQUIRED')
}
Expand Down Expand Up @@ -363,7 +392,6 @@ export function getDashboardSummary() {
return request('/dashboard/summary')
}


export function getFindings() {
return request<FindingsResponse>('/findings')
}
Expand All @@ -372,7 +400,6 @@ export function getFindingGroups() {
return request<{ groups: FindingGroup[]; total: number }>('/finding-groups')
}


export function getReports() {
return request('/reports')
}
Expand Down Expand Up @@ -541,6 +568,7 @@ export function streamTask(taskId: string, onEvent: (ev: MessageEvent) => void)
es.onerror = () => {}
return es
}

export interface WorkflowStep {
plugin_id: string
inputs: Record<string, unknown>
Expand Down Expand Up @@ -624,6 +652,9 @@ export async function createWorkflow(data: WorkflowCreatePayload): Promise<Workf
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
retryable: true,
label: 'Create Workflow',
actionType: 'createWorkflow',
})
return normalizeWorkflow(workflow)
}
Expand All @@ -647,6 +678,9 @@ export async function updateWorkflow(workflowId: string, data: WorkflowUpdatePay
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
retryable: true,
label: 'Update Workflow',
actionType: 'updateWorkflow',
})
return normalizeWorkflow(workflow)
}
Expand Down
8 changes: 0 additions & 8 deletions frontend/src/components/ApiKeySetupModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,6 @@ interface Props {
onSaved: () => void
}

/**
* First-run / 401 modal.
*
* Shown when the app receives HTTP 401 or detects no stored API key.
* The operator reads the key from `backend/data/.api_key`, pastes it here,
* and clicks Save. The key is written only to localStorage (secuscan_api_key);
* it is never sent to any server other than as the X-Api-Key request header.
*/
export default function ApiKeySetupModal({ onSaved }: Props) {
const [key, setKey] = useState('')
const [visible, setVisible] = useState(false)
Expand Down
12 changes: 0 additions & 12 deletions frontend/src/components/ApiKeySetupScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,6 @@ interface Props {
onSaved: () => void
}

/**
* Full-page first-run / 401 gate.
*
* Replaces the entire app until the operator provides the API key.
* Because this component renders instead of the normal route tree, no page
* component mounts and no protected API call fires before the key is saved.
*
* The operator reads the key from the server key file and pastes it here.
* The key is stored only in localStorage under `secuscan_api_key` and sent
* exclusively as the `X-Api-Key` request header — never logged or stored
* server-side.
*/
export default function ApiKeySetupScreen({ onSaved }: Props) {
const [key, setKey] = useState('')
const [error, setError] = useState('')
Expand Down
Loading
Loading