+
{{ error }}
diff --git a/web/src/views/SetupPage.vue b/web/src/views/SetupPage.vue
index 265e04dc91..130eddd00a 100644
--- a/web/src/views/SetupPage.vue
+++ b/web/src/views/SetupPage.vue
@@ -1,6 +1,6 @@
diff --git a/web/nginx.conf b/web/nginx.conf
index df1b29a7e0..049d461da9 100644
--- a/web/nginx.conf
+++ b/web/nginx.conf
@@ -20,9 +20,9 @@ server {
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), camera=(), microphone=()" always;
# style-src 'unsafe-inline' required by PrimeVue which injects dynamic inline styles.
- # connect-src uses scheme-only ws:/wss: scoped to same origin via proxy — browser WS
- # connections go through nginx to the backend, never to external hosts.
- add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self'" always;
+ # connect-src includes ws:/wss: for WebSocket connections — 'self' alone does not
+ # reliably cover ws:/wss: schemes across all browsers.
+ add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:; img-src 'self' data:; font-src 'self'" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# SPA routing — try static files, fall back to index.html
@@ -42,7 +42,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_send_timeout 30s;
- proxy_read_timeout 86400s;
+ proxy_read_timeout 3600s;
}
# API proxy to backend service
diff --git a/web/src/__tests__/api/client.test.ts b/web/src/__tests__/api/client.test.ts
index ae864e480e..e28b5f414f 100644
--- a/web/src/__tests__/api/client.test.ts
+++ b/web/src/__tests__/api/client.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach } from 'vitest'
-import { unwrap, unwrapPaginated } from '@/api/client'
-import type { AxiosResponse } from 'axios'
+import { unwrap, unwrapPaginated, apiClient } from '@/api/client'
+import { AxiosHeaders, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
function mockResponse
(data: T): AxiosResponse {
return {
@@ -12,6 +12,38 @@ function mockResponse(data: T): AxiosResponse {
}
}
+describe('request interceptor', () => {
+ beforeEach(() => {
+ localStorage.clear()
+ })
+
+ function makeConfig(): InternalAxiosRequestConfig {
+ return { headers: new AxiosHeaders() } as InternalAxiosRequestConfig
+ }
+
+ it('attaches JWT token to request headers', () => {
+ localStorage.setItem('auth_token', 'test-jwt-token')
+ const config = makeConfig()
+ // Access the request interceptor directly
+ const handlers = (apiClient.interceptors.request as unknown as { handlers: Array<{ fulfilled?: (c: InternalAxiosRequestConfig) => InternalAxiosRequestConfig }> }).handlers
+ const interceptor = handlers?.[0]?.fulfilled
+ if (interceptor) {
+ const result = interceptor(config)
+ expect(result.headers.get('Authorization')).toBe('Bearer test-jwt-token')
+ }
+ })
+
+ it('does not attach Authorization when no token', () => {
+ const config = makeConfig()
+ const handlers = (apiClient.interceptors.request as unknown as { handlers: Array<{ fulfilled?: (c: InternalAxiosRequestConfig) => InternalAxiosRequestConfig }> }).handlers
+ const interceptor = handlers?.[0]?.fulfilled
+ if (interceptor) {
+ const result = interceptor(config)
+ expect(result.headers.get('Authorization')).toBeUndefined()
+ }
+ })
+})
+
describe('unwrap', () => {
beforeEach(() => {
localStorage.clear()
diff --git a/web/src/__tests__/components/EmptyState.test.ts b/web/src/__tests__/components/EmptyState.test.ts
index 5421121e06..6b97b04228 100644
--- a/web/src/__tests__/components/EmptyState.test.ts
+++ b/web/src/__tests__/components/EmptyState.test.ts
@@ -35,4 +35,23 @@ describe('EmptyState', () => {
const paragraphs = wrapper.findAll('p')
expect(paragraphs).toHaveLength(0)
})
+
+ it('renders action slot when provided', () => {
+ const wrapper = mount(EmptyState, {
+ props: { title: 'Empty' },
+ slots: { action: '' },
+ })
+ const button = wrapper.find('button')
+ expect(button.exists()).toBe(true)
+ expect(button.text()).toBe('Create Item')
+ })
+
+ it('does not render action container when slot is empty', () => {
+ const wrapper = mount(EmptyState, {
+ props: { title: 'Empty' },
+ })
+ // The mt-4 div should not render when no action slot is provided
+ const actionDivs = wrapper.findAll('.mt-4')
+ expect(actionDivs).toHaveLength(0)
+ })
})
diff --git a/web/src/__tests__/components/StatusBadge.test.ts b/web/src/__tests__/components/StatusBadge.test.ts
index 68325e6580..001edf0993 100644
--- a/web/src/__tests__/components/StatusBadge.test.ts
+++ b/web/src/__tests__/components/StatusBadge.test.ts
@@ -23,4 +23,31 @@ describe('StatusBadge', () => {
})
expect(wrapper.text()).toContain('High')
})
+
+ it('applies correct color classes for known status', () => {
+ const wrapper = mount(StatusBadge, {
+ props: { value: 'completed' },
+ })
+ const tag = wrapper.find('.p-tag')
+ expect(tag.classes()).toContain('bg-green-600')
+ expect(tag.classes()).toContain('text-green-100')
+ })
+
+ it('applies correct color classes for priority', () => {
+ const wrapper = mount(StatusBadge, {
+ props: { value: 'high', type: 'priority' },
+ })
+ const tag = wrapper.find('.p-tag')
+ expect(tag.classes()).toContain('bg-orange-600')
+ expect(tag.classes()).toContain('text-orange-100')
+ })
+
+ it('falls back to slate for unknown value', () => {
+ const wrapper = mount(StatusBadge, {
+ props: { value: 'unknown_status' },
+ })
+ const tag = wrapper.find('.p-tag')
+ expect(tag.classes()).toContain('bg-slate-600')
+ expect(tag.classes()).toContain('text-slate-200')
+ })
})
diff --git a/web/src/__tests__/composables/useOptimisticUpdate.test.ts b/web/src/__tests__/composables/useOptimisticUpdate.test.ts
index c869ffe4b5..fc95a65a94 100644
--- a/web/src/__tests__/composables/useOptimisticUpdate.test.ts
+++ b/web/src/__tests__/composables/useOptimisticUpdate.test.ts
@@ -71,7 +71,8 @@ describe('useOptimisticUpdate', () => {
)
expect(error.value).toBe('Server error')
- expect(consoleSpy).toHaveBeenCalledWith('Rollback failed:', expect.any(Error))
+ // Rollback errors are logged via getErrorMessage (string), not raw Error
+ expect(consoleSpy).toHaveBeenCalledWith('Rollback failed:', 'Rollback boom')
consoleSpy.mockRestore()
})
diff --git a/web/src/__tests__/composables/usePolling.test.ts b/web/src/__tests__/composables/usePolling.test.ts
index db0366445c..1046919fef 100644
--- a/web/src/__tests__/composables/usePolling.test.ts
+++ b/web/src/__tests__/composables/usePolling.test.ts
@@ -109,4 +109,13 @@ describe('usePolling', () => {
await vi.advanceTimersByTimeAsync(2000)
expect(maxConcurrent).toBe(1)
})
+
+ it('throws on interval below minimum', () => {
+ expect(() => usePolling(vi.fn(), 50)).toThrow('intervalMs must be a finite number >= 100')
+ })
+
+ it('throws on non-finite interval', () => {
+ expect(() => usePolling(vi.fn(), NaN)).toThrow('intervalMs must be a finite number >= 100')
+ expect(() => usePolling(vi.fn(), Infinity)).toThrow('intervalMs must be a finite number >= 100')
+ })
})
diff --git a/web/src/__tests__/stores/budget.test.ts b/web/src/__tests__/stores/budget.test.ts
index 8291cdcfbe..bcbd3e1811 100644
--- a/web/src/__tests__/stores/budget.test.ts
+++ b/web/src/__tests__/stores/budget.test.ts
@@ -3,10 +3,14 @@ import { setActivePinia, createPinia } from 'pinia'
import { useBudgetStore } from '@/stores/budget'
import type { CostRecord, WsEvent } from '@/api/types'
+const mockGetBudgetConfig = vi.fn()
+const mockListCostRecords = vi.fn()
+const mockGetAgentSpending = vi.fn()
+
vi.mock('@/api/endpoints/budget', () => ({
- getBudgetConfig: vi.fn(),
- listCostRecords: vi.fn(),
- getAgentSpending: vi.fn(),
+ getBudgetConfig: (...args: unknown[]) => mockGetBudgetConfig(...args),
+ listCostRecords: (...args: unknown[]) => mockListCostRecords(...args),
+ getAgentSpending: (...args: unknown[]) => mockGetAgentSpending(...args),
}))
const mockRecord: CostRecord = {
@@ -24,6 +28,7 @@ const mockRecord: CostRecord = {
describe('useBudgetStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
+ vi.clearAllMocks()
})
it('initializes with empty state', () => {
@@ -31,19 +36,108 @@ describe('useBudgetStore', () => {
expect(store.config).toBeNull()
expect(store.records).toEqual([])
expect(store.totalRecords).toBe(0)
+ expect(store.loading).toBe(false)
+ expect(store.error).toBeNull()
})
- it('handles budget.record_added WS event', () => {
- const store = useBudgetStore()
- const event: WsEvent = {
- event_type: 'budget.record_added',
- channel: 'budget',
- timestamp: '2026-03-12T10:00:00Z',
- payload: { ...mockRecord },
- }
- store.handleWsEvent(event)
- expect(store.records).toHaveLength(1)
- expect(store.records[0].cost_usd).toBe(0.005)
- expect(store.totalRecords).toBe(1)
+ describe('fetchConfig', () => {
+ it('sets config on success', async () => {
+ const mockConfig = { daily_limit: 100, total_budget: 1000 }
+ mockGetBudgetConfig.mockResolvedValue(mockConfig)
+
+ const store = useBudgetStore()
+ await store.fetchConfig()
+
+ expect(store.config).toEqual(mockConfig)
+ expect(store.loading).toBe(false)
+ expect(store.error).toBeNull()
+ })
+
+ it('sets error on failure', async () => {
+ mockGetBudgetConfig.mockRejectedValue(new Error('Unauthorized'))
+
+ const store = useBudgetStore()
+ await store.fetchConfig()
+
+ expect(store.config).toBeNull()
+ expect(store.error).toBe('Unauthorized')
+ expect(store.loading).toBe(false)
+ })
+ })
+
+ describe('fetchRecords', () => {
+ it('sets records on success', async () => {
+ mockListCostRecords.mockResolvedValue({ data: [mockRecord], total: 1 })
+
+ const store = useBudgetStore()
+ await store.fetchRecords()
+
+ expect(store.records).toEqual([mockRecord])
+ expect(store.totalRecords).toBe(1)
+ expect(store.loading).toBe(false)
+ })
+
+ it('sets error on failure', async () => {
+ mockListCostRecords.mockRejectedValue(new Error('Server error'))
+
+ const store = useBudgetStore()
+ await store.fetchRecords()
+
+ expect(store.records).toEqual([])
+ expect(store.error).toBe('Server error')
+ })
+ })
+
+ describe('fetchAgentSpending', () => {
+ it('returns spending on success', async () => {
+ const mockSpending = { agent_id: 'alice', total_cost: 1.5 }
+ mockGetAgentSpending.mockResolvedValue(mockSpending)
+
+ const store = useBudgetStore()
+ const result = await store.fetchAgentSpending('alice')
+
+ expect(result).toEqual(mockSpending)
+ expect(store.loading).toBe(false)
+ expect(store.error).toBeNull()
+ })
+
+ it('returns null and sets error on failure', async () => {
+ mockGetAgentSpending.mockRejectedValue(new Error('Not found'))
+
+ const store = useBudgetStore()
+ const result = await store.fetchAgentSpending('alice')
+
+ expect(result).toBeNull()
+ expect(store.error).toBe('Not found')
+ })
+ })
+
+ describe('WS events', () => {
+ it('handles budget.record_added WS event', () => {
+ const store = useBudgetStore()
+ const event: WsEvent = {
+ event_type: 'budget.record_added',
+ channel: 'budget',
+ timestamp: '2026-03-12T10:00:00Z',
+ payload: { ...mockRecord },
+ }
+ store.handleWsEvent(event)
+ expect(store.records).toHaveLength(1)
+ expect(store.records[0].cost_usd).toBe(0.005)
+ expect(store.totalRecords).toBe(1)
+ })
+
+ it('ignores WS event with invalid payload', () => {
+ const store = useBudgetStore()
+ const event: WsEvent = {
+ event_type: 'budget.record_added',
+ channel: 'budget',
+ timestamp: '2026-03-12T10:00:00Z',
+ payload: { not_a_record: true },
+ }
+ store.handleWsEvent(event)
+ expect(store.records).toHaveLength(0)
+ expect(store.totalRecords).toBe(0)
+ })
})
})
diff --git a/web/src/__tests__/stores/messages.test.ts b/web/src/__tests__/stores/messages.test.ts
index 91c6bda439..cb93713277 100644
--- a/web/src/__tests__/stores/messages.test.ts
+++ b/web/src/__tests__/stores/messages.test.ts
@@ -37,7 +37,8 @@ describe('useMessageStore', () => {
}
store.handleWsEvent(event)
expect(store.messages).toHaveLength(1)
- expect(store.total).toBe(1)
+ // total is only updated from REST API, not WS events
+ expect(store.total).toBe(0)
})
it('does not increment total for messages filtered by activeChannel', () => {
diff --git a/web/src/__tests__/stores/tasks.test.ts b/web/src/__tests__/stores/tasks.test.ts
index 9e5769c884..d51cf3cb0e 100644
--- a/web/src/__tests__/stores/tasks.test.ts
+++ b/web/src/__tests__/stores/tasks.test.ts
@@ -4,7 +4,6 @@ import { useTaskStore } from '@/stores/tasks'
import type { Task, WsEvent } from '@/api/types'
const mockListTasks = vi.fn()
-const mockGetTask = vi.fn()
const mockCreateTask = vi.fn()
const mockUpdateTask = vi.fn()
const mockTransitionTask = vi.fn()
@@ -12,7 +11,6 @@ const mockCancelTask = vi.fn()
vi.mock('@/api/endpoints/tasks', () => ({
listTasks: (...args: unknown[]) => mockListTasks(...args),
- getTask: (...args: unknown[]) => mockGetTask(...args),
createTask: (...args: unknown[]) => mockCreateTask(...args),
updateTask: (...args: unknown[]) => mockUpdateTask(...args),
transitionTask: (...args: unknown[]) => mockTransitionTask(...args),
@@ -169,6 +167,21 @@ describe('useTaskStore', () => {
expect(result).toEqual(transitioned)
expect(store.tasks[0].status).toBe('assigned')
})
+
+ it('returns null and sets error on failure', async () => {
+ mockTransitionTask.mockRejectedValue(new Error('Version conflict'))
+
+ const store = useTaskStore()
+ store.tasks = [mockTask]
+ const result = await store.transitionTask('task-1', {
+ target_status: 'assigned',
+ expected_version: 1,
+ })
+
+ expect(result).toBeNull()
+ expect(store.error).toBe('Version conflict')
+ expect(store.tasks[0].status).toBe('created')
+ })
})
describe('cancelTask', () => {
@@ -183,6 +196,18 @@ describe('useTaskStore', () => {
expect(result).toEqual(cancelled)
expect(store.tasks[0].status).toBe('cancelled')
})
+
+ it('returns null and sets error on failure', async () => {
+ mockCancelTask.mockRejectedValue(new Error('Forbidden'))
+
+ const store = useTaskStore()
+ store.tasks = [mockTask]
+ const result = await store.cancelTask('task-1', { reason: 'done' })
+
+ expect(result).toBeNull()
+ expect(store.error).toBe('Forbidden')
+ expect(store.tasks[0].status).toBe('created')
+ })
})
describe('WS events', () => {
diff --git a/web/src/__tests__/utils/format.test.ts b/web/src/__tests__/utils/format.test.ts
index 0d07531146..b6a45dd282 100644
--- a/web/src/__tests__/utils/format.test.ts
+++ b/web/src/__tests__/utils/format.test.ts
@@ -39,9 +39,12 @@ describe('formatRelativeTime', () => {
expect(formatRelativeTime(recent)).toBe('just now')
})
- it('returns "just now" for future dates', () => {
+ it('returns formatted date for future timestamps', () => {
const future = new Date(Date.now() + 60_000).toISOString()
- expect(formatRelativeTime(future)).toBe('just now')
+ // Future dates fall through to formatDate instead of 'just now'
+ const result = formatRelativeTime(future)
+ expect(result).not.toBe('just now')
+ expect(result).not.toBe('—')
})
it('returns dash for invalid date string', () => {
@@ -86,9 +89,17 @@ describe('formatUptime', () => {
expect(formatUptime(3720)).toBe('1h 2m')
})
+ it('formats round hours without trailing 0m', () => {
+ expect(formatUptime(3600)).toBe('1h')
+ })
+
it('formats days hours and minutes', () => {
expect(formatUptime(90060)).toBe('1d 1h 1m')
})
+
+ it('formats zero seconds as 0m', () => {
+ expect(formatUptime(0)).toBe('0m')
+ })
})
describe('formatLabel', () => {
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
index d26ce653e4..e411fcbdc6 100644
--- a/web/src/api/client.ts
+++ b/web/src/api/client.ts
@@ -5,7 +5,9 @@
import axios, { type AxiosError, type AxiosResponse } from 'axios'
import type { ApiResponse, PaginatedResponse } from './types'
-const BASE_URL = import.meta.env.VITE_API_BASE_URL || ''
+// Normalize: strip trailing slashes and any existing /api/v1 suffix
+const RAW_BASE = (import.meta.env.VITE_API_BASE_URL as string) || ''
+const BASE_URL = RAW_BASE.replace(/\/+$/, '').replace(/\/api\/v1\/?$/, '')
export const apiClient = axios.create({
baseURL: `${BASE_URL}/api/v1`,
@@ -36,6 +38,8 @@ apiClient.interceptors.response.use(
// Dynamic import to avoid circular dependency with router -> stores -> api
import('@/router').then(({ router }) => {
router.push('/login')
+ }).catch(() => {
+ window.location.href = '/login'
})
}
}
diff --git a/web/src/api/endpoints/agents.ts b/web/src/api/endpoints/agents.ts
index 7caf386f20..35556858ad 100644
--- a/web/src/api/endpoints/agents.ts
+++ b/web/src/api/endpoints/agents.ts
@@ -1,18 +1,18 @@
import { apiClient, unwrap, unwrapPaginated } from '../client'
-import type { AgentConfig, AutonomyLevelRequest, AutonomyLevelResponse, PaginationParams } from '../types'
+import type { AgentConfig, ApiResponse, AutonomyLevelRequest, AutonomyLevelResponse, PaginatedResponse, PaginationParams } from '../types'
export async function listAgents(params?: PaginationParams) {
- const response = await apiClient.get('/agents', { params })
+ const response = await apiClient.get>('/agents', { params })
return unwrapPaginated(response)
}
export async function getAgent(name: string): Promise {
- const response = await apiClient.get(`/agents/${encodeURIComponent(name)}`)
+ const response = await apiClient.get>(`/agents/${encodeURIComponent(name)}`)
return unwrap(response)
}
export async function getAutonomy(agentId: string): Promise {
- const response = await apiClient.get(`/agents/${encodeURIComponent(agentId)}/autonomy`)
+ const response = await apiClient.get>(`/agents/${encodeURIComponent(agentId)}/autonomy`)
return unwrap(response)
}
diff --git a/web/src/api/endpoints/approvals.ts b/web/src/api/endpoints/approvals.ts
index 3082879297..1c89d94d32 100644
--- a/web/src/api/endpoints/approvals.ts
+++ b/web/src/api/endpoints/approvals.ts
@@ -1,19 +1,21 @@
import { apiClient, unwrap, unwrapPaginated } from '../client'
import type {
+ ApiResponse,
ApprovalFilters,
ApprovalItem,
ApproveRequest,
CreateApprovalRequest,
+ PaginatedResponse,
RejectRequest,
} from '../types'
export async function listApprovals(filters?: ApprovalFilters) {
- const response = await apiClient.get('/approvals', { params: filters })
+ const response = await apiClient.get>('/approvals', { params: filters })
return unwrapPaginated(response)
}
export async function getApproval(id: string): Promise {
- const response = await apiClient.get(`/approvals/${encodeURIComponent(id)}`)
+ const response = await apiClient.get>(`/approvals/${encodeURIComponent(id)}`)
return unwrap(response)
}
diff --git a/web/src/api/endpoints/auth.ts b/web/src/api/endpoints/auth.ts
index 3bcbf16271..00195f4395 100644
--- a/web/src/api/endpoints/auth.ts
+++ b/web/src/api/endpoints/auth.ts
@@ -1,5 +1,6 @@
import { apiClient, unwrap } from '../client'
import type {
+ ApiResponse,
ChangePasswordRequest,
LoginRequest,
SetupRequest,
@@ -8,21 +9,21 @@ import type {
} from '../types'
export async function setup(data: SetupRequest): Promise {
- const response = await apiClient.post('/auth/setup', data)
+ const response = await apiClient.post>('/auth/setup', data)
return unwrap(response)
}
export async function login(data: LoginRequest): Promise {
- const response = await apiClient.post('/auth/login', data)
+ const response = await apiClient.post>('/auth/login', data)
return unwrap(response)
}
export async function changePassword(data: ChangePasswordRequest): Promise {
- const response = await apiClient.post('/auth/change-password', data)
+ const response = await apiClient.post>('/auth/change-password', data)
return unwrap(response)
}
export async function getMe(): Promise {
- const response = await apiClient.get('/auth/me')
+ const response = await apiClient.get>('/auth/me')
return unwrap(response)
}
diff --git a/web/src/api/endpoints/budget.ts b/web/src/api/endpoints/budget.ts
index a47c3181f7..b05d422319 100644
--- a/web/src/api/endpoints/budget.ts
+++ b/web/src/api/endpoints/budget.ts
@@ -1,19 +1,19 @@
import { apiClient, unwrap, unwrapPaginated } from '../client'
-import type { AgentSpending, BudgetConfig, CostRecord, PaginationParams } from '../types'
+import type { AgentSpending, ApiResponse, BudgetConfig, CostRecord, PaginatedResponse, PaginationParams } from '../types'
export async function getBudgetConfig(): Promise {
- const response = await apiClient.get('/budget/config')
+ const response = await apiClient.get>('/budget/config')
return unwrap(response)
}
export async function listCostRecords(
params?: PaginationParams & { agent_id?: string; task_id?: string },
) {
- const response = await apiClient.get('/budget/records', { params })
+ const response = await apiClient.get>('/budget/records', { params })
return unwrapPaginated(response)
}
export async function getAgentSpending(agentId: string): Promise {
- const response = await apiClient.get(`/budget/agents/${encodeURIComponent(agentId)}`)
+ const response = await apiClient.get>(`/budget/agents/${encodeURIComponent(agentId)}`)
return unwrap(response)
}
diff --git a/web/src/api/endpoints/company.ts b/web/src/api/endpoints/company.ts
index 1d76153556..ad1594365d 100644
--- a/web/src/api/endpoints/company.ts
+++ b/web/src/api/endpoints/company.ts
@@ -1,17 +1,17 @@
import { apiClient, unwrap, unwrapPaginated } from '../client'
-import type { CompanyConfig, Department, PaginationParams } from '../types'
+import type { ApiResponse, CompanyConfig, Department, PaginatedResponse, PaginationParams } from '../types'
export async function getCompanyConfig(): Promise {
- const response = await apiClient.get('/company')
+ const response = await apiClient.get>('/company')
return unwrap(response)
}
export async function listDepartments(params?: PaginationParams): Promise<{ data: Department[]; total: number; offset: number; limit: number }> {
- const response = await apiClient.get('/departments', { params })
+ const response = await apiClient.get>('/departments', { params })
return unwrapPaginated(response)
}
export async function getDepartment(name: string): Promise {
- const response = await apiClient.get(`/departments/${encodeURIComponent(name)}`)
+ const response = await apiClient.get>(`/departments/${encodeURIComponent(name)}`)
return unwrap(response)
}
diff --git a/web/src/api/endpoints/messages.ts b/web/src/api/endpoints/messages.ts
index ecc5be3d58..55c7dc8c13 100644
--- a/web/src/api/endpoints/messages.ts
+++ b/web/src/api/endpoints/messages.ts
@@ -1,13 +1,12 @@
import { apiClient, unwrap, unwrapPaginated } from '../client'
-import type { Channel, Message, PaginationParams } from '../types'
+import type { ApiResponse, Channel, Message, PaginatedResponse, PaginationParams } from '../types'
export async function listMessages(params?: PaginationParams & { channel?: string }): Promise<{ data: Message[]; total: number; offset: number; limit: number }> {
- const response = await apiClient.get('/messages', { params })
+ const response = await apiClient.get>('/messages', { params })
return unwrapPaginated(response)
}
export async function listChannels(): Promise {
- const response = await apiClient.get('/messages/channels')
- const data = unwrap(response)
- return data
+ const response = await apiClient.get>('/messages/channels')
+ return unwrap(response)
}
diff --git a/web/src/api/endpoints/providers.ts b/web/src/api/endpoints/providers.ts
index 2b5515fe0e..b122e4fa37 100644
--- a/web/src/api/endpoints/providers.ts
+++ b/web/src/api/endpoints/providers.ts
@@ -1,17 +1,28 @@
import { apiClient, unwrap } from '../client'
-import type { ProviderConfig, ProviderModelConfig } from '../types'
+import type { ApiResponse, ProviderConfig, ProviderModelConfig } from '../types'
+
+/** Strip api_key from a single provider config. */
+function stripSecrets(raw: ProviderConfig & { api_key?: unknown }): ProviderConfig {
+ const { api_key: _discarded, ...safe } = raw
+ return safe
+}
export async function listProviders(): Promise> {
- const response = await apiClient.get('/providers')
- return unwrap(response)
+ const response = await apiClient.get>>('/providers')
+ const raw = unwrap>(response)
+ const result: Record = {}
+ for (const [key, provider] of Object.entries(raw)) {
+ result[key] = stripSecrets(provider)
+ }
+ return result
}
export async function getProvider(name: string): Promise {
- const response = await apiClient.get(`/providers/${encodeURIComponent(name)}`)
- return unwrap(response)
+ const response = await apiClient.get>(`/providers/${encodeURIComponent(name)}`)
+ return stripSecrets(unwrap(response))
}
export async function getProviderModels(name: string): Promise {
- const response = await apiClient.get(`/providers/${encodeURIComponent(name)}/models`)
+ const response = await apiClient.get>(`/providers/${encodeURIComponent(name)}/models`)
return unwrap(response)
}
diff --git a/web/src/api/endpoints/tasks.ts b/web/src/api/endpoints/tasks.ts
index 52d7fce6e3..d419068b0c 100644
--- a/web/src/api/endpoints/tasks.ts
+++ b/web/src/api/endpoints/tasks.ts
@@ -1,7 +1,9 @@
import { apiClient, unwrap, unwrapPaginated } from '../client'
import type {
+ ApiResponse,
CancelTaskRequest,
CreateTaskRequest,
+ PaginatedResponse,
Task,
TaskFilters,
TransitionTaskRequest,
@@ -9,12 +11,12 @@ import type {
} from '../types'
export async function listTasks(filters?: TaskFilters) {
- const response = await apiClient.get('/tasks', { params: filters })
+ const response = await apiClient.get>('/tasks', { params: filters })
return unwrapPaginated(response)
}
export async function getTask(taskId: string): Promise {
- const response = await apiClient.get(`/tasks/${encodeURIComponent(taskId)}`)
+ const response = await apiClient.get>(`/tasks/${encodeURIComponent(taskId)}`)
return unwrap(response)
}
diff --git a/web/src/components/common/StatusBadge.vue b/web/src/components/common/StatusBadge.vue
index 3a3d148fb3..bb7de7e0f6 100644
--- a/web/src/components/common/StatusBadge.vue
+++ b/web/src/components/common/StatusBadge.vue
@@ -1,8 +1,10 @@
diff --git a/web/src/components/layout/ConnectionStatus.vue b/web/src/components/layout/ConnectionStatus.vue
index c6e704d2a6..e25d4e7ee6 100644
--- a/web/src/components/layout/ConnectionStatus.vue
+++ b/web/src/components/layout/ConnectionStatus.vue
@@ -1,14 +1,14 @@
diff --git a/web/src/components/layout/Sidebar.vue b/web/src/components/layout/Sidebar.vue
index 67c9e69659..3b43172365 100644
--- a/web/src/components/layout/Sidebar.vue
+++ b/web/src/components/layout/Sidebar.vue
@@ -44,6 +44,7 @@ function navigate(to: string) {