diff --git a/Containerfile b/Containerfile
index ad38980..49bec68 100644
--- a/Containerfile
+++ b/Containerfile
@@ -1,29 +1,31 @@
-# Multi-stage Next.js 14 build
-ARG NEXT_PUBLIC_AICONFIGURATOR_API_URL=https://aiconfigurator.dev
+# Build from repo root:
+# podman build -f Containerfile -t gpu-calc-v2:latest .
-FROM node:20-alpine AS builder
+FROM node:20-slim AS builder
-WORKDIR /app
+RUN apt-get update && \
+ apt-get install -y --no-install-recommends git && \
+ rm -rf /var/lib/apt/lists/*
+
+# NEXT_PUBLIC_* vars are inlined at build time — must be set here, not at runtime
+ARG NEXT_PUBLIC_AICONFIGURATOR_API_URL=https://aic-backend.apps.ocp4.intlab.redhat.com
+ENV NEXT_PUBLIC_AICONFIGURATOR_API_URL=${NEXT_PUBLIC_AICONFIGURATOR_API_URL}
+WORKDIR /src
COPY package.json package-lock.json ./
RUN npm ci
-
COPY . .
RUN NEXT_PUBLIC_AICONFIGURATOR_API_URL=${NEXT_PUBLIC_AICONFIGURATOR_API_URL} npm run build
-# Runtime stage
-FROM node:20-alpine
-
+FROM node:20-slim
WORKDIR /app
-
ENV NODE_ENV=production
-RUN addgroup -g 1001 -S configiq && adduser -S configiq -u 1001
-
-COPY --from=builder --chown=configiq:configiq /app/.next/standalone ./
-COPY --from=builder --chown=configiq:configiq /app/.next/static ./.next/static
-COPY --from=builder --chown=configiq:configiq /app/public ./public
+ENV HOSTNAME=0.0.0.0
+ENV PORT=3000
-USER configiq
+COPY --from=builder /src/public ./public
+COPY --from=builder /src/.next/standalone ./
+COPY --from=builder /src/.next/static ./.next/static
EXPOSE 3000
diff --git a/app/gpu-explorer/GpuBubbleChart.tsx b/app/gpu-explorer/GpuBubbleChart.tsx
index 1cec602..d8743b7 100644
--- a/app/gpu-explorer/GpuBubbleChart.tsx
+++ b/app/gpu-explorer/GpuBubbleChart.tsx
@@ -49,10 +49,8 @@ export function GpuBubbleChart({ data, width, height, xLabel, yLabel }: Props) {
const yValues = data.map(d => d.y);
const sizeValues = data.map(d => d.size);
- const xMin = 0;
const rawXMax = Math.max(0, ...xValues);
const xMax = Number.isFinite(rawXMax) && rawXMax > 0 ? rawXMax * 1.1 : 1;
- const yMin = 0;
const rawYMax = Math.max(0, ...yValues);
const yMax = Number.isFinite(rawYMax) && rawYMax > 0 ? rawYMax * 1.1 : 1;
const sizeMin = sizeValues.length === 0 ? 0 : Math.min(...sizeValues);
@@ -157,7 +155,7 @@ export function GpuBubbleChart({ data, width, height, xLabel, yLabel }: Props) {
x={padding.left + scaleX(tick)}
y={padding.top + chartHeight + 20}
textAnchor="middle"
- fontSize={12}
+ fontSize={13}
fill="#3c3f42"
fontFamily="var(--font-sans, sans-serif)"
>
@@ -169,7 +167,7 @@ export function GpuBubbleChart({ data, width, height, xLabel, yLabel }: Props) {
x={padding.left + chartWidth / 2}
y={height - 10}
textAnchor="middle"
- fontSize={13}
+ fontSize={16}
fontWeight={500}
fill="#151515"
fontFamily="var(--font-sans, sans-serif)"
@@ -201,7 +199,7 @@ export function GpuBubbleChart({ data, width, height, xLabel, yLabel }: Props) {
y={padding.top + scaleY(tick)}
textAnchor="end"
dominantBaseline="middle"
- fontSize={12}
+ fontSize={13}
fill="#3c3f42"
fontFamily="var(--font-sans, sans-serif)"
>
@@ -213,7 +211,7 @@ export function GpuBubbleChart({ data, width, height, xLabel, yLabel }: Props) {
x={15}
y={padding.top + chartHeight / 2}
textAnchor="middle"
- fontSize={13}
+ fontSize={16}
fontWeight={500}
fill="#151515"
fontFamily="var(--font-sans, sans-serif)"
@@ -265,7 +263,7 @@ export function GpuBubbleChart({ data, width, height, xLabel, yLabel }: Props) {
{/* Tooltip - follows mouse cursor */}
{hoveredIndex !== null && (() => {
const tooltipWidth = 260;
- const tooltipHeight = 180;
+ const tooltipHeight = 160;
const tooltipX = mousePos.x + 15 > width - tooltipWidth ? mousePos.x - tooltipWidth - 15 : mousePos.x + 15;
const tooltipY = mousePos.y - 90 < 0 ? mousePos.y + 20 : mousePos.y - 90;
@@ -281,7 +279,7 @@ export function GpuBubbleChart({ data, width, height, xLabel, yLabel }: Props) {
background: 'rgba(255, 255, 255, 0.98)',
border: '2px solid #151515',
borderRadius: '8px',
- padding: '14px 16px',
+ padding: '14px 16px 0px 16px',
fontSize: '13px',
lineHeight: '1.7',
boxShadow: '0 6px 16px rgba(0,0,0,0.2)',
@@ -296,10 +294,10 @@ export function GpuBubbleChart({ data, width, height, xLabel, yLabel }: Props) {
VRAM: {data[hoveredIndex].vram} GB
Memory BW: {data[hoveredIndex].memBW?.toFixed(1)} GB/s
-
Hardware Cost: ${data[hoveredIndex].hwCost?.toLocaleString()}
+ {/*
Hardware Cost: ${data[hoveredIndex].hwCost?.toLocaleString()}
*/}
Architecture: {data[hoveredIndex].architecture}
TFLOPS (BF16): {data[hoveredIndex].tflops}
-
Tokens/$: {data[hoveredIndex].tokensPerDollar?.toLocaleString()}
+ {/*
Tokens/$: {data[hoveredIndex].tokensPerDollar?.toLocaleString()}
*/}
diff --git a/app/gpu-explorer/page.tsx b/app/gpu-explorer/page.tsx
index 4f38354..505a25a 100644
--- a/app/gpu-explorer/page.tsx
+++ b/app/gpu-explorer/page.tsx
@@ -131,7 +131,7 @@ export default function GpuExplorerPage() {
- {/* Presets */}
+ {/* Presets — hidden until all presets are functional
Preset:
@@ -142,8 +142,9 @@ export default function GpuExplorerPage() {
setPreset('performance')} />
+ */}
- {/* Vendor Filter */}
+ {/* Vendor Filter — hidden until needed
@@ -171,6 +172,7 @@ export default function GpuExplorerPage() {
+ */}
{/* Axis Selectors */}
@@ -181,7 +183,7 @@ export default function GpuExplorerPage() {
setXAxis('vram')} />
- setXAxis('price')} isDisabled />
+ {/* setXAxis('price')} isDisabled /> */}
setXAxis('throughput-index')} />
setXAxis('mem-bw')} />
@@ -193,7 +195,7 @@ export default function GpuExplorerPage() {
setYAxis('vram')} />
- setYAxis('price')} isDisabled />
+ {/* setYAxis('price')} isDisabled /> */}
setYAxis('throughput-index')} />
setYAxis('mem-bw')} />
@@ -204,11 +206,11 @@ export default function GpuExplorerPage() {
{/* Chart */}
{!mounted || isLoading ? (
-
+
Loading GPU catalog…
) : allData.length === 0 ? (
-
+
No data available
Try selecting a different vendor filter
diff --git a/app/kv-cache/KvCacheCalc.tsx b/app/kv-cache/KvCacheCalc.tsx
index e80a22e..0c666dc 100644
--- a/app/kv-cache/KvCacheCalc.tsx
+++ b/app/kv-cache/KvCacheCalc.tsx
@@ -8,7 +8,7 @@ import { useCountUp } from '@/app/performance/quickEstimateHelpers'
import { useAicCatalog } from '@/lib/hooks/useAicCatalog'
import { useSettings, type InferenceBackend } from '@/contexts/SettingsContext'
import { getAppConfig } from '@/lib/app-config'
-import { ModelInput, type ModelStatus } from '@/components/ui/ModelInput'
+import { ComboBox, type ComboBoxItem } from '@/components/ModelComboBox/ModelComboBox'
import { GpuSystemInput } from '@/components/ui/GpuSystemInput'
import type { KvCacheCalcResult } from '@/lib/api/kv-cache-calc'
import styles from './KvCacheCalc.module.css'
@@ -23,10 +23,16 @@ const BREAKDOWN_COLORS: Record
= {
}
export default function KvCacheCalc() {
- const { hydrated, defaultModel: settingsDefaultModel, inferenceBackend, backendVersion: settingsBackendVersion } = useSettings()
+ const { hydrated, hfToken, defaultModel: settingsDefaultModel, inferenceBackend, backendVersion: settingsBackendVersion } = useSettings()
const { modelOptions: aicModels, gpuOptions: aicGpus, isLoading: catalogLoading } = useAicCatalog()
const MODEL_OPTIONS = aicModels
+ const modelItems: ComboBoxItem[] = React.useMemo(() =>
+ aicModels.map(m => {
+ const slash = m.indexOf('/');
+ return { value: m, label: m, group: slash > 0 ? m.slice(0, slash) : '' };
+ }), [aicModels]);
+
const [model, setModel] = React.useState('')
const [system, setSystem] = React.useState(() => getAppConfig().defaultSystem)
const [backend, setBackend] = React.useState(() => getAppConfig().defaultBackend)
@@ -111,11 +117,6 @@ export default function KvCacheCalc() {
};
const catalogMatch = MODEL_OPTIONS.includes(model)
- const kvModelStatus: ModelStatus = getAppConfig().supportedModels.includes(model)
- ? 'supported'
- : catalogMatch ? 'catalog'
- : catalogLoading ? 'fetching'
- : model ? 'idle' : 'idle'
async function handleCalculate() {
setLoading(true)
@@ -194,13 +195,15 @@ export default function KvCacheCalc() {
-
diff --git a/app/performance/PerformanceEstimate.tsx b/app/performance/PerformanceEstimate.tsx
index 6442ff1..75f45fd 100644
--- a/app/performance/PerformanceEstimate.tsx
+++ b/app/performance/PerformanceEstimate.tsx
@@ -28,7 +28,7 @@ import { fetchModelConfig, type HFModelConfig } from '@/lib/huggingface/fetch-co
import { saveEstimate, getSavedEstimateCount } from '@/lib/saved-estimates';
import { fetchEstimateAsInferenceResult, EstimateError } from '@/lib/api/estimate-adapter';
import { InfoStrip, InfoStripAction } from '@/components/ui/InfoStrip';
-import { ModelInput, type ModelStatus } from '@/components/ui/ModelInput';
+import { ComboBox, type ComboBoxItem } from '@/components/ModelComboBox/ModelComboBox';
import { GpuSystemInput } from '@/components/ui/GpuSystemInput';
import { useAicCatalog } from '@/lib/hooks/useAicCatalog';
import { GpuChipLoader } from '@/components/GpuChipLoader/GpuChipLoader';
@@ -78,6 +78,12 @@ export default function QuickEstimate() {
const { hydrated, hfToken, defaultModel: settingsDefaultModel, inferenceBackend, backendVersion } = useSettings();
const { gpuOptions: aicGpus, modelOptions: aicModels, modelSpecs, isLoading: catalogLoading } = useAicCatalog();
+ const modelItems: ComboBoxItem[] = React.useMemo(() =>
+ aicModels.map(m => {
+ const slash = m.indexOf('/');
+ return { value: m, label: m, group: slash > 0 ? m.slice(0, slash) : '' };
+ }), [aicModels]);
+
const [model, setModel] = React.useState('');
const [gpu, setGpu] = React.useState(() => getAppConfig().defaultSystem);
@@ -113,18 +119,6 @@ export default function QuickEstimate() {
const [isUsingFallback, setIsUsingFallback] = React.useState(false);
const [fallbackReason, setFallbackReason] = React.useState
('');
- const modelStatus: ModelStatus = getAppConfig().supportedModels.includes(model)
- ? 'supported'
- : aicModels.includes(model)
- ? 'catalog'
- : isFetchingConfig || catalogLoading
- ? 'fetching'
- : hfConfig
- ? 'fetched'
- : testError
- ? 'error'
- : 'idle';
-
// Collapsible state for "Why this GPU count?" card
const [whyGpuExpanded, setWhyGpuExpanded] = React.useState(false);
@@ -735,22 +729,22 @@ export default function QuickEstimate() {
},
],
},
- {
- id: 'hardware', title: 'Hardware',
- summary: [{ k: 'GPU', v: currentAicGpu ? gpuOptionLabel(currentAicGpu.label, currentAicGpu.vramGb) : gpu }],
- fields: [
- {
- label: 'GPU type',
- value: gpu,
- type: 'select' as const,
- options: aicGpus.map(g => gpuOptionLabel(g.label, g.vramGb)),
- onChange: (val: string) => {
- const match = aicGpus.find(g => gpuOptionLabel(g.label, g.vramGb) === val)
- if (match) setGpu(match.systemId)
- }
- },
- ],
- },
+ // {
+ // id: 'hardware', title: 'Hardware',
+ // summary: [{ k: 'GPU', v: currentAicGpu ? gpuOptionLabel(currentAicGpu.label, currentAicGpu.vramGb) : gpu }],
+ // fields: [
+ // {
+ // label: 'GPU type',
+ // value: gpu,
+ // type: 'select' as const,
+ // options: aicGpus.map(g => gpuOptionLabel(g.label, g.vramGb)),
+ // onChange: (val: string) => {
+ // const match = aicGpus.find(g => gpuOptionLabel(g.label, g.vramGb) === val)
+ // if (match) setGpu(match.systemId)
+ // }
+ // },
+ // ],
+ // },
{
id: 'parallel',
title: 'Parallelism',
@@ -916,14 +910,15 @@ export default function QuickEstimate() {
{/* Column 1: Model field */}
-
diff --git a/app/recommend/AdvancedEstimate.tsx b/app/recommend/AdvancedEstimate.tsx
index 5db969c..6a1a822 100644
--- a/app/recommend/AdvancedEstimate.tsx
+++ b/app/recommend/AdvancedEstimate.tsx
@@ -13,12 +13,11 @@ import CheckCircleIcon from '@patternfly/react-icons/dist/esm/icons/check-circle
import { InfoStrip, InfoStripAction } from '@/components/ui/InfoStrip';
import styles from './AdvancedEstimate.module.css';
-import { fetchModelConfig } from '@/lib/huggingface/fetch-config';
import { useGpuSizer } from '@/contexts/GpuSizerContext';
import { useAicCatalog } from '@/lib/hooks/useAicCatalog';
import { useSettings } from '@/contexts/SettingsContext';
import { getAppConfig } from '@/lib/app-config';
-import { ModelInput } from '@/components/ui/ModelInput';
+import { ComboBox, type ComboBoxItem } from '@/components/ModelComboBox/ModelComboBox';
import { GpuSystemInput } from '@/components/ui/GpuSystemInput';
function modelSuggestions(): string {
@@ -136,6 +135,12 @@ export default function AdvancedEstimate() {
const { modelOptions: aicModels, gpuOptions: aicGpus, isLoading: catalogLoading } = useAicCatalog();
const MODEL_OPTIONS = aicModels;
+ const modelItems: ComboBoxItem[] = React.useMemo(() =>
+ aicModels.map(m => {
+ const slash = m.indexOf('/');
+ return { value: m, label: m, group: slash > 0 ? m.slice(0, slash) : '' };
+ }), [aicModels]);
+
// Input state
const [model, setModel] = React.useState('');
@@ -151,9 +156,6 @@ export default function AdvancedEstimate() {
const [osl, setOsl] = React.useState(128);
const [ttft, setTtft] = React.useState(1000);
- // Model status + HF config
- const [modelStatus, setModelStatus] = React.useState<'idle' | 'supported' | 'catalog' | 'fetching' | 'fetched' | 'error'>('idle');
-
// GPU sizer (persistent across navigation)
const { isLoading, result, error, errorCode, elapsed, debugRequest, debugResponse, debugStatus, debugDuration, startSizing } = useGpuSizer();
const [debugOpen, setDebugOpen] = React.useState(false);
@@ -194,22 +196,6 @@ export default function AdvancedEstimate() {
};
- // Model status check + fetch HF config
- React.useEffect(() => {
- if (catalogLoading) { setModelStatus('idle'); return; }
- const timer = setTimeout(() => {
- if (!model.includes('/')) { setModelStatus('idle'); return; }
- if (getAppConfig().supportedModels.includes(model)) { setModelStatus('supported'); return; }
- const inCatalog = MODEL_OPTIONS.includes(model);
- if (inCatalog) { setModelStatus('catalog'); return; }
- setModelStatus('fetching');
- fetchModelConfig(model, hfToken).then(r => {
- setModelStatus(r.success && r.config ? 'fetched' : 'error');
- });
- }, 500);
- return () => clearTimeout(timer);
- }, [model, hfToken, MODEL_OPTIONS, catalogLoading, hydrated]);
-
// Fetch live pricing
React.useEffect(() => {
const fetchPricing = async () => {
@@ -267,15 +253,15 @@ export default function AdvancedEstimate() {
{/* Model + GPU row */}
-
diff --git a/app/settings/Settings.tsx b/app/settings/Settings.tsx
index 6e857b2..217ec0d 100644
--- a/app/settings/Settings.tsx
+++ b/app/settings/Settings.tsx
@@ -135,7 +135,7 @@ export function Settings() {
Tested models
-
+
{validatedOpen ? '▲' : '▼'}
diff --git a/components/ModelComboBox/ModelComboBox.module.css b/components/ModelComboBox/ModelComboBox.module.css
new file mode 100644
index 0000000..5b9f232
--- /dev/null
+++ b/components/ModelComboBox/ModelComboBox.module.css
@@ -0,0 +1,218 @@
+.wrapper {
+ width: 100%;
+}
+
+.toggleAnchor {
+ position: relative;
+}
+
+.labelRow {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 6px;
+}
+
+.label {
+ font-size: 12px;
+ font-weight: 600;
+ font-family: var(--mono);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--t2, #3c3f42);
+ line-height: 1.2;
+ margin: 0;
+}
+
+.wrapper :global(.pf-v5-c-switch) {
+ --pf-v5-c-switch--Height: 18px !important;
+ --pf-v5-c-switch__toggle--Width: 32px !important;
+ --pf-v5-c-switch__toggle--Height: 16px !important;
+ --pf-v5-c-switch__toggle--BorderRadius: 9px !important;
+ font-size: 11.5px !important;
+}
+
+.wrapper :global(.pf-v5-c-switch__label) {
+ font-size: 11.5px !important;
+ font-weight: 500;
+}
+
+.wrapper :global(.pf-v5-c-switch__toggle) {
+ width: 32px !important;
+ height: 16px !important;
+}
+
+.wrapper :global(.pf-v5-c-switch__toggle::before) {
+ width: 12px !important;
+ height: 12px !important;
+}
+
+.wrapper :global(.pf-v5-c-switch__input:focus-visible ~ .pf-v5-c-switch__toggle) {
+ outline: none !important;
+ box-shadow: none !important;
+}
+
+.toggle {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ height: 42px;
+ border: 1px solid #b8bbbe;
+ border-radius: 4px;
+ padding: 0;
+ background: #ffffff;
+ cursor: text;
+ box-sizing: border-box;
+ transition: border-color 0.15s;
+}
+.toggle:focus-within {
+ border-color: var(--blue, #0066cc);
+}
+.toggleOpen {
+ border-radius: 4px 4px 0 0;
+ border-color: var(--blue, #0066cc);
+}
+
+.input {
+ flex: 1;
+ height: 100%;
+ border: none;
+ outline: none;
+ padding: 0 0 0 12px;
+ font-size: 14px;
+ font-family: var(--sans);
+ color: var(--t, #151515);
+ background: transparent;
+ min-width: 0;
+}
+.input::placeholder {
+ color: #6a6e73;
+}
+
+.chevron {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 100%;
+ cursor: pointer;
+ color: #6a6e73;
+ flex-shrink: 0;
+ transition: transform 0.2s;
+}
+.chevronOpen {
+ transform: rotate(180deg);
+}
+
+.clear {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 100%;
+ cursor: pointer;
+ color: #6a6e73;
+ border: none;
+ background: none;
+ padding: 0;
+ flex-shrink: 0;
+}
+.clear:hover {
+ color: #151515;
+}
+
+/* ─── Dropdown panel ─── */
+.menu {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ max-height: 320px;
+ overflow-y: auto;
+ background: #ffffff;
+ border: 1px solid var(--blue, #0066cc);
+ border-top: none;
+ border-radius: 0 0 4px 4px;
+ z-index: 1000;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
+ padding: 4px 0;
+}
+
+.groupLabel {
+ padding: 8px 12px 4px;
+ font-size: 11.5px;
+ font-family: var(--mono, monospace);
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: #3c3f42;
+ user-select: none;
+}
+
+.option {
+ display: flex;
+ align-items: center;
+ padding: 8px 12px;
+ font-size: 14px;
+ font-family: var(--sans);
+ color: #151515;
+ cursor: pointer;
+ user-select: none;
+}
+.option:hover,
+.optionFocused {
+ background: rgba(0, 102, 204, 0.08);
+}
+.optionSelected {
+ background: rgba(0, 102, 204, 0.12);
+ font-weight: 500;
+}
+
+.optionVendor {
+ color: #6a6e73;
+ margin-right: 2px;
+}
+.optionName {
+ color: #151515;
+}
+
+.customOption {
+ border-top: 1px solid #d2d2d2;
+ margin-top: 4px;
+ padding-top: 8px;
+ font-style: italic;
+ color: #3c3f42;
+}
+.customOptionLabel {
+ font-family: var(--mono, monospace);
+ font-size: 12px;
+ font-weight: 500;
+ color: #0066cc;
+ margin-right: 6px;
+ font-style: normal;
+}
+
+.empty {
+ padding: 12px;
+ font-size: 13px;
+ color: #6a6e73;
+ text-align: center;
+}
+
+.matchHighlight {
+ background: rgba(240, 171, 0, 0.25);
+ border-radius: 1px;
+}
+
+.helperText {
+ font-size: 12px;
+ color: var(--gc-text-3, #6a6e73);
+ margin-top: 4px;
+}
+
+.requestLink {
+ color: var(--blue, #0066cc);
+}
+.requestLink:hover {
+ text-decoration: underline;
+}
diff --git a/components/ModelComboBox/ModelComboBox.tsx b/components/ModelComboBox/ModelComboBox.tsx
new file mode 100644
index 0000000..bd05c4d
--- /dev/null
+++ b/components/ModelComboBox/ModelComboBox.tsx
@@ -0,0 +1,354 @@
+'use client'
+
+import * as React from 'react'
+import { Switch } from '@patternfly/react-core'
+import { getAppConfig } from '@/lib/app-config'
+import styles from './ModelComboBox.module.css'
+
+export interface ComboBoxItem {
+ value: string
+ label: string
+ group: string
+}
+
+interface ComboBoxProps {
+ value: string
+ onChange: (value: string) => void
+ items: ComboBoxItem[]
+ placeholder?: string
+ id?: string
+ allowCustom?: boolean
+ supportedModels?: string[]
+ hfToken?: string
+ helperText?: React.ReactNode
+}
+
+interface GroupedItems {
+ group: string
+ items: ComboBoxItem[]
+}
+
+function groupItems(items: ComboBoxItem[]): GroupedItems[] {
+ const map = new Map
()
+ for (const item of items) {
+ if (!map.has(item.group)) map.set(item.group, [])
+ map.get(item.group)!.push(item)
+ }
+ return Array.from(map, ([group, items]) => ({ group, items }))
+}
+
+function highlightMatch(text: string, query: string) {
+ if (!query) return <>{text}>
+ const idx = text.toLowerCase().indexOf(query.toLowerCase())
+ if (idx < 0) return <>{text}>
+ return (
+ <>
+ {text.slice(0, idx)}
+ {text.slice(idx, idx + query.length)}
+ {text.slice(idx + query.length)}
+ >
+ )
+}
+
+function suggestedNames(): string {
+ const names = getAppConfig().suggestedModelNames
+ return names.length > 0 ? names.join(', ') : 'Nemotron, DeepSeek V4, Gemma 4, Kimi'
+}
+
+export function ComboBox({ value, onChange, items, placeholder, id, allowCustom = false, supportedModels, hfToken, helperText }: ComboBoxProps) {
+ const [open, setOpen] = React.useState(false)
+ const [filter, setFilter] = React.useState('')
+ const [supportedOnly, setSupportedOnly] = React.useState(false)
+ const prevModel = React.useRef(value)
+ const [focusIndex, setFocusIndex] = React.useState(-1)
+ const wrapperRef = React.useRef(null)
+ const inputRef = React.useRef(null)
+ const menuRef = React.useRef(null)
+
+ const validatedItems = React.useMemo(() =>
+ supportedModels ? items.filter(i => supportedModels.includes(i.value)) : items,
+ [items, supportedModels])
+
+ const activeItems = supportedOnly ? validatedItems : items
+
+ const handleToggle = (_: React.FormEvent, checked: boolean) => {
+ setSupportedOnly(checked)
+ if (checked) {
+ prevModel.current = value
+ if (!supportedModels?.includes(value) && validatedItems.length > 0) {
+ onChange(validatedItems[0].value)
+ }
+ } else {
+ onChange(prevModel.current)
+ }
+ }
+
+ const selectedItem = activeItems.find(i => i.value === value)
+ const displayValue = open ? filter : (selectedItem?.label ?? value)
+
+ const filtered = React.useMemo(() => {
+ if (!filter) return activeItems
+ const q = filter.toLowerCase()
+ return activeItems.filter(i =>
+ i.label.toLowerCase().includes(q) ||
+ i.value.toLowerCase().includes(q) ||
+ i.group.toLowerCase().includes(q)
+ )
+ }, [activeItems, filter])
+
+ const groups = React.useMemo(() => groupItems(filtered), [filtered])
+
+ const flatItems = React.useMemo(() => {
+ const flat: ComboBoxItem[] = []
+ for (const g of groups) flat.push(...g.items)
+ return flat
+ }, [groups])
+
+ const exactMatch = items.some(i => i.value.toLowerCase() === filter.toLowerCase() || i.label.toLowerCase() === filter.toLowerCase())
+ const showCustom = allowCustom && open && filter.trim() && !exactMatch
+
+ React.useEffect(() => {
+ function handleClickOutside(e: MouseEvent) {
+ if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
+ setOpen(false)
+ setFilter('')
+ setFocusIndex(-1)
+ }
+ }
+ document.addEventListener('mousedown', handleClickOutside)
+ return () => document.removeEventListener('mousedown', handleClickOutside)
+ }, [])
+
+ React.useEffect(() => {
+ if (open && focusIndex >= 0 && menuRef.current) {
+ const item = menuRef.current.querySelector(`[data-index="${focusIndex}"]`)
+ item?.scrollIntoView({ block: 'nearest' })
+ }
+ }, [open, focusIndex])
+
+ function select(val: string) {
+ onChange(val)
+ setOpen(false)
+ setFilter('')
+ setFocusIndex(-1)
+ inputRef.current?.blur()
+ }
+
+ function handleInputChange(e: React.ChangeEvent) {
+ setFilter(e.target.value)
+ setFocusIndex(-1)
+ if (!open) setOpen(true)
+ }
+
+ function handleInputFocus() {
+ setOpen(true)
+ setFilter('')
+ }
+
+ function handleKeyDown(e: React.KeyboardEvent) {
+ const totalItems = flatItems.length + (showCustom ? 1 : 0)
+
+ switch (e.key) {
+ case 'ArrowDown':
+ e.preventDefault()
+ if (totalItems === 0) { if (!open) setOpen(true); break }
+ setFocusIndex(prev => (prev + 1) % totalItems)
+ if (!open) setOpen(true)
+ break
+ case 'ArrowUp':
+ e.preventDefault()
+ if (totalItems === 0) { if (!open) setOpen(true); break }
+ setFocusIndex(prev => (prev <= 0 ? totalItems - 1 : prev - 1))
+ if (!open) setOpen(true)
+ break
+ case 'Enter':
+ e.preventDefault()
+ if (focusIndex >= 0 && focusIndex < flatItems.length) {
+ select(flatItems[focusIndex].value)
+ } else if (focusIndex === flatItems.length && showCustom) {
+ select(filter.trim())
+ } else if (allowCustom && filter.trim()) {
+ select(filter.trim())
+ } else if (flatItems.length === 1) {
+ select(flatItems[0].value)
+ }
+ break
+ case 'Escape':
+ setOpen(false)
+ setFilter('')
+ setFocusIndex(-1)
+ inputRef.current?.blur()
+ break
+ case 'Tab':
+ setOpen(false)
+ setFilter('')
+ setFocusIndex(-1)
+ break
+ }
+ }
+
+ function handleClear(e: React.MouseEvent) {
+ e.stopPropagation()
+ onChange('')
+ setFilter('')
+ setFocusIndex(-1)
+ inputRef.current?.focus()
+ }
+
+ function handleChevronClick(e: React.MouseEvent) {
+ e.stopPropagation()
+ if (open) {
+ setOpen(false)
+ setFilter('')
+ setFocusIndex(-1)
+ } else {
+ setOpen(true)
+ setFilter('')
+ inputRef.current?.focus()
+ }
+ }
+
+ return (
+
+
+
+ {supportedModels && (
+
+ )}
+
+
+
+
= 0 ? `opt-${focusIndex}` : undefined}
+ />
+ {value && !open && (
+
+ )}
+
+
+
+ {open && (
+
+ {groups.length === 0 && !showCustom && (
+
No matches
+ )}
+
+ {groups.map(group => (
+
+ {group.group && (
+ {group.group}
+ )}
+ {group.items.map(item => {
+ const idx = flatItems.indexOf(item)
+ const isSelected = item.value === value
+ const isFocused = idx === focusIndex
+ return (
+ { e.preventDefault(); select(item.value) }}
+ onMouseEnter={() => setFocusIndex(idx)}
+ >
+
+ {highlightMatch(item.label, filter)}
+
+
+ )
+ })}
+
+ ))}
+
+ {showCustom && (
+
{ e.preventDefault(); select(filter.trim()) }}
+ onMouseEnter={() => setFocusIndex(flatItems.length)}
+ >
+ Use:
+ {filter.trim()}
+
+ )}
+
+ )}
+
+
+ {supportedModels && (
+
+ {helperText ?? (supportedOnly ? (
+
Tested: {suggestedNames()}, ... — type to autocomplete
+ ) : (
+ <>
+
Tested: {suggestedNames()}, ... — type to autocomplete
+ {value && !supportedModels.includes(value) && getAppConfig().modelRequestUrl && (
+
+ )}
+ {hfToken ? (
+
HF token active
+ ) : (
+
+ )}
+ >
+ )
+ )}
+
+ )}
+
+ )
+}
diff --git a/components/ui/GpuSystemInput.module.css b/components/ui/GpuSystemInput.module.css
index 8e9cd9e..6d85d7d 100644
--- a/components/ui/GpuSystemInput.module.css
+++ b/components/ui/GpuSystemInput.module.css
@@ -1,6 +1,7 @@
.wrapper {
display: flex;
flex-direction: column;
+ position: relative;
}
.label {
@@ -14,6 +15,40 @@
margin: 0 0 6px 0;
}
+.toggle {
+ position: absolute;
+ top: -2px;
+ right: 10px;
+}
+
+.toggle :global(.pf-v5-c-switch__input:focus-visible ~ .pf-v5-c-switch__toggle) {
+ outline: none !important;
+ box-shadow: none !important;
+}
+
+.wrapper :global(.pf-v5-c-switch) {
+ --pf-v5-c-switch--Height: 18px !important;
+ --pf-v5-c-switch__toggle--Width: 32px !important;
+ --pf-v5-c-switch__toggle--Height: 16px !important;
+ --pf-v5-c-switch__toggle--BorderRadius: 9px !important;
+ font-size: 11px !important;
+}
+
+.wrapper :global(.pf-v5-c-switch__label) {
+ font-size: 11px !important;
+ font-weight: 500;
+}
+
+.wrapper :global(.pf-v5-c-switch__toggle) {
+ width: 32px !important;
+ height: 16px !important;
+}
+
+.wrapper :global(.pf-v5-c-switch__toggle::before) {
+ width: 12px !important;
+ height: 12px !important;
+}
+
.select {
width: 100%;
height: 42px;
diff --git a/components/ui/GpuSystemInput.tsx b/components/ui/GpuSystemInput.tsx
index 8fb7031..e2eec52 100644
--- a/components/ui/GpuSystemInput.tsx
+++ b/components/ui/GpuSystemInput.tsx
@@ -1,6 +1,8 @@
'use client'
import * as React from 'react'
+import { Switch } from '@patternfly/react-core'
+import { getAppConfig } from '@/lib/app-config'
import type { GpuOption } from '@/lib/hooks/useAicCatalog'
import styles from './GpuSystemInput.module.css'
@@ -12,23 +14,69 @@ interface GpuSystemInputProps {
}
export function GpuSystemInput({ id, value, onChange, gpuOptions }: GpuSystemInputProps) {
- const current = gpuOptions.find(g => g.systemId === value)
+ const [supportedOnly, setSupportedOnly] = React.useState(false)
+ const prevGpu = React.useRef(value)
+
+ const cfg = getAppConfig()
+ const supportedSystems = cfg.supportedSystems ?? []
+ const validatedOptions = gpuOptions.filter(g => supportedSystems.includes(g.systemId))
+ const activeOptions = supportedOnly ? validatedOptions : gpuOptions
+ const current = activeOptions.find(g => g.systemId === value)
+
+ const handleToggle = (_: React.FormEvent, checked: boolean) => {
+ setSupportedOnly(checked)
+ if (checked) {
+ prevGpu.current = value
+ if (!supportedSystems.includes(value) && validatedOptions.length > 0) {
+ onChange(validatedOptions[0].systemId)
+ }
+ } else {
+ onChange(prevGpu.current)
+ }
+ }
+
+ const vendorGroups = React.useMemo(() => {
+ const groups = new Map()
+ for (const g of activeOptions) {
+ const key = g.vendor ?? 'other'
+ const list = groups.get(key)
+ if (list) list.push(g)
+ else groups.set(key, [g])
+ }
+ return groups
+ }, [activeOptions])
return (
+
+
+
diff --git a/components/ui/ModelInput.module.css b/components/ui/ModelInput.module.css
index b3dd0dc..64bc93a 100644
--- a/components/ui/ModelInput.module.css
+++ b/components/ui/ModelInput.module.css
@@ -31,11 +31,11 @@
--pf-v5-c-switch__toggle--Width: 32px !important;
--pf-v5-c-switch__toggle--Height: 16px !important;
--pf-v5-c-switch__toggle--BorderRadius: 9px !important;
- font-size: 11px !important;
+ font-size: 11.5px !important;
}
.wrapper :global(.pf-v5-c-switch__label) {
- font-size: 11px !important;
+ font-size: 11.5px !important;
font-weight: 500;
}
diff --git a/lib/app-config.ts b/lib/app-config.ts
index 44fd81e..41ef1fc 100644
--- a/lib/app-config.ts
+++ b/lib/app-config.ts
@@ -6,6 +6,7 @@ export interface AppConfig {
defaultBackend: string;
backendVersions: Record
;
supportedModels: string[];
+ supportedSystems: string[];
suggestedModelNames: string[];
modelRequestUrl: string;
}
@@ -23,6 +24,7 @@ const FALLBACK: AppConfig = {
'sglang': '0.5.17',
},
supportedModels: [],
+ supportedSystems: [],
suggestedModelNames: [],
modelRequestUrl: '',
};
@@ -43,6 +45,7 @@ export async function loadAppConfig(): Promise {
defaultBackend: data.defaultBackend ?? FALLBACK.defaultBackend,
backendVersions: data.backendVersions ?? FALLBACK.backendVersions,
supportedModels: data.supportedModels ?? FALLBACK.supportedModels,
+ supportedSystems: data.supportedSystems ?? FALLBACK.supportedSystems,
suggestedModelNames: data.suggestedModelNames ?? FALLBACK.suggestedModelNames,
modelRequestUrl: data.modelRequestUrl ?? FALLBACK.modelRequestUrl,
};
diff --git a/public/config.json b/public/config.json
index 76e5a6b..0afcb3e 100644
--- a/public/config.json
+++ b/public/config.json
@@ -10,6 +10,10 @@
"sglang": "0.5.17"
},
"suggestedModelNames": ["Gemma 4", "Nemotron Super", "DeepSeek V4", "Kimi K3"],
+ "supportedSystems": [
+ "h200_sxm",
+ "b200_sxm"
+ ],
"supportedModels": [
"google/gemma-4-26B-A4B",
"nvidia/Llama-3_3-Nemotron-Super-49B-v1",