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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,7 @@ STALE_SERVICES.txt
# -- git history on the real file is the rollback mechanism now, these are
# just local working-copy scratch files.
docker/*.bak-*

# vitest/v8 coverage report output (npm run coverage:ui, packages/omnibioai-ui's
# npm run coverage) -- generated, not source.
coverage/
10 changes: 10 additions & 0 deletions docker/nginx-router.conf
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,16 @@ server {
auth_request /internal/auth/verify;
auth_request_set $auth_status $upstream_status;
error_page 401 = @cc_unauthorized;
# auth_request above only gates the request -- it does not, on its
# own, change what header the actual proxied request below carries.
# Without this, a cookie-only iframe navigation (no Authorization
# header to begin with) passes the gate via $control_authorization's
# cookie fallback (see the map{} near the top of this file) but then
# reaches control-center with no Authorization header at all, and
# control-center's own require_permission() dependency 401s it
# independently ("Missing or malformed Authorization header") --
# same pattern /_svc/toolserver below already gets right.
proxy_set_header Authorization $control_authorization;
set $control_upstream control-center:7070;
rewrite ^/_svc/control(/.*)$ $1 break;
proxy_pass http://$control_upstream;
Expand Down
10 changes: 10 additions & 0 deletions packages/omnibioai-ui/src/components/Card/Card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,14 @@ describe('Card', () => {
fireEvent.click(container.firstChild!);
expect(fn).toHaveBeenCalledTimes(1);
});
it('applies inline padding style when provided', () => {
const { container } = render(<Card padding="12px">body</Card>);
expect((container.firstChild as HTMLElement).style.padding).toBe('12px');
});
it('renders header with actions but no title', () => {
const { container } = render(<Card actions={<button>Go</button>}>body</Card>);
expect(container.querySelector('.omni-card__header')).not.toBeNull();
expect(container.querySelector('.omni-card__title')).toBeNull();
expect(screen.getByText('Go')).toBeInTheDocument();
});
});
63 changes: 63 additions & 0 deletions packages/omnibioai-ui/src/components/Table/Table.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,66 @@ describe('Table', () => {
expect(screen.getByText('alpha').tagName).toBe('STRONG');
});
});

it('cycles sort direction, handles nulls, and resets after a third click', () => {
const columns = [
{ key: 'name' as const, label: 'Name', sortable: true },
{ key: 'value' as const, label: 'Value', sortable: true },
{ key: 'plain' as const, label: 'Plain', sortable: false },
];
const rows = [
{ name: 'zeta', value: null as number | null, plain: 'x' },
{ name: 'alpha', value: 2, plain: 'y' },
{ name: 'beta', value: 1, plain: 'z' },
];
render(<Table columns={columns} data={rows} />);
const name = screen.getByText('Name');
fireEvent.click(name); // asc
fireEvent.click(name); // desc
expect(screen.getAllByRole('cell')[0]).toHaveTextContent('zeta');
fireEvent.click(name); // clear sort
expect(screen.getAllByRole('cell')[0]).toHaveTextContent('zeta');
fireEvent.click(screen.getByText('Plain')); // no-op
});

it('compares nulls on both sides and equal values when sorting', () => {
const columns = [{ key: 'value' as const, label: 'Value', sortable: true }];
const rows = [
{ value: null as number | null },
{ value: 5 },
{ value: 5 },
{ value: 3 },
];
render(<Table columns={columns} data={rows} />);
fireEvent.click(screen.getByText('Value')); // ascending
const asc = screen.getAllByRole('cell').map(c => c.textContent);
expect(asc[asc.length - 1]).toBe('—'); // null sorts last ascending
fireEvent.click(screen.getByText('Value')); // descending
const desc = screen.getAllByRole('cell').map(c => c.textContent);
expect(desc[desc.length - 1]).toBe('—'); // null comparator return bypasses the direction flip
});

it('re-sorts ascending on the same column after a full reset cycle', () => {
const columns = [{ key: 'name' as const, label: 'Name', sortable: true }];
const rows = [{ name: 'zeta' }, { name: 'alpha' }, { name: 'beta' }];
render(<Table columns={columns} data={rows} />);
const name = screen.getByText('Name');
fireEvent.click(name); // asc
fireEvent.click(name); // desc
fireEvent.click(name); // reset (null)
fireEvent.click(name); // asc again
expect(screen.getAllByRole('cell')[0]).toHaveTextContent('alpha');
});

it('paginates, renders ellipses, and supports page navigation', () => {
const columns = [{ key: 'name' as const, label: 'Name', sortable: true }];
const rows = Array.from({ length: 50 }, (_, i) => ({ name: `row-${i}` }));
render(<Table columns={columns} data={rows} pageSize={2} />);
expect(screen.getByText('row-0')).toBeInTheDocument();
expect(screen.getByText('…')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '2' }));
expect(screen.getByText('row-2')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '→' }));
fireEvent.click(screen.getByRole('button', { name: '←' }));
expect(screen.getByText('row-2')).toBeInTheDocument();
});
8 changes: 8 additions & 0 deletions packages/omnibioai-ui/src/components/Tabs/Tabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,12 @@ describe('Tabs', () => {
render(<Tabs tabs={tabs} defaultTab="b" />);
expect(screen.getByText('Content B')).toBeInTheDocument();
});
it('renders an empty panel when there are no tabs', () => {
const { container } = render(<Tabs tabs={[]} />);
expect(container.querySelector('.omni-tab-panel')?.textContent).toBe('');
});
it('renders an empty panel when defaultTab matches no tab', () => {
const { container } = render(<Tabs tabs={tabs} defaultTab="missing" />);
expect(container.querySelector('.omni-tab-panel')?.textContent).toBe('');
});
});
3 changes: 3 additions & 0 deletions packages/omnibioai-ui/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ export default defineConfig({
setupFiles: ['./src/test-setup.ts'],
css: true,
include: ['src/**/*.test.{ts,tsx}'],
coverage: {
thresholds: { statements: 95, lines: 95, functions: 95, branches: 95 },
},
},
},
],
Expand Down
21 changes: 19 additions & 2 deletions src/ui/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import OAuthLinkConfirm from "./components/OAuthLinkConfirm";
import Login from "./components/Login";
import { GrafanaViewer } from "./components/GrafanaViewer";
import { getCurrentUser, onSessionChange, consumeOAuthRedirectParams, isElectron, refresh, getRefreshToken } from "./lib/session";
import { loadConfig as loadWebConfig } from "./lib/web/webApi";

