Skip to content
Merged
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
1 change: 1 addition & 0 deletions evalscope/service/api_models/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class ApiResponseModel(BaseModel):

class ConfigResponse(ApiResponseModel):
outputs_root: str
version: str


class DataFrameResponse(ApiResponseModel):
Expand Down
2 changes: 2 additions & 0 deletions evalscope/service/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from flask import Flask, jsonify, send_from_directory

from evalscope.utils.logger import get_logger
from evalscope.version import __version__

from .api_models import ConfigResponse
from .blueprints import bp_eval, bp_perf, bp_reports
Expand Down Expand Up @@ -67,6 +68,7 @@ def get_config():
ConfigResponse,
{
'outputs_root': outputs_root or _DEFAULT_ROOT,
'version': __version__,
},
)

Expand Down
6 changes: 5 additions & 1 deletion evalscope/web/src/api/generated/contracts.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -485,10 +485,14 @@
"properties": {
"outputs_root": {
"type": "string"
},
"version": {
"type": "string"
}
},
"required": [
"outputs_root"
"outputs_root",
"version"
],
"type": "object"
},
Expand Down
1 change: 1 addition & 0 deletions evalscope/web/src/api/generated/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ export interface PredictionToolCall {
}
export interface ConfigResponse {
outputs_root: string;
version: string;
}
export interface DataFrameResponse {
columns: string[];
Expand Down
8 changes: 8 additions & 0 deletions evalscope/web/src/components/nav/TopNav.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useState } from 'react'
import { NavLink } from 'react-router-dom'
import Badge from '@/components/ui/Badge'
import { useLocale } from '@/contexts/LocaleContext'
import { useAppConfig } from '@/contexts/ReportsContext'
import LocaleToggle from './LocaleToggle'
import ThemeToggle from './ThemeToggle'
import { BarChart3, Gauge, FlaskConical, BookOpen, FileText, Menu, X } from 'lucide-react'
Expand Down Expand Up @@ -28,6 +30,7 @@ const mobileLinkClass = ({ isActive }: { isActive: boolean }) =>

