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
31 changes: 31 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,37 @@ const config = [
'react-hooks/immutability': 'off',
},
},
// P1.5 — Discourage bare `fetch('/api/...')`.
// The team must migrate to `apiFetch<T>()` from `@/lib/api-client` so that
// 401 / 403 / 5xx / network failures are handled uniformly.
// Phase 1 (this PR): warn level, allow incremental migration.
// Phase 3 (final cleanup): upgrade to error and forbid merges that introduce
// new bare fetch('/api/...') sites.
// Selector rationale:
// - covers single-quoted, double-quoted, and template-literal forms
// - filters by /api prefix so cross-origin / external fetches stay untouched
// - exempts api-client.ts itself (the one allowed implementer)
{
files: ['src/**/*.{ts,tsx,js,jsx}'],
ignores: ['src/lib/api-client.ts'],
rules: {
'no-restricted-syntax': [
'warn',
{
selector:
"CallExpression[callee.name='fetch'] > Literal[value=/^\\/api\\//]",
message:
"Use apiFetch<T>() from '@/lib/api-client' instead of bare fetch('/api/...'). It handles 401 redirect, 403/5xx typed errors, and network failures uniformly. See PR-api-client.md.",
},
{
selector:
"CallExpression[callee.name='fetch'] > TemplateLiteral.arguments:first-child[quasis.0.value.raw=/^\\/api\\//]",
message:
"Use apiFetch<T>() from '@/lib/api-client' instead of bare fetch(`/api/...`). It handles 401 redirect, 403/5xx typed errors, and network failures uniformly.",
},
],
},
},
]

export default config
2 changes: 2 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { NextIntlClientProvider } from 'next-intl'
import { getLocale, getMessages } from 'next-intl/server'
import { THEME_IDS } from '@/lib/themes'
import { ThemeBackground } from '@/components/ui/theme-background'
import { AuthExpiredListener } from '@/components/auth-expired-listener'
import './globals.css'