const BASE_NAV = [
{ section: "Setup", items: [
Expand Down Expand Up @@ -112,6 +113,18 @@ export default function App() {
// No config at all → first run → Settings
setStep(8);
}
} else if (!isElectron()) {
// Web/cloud deployment (e.g. webstudio.omnibioai.org): window.api
// (Electron's preload bridge) is never injected into a plain
// browser tab, so the branch above never runs here and `config`
// was silently stuck at its hardcoded initial default forever.
// webApi.loadConfig() (src/ui/lib/web/webApi.js) is the purpose-
// built web-mode stand-in — there's no local Docker stack or
// data_dir to configure when connecting to an already-running
// backend, so it always resolves with mode: "beta" rather than
// needing a first-run redirect the way the Electron branch does.
const saved = await loadWebConfig();
setConfig(prev => ({ ...prev, ...saved, mode: saved.mode || "beta" }));
}
} catch (_) {
// Dev mode — no Electron API, stay on Mode page
Expand Down Expand Up @@ -325,8 +338,12 @@ export default function App() {
<span style={{ color:"var(--text)" }}>{currentName}</span>
</div>

{/* First run warning */}
{!config?.settings?.data_dir && (
{/* First run warning — data_dir is an Electron-only concept (the
local Docker stack's data directory). Beta/web mode connects to
an already-running remote backend and never has one to set
(see webApi.loadConfig() above), so it's excluded here rather
than showing a "setup required" warning that doesn't apply. */}
{config?.mode !== "beta" && !config?.settings?.data_dir && (
<div style={{
fontSize:'var(--font-size-xs)', fontFamily:"var(--mono)",
color:"var(--warn)",
Expand Down
Loading
Loading