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
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export const GuardrailsDataView: FC<GuardrailsDataViewProps> = ({
},
DataViewTableContent: {
renderEmptyState: () => {
if (data?.data.length === 0 && !isFetching && !hasSearchOrFilters) {
if (data?.data?.length === 0 && !isFetching && !hasSearchOrFilters) {
return (
<TableEmptyState
icon={<ShieldCheck className="h-[64px] w-[64px]" />}
Expand Down
2 changes: 2 additions & 0 deletions web/packages/studio/src/constants/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const ROUTE_PARAMS = {
benchmarkName: 'benchmarkName',
experimentGroupName: 'experimentGroupName',
experimentName: 'experimentName',
guardrailConfigName: 'guardrailConfigName',
} as const;

// Just an alias to make the routes more readable
Expand Down Expand Up @@ -107,6 +108,7 @@ export const ROUTES = {
dataDesignerJobNew: `/workspaces/:${P.workspace}/data-designer/new`,
secrets: `/workspaces/:${P.workspace}/secrets`,
guardrails: `/workspaces/:${P.workspace}/guardrails`,
guardrailDetail: `/workspaces/:${P.workspace}/guardrails/:${P.guardrailConfigName}`,
settings: `/workspaces/:${P.workspace}/settings`,
/** Workspace members and role-based access (Entities role bindings) */
members: `/workspaces/:${P.workspace}/members`,
Expand Down
8 changes: 8 additions & 0 deletions web/packages/studio/src/mocks/handlers/guardrails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ export const guardrailsHandlers = [
},
})
),
http.get(
`${PLATFORM_BASE_URL}/apis/guardrails/v2/workspaces/:workspace/configs/:name`,
({ params }) => {
const config = mockGuardrailConfigs.find((c) => c.name === params.name);
if (!config) return new HttpResponse(null, { status: 404 });
return HttpResponse.json(config);
}
),
http.delete(
`${PLATFORM_BASE_URL}/apis/guardrails/v2/workspaces/:workspace/configs/:name`,
() => new HttpResponse(null, { status: 200 })
Expand Down
38 changes: 22 additions & 16 deletions web/packages/studio/src/routes/groups/guardrailsRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,32 @@
// SPDX-License-Identifier: Apache-2.0

import { ErrorPanel } from '@studio/components/ErrorPanel';
import { GUARDRAILS_ENABLED } from '@studio/constants/environment';
import { ROUTES } from '@studio/constants/routes';
import { gateGuardrailsRoutes } from '@studio/routes/utils';
import { lazy } from 'react';
import type { RouteObject } from 'react-router-dom';

const GuardrailsRoute =
GUARDRAILS_ENABLED &&
lazy(() =>
import('@studio/routes/guardrails/GuardrailsRoute').then((m) => ({
default: m.GuardrailsRoute,
}))
);
const GuardrailsRoute = lazy(() =>
import('@studio/routes/guardrails/GuardrailsRoute').then((m) => ({
default: m.GuardrailsRoute,
}))
);

export const guardrailsRoutes: RouteObject[] = gateGuardrailsRoutes(
GuardrailsRoute
? {
path: ROUTES.workspace.guardrails,
element: <GuardrailsRoute />,
errorElement: <ErrorPanel title="Guardrails" />,
}
: []
const GuardrailDetailRoute = lazy(() =>
import('@studio/routes/guardrails/GuardrailDetailRoute').then((m) => ({
default: m.GuardrailDetailRoute,
}))
);

export const guardrailsRoutes: RouteObject[] = gateGuardrailsRoutes([
{
path: ROUTES.workspace.guardrails,
element: <GuardrailsRoute />,
errorElement: <ErrorPanel title="Guardrails" />,
},
{
path: ROUTES.workspace.guardrailDetail,
element: <GuardrailDetailRoute />,
errorElement: <ErrorPanel title="Guardrails" />,
},
]);
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { PLATFORM_BASE_URL } from '@studio/constants/environment';
import { ROUTES } from '@studio/constants/routes';
import { mockGuardrailConfigs } from '@studio/mocks/handlers/guardrails';
import { server } from '@studio/mocks/node';
import { GuardrailDetailRoute } from '@studio/routes/guardrails/GuardrailDetailRoute';
import { getGuardrailDetailRoute } from '@studio/routes/utils';
import { XL_SELECTOR_TIMEOUT } from '@studio/tests/util/constants';
import { renderRoute, screen } from '@studio/tests/util/render';
import { within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { delay, http, HttpResponse } from 'msw';

const WORKSPACE = 'default';

const routes = [
{
path: ROUTES.workspace.guardrailDetail,
element: <GuardrailDetailRoute />,
},
{
path: ROUTES.workspace.guardrails,
element: <div data-testid="guardrails-list">LIST</div>,
},
];

const renderDetail = (name: string) =>
renderRoute(undefined, {
history: getGuardrailDetailRoute(WORKSPACE, name),
routes,
});

describe('GuardrailDetailRoute', () => {
it('renders the config details from the detail endpoint', async () => {
renderDetail('pii-filter');

expect(
await screen.findByText('pii-filter', undefined, { timeout: XL_SELECTOR_TIMEOUT })
).toBeInTheDocument();
expect(screen.getByText('Blocks PII in user inputs and outputs')).toBeInTheDocument();
// pii-filter has 2 models and 4 rail flows (2 input + 2 output).
expect(screen.getAllByText('2').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('4').length).toBeGreaterThanOrEqual(1);
// Raw config block.
expect(screen.getByText('Config')).toBeInTheDocument();
});

it('shows the Edit button disabled', async () => {
renderDetail('pii-filter');
await screen.findByText('pii-filter', undefined, { timeout: XL_SELECTOR_TIMEOUT });
expect(screen.getByRole('button', { name: 'Edit' })).toBeDisabled();
});

it('shows a loading state while fetching', async () => {
server.use(
http.get(
`${PLATFORM_BASE_URL}/apis/guardrails/v2/workspaces/:workspace/configs/:name`,
async () => {
await delay();
return HttpResponse.json(mockGuardrailConfigs[0]);
}
)
);
renderDetail('pii-filter');
expect(await screen.findByText('Loading guardrail config...')).toBeInTheDocument();
});

it('shows an error state when the config cannot be loaded', async () => {
renderDetail('does-not-exist');
expect(
await screen.findByText('Failed to load guardrail config.', undefined, {
timeout: XL_SELECTOR_TIMEOUT,
})
).toBeInTheDocument();
});

it('deletes the config and navigates back to the list', async () => {
const user = userEvent.setup();
renderDetail('pii-filter');
await screen.findByText('pii-filter', undefined, { timeout: XL_SELECTOR_TIMEOUT });

await user.click(screen.getByRole('button', { name: 'Delete' }));
const dialog = await screen.findByRole('dialog');
await user.click(within(dialog).getByRole('button', { name: 'Delete' }));

expect(
await screen.findByTestId('guardrails-list', undefined, { timeout: XL_SELECTOR_TIMEOUT })
).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/*
* SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/

import { KVPair } from '@nemo/common/src/components/KVPair';
import { RelativeTime } from '@nemo/common/src/components/RelativeTime';
import {
useGuardrailsDeleteConfig,
useGuardrailsGetGuardrailConfig,
} from '@nemo/sdk/generated/platform/api';
import { Button, Flex, PageHeader, Stack, Text } from '@nvidia/foundations-react-core';
import { AccessibleTitle } from '@studio/components/AccessibleTitle';
import { countRails } from '@studio/components/dataViews/GuardrailsDataView/guardrailUtils';
import { DeleteConfirmationModal } from '@studio/components/DeleteConfirmationModal';
import { Loading } from '@studio/components/Layouts/Loading';
import { ROUTE_PARAMS } from '@studio/constants/routes';
import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath';
import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs';
import { getGuardrailsRoute } from '@studio/routes/utils';
import { useRequiredPathParams } from '@studio/util/hooks/useRequiredPathParams';
import { useQueryClient } from '@tanstack/react-query';
import { type FC, useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';

export const GuardrailDetailRoute: FC = () => {
const workspace = useWorkspaceFromPath();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { guardrailConfigName } = useRequiredPathParams([ROUTE_PARAMS.guardrailConfigName]);

const [showDelete, setShowDelete] = useState(false);

useBreadcrumbs({
items: [
{ href: getGuardrailsRoute(workspace), slotLabel: 'Guardrails' },
{ slotLabel: guardrailConfigName },
],
});

const queryEnabled = Boolean(workspace && guardrailConfigName);
const {
data: config,
isPending,
isError,
} = useGuardrailsGetGuardrailConfig(workspace, guardrailConfigName, {
query: { enabled: queryEnabled },
});

const { mutateAsync: deleteConfig } = useGuardrailsDeleteConfig();

const handleDelete = useCallback(async (): Promise<boolean> => {
try {
await deleteConfig({ workspace, name: guardrailConfigName });
// Invalidate by URL prefix — matches all pages/sorts for this workspace.
await queryClient.invalidateQueries({
queryKey: [`/apis/guardrails/v2/workspaces/${workspace}/configs`],
});
navigate(getGuardrailsRoute(workspace));
return true;
} catch {
return false;
}
}, [deleteConfig, guardrailConfigName, navigate, queryClient, workspace]);

if (isPending) {
return <Loading description="Loading guardrail config..." />;
}

if (isError || !config) {
return (
<AccessibleTitle title={`Guardrail config ${guardrailConfigName}`}>
<Stack className="w-full h-full min-h-0 p-density-2xl" gap="density-xl">
<PageHeader slotHeading={guardrailConfigName} />
<Text className="text-feedback-danger">Failed to load guardrail config.</Text>
</Stack>
</AccessibleTitle>
);
}

const modelCount = config.data?.models?.length ?? 0;
const railCount = countRails(config.data);

return (
<AccessibleTitle title={`Guardrail config ${guardrailConfigName}`}>
<Stack className="w-full min-h-full p-density-2xl" gap="density-xl">
<PageHeader
slotHeading={
<Flex gap="density-sm" align="center" justify="between">
<span className="min-w-0 truncate" title={config.name}>
{config.name}
</span>
<Flex gap="density-sm">
<Button kind="secondary" disabled title="Edit — coming soon">
Edit
</Button>
<Button kind="secondary" color="danger" onClick={() => setShowDelete(true)}>
Delete
</Button>
</Flex>
</Flex>
}
/>

<Stack className="gap-density-lg">
<Stack className="gap-density-md">
{config.description ? (
<KVPair
label="Description"
orientation="horizontal"
size="medium"
truncate={false}
value={config.description}
/>
) : null}
<KVPair
label="Models"
orientation="horizontal"
size="medium"
value={String(modelCount)}
/>
<KVPair
label="Rails"
orientation="horizontal"
size="medium"
value={String(railCount)}
/>
<KVPair
label="Created"
orientation="horizontal"
size="medium"
value={
config.created_at ? (
<RelativeTime datetime={config.created_at} focusableForTooltip={false} />
) : (
'—'
)
}
/>
<KVPair
label="Updated"
orientation="horizontal"
size="medium"
value={
config.updated_at ? (
<RelativeTime datetime={config.updated_at} focusableForTooltip={false} />
) : (
'—'
)
}
/>
</Stack>

{config.data ? (
<Stack gap="density-sm">
<Text kind="label/bold/sm">Config</Text>
<pre className="overflow-auto rounded bg-surface-raised p-density-md text-xs leading-relaxed">
{JSON.stringify(config.data, null, 2)}
</pre>
</Stack>
) : null}
</Stack>
</Stack>

{showDelete ? (
<DeleteConfirmationModal
open
simpleConfirm
title={`Delete guardrail config: ${config.name}`}
successText="Guardrail config deleted successfully."
errorText="Failed to delete the guardrail config. Please try again."
onDelete={handleDelete}
onClose={() => setShowDelete(false)}
/>
) : null}
</AccessibleTitle>
);
};
Loading