export default function TopNav() {
const { t } = useLocale()
const { config } = useAppConfig()
const [mobileOpen, setMobileOpen] = useState(false)

const navItems = [
Expand Down Expand Up @@ -58,6 +61,11 @@ export default function TopNav() {
<span className="font-bold text-base tracking-tight text-[var(--text)]">
Eval<span className="text-[var(--accent)]">Scope</span>
</span>
{config?.version && (
<Badge title={`EvalScope version ${config.version}`}>
v{config.version}
</Badge>
)}
</div>
{/* Desktop nav — full text, lg+ */}
<nav className="hidden lg:flex items-center gap-0.5">
Expand Down
8 changes: 4 additions & 4 deletions evalscope/web/src/components/ui/Badge.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import type { ReactNode } from 'react'
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
import { cn } from '@/lib/utils'

type BadgeVariant = 'default' | 'success' | 'warning' | 'danger'

interface BadgeProps {
interface BadgeProps extends ComponentPropsWithoutRef<'span'> {
children: ReactNode
variant?: BadgeVariant
className?: string
}

const variantStyles: Record<BadgeVariant, string> = {
Expand All @@ -16,14 +15,15 @@ const variantStyles: Record<BadgeVariant, string> = {
danger: 'bg-[var(--danger-bg)] text-[var(--danger)]',
}

export default function Badge({ children, variant = 'default', className }: BadgeProps) {
export default function Badge({ children, variant = 'default', className, ...rest }: BadgeProps) {
return (
<span
className={cn(
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium',
variantStyles[variant],
className,
)}
{...rest}
>
{children}
</span>
Expand Down
73 changes: 48 additions & 25 deletions evalscope/web/src/contexts/ReportsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,41 @@ import * as reportsApi from '@/api/reports'
import { apiValidated } from '@/api/client'

/**
* Report-scoped application state, split into three independent contexts.
*
* The three concerns below change at unrelated times, so they are published
* separately: a compare-selection toggle on the reports list must not re-render
* the dashboard, and a report landing in the cache must not re-render the
* scan-path bar. `ReportsProvider` composes all three so callers still mount a
* single provider.
* Application and report state are split into contexts with unrelated update
* times. `ReportsProvider` composes them so callers still mount a single
* provider while consumers subscribe only to the state they need.
*/

const INITIAL_ROOT = './outputs' // fallback; will be overridden by /api/v1/config
const REPORT_CACHE_LIMIT = 32 // bound the in-memory cache so long sessions don't grow unbounded

// ------------------------------------------------------------------ //
// Application configuration: backend metadata shared across the shell //
// ------------------------------------------------------------------ //

interface AppConfigCtx {
config: ConfigResponse | null
}

const AppConfigContext = createContext<AppConfigCtx>({ config: null })

function AppConfigProvider({ children }: { children: ReactNode }) {
const [config, setConfig] = useState<ConfigResponse | null>(null)

useEffect(() => {
let cancelled = false
apiValidated<ConfigResponse>('/api/v1/config')
.then((response) => {
if (!cancelled) setConfig(response)
})
.catch(() => {/* retain the empty config when the service is unavailable */})
return () => { cancelled = true }
}, [])

const value = useMemo(() => ({ config }), [config])
return <AppConfigContext.Provider value={value}>{children}</AppConfigContext.Provider>
}

// ------------------------------------------------------------------ //
// Scan scope: which directory is being read, and when to re-read it //
// ------------------------------------------------------------------ //
Expand Down Expand Up @@ -96,6 +119,7 @@ function withCacheLimit(
}

function ScanProvider({ children }: { children: ReactNode }) {
const { config } = useAppConfig()
const [rootPath, setRootPathState] = useState(INITIAL_ROOT)
const [scanToken, setScanToken] = useState(0)

Expand All @@ -104,20 +128,12 @@ function ScanProvider({ children }: { children: ReactNode }) {
const rootRef = useRef(rootPath)
useEffect(() => { rootRef.current = rootPath }, [rootPath])

// Fetch the server-side default outputs_root from /api/v1/config on mount.
// Only apply it when the user has not already changed the root away from the
// initial default (checked at resolve time via the ref).
// Apply the server-side default unless the user has already changed the root.
useEffect(() => {
let cancelled = false
apiValidated<ConfigResponse>('/api/v1/config')
.then((cfg) => {
if (!cancelled && cfg.outputs_root && rootRef.current === INITIAL_ROOT) {
setRootPathState(cfg.outputs_root)
}
})
.catch(() => {/* ignore; keep default */})
return () => { cancelled = true }
}, [])
if (config?.outputs_root && rootRef.current === INITIAL_ROOT) {
setRootPathState(config.outputs_root)
}
}, [config?.outputs_root])

const setRootPath = useCallback((path: string) => setRootPathState(path), [])

Expand Down Expand Up @@ -205,16 +221,23 @@ function ReportCacheProvider({ children }: { children: ReactNode }) {

export function ReportsProvider({ children }: { children: ReactNode }) {
return (
<ScanProvider>
<CompareSelectionProvider>
<ReportCacheProvider>{children}</ReportCacheProvider>
</CompareSelectionProvider>
</ScanProvider>
<AppConfigProvider>
<ScanProvider>
<CompareSelectionProvider>
<ReportCacheProvider>{children}</ReportCacheProvider>
</CompareSelectionProvider>
</ScanProvider>
</AppConfigProvider>
)
}

/* eslint-disable react-refresh/only-export-components */

/** Backend configuration shared by the application shell and scan controls. */
export function useAppConfig(): AppConfigCtx {
return useContext(AppConfigContext)
}

/** Which output directory is being read, and the token that fans out a rescan. */
export function useScan(): ScanCtx {
return useContext(ScanContext)
Expand Down
40 changes: 40 additions & 0 deletions evalscope/web/src/layouts/MainLayout.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render, screen } from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router-dom'

vi.mock('@/api/client', () => ({
apiValidated: vi.fn().mockResolvedValue({ outputs_root: '', version: '1.2.3' }),
}))

import { LocaleProvider } from '@/contexts/LocaleContext'
import { ReportsProvider } from '@/contexts/ReportsContext'
import MainLayout from './MainLayout'

afterEach(() => {
cleanup()
vi.clearAllMocks()
})

describe('MainLayout', () => {
it('shows the backend EvalScope version', async () => {
vi.stubGlobal('scrollTo', vi.fn())

render(
<LocaleProvider>
<ReportsProvider>
<MemoryRouter initialEntries={['/dashboard']}>
<Routes>
<Route element={<MainLayout />}>
<Route path="/dashboard" element={null} />
</Route>
</Routes>
</MemoryRouter>
</ReportsProvider>
</LocaleProvider>,
)

await act(async () => { await Promise.resolve() })

expect(screen.getByTitle('EvalScope version 1.2.3')).toHaveTextContent('v1.2.3')
})
})
3 changes: 2 additions & 1 deletion tests/service/test_api_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
WebApiContracts,
)
from evalscope.service.responses import json_response
from evalscope.version import __version__

ROOT = Path(__file__).parents[2]

Expand Down Expand Up @@ -116,7 +117,7 @@ def client(tmp_path):
def test_config_and_idle_task_endpoints_use_response_contracts(client, tmp_path) -> None:
config = client.get('/api/v1/config')
assert config.status_code == 200
assert config.get_json() == {'outputs_root': str(tmp_path)}
assert config.get_json() == {'outputs_root': str(tmp_path), 'version': __version__}

for scope in ('eval', 'perf'):
progress = client.get(f'/api/v1/{scope}/progress', query_string={'task_id': 'missing-task'})
Expand Down
Loading