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",