Spaces:
Running
Running
Amlan-109
feat: Initial commit of LocalAI Amlan Edition with premium branding and personalization
750bbe6
| <html lang="en"> | |
| {{template "views/partials/head" .}} | |
| <body class="bg-[#101827] text-[#E5E7EB]"> | |
| <div class="flex flex-col min-h-screen" x-data="backendsGallery()"> | |
| {{template "views/partials/navbar" .}} | |
| <!-- Notifications --> | |
| <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-green-500'" | |
| 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"> | |
| <!-- Hero Header --> | |
| <div class="hero-section"> | |
| <div class="hero-content"> | |
| <h1 class="hero-title"> | |
| Backend Management | |
| </h1> | |
| <p class="hero-subtitle"> | |
| Discover and install AI backends to power your models | |
| </p> | |
| <div class="flex flex-wrap justify-center items-center gap-6 text-sm md:text-base"> | |
| <div class="flex items-center bg-[#101827] rounded-lg px-4 py-2"> | |
| <div class="w-2 h-2 bg-emerald-400 rounded-full mr-2"></div> | |
| <span class="font-semibold text-emerald-300" x-text="availableBackends"></span> | |
| <span class="text-[#94A3B8] ml-1">backends available</span> | |
| </div> | |
| <a href="/manage" class="flex items-center bg-[#101827] hover:bg-[#1E293B] rounded-lg px-4 py-2 transition-colors border border-[#8B5CF6]/30 hover:border-[#8B5CF6]/50"> | |
| <div class="w-2 h-2 bg-cyan-400 rounded-full mr-2"></div> | |
| <span class="font-semibold text-cyan-300" x-text="installedBackends"></span> | |
| <span class="text-[#94A3B8] ml-1">installed</span> | |
| </a> | |
| <div class="flex items-center bg-[#101827] rounded-lg px-4 py-2 border border-[#38BDF8]/30"> | |
| <i class="fas fa-microchip text-[#38BDF8] mr-2"></i> | |
| <span class="text-[#94A3B8] mr-1">Capability:</span> | |
| <span class="font-semibold text-[#38BDF8]" x-text="systemCapability"></span> | |
| </div> | |
| <a href="https://localai.io/backends/" target="_blank" class="btn-primary"> | |
| <i class="fas fa-info-circle mr-2"></i> | |
| <span>Documentation</span> | |
| <i class="fas fa-external-link-alt ml-2 text-xs"></i> | |
| </a> | |
| </div> | |
| </div> | |
| </div> | |
| {{template "views/partials/inprogress" .}} | |
| <!-- Manual Backend Installation Form (Collapsible) --> | |
| <div class="card p-6 mb-8"> | |
| <button | |
| @click="showManualInstall = !showManualInstall" | |
| class="w-full flex items-center justify-between text-left" | |
| > | |
| <div class="flex items-center gap-2"> | |
| <i class="fas fa-plus-circle text-[#38BDF8] text-lg"></i> | |
| <h3 class="text-lg font-semibold text-[#E5E7EB]">Install Backend Manually</h3> | |
| </div> | |
| <i class="fas text-[#94A3B8] transition-transform duration-200" :class="showManualInstall ? 'fa-chevron-up' : 'fa-chevron-down'"></i> | |
| </button> | |
| <div x-show="showManualInstall" x-collapse> | |
| <p class="text-sm text-[#94A3B8] mt-4 mb-6">Install a backend from an OCI image, URL, or local path</p> | |
| <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4"> | |
| <div> | |
| <label class="block text-sm font-medium text-[#94A3B8] mb-2">OCI Image / URL / Path *</label> | |
| <input | |
| type="text" | |
| x-model="externalBackend.uri" | |
| placeholder="e.g., oci://quay.io/example/backend:latest" | |
| class="w-full px-4 py-3 text-sm bg-[#101827] border border-[#38BDF8]/30 rounded-lg text-[#E5E7EB] placeholder-[#94A3B8]/50 focus:border-[#38BDF8] focus:outline-none focus:ring-1 focus:ring-[#38BDF8]" | |
| > | |
| </div> | |
| <div> | |
| <label class="block text-sm font-medium text-[#94A3B8] mb-2">Name (required for OCI)</label> | |
| <input | |
| type="text" | |
| x-model="externalBackend.name" | |
| placeholder="e.g., my-backend" | |
| class="w-full px-4 py-3 text-sm bg-[#101827] border border-[#38BDF8]/30 rounded-lg text-[#E5E7EB] placeholder-[#94A3B8]/50 focus:border-[#38BDF8] focus:outline-none focus:ring-1 focus:ring-[#38BDF8]" | |
| > | |
| </div> | |
| <div> | |
| <label class="block text-sm font-medium text-[#94A3B8] mb-2">Alias (optional)</label> | |
| <input | |
| type="text" | |
| x-model="externalBackend.alias" | |
| placeholder="e.g., backend-alias" | |
| class="w-full px-4 py-3 text-sm bg-[#101827] border border-[#38BDF8]/30 rounded-lg text-[#E5E7EB] placeholder-[#94A3B8]/50 focus:border-[#38BDF8] focus:outline-none focus:ring-1 focus:ring-[#38BDF8]" | |
| > | |
| </div> | |
| </div> | |
| <div class="flex items-center gap-4"> | |
| <button | |
| @click="installExternalBackend()" | |
| :disabled="installingExternal || !externalBackend.uri" | |
| class="inline-flex items-center px-5 py-2.5 rounded-lg bg-[#38BDF8] hover:bg-[#38BDF8]/80 text-sm font-medium text-white transition duration-200 disabled:opacity-50 disabled:cursor-not-allowed" | |
| > | |
| <i class="mr-2" :class="installingExternal ? 'fas fa-spinner fa-spin' : 'fas fa-download'"></i> | |
| <span x-text="installingExternal ? 'Installing...' : 'Install Backend'"></span> | |
| </button> | |
| <span x-show="externalBackendProgress" class="text-sm text-[#94A3B8]" x-text="externalBackendProgress"></span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Search and Filter Section --> | |
| <div class="card p-8 mb-8"> | |
| <div> | |
| <!-- Search Input --> | |
| <div class="mb-8"> | |
| <h3 class="text-xl font-semibold text-[#E5E7EB] mb-4 flex items-center"> | |
| <i class="fas fa-search mr-3 text-[#8B5CF6]"></i> | |
| Find Backend Components | |
| </h3> | |
| <div class="relative"> | |
| <div class="absolute inset-y-0 start-0 flex items-center ps-4 pointer-events-none z-10"> | |
| <i class="fas fa-search text-[#94A3B8]"></i> | |
| </div> | |
| <input | |
| x-model="searchTerm" | |
| @input.debounce.500ms="fetchBackends()" | |
| class="input w-full pr-16 py-4" | |
| style="padding-left: 3.5rem !important;" | |
| type="search" | |
| placeholder="Search backends by name, description or type..."> | |
| <span class="absolute right-4 top-4" x-show="loading"> | |
| <svg class="animate-spin h-6 w-6 text-[#8B5CF6]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> | |
| <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> | |
| <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> | |
| </svg> | |
| </span> | |
| </div> | |
| </div> | |
| <!-- Filter by Type --> | |
| <div> | |
| <h3 class="text-lg font-semibold text-white mb-4 flex items-center"> | |
| <i class="fas fa-filter mr-3 text-teal-400"></i> | |
| Filter by Backend Type | |
| </h3> | |
| <div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3"> | |
| <button @click="filterByTerm('llm')" | |
| class="flex items-center justify-center rounded-lg px-4 py-3 text-sm font-semibold bg-indigo-600/20 hover:bg-indigo-600/30 text-indigo-300 border border-indigo-500/30 transition-colors"> | |
| <i class="fas fa-brain mr-2"></i> | |
| <span>LLM</span> | |
| </button> | |
| <button @click="filterByTerm('diffusion')" | |
| class="flex items-center justify-center rounded-lg px-4 py-3 text-sm font-semibold bg-purple-600/20 hover:bg-purple-600/30 text-purple-300 border border-purple-500/30 transition-colors"> | |
| <i class="fas fa-image mr-2"></i> | |
| <span>Diffusion</span> | |
| </button> | |
| <button @click="filterByTerm('tts')" | |
| class="flex items-center justify-center rounded-lg px-4 py-3 text-sm font-semibold bg-blue-600/20 hover:bg-blue-600/30 text-blue-300 border border-blue-500/30 transition-colors"> | |
| <i class="fas fa-microphone mr-2"></i> | |
| <span>TTS</span> | |
| </button> | |
| <button @click="filterByTerm('whisper')" | |
| class="flex items-center justify-center rounded-lg px-4 py-3 text-sm font-semibold bg-green-600/20 hover:bg-green-600/30 text-green-300 border border-green-500/30 transition-colors"> | |
| <i class="fas fa-headphones mr-2"></i> | |
| <span>Whisper</span> | |
| </button> | |
| <button @click="filterByTerm('object-detection')" | |
| class="flex items-center justify-center rounded-lg px-4 py-3 text-sm font-semibold bg-red-600/20 hover:bg-red-600/30 text-red-300 border border-red-500/30 transition-colors"> | |
| <i class="fas fa-eye mr-2"></i> | |
| <span>Vision</span> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Results Section --> | |
| <div id="search-results" class="transition-all duration-300"> | |
| <div x-show="loading && backends.length === 0" class="text-center py-12"> | |
| <svg class="animate-spin h-12 w-12 text-emerald-500 mx-auto mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> | |
| <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> | |
| <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> | |
| </svg> | |
| <p class="text-gray-400">Loading backends...</p> | |
| </div> | |
| <div x-show="!loading && backends.length === 0" class="text-center py-12"> | |
| <i class="fas fa-search text-gray-500 text-4xl mb-4"></i> | |
| <p class="text-gray-400">No backends found matching your criteria</p> | |
| </div> | |
| <!-- Table View --> | |
| <div x-show="backends.length > 0" class="bg-[#1E293B] rounded-2xl border border-[#38BDF8]/20 overflow-hidden shadow-xl backdrop-blur-sm"> | |
| <div class="overflow-x-auto"> | |
| <table class="w-full"> | |
| <thead> | |
| <tr class="bg-gradient-to-r from-[#38BDF8]/20 to-[#8B5CF6]/20 border-b border-[#38BDF8]/30"> | |
| <th class="px-6 py-4 text-left text-xs font-semibold text-[#E5E7EB] uppercase tracking-wider">Icon</th> | |
| <th @click="setSort('name')" | |
| :class="sortBy === 'name' ? 'bg-[#38BDF8]/20' : ''" | |
| class="px-6 py-4 text-left text-xs font-semibold text-[#E5E7EB] uppercase tracking-wider cursor-pointer hover:bg-[#38BDF8]/10 transition-colors"> | |
| <div class="flex items-center gap-2"> | |
| <span>Backend Name</span> | |
| <i :class="sortBy === 'name' ? (sortOrder === 'asc' ? 'fas fa-sort-up' : 'fas fa-sort-down') : 'fas fa-sort'" | |
| :class="sortBy === 'name' ? 'text-[#38BDF8]' : 'text-[#94A3B8]'" | |
| class="text-xs"></i> | |
| </div> | |
| </th> | |
| <th class="px-6 py-4 text-left text-xs font-semibold text-[#E5E7EB] uppercase tracking-wider">Description</th> | |
| <th @click="setSort('repository')" | |
| :class="sortBy === 'repository' ? 'bg-[#38BDF8]/20' : ''" | |
| class="px-6 py-4 text-left text-xs font-semibold text-[#E5E7EB] uppercase tracking-wider cursor-pointer hover:bg-[#38BDF8]/10 transition-colors"> | |
| <div class="flex items-center gap-2"> | |
| <span>Repository</span> | |
| <i :class="sortBy === 'repository' ? (sortOrder === 'asc' ? 'fas fa-sort-up' : 'fas fa-sort-down') : 'fas fa-sort'" | |
| :class="sortBy === 'repository' ? 'text-[#38BDF8]' : 'text-[#94A3B8]'" | |
| class="text-xs"></i> | |
| </div> | |
| </th> | |
| <th @click="setSort('license')" | |
| :class="sortBy === 'license' ? 'bg-[#38BDF8]/20' : ''" | |
| class="px-6 py-4 text-left text-xs font-semibold text-[#E5E7EB] uppercase tracking-wider cursor-pointer hover:bg-[#38BDF8]/10 transition-colors"> | |
| <div class="flex items-center gap-2"> | |
| <span>License</span> | |
| <i :class="sortBy === 'license' ? (sortOrder === 'asc' ? 'fas fa-sort-up' : 'fas fa-sort-down') : 'fas fa-sort'" | |
| :class="sortBy === 'license' ? 'text-[#38BDF8]' : 'text-[#94A3B8]'" | |
| class="text-xs"></i> | |
| </div> | |
| </th> | |
| <th @click="setSort('status')" | |
| :class="sortBy === 'status' ? 'bg-[#38BDF8]/20' : ''" | |
| class="px-6 py-4 text-left text-xs font-semibold text-[#E5E7EB] uppercase tracking-wider cursor-pointer hover:bg-[#38BDF8]/10 transition-colors"> | |
| <div class="flex items-center gap-2"> | |
| <span>Status</span> | |
| <i :class="sortBy === 'status' ? (sortOrder === 'asc' ? 'fas fa-sort-up' : 'fas fa-sort-down') : 'fas fa-sort'" | |
| :class="sortBy === 'status' ? 'text-[#38BDF8]' : 'text-[#94A3B8]'" | |
| class="text-xs"></i> | |
| </div> | |
| </th> | |
| <th class="px-6 py-4 text-right text-xs font-semibold text-[#E5E7EB] uppercase tracking-wider">Actions</th> | |
| </tr> | |
| </thead> | |
| <tbody class="divide-y divide-[#38BDF8]/20"> | |
| <template x-for="backend in backends" :key="backend.id"> | |
| <tr class="hover:bg-[#38BDF8]/10 transition-colors duration-200"> | |
| <!-- Icon --> | |
| <td class="px-6 py-4"> | |
| <div class="w-12 h-12 rounded-lg border border-[#38BDF8]/30 flex items-center justify-center bg-[#101827]"> | |
| <img x-show="backend.icon" | |
| :src="backend.icon" | |
| class="w-full h-full object-cover rounded-lg" | |
| loading="lazy" | |
| :alt="backend.name"> | |
| <i x-show="!backend.icon" class="fas fa-cog text-xl text-[#8B5CF6]"></i> | |
| </div> | |
| </td> | |
| <!-- Backend Name --> | |
| <td class="px-6 py-4"> | |
| <span class="text-sm font-semibold text-[#E5E7EB]" x-text="backend.name"></span> | |
| </td> | |
| <!-- Description --> | |
| <td class="px-6 py-4"> | |
| <div class="text-sm text-[#94A3B8] max-w-xs truncate" x-text="backend.description" :title="backend.description"></div> | |
| </td> | |
| <!-- Repository --> | |
| <td class="px-6 py-4"> | |
| <span class="inline-flex items-center text-xs px-2 py-1 rounded bg-[#38BDF8]/10 text-[#E5E7EB] border border-[#38BDF8]/30"> | |
| <i class="fa-brands fa-git-alt mr-1"></i> | |
| <span x-text="backend.gallery"></span> | |
| </span> | |
| </td> | |
| <!-- License --> | |
| <td class="px-6 py-4"> | |
| <span x-show="backend.license" class="inline-flex items-center text-xs px-2 py-1 rounded bg-[#8B5CF6]/10 text-[#E5E7EB] border border-[#8B5CF6]/30"> | |
| <i class="fas fa-book mr-1"></i> | |
| <span x-text="backend.license"></span> | |
| </span> | |
| <span x-show="!backend.license" class="text-xs text-[#94A3B8]">-</span> | |
| </td> | |
| <!-- Status --> | |
| <td class="px-6 py-4"> | |
| <!-- Processing State --> | |
| <div x-show="backend.processing" class="min-w-[200px]"> | |
| <div class="text-xs font-medium text-[#E5E7EB] mb-1"> | |
| <span x-text="backend.isDeletion ? 'Deleting...' : 'Installing...'"></span> | |
| </div> | |
| <div x-show="(jobProgress[backend.jobID] || 0) === 0" class="text-xs text-[#38BDF8]"> | |
| <i class="fas fa-clock mr-1"></i>Queued | |
| </div> | |
| <div class="progress-table mt-1"> | |
| <div class="progress-bar-table-backend" :style="'width:' + (jobProgress[backend.jobID] || 0) + '%'"></div> | |
| </div> | |
| </div> | |
| <!-- Installed State --> | |
| <div x-show="!backend.processing && backend.installed"> | |
| <span class="inline-flex items-center text-xs px-2 py-1 rounded bg-green-500/20 text-green-300 border border-green-500/30"> | |
| <i class="fas fa-check-circle mr-1"></i> | |
| Installed | |
| </span> | |
| </div> | |
| <!-- Not Installed State --> | |
| <div x-show="!backend.processing && !backend.installed"> | |
| <span class="inline-flex items-center text-xs px-2 py-1 rounded bg-[#1E293B] text-[#94A3B8] border border-[#38BDF8]/30"> | |
| <i class="fas fa-circle mr-1"></i> | |
| Not Installed | |
| </span> | |
| </div> | |
| </td> | |
| <!-- Actions --> | |
| <td class="px-6 py-4"> | |
| <div class="flex items-center justify-end gap-2"> | |
| <!-- Info Button --> | |
| <button @click="openModal(backend)" | |
| class="inline-flex items-center px-3 py-1.5 rounded-lg bg-[#1E293B] hover:bg-[#38BDF8]/20 text-xs font-medium text-[#E5E7EB] transition duration-200 border border-[#38BDF8]/30" | |
| title="View details"> | |
| <i class="fas fa-info-circle"></i> | |
| </button> | |
| <!-- Installed State Actions --> | |
| <template x-if="!backend.processing && backend.installed"> | |
| <div class="flex gap-2"> | |
| <button @click="reinstallBackend(backend.id)" | |
| class="inline-flex items-center px-3 py-1.5 rounded-lg bg-[#38BDF8] hover:bg-[#38BDF8]/80 text-xs font-medium text-white transition duration-200" | |
| title="Reinstall"> | |
| <i class="fa-solid fa-arrow-rotate-right"></i> | |
| </button> | |
| <button @click="deleteBackend(backend.id)" | |
| class="inline-flex items-center px-3 py-1.5 rounded-lg bg-red-600 hover:bg-red-700 text-xs font-medium text-white transition duration-200" | |
| title="Delete"> | |
| <i class="fa-solid fa-trash"></i> | |
| </button> | |
| </div> | |
| </template> | |
| <!-- Not Installed State Actions --> | |
| <template x-if="!backend.processing && !backend.installed"> | |
| <button @click="installBackend(backend.id)" | |
| class="inline-flex items-center px-3 py-1.5 rounded-lg bg-[#38BDF8] hover:bg-[#38BDF8]/80 text-xs font-medium text-white transition duration-200" | |
| title="Install"> | |
| <i class="fa-solid fa-download"></i> | |
| </button> | |
| </template> | |
| </div> | |
| </td> | |
| </tr> | |
| </template> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <!-- Modal --> | |
| <div x-show="selectedBackend" | |
| x-transition | |
| @click.away="closeModal()" | |
| class="fixed top-0 right-0 left-0 z-50 flex justify-center items-center w-full md:inset-0 h-full max-h-full bg-gray-900/50" | |
| style="display: none;"> | |
| <div class="relative p-4 w-full max-w-2xl h-[90vh] mx-auto mt-[5vh]"> | |
| <div class="relative bg-white rounded-lg shadow dark:bg-gray-700 h-full flex flex-col"> | |
| <!-- Modal Header --> | |
| <div class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600"> | |
| <h3 class="text-xl font-semibold text-gray-900 dark:text-white" x-text="selectedBackend?.name"></h3> | |
| <button @click="closeModal()" | |
| class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white"> | |
| <svg class="w-3 h-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14"> | |
| <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/> | |
| </svg> | |
| <span class="sr-only">Close modal</span> | |
| </button> | |
| </div> | |
| <!-- Modal Body --> | |
| <div class="p-4 md:p-5 space-y-4 overflow-y-auto flex-1 min-h-0"> | |
| <div class="flex justify-center items-center"> | |
| <div class="w-48 h-48 rounded-lg border border-gray-300 dark:border-gray-600 flex items-center justify-center bg-gray-100 dark:bg-gray-800 mt-3"> | |
| <img x-show="selectedBackend?.icon" | |
| :src="selectedBackend?.icon" | |
| class="rounded-lg max-h-48 max-w-96 object-cover" | |
| loading="lazy"> | |
| <i x-show="!selectedBackend?.icon" class="fas fa-cog text-6xl text-gray-400 dark:text-gray-500"></i> | |
| </div> | |
| </div> | |
| <div class="text-base leading-relaxed text-gray-500 dark:text-gray-400 break-words max-w-full markdown-content" x-html="renderMarkdown(selectedBackend?.description)"></div> | |
| <template x-if="selectedBackend?.tags && selectedBackend.tags.length > 0"> | |
| <div> | |
| <p class="text-sm mb-3 font-semibold text-gray-900 dark:text-white">Tags</p> | |
| <div class="flex flex-wrap gap-2"> | |
| <template x-for="tag in selectedBackend.tags" :key="tag"> | |
| <span class="inline-flex items-center text-xs px-3 py-1 rounded-full bg-gray-700/60 text-gray-300 border border-gray-600/50"> | |
| <i class="fas fa-tag pr-2"></i> | |
| <span x-text="tag"></span> | |
| </span> | |
| </template> | |
| </div> | |
| </div> | |
| </template> | |
| <template x-if="selectedBackend?.urls && selectedBackend.urls.length > 0"> | |
| <div> | |
| <p class="text-sm font-semibold text-gray-900 dark:text-white mb-2">Links</p> | |
| <ul> | |
| <template x-for="url in selectedBackend.urls" :key="url"> | |
| <li> | |
| <a :href="url" target="_blank" class="text-blue-500 hover:underline"> | |
| <i class="fas fa-link pr-2"></i> | |
| <span x-text="url"></span> | |
| </a> | |
| </li> | |
| </template> | |
| </ul> | |
| </div> | |
| </template> | |
| </div> | |
| <!-- Modal Footer --> | |
| <div class="flex items-center p-4 md:p-5 border-t border-gray-200 rounded-b dark:border-gray-600"> | |
| <button @click="closeModal()" | |
| class="text-white bg-emerald-700 hover:bg-emerald-800 focus:ring-4 focus:outline-none focus:ring-emerald-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-emerald-600 dark:hover:bg-emerald-700 dark:focus:ring-emerald-800"> | |
| Close | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Pagination --> | |
| <div x-show="totalPages > 1" class="flex justify-center mt-12"> | |
| <div class="flex items-center gap-4 bg-gray-800/60 rounded-2xl p-4 backdrop-blur-sm border border-gray-700/50"> | |
| <button @click="goToPage(currentPage - 1)" | |
| :disabled="currentPage <= 1" | |
| :class="currentPage <= 1 ? 'opacity-50 cursor-not-allowed' : ''" | |
| class="flex items-center justify-center h-12 w-12 bg-[#1E293B] hover:bg-emerald-600 text-[#94A3B8] hover:text-white rounded-lg transition-colors"> | |
| <i class="fas fa-chevron-left"></i> | |
| </button> | |
| <div class="text-gray-300 text-sm font-medium px-4"> | |
| <span class="text-gray-400">Page</span> | |
| <span class="text-white font-bold text-lg mx-2" x-text="currentPage"></span> | |
| <span class="text-gray-400">of</span> | |
| <span class="text-white font-bold text-lg mx-2" x-text="totalPages"></span> | |
| </div> | |
| <button @click="goToPage(currentPage + 1)" | |
| :disabled="currentPage >= totalPages" | |
| :class="currentPage >= totalPages ? 'opacity-50 cursor-not-allowed' : ''" | |
| class="group flex items-center justify-center h-12 w-12 bg-gray-700/80 hover:bg-emerald-600 text-gray-300 hover:text-white rounded-xl shadow-lg transition-all duration-300 ease-in-out transform hover:scale-110"> | |
| <i class="fas fa-chevron-right group-hover:animate-pulse"></i> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| {{template "views/partials/footer" .}} | |
| </div> | |
| <style> | |
| /* Enhanced scrollbar styling */ | |
| .scrollbar-thin::-webkit-scrollbar { | |
| width: 6px; | |
| } | |
| .scrollbar-thin::-webkit-scrollbar-track { | |
| background: rgba(31, 41, 55, 0.5); | |
| border-radius: 6px; | |
| } | |
| .scrollbar-thin::-webkit-scrollbar-thumb { | |
| background: rgba(107, 114, 128, 0.5); | |
| border-radius: 6px; | |
| } | |
| .scrollbar-thin::-webkit-scrollbar-thumb:hover { | |
| background: rgba(107, 114, 128, 0.8); | |
| } | |
| /* Progress bar styling */ | |
| .progress { | |
| background: linear-gradient(135deg, rgba(16, 185, 129, 0.2) 0%, rgba(20, 184, 166, 0.2) 100%); | |
| border-radius: 0.5rem; | |
| border: 1px solid rgba(16, 185, 129, 0.3); | |
| height: 24px; | |
| overflow: hidden; | |
| } | |
| .progress-bar { | |
| background: linear-gradient(135deg, #10b981 0%, #14b8a6 100%); | |
| height: 100%; | |
| transition: width 0.3s ease; | |
| } | |
| /* Table progress bar styling */ | |
| .progress-table { | |
| background: linear-gradient(135deg, rgba(56, 189, 248, 0.2) 0%, rgba(139, 92, 246, 0.2) 100%); | |
| border-radius: 0.25rem; | |
| border: 1px solid rgba(56, 189, 248, 0.3); | |
| height: 6px; | |
| overflow: hidden; | |
| width: 100%; | |
| } | |
| .progress-bar-table-backend { | |
| background: linear-gradient(135deg, #38BDF8 0%, #8B5CF6 100%); | |
| height: 100%; | |
| transition: width 0.3s ease; | |
| } | |
| /* Table styling */ | |
| table { | |
| border-collapse: separate; | |
| border-spacing: 0; | |
| } | |
| tbody tr:last-child td:first-child { | |
| border-bottom-left-radius: 1rem; | |
| } | |
| tbody tr:last-child td:last-child { | |
| border-bottom-right-radius: 1rem; | |
| } | |
| /* Markdown content overflow handling */ | |
| .markdown-content { | |
| word-wrap: break-word; | |
| overflow-wrap: anywhere; | |
| max-width: 100%; | |
| } | |
| .markdown-content pre { | |
| overflow-x: auto; | |
| max-width: 100%; | |
| white-space: pre-wrap; | |
| word-wrap: break-word; | |
| } | |
| .markdown-content code { | |
| word-wrap: break-word; | |
| overflow-wrap: break-word; | |
| } | |
| .markdown-content pre code { | |
| white-space: pre; | |
| overflow-x: auto; | |
| display: block; | |
| } | |
| .markdown-content table { | |
| max-width: 100%; | |
| overflow-x: auto; | |
| display: block; | |
| } | |
| .markdown-content img { | |
| max-width: 100%; | |
| height: auto; | |
| } | |
| </style> | |
| <script> | |
| function backendsGallery() { | |
| return { | |
| backends: [], | |
| allTags: [], | |
| repositories: [], | |
| searchTerm: '', | |
| loading: false, | |
| currentPage: 1, | |
| totalPages: 1, | |
| availableBackends: 0, | |
| installedBackends: 0, | |
| systemCapability: '', | |
| selectedBackend: null, | |
| jobProgress: {}, | |
| notifications: [], | |
| sortBy: '', | |
| sortOrder: 'asc', | |
| // External backend installation state | |
| showManualInstall: false, | |
| externalBackend: { | |
| uri: '', | |
| name: '', | |
| alias: '' | |
| }, | |
| installingExternal: false, | |
| externalBackendJobID: null, | |
| externalBackendProgress: '', | |
| init() { | |
| this.fetchBackends(); | |
| // Poll for job progress every 600ms | |
| setInterval(() => this.pollJobs(), 600); | |
| }, | |
| addNotification(message, type = 'error') { | |
| const id = Date.now(); | |
| this.notifications.push({ id, message, type }); | |
| // Auto-dismiss after 10 seconds | |
| setTimeout(() => this.dismissNotification(id), 10000); | |
| }, | |
| dismissNotification(id) { | |
| this.notifications = this.notifications.filter(n => n.id !== id); | |
| }, | |
| async installExternalBackend() { | |
| if (this.installingExternal || !this.externalBackend.uri) { | |
| return; | |
| } | |
| try { | |
| this.installingExternal = true; | |
| this.externalBackendProgress = 'Starting installation...'; | |
| const response = await fetch('/api/backends/install-external', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify({ | |
| uri: this.externalBackend.uri, | |
| name: this.externalBackend.name, | |
| alias: this.externalBackend.alias | |
| }) | |
| }); | |
| const data = await response.json(); | |
| if (response.ok && data.jobID) { | |
| this.externalBackendJobID = data.jobID; | |
| const displayName = this.externalBackend.name || this.externalBackend.uri; | |
| this.addNotification(`Installing backend "${displayName}"...`, 'success'); | |
| } else { | |
| this.installingExternal = false; | |
| this.externalBackendProgress = ''; | |
| this.addNotification(`Failed to start installation: ${data.error || 'Unknown error'}`, 'error'); | |
| } | |
| } catch (error) { | |
| console.error('Error installing external backend:', error); | |
| this.installingExternal = false; | |
| this.externalBackendProgress = ''; | |
| this.addNotification(`Failed to install backend: ${error.message}`, 'error'); | |
| } | |
| }, | |
| async fetchBackends() { | |
| this.loading = true; | |
| try { | |
| const params = new URLSearchParams({ | |
| page: this.currentPage, | |
| items: 21, | |
| term: this.searchTerm | |
| }); | |
| if (this.sortBy) { | |
| params.append('sort', this.sortBy); | |
| params.append('order', this.sortOrder); | |
| } | |
| const response = await fetch(`/api/backends?${params}`); | |
| const data = await response.json(); | |
| this.backends = data.backends || []; | |
| this.allTags = data.allTags || []; | |
| this.repositories = data.repositories || []; | |
| this.currentPage = data.currentPage || 1; | |
| this.totalPages = data.totalPages || 1; | |
| this.availableBackends = data.availableBackends || 0; | |
| this.installedBackends = data.installedBackends || 0; | |
| this.systemCapability = data.systemCapability || 'default'; | |
| } catch (error) { | |
| console.error('Error fetching backends:', error); | |
| } finally { | |
| this.loading = false; | |
| } | |
| }, | |
| filterByTerm(term) { | |
| this.searchTerm = term; | |
| this.currentPage = 1; | |
| this.fetchBackends(); | |
| }, | |
| setSort(column) { | |
| if (this.sortBy === column) { | |
| // Toggle sort order if clicking the same column | |
| this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc'; | |
| } else { | |
| // Set new column and default to ascending | |
| this.sortBy = column; | |
| this.sortOrder = 'asc'; | |
| } | |
| this.currentPage = 1; | |
| this.fetchBackends(); | |
| }, | |
| goToPage(page) { | |
| if (page >= 1 && page <= this.totalPages) { | |
| this.currentPage = page; | |
| this.fetchBackends(); | |
| } | |
| }, | |
| async installBackend(backendId) { | |
| try { | |
| const response = await fetch(`/api/backends/install/${encodeURIComponent(backendId)}`, { | |
| method: 'POST' | |
| }); | |
| const data = await response.json(); | |
| if (data.jobID) { | |
| const backend = this.backends.find(b => b.id === backendId); | |
| if (backend) { | |
| backend.processing = true; | |
| backend.jobID = data.jobID; | |
| backend.isDeletion = false; | |
| } | |
| } | |
| } catch (error) { | |
| console.error('Error installing backend:', error); | |
| alert('Failed to start installation'); | |
| } | |
| }, | |
| async deleteBackend(backendId) { | |
| if (!confirm('Are you sure you wish to delete the backend?')) { | |
| return; | |
| } | |
| try { | |
| const response = await fetch(`/api/backends/delete/${encodeURIComponent(backendId)}`, { | |
| method: 'POST' | |
| }); | |
| const data = await response.json(); | |
| if (data.jobID) { | |
| const backend = this.backends.find(b => b.id === backendId); | |
| if (backend) { | |
| backend.processing = true; | |
| backend.jobID = data.jobID; | |
| backend.isDeletion = true; | |
| } | |
| } | |
| } catch (error) { | |
| console.error('Error deleting backend:', error); | |
| alert('Failed to start deletion'); | |
| } | |
| }, | |
| async reinstallBackend(backendId) { | |
| this.installBackend(backendId); | |
| }, | |
| async pollJobs() { | |
| const processingBackends = this.backends.filter(b => b.processing && b.jobID); | |
| for (const backend of processingBackends) { | |
| try { | |
| const response = await fetch(`/api/backends/job/${backend.jobID}`); | |
| const jobData = await response.json(); | |
| // Handle queued status | |
| if (jobData.queued) { | |
| this.jobProgress[backend.jobID] = 0; | |
| // Keep processing state but don't show error | |
| continue; | |
| } | |
| this.jobProgress[backend.jobID] = jobData.progress || 0; | |
| if (jobData.completed) { | |
| backend.processing = false; | |
| backend.installed = !jobData.deletion; | |
| delete this.jobProgress[backend.jobID]; | |
| // Show success notification | |
| const action = jobData.deletion ? 'deleted' : 'installed'; | |
| this.addNotification(`Backend "${backend.name}" ${action} successfully!`, 'success'); | |
| // Refresh the backends list to get updated state | |
| this.fetchBackends(); | |
| } | |
| if (jobData.error || (jobData.message && jobData.message.startsWith('error:'))) { | |
| backend.processing = false; | |
| delete this.jobProgress[backend.jobID]; | |
| const action = backend.isDeletion ? 'deleting' : 'installing'; | |
| // Extract error message - handle both string and object errors | |
| let errorMessage = 'Unknown error'; | |
| if (typeof jobData.error === 'string') { | |
| errorMessage = jobData.error; | |
| } else if (jobData.error && typeof jobData.error === 'object') { | |
| // Check if error object has any properties | |
| const errorKeys = Object.keys(jobData.error); | |
| if (errorKeys.length > 0) { | |
| // Try common error object properties | |
| errorMessage = jobData.error.message || jobData.error.error || jobData.error.Error || JSON.stringify(jobData.error); | |
| } else { | |
| // Empty object {}, fall back to message field | |
| errorMessage = jobData.message || 'Unknown error'; | |
| } | |
| } else if (jobData.message) { | |
| // Use message field if error is not present or is empty | |
| errorMessage = jobData.message; | |
| } | |
| // Remove "error: " prefix if present | |
| if (errorMessage.startsWith('error: ')) { | |
| errorMessage = errorMessage.substring(7); | |
| } | |
| this.addNotification(`Error ${action} backend "${backend.name}": ${errorMessage}`, 'error'); | |
| } | |
| } catch (error) { | |
| console.error('Error polling job:', error); | |
| // Don't show notification for every polling error, only if backend is stuck | |
| } | |
| } | |
| // Poll for external backend installation job | |
| if (this.externalBackendJobID) { | |
| try { | |
| const response = await fetch(`/api/backends/job/${this.externalBackendJobID}`); | |
| const jobData = await response.json(); | |
| // Update progress message | |
| if (jobData.message && !jobData.processed) { | |
| this.externalBackendProgress = jobData.message; | |
| if (jobData.progress) { | |
| this.externalBackendProgress += ` (${Math.round(jobData.progress)}%)`; | |
| } | |
| } | |
| if (jobData.completed) { | |
| const displayName = this.externalBackend.name || this.externalBackend.uri; | |
| this.addNotification(`Backend "${displayName}" installed successfully!`, 'success'); | |
| this.externalBackendJobID = null; | |
| this.installingExternal = false; | |
| this.externalBackendProgress = ''; | |
| // Reset form | |
| this.externalBackend = { uri: '', name: '', alias: '' }; | |
| // Refresh the backends list | |
| this.fetchBackends(); | |
| } | |
| if (jobData.error || (jobData.message && jobData.message.startsWith('error:'))) { | |
| let errorMessage = 'Unknown error'; | |
| if (typeof jobData.error === 'string') { | |
| errorMessage = jobData.error; | |
| } else if (jobData.message) { | |
| errorMessage = jobData.message; | |
| } | |
| if (errorMessage.startsWith('error: ')) { | |
| errorMessage = errorMessage.substring(7); | |
| } | |
| this.addNotification(`Error installing backend: ${errorMessage}`, 'error'); | |
| this.externalBackendJobID = null; | |
| this.installingExternal = false; | |
| this.externalBackendProgress = ''; | |
| } | |
| } catch (error) { | |
| console.error('Error polling external backend job:', error); | |
| } | |
| } | |
| }, | |
| renderMarkdown(text) { | |
| if (!text) return ''; | |
| try { | |
| if (typeof marked === 'undefined' || typeof DOMPurify === 'undefined') { | |
| return text; // Return plain text if libraries not loaded | |
| } | |
| const html = marked.parse(text); | |
| return DOMPurify.sanitize(html); | |
| } catch (error) { | |
| console.error('Error rendering markdown:', error); | |
| return text; | |
| } | |
| }, | |
| openModal(backend) { | |
| this.selectedBackend = backend; | |
| }, | |
| closeModal() { | |
| this.selectedBackend = null; | |
| } | |
| } | |
| } | |
| </script> | |
| </body> | |
| </html> | |