mirror of
https://github.com/mudler/LocalAI.git
synced 2025-12-27 08:29:29 -05:00
* feat: allow to install backends from URL in the WebUI and API Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * tests Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * trace backends installations Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
903 lines
48 KiB
HTML
903 lines
48 KiB
HTML
<!DOCTYPE html>
|
|
<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>
|
|
<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,
|
|
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;
|
|
} 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>
|