diff --git a/clients/ui/frontend/src/app/App.tsx b/clients/ui/frontend/src/app/App.tsx index 6dac1cd33b..6e37d1384b 100644 --- a/clients/ui/frontend/src/app/App.tsx +++ b/clients/ui/frontend/src/app/App.tsx @@ -23,6 +23,7 @@ import ToastNotifications from '~/app/standalone/ToastNotifications'; import AppNavSidebar from '~/app/standalone/AppNavSidebar'; import AppRoutes from '~/app/AppRoutes'; import { AppContext } from '~/app/context/AppContext'; +import { CustomThemeProvider } from '~/app/context/ThemeContext'; import { ModelRegistrySelectorContextProvider } from '~/app/context/ModelRegistrySelectorContext'; import { TransferJobNotificationsProvider } from '~/app/context/TransferJobNotificationsContext'; @@ -95,32 +96,34 @@ const App: React.FC = () => { ) : ( - - - { - logout().then(() => window.location.reload()); - }} - /> - ) : ( - '' - ) - } - isManagedSidebar={isStandalone} - sidebar={isStandalone ? : sidebar} - > - - - - - - - + + + + { + logout().then(() => window.location.reload()); + }} + /> + ) : ( + '' + ) + } + isManagedSidebar={isStandalone} + sidebar={isStandalone ? : sidebar} + > + + + + + + + + ); }; diff --git a/clients/ui/frontend/src/app/app.css b/clients/ui/frontend/src/app/app.css index 06f062d46b..af1293261c 100644 --- a/clients/ui/frontend/src/app/app.css +++ b/clients/ui/frontend/src/app/app.css @@ -3,3 +3,266 @@ body, #root { height: 100%; } + +/* ========================================================================== + Premium Dark Theme Design System for Kubeflow Hub + ========================================================================== */ + +html.dark-theme { + --pf-v6-global--BackgroundColor--100: #0b0f19; + --pf-v6-global--BackgroundColor--200: #111827; + --pf-v6-global--Color--100: #f8fafc; + --pf-v6-global--Color--200: #cbd5e1; + --pf-v6-global--BorderColor--100: #1e293b; + --pf-v6-global--link--Color: #38bdf8; + --pf-v6-global--link--Color--hover: #7dd3fc; + color-scheme: dark; +} + +html.dark-theme, +html.dark-theme body { + background-color: #0b0f19 !important; + color: #f8fafc !important; +} + +/* Page Layout & Main Backgrounds */ +html.dark-theme .pf-v6-c-page, +html.dark-theme .pf-v6-c-page__main, +html.dark-theme .pf-v6-c-page__main-section, +html.dark-theme .pf-v6-c-page__main-body { + background-color: #0b0f19 !important; + color: #f8fafc !important; +} + +/* Header Masthead */ +html.dark-theme .pf-v6-c-masthead { + background-color: #0f172a !important; + color: #f8fafc !important; + border-bottom: 1px solid #1e293b !important; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3); +} + +/* Sidebar Navigation */ +html.dark-theme .pf-v6-c-page__sidebar, +html.dark-theme .pf-v6-c-nav { + background-color: #0b0f19 !important; + border-right: 1px solid #1e293b !important; +} + +html.dark-theme .pf-v6-c-nav__link { + color: #cbd5e1 !important; + transition: all 0.2s ease; +} + +html.dark-theme .pf-v6-c-nav__link:hover, +html.dark-theme .pf-v6-c-nav__link.pf-m-current { + color: #38bdf8 !important; + background-color: #1e293b !important; + border-left: 3px solid #38bdf8 !important; +} + +/* Fix Dropdowns & Menu Toggles (bella-namespace, user@example.com, Keyword filter) */ +html.dark-theme .pf-v6-c-menu-toggle, +html.dark-theme .pf-v6-c-menu-toggle__button, +html.dark-theme .pf-v6-c-menu-toggle__text, +html.dark-theme .pf-v6-c-select, +html.dark-theme button[id*="menu-toggle"] { + background-color: #1e293b !important; + color: #f8fafc !important; + border: 1px solid #334155 !important; + border-radius: 6px !important; +} + +html.dark-theme .pf-v6-c-menu-toggle__controls, +html.dark-theme .pf-v6-c-menu-toggle__toggle-icon, +html.dark-theme .pf-v6-c-menu-toggle__icon, +html.dark-theme .pf-v6-c-menu-toggle svg { + color: #94a3b8 !important; +} + +html.dark-theme .pf-v6-c-menu-toggle:hover { + background-color: #334155 !important; + border-color: #475569 !important; +} + +/* Description Text, Subtitles & Paragraphs */ +html.dark-theme .pf-v6-c-content p, +html.dark-theme .pf-v6-c-content, +html.dark-theme .pf-v6-c-page__main-section p, +html.dark-theme .pf-v6-c-page__main-body p, +html.dark-theme p, +html.dark-theme span, +html.dark-theme label, +html.dark-theme small, +html.dark-theme .pf-v6-c-form__helper-text { + color: #cbd5e1 !important; +} + +/* Headings & Main Titles */ +html.dark-theme h1, +html.dark-theme h2, +html.dark-theme h3, +html.dark-theme h4, +html.dark-theme h5, +html.dark-theme h6, +html.dark-theme .pf-v6-c-title, +html.dark-theme .pf-v6-c-page__main-title { + color: #f8fafc !important; + font-weight: 600; +} + +/* Search Inputs & Form Controls */ +html.dark-theme .pf-v6-c-form-control, +html.dark-theme .pf-v6-c-text-input-group, +html.dark-theme input, +html.dark-theme select, +html.dark-theme textarea { + background-color: #1e293b !important; + color: #f8fafc !important; + border: 1px solid #334155 !important; + border-radius: 6px !important; +} + +html.dark-theme input::placeholder, +html.dark-theme .pf-v6-c-text-input-group__text-input::placeholder { + color: #94a3b8 !important; +} + +/* Links & Interactive Actions */ +html.dark-theme a, +html.dark-theme .pf-v6-c-button.pf-m-link, +html.dark-theme .pf-v6-c-button.pf-m-inline { + color: #38bdf8 !important; + text-decoration: none; +} + +html.dark-theme a:hover, +html.dark-theme .pf-v6-c-button.pf-m-link:hover, +html.dark-theme .pf-v6-c-button.pf-m-inline:hover { + color: #7dd3fc !important; + text-decoration: underline; +} + +/* Tables & Data Grids */ +html.dark-theme table, +html.dark-theme .pf-v6-c-table, +html.dark-theme .MuiTable-root { + background-color: #111827 !important; + color: #f8fafc !important; + border-color: #1e293b !important; +} + +html.dark-theme th, +html.dark-theme .pf-v6-c-table__th, +html.dark-theme .pf-v6-c-table__button, +html.dark-theme .pf-v6-c-table__text, +html.dark-theme .pf-v6-c-table__sort-indicator { + background-color: #0f172a !important; + color: #f8fafc !important; + font-weight: 600; + border-bottom: 1px solid #1e293b !important; +} + +html.dark-theme th button, +html.dark-theme .pf-v6-c-table__button span, +html.dark-theme .pf-v6-c-table__button svg { + color: #f8fafc !important; +} + +html.dark-theme tr, +html.dark-theme .pf-v6-c-table__tr { + border-bottom: 1px solid #1e293b !important; + transition: background-color 0.15s ease; +} + +html.dark-theme tr:hover, +html.dark-theme .pf-v6-c-table__tr:hover { + background-color: #1e293b !important; +} + +html.dark-theme td, +html.dark-theme .pf-v6-c-table__td { + color: #f1f5f9 !important; +} + +/* Badges & Tags */ +html.dark-theme .pf-v6-c-badge, +html.dark-theme .pf-v6-c-label, +html.dark-theme .MuiChip-root { + background-color: #1e293b !important; + color: #38bdf8 !important; + border: 1px solid #334155 !important; + border-radius: 9999px; + font-weight: 500; +} + +/* Dropdowns & Menus */ +html.dark-theme .pf-v6-c-dropdown__menu, +html.dark-theme .pf-v6-c-menu, +html.dark-theme .MuiMenu-paper { + background-color: #1e293b !important; + color: #f8fafc !important; + border: 1px solid #334155 !important; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5) !important; +} + +html.dark-theme .pf-v6-c-dropdown__menu-item:hover, +html.dark-theme .pf-v6-c-menu__item:hover, +html.dark-theme .MuiMenuItem-root:hover { + background-color: #334155 !important; + color: #38bdf8 !important; +} + +/* Global Border Cleanup in Dark Mode */ +html.dark-theme *, +html.dark-theme *::before, +html.dark-theme *::after { + border-color: #1e293b; +} + +html.dark-theme .pf-v6-c-page__main, +html.dark-theme .pf-v6-c-page__main-section, +html.dark-theme .pf-v6-c-page__main-body, +html.dark-theme .pf-v6-c-page__sidebar, +html.dark-theme .pf-v6-c-masthead { + border-color: #1e293b !important; + box-shadow: none !important; +} + +/* Pagination & Toolbar Dark Overrides */ +html.dark-theme .pf-v6-c-pagination, +html.dark-theme .pf-v6-c-pagination__menu, +html.dark-theme .pf-v6-c-toolbar, +html.dark-theme .pf-v6-c-toolbar__content, +html.dark-theme .pf-v6-c-toolbar__group, +html.dark-theme .pf-v6-c-toolbar__item, +html.dark-theme .pf-v6-c-options-menu, +html.dark-theme .pf-v6-c-pagination__total-items { + background-color: #0b0f19 !important; + color: #cbd5e1 !important; + border-color: #1e293b !important; +} + +html.dark-theme .pf-v6-c-pagination .pf-v6-c-button, +html.dark-theme .pf-v6-c-pagination__nav-button { + color: #f8fafc !important; + background-color: transparent !important; +} + +html.dark-theme .pf-v6-c-pagination .pf-v6-c-button.pf-m-disabled, +html.dark-theme .pf-v6-c-pagination__nav-button.pf-m-disabled { + color: #475569 !important; +} + +/* Toggle Button Styling */ +#theme-toggle-button { + transition: transform 0.2s ease, background-color 0.2s ease; +} + +#theme-toggle-button:hover { + transform: scale(1.1); + background-color: rgba(255, 255, 255, 0.1); + border-radius: 50%; +} + + diff --git a/clients/ui/frontend/src/app/context/ThemeContext.tsx b/clients/ui/frontend/src/app/context/ThemeContext.tsx new file mode 100644 index 0000000000..42390ffbf1 --- /dev/null +++ b/clients/ui/frontend/src/app/context/ThemeContext.tsx @@ -0,0 +1,81 @@ +import * as React from 'react'; + +export type ThemeMode = 'light' | 'dark' | 'system'; + +interface ThemeContextType { + themeMode: ThemeMode; + effectiveTheme: 'light' | 'dark'; + setThemeMode: (mode: ThemeMode) => void; + toggleTheme: () => void; +} + +const STORAGE_KEY = 'kubeflow_theme_mode'; + +const ThemeContext = React.createContext(undefined); + +export const CustomThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [themeMode, setThemeModeState] = React.useState(() => { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved === 'light' || saved === 'dark' || saved === 'system') { + return saved as ThemeMode; + } + return 'system'; + }); + + const [systemPrefersDark, setSystemPrefersDark] = React.useState(() => { + return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + }); + + React.useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleChange = (e: MediaQueryListEvent) => { + setSystemPrefersDark(e.matches); + }; + + if (mediaQuery && mediaQuery.addEventListener) { + mediaQuery.addEventListener('change', handleChange); + return () => { + mediaQuery.removeEventListener('change', handleChange); + }; + } + return undefined; + }, []); + + const effectiveTheme: 'light' | 'dark' = + themeMode === 'system' ? (systemPrefersDark ? 'dark' : 'light') : themeMode; + + React.useEffect(() => { + const root = document.documentElement; + if (effectiveTheme === 'dark') { + root.classList.add('dark-theme'); + root.classList.add('pf-v6-theme-dark'); + } else { + root.classList.remove('dark-theme'); + root.classList.remove('pf-v6-theme-dark'); + } + }, [effectiveTheme]); + + const setThemeMode = (mode: ThemeMode) => { + setThemeModeState(mode); + localStorage.setItem(STORAGE_KEY, mode); + }; + + const toggleTheme = () => { + const nextMode = effectiveTheme === 'light' ? 'dark' : 'light'; + setThemeMode(nextMode); + }; + + return ( + + {children} + + ); +}; + +export const useCustomTheme = (): ThemeContextType => { + const context = React.useContext(ThemeContext); + if (!context) { + throw new Error('useCustomTheme must be used within a CustomThemeProvider'); + } + return context; +}; diff --git a/clients/ui/frontend/src/app/standalone/NavBar.tsx b/clients/ui/frontend/src/app/standalone/NavBar.tsx index 52c84261a1..7971e8e43d 100644 --- a/clients/ui/frontend/src/app/standalone/NavBar.tsx +++ b/clients/ui/frontend/src/app/standalone/NavBar.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Brand, + Button, Dropdown, DropdownItem, DropdownList, @@ -18,10 +19,11 @@ import { ToolbarGroup, ToolbarItem, } from '@patternfly/react-core'; -import { BarsIcon } from '@patternfly/react-icons'; +import { BarsIcon, MoonIcon, SunIcon } from '@patternfly/react-icons'; import { useThemeContext } from 'mod-arch-kubeflow'; import { images as sharedImages } from 'mod-arch-shared'; import GlobalNamespaceSelector from './GlobalNamespaceSelector'; +import { useCustomTheme } from '~/app/context/ThemeContext'; interface NavBarProps { username?: string; @@ -30,6 +32,7 @@ interface NavBarProps { const NavBar: React.FC = ({ username, onLogout }) => { const { isMUITheme } = useThemeContext(); + const { effectiveTheme, toggleTheme } = useCustomTheme(); const [userMenuOpen, setUserMenuOpen] = React.useState(false); @@ -72,8 +75,24 @@ const NavBar: React.FC = ({ username, onLogout }) => { - {username && ( - + + + + + {username && ( = ({ username, onLogout }) => { {userMenuItems} - - )} + )} + diff --git a/internal/core/inference_service.go b/internal/core/inference_service.go index f524014a6c..cdbe5848fc 100644 --- a/internal/core/inference_service.go +++ b/internal/core/inference_service.go @@ -138,6 +138,7 @@ func (b *ModelRegistryService) GetInferenceServices(listOptions api.ListOptions, OrderBy: listOptions.OrderBy, SortOrder: listOptions.SortOrder, NextPageToken: listOptions.NextPageToken, + FilterQuery: listOptions.FilterQuery, }, Runtime: runtime, ParentResourceID: parentResourceID, diff --git a/internal/core/inference_service_test.go b/internal/core/inference_service_test.go index ca7a310074..0eb7513798 100644 --- a/internal/core/inference_service_test.go +++ b/internal/core/inference_service_test.go @@ -1092,3 +1092,189 @@ func TestInferenceServiceRoundTrip(t *testing.T) { assert.Equal(t, "new_value", finalProps["new_prop"].MetadataStringValue.StringValue) }) } + +// TestGetInferenceServicesWithFilterQuery verifies that the filterQuery parameter +// is correctly propagated and applied when listing InferenceServices. +// +// Regression test for: filterQuery silently ignored on GET /inference_services +// Root cause was missing FilterQuery field propagation in GetInferenceServices +// and missing GetRestEntityType() on InferenceServiceListOptions. +func TestGetInferenceServicesWithFilterQuery(t *testing.T) { + _service, cleanup := SetupModelRegistryService(t) + defer cleanup() + + // Create shared prerequisites + rm, err := _service.UpsertRegisteredModel(&openapi.RegisteredModel{Name: "filter-test-rm"}) + require.NoError(t, err) + + env, err := _service.UpsertServingEnvironment(&openapi.ServingEnvironment{Name: "filter-test-env"}) + require.NoError(t, err) + + // Create inference services with distinct properties for filtering + type svcDef struct { + name string + runtime string + extID string + } + svcDefs := []svcDef{ + {"fraud-detector", "tensorflow", "ext-fraud-001"}, + {"image-classifier", "pytorch", "ext-image-002"}, + {"nlp-pipeline", "tensorflow", "ext-nlp-003"}, + {"recommendation-engine", "sklearn", "ext-rec-004"}, + } + for _, svc := range svcDefs { + name := svc.name + rt := svc.runtime + eid := svc.extID + _, err := _service.UpsertInferenceService(&openapi.InferenceService{ + Name: &name, + ExternalId: &eid, + Runtime: &rt, + ServingEnvironmentId: *env.Id, + RegisteredModelId: *rm.Id, + }) + require.NoError(t, err) + } + + testCases := []struct { + name string + filterQuery string + expectedCount int + expectedNames []string + }{ + { + name: "Filter by exact name", + filterQuery: "name = 'fraud-detector'", + expectedCount: 1, + expectedNames: []string{"fraud-detector"}, + }, + { + name: "Filter by name pattern", + filterQuery: "name LIKE '%-detector'", + expectedCount: 1, + expectedNames: []string{"fraud-detector"}, + }, + { + name: "Filter by externalId", + filterQuery: "externalId = 'ext-image-002'", + expectedCount: 1, + expectedNames: []string{"image-classifier"}, + }, + { + name: "Filter by runtime - tensorflow", + filterQuery: "runtime = 'tensorflow'", + expectedCount: 2, + expectedNames: []string{"fraud-detector", "nlp-pipeline"}, + }, + { + name: "Filter by runtime - pytorch", + filterQuery: "runtime = 'pytorch'", + expectedCount: 1, + expectedNames: []string{"image-classifier"}, + }, + { + name: "Complex filter with AND", + filterQuery: "runtime = 'tensorflow' AND name = 'fraud-detector'", + expectedCount: 1, + expectedNames: []string{"fraud-detector"}, + }, + { + name: "Complex filter with OR", + filterQuery: "runtime = 'pytorch' OR runtime = 'sklearn'", + expectedCount: 2, + expectedNames: []string{"image-classifier", "recommendation-engine"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + pageSize := int32(20) + fq := tc.filterQuery + result, err := _service.GetInferenceServices(api.ListOptions{ + PageSize: &pageSize, + FilterQuery: &fq, + }, nil, nil) + + require.NoError(t, err) + require.NotNil(t, result) + + var matchedNames []string + for _, item := range result.Items { + if slices.Contains(tc.expectedNames, *item.Name) { + matchedNames = append(matchedNames, *item.Name) + } + } + + assert.Equal(t, tc.expectedCount, len(matchedNames), + "filterQuery %q: expected %d items, got %d (filter may be silently ignored)", + tc.filterQuery, tc.expectedCount, len(matchedNames)) + assert.ElementsMatch(t, tc.expectedNames, matchedNames, + "filterQuery %q: unexpected items returned", tc.filterQuery) + }) + } + + t.Run("Invalid filter syntax returns error", func(t *testing.T) { + invalidFilter := "invalid <<