| <!DOCTYPE html> |
| <html lang="en"> |
| {{template "views/partials/head" .}} |
|
|
| <body class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]"> |
| <div class="flex flex-col min-h-screen" x-data="indexDashboard()"> |
|
|
| {{template "views/partials/navbar" .}} |
|
|
| |
| <div class="fixed top-20 right-4 z-50 space-y-2" style="max-width: 400px;"> |
| <template x-for="notification in notifications" :key="notification.id"> |
| <div x-show="true" |
| x-transition:enter="transition ease-out duration-200" |
| x-transition:enter-start="opacity-0" |
| x-transition:enter-end="opacity-100" |
| x-transition:leave="transition ease-in duration-150" |
| x-transition:leave-start="opacity-100" |
| x-transition:leave-end="opacity-0" |
| :class="notification.type === 'error' ? 'bg-red-500' : 'bg-[var(--color-success)]'" |
| class="rounded-lg p-4 text-white flex items-start space-x-3"> |
| <div class="flex-shrink-0"> |
| <i :class="notification.type === 'error' ? 'fas fa-exclamation-circle' : 'fas fa-check-circle'" class="text-xl"></i> |
| </div> |
| <div class="flex-1 min-w-0"> |
| <p class="text-sm font-medium break-words" x-text="notification.message"></p> |
| </div> |
| <button @click="dismissNotification(notification.id)" class="flex-shrink-0 text-white hover:opacity-80 transition-opacity"> |
| <i class="fas fa-times"></i> |
| </button> |
| </div> |
| </template> |
| </div> |
|
|
| <div class="container mx-auto px-4 py-8 flex-grow"> |
| |
| <div class="hero-section"> |
| <div class="hero-content"> |
| <h1 class="hero-title"> |
| Model & Backend Management |
| </h1> |
| <p class="hero-subtitle">Manage your installed models and backends</p> |
| |
| |
| <div class="flex flex-wrap justify-center gap-3"> |
| <a href="browse/" class="btn-primary text-sm py-1.5 px-3"> |
| <i class="fas fa-images mr-1.5 text-[10px]"></i> |
| <span>Model Gallery</span> |
| </a> |
| |
| <a href="/import-model" class="btn-primary text-sm py-1.5 px-3"> |
| <i class="fas fa-plus mr-1.5 text-[10px]"></i> |
| <span>Import Model</span> |
| </a> |
|
|
| <button id="reload-models-btn" class="btn-primary text-sm py-1.5 px-3"> |
| <i class="fas fa-sync-alt mr-1.5 text-[10px]"></i> |
| <span>Update Models</span> |
| </button> |
|
|
| <a href="/browse/backends" class="btn-secondary text-sm py-1.5 px-3"> |
| <i class="fas fa-cogs mr-1.5 text-[10px]"></i> |
| <span>Backend Gallery</span> |
| </a> |
|
|
| {{ if not .DisableRuntimeSettings }} |
| <a href="/settings" class="btn-secondary text-sm py-1.5 px-3"> |
| <i class="fas fa-cog mr-1.5 text-[10px]"></i> |
| <span>Settings</span> |
| </a> |
| {{ end }} |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="mt-8" x-data="resourceMonitor()" x-init="startPolling()"> |
| <template x-if="resourceData && resourceData.available"> |
| <div class="bg-[var(--color-bg-secondary)] border border-[var(--color-primary-border)]/20 rounded-lg p-4 mb-6"> |
| <div class="flex items-center justify-between mb-3"> |
| <h2 class="h3 flex items-center"> |
| <i :class="resourceData.type === 'gpu' ? 'fas fa-microchip' : 'fas fa-memory'" class="mr-2 text-[var(--color-primary)] text-sm"></i> |
| <span x-text="resourceData.type === 'gpu' ? 'GPU Status' : 'Memory Status'"></span> |
| </h2> |
| <div class="flex items-center gap-2 text-xs text-[var(--color-text-secondary)]"> |
| <template x-if="resourceData.type === 'gpu'"> |
| <span x-text="`${resourceData.aggregate.gpu_count} GPU${resourceData.aggregate.gpu_count > 1 ? 's' : ''}`"></span> |
| </template> |
| <template x-if="resourceData.type === 'ram'"> |
| <span>System RAM</span> |
| </template> |
| <template x-if="resourceData.reclaimer_enabled"> |
| <span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-[var(--color-primary)]/10 text-[var(--color-primary)]"> |
| <i class="fas fa-shield-alt text-[8px] mr-1"></i>Reclaimer Active |
| </span> |
| </template> |
| </div> |
| </div> |
| |
| |
| <template x-if="resourceData.type === 'gpu' && resourceData.gpus"> |
| <div class="space-y-3"> |
| <template x-for="gpu in resourceData.gpus" :key="gpu.index"> |
| <div class="bg-[var(--color-bg-primary)] rounded p-3"> |
| <div class="flex items-center justify-between mb-2"> |
| <div class="flex items-center gap-2"> |
| <span class="text-xs font-medium text-[var(--color-text-primary)] truncate max-w-[200px]" x-text="gpu.name"></span> |
| <span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium" |
| :class="gpu.vendor === 'nvidia' ? 'bg-green-500/10 text-green-300' : |
| gpu.vendor === 'amd' ? 'bg-red-500/10 text-red-300' : |
| gpu.vendor === 'intel' ? 'bg-blue-500/10 text-blue-300' : |
| 'bg-[var(--color-accent-light)] text-[var(--color-accent)]'" |
| x-text="gpu.vendor.toUpperCase()"> |
| </span> |
| </div> |
| <span class="text-xs font-mono" |
| :class="gpu.usage_percent > 90 ? 'text-red-400' : gpu.usage_percent > 70 ? 'text-yellow-400' : 'text-green-400'" |
| x-text="`${gpu.usage_percent.toFixed(1)}%`"></span> |
| </div> |
| |
| <div class="w-full bg-[var(--color-bg-secondary)] rounded-full h-2 overflow-hidden"> |
| <div class="h-full rounded-full transition-all duration-300" |
| :class="gpu.usage_percent > 90 ? 'bg-red-500' : gpu.usage_percent > 70 ? 'bg-yellow-500' : 'bg-[var(--color-success)]'" |
| :style="`width: ${gpu.usage_percent}%`"></div> |
| </div> |
| <div class="flex justify-between mt-1 text-[10px] text-[var(--color-text-secondary)]"> |
| <span x-text="`Used: ${formatBytes(gpu.used_vram)}`"></span> |
| <span x-text="`Total: ${formatBytes(gpu.total_vram)}`"></span> |
| </div> |
| </div> |
| </template> |
| </div> |
| </template> |
| |
| |
| <template x-if="resourceData.type === 'ram' && resourceData.ram"> |
| <div class="bg-[var(--color-bg-primary)] rounded p-3"> |
| <div class="flex items-center justify-between mb-2"> |
| <div class="flex items-center gap-2"> |
| <span class="text-xs font-medium text-[var(--color-text-primary)]">System RAM</span> |
| <span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-[var(--color-accent-light)] text-[var(--color-accent)]"> |
| RAM |
| </span> |
| </div> |
| <span class="text-xs font-mono" |
| :class="resourceData.ram.usage_percent > 90 ? 'text-red-400' : resourceData.ram.usage_percent > 70 ? 'text-yellow-400' : 'text-green-400'" |
| x-text="`${resourceData.ram.usage_percent.toFixed(1)}%`"></span> |
| </div> |
| |
| <div class="w-full bg-[var(--color-bg-secondary)] rounded-full h-2 overflow-hidden"> |
| <div class="h-full rounded-full transition-all duration-300" |
| :class="resourceData.ram.usage_percent > 90 ? 'bg-red-500' : resourceData.ram.usage_percent > 70 ? 'bg-yellow-500' : 'bg-[var(--color-success)]'" |
| :style="`width: ${resourceData.ram.usage_percent}%`"></div> |
| </div> |
| <div class="flex justify-between mt-1 text-[10px] text-[var(--color-text-secondary)]"> |
| <span x-text="`Used: ${formatBytes(resourceData.ram.used)}`"></span> |
| <span x-text="`Total: ${formatBytes(resourceData.ram.total)}`"></span> |
| </div> |
| </div> |
| </template> |
| |
| |
| <template x-if="resourceData.type === 'gpu' && resourceData.aggregate.gpu_count > 1"> |
| <div class="mt-3 pt-3 border-t border-[var(--color-primary-border)]/20"> |
| <div class="flex items-center justify-between text-xs"> |
| <span class="text-[var(--color-text-secondary)]">Total VRAM:</span> |
| <span class="font-mono text-[var(--color-text-primary)]" |
| x-text="`${formatBytes(resourceData.aggregate.used_memory)} / ${formatBytes(resourceData.aggregate.total_memory)} (${resourceData.aggregate.usage_percent.toFixed(1)}%)`"></span> |
| </div> |
| </div> |
| </template> |
| </div> |
| </template> |
| </div> |
|
|
| |
| <div class="models mt-8"> |
| {{template "views/partials/inprogress" .}} |
| |
| {{ if eq (len .ModelsConfig) 0 }} |
| |
| <div class="card p-8"> |
| <div class="text-center max-w-4xl mx-auto"> |
| <div class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-yellow-500/10 border border-yellow-500/20 mb-4"> |
| <i class="text-yellow-400 text-xl fas fa-robot"></i> |
| </div> |
| <h2 class="h2 mb-2">No models installed yet</h2> |
| <p class="text-sm text-[var(--color-text-secondary)] mb-6">Get started by installing a model from the gallery or importing it</p> |
| |
| <div class="flex flex-wrap justify-center gap-2 mb-6"> |
| <a href="browse" class="btn-primary text-sm py-1.5 px-3"> |
| <i class="fas fa-images mr-1.5 text-[10px]"></i> |
| Browse Model Gallery |
| </a> |
| <a href="/import-model" class="btn-primary text-sm py-1.5 px-3"> |
| <i class="fas fa-upload mr-1.5 text-[10px]"></i> |
| Import Model |
| </a> |
| <a href="https://localai.io/basics/getting_started/" target="_blank" class="btn-secondary text-sm py-1.5 px-3"> |
| <i class="fas fa-book mr-1.5 text-[10px]"></i> |
| Documentation |
| </a> |
| </div> |
|
|
| {{ if ne (len .Models) 0 }} |
| <div class="mt-8 pt-6 border-t border-[var(--color-primary-border)]/20"> |
| <h3 class="text-lg font-semibold text-[var(--color-text-primary)] mb-2 flex items-center"> |
| <i class="fas fa-file-alt mr-2 text-[var(--color-primary)] text-sm"></i> |
| Detected Model Files |
| </h3> |
| <p class="text-xs text-[var(--color-text-secondary)] mb-4">These models were found but don't have configuration files yet</p> |
| <div class="flex flex-wrap gap-2 justify-center"> |
| {{ range .Models }} |
| <div class="bg-[var(--color-bg-primary)] border border-[var(--color-primary-border)]/20 rounded px-2 py-1 flex items-center gap-2"> |
| <i class="fas fa-brain text-xs text-[var(--color-primary)]"></i> |
| <span class="text-xs text-[var(--color-text-primary)] font-medium">{{.}}</span> |
| </div> |
| {{end}} |
| </div> |
| </div> |
| {{end}} |
| </div> |
| </div> |
| {{ else }} |
| |
| {{ $modelsN := len .ModelsConfig}} |
| {{ $modelsN = add $modelsN (len .Models)}} |
| <div class="mb-6"> |
| <h2 class="h3 mb-1 flex items-center"> |
| <i class="fas fa-brain mr-2 text-[var(--color-primary)] text-sm"></i> |
| Installed Models |
| </h2> |
| <p class="text-sm text-[var(--color-text-secondary)] mb-4"> |
| <span class="text-[var(--color-primary)] font-medium">{{$modelsN}}</span> model{{if gt $modelsN 1}}s{{end}} ready to use |
| </p> |
| </div> |
| |
| <div class="overflow-x-auto mb-8"> |
| <table class="w-full border-collapse"> |
| <thead> |
| <tr class="border-b border-[var(--color-bg-secondary)]"> |
| <th class="text-left p-2 text-xs font-semibold text-[var(--color-text-secondary)]">Name</th> |
| <th class="text-left p-2 text-xs font-semibold text-[var(--color-text-secondary)]">Status</th> |
| <th class="text-left p-2 text-xs font-semibold text-[var(--color-text-secondary)]">Backend</th> |
| <th class="text-left p-2 text-xs font-semibold text-[var(--color-text-secondary)]">Use Cases</th> |
| <th class="text-right p-2 text-xs font-semibold text-[var(--color-text-secondary)]">Actions</th> |
| </tr> |
| </thead> |
| <tbody> |
| {{$galleryConfig:=.GalleryConfig}} |
| {{ $loadedModels := .LoadedModels }} |
| |
| {{ range .ModelsConfig }} |
| {{ $backendCfg := . }} |
| {{ $cfg:= index $galleryConfig .Name}} |
| <tr class="hover:bg-[var(--color-bg-secondary)]/50 border-b border-[var(--color-bg-secondary)] transition-colors"> |
| |
| <td class="p-2"> |
| <div class="flex items-center gap-2"> |
| <div class="relative flex-shrink-0"> |
| {{ if and $cfg $cfg.Icon }} |
| <img src="{{$cfg.Icon}}" class="w-4 h-4 object-contain" alt="{{.Name}} icon"> |
| {{ else }} |
| <i class="fas fa-brain text-xs text-[var(--color-primary)]"></i> |
| {{ end }} |
| {{ if index $loadedModels .Name }} |
| <div class="absolute -top-0.5 -right-0.5 w-2 h-2 bg-[var(--color-success)] rounded-full border border-[var(--color-bg-secondary)]"></div> |
| {{ end }} |
| </div> |
| <span class="text-xs text-[var(--color-text-primary)] font-medium truncate">{{.Name}}</span> |
| <a href="/models/edit/{{.Name}}" |
| class="text-[var(--color-primary)]/60 hover:text-[var(--color-primary)] hover:bg-[var(--color-primary)]/10 rounded p-0.5 transition-colors ml-1 flex-shrink-0" |
| title="Edit {{.Name}}"> |
| <i class="fas fa-edit text-[10px]"></i> |
| </a> |
| </div> |
| </td> |
| |
| |
| <td class="p-2"> |
| <div class="flex flex-wrap gap-1"> |
| {{ if index $loadedModels .Name }} |
| <span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-[var(--color-success)]/10 text-green-300"> |
| <i class="fas fa-circle text-[8px] mr-1"></i>Running |
| </span> |
| {{ end }} |
| {{ if and $backendCfg (or (ne $backendCfg.MCP.Servers "") (ne $backendCfg.MCP.Stdio "")) }} |
| <span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-[var(--color-accent-light)] text-[var(--color-accent)]"> |
| <i class="fas fa-plug text-[8px] mr-1"></i>MCP |
| </span> |
| {{ end }} |
| </div> |
| </td> |
| |
| |
| <td class="p-2"> |
| {{ if .Backend }} |
| <span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-[var(--color-primary)]/10 text-[var(--color-primary)]"> |
| <i class="fas fa-cog text-[8px] mr-1"></i>{{.Backend}} |
| </span> |
| {{ else }} |
| <span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-yellow-500/10 text-yellow-300"> |
| <i class="fas fa-magic text-[8px] mr-1"></i>Auto |
| </span> |
| {{ end }} |
| </td> |
| |
| |
| <td class="p-2"> |
| <div class="flex flex-wrap gap-1"> |
| {{ range .KnownUsecaseStrings }} |
| {{ if eq . "FLAG_CHAT" }} |
| <a href="chat/{{$backendCfg.Name}}" onclick="sessionStorage.setItem('localai_create_new_chat', 'true');" class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-[var(--color-primary)]/10 text-[var(--color-primary)] hover:bg-[var(--color-primary)]/20 transition-colors" title="Chat"> |
| <i class="fas fa-comment-alt text-[8px] mr-1"></i>Chat |
| </a> |
| {{ end }} |
| {{ if eq . "FLAG_IMAGE" }} |
| <a href="image/{{$backendCfg.Name}}" class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-[var(--color-success)]/10 text-green-300 hover:bg-[var(--color-success)]/20 transition-colors" title="Image"> |
| <i class="fas fa-image text-[8px] mr-1"></i>Image |
| </a> |
| {{ end }} |
| {{ if eq . "FLAG_TTS" }} |
| <a href="tts/{{$backendCfg.Name}}" class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-[var(--color-accent-light)] text-[var(--color-accent)] hover:bg-[var(--color-accent-light)] transition-colors" title="TTS"> |
| <i class="fas fa-microphone text-[8px] mr-1"></i>TTS |
| </a> |
| {{ end }} |
| {{ end }} |
| </div> |
| </td> |
| |
| |
| <td class="p-2"> |
| <div class="flex items-center justify-end gap-1"> |
| {{ if index $loadedModels .Name }} |
| <button class="text-red-400/60 hover:text-red-400 hover:bg-red-500/10 rounded p-1 transition-colors" |
| onclick="handleStopModel('{{.Name}}')" |
| title="Stop {{.Name}}"> |
| <i class="fas fa-stop text-xs"></i> |
| </button> |
| {{ end }} |
| <button class="text-red-400/60 hover:text-red-400 hover:bg-red-500/10 rounded p-1 transition-colors" |
| onclick="handleDeleteModel('{{.Name}}')" |
| title="Delete {{.Name}}"> |
| <i class="fas fa-trash-alt text-xs"></i> |
| </button> |
| </div> |
| </td> |
| </tr> |
| {{ end }} |
| |
| |
| {{ range .Models }} |
| <tr class="hover:bg-[var(--color-bg-secondary)]/50 border-b border-[var(--color-bg-secondary)] transition-colors"> |
| <td class="p-2"> |
| <div class="flex items-center gap-2"> |
| <i class="fas fa-brain text-xs text-[var(--color-text-secondary)]"></i> |
| <span class="text-xs text-[var(--color-text-primary)] font-medium truncate">{{.}}</span> |
| </div> |
| </td> |
| <td class="p-2"> |
| <span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-orange-500/10 text-orange-300"> |
| <i class="fas fa-exclamation-triangle text-[8px] mr-1"></i>No Config |
| </span> |
| </td> |
| <td class="p-2"> |
| <span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-yellow-500/10 text-yellow-300"> |
| <i class="fas fa-magic text-[8px] mr-1"></i>Auto |
| </span> |
| </td> |
| <td class="p-2"> |
| <span class="text-xs text-[var(--color-text-secondary)]">—</span> |
| </td> |
| <td class="p-2"> |
| <span class="text-xs text-[var(--color-text-secondary)]">—</span> |
| </td> |
| </tr> |
| {{end}} |
| </tbody> |
| </table> |
| </div> |
| {{ end }} |
| </div> |
|
|
| |
| <div class="mt-8"> |
| <div class="mb-6"> |
| <div class="flex items-center justify-between mb-1"> |
| <h2 class="h3 flex items-center"> |
| <i class="fas fa-cogs mr-2 text-[var(--color-accent)] text-sm"></i> |
| Installed Backends |
| </h2> |
| {{ if gt (len .InstalledBackends) 0 }} |
| <button |
| @click="reinstallAllBackends()" |
| :disabled="reinstallingAll" |
| class="btn-primary text-sm py-1.5 px-3" |
| title="Reinstall all backends"> |
| <i class="fas fa-arrow-rotate-right mr-1.5 text-[10px]" :class="reinstallingAll ? 'fa-spin' : ''"></i> |
| <span x-text="reinstallingAll ? 'Reinstalling...' : 'Reinstall All'"></span> |
| </button> |
| {{ end }} |
| </div> |
| <p class="text-sm text-[var(--color-text-secondary)] mb-4"> |
| <span class="text-[var(--color-accent)] font-medium">{{len .InstalledBackends}}</span> backend{{if gt (len .InstalledBackends) 1}}s{{end}} ready to use |
| </p> |
| </div> |
|
|
| {{ if eq (len .InstalledBackends) 0 }} |
| |
| <div class="card p-8"> |
| <div class="text-center max-w-4xl mx-auto"> |
| <div class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-[var(--color-accent-light)] border border-[var(--color-accent-border)] mb-4"> |
| <i class="text-[var(--color-accent)] text-xl fas fa-cogs"></i> |
| </div> |
| <h2 class="h2 mb-2">No backends installed yet</h2> |
| <p class="text-sm text-[var(--color-text-secondary)] mb-6">Backends power your AI models. Install them from the backend gallery to get started</p> |
| |
| <div class="flex flex-wrap justify-center gap-3"> |
| <a href="/browse/backends" class="btn-primary"> |
| <i class="fas fa-cogs mr-2 text-xs"></i> |
| Browse Backend Gallery |
| </a> |
| <a href="https://localai.io/backends/" target="_blank" class="btn-secondary"> |
| <i class="fas fa-book mr-2 text-xs"></i> |
| Documentation |
| </a> |
| </div> |
| </div> |
| </div> |
| {{ else }} |
| |
| <div class="overflow-x-auto mb-8"> |
| <table class="w-full border-collapse"> |
| <thead> |
| <tr class="border-b border-[var(--color-bg-secondary)]"> |
| <th class="text-left p-2 text-xs font-semibold text-[var(--color-text-secondary)]">Name</th> |
| <th class="text-left p-2 text-xs font-semibold text-[var(--color-text-secondary)]">Type</th> |
| <th class="text-left p-2 text-xs font-semibold text-[var(--color-text-secondary)]">Metadata</th> |
| <th class="text-right p-2 text-xs font-semibold text-[var(--color-text-secondary)]">Actions</th> |
| </tr> |
| </thead> |
| <tbody> |
| {{ range .InstalledBackends }} |
| <tr class="hover:bg-[var(--color-bg-secondary)]/50 border-b border-[var(--color-bg-secondary)] transition-colors" data-backend-name="{{.Name}}" data-is-system="{{.IsSystem}}"> |
| |
| <td class="p-2"> |
| <div class="flex items-center gap-2"> |
| <i class="fas fa-cog text-xs text-[var(--color-accent)]"></i> |
| <span class="text-xs text-[var(--color-text-primary)] font-medium truncate">{{.Name}}</span> |
| </div> |
| </td> |
| |
| |
| <td class="p-2"> |
| <div class="flex flex-wrap gap-1"> |
| {{ if .IsSystem }} |
| <span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-blue-500/10 text-blue-300"> |
| <i class="fas fa-shield-alt text-[8px] mr-1"></i>System |
| </span> |
| {{ else }} |
| <span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-[var(--color-success)]/10 text-green-300"> |
| <i class="fas fa-download text-[8px] mr-1"></i>User |
| </span> |
| {{ end }} |
| {{ if .IsMeta }} |
| <span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-[var(--color-accent-light)] text-[var(--color-accent)]"> |
| <i class="fas fa-layer-group text-[8px] mr-1"></i>Meta |
| </span> |
| {{ end }} |
| </div> |
| </td> |
| |
| |
| <td class="p-2"> |
| <div class="flex flex-col gap-1"> |
| {{ if and .Metadata .Metadata.Alias }} |
| <span class="text-xs text-[var(--color-text-secondary)]"> |
| <i class="fas fa-tag text-[8px] mr-1"></i>Alias: <span class="text-[var(--color-text-primary)]">{{.Metadata.Alias}}</span> |
| </span> |
| {{ end }} |
| {{ if and .Metadata .Metadata.MetaBackendFor }} |
| <span class="text-xs text-[var(--color-text-secondary)]"> |
| <i class="fas fa-link text-[8px] mr-1"></i>For: <span class="text-[var(--color-accent)]">{{.Metadata.MetaBackendFor}}</span> |
| </span> |
| {{ end }} |
| {{ if and .Metadata .Metadata.InstalledAt }} |
| <span class="text-xs text-[var(--color-text-secondary)]"> |
| <i class="fas fa-calendar text-[8px] mr-1"></i>{{.Metadata.InstalledAt}} |
| </span> |
| {{ end }} |
| </div> |
| </td> |
| |
| |
| <td class="p-2"> |
| <div class="flex items-center justify-end gap-1"> |
| {{ if not .IsSystem }} |
| <button |
| @click="reinstallBackend('{{.Name}}')" |
| :disabled="reinstallingBackends['{{.Name}}']" |
| class="text-[var(--color-primary)]/60 hover:text-[var(--color-primary)] hover:bg-[var(--color-primary)]/10 disabled:opacity-50 disabled:cursor-not-allowed rounded p-1 transition-colors" |
| title="Reinstall {{.Name}}"> |
| <i class="fas fa-arrow-rotate-right text-xs" :class="reinstallingBackends['{{.Name}}'] ? 'fa-spin' : ''"></i> |
| </button> |
| <button |
| @click="deleteBackend('{{.Name}}')" |
| class="text-red-400/60 hover:text-red-400 hover:bg-red-500/10 rounded p-1 transition-colors" |
| title="Delete {{.Name}}"> |
| <i class="fas fa-trash-alt text-xs"></i> |
| </button> |
| {{ else }} |
| <span class="text-xs text-[var(--color-text-secondary)]">—</span> |
| {{ end }} |
| </div> |
| </td> |
| </tr> |
| {{end}} |
| </tbody> |
| </table> |
| </div> |
| {{ end }} |
| </div> |
| </div> |
|
|
| {{template "views/partials/footer" .}} |
| </div> |
|
|
| <script> |
| |
| function resourceMonitor() { |
| return { |
| resourceData: null, |
| pollInterval: null, |
| |
| async fetchResourceData() { |
| try { |
| const response = await fetch('/api/resources'); |
| if (response.ok) { |
| this.resourceData = await response.json(); |
| } |
| } catch (error) { |
| console.error('Error fetching resource data:', error); |
| } |
| }, |
| |
| startPolling() { |
| |
| this.fetchResourceData(); |
| |
| this.pollInterval = setInterval(() => this.fetchResourceData(), 5000); |
| }, |
| |
| stopPolling() { |
| if (this.pollInterval) { |
| clearInterval(this.pollInterval); |
| } |
| } |
| } |
| } |
| |
| |
| function formatBytes(bytes) { |
| if (bytes === 0) return '0 B'; |
| const k = 1024; |
| const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; |
| const i = Math.floor(Math.log(bytes) / Math.log(k)); |
| return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; |
| } |
| |
| |
| function indexDashboard() { |
| return { |
| notifications: [], |
| reinstallingBackends: {}, |
| reinstallingAll: false, |
| backendJobs: {}, |
| |
| init() { |
| |
| setInterval(() => this.pollJobs(), 600); |
| }, |
| |
| addNotification(message, type = 'success') { |
| const id = Date.now(); |
| this.notifications.push({ id, message, type }); |
| |
| setTimeout(() => this.dismissNotification(id), 5000); |
| }, |
| |
| dismissNotification(id) { |
| this.notifications = this.notifications.filter(n => n.id !== id); |
| }, |
| |
| async reinstallBackend(backendName) { |
| if (this.reinstallingBackends[backendName]) { |
| return; |
| } |
| |
| try { |
| this.reinstallingBackends[backendName] = true; |
| const response = await fetch(`/api/backends/install/${encodeURIComponent(backendName)}`, { |
| method: 'POST' |
| }); |
| |
| const data = await response.json(); |
| |
| if (response.ok && data.jobID) { |
| this.backendJobs[backendName] = data.jobID; |
| this.addNotification(`Reinstalling backend "${backendName}"...`, 'success'); |
| } else { |
| this.reinstallingBackends[backendName] = false; |
| this.addNotification(`Failed to start reinstall: ${data.error || 'Unknown error'}`, 'error'); |
| } |
| } catch (error) { |
| console.error('Error reinstalling backend:', error); |
| this.reinstallingBackends[backendName] = false; |
| this.addNotification(`Failed to reinstall backend: ${error.message}`, 'error'); |
| } |
| }, |
| |
| async reinstallAllBackends() { |
| if (this.reinstallingAll) { |
| return; |
| } |
| |
| if (!confirm('Are you sure you want to reinstall all backends? This may take some time.')) { |
| return; |
| } |
| |
| this.reinstallingAll = true; |
| |
| |
| const backendRows = document.querySelectorAll('tr[data-backend-name]'); |
| const backendsToReinstall = []; |
| |
| backendRows.forEach(row => { |
| const backendName = row.getAttribute('data-backend-name'); |
| const isSystem = row.getAttribute('data-is-system') === 'true'; |
| if (backendName && !isSystem && !this.reinstallingBackends[backendName]) { |
| backendsToReinstall.push(backendName); |
| } |
| }); |
| |
| if (backendsToReinstall.length === 0) { |
| this.reinstallingAll = false; |
| this.addNotification('No backends available to reinstall', 'error'); |
| return; |
| } |
| |
| this.addNotification(`Starting reinstall of ${backendsToReinstall.length} backend(s)...`, 'success'); |
| |
| |
| for (const backendName of backendsToReinstall) { |
| await this.reinstallBackend(backendName); |
| |
| await new Promise(resolve => setTimeout(resolve, 500)); |
| } |
| |
| |
| |
| }, |
| |
| async pollJobs() { |
| for (const [backendName, jobID] of Object.entries(this.backendJobs)) { |
| try { |
| const response = await fetch(`/api/backends/job/${jobID}`); |
| const jobData = await response.json(); |
| |
| if (jobData.completed) { |
| delete this.backendJobs[backendName]; |
| this.reinstallingBackends[backendName] = false; |
| this.addNotification(`Backend "${backendName}" reinstalled successfully!`, 'success'); |
| |
| |
| if (!this.reinstallingAll && Object.keys(this.backendJobs).length === 0) { |
| setTimeout(() => { |
| window.location.reload(); |
| }, 1500); |
| } |
| } |
| |
| if (jobData.error || (jobData.message && jobData.message.startsWith('error:'))) { |
| delete this.backendJobs[backendName]; |
| this.reinstallingBackends[backendName] = false; |
| let errorMessage = 'Unknown error'; |
| if (typeof jobData.error === 'string') { |
| errorMessage = jobData.error; |
| } else if (jobData.error && typeof jobData.error === 'object') { |
| const errorKeys = Object.keys(jobData.error); |
| if (errorKeys.length > 0) { |
| errorMessage = jobData.error.message || jobData.error.error || jobData.error.Error || JSON.stringify(jobData.error); |
| } else { |
| errorMessage = jobData.message || 'Unknown error'; |
| } |
| } else if (jobData.message) { |
| errorMessage = jobData.message; |
| } |
| if (errorMessage.startsWith('error: ')) { |
| errorMessage = errorMessage.substring(7); |
| } |
| this.addNotification(`Error reinstalling backend "${backendName}": ${errorMessage}`, 'error'); |
| |
| |
| if (this.reinstallingAll && Object.keys(this.backendJobs).length === 0) { |
| this.reinstallingAll = false; |
| setTimeout(() => { |
| window.location.reload(); |
| }, 2000); |
| } |
| } |
| } catch (error) { |
| console.error('Error polling job:', error); |
| } |
| } |
| |
| |
| if (this.reinstallingAll && Object.keys(this.backendJobs).length === 0) { |
| this.reinstallingAll = false; |
| setTimeout(() => { |
| window.location.reload(); |
| }, 2000); |
| } |
| }, |
| |
| async deleteBackend(backendName) { |
| if (!confirm(`Are you sure you want to delete the backend "${backendName}"?`)) { |
| return; |
| } |
| |
| try { |
| const response = await fetch(`/api/backends/system/delete/${encodeURIComponent(backendName)}`, { |
| method: 'POST' |
| }); |
| |
| const data = await response.json(); |
| |
| if (response.ok && data.success) { |
| this.addNotification(`Backend "${backendName}" deleted successfully!`, 'success'); |
| |
| setTimeout(() => { |
| window.location.reload(); |
| }, 1500); |
| } else { |
| this.addNotification(`Failed to delete backend: ${data.error || 'Unknown error'}`, 'error'); |
| } |
| } catch (error) { |
| console.error('Error deleting backend:', error); |
| this.addNotification(`Failed to delete backend: ${error.message}`, 'error'); |
| } |
| } |
| } |
| } |
| |
| async function handleStopModel(modelName) { |
| if (!confirm('Are you sure you wish to stop this model?')) { |
| return; |
| } |
| |
| try { |
| const response = await fetch('/backend/shutdown', { |
| method: 'POST', |
| headers: { |
| 'Content-Type': 'application/json', |
| }, |
| body: JSON.stringify({ model: modelName }) |
| }); |
| |
| if (response.ok) { |
| window.location.reload(); |
| } else { |
| alert('Failed to stop model'); |
| } |
| } catch (error) { |
| console.error('Error stopping model:', error); |
| alert('Failed to stop model'); |
| } |
| } |
| |
| async function handleDeleteModel(modelName) { |
| if (!confirm('Are you sure you wish to delete this model?')) { |
| return; |
| } |
| |
| try { |
| const response = await fetch(`/api/models/delete/${encodeURIComponent(modelName)}`, { |
| method: 'POST' |
| }); |
| |
| if (response.ok) { |
| window.location.reload(); |
| } else { |
| alert('Failed to delete model'); |
| } |
| } catch (error) { |
| console.error('Error deleting model:', error); |
| alert('Failed to delete model'); |
| } |
| } |
| |
| |
| document.addEventListener('DOMContentLoaded', function() { |
| const reloadBtn = document.getElementById('reload-models-btn'); |
| if (reloadBtn) { |
| reloadBtn.addEventListener('click', function() { |
| const button = this; |
| const originalText = button.querySelector('span').textContent; |
| const icon = button.querySelector('i'); |
| |
| |
| button.disabled = true; |
| button.querySelector('span').textContent = 'Updating...'; |
| icon.classList.add('fa-spin'); |
| |
| |
| fetch('/models/reload', { |
| method: 'POST', |
| headers: { |
| 'Content-Type': 'application/json', |
| } |
| }) |
| .then(response => response.json()) |
| .then(data => { |
| if (data.success) { |
| |
| button.querySelector('span').textContent = 'Updated!'; |
| icon.classList.remove('fa-spin', 'fa-sync-alt'); |
| icon.classList.add('fa-check'); |
| |
| |
| setTimeout(() => { |
| window.location.reload(); |
| }, 1000); |
| } else { |
| |
| button.querySelector('span').textContent = 'Error!'; |
| icon.classList.remove('fa-spin'); |
| console.error('Failed to reload models:', data.error); |
| |
| |
| setTimeout(() => { |
| button.disabled = false; |
| button.querySelector('span').textContent = originalText; |
| icon.classList.remove('fa-check'); |
| icon.classList.add('fa-sync-alt'); |
| }, 3000); |
| } |
| }) |
| .catch(error => { |
| |
| button.querySelector('span').textContent = 'Error!'; |
| icon.classList.remove('fa-spin'); |
| console.error('Error reloading models:', error); |
| |
| |
| setTimeout(() => { |
| button.disabled = false; |
| button.querySelector('span').textContent = originalText; |
| icon.classList.remove('fa-check'); |
| icon.classList.add('fa-sync-alt'); |
| }, 3000); |
| }); |
| }); |
| } |
| }); |
| </script> |
|
|
| </body> |
| </html> |
|
|
|
|