const inter = Inter({
Expand Down Expand Up @@ -114,6 +115,7 @@ export default async function RootLayout({
disableTransitionOnChange
>
<ThemeBackground />
<AuthExpiredListener />
<div className="h-screen overflow-hidden bg-background text-foreground">
{children}
</div>
Expand Down
33 changes: 33 additions & 0 deletions src/components/auth-expired-listener.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
'use client'

import { useEffect } from 'react'

/**
* Listens for `mc:auth-expired` CustomEvent dispatched by `apiFetch()` when the
* server returns 401. The redirect to `/login?from=...` is already handled inside
* `apiFetch`; this listener exists so we have a single observability hook the
* team can extend (toast, telemetry, Sentry).
*
* Mounted once at the root layout. SSR-safe (effect runs only on the client).
*
* Why a separate component?
* - layout.tsx is a server component (uses `await headers()`); we cannot
* attach window listeners there directly.
* - Co-locating the listener with the api-client keeps the auth-failure
* contract in one place.
*/
export function AuthExpiredListener(): null {
useEffect(() => {
const onExpired = (e: Event) => {
const detail = (e as CustomEvent<{ path: string; status: number }>).detail
// No toast lib installed yet — log for now. Replace with sonner in next PR.
console.warn(
`[mc] session expired on ${detail?.path ?? 'unknown'} (status=${detail?.status ?? 401}), redirecting to /login`
)
}
window.addEventListener('mc:auth-expired', onExpired)
return () => window.removeEventListener('mc:auth-expired', onExpired)
}, [])

return null
}
64 changes: 30 additions & 34 deletions src/components/dashboard/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { SignalPill, getLocalOsStatus, getProviderHealth, getMcHealth } from './
import { OnboardingChecklistWidget } from './widgets/onboarding-checklist-widget'
import { EmptyStateLaunchpad } from './empty-state-launchpad'
import { WidgetGrid } from './widget-grid'
import { apiFetch } from '@/lib/api-client'
import type { DbStats, ClaudeStats, LogLike, DashboardData } from './widget-primitives'

export function Dashboard() {
Expand Down Expand Up @@ -55,61 +56,56 @@ export function Dashboard() {
const requests: Promise<void>[] = []

requests.push(
fetch('/api/status?action=dashboard')
.then(async (res) => {
if (!res.ok) return
const data = await res.json()
(async () => {
try {
const data = await apiFetch<any>('/api/status?action=dashboard')
if (data && !data.error) {
setSystemStats(data)
if (data.db) setDbStats(data.db)
}
})
.catch(() => {})
.finally(() => setLoading(prev => ({ ...prev, system: false })))
} catch {}
finally { setLoading(prev => ({ ...prev, system: false })) }
})()
)

requests.push(
fetch('/api/sessions')
.then(async (res) => {
if (!res.ok) return
const data = await res.json()
(async () => {
try {
const data = await apiFetch<any>('/api/sessions')
if (data && !data.error) setSessions(data.sessions || data)
})
.catch(() => {})
.finally(() => setLoading(prev => ({ ...prev, sessions: false })))
} catch {}
finally { setLoading(prev => ({ ...prev, sessions: false })) }
})()
)

if (isLocal) {
requests.push(
fetch('/api/claude/sessions')
.then(async (res) => {
if (!res.ok) return
const data = await res.json()
(async () => {
try {
const data = await apiFetch<any>('/api/claude/sessions')
if (data?.stats) setClaudeStats(data.stats)
})
.catch(() => {})
.finally(() => setLoading(prev => ({ ...prev, claude: false })))
} catch {}
finally { setLoading(prev => ({ ...prev, claude: false })) }
})()
)

requests.push(
fetch('/api/github?action=stats')
.then(async (res) => {
if (!res.ok) return
const data = await res.json()
(async () => {
try {
const data = await apiFetch<any>('/api/github?action=stats')
if (data && !data.error) setGithubStats(data)
})
.catch(() => {})
.finally(() => setLoading(prev => ({ ...prev, github: false })))
} catch {}
finally { setLoading(prev => ({ ...prev, github: false })) }
})()
)

requests.push(
fetch('/api/hermes')
.then(async (res) => {
if (!res.ok) return
const data = await res.json()
(async () => {
try {
const data = await apiFetch<any>('/api/hermes')
if (data?.cronJobCount != null) setHermesCronJobCount(data.cronJobCount)
})
.catch(() => {})
} catch {}
})()
)
} else {
setLoading(prev => ({ ...prev, claude: false, github: false }))
Expand Down
30 changes: 14 additions & 16 deletions src/components/dashboard/empty-state-launchpad.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { apiFetch } from '@/lib/api-client'

interface RuntimeStatus {
id: string
Expand All @@ -25,26 +26,23 @@ export function EmptyStateLaunchpad({ agentCount, taskCount, onNavigate }: Props

useEffect(() => {
// Try the agent-runtimes API first, fall back to capabilities endpoint
fetch('/api/agent-runtimes')
.then(r => r.ok ? r.json() : null)
.then(d => {
;(async () => {
try {
const d = await apiFetch<any>('/api/agent-runtimes')
if (d?.runtimes) {
setRuntimes(d.runtimes)
setLoaded(true)
return
}
// Fallback: use capabilities endpoint for detection
return fetch('/api/status?action=capabilities')
.then(r => r.ok ? r.json() : {})
.then((caps: Record<string, unknown>) => {
const detected: RuntimeStatus[] = []
if (caps.openclawHome) detected.push({ id: 'openclaw', name: 'OpenClaw', installed: true })
if (caps.hermesInstalled) detected.push({ id: 'hermes', name: 'Hermes Agent', installed: true })
if (caps.claudeHome) detected.push({ id: 'claude', name: 'Claude Code', installed: true })
setRuntimes(detected)
})
})
.catch(() => {})
.finally(() => setLoaded(true))
const caps = await apiFetch<Record<string, unknown>>('/api/status?action=capabilities')
const detected: RuntimeStatus[] = []
if (caps.openclawHome) detected.push({ id: 'openclaw', name: 'OpenClaw', installed: true })
if (caps.hermesInstalled) detected.push({ id: 'hermes', name: 'Hermes Agent', installed: true })
if (caps.claudeHome) detected.push({ id: 'claude', name: 'Claude Code', installed: true })
setRuntimes(detected)
} catch {}
setLoaded(true)
})()
}, [])

const installed = runtimes.filter(r => r.installed)
Expand Down
11 changes: 7 additions & 4 deletions src/components/dashboard/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useEffect, useState } from 'react'
import { useMissionControl } from '@/store'
import { useNavigateToPanel } from '@/lib/navigation'
import { createClientLogger } from '@/lib/client-logger'
import { apiFetch } from '@/lib/api-client'
import { Button } from '@/components/ui/button'

const log = createClientLogger('Sidebar')
Expand Down Expand Up @@ -70,10 +71,12 @@ export function Sidebar() {

useEffect(() => {
let cancelled = false
fetch('/api/status?action=overview')
.then(res => res.json())
.then(data => { if (!cancelled) setSystemStats(readSystemStats(data)) })
.catch(err => log.error('Failed to fetch system status:', err))
;(async () => {
try {
const data = await apiFetch<any>('/api/status?action=overview')
if (!cancelled) setSystemStats(readSystemStats(data))
} catch (err) { log.error('Failed to fetch system status:', err) }
})()
return () => { cancelled = true }
}, [])

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { useMissionControl } from '@/store'
import { useNavigateToPanel } from '@/lib/navigation'
import { apiFetch } from '@/lib/api-client'

interface ChecklistItem {
id: string
Expand All @@ -26,10 +27,7 @@ export function OnboardingChecklistWidget() {
let cancelled = false
async function check() {
try {
const onboardingRes = await fetch('/api/onboarding')
if (cancelled) return

const onboardingData = onboardingRes.ok ? await onboardingRes.json() : null
const onboardingData = await apiFetch<any>('/api/onboarding')

const completed = onboardingData?.completed === true
const skipped = onboardingData?.skipped === true
Expand Down Expand Up @@ -68,11 +66,7 @@ export function OnboardingChecklistWidget() {
setCelebrating(true)
const timer = setTimeout(async () => {
try {
await fetch('/api/onboarding', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'dismiss_checklist' }),
})
await apiFetch('/api/onboarding', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'dismiss_checklist' }) })
} catch {}
setVisible(false)
}, 3000)
Expand All @@ -83,7 +77,7 @@ export function OnboardingChecklistWidget() {
const handleDismiss = useCallback(async () => {
setDismissing(true)
try {
await fetch('/api/onboarding', {
await apiFetch('/api/onboarding', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'dismiss_checklist' }),
Expand Down
8 changes: 3 additions & 5 deletions src/components/dashboard/widgets/security-audit-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useState, useEffect, useCallback } from 'react'
import { StatRow, type DashboardData } from '../widget-primitives'
import { useNavigateToPanel } from '@/lib/navigation'
import { apiFetch } from '@/lib/api-client'

interface PostureInfo {
score: number
Expand All @@ -23,11 +24,8 @@ export function SecurityAuditWidget({ data }: { data: DashboardData }) {

const fetchPosture = useCallback(async () => {
try {
const res = await fetch('/api/security-audit?timeframe=day')
if (res.ok) {
const json = await res.json()
if (json.posture) setPosture(json.posture)
}
const json = await apiFetch<any>('/api/security-audit?timeframe=day')
if (json?.posture) setPosture(json.posture)
} catch {
// Silent
}
Expand Down
17 changes: 7 additions & 10 deletions src/components/panels/cost-tracker-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
import { Loader } from '@/components/ui/loader'
import { useMissionControl } from '@/store'
import { createClientLogger } from '@/lib/client-logger'
import { apiFetch } from '@/lib/api-client'
import {
PieChart, Pie, Cell, LineChart, Line, XAxis, YAxis, CartesianGrid,
Tooltip, Legend, ResponsiveContainer, BarChart, Bar,
Expand Down Expand Up @@ -117,14 +118,11 @@ export function CostTrackerPanel() {
const loadData = useCallback(async () => {
setIsLoading(true)
try {
const [statsRes, trendRes, byAgentRes, taskRes] = await Promise.all([
fetch(`/api/tokens?action=stats&timeframe=${timeframe}`),
fetch(`/api/tokens?action=trends&timeframe=${timeframe}`),
fetch(`/api/tokens/by-agent?days=${timeframeToDays(timeframe)}`),
fetch(`/api/tokens?action=task-costs&timeframe=${timeframe}`),
])
const [statsJson, trendJson, byAgentJson, taskJson] = await Promise.all([
statsRes.json(), trendRes.json(), byAgentRes.json(), taskRes.json(),
apiFetch<UsageStats>(`/api/tokens?action=stats&timeframe=${timeframe}`),
apiFetch<TrendData>(`/api/tokens?action=trends&timeframe=${timeframe}`),
apiFetch<ByAgentResponse>(`/api/tokens/by-agent?days=${timeframeToDays(timeframe)}`),
apiFetch<TaskCostsResponse>(`/api/tokens?action=task-costs&timeframe=${timeframe}`),
])
setUsageStats(statsJson)
setTrendData(trendJson)
Expand All @@ -139,8 +137,7 @@ export function CostTrackerPanel() {

const loadSessionCosts = useCallback(async () => {
try {
const res = await fetch(`/api/tokens?action=session-costs&timeframe=${timeframe}`)
const data = await res.json()
const data = await apiFetch<{ sessions: SessionCostEntry[] }>(`/api/tokens?action=session-costs&timeframe=${timeframe}`)
if (Array.isArray(data?.sessions)) {
setSessionCosts(data.sessions)
} else if (usageStats?.sessions) {
Expand Down Expand Up @@ -171,7 +168,7 @@ export function CostTrackerPanel() {
const exportData = async (format: 'json' | 'csv') => {
setIsExporting(true)
try {
const res = await fetch(`/api/tokens?action=export&timeframe=${timeframe}&format=${format}`)
const res = await apiFetch<Response>(`/api/tokens?action=export&timeframe=${timeframe}&format=${format}`, { raw: true })
if (!res.ok) throw new Error('Export failed')
const blob = await res.blob()
const url = window.URL.createObjectURL(blob)
Expand Down
Loading