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
44 changes: 44 additions & 0 deletions .github/workflows/ui-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: UI Tests

on:
push:
branches: [ "main" ]
paths:
- 'ui/**'
- '.github/workflows/ui-tests.yml'
pull_request:
branches: [ "main" ]
paths:
- 'ui/**'
- '.github/workflows/ui-tests.yml'

jobs:
ui-test:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
defaults:
run:
working-directory: ui
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

- name: Set up node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: '18'
cache: 'yarn'
cache-dependency-path: ui/yarn.lock

- name: Install dependencies
run: yarn install --frozen-lockfile --network-timeout 1000000

# Jest unit + component tests (all HTTP is mocked; no external services).
- name: Run unit + component tests
run: CI=true yarn test --watchAll=false

# Production build type-checks the whole app (tsc is stricter than jest).
- name: Production build
run: yarn build
6 changes: 6 additions & 0 deletions ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,5 +62,11 @@
"resolutions": {
"react-scripts/resolve-url-loader/postcss": "8.4.49",
"react-scripts/@svgr/webpack/**/nth-check": "2.1.1"
},
"jest": {
"moduleNameMapper": {
"^axios$": "axios/dist/node/axios.cjs",
"^antd/es/(.*)$": "antd/lib/$1"
}
}
}
56 changes: 54 additions & 2 deletions ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ import {
} from 'antd';
import {
createBrowserRouter,
Navigate,
Outlet,
RouterProvider,
useLocation,
useNavigate,
} from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
Expand All @@ -30,6 +32,10 @@ import Login from './pages/Login';
import { AuthProvider, useAuth } from './context/auth-context';
import { UserOutlined } from '@ant-design/icons';
import ModelVersionDetails from './pages/ModelVersionDetails';
import ExternalData from './pages/ExternalData';
import CredentialDetails from './pages/CredentialDetails';
import ExternalLocationDetails from './pages/ExternalLocationDetails';
import UsersList from './pages/UsersList';

// TODO:
// As of [19/02/2025], this implementation should be updated once the following PR are merged.
Expand Down Expand Up @@ -73,13 +79,44 @@ const router = createBrowserRouter([
path: '/models/:catalog/:schema/:model/versions/:version',
element: <ModelVersionDetails />,
},
{
path: '/external-data',
element: <Navigate to="/external-data/external-locations" replace />,
},
{
path: '/external-data/external-locations',
element: <ExternalData />,
},
{
path: '/external-data/credentials',
element: <ExternalData />,
},
{
path: '/external-data/external-locations/:name',
element: <ExternalLocationDetails />,
},
{
path: '/external-data/credentials/:name',
element: <CredentialDetails />,
},
{
path: '/users',
element: <UsersList />,
},
],
},
]);

