feat(webui): show detected hardware in Performance tab
- commit
- d4c31caf23c5ffc97d66dd51e1c0890d49dbf26b
- author
- brice · · 2025-12-28 17:36 UTC
- committer
- brice · · 2025-12-29 17:28 UTC
- parent
- 80b95cf
- signature
- unsigned
2 files · +81 -14
M internal/infrastructure/webkit/scheme_handler.go +54 -13
| @@ -13,7 +13,9 @@ import ( | |||
| 13 | 13 | "strings" | |
| 14 | 14 | "sync" | |
| 15 | 15 | ||
| 16 | + | "github.com/bnema/dumber/internal/application/port" | |
| 16 | 17 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 18 | + | "github.com/bnema/dumber/internal/infrastructure/env" | |
| 17 | 19 | "github.com/bnema/dumber/internal/logging" | |
| 18 | 20 | "github.com/bnema/puregotk-webkit/soup" | |
| 19 | 21 | "github.com/bnema/puregotk-webkit/webkit" | |
| @@ -59,11 +61,13 @@ func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse { | |||
| 59 | 61 | ||
| 60 | 62 | // DumbSchemeHandler handles dumb:// URI scheme requests. | |
| 61 | 63 | type DumbSchemeHandler struct { | |
| 62 | - | handlers map[string]PageHandler | |
| 63 | - | assets embed.FS | |
| 64 | - | assetDir string // subdirectory within embed.FS (e.g., "assets/webui") | |
| 65 | - | logger zerolog.Logger | |
| 66 | - | mu sync.RWMutex | |
| 64 | + | handlers map[string]PageHandler | |
| 65 | + | assets embed.FS | |
| 66 | + | assetDir string // subdirectory within embed.FS (e.g., "assets/webui") | |
| 67 | + | logger zerolog.Logger | |
| 68 | + | mu sync.RWMutex | |
| 69 | + | hwSurveyor *env.HardwareSurveyor | |
| 70 | + | ctx context.Context | |
| 67 | 71 | } | |
| 68 | 72 | ||
| 69 | 73 | type configAppearancePayload struct { | |
| @@ -76,9 +80,19 @@ type configAppearancePayload struct { | |||
| 76 | 80 | DarkPalette config.ColorPalette `json:"dark_palette"` | |
| 77 | 81 | } | |
| 78 | 82 | ||
| 83 | + | type configHardwarePayload struct { | |
| 84 | + | CPUCores int `json:"cpu_cores"` | |
| 85 | + | CPUThreads int `json:"cpu_threads"` | |
| 86 | + | TotalRAMMB int `json:"total_ram_mb"` | |
| 87 | + | GPUVendor string `json:"gpu_vendor"` | |
| 88 | + | GPUName string `json:"gpu_name"` | |
| 89 | + | VRAMMB int `json:"vram_mb"` | |
| 90 | + | } | |
| 91 | + | ||
| 79 | 92 | type configPerformancePayload struct { | |
| 80 | 93 | Profile string `json:"profile"` | |
| 81 | 94 | Resolved configResolvedPerformance `json:"resolved"` | |
| 95 | + | Hardware configHardwarePayload `json:"hardware"` | |
| 82 | 96 | } | |
| 83 | 97 | ||
| 84 | 98 | // configResolvedPerformance shows the actual values that will be applied at startup. | |
| @@ -106,9 +120,11 @@ func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler { | |||
| 106 | 120 | log := logging.FromContext(ctx) | |
| 107 | 121 | ||
| 108 | 122 | h := &DumbSchemeHandler{ | |
| 109 | - | handlers: make(map[string]PageHandler), | |
| 110 | - | assetDir: "webui", | |
| 111 | - | logger: log.With().Str("component", "scheme-handler").Logger(), | |
| 123 | + | handlers: make(map[string]PageHandler), | |
| 124 | + | assetDir: "webui", | |
| 125 | + | logger: log.With().Str("component", "scheme-handler").Logger(), | |
| 126 | + | hwSurveyor: env.NewHardwareSurveyor(), | |
| 127 | + | ctx: ctx, | |
| 112 | 128 | } | |
| 113 | 129 | ||
| 114 | 130 | // Register default pages | |
| @@ -142,7 +158,7 @@ func (h *DumbSchemeHandler) registerDefaults() { | |||
| 142 | 158 | return nil | |
| 143 | 159 | } | |
| 144 | 160 | ||
| 145 | - | return buildConfigResponse(config.Get()) | |
| 161 | + | return h.buildConfigResponse(config.Get()) | |
| 146 | 162 | })) | |
| 147 | 163 | ||
| 148 | 164 | // API: Get default config (used by Reset Defaults in dumb://config) | |
| @@ -151,13 +167,22 @@ func (h *DumbSchemeHandler) registerDefaults() { | |||
| 151 | 167 | return nil | |
| 152 | 168 | } | |
| 153 | 169 | ||
| 154 | - | return buildConfigResponse(config.DefaultConfig()) | |
| 170 | + | return h.buildConfigResponse(config.DefaultConfig()) | |
| 155 | 171 | })) | |
| 156 | 172 | } | |
| 157 | 173 | ||
| 158 | - | func buildConfigResponse(cfg *config.Config) *SchemeResponse { | |
| 159 | - | // Resolve performance profile to get actual values | |
| 160 | - | resolved := config.ResolvePerformanceProfile(&cfg.Performance) | |
| 174 | + | func (h *DumbSchemeHandler) buildConfigResponse(cfg *config.Config) *SchemeResponse { | |
| 175 | + | // Get hardware info for display and profile resolution | |
| 176 | + | // Use background context since survey results are cached and we don't want | |
| 177 | + | // request context cancellation to affect this | |
| 178 | + | var hw *port.HardwareInfo | |
| 179 | + | if h.hwSurveyor != nil { | |
| 180 | + | hwInfo := h.hwSurveyor.Survey(context.Background()) | |
| 181 | + | hw = &hwInfo | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | // Resolve performance profile with hardware info | |
| 185 | + | resolved := config.ResolvePerformanceProfile(&cfg.Performance, hw) | |
| 161 | 186 | ||
| 162 | 187 | resp := configPayload{ | |
| 163 | 188 | DefaultUIScale: cfg.DefaultUIScale, | |
| @@ -184,6 +209,7 @@ func buildConfigResponse(cfg *config.Config) *SchemeResponse { | |||
| 184 | 209 | StrictThreshold: resolved.WebProcessMemoryStrictThreshold, | |
| 185 | 210 | KillThreshold: resolved.WebProcessMemoryKillThreshold, | |
| 186 | 211 | }, | |
| 212 | + | Hardware: buildHardwarePayload(hw), | |
| 187 | 213 | }, | |
| 188 | 214 | } | |
| 189 | 215 | ||
| @@ -203,6 +229,21 @@ func buildConfigResponse(cfg *config.Config) *SchemeResponse { | |||
| 203 | 229 | } | |
| 204 | 230 | } | |
| 205 | 231 | ||
| 232 | + | // buildHardwarePayload converts HardwareInfo to JSON payload. | |
| 233 | + | func buildHardwarePayload(hw *port.HardwareInfo) configHardwarePayload { | |
| 234 | + | if hw == nil { | |
| 235 | + | return configHardwarePayload{} | |
| 236 | + | } | |
| 237 | + | return configHardwarePayload{ | |
| 238 | + | CPUCores: hw.CPUCores, | |
| 239 | + | CPUThreads: hw.CPUThreads, | |
| 240 | + | TotalRAMMB: hw.TotalRAMMB(), | |
| 241 | + | GPUVendor: string(hw.GPUVendor), | |
| 242 | + | GPUName: hw.GPUName, | |
| 243 | + | VRAMMB: hw.VRAMMB(), | |
| 244 | + | } | |
| 245 | + | } | |
| 246 | + | ||
| 206 | 247 | // RegisterPage registers a handler for a specific path. | |
| 207 | 248 | func (h *DumbSchemeHandler) RegisterPage(path string, handler PageHandler) { | |
| 208 | 249 | h.mu.Lock() | |
| @@ -13,7 +13,9 @@ import ( | |||||
| 13 | "strings" | 13 | "strings" | ||
| 14 | "sync" | 14 | "sync" | ||
| 15 | 15 | ||||
| 16 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 16 | "github.com/bnema/dumber/internal/infrastructure/config" | 17 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 18 | + | "github.com/bnema/dumber/internal/infrastructure/env" | |||
| 17 | "github.com/bnema/dumber/internal/logging" | 19 | "github.com/bnema/dumber/internal/logging" | ||
| 18 | "github.com/bnema/puregotk-webkit/soup" | 20 | "github.com/bnema/puregotk-webkit/soup" | ||
| 19 | "github.com/bnema/puregotk-webkit/webkit" | 21 | "github.com/bnema/puregotk-webkit/webkit" | ||
| @@ -59,11 +61,13 @@ func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse { | |||||
| 59 | 61 | ||||
| 60 | // DumbSchemeHandler handles dumb:// URI scheme requests. | 62 | // DumbSchemeHandler handles dumb:// URI scheme requests. | ||
| 61 | type DumbSchemeHandler struct { | 63 | type DumbSchemeHandler struct { | ||
| 62 | - | handlers map[string]PageHandler | 64 | + | handlers map[string]PageHandler |
| 63 | - | assets embed.FS | 65 | + | assets embed.FS |
| 64 | - | assetDir string // subdirectory within embed.FS (e.g., "assets/webui") | 66 | + | assetDir string // subdirectory within embed.FS (e.g., "assets/webui") |
| 65 | - | logger zerolog.Logger | 67 | + | logger zerolog.Logger |
| 66 | - | mu sync.RWMutex | 68 | + | mu sync.RWMutex |
| 69 | + | hwSurveyor *env.HardwareSurveyor | |||
| 70 | + | ctx context.Context | |||
| 67 | } | 71 | } | ||
| 68 | 72 | ||||
| 69 | type configAppearancePayload struct { | 73 | type configAppearancePayload struct { | ||
| @@ -76,9 +80,19 @@ type configAppearancePayload struct { | |||||
| 76 | DarkPalette config.ColorPalette `json:"dark_palette"` | 80 | DarkPalette config.ColorPalette `json:"dark_palette"` | ||
| 77 | } | 81 | } | ||
| 78 | 82 | ||||
| 83 | + | type configHardwarePayload struct { | |||
| 84 | + | CPUCores int `json:"cpu_cores"` | |||
| 85 | + | CPUThreads int `json:"cpu_threads"` | |||
| 86 | + | TotalRAMMB int `json:"total_ram_mb"` | |||
| 87 | + | GPUVendor string `json:"gpu_vendor"` | |||
| 88 | + | GPUName string `json:"gpu_name"` | |||
| 89 | + | VRAMMB int `json:"vram_mb"` | |||
| 90 | + | } | |||
| 91 | + | ||||
| 79 | type configPerformancePayload struct { | 92 | type configPerformancePayload struct { | ||
| 80 | Profile string `json:"profile"` | 93 | Profile string `json:"profile"` | ||
| 81 | Resolved configResolvedPerformance `json:"resolved"` | 94 | Resolved configResolvedPerformance `json:"resolved"` | ||
| 95 | + | Hardware configHardwarePayload `json:"hardware"` | |||
| 82 | } | 96 | } | ||
| 83 | 97 | ||||
| 84 | // configResolvedPerformance shows the actual values that will be applied at startup. | 98 | // configResolvedPerformance shows the actual values that will be applied at startup. | ||
| @@ -106,9 +120,11 @@ func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler { | |||||
| 106 | log := logging.FromContext(ctx) | 120 | log := logging.FromContext(ctx) | ||
| 107 | 121 | ||||
| 108 | h := &DumbSchemeHandler{ | 122 | h := &DumbSchemeHandler{ | ||
| 109 | - | handlers: make(map[string]PageHandler), | 123 | + | handlers: make(map[string]PageHandler), |
| 110 | - | assetDir: "webui", | 124 | + | assetDir: "webui", |
| 111 | - | logger: log.With().Str("component", "scheme-handler").Logger(), | 125 | + | logger: log.With().Str("component", "scheme-handler").Logger(), |
| 126 | + | hwSurveyor: env.NewHardwareSurveyor(), | |||
| 127 | + | ctx: ctx, | |||
| 112 | } | 128 | } | ||
| 113 | 129 | ||||
| 114 | // Register default pages | 130 | // Register default pages | ||
| @@ -142,7 +158,7 @@ func (h *DumbSchemeHandler) registerDefaults() { | |||||
| 142 | return nil | 158 | return nil | ||
| 143 | } | 159 | } | ||
| 144 | 160 | ||||
| 145 | - | return buildConfigResponse(config.Get()) | 161 | + | return h.buildConfigResponse(config.Get()) |
| 146 | })) | 162 | })) | ||
| 147 | 163 | ||||
| 148 | // API: Get default config (used by Reset Defaults in dumb://config) | 164 | // API: Get default config (used by Reset Defaults in dumb://config) | ||
| @@ -151,13 +167,22 @@ func (h *DumbSchemeHandler) registerDefaults() { | |||||
| 151 | return nil | 167 | return nil | ||
| 152 | } | 168 | } | ||
| 153 | 169 | ||||
| 154 | - | return buildConfigResponse(config.DefaultConfig()) | 170 | + | return h.buildConfigResponse(config.DefaultConfig()) |
| 155 | })) | 171 | })) | ||
| 156 | } | 172 | } | ||
| 157 | 173 | ||||
| 158 | - | func buildConfigResponse(cfg *config.Config) *SchemeResponse { | 174 | + | func (h *DumbSchemeHandler) buildConfigResponse(cfg *config.Config) *SchemeResponse { |
| 159 | - | // Resolve performance profile to get actual values | 175 | + | // Get hardware info for display and profile resolution |
| 160 | - | resolved := config.ResolvePerformanceProfile(&cfg.Performance) | 176 | + | // Use background context since survey results are cached and we don't want |
| 177 | + | // request context cancellation to affect this | |||
| 178 | + | var hw *port.HardwareInfo | |||
| 179 | + | if h.hwSurveyor != nil { | |||
| 180 | + | hwInfo := h.hwSurveyor.Survey(context.Background()) | |||
| 181 | + | hw = &hwInfo | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | // Resolve performance profile with hardware info | |||
| 185 | + | resolved := config.ResolvePerformanceProfile(&cfg.Performance, hw) | |||
| 161 | 186 | ||||
| 162 | resp := configPayload{ | 187 | resp := configPayload{ | ||
| 163 | DefaultUIScale: cfg.DefaultUIScale, | 188 | DefaultUIScale: cfg.DefaultUIScale, | ||
| @@ -184,6 +209,7 @@ func buildConfigResponse(cfg *config.Config) *SchemeResponse { | |||||
| 184 | StrictThreshold: resolved.WebProcessMemoryStrictThreshold, | 209 | StrictThreshold: resolved.WebProcessMemoryStrictThreshold, | ||
| 185 | KillThreshold: resolved.WebProcessMemoryKillThreshold, | 210 | KillThreshold: resolved.WebProcessMemoryKillThreshold, | ||
| 186 | }, | 211 | }, | ||
| 212 | + | Hardware: buildHardwarePayload(hw), | |||
| 187 | }, | 213 | }, | ||
| 188 | } | 214 | } | ||
| 189 | 215 | ||||
| @@ -203,6 +229,21 @@ func buildConfigResponse(cfg *config.Config) *SchemeResponse { | |||||
| 203 | } | 229 | } | ||
| 204 | } | 230 | } | ||
| 205 | 231 | ||||
| 232 | + | // buildHardwarePayload converts HardwareInfo to JSON payload. | |||
| 233 | + | func buildHardwarePayload(hw *port.HardwareInfo) configHardwarePayload { | |||
| 234 | + | if hw == nil { | |||
| 235 | + | return configHardwarePayload{} | |||
| 236 | + | } | |||
| 237 | + | return configHardwarePayload{ | |||
| 238 | + | CPUCores: hw.CPUCores, | |||
| 239 | + | CPUThreads: hw.CPUThreads, | |||
| 240 | + | TotalRAMMB: hw.TotalRAMMB(), | |||
| 241 | + | GPUVendor: string(hw.GPUVendor), | |||
| 242 | + | GPUName: hw.GPUName, | |||
| 243 | + | VRAMMB: hw.VRAMMB(), | |||
| 244 | + | } | |||
| 245 | + | } | |||
| 246 | + | ||||
| 206 | // RegisterPage registers a handler for a specific path. | 247 | // RegisterPage registers a handler for a specific path. | ||
| 207 | func (h *DumbSchemeHandler) RegisterPage(path string, handler PageHandler) { | 248 | func (h *DumbSchemeHandler) RegisterPage(path string, handler PageHandler) { | ||
| 208 | h.mu.Lock() | 249 | h.mu.Lock() | ||
M webui/src/pages/ConfigPage.svelte +27 -1
| @@ -37,9 +37,19 @@ | |||
| 37 | 37 | kill_threshold: number; | |
| 38 | 38 | }; | |
| 39 | 39 | ||
| 40 | + | type HardwareInfo = { | |
| 41 | + | cpu_cores: number; | |
| 42 | + | cpu_threads: number; | |
| 43 | + | total_ram_mb: number; | |
| 44 | + | gpu_vendor: string; | |
| 45 | + | gpu_name: string; | |
| 46 | + | vram_mb: number; | |
| 47 | + | }; | |
| 48 | + | ||
| 40 | 49 | type PerformanceConfig = { | |
| 41 | 50 | profile: string; | |
| 42 | 51 | resolved: ResolvedPerformance; | |
| 52 | + | hardware: HardwareInfo; | |
| 43 | 53 | }; | |
| 44 | 54 | ||
| 45 | 55 | type ConfigDTO = { | |
| @@ -61,7 +71,7 @@ | |||
| 61 | 71 | const PERFORMANCE_PROFILES = [ | |
| 62 | 72 | { value: "default", label: "Default", description: "No tuning, uses WebKit defaults. Recommended for most users." }, | |
| 63 | 73 | { value: "lite", label: "Lite", description: "Reduced resource usage for low-RAM systems (< 4GB) or battery saving." }, | |
| 64 | - | { value: "max", label: "Max", description: "Maximum responsiveness for heavy pages (GitHub PRs, complex SPAs)." }, | |
| 74 | + | { value: "max", label: "Max", description: "Maximum responsiveness, scales based on detected hardware." }, | |
| 65 | 75 | { value: "custom", label: "Custom", description: "Manual control via config file. Edit config.toml to set individual values." }, | |
| 66 | 76 | ]; | |
| 67 | 77 | ||
| @@ -415,6 +425,22 @@ | |||
| 415 | 425 | </Card.Description> | |
| 416 | 426 | </Card.Header> | |
| 417 | 427 | <Card.Content class="space-y-6"> | |
| 428 | + | <!-- Detected Hardware Card --> | |
| 429 | + | {#if config.performance?.hardware} | |
| 430 | + | {@const hw = config.performance.hardware} | |
| 431 | + | <div class="rounded-md border border-border bg-muted/30 px-4 py-3"> | |
| 432 | + | <div class="mb-2 text-xs font-semibold uppercase tracking-[0.15em] text-muted-foreground"> | |
| 433 | + | Detected Hardware | |
| 434 | + | </div> | |
| 435 | + | <div class="flex flex-wrap gap-x-6 gap-y-1 text-sm"> | |
| 436 | + | <div><span class="text-muted-foreground">CPU:</span> <span class="font-mono">{hw.cpu_cores}c/{hw.cpu_threads}t</span></div> | |
| 437 | + | <div><span class="text-muted-foreground">RAM:</span> <span class="font-mono">{hw.total_ram_mb > 0 ? (hw.total_ram_mb / 1024).toFixed(0) + ' GB' : '?'}</span></div> | |
| 438 | + | <div><span class="text-muted-foreground">GPU:</span> <span class="font-mono capitalize">{hw.gpu_vendor || 'Unknown'}</span></div> | |
| 439 | + | <div><span class="text-muted-foreground">VRAM:</span> <span class="font-mono">{hw.vram_mb > 0 ? (hw.vram_mb / 1024).toFixed(0) + ' GB' : '?'}</span></div> | |
| 440 | + | </div> | |
| 441 | + | </div> | |
| 442 | + | {/if} | |
| 443 | + | ||
| 418 | 444 | <!-- Experimental feature warning --> | |
| 419 | 445 | <div class="flex items-start gap-3 rounded-md border border-info/30 bg-info/10 px-4 py-3"> | |
| 420 | 446 | <span class="text-info">🧪</span> | |
| @@ -37,9 +37,19 @@ | |||||
| 37 | kill_threshold: number; | 37 | kill_threshold: number; | ||
| 38 | }; | 38 | }; | ||
| 39 | 39 | ||||
| 40 | + | type HardwareInfo = { | |||
| 41 | + | cpu_cores: number; | |||
| 42 | + | cpu_threads: number; | |||
| 43 | + | total_ram_mb: number; | |||
| 44 | + | gpu_vendor: string; | |||
| 45 | + | gpu_name: string; | |||
| 46 | + | vram_mb: number; | |||
| 47 | + | }; | |||
| 48 | + | ||||
| 40 | type PerformanceConfig = { | 49 | type PerformanceConfig = { | ||
| 41 | profile: string; | 50 | profile: string; | ||
| 42 | resolved: ResolvedPerformance; | 51 | resolved: ResolvedPerformance; | ||
| 52 | + | hardware: HardwareInfo; | |||
| 43 | }; | 53 | }; | ||
| 44 | 54 | ||||
| 45 | type ConfigDTO = { | 55 | type ConfigDTO = { | ||
| @@ -61,7 +71,7 @@ | |||||
| 61 | const PERFORMANCE_PROFILES = [ | 71 | const PERFORMANCE_PROFILES = [ | ||
| 62 | { value: "default", label: "Default", description: "No tuning, uses WebKit defaults. Recommended for most users." }, | 72 | { value: "default", label: "Default", description: "No tuning, uses WebKit defaults. Recommended for most users." }, | ||
| 63 | { value: "lite", label: "Lite", description: "Reduced resource usage for low-RAM systems (< 4GB) or battery saving." }, | 73 | { value: "lite", label: "Lite", description: "Reduced resource usage for low-RAM systems (< 4GB) or battery saving." }, | ||
| 64 | - | { value: "max", label: "Max", description: "Maximum responsiveness for heavy pages (GitHub PRs, complex SPAs)." }, | 74 | + | { value: "max", label: "Max", description: "Maximum responsiveness, scales based on detected hardware." }, |
| 65 | { value: "custom", label: "Custom", description: "Manual control via config file. Edit config.toml to set individual values." }, | 75 | { value: "custom", label: "Custom", description: "Manual control via config file. Edit config.toml to set individual values." }, | ||
| 66 | ]; | 76 | ]; | ||
| 67 | 77 | ||||
| @@ -415,6 +425,22 @@ | |||||
| 415 | </Card.Description> | 425 | </Card.Description> | ||
| 416 | </Card.Header> | 426 | </Card.Header> | ||
| 417 | <Card.Content class="space-y-6"> | 427 | <Card.Content class="space-y-6"> | ||
| 428 | + | <!-- Detected Hardware Card --> | |||
| 429 | + | {#if config.performance?.hardware} | |||
| 430 | + | {@const hw = config.performance.hardware} | |||
| 431 | + | <div class="rounded-md border border-border bg-muted/30 px-4 py-3"> | |||
| 432 | + | <div class="mb-2 text-xs font-semibold uppercase tracking-[0.15em] text-muted-foreground"> | |||
| 433 | + | Detected Hardware | |||
| 434 | + | </div> | |||
| 435 | + | <div class="flex flex-wrap gap-x-6 gap-y-1 text-sm"> | |||
| 436 | + | <div><span class="text-muted-foreground">CPU:</span> <span class="font-mono">{hw.cpu_cores}c/{hw.cpu_threads}t</span></div> | |||
| 437 | + | <div><span class="text-muted-foreground">RAM:</span> <span class="font-mono">{hw.total_ram_mb > 0 ? (hw.total_ram_mb / 1024).toFixed(0) + ' GB' : '?'}</span></div> | |||
| 438 | + | <div><span class="text-muted-foreground">GPU:</span> <span class="font-mono capitalize">{hw.gpu_vendor || 'Unknown'}</span></div> | |||
| 439 | + | <div><span class="text-muted-foreground">VRAM:</span> <span class="font-mono">{hw.vram_mb > 0 ? (hw.vram_mb / 1024).toFixed(0) + ' GB' : '?'}</span></div> | |||
| 440 | + | </div> | |||
| 441 | + | </div> | |||
| 442 | + | {/if} | |||
| 443 | + | ||||
| 418 | <!-- Experimental feature warning --> | 444 | <!-- Experimental feature warning --> | ||
| 419 | <div class="flex items-start gap-3 rounded-md border border-info/30 bg-info/10 px-4 py-3"> | 445 | <div class="flex items-start gap-3 rounded-md border border-info/30 bg-info/10 px-4 py-3"> | ||
| 420 | <span class="text-info">🧪</span> | 446 | <span class="text-info">🧪</span> | ||