mirror of
https://github.com/mudler/LocalAI.git
synced 2026-04-01 05:36:49 -04:00
* feat: Rename 'Whisper' model type to 'STT' in UI - Updated models.html: Changed 'Whisper' filter button to 'STT' - Updated talk.html: Changed 'Whisper Model' to 'STT Model' - Updated backends.html: Changed 'Whisper' to 'STT' - Updated talk.js: Renamed getWhisperModel() to getSTTModel(), sendAudioToWhisper() to sendAudioToSTT(), and whisperModelSelect to sttModelSelect This change makes the UI more consistent with the model category naming, where all speech-to-text models (including Whisper, Parakeet, Moonshine, WhisperX, etc.) are grouped under the 'STT' (Speech-to-Text) category. Fixes #8776 Signed-off-by: team-coding-agent-1 <team-coding-agent-1@localai.dev> * Rename whisperModelSelect to sttModelSelect in talk.html As requested by maintainer mudler in PR review, replacing all whisperModelSelect occurrences with sttModelSelect since the model type was renamed from Whisper to STT. Signed-off-by: LocalAI [bot] <localai-bot@users.noreply.github.com> --------- Signed-off-by: team-coding-agent-1 <team-coding-agent-1@localai.dev> Signed-off-by: LocalAI [bot] <localai-bot@users.noreply.github.com> Co-authored-by: team-coding-agent-1 <team-coding-agent-1@localai.dev> Co-authored-by: LocalAI [bot] <localai-bot@users.noreply.github.com>
917 lines
50 KiB
HTML
917 lines
50 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
{{template "views/partials/head" .}}
|
|
|
|
<body class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]">
|
|
<div class="app-layout">
|
|
{{template "views/partials/navbar" .}}
|
|
|
|
<main class="main-content">
|
|
<div class="main-content-inner" x-data="backendsGallery()">
|
|
|
|
<!-- 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-[var(--color-bg-primary)] rounded-lg px-4 py-2">
|
|
<div class="w-2 h-2 bg-[var(--color-success)] rounded-full mr-2"></div>
|
|
<span class="font-semibold text-[var(--color-success)]" x-text="availableBackends"></span>
|
|
<span class="text-[var(--color-text-secondary)] ml-1">backends available</span>
|
|
</div>
|
|
<a href="/manage" class="flex items-center bg-[var(--color-bg-primary)] hover:bg-[var(--color-bg-secondary)] rounded-lg px-4 py-2 transition-colors border border-[var(--color-accent)]/30 hover:border-[var(--color-accent)]/50">
|
|
<div class="w-2 h-2 bg-[var(--color-primary)] rounded-full mr-2"></div>
|
|
<span class="font-semibold text-[var(--color-primary)]" x-text="installedBackends"></span>
|
|
<span class="text-[var(--color-text-secondary)] ml-1">installed</span>
|
|
</a>
|
|
<div class="flex items-center bg-[var(--color-bg-primary)] rounded-lg px-4 py-2 border border-[var(--color-primary-border)]">
|
|
<i class="fas fa-microchip text-[var(--color-primary)] mr-2"></i>
|
|
<span class="text-[var(--color-text-secondary)] mr-1">Capability:</span>
|
|
<span class="font-semibold text-[var(--color-primary)]" x-text="systemCapability"></span>
|
|
</div>
|
|
<a href="https://localai.io/backends/" target="_blank" class="inline-flex items-center gap-1.5 text-xs text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] bg-transparent hover:bg-[var(--color-primary)]/10 border border-[var(--color-border-subtle)] hover:border-[var(--color-primary)]/30 rounded-md py-1.5 px-2.5 transition-colors">
|
|
<i class="fas fa-info-circle"></i>
|
|
<span>Documentation</span>
|
|
<i class="fas fa-external-link-alt text-[10px]"></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-[var(--color-primary)] text-lg"></i>
|
|
<h3 class="text-lg font-semibold text-[var(--color-text-primary)]">Install Backend Manually</h3>
|
|
</div>
|
|
<i class="fas text-[var(--color-text-secondary)] 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-[var(--color-text-secondary)] 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-[var(--color-text-secondary)] mb-2">OCI Image / URL / Path *</label>
|
|
<input
|
|
type="text"
|
|
x-model="externalBackend.uri"
|
|
placeholder="e.g., oci://quay.io/example/backend:latest"
|
|
class="input w-full px-4 py-3 text-sm"
|
|
>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">Name (required for OCI)</label>
|
|
<input
|
|
type="text"
|
|
x-model="externalBackend.name"
|
|
placeholder="e.g., my-backend"
|
|
class="input w-full px-4 py-3 text-sm"
|
|
>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">Alias (optional)</label>
|
|
<input
|
|
type="text"
|
|
x-model="externalBackend.alias"
|
|
placeholder="e.g., backend-alias"
|
|
class="input w-full px-4 py-3 text-sm"
|
|
>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-4">
|
|
<button type="button"
|
|
@click="installExternalBackend()"
|
|
:disabled="installingExternal || !externalBackend.uri"
|
|
class="inline-flex items-center gap-1.5 text-xs text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] bg-transparent hover:bg-[var(--color-primary)]/10 border border-[var(--color-border-subtle)] hover:border-[var(--color-primary)]/30 rounded-md py-1.5 px-2.5 transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:border-[var(--color-border-subtle)]"
|
|
>
|
|
<i class="text-[10px]" :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-[var(--color-text-secondary)]" 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-[var(--color-text-primary)] mb-4 flex items-center">
|
|
<i class="fas fa-search mr-3 text-[var(--color-accent)]"></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-[var(--color-text-secondary)]"></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-[var(--color-accent)]" 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-[var(--color-text-primary)] mb-4 flex items-center">
|
|
<i class="fas fa-filter mr-3 text-[var(--color-secondary)]"></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-[var(--color-accent-light)] hover:bg-[var(--color-accent)]/30 text-[var(--color-text-primary)] border border-[var(--color-accent)]/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-[var(--color-accent-light)] hover:bg-[var(--color-accent)]/30 text-[var(--color-text-primary)] border border-[var(--color-accent)]/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-[var(--color-primary-light)] hover:bg-[var(--color-primary)]/30 text-[var(--color-text-primary)] border border-[var(--color-primary-border)] 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-[var(--color-success-light)] hover:bg-[var(--color-success)]/30 text-[var(--color-success)] border border-[var(--color-success)]/30 transition-colors">
|
|
<i class="fas fa-headphones mr-2"></i>
|
|
<span>STT</span>
|
|
</button>
|
|
<button @click="filterByTerm('object-detection')"
|
|
class="flex items-center justify-center rounded-lg px-4 py-3 text-sm font-semibold bg-[var(--color-error-light)] hover:bg-[var(--color-error)]/30 text-[var(--color-error)] border border-[var(--color-error)]/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-[var(--color-primary)] 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-[var(--color-text-secondary)]">Loading backends...</p>
|
|
</div>
|
|
|
|
<div x-show="!loading && backends.length === 0" class="text-center py-12">
|
|
<i class="fas fa-search text-[var(--color-text-muted)] text-4xl mb-4"></i>
|
|
<p class="text-[var(--color-text-secondary)]">No backends found matching your criteria</p>
|
|
</div>
|
|
|
|
<!-- Table View -->
|
|
<div x-show="backends.length > 0" class="bg-[var(--color-bg-secondary)] rounded-2xl border border-[var(--color-border-subtle)] overflow-hidden shadow-xl backdrop-blur-sm">
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full">
|
|
<thead>
|
|
<tr class="bg-[var(--color-primary-light)] border-b border-[var(--color-border-subtle)]">
|
|
<th class="px-6 py-4 text-left text-xs font-semibold text-[var(--color-text-primary)] uppercase tracking-wider">Icon</th>
|
|
<th @click="setSort('name')"
|
|
:class="sortBy === 'name' ? 'bg-[var(--color-primary-light)]' : ''"
|
|
class="px-6 py-4 text-left text-xs font-semibold text-[var(--color-text-primary)] uppercase tracking-wider cursor-pointer hover:bg-[var(--color-bg-primary)] 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-[var(--color-primary)]' : 'text-[var(--color-text-secondary)]'"
|
|
class="text-xs"></i>
|
|
</div>
|
|
</th>
|
|
<th class="px-6 py-4 text-left text-xs font-semibold text-[var(--color-text-primary)] uppercase tracking-wider">Description</th>
|
|
<th @click="setSort('repository')"
|
|
:class="sortBy === 'repository' ? 'bg-[var(--color-primary-light)]' : ''"
|
|
class="px-6 py-4 text-left text-xs font-semibold text-[var(--color-text-primary)] uppercase tracking-wider cursor-pointer hover:bg-[var(--color-bg-primary)] 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-[var(--color-primary)]' : 'text-[var(--color-text-secondary)]'"
|
|
class="text-xs"></i>
|
|
</div>
|
|
</th>
|
|
<th @click="setSort('license')"
|
|
:class="sortBy === 'license' ? 'bg-[var(--color-primary-light)]' : ''"
|
|
class="px-6 py-4 text-left text-xs font-semibold text-[var(--color-text-primary)] uppercase tracking-wider cursor-pointer hover:bg-[var(--color-bg-primary)] 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-[var(--color-primary)]' : 'text-[var(--color-text-secondary)]'"
|
|
class="text-xs"></i>
|
|
</div>
|
|
</th>
|
|
<th @click="setSort('status')"
|
|
:class="sortBy === 'status' ? 'bg-[var(--color-primary-light)]' : ''"
|
|
class="px-6 py-4 text-left text-xs font-semibold text-[var(--color-text-primary)] uppercase tracking-wider cursor-pointer hover:bg-[var(--color-bg-primary)] 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-[var(--color-primary)]' : 'text-[var(--color-text-secondary)]'"
|
|
class="text-xs"></i>
|
|
</div>
|
|
</th>
|
|
<th class="px-6 py-4 text-right text-xs font-semibold text-[var(--color-text-primary)] uppercase tracking-wider">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-[var(--color-border-subtle)]">
|
|
<template x-for="backend in backends" :key="backend.id">
|
|
<tr class="hover:bg-[var(--color-bg-primary)] transition-colors duration-200">
|
|
<!-- Icon -->
|
|
<td class="px-6 py-4">
|
|
<div class="w-12 h-12 rounded-lg border border-[var(--color-border-subtle)] flex items-center justify-center bg-[var(--color-bg-primary)]">
|
|
<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-[var(--color-accent)]"></i>
|
|
</div>
|
|
</td>
|
|
|
|
<!-- Backend Name -->
|
|
<td class="px-6 py-4">
|
|
<span class="text-sm font-semibold text-[var(--color-text-primary)]" x-text="backend.name"></span>
|
|
</td>
|
|
|
|
<!-- Description -->
|
|
<td class="px-6 py-4">
|
|
<div class="text-sm text-[var(--color-text-secondary)] 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-[var(--color-primary-light)] text-[var(--color-text-primary)] border border-[var(--color-primary-border)]">
|
|
<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-[var(--color-accent-light)] text-[var(--color-text-primary)] border border-[var(--color-accent)]/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-[var(--color-text-secondary)]">-</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-[var(--color-text-primary)] mb-1">
|
|
<span x-text="backend.isDeletion ? 'Deleting...' : 'Installing...'"></span>
|
|
</div>
|
|
<div x-show="(jobProgress[backend.jobID] || 0) === 0" class="text-xs text-[var(--color-primary)]">
|
|
<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-[var(--color-success-light)] text-[var(--color-success)] border border-[var(--color-success)]/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-[var(--color-bg-primary)] text-[var(--color-text-secondary)] border border-[var(--color-border-subtle)]">
|
|
<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-[var(--color-bg-primary)] hover:bg-[var(--color-primary-light)] text-xs font-medium text-[var(--color-text-primary)] transition duration-200 border border-[var(--color-border-subtle)]"
|
|
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-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] 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-[var(--color-error)] hover:bg-[var(--color-error)]/80 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-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] 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-black/50"
|
|
style="display: none;">
|
|
<div class="relative p-4 w-full max-w-2xl h-[90vh] mx-auto mt-[5vh]">
|
|
<div class="relative bg-[var(--color-bg-secondary)] rounded-lg shadow h-full flex flex-col border border-[var(--color-border-subtle)]">
|
|
<!-- Modal Header -->
|
|
<div class="flex items-center justify-between p-4 md:p-5 border-b border-[var(--color-border-subtle)] rounded-t">
|
|
<h3 class="text-xl font-semibold text-[var(--color-text-primary)]" x-text="selectedBackend?.name"></h3>
|
|
<button @click="closeModal()"
|
|
class="text-[var(--color-text-secondary)] bg-transparent hover:bg-[var(--color-bg-primary)] hover:text-[var(--color-text-primary)] rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center transition-colors">
|
|
<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-[var(--color-border-subtle)] flex items-center justify-center bg-[var(--color-bg-primary)] 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-[var(--color-text-muted)]"></i>
|
|
</div>
|
|
</div>
|
|
<div class="text-base leading-relaxed text-[var(--color-text-secondary)] 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-[var(--color-text-primary)]">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-[var(--color-bg-primary)] text-[var(--color-text-secondary)] border border-[var(--color-border-subtle)]">
|
|
<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-[var(--color-text-primary)] mb-2">Links</p>
|
|
<ul>
|
|
<template x-for="url in selectedBackend.urls" :key="url">
|
|
<li>
|
|
<a :href="url" target="_blank" class="text-[var(--color-primary)] 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-[var(--color-border-subtle)] rounded-b">
|
|
<button @click="closeModal()"
|
|
class="text-white bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] focus:ring-2 focus:outline-none focus:ring-[var(--color-primary)]/50 font-medium rounded-lg text-sm px-5 py-2.5 text-center transition-colors">
|
|
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-[var(--color-bg-secondary)] rounded-2xl p-4 backdrop-blur-sm border border-[var(--color-border-subtle)]">
|
|
<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-[var(--color-bg-primary)] hover:bg-[var(--color-success)] text-[var(--color-text-secondary)] hover:text-white rounded-lg transition-colors">
|
|
<i class="fas fa-chevron-left"></i>
|
|
</button>
|
|
<div class="text-[var(--color-text-primary)] text-sm font-medium px-4">
|
|
<span class="text-[var(--color-text-secondary)]">Page</span>
|
|
<span class="text-[var(--color-text-primary)] font-bold text-lg mx-2" x-text="currentPage"></span>
|
|
<span class="text-[var(--color-text-secondary)]">of</span>
|
|
<span class="text-[var(--color-text-primary)] 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-[var(--color-bg-primary)] hover:bg-[var(--color-success)] text-[var(--color-text-secondary)] 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>
|
|
</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: var(--color-primary-light);
|
|
border-radius: 0.25rem;
|
|
border: 1px solid var(--color-primary-border);
|
|
height: 6px;
|
|
overflow: hidden;
|
|
width: 100%;
|
|
}
|
|
|
|
.progress-bar-table-backend {
|
|
background: var(--gradient-primary);
|
|
height: 100%;
|
|
transition: width 0.3s ease;
|
|
}
|
|
|
|
/* Table styling */
|
|
table {
|
|
border-collapse: separate;
|
|
border-spacing: 0;
|
|
background: var(--color-bg-secondary);
|
|
}
|
|
|
|
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>
|
|
|
|
{{template "views/partials/footer" .}}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
</body>
|
|
</html>
|