1168 lines
47 KiB
TypeScript
1168 lines
47 KiB
TypeScript
import { Head, router } from '@inertiajs/react'
|
|
import { useEffect, useRef, useState } from 'react'
|
|
import {
|
|
IconAlertTriangle,
|
|
IconArrowRight,
|
|
IconArrowUp,
|
|
IconBook,
|
|
IconBox,
|
|
IconBrandDocker,
|
|
IconChartBar,
|
|
IconClockBolt,
|
|
IconCloudDownload,
|
|
IconFileText,
|
|
IconPackage,
|
|
IconPencil,
|
|
IconPlayerPlay,
|
|
IconPlayerStop,
|
|
IconRefresh,
|
|
IconSearch,
|
|
IconTrash,
|
|
IconWorld,
|
|
} from '@tabler/icons-react'
|
|
import AppLayout from '~/layouts/AppLayout'
|
|
import DynamicIcon, { DynamicIconName } from '~/components/DynamicIcon'
|
|
import StyledButton from '~/components/StyledButton'
|
|
import StyledModal from '~/components/StyledModal'
|
|
import InstallActivityFeed from '~/components/InstallActivityFeed'
|
|
import LoadingSpinner from '~/components/LoadingSpinner'
|
|
import Alert from '~/components/Alert'
|
|
import CustomAppModal, { CustomAppInitial } from '~/components/CustomAppModal'
|
|
import AppUrlModal from '~/components/AppUrlModal'
|
|
import ServiceLogsModal from '~/components/ServiceLogsModal'
|
|
import ServiceStatsModal from '~/components/ServiceStatsModal'
|
|
import StyledSectionHeader from '~/components/StyledSectionHeader'
|
|
import UpdateServiceModal from '~/components/UpdateServiceModal'
|
|
import useErrorNotification from '~/hooks/useErrorNotification'
|
|
import { useNotifications } from '~/context/NotificationContext'
|
|
import useInternetStatus from '~/hooks/useInternetStatus'
|
|
import { useAppAutoUpdateStatus } from '~/hooks/useAppAutoUpdateStatus'
|
|
import useServiceInstallationActivity from '~/hooks/useServiceInstallationActivity'
|
|
import { useTransmit } from 'react-adonis-transmit'
|
|
import { BROADCAST_CHANNELS } from '../../constants/broadcast'
|
|
import { ServiceSlim } from '../../types/services'
|
|
import { getServiceLink } from '~/lib/navigation'
|
|
import { getSupplyDepotDocLink } from '../../constants/supply_depot_docs'
|
|
import api from '~/lib/api'
|
|
import { toTitleCase } from '../../app/utils/misc'
|
|
import { SERVICE_NAMES } from '../../constants/service_names'
|
|
|
|
function extractTag(containerImage: string): string {
|
|
if (!containerImage) return ''
|
|
const parts = containerImage.split(':')
|
|
return parts.length > 1 ? parts[parts.length - 1] : 'latest'
|
|
}
|
|
|
|
const CATEGORIES = [
|
|
{ id: 'all', label: 'All' },
|
|
{ id: 'installed', label: 'Installed' },
|
|
{ id: 'productivity', label: 'Productivity' },
|
|
{ id: 'media', label: 'Media' },
|
|
{ id: 'security', label: 'Security' },
|
|
{ id: 'networking', label: 'Networking' },
|
|
{ id: 'utility', label: 'Utility' },
|
|
{ id: 'ai', label: 'AI' },
|
|
{ id: 'education', label: 'Education' },
|
|
{ id: 'custom', label: 'Custom' },
|
|
]
|
|
|
|
const CATEGORY_COLORS: Record<string, string> = {
|
|
productivity: 'border border-desert-green-light bg-desert-green-lighter text-desert-green-dark',
|
|
media: 'border border-desert-tan-light bg-desert-tan-lighter text-desert-tan-dark',
|
|
security: 'border border-desert-red-light bg-desert-red-lighter text-desert-red-dark',
|
|
networking: 'border border-desert-stone-light bg-desert-stone-lighter text-desert-stone-dark',
|
|
utility: 'border border-desert-olive-light bg-desert-olive-lighter text-desert-olive-dark',
|
|
ai: 'border border-desert-green bg-desert-green-light text-desert-green-darker',
|
|
education: 'border border-desert-orange-light bg-desert-orange-lighter text-desert-orange-dark',
|
|
custom: 'border border-border-subtle bg-surface-secondary text-text-secondary',
|
|
}
|
|
|
|
type Modal =
|
|
| { type: 'install'; service: ServiceSlim }
|
|
| { type: 'start'; service: ServiceSlim }
|
|
| { type: 'stop'; service: ServiceSlim }
|
|
| { type: 'restart'; service: ServiceSlim }
|
|
| { type: 'reinstall'; service: ServiceSlim }
|
|
| { type: 'delete'; service: ServiceSlim }
|
|
| { type: 'uninstall'; service: ServiceSlim }
|
|
| { type: 'logs'; service: ServiceSlim }
|
|
| { type: 'stats'; service: ServiceSlim }
|
|
| { type: 'update'; service: ServiceSlim }
|
|
| null
|
|
|
|
export default function SupplyDepotPage(props: { system: { services: ServiceSlim[] } }) {
|
|
const { showError } = useErrorNotification()
|
|
const { addNotification } = useNotifications()
|
|
const { isOnline } = useInternetStatus()
|
|
const { subscribe } = useTransmit()
|
|
const installActivity = useServiceInstallationActivity()
|
|
// Global master switch for app auto-updates (Settings → Updates). Per-app
|
|
// toggles are inert until this is on, so the UI reflects that state.
|
|
const { data: appAutoUpdateStatus } = useAppAutoUpdateStatus()
|
|
const appAutoUpdateMasterEnabled = appAutoUpdateStatus?.enabled ?? false
|
|
|
|
const [activeCategory, setActiveCategory] = useState('all')
|
|
const [search, setSearch] = useState('')
|
|
const [modal, setModal] = useState<Modal>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
const [checkingUpdates, setCheckingUpdates] = useState(false)
|
|
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
|
|
const [customAppOpen, setCustomAppOpen] = useState(false)
|
|
const [editApp, setEditApp] = useState<CustomAppInitial | null>(null)
|
|
// App whose custom launch URL is being configured (null while the modal is closed).
|
|
const [urlApp, setUrlApp] = useState<ServiceSlim | null>(null)
|
|
const [removeImage, setRemoveImage] = useState(false)
|
|
// Optimistic per-app auto-update toggle state, keyed by service_name. Lets the
|
|
// toggle reflect instantly without a full page reload (props come from Inertia).
|
|
const [autoUpdateOverrides, setAutoUpdateOverrides] = useState<Record<string, boolean>>({})
|
|
|
|
// Preflight state — scoped to the current install modal
|
|
const [preflight, setPreflight] = useState<{
|
|
portConflicts: Array<{ port: number; usedBy: string }>
|
|
resourceWarnings: string[]
|
|
} | null>(null)
|
|
const [preflightLoading, setPreflightLoading] = useState(false)
|
|
const [forceInstall, setForceInstall] = useState(false)
|
|
|
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
|
|
|
// Auto-reload when installation completes
|
|
useEffect(() => {
|
|
if (!installActivity.length) return
|
|
if (installActivity.some((a) => a.type === 'completed' || a.type === 'update-complete')) {
|
|
setTimeout(() => window.location.reload(), 3000)
|
|
}
|
|
}, [installActivity])
|
|
|
|
// Listen for service update-check completion (manual or nightly), then reload so
|
|
// refreshed available_update_version values surface on the cards.
|
|
useEffect(() => {
|
|
const unsubscribe = subscribe(BROADCAST_CHANNELS.SERVICE_UPDATES, () => {
|
|
setCheckingUpdates(false)
|
|
window.location.reload()
|
|
})
|
|
return () => {
|
|
unsubscribe()
|
|
}
|
|
}, [])
|
|
|
|
// Close dropdown on outside click
|
|
useEffect(() => {
|
|
function handleClick(e: MouseEvent) {
|
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
|
setOpenDropdown(null)
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handleClick)
|
|
return () => document.removeEventListener('mousedown', handleClick)
|
|
}, [])
|
|
|
|
// Run preflight when install modal opens
|
|
useEffect(() => {
|
|
if (modal?.type !== 'install') {
|
|
setPreflight(null)
|
|
setForceInstall(false)
|
|
return
|
|
}
|
|
setPreflightLoading(true)
|
|
api
|
|
.preflightCheck(modal.service.service_name)
|
|
.then((res) => {
|
|
if (res) setPreflight(res)
|
|
})
|
|
.catch(() => { }) // non-fatal; proceed without warnings
|
|
.finally(() => setPreflightLoading(false))
|
|
}, [modal])
|
|
|
|
// ── Filtering ─────────────────────────────────────────────────────────────
|
|
const filteredServices = props.system.services.filter((s) => {
|
|
if (activeCategory === 'installed' && !s.installed) return false
|
|
if (activeCategory !== 'all' && activeCategory !== 'installed') {
|
|
if (s.category !== activeCategory) return false
|
|
}
|
|
if (search.trim()) {
|
|
const q = search.toLowerCase()
|
|
return (
|
|
s.friendly_name?.toLowerCase().includes(q) ||
|
|
s.description?.toLowerCase().includes(q) ||
|
|
s.powered_by?.toLowerCase().includes(q) ||
|
|
s.category?.toLowerCase().includes(q)
|
|
)
|
|
}
|
|
return true
|
|
})
|
|
|
|
const installedServices = filteredServices.filter((s) => s.installed)
|
|
const availableServices = filteredServices.filter((s) => !s.installed)
|
|
|
|
// Whether the new Kolibri (Gen 2) install exists — gates the "Migrate content to Gen 2" action on
|
|
// the legacy Kolibri card. Computed from the full (unfiltered) list so a search filter can't hide it.
|
|
const educationGen2Installed = props.system.services.some(
|
|
(s) => s.service_name === SERVICE_NAMES.KOLIBRI_GEN2 && s.installed
|
|
)
|
|
|
|
// ── Actions ───────────────────────────────────────────────────────────────
|
|
async function handleInstall(service: ServiceSlim) {
|
|
const hasWarnings =
|
|
(preflight?.portConflicts.length ?? 0) > 0 || (preflight?.resourceWarnings.length ?? 0) > 0
|
|
|
|
if (hasWarnings && !forceInstall) return
|
|
|
|
// Keep the modal open with a spinning confirm button while the request is in
|
|
// flight; close it once the install job is dispatched. Progress then streams
|
|
// via the InstallActivityFeed broadcast, so we drop the loading flag here.
|
|
setLoading(true)
|
|
const result = await api.installService(service.service_name)
|
|
setModal(null)
|
|
setLoading(false)
|
|
if (!result?.success) showError(result?.message || 'Failed to start installation.')
|
|
}
|
|
|
|
async function handleAffect(service: ServiceSlim, action: 'start' | 'stop' | 'restart') {
|
|
setLoading(true)
|
|
const result = await api.affectService(service.service_name, action)
|
|
setModal(null)
|
|
if (!result?.success) {
|
|
setLoading(false)
|
|
showError(result?.message || `Failed to ${action} service.`)
|
|
} else {
|
|
// Keep loading=true so the overlay covers the page until it reloads.
|
|
setTimeout(() => window.location.reload(), 1500)
|
|
}
|
|
}
|
|
|
|
async function handleForceReinstall(service: ServiceSlim) {
|
|
setLoading(true)
|
|
const result = await api.forceReinstallService(service.service_name)
|
|
setModal(null)
|
|
setLoading(false)
|
|
if (!result?.success) showError(result?.message || 'Failed to start reinstall.')
|
|
}
|
|
|
|
async function handleDelete(service: ServiceSlim) {
|
|
setLoading(true)
|
|
const result = await api.deleteCustomApp(service.service_name, removeImage)
|
|
setRemoveImage(false)
|
|
setModal(null)
|
|
if (!result?.success) {
|
|
setLoading(false)
|
|
showError(result?.message || 'Failed to delete app.')
|
|
} else {
|
|
setTimeout(() => window.location.reload(), 1000)
|
|
}
|
|
}
|
|
|
|
async function handleUninstall(service: ServiceSlim) {
|
|
setLoading(true)
|
|
const result = await api.uninstallService(service.service_name, removeImage)
|
|
setRemoveImage(false)
|
|
setModal(null)
|
|
if (!result?.success) {
|
|
setLoading(false)
|
|
showError(result?.message || 'Failed to uninstall app.')
|
|
} else {
|
|
setTimeout(() => window.location.reload(), 1000)
|
|
}
|
|
}
|
|
|
|
async function handleUpdate(service: ServiceSlim) {
|
|
setOpenDropdown(null)
|
|
setLoading(true)
|
|
const result = await api.updateCustomAppImage(service.service_name)
|
|
setLoading(false)
|
|
if (!result?.success) showError(result?.message || 'Failed to update app.')
|
|
else setTimeout(() => window.location.reload(), 1500)
|
|
}
|
|
|
|
// Manual trigger for the catalog-wide update check. Results stream back over the
|
|
// SERVICE_UPDATES broadcast (handled by the effect above), which reloads the page.
|
|
async function handleCheckUpdates() {
|
|
if (!isOnline) {
|
|
showError('You must have an internet connection to check for updates.')
|
|
return
|
|
}
|
|
try {
|
|
setCheckingUpdates(true)
|
|
const response = await api.checkServiceUpdates()
|
|
if (!response?.success) throw new Error(response?.message || 'Failed to dispatch update check')
|
|
} catch (error: any) {
|
|
showError(`Failed to check for updates: ${error?.message || 'Unknown error'}`)
|
|
setCheckingUpdates(false)
|
|
}
|
|
}
|
|
|
|
// Versioned update for a curated (non-custom) catalog app. Progress + reload are handled
|
|
// by the installActivity effect (update-complete) above.
|
|
async function handleUpdateService(service: ServiceSlim, targetVersion: string) {
|
|
setLoading(true)
|
|
const result = await api.updateService(service.service_name, targetVersion)
|
|
setLoading(false)
|
|
if (!result?.success) showError(result?.message || 'Failed to update service.')
|
|
}
|
|
|
|
// Toggle per-app automatic updates (opt-in). Optimistically reflects the new
|
|
// state, reverting if the request fails. Gated by the global master switch in
|
|
// Settings → Updates; this only sets the per-app preference.
|
|
async function handleToggleAutoUpdate(service: ServiceSlim, enabled: boolean) {
|
|
setOpenDropdown(null)
|
|
setAutoUpdateOverrides((prev) => ({ ...prev, [service.service_name]: enabled }))
|
|
const result = await api.setServiceAutoUpdate(service.service_name, enabled)
|
|
if (!result?.success) {
|
|
setAutoUpdateOverrides((prev) => ({ ...prev, [service.service_name]: !enabled }))
|
|
showError(result?.message || 'Failed to update auto-update preference.')
|
|
return
|
|
}
|
|
const appName = service.friendly_name || service.service_name
|
|
addNotification({
|
|
message: `Auto-updates for ${appName} are ${enabled ? 'on' : 'off'}.`,
|
|
type: 'success',
|
|
})
|
|
}
|
|
|
|
function handleCustomAppCreated() {
|
|
setCustomAppOpen(false)
|
|
// Page will reload when installation completes via broadcast
|
|
}
|
|
|
|
async function handleEdit(service: ServiceSlim) {
|
|
setOpenDropdown(null)
|
|
setLoading(true)
|
|
const res = await api.getCustomApp(service.service_name)
|
|
setLoading(false)
|
|
if (res?.success && res.app) {
|
|
setEditApp(res.app)
|
|
} else {
|
|
showError('Could not load this app for editing.')
|
|
}
|
|
}
|
|
|
|
function handleEdited() {
|
|
setEditApp(null)
|
|
setTimeout(() => window.location.reload(), 1500)
|
|
}
|
|
|
|
function handleSetUrl(service: ServiceSlim) {
|
|
setOpenDropdown(null)
|
|
setUrlApp(service)
|
|
}
|
|
|
|
function handleUrlSaved() {
|
|
setUrlApp(null)
|
|
// Reload so the new link flows through to the card, /home, and settings.
|
|
window.location.reload()
|
|
}
|
|
|
|
// ── Install modal helpers ─────────────────────────────────────────────────
|
|
const hasPreflightWarnings =
|
|
(preflight?.portConflicts.length ?? 0) > 0 || (preflight?.resourceWarnings.length ?? 0) > 0
|
|
|
|
return (
|
|
<AppLayout>
|
|
<Head title="Supply Depot" />
|
|
|
|
{loading && !modal && <LoadingSpinner fullscreen text="Working..." />}
|
|
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
{!isOnline && (
|
|
<Alert
|
|
title="No internet connection. You may not be able to download files."
|
|
message=""
|
|
type="warning"
|
|
variant="solid"
|
|
className="!mb-4"
|
|
/>
|
|
)}
|
|
|
|
{/* ── Hero / controls panel ─────────────────────────────────────────── */}
|
|
<div className="rounded-lg overflow-hidden bg-desert-white border border-desert-stone-light shadow-sm mb-8">
|
|
{/* Green header band */}
|
|
<div className="relative bg-desert-green px-6 py-5 overflow-hidden">
|
|
{/* Diagonal line pattern */}
|
|
<div
|
|
className="absolute inset-0 opacity-10"
|
|
style={{
|
|
backgroundImage: `repeating-linear-gradient(
|
|
45deg,
|
|
transparent,
|
|
transparent 10px,
|
|
rgba(255, 255, 255, 0.1) 10px,
|
|
rgba(255, 255, 255, 0.1) 20px
|
|
)`,
|
|
}}
|
|
/>
|
|
<div className="absolute top-0 right-0 w-24 h-24 transform translate-x-8 -translate-y-8">
|
|
<div className="w-full h-full bg-desert-green-dark opacity-30 transform rotate-45" />
|
|
</div>
|
|
<div className="relative flex items-center gap-3">
|
|
<IconBox className="text-white opacity-90 flex-shrink-0" size={28} />
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-white uppercase tracking-wide leading-tight">
|
|
Supply Depot
|
|
</h1>
|
|
<p className="text-sm text-white/70 mt-1 max-w-xl">
|
|
Browse and install curated apps, or add your own custom apps by providing a Docker
|
|
image.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Controls body */}
|
|
<div className="p-6 space-y-4">
|
|
{/* Activity feed (shown only while installing) */}
|
|
{installActivity.length > 0 && (
|
|
<InstallActivityFeed activity={installActivity} withHeader />
|
|
)}
|
|
|
|
{/* Search + Add Custom App */}
|
|
<div className="flex flex-col sm:flex-row gap-3">
|
|
<div className="relative flex-1">
|
|
<IconSearch className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted h-4 w-4" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search apps..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="w-full pl-9 pr-4 py-2 rounded-md bg-surface-secondary border border-desert-stone-lighter text-text-primary text-sm focus:outline-none focus:ring-1 focus:ring-desert-green placeholder:text-text-muted/50"
|
|
/>
|
|
</div>
|
|
<StyledButton
|
|
icon="IconRefreshAlert"
|
|
variant="outline"
|
|
onClick={handleCheckUpdates}
|
|
loading={checkingUpdates}
|
|
disabled={checkingUpdates || !isOnline}
|
|
>
|
|
Check for Updates
|
|
</StyledButton>
|
|
<StyledButton
|
|
icon="IconBrandDocker"
|
|
variant="outline"
|
|
onClick={() => setCustomAppOpen(true)}
|
|
>
|
|
Add Custom App
|
|
</StyledButton>
|
|
</div>
|
|
|
|
{/* Category filters */}
|
|
<div className="flex flex-wrap gap-2">
|
|
{CATEGORIES.map((cat) => (
|
|
<button
|
|
key={cat.id}
|
|
onClick={() => setActiveCategory(cat.id)}
|
|
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors cursor-pointer border ${activeCategory === cat.id
|
|
? 'bg-desert-green text-white border-desert-green'
|
|
: 'bg-surface-secondary text-text-muted border-desert-stone-lighter hover:text-text-primary hover:border-desert-stone-light'
|
|
}`}
|
|
>
|
|
{cat.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bottom accent bar */}
|
|
<div className="h-1 bg-desert-green" />
|
|
</div>
|
|
|
|
{/* App cards */}
|
|
{filteredServices.length === 0 ? (
|
|
<div className="text-center py-16">
|
|
<IconPackage className="mx-auto mb-3 opacity-40 text-desert-stone-light" size={48} />
|
|
<p className="text-text-muted">No apps match your filter.</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-10">
|
|
{installedServices.length > 0 && (
|
|
<section>
|
|
<StyledSectionHeader title={`Installed (${installedServices.length})`} />
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{installedServices.map((service) => (
|
|
<AppCard
|
|
key={service.service_name}
|
|
service={service}
|
|
openDropdown={openDropdown}
|
|
dropdownRef={dropdownRef}
|
|
onOpenDropdown={setOpenDropdown}
|
|
onInstall={() => setModal({ type: 'install', service })}
|
|
onStart={() => setModal({ type: 'start', service })}
|
|
onStop={() => setModal({ type: 'stop', service })}
|
|
onRestart={() => setModal({ type: 'restart', service })}
|
|
onReinstall={() => setModal({ type: 'reinstall', service })}
|
|
onDelete={() => setModal({ type: 'delete', service })}
|
|
onUninstall={() => setModal({ type: 'uninstall', service })}
|
|
onLogs={() => setModal({ type: 'logs', service })}
|
|
onStats={() => setModal({ type: 'stats', service })}
|
|
onEdit={() => handleEdit(service)}
|
|
onSetUrl={() => handleSetUrl(service)}
|
|
onUpdate={() => handleUpdate(service)}
|
|
onUpdateVersion={() => setModal({ type: 'update', service })}
|
|
autoUpdateEnabled={
|
|
autoUpdateOverrides[service.service_name] ?? service.auto_update_enabled
|
|
}
|
|
autoUpdateMasterEnabled={appAutoUpdateMasterEnabled}
|
|
onToggleAutoUpdate={(enabled) => handleToggleAutoUpdate(service, enabled)}
|
|
migrationInstructionsHref={(service.service_name.startsWith(SERVICE_NAMES.KOLIBRI) && educationGen2Installed) ? getSupplyDepotDocLink(SERVICE_NAMES.KOLIBRI) || undefined : undefined}
|
|
migrationInstructionsText={(service.service_name === SERVICE_NAMES.KOLIBRI) ? 'How to migrate content to Gen 2' : "How to migrate content from Gen 1"}
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{availableServices.length > 0 && (
|
|
<section>
|
|
<StyledSectionHeader title={`Available (${availableServices.length})`} />
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{availableServices.map((service) => (
|
|
<AppCard
|
|
key={service.service_name}
|
|
service={service}
|
|
openDropdown={openDropdown}
|
|
dropdownRef={dropdownRef}
|
|
onOpenDropdown={setOpenDropdown}
|
|
onInstall={() => setModal({ type: 'install', service })}
|
|
onStart={() => setModal({ type: 'start', service })}
|
|
onStop={() => setModal({ type: 'stop', service })}
|
|
onRestart={() => setModal({ type: 'restart', service })}
|
|
onReinstall={() => setModal({ type: 'reinstall', service })}
|
|
onDelete={() => setModal({ type: 'delete', service })}
|
|
onUninstall={() => setModal({ type: 'uninstall', service })}
|
|
onLogs={() => setModal({ type: 'logs', service })}
|
|
onStats={() => setModal({ type: 'stats', service })}
|
|
onEdit={() => handleEdit(service)}
|
|
onSetUrl={() => handleSetUrl(service)}
|
|
onUpdate={() => handleUpdate(service)}
|
|
onUpdateVersion={() => setModal({ type: 'update', service })}
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Modals ─────────────────────────────────────────────────────────── */}
|
|
|
|
{/* Install modal */}
|
|
{modal?.type === 'install' && (
|
|
<StyledModal
|
|
title={`Install ${modal.service.friendly_name ?? modal.service.service_name}`}
|
|
open
|
|
onCancel={() => {
|
|
if (loading) return
|
|
setModal(null)
|
|
}}
|
|
onConfirm={() => handleInstall(modal.service)}
|
|
confirmText="Install"
|
|
confirmIcon="IconDownload"
|
|
confirmVariant="primary"
|
|
confirmLoading={loading}
|
|
>
|
|
<div className="space-y-3 text-sm text-text-muted">
|
|
<p>
|
|
This will download and start <strong className="text-text-primary">{modal.service.friendly_name}</strong>
|
|
{modal.service.ui_location && (
|
|
<> on port <strong className="text-text-primary">{modal.service.ui_location}</strong></>
|
|
)}.
|
|
</p>
|
|
{modal.service.powered_by && (
|
|
<p className="text-xs">Powered by {modal.service.powered_by}</p>
|
|
)}
|
|
|
|
{preflightLoading && (
|
|
<div className="flex items-center gap-2 text-xs text-text-muted py-2">
|
|
<span className="animate-spin inline-block w-3 h-3 border border-desert-green border-t-transparent rounded-full" />
|
|
Checking for conflicts…
|
|
</div>
|
|
)}
|
|
|
|
{!preflightLoading && preflight && hasPreflightWarnings && (
|
|
<div className="space-y-2 pt-1">
|
|
{preflight.portConflicts.map((c) => (
|
|
<Alert
|
|
key={c.port}
|
|
type="warning"
|
|
title={`Port ${c.port} already in use`}
|
|
message={`Currently bound by: ${c.usedBy}. Installation may fail.`}
|
|
/>
|
|
))}
|
|
{preflight.resourceWarnings.map((w, i) => (
|
|
<Alert key={i} type="warning" title="Resource warning" message={w} />
|
|
))}
|
|
<label className="flex items-center gap-2 cursor-pointer select-none mt-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={forceInstall}
|
|
onChange={(e) => setForceInstall(e.target.checked)}
|
|
className="accent-desert-orange h-4 w-4 rounded"
|
|
/>
|
|
<span className="text-xs text-text-muted">I understand — install anyway</span>
|
|
</label>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</StyledModal>
|
|
)}
|
|
|
|
{/* Start modal */}
|
|
{modal?.type === 'start' && (
|
|
<StyledModal
|
|
title={`Start ${modal.service.friendly_name ?? modal.service.service_name}`}
|
|
open
|
|
onCancel={() => {
|
|
if (loading) return
|
|
setModal(null)
|
|
}}
|
|
onConfirm={() => handleAffect(modal.service, 'start')}
|
|
confirmText="Start"
|
|
confirmIcon="IconPlayerPlay"
|
|
confirmVariant="primary"
|
|
confirmLoading={loading}
|
|
>
|
|
<p className="text-sm text-text-muted">This will start the container.</p>
|
|
</StyledModal>
|
|
)}
|
|
|
|
{/* Stop modal */}
|
|
{modal?.type === 'stop' && (
|
|
<StyledModal
|
|
title={`Stop ${modal.service.friendly_name ?? modal.service.service_name}`}
|
|
open
|
|
onCancel={() => {
|
|
if (loading) return
|
|
setModal(null)
|
|
}}
|
|
onConfirm={() => handleAffect(modal.service, 'stop')}
|
|
confirmText="Stop"
|
|
confirmIcon="IconPlayerStop"
|
|
confirmVariant="action"
|
|
confirmLoading={loading}
|
|
>
|
|
<p className="text-sm text-text-muted">The container will be stopped. Your data is preserved.</p>
|
|
</StyledModal>
|
|
)}
|
|
|
|
{/* Restart modal */}
|
|
{modal?.type === 'restart' && (
|
|
<StyledModal
|
|
title={`Restart ${modal.service.friendly_name ?? modal.service.service_name}`}
|
|
open
|
|
onCancel={() => {
|
|
if (loading) return
|
|
setModal(null)
|
|
}}
|
|
onConfirm={() => handleAffect(modal.service, 'restart')}
|
|
confirmText="Restart"
|
|
confirmIcon="IconRefresh"
|
|
confirmVariant="action"
|
|
confirmLoading={loading}
|
|
>
|
|
<p className="text-sm text-text-muted">The container will be briefly stopped and restarted.</p>
|
|
</StyledModal>
|
|
)}
|
|
|
|
{/* Force reinstall modal */}
|
|
{modal?.type === 'reinstall' && (
|
|
<StyledModal
|
|
title={`Force Reinstall ${modal.service.friendly_name ?? modal.service.service_name}`}
|
|
open
|
|
onCancel={() => {
|
|
if (loading) return
|
|
setModal(null)
|
|
}}
|
|
onConfirm={() => handleForceReinstall(modal.service)}
|
|
confirmText="Wipe & Reinstall"
|
|
confirmIcon="IconRefresh"
|
|
confirmVariant="danger"
|
|
confirmLoading={loading}
|
|
icon={<IconAlertTriangle className="text-desert-red" size={40} />}
|
|
>
|
|
<div className="space-y-2 text-sm text-text-muted">
|
|
<p className="font-semibold text-desert-red">This will delete all app data and cannot be undone.</p>
|
|
<p>The container and its associated volumes will be removed, then a fresh installation will begin.</p>
|
|
</div>
|
|
</StyledModal>
|
|
)}
|
|
|
|
{/* Delete custom app modal */}
|
|
{modal?.type === 'delete' && (
|
|
<StyledModal
|
|
title={`Delete ${modal.service.friendly_name ?? modal.service.service_name}`}
|
|
open
|
|
onCancel={() => {
|
|
if (loading) return
|
|
setRemoveImage(false)
|
|
setModal(null)
|
|
}}
|
|
onConfirm={() => handleDelete(modal.service)}
|
|
confirmText="Delete"
|
|
confirmIcon="IconTrash"
|
|
confirmVariant="danger"
|
|
confirmLoading={loading}
|
|
icon={<IconAlertTriangle className="text-desert-red" size={40} />}
|
|
>
|
|
<div className="space-y-3 text-sm text-text-muted">
|
|
<p className="font-semibold text-desert-red">This will permanently remove this custom app.</p>
|
|
<p>The container will be stopped and removed. Host volume data will remain on disk.</p>
|
|
<label className="flex items-center gap-2 cursor-pointer select-none">
|
|
<input
|
|
type="checkbox"
|
|
checked={removeImage}
|
|
onChange={(e) => setRemoveImage(e.target.checked)}
|
|
className="accent-desert-red h-4 w-4 rounded"
|
|
/>
|
|
<span className="text-text-muted text-xs">Also remove the Docker image to reclaim disk space</span>
|
|
</label>
|
|
</div>
|
|
</StyledModal>
|
|
)}
|
|
|
|
{/* Uninstall curated app modal */}
|
|
{modal?.type === 'uninstall' && (
|
|
<StyledModal
|
|
title={`Uninstall ${modal.service.friendly_name ?? modal.service.service_name}`}
|
|
open
|
|
onCancel={() => {
|
|
if (loading) return
|
|
setRemoveImage(false)
|
|
setModal(null)
|
|
}}
|
|
onConfirm={() => handleUninstall(modal.service)}
|
|
confirmText="Uninstall"
|
|
confirmIcon="IconTrash"
|
|
confirmVariant="danger"
|
|
confirmLoading={loading}
|
|
icon={<IconAlertTriangle className="text-desert-red" size={40} />}
|
|
>
|
|
<div className="space-y-3 text-sm text-text-muted">
|
|
<p className="font-semibold text-desert-red">This will remove the app from this device.</p>
|
|
<p>The container will be stopped and removed, and the app returns to the catalog below. App data under the storage folder stays on disk, so reinstalling brings it back as it was.</p>
|
|
<label className="flex items-center gap-2 cursor-pointer select-none">
|
|
<input
|
|
type="checkbox"
|
|
checked={removeImage}
|
|
onChange={(e) => setRemoveImage(e.target.checked)}
|
|
className="accent-desert-red h-4 w-4 rounded"
|
|
/>
|
|
<span className="text-text-muted text-xs">Also remove the Docker image to reclaim disk space</span>
|
|
</label>
|
|
</div>
|
|
</StyledModal>
|
|
)}
|
|
|
|
{/* Logs modal */}
|
|
{modal?.type === 'logs' && (
|
|
<ServiceLogsModal
|
|
serviceName={modal.service.service_name}
|
|
friendlyName={modal.service.friendly_name ?? modal.service.service_name}
|
|
open
|
|
onClose={() => setModal(null)}
|
|
/>
|
|
)}
|
|
|
|
{/* Stats modal */}
|
|
{modal?.type === 'stats' && (
|
|
<ServiceStatsModal
|
|
serviceName={modal.service.service_name}
|
|
friendlyName={modal.service.friendly_name ?? modal.service.service_name}
|
|
open
|
|
onClose={() => setModal(null)}
|
|
/>
|
|
)}
|
|
|
|
{/* Versioned update modal (curated apps with an available update) */}
|
|
{modal?.type === 'update' && (
|
|
<UpdateServiceModal
|
|
record={modal.service}
|
|
currentTag={extractTag(modal.service.container_image)}
|
|
latestVersion={modal.service.available_update_version!}
|
|
onCancel={() => {
|
|
if (loading) return
|
|
setModal(null)
|
|
}}
|
|
onUpdate={(targetVersion) => {
|
|
const service = modal.service
|
|
setModal(null)
|
|
handleUpdateService(service, targetVersion)
|
|
}}
|
|
showError={showError}
|
|
/>
|
|
)}
|
|
|
|
{/* Custom app creation modal */}
|
|
<CustomAppModal
|
|
open={customAppOpen}
|
|
onClose={() => setCustomAppOpen(false)}
|
|
onCreated={handleCustomAppCreated}
|
|
showError={showError}
|
|
/>
|
|
|
|
{/* Custom app edit modal */}
|
|
<CustomAppModal
|
|
open={!!editApp}
|
|
mode="edit"
|
|
initial={editApp}
|
|
onClose={() => setEditApp(null)}
|
|
onCreated={handleEdited}
|
|
showError={showError}
|
|
/>
|
|
|
|
{/* Custom launch URL modal */}
|
|
<AppUrlModal
|
|
open={!!urlApp}
|
|
service={urlApp}
|
|
onClose={() => setUrlApp(null)}
|
|
onSaved={handleUrlSaved}
|
|
showError={showError}
|
|
/>
|
|
</AppLayout>
|
|
)
|
|
}
|
|
|
|
// ── App Card component ────────────────────────────────────────────────────────
|
|
|
|
interface AppCardProps {
|
|
service: ServiceSlim
|
|
openDropdown: string | null
|
|
dropdownRef: React.RefObject<HTMLDivElement | null>
|
|
onOpenDropdown: (name: string | null) => void
|
|
onInstall: () => void
|
|
onStart: () => void
|
|
onStop: () => void
|
|
onRestart: () => void
|
|
onReinstall: () => void
|
|
onDelete: () => void
|
|
onUninstall: () => void
|
|
onLogs: () => void
|
|
onStats: () => void
|
|
onEdit: () => void
|
|
onSetUrl: () => void
|
|
onUpdate: () => void
|
|
onUpdateVersion: () => void
|
|
// Installed-only: per-app auto-update preference + toggle handler.
|
|
autoUpdateEnabled?: boolean
|
|
// Global master switch (Settings → Updates). When off, per-app toggles are inert.
|
|
autoUpdateMasterEnabled?: boolean
|
|
onToggleAutoUpdate?: (enabled: boolean) => void
|
|
migrationInstructionsHref?: string
|
|
migrationInstructionsText?: string
|
|
}
|
|
|
|
function AppCard({
|
|
service,
|
|
openDropdown,
|
|
dropdownRef,
|
|
onOpenDropdown,
|
|
onInstall,
|
|
onStart,
|
|
onStop,
|
|
onRestart,
|
|
onReinstall,
|
|
onDelete,
|
|
onUninstall,
|
|
onLogs,
|
|
onStats,
|
|
onEdit,
|
|
onSetUrl,
|
|
onUpdate,
|
|
onUpdateVersion,
|
|
autoUpdateEnabled,
|
|
autoUpdateMasterEnabled,
|
|
onToggleAutoUpdate,
|
|
migrationInstructionsHref,
|
|
migrationInstructionsText,
|
|
}: AppCardProps) {
|
|
const isRunning = service.status === 'running'
|
|
const isStopped = service.installed && !isRunning
|
|
const catColor = service.category ? CATEGORY_COLORS[service.category] ?? CATEGORY_COLORS.custom : CATEGORY_COLORS.custom
|
|
const isDropdownOpen = openDropdown === service.service_name
|
|
// Port pill: an ui_location may carry an explicit scheme ("https:8480") — show just the port,
|
|
// with a lock when it's served over HTTPS, rather than the raw "https:8480" string.
|
|
const uiIsPath = !!service.ui_location && service.ui_location.startsWith('/')
|
|
const uiIsHttps = /^https:/.test(service.ui_location || '')
|
|
const uiPort = service.ui_location && !uiIsPath ? service.ui_location.replace(/^https?:/, '') : null
|
|
// Per-app documentation link (in-app docs page, anchored to this app's section). Null for apps
|
|
// without a doc section (custom apps, undocumented catalog apps) so the Docs item is hidden.
|
|
const docLink = getSupplyDepotDocLink(service.service_name)
|
|
|
|
// Subtitle under the app name: the powered-by name plus the installed image tag
|
|
// (e.g. "Kiwix · 3.7.0"). Only installed apps have a meaningful running version; a
|
|
// not-yet-installed catalog entry shows just the powered-by name. Null when neither exists.
|
|
const version = service.installed ? extractTag(service.container_image) : ''
|
|
const subtitle =
|
|
!service.powered_by && !version ? null : (
|
|
<p className="text-xs text-text-muted truncate">
|
|
{service.powered_by}
|
|
{service.powered_by && version ? ' · ' : ''}
|
|
{version ? <span className="font-mono">{version}</span> : null}
|
|
</p>
|
|
)
|
|
|
|
function toggleDropdown(e: React.MouseEvent) {
|
|
e.stopPropagation()
|
|
onOpenDropdown(isDropdownOpen ? null : service.service_name)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={`relative flex flex-col rounded-xl border p-4 bg-surface-primary shadow-sm transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5 ${service.installed
|
|
? 'border-desert-stone-light'
|
|
: 'border-desert-stone-lighter hover:border-desert-stone-light'
|
|
}`}
|
|
>
|
|
{/* Installed accent spine (rounded to follow the card corners — the card no longer clips
|
|
overflow so the Manage dropdown can open above the card without being cut off) */}
|
|
{service.installed ? (
|
|
<div className="absolute left-0 top-0 bottom-0 w-1 bg-desert-green rounded-l-xl" />
|
|
) : null}
|
|
|
|
{/* Top row: icon + status badge */}
|
|
<div className="flex items-start justify-between mb-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-12 h-12 rounded-lg bg-desert-green-lighter border border-desert-green-light flex items-center justify-center flex-shrink-0">
|
|
{service.icon ? (
|
|
<DynamicIcon icon={service.icon as DynamicIconName} className="h-7 w-7 text-desert-green" />
|
|
) : (
|
|
<IconBrandDocker className="h-7 w-7 text-text-muted" />
|
|
)}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="font-semibold text-text-primary text-sm leading-tight truncate">
|
|
{service.friendly_name ?? service.service_name}
|
|
</p>
|
|
{subtitle}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status indicator */}
|
|
<div className="flex-shrink-0 ml-2">
|
|
{service.installation_status === 'installing' ? (
|
|
<span className="flex items-center gap-1 text-xs text-desert-orange">
|
|
<span className="animate-spin inline-block w-3 h-3 border border-desert-orange border-t-transparent rounded-full" />
|
|
Installing
|
|
</span>
|
|
) : isRunning ? (
|
|
<span className="flex items-center gap-1 text-xs text-desert-green">
|
|
<span className="h-2 w-2 rounded-full bg-desert-green" />
|
|
Running
|
|
</span>
|
|
) : isStopped ? (
|
|
<span className="flex items-center gap-1 text-xs text-text-muted">
|
|
<span className="h-2 w-2 rounded-full bg-text-muted" />
|
|
Stopped
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
{service.description && (
|
|
<p className="text-xs text-text-muted leading-relaxed mb-3 flex-1 line-clamp-2">
|
|
{service.description}
|
|
</p>
|
|
)}
|
|
|
|
{/* Metadata row: category badge + port pill */}
|
|
<div className="flex items-center gap-2 mb-4 flex-wrap">
|
|
{service.category && (
|
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${catColor}`}>
|
|
{toTitleCase(service.category)}
|
|
</span>
|
|
)}
|
|
{service.is_custom ? (
|
|
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-surface-secondary text-text-muted border border-surface-secondary">
|
|
custom
|
|
</span>
|
|
) : null}
|
|
{service.is_user_modified && !service.is_custom ? (
|
|
<span
|
|
className="text-xs px-2 py-0.5 rounded-full font-medium bg-desert-tan-lighter text-desert-tan-dark border border-desert-tan-light"
|
|
title="You've customized this app, so it won't be overwritten by catalog updates."
|
|
>
|
|
modified
|
|
</span>
|
|
) : null}
|
|
{service.is_deprecated ? (
|
|
<span
|
|
className="text-xs px-2 py-0.5 rounded-full font-medium bg-desert-orange-lighter text-desert-orange-dark border border-desert-orange-light"
|
|
title="This is a legacy version that's no longer maintained. Install the current Education Platform from the catalog, then uninstall this one."
|
|
>
|
|
legacy
|
|
</span>
|
|
) : null}
|
|
{uiPort && (
|
|
<span className="text-xs px-2 py-0.5 rounded-full bg-surface-secondary text-text-muted font-mono">
|
|
{uiIsHttps ? '🔒 ' : ''}:{uiPort}
|
|
</span>
|
|
)}
|
|
{service.available_update_version && !service.is_custom && (
|
|
<button
|
|
type="button"
|
|
onClick={onUpdateVersion}
|
|
title={`Update to ${service.available_update_version}`}
|
|
className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-semibold bg-desert-orange text-white shadow-sm cursor-pointer transition-colors hover:bg-desert-orange-dark"
|
|
>
|
|
<IconArrowUp className="h-3 w-3" />
|
|
Update available
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Action buttons */}
|
|
<div className="flex items-center gap-2">
|
|
{!service.installed && service.installation_status !== 'installing' && (
|
|
<StyledButton
|
|
size="sm"
|
|
variant="primary"
|
|
icon="IconDownload"
|
|
onClick={onInstall}
|
|
fullWidth
|
|
>
|
|
Install
|
|
</StyledButton>
|
|
)}
|
|
|
|
{service.installed ? (
|
|
<>
|
|
{/* Open button — shown when the app has a default location or a user-set custom URL */}
|
|
{(service.ui_location || service.custom_url) && (
|
|
<a
|
|
href={getServiceLink(service.ui_location || "", service.custom_url)}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex-1"
|
|
>
|
|
<StyledButton size="sm" variant="primary" icon="IconExternalLink" fullWidth>
|
|
Open
|
|
</StyledButton>
|
|
</a>
|
|
)}
|
|
|
|
{/* Manage dropdown */}
|
|
<div className="relative" ref={isDropdownOpen ? dropdownRef : null}>
|
|
<StyledButton size="sm" variant="outline" onClick={toggleDropdown} icon="IconChevronDown">
|
|
Manage
|
|
</StyledButton>
|
|
|
|
{isDropdownOpen && (
|
|
<div className="absolute right-0 bottom-full mb-1 w-44 bg-surface-primary border border-surface-secondary rounded-lg shadow-xl z-20 overflow-hidden">
|
|
{docLink && (
|
|
<a
|
|
href={docLink}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="flex items-center gap-2 w-full px-3 py-2 text-xs transition-colors text-left cursor-pointer text-text-primary hover:bg-surface-secondary"
|
|
>
|
|
<IconBook className="h-4 w-4" />
|
|
Docs
|
|
</a>
|
|
)}
|
|
{isStopped && (
|
|
<DropdownItem icon={<IconPlayerPlay className="h-4 w-4" />} label="Start" onClick={onStart} />
|
|
)}
|
|
{isRunning && (
|
|
<DropdownItem icon={<IconPlayerStop className="h-4 w-4" />} label="Stop" onClick={onStop} />
|
|
)}
|
|
<DropdownItem icon={<IconRefresh className="h-4 w-4" />} label="Restart" onClick={onRestart} />
|
|
<DropdownItem icon={<IconFileText className="h-4 w-4" />} label="Logs" onClick={onLogs} />
|
|
<DropdownItem icon={<IconChartBar className="h-4 w-4" />} label="Stats" onClick={onStats} />
|
|
<DropdownItem icon={<IconPencil className="h-4 w-4" />} label="Edit" onClick={onEdit} />
|
|
<DropdownItem icon={<IconWorld className="h-4 w-4" />} label="Set custom URL" onClick={onSetUrl} />
|
|
{
|
|
migrationInstructionsHref ? (
|
|
<a
|
|
href={migrationInstructionsHref}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="flex items-center gap-2 w-full px-3 py-2 text-xs transition-colors text-left cursor-pointer text-text-primary hover:bg-surface-secondary"
|
|
>
|
|
<IconBook className="h-4 w-4" />
|
|
{migrationInstructionsText || 'Migration instructions'}
|
|
</a>
|
|
) : (null)
|
|
}
|
|
{!service.is_custom && onToggleAutoUpdate ? (
|
|
autoUpdateMasterEnabled ? (
|
|
<DropdownItem
|
|
icon={
|
|
<IconClockBolt
|
|
className={`h-4 w-4 ${autoUpdateEnabled ? 'text-desert-green' : ''}`}
|
|
/>
|
|
}
|
|
label={`Auto-update: ${autoUpdateEnabled ? 'On' : 'Off'}`}
|
|
onClick={() => onToggleAutoUpdate(!autoUpdateEnabled)}
|
|
/>
|
|
) : (
|
|
<DropdownItem
|
|
icon={<IconClockBolt className="h-4 w-4" />}
|
|
label="App auto-updates off — open Settings"
|
|
onClick={() => router.visit('/settings/update')}
|
|
/>
|
|
)
|
|
) : null}
|
|
{service.available_update_version && !service.is_custom ? (
|
|
<DropdownItem
|
|
icon={<IconArrowUp className="h-4 w-4 text-desert-green" />}
|
|
label={`Update to ${service.available_update_version}`}
|
|
onClick={onUpdateVersion}
|
|
/>
|
|
) : null}
|
|
{service.is_custom ? (
|
|
<DropdownItem icon={<IconCloudDownload className="h-4 w-4" />} label="Update (pull latest)" onClick={onUpdate} />
|
|
) : null}
|
|
<DropdownItem icon={<IconRefresh className="h-4 w-4 text-desert-orange" />} label="Force Reinstall" onClick={onReinstall} danger />
|
|
{service.is_custom ? (
|
|
<DropdownItem icon={<IconTrash className="h-4 w-4 text-desert-red" />} label="Delete" onClick={onDelete} danger />
|
|
) : (
|
|
<DropdownItem icon={<IconTrash className="h-4 w-4 text-desert-red" />} label="Uninstall" onClick={onUninstall} danger />
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
) : null}
|
|
|
|
{service.installation_status === 'installing' && (
|
|
<div className="flex-1 flex items-center justify-center text-xs text-text-muted gap-1 py-1">
|
|
<span className="animate-spin inline-block w-3 h-3 border border-desert-green border-t-transparent rounded-full" />
|
|
In progress…
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function DropdownItem({
|
|
icon,
|
|
label,
|
|
onClick,
|
|
danger = false,
|
|
}: {
|
|
icon: React.ReactNode
|
|
label: string
|
|
onClick: () => void
|
|
danger?: boolean
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
onClick()
|
|
}}
|
|
className={`flex items-center gap-2 w-full px-3 py-2 text-xs transition-colors text-left cursor-pointer ${danger
|
|
? 'text-desert-red hover:bg-desert-red/10'
|
|
: 'text-text-primary hover:bg-surface-secondary'
|
|
}`}
|
|
>
|
|
{icon}
|
|
{label}
|
|
</button>
|
|
)
|
|
}
|