function AppProvider() {
const { logout, currentUser } = useAuth();
const navigate = useNavigate();
const { pathname } = useLocation();

const selectedNavKey = pathname.startsWith('/external-data')
? 'external-data'
: pathname.startsWith('/users')
? 'users'
: 'catalogs';

const profileMenuItems = useMemo(
(): MenuProps['items'] => [
Expand Down Expand Up @@ -146,13 +183,23 @@ function AppProvider() {
<Menu
theme="dark"
mode="horizontal"
defaultSelectedKeys={['catalogs']}
selectedKeys={[selectedNavKey]}
items={[
{
key: 'catalogs',
label: 'Catalogs',
onClick: () => navigate('/'),
},
{
key: 'external-data',
label: 'External Data',
onClick: () => navigate('/external-data'),
},
{
key: 'users',
label: 'Users',
onClick: () => navigate('/users'),
},
]}
style={{ flex: 1, minWidth: 0 }}
/>
Expand Down Expand Up @@ -215,7 +262,12 @@ function AppProvider() {

function App() {
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: QUERY_STALE_TIME } },
defaultOptions: {
// No retry: these are authenticated admin CRUD reads — a 401/403/404 is
// deterministic, and the default 3 retries just delayed the error state
// by several seconds of blank UI.
queries: { staleTime: QUERY_STALE_TIME, retry: false },
},
});

return authEnabled ? (
Expand Down
90 changes: 90 additions & 0 deletions ui/src/components/credentials/CredentialsList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { useState } from 'react';
import { Button, Tag, Typography } from 'antd';
import { useNavigate } from 'react-router-dom';
import ListLayout from '../layouts/ListLayout';
import { formatTimestamp } from '../../utils/formatTimestamp';
import {
CredentialInterface,
useListCredentials,
} from '../../hooks/credentials';
import { useListExternalLocations } from '../../hooks/externalLocations';
import { credentialIdentityOf, credentialTypeOf } from '../../utils/credential';
import { CreateCredentialModal } from '../modals/CreateCredentialModal';
import ValidateCredentialButton from './ValidateCredentialButton';

const TYPE_COLORS: Record<string, string> = {
'Aliyun RAM (STS)': 'orange',
'Aliyun AK/SK (static)': 'gold',
'AWS IAM role': 'geekblue',
};

export default function CredentialsList() {
const { data, isLoading, error } = useListCredentials();
const { data: locationsData } = useListExternalLocations();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const externalLocations = locationsData?.external_locations ?? [];

return (
<>
<ListLayout<CredentialInterface>
loading={isLoading}
error={error}
title={
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button type="primary" onClick={() => setOpen(true)}>
Create Credential
</Button>
</div>
}
data={data?.credentials}
onRowClick={(record) =>
navigate(`/external-data/credentials/${record.name}`)
}
rowKey={(record) => `credential-${record.id}`}
columns={[
{ title: 'Name', dataIndex: 'name', key: 'name', width: '25%' },
{
title: 'Type',
key: 'type',
width: '15%',
render: (_, record) => {
const type = credentialTypeOf(record);
return <Tag color={TYPE_COLORS[type]}>{type}</Tag>;
},
},
{
title: 'Role ARN / Access key',
key: 'identity',
width: '30%',
render: (_, record) => (
<Typography.Text code>
{credentialIdentityOf(record)}
</Typography.Text>
),
},
{ title: 'Owner', dataIndex: 'owner', key: 'owner', width: '10%' },
{
title: 'Created At',
dataIndex: 'created_at',
key: 'created_at',
width: '12%',
render: (value) => (value ? formatTimestamp(value) : ''),
},
{
title: 'Actions',
key: 'actions',
width: '8%',
render: (_, record) => (
<ValidateCredentialButton
credential={record}
externalLocations={externalLocations}
/>
),
},
]}
/>
<CreateCredentialModal open={open} closeModal={() => setOpen(false)} />
</>
);
}
76 changes: 76 additions & 0 deletions ui/src/components/credentials/ValidateCredentialButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../../test-utils/render';
import { programClient, requestsTo } from '../../test-utils/mockClient';
import ValidateCredentialButton from './ValidateCredentialButton';

jest.mock('../../context/client', () => ({ CLIENT: { request: jest.fn() } }));

const CREDENTIAL = {
name: 'demo-sts',
aliyun_ram_role: { role_arn: 'acs:ram::1:role/r' },
};
const LOCATION = {
name: 'loc1',
url: 'oss://bkt/path',
credential_name: 'demo-sts',
};

describe('ValidateCredentialButton', () => {
it('is disabled when the credential has no bound external location', () => {
programClient([]);
renderWithProviders(
<ValidateCredentialButton
credential={CREDENTIAL}
externalLocations={[]}
/>,
);
expect(screen.getByRole('button', { name: /validate/i })).toBeDisabled();
});

it('vends PATH_READ credentials for the bound location url', async () => {
programClient([
{
method: 'post',
url: '/temporary-path-credentials',
response: { expiration_time: 123 },
},
]);
renderWithProviders(
<ValidateCredentialButton
credential={CREDENTIAL}
externalLocations={[LOCATION]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /validate/i }));
await waitFor(() =>
expect(requestsTo('post', '/temporary-path-credentials')).toHaveLength(1),
);
expect(requestsTo('post', '/temporary-path-credentials')[0].data).toEqual({
url: 'oss://bkt/path',
operation: 'PATH_READ',
});
// Success notification names the probed path and the credential.
await screen.findByText(
/Credential vending succeeded for oss:\/\/bkt\/path using demo-sts/,
);
});

it('surfaces the server error message on failure', async () => {
programClient([
{
method: 'post',
url: '/temporary-path-credentials',
response: { message: 'STS AssumeRole denied for role' },
status: 403,
},
]);
renderWithProviders(
<ValidateCredentialButton
credential={CREDENTIAL}
externalLocations={[LOCATION]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /validate/i }));
await screen.findByText(/STS AssumeRole denied for role/);
});
});
Loading
Loading