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
2 changes: 1 addition & 1 deletion charts/taugrid-core/templates/portal.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ rules:
resources: ["jobs"]
verbs: ["get", "list", "watch"]
- apiGroups: ["ray.io"]
resources: ["rayjobs", "rayclusters"]
resources: ["rayjobs", "rayclusters", "rayservices"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
Expand Down
1 change: 1 addition & 0 deletions charts/taugrid-core/tests/portal_jobs_scope_test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ tests:
resources:
- rayjobs
- rayclusters
- rayservices
verbs:
- get
- list
Expand Down
7 changes: 4 additions & 3 deletions portal/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Empty } from './components';
import type { WorkspaceScope } from './types';
import { CostBoard, ExperimentsBoard, Kueue, Observability, Overview, Services } from './Boards';
import { Fleet } from './Fleet';
import { JobDetailBoard, RayBoard, RayHistoryBoard, RunsBoard } from './Workloads';
import { JobDetailBoard, LegacyJobDetailRedirect, RayBoard, RayHistoryBoard, RunsBoard } from './Workloads';

const tabs = [
{ id: 'platform', label: 'Platform', items: [['Overview', '/portal'], ['Fleet', '/portal/fleet'], ['Kueue', '/portal/jobs'], ['Ray', '/portal/ray'], ['Observability', '/portal/observability'], ['Cost', '/portal/cost']] },
Expand All @@ -18,7 +18,7 @@ const fleetPaths = ['/portal/fleet', '/portal/cluster', '/portal/gpu', '/portal/
export function tabForPath(path: string): string | undefined {
if (path === '/stellar' || path.startsWith('/stellar/')) return 'experiments';
if (fleetPaths.includes(path) || path === '/portal/kueueviz' || path.startsWith('/portal/ray/')) return 'platform';
if (path.startsWith('/portal/runs/')) return 'workloads';
if (path.startsWith('/portal/runs/') || path.startsWith('/portal/workloads/')) return 'workloads';
return tabs.find(t => t.items.some(([, p]) => p !== '/portal' && p === path))?.id;
}
class BoardBoundary extends Component<{ children: ReactNode }, { error?: Error }> {
Expand Down Expand Up @@ -103,8 +103,9 @@ export function App() {
<BoardBoundary key={location.pathname + persona + ':' + new URLSearchParams(location.search).get('target')}>
<Routes>
<Route path="/portal/runs" element={<RunsBoard/>}/>
<Route path="/portal/runs/:namespace/:name" element={<JobDetailBoard/>}/>
<Route path="/portal/runs/:namespace/:name" element={<LegacyJobDetailRedirect/>}/>
<Route path="/portal/runs/*" element={<Empty warn>Invalid job path: expected /portal/runs/&lt;namespace&gt;/&lt;name&gt;.</Empty>}/>
<Route path="/portal/workloads/:resourceUID" element={<JobDetailBoard/>}/>
<Route path="/portal/ray" element={<RayBoard/>}/>
<Route path="/portal/ray/history/:resourceUID" element={<RayHistoryBoard/>}/>
<Route path="/portal/services" element={<Services/>}/>
Expand Down
9 changes: 7 additions & 2 deletions portal/frontend/src/Boards.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,14 @@ describe('Platform overview', () => {
runId: 'live-ray-train-01', experimentPath: '/stellar?target=live-ray-train-01', experimentTracking: 'legacy' },
{ name: 'live-batch-eval', namespace: 'tau-default', kind: 'Job', status: 'Running', age: '2m',
experimentTracking: 'untracked' },
], waiting: [
{ name: 'gpu-waiting', namespace: 'aks-ai-runtime-e2e', resourceUid: 'uid-pending', queue: 'jobqueue', clusterQueue: 'tau-cq',
pendingReason: 'Pending', admissionPriorityClass: 'taugrid-priority', admissionPriority: 1200, podPriorityClasses: ['taugrid-priority'] },
], running: [
{ name: 'gpu-service', namespace: 'tau-default', queue: 'jobqueue', clusterQueue: 'tau-cq',
admissionPriorityClass: 'taugrid-priority', admissionPriority: 1200, podPriorityClasses: ['taugrid-priority'] },
resourceUid: 'uid-gpu', admissionPriorityClass: 'taugrid-priority', admissionPriority: 1200, podPriorityClasses: ['taugrid-priority'] },
{ name: 'cpu-viewer', namespace: 'tau-default', queue: 'cpu', clusterQueue: 'tau-cpu-cq',
admissionPriorityClass: 'taugrid-default', admissionPriority: 1000, podPriorityClasses: ['taugrid-default'] },
resourceUid: 'uid-cpu', admissionPriorityClass: 'taugrid-default', admissionPriority: 1000, podPriorityClasses: ['taugrid-default'] },
] }));
if (url.includes('/api/portal/nodes')) return Promise.resolve(json({
readyNodes: 2, totalNodes: 2, totalGPUs: 16, gpuNodes: 2, gpuSchedulable: 16, gpuAvailable: 8,
Expand Down Expand Up @@ -86,6 +89,8 @@ describe('Platform overview', () => {
expect(screen.getByRole('link', { name: 'Inspect active jobs →' })).toHaveAttribute('href', '/portal/runs');
expect(screen.getByText('GPU quota admitted')).toBeInTheDocument();
expect(screen.getByText('CPU quota admitted')).toBeInTheDocument();
expect(screen.getAllByText('Waiting for quota').length).toBeGreaterThan(0);
expect(screen.getByRole('link', { name: 'gpu-waiting' })).toHaveAttribute('href', '/portal/workloads/uid-pending');
expect(screen.getByText('gpu-service')).toBeInTheDocument();
expect(screen.getByText('cpu-viewer')).toBeInTheDocument();
expect(screen.getByText(/Active jobs come from Job and RayJob runtime status/)).toBeInTheDocument();
Expand Down
13 changes: 8 additions & 5 deletions portal/frontend/src/Overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,16 +199,18 @@ function QueueBridge({ data }: { data: OverviewData }) {
function WorkloadFlow({ data }: { data: OverviewData }) {
const admitted = data.running ?? [];
const active = data.active ?? [];
const waiting = data.waiting ?? [];
const cpu = admitted.filter(run => run.queue === 'cpu' || run.clusterQueue === 'tau-cpu-cq');
const gpu = admitted.filter(run => !cpu.includes(run));
const group = (label: string, runs: typeof admitted) => <section className="overview-workload-group">
type WorkloadRow = (typeof admitted)[number] & { pendingReason?: string };
const group = (label: string, runs: WorkloadRow[], state = 'Quota admitted') => <section className="overview-workload-group">
<div className="overview-workload-group-head"><strong>{label}</strong><span>{runs.length}</span></div>
{!runs.length ? <p className="overview-workload-empty">No admitted workloads.</p>
{!runs.length ? <p className="overview-workload-empty">No {state === 'Waiting for quota' ? 'pending' : 'admitted'} workloads.</p>
: <div className="overview-workload-list">{runs.slice(0, 5).map(run => <div className="overview-workload" key={`${run.namespace}/${run.name}`}>
<span className="overview-workload-state">Quota admitted</span>
<span className="overview-workload-state">{state}</span>
<div>
<strong>{run.job || run.name}</strong>
<small>{run.namespace} · {run.queue || 'queue unknown'}</small>
{run.resourceUid ? <ScopedLink to={'/portal/workloads/' + encodeURIComponent(run.resourceUid)}><strong>{run.job || run.name}</strong></ScopedLink> : <strong>{run.job || run.name}</strong>}
<small>{run.namespace} · {run.queue || 'queue unknown'}{run.pendingReason ? ` · ${run.pendingReason}` : ''}</small>
<PriorityDetail workload={run}/>
</div>
<TrackingLink run={run} label="Experiment ↗"/>
Expand All @@ -233,6 +235,7 @@ function WorkloadFlow({ data }: { data: OverviewData }) {
</section>
{data.runningUnavailable ? <div className="overview-unavailable">{data.runningUnavailable}</div>
: <>
{group('Waiting for quota', waiting, 'Waiting for quota')}
{group('GPU quota admitted', gpu)}
{group('CPU quota admitted', cpu)}
</>}
Expand Down
84 changes: 84 additions & 0 deletions portal/frontend/src/Workloads.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { WorkspaceProvider, createPortalQueryClient } from './data';
import { JobDetailBoard } from './Workloads';
import type { JobDetail, WorkspaceScope } from './types';

const scope: WorkspaceScope = {
workspace: 'flex', name: 'Flex', cluster: 'cluster-a', namespace: 'team-a',
source: 'portal', authorizationMode: 'namespace', availability: 'available', managed: true,
};

function detail(overrides: Partial<JobDetail>): JobDetail {
return {
name: 'train', namespace: 'team-a', kind: 'RayJob', resourceUid: 'uid-1',
objectState: 'live', status: 'Running', runId: 'run-1',
stages: { object: 'live', admission: 'admitted', scheduling: 'scheduled', application: 'running', tracking: 'linked' },
object: { age: '5m' },
links: { rayDashboardReachable: false, stellarPath: '/portal/experiments?target=run-1' },
diagnostics: {
workloads: { state: 'ready' }, pods: { state: 'ready' }, events: { state: 'empty' },
tracking: { state: 'ready' }, telemetry: { state: 'empty', message: 'No GPU samples.' },
},
workloads: [{ name: 'wl-1', queue: 'gpu', admitted: true, finished: false }],
pods: [{ name: 'head', phase: 'Running', node: 'gpu-a', restarts: 0, containers: [{ name: 'ray-head', ready: true, restarts: 0, state: 'running' }] }],
...overrides,
};
}

function renderDetail(payload: JobDetail) {
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response(JSON.stringify(payload), {
status: 200, headers: { 'content-type': 'application/json' },
}))));
const client = createPortalQueryClient();
render(<QueryClientProvider client={client}><MemoryRouter initialEntries={['/portal/workloads/uid-1']}>
<WorkspaceProvider scope={scope} managed><Routes><Route path="/portal/workloads/:resourceUID" element={<JobDetailBoard/>}/></Routes></WorkspaceProvider>
</MemoryRouter></QueryClientProvider>);
}

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

describe('Workload detail lifecycle', () => {
it.each([
['pending', detail({ status: 'Pending', stages: { object: 'live', admission: 'pending', scheduling: 'no_pods', application: 'waiting', tracking: 'unlinked' }, pods: [], workloads: [{ name: 'wl-1', queue: 'gpu', admitted: false, finished: false, pendingReason: 'Pending', pendingMessage: 'insufficient quota' }] }), 'insufficient quota'],
['running', detail({}), 'running'],
['failed', detail({ status: 'Failed', stages: { object: 'live', admission: 'finished', scheduling: 'completed', application: 'failed', tracking: 'linked' }, object: { age: '10m', reason: 'JobFailed', message: 'worker exited' } }), 'failed'],
['deleted', detail({ objectState: 'deleted', status: 'Succeeded', stages: { object: 'deleted', admission: 'unavailable', scheduling: 'unavailable', application: 'succeeded', tracking: 'linked' }, pods: [], workloads: [], diagnostics: {
workloads: { state: 'unavailable', message: 'deleted' }, pods: { state: 'unavailable', message: 'deleted' },
events: { state: 'unavailable', message: 'expired' }, tracking: { state: 'ready' }, telemetry: { state: 'unavailable', message: 'historical attribution unavailable' },
} }), 'Object deleted'],
])('renders distinct %s evidence without conflating stages', async (_name, payload, expected) => {
renderDetail(payload);
expect((await screen.findAllByText(expected, { exact: false })).length).toBeGreaterThan(0);
expect(screen.getByLabelText('Workload lifecycle')).toHaveTextContent(`Admission ${payload.stages.admission}`);
expect(screen.getByLabelText('Workload lifecycle')).toHaveTextContent(`Application ${payload.stages.application}`);
});

it('shows temperature and power consumption gauges with absolute readings', async () => {
renderDetail(detail({
telemetry: {
start: '2026-09-23T00:00:00Z', end: '2026-09-23T01:00:00Z', gpuCount: 1,
sampleCount: 80, utilizationSampleCount: 10, averageUtilizationPct: 92, coverage: 'observed',
gpus: [{
instance: 'gpu-a', pod: 'worker-0', gpu: '0', samples: 80, utilizationSamples: 10,
averageUtilizationPct: 92, peakUtilizationPct: 99, maxTemperatureCelsius: 84,
maxPowerWatts: 675, maxMemoryUsedMB: 74000, maxRowRemapFailure: 0,
}],
},
diagnostics: {
workloads: { state: 'ready' }, pods: { state: 'ready' }, events: { state: 'empty' },
tracking: { state: 'ready' }, telemetry: { state: 'ready' },
},
}));
expect(await screen.findByRole('meter', { name: 'Peak GPU temperature: 84.0 °C' })).toHaveAttribute('aria-valuemax', '100');
expect(screen.getByRole('meter', { name: 'Peak GPU power consumption: 675.0 W' })).toHaveAttribute('aria-valuemax', '1000');
});
});
Loading
Loading