|
|
<!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> |
|
|
|
|
|
|