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 = { 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(null) const [loading, setLoading] = useState(false) const [checkingUpdates, setCheckingUpdates] = useState(false) const [openDropdown, setOpenDropdown] = useState(null) const [customAppOpen, setCustomAppOpen] = useState(false) const [editApp, setEditApp] = useState(null) // App whose custom launch URL is being configured (null while the modal is closed). const [urlApp, setUrlApp] = useState(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>({}) // 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(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 ( {loading && !modal && }
{!isOnline && ( )} {/* ── Hero / controls panel ─────────────────────────────────────────── */}
{/* Green header band */}
{/* Diagonal line pattern */}

Supply Depot

Browse and install curated apps, or add your own custom apps by providing a Docker image.

{/* Controls body */}
{/* Activity feed (shown only while installing) */} {installActivity.length > 0 && ( )} {/* Search + Add Custom App */}
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" />
Check for Updates setCustomAppOpen(true)} > Add Custom App
{/* Category filters */}
{CATEGORIES.map((cat) => ( ))}
{/* Bottom accent bar */}
{/* App cards */} {filteredServices.length === 0 ? (

No apps match your filter.

) : (
{installedServices.length > 0 && (
{installedServices.map((service) => ( 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"} /> ))}
)} {availableServices.length > 0 && (
{availableServices.map((service) => ( 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 })} /> ))}
)}
)}
{/* ── Modals ─────────────────────────────────────────────────────────── */} {/* Install modal */} {modal?.type === 'install' && ( { if (loading) return setModal(null) }} onConfirm={() => handleInstall(modal.service)} confirmText="Install" confirmIcon="IconDownload" confirmVariant="primary" confirmLoading={loading} >

This will download and start {modal.service.friendly_name} {modal.service.ui_location && ( <> on port {modal.service.ui_location} )}.

{modal.service.powered_by && (

Powered by {modal.service.powered_by}

)} {preflightLoading && (
Checking for conflicts…
)} {!preflightLoading && preflight && hasPreflightWarnings && (
{preflight.portConflicts.map((c) => ( ))} {preflight.resourceWarnings.map((w, i) => ( ))}
)}
)} {/* Start modal */} {modal?.type === 'start' && ( { if (loading) return setModal(null) }} onConfirm={() => handleAffect(modal.service, 'start')} confirmText="Start" confirmIcon="IconPlayerPlay" confirmVariant="primary" confirmLoading={loading} >

This will start the container.

)} {/* Stop modal */} {modal?.type === 'stop' && ( { if (loading) return setModal(null) }} onConfirm={() => handleAffect(modal.service, 'stop')} confirmText="Stop" confirmIcon="IconPlayerStop" confirmVariant="action" confirmLoading={loading} >

The container will be stopped. Your data is preserved.

)} {/* Restart modal */} {modal?.type === 'restart' && ( { if (loading) return setModal(null) }} onConfirm={() => handleAffect(modal.service, 'restart')} confirmText="Restart" confirmIcon="IconRefresh" confirmVariant="action" confirmLoading={loading} >

The container will be briefly stopped and restarted.

)} {/* Force reinstall modal */} {modal?.type === 'reinstall' && ( { if (loading) return setModal(null) }} onConfirm={() => handleForceReinstall(modal.service)} confirmText="Wipe & Reinstall" confirmIcon="IconRefresh" confirmVariant="danger" confirmLoading={loading} icon={} >

This will delete all app data and cannot be undone.

The container and its associated volumes will be removed, then a fresh installation will begin.

)} {/* Delete custom app modal */} {modal?.type === 'delete' && ( { if (loading) return setRemoveImage(false) setModal(null) }} onConfirm={() => handleDelete(modal.service)} confirmText="Delete" confirmIcon="IconTrash" confirmVariant="danger" confirmLoading={loading} icon={} >

This will permanently remove this custom app.

The container will be stopped and removed. Host volume data will remain on disk.

)} {/* Uninstall curated app modal */} {modal?.type === 'uninstall' && ( { if (loading) return setRemoveImage(false) setModal(null) }} onConfirm={() => handleUninstall(modal.service)} confirmText="Uninstall" confirmIcon="IconTrash" confirmVariant="danger" confirmLoading={loading} icon={} >

This will remove the app from this device.

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.

)} {/* Logs modal */} {modal?.type === 'logs' && ( setModal(null)} /> )} {/* Stats modal */} {modal?.type === 'stats' && ( setModal(null)} /> )} {/* Versioned update modal (curated apps with an available update) */} {modal?.type === 'update' && ( { if (loading) return setModal(null) }} onUpdate={(targetVersion) => { const service = modal.service setModal(null) handleUpdateService(service, targetVersion) }} showError={showError} /> )} {/* Custom app creation modal */} setCustomAppOpen(false)} onCreated={handleCustomAppCreated} showError={showError} /> {/* Custom app edit modal */} setEditApp(null)} onCreated={handleEdited} showError={showError} /> {/* Custom launch URL modal */} setUrlApp(null)} onSaved={handleUrlSaved} showError={showError} /> ) } // ── App Card component ──────────────────────────────────────────────────────── interface AppCardProps { service: ServiceSlim openDropdown: string | null dropdownRef: React.RefObject 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 : (

{service.powered_by} {service.powered_by && version ? ' · ' : ''} {version ? {version} : null}

) function toggleDropdown(e: React.MouseEvent) { e.stopPropagation() onOpenDropdown(isDropdownOpen ? null : service.service_name) } return (
{/* 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 ? (
) : null} {/* Top row: icon + status badge */}
{service.icon ? ( ) : ( )}

{service.friendly_name ?? service.service_name}

{subtitle}
{/* Status indicator */}
{service.installation_status === 'installing' ? ( Installing ) : isRunning ? ( Running ) : isStopped ? ( Stopped ) : null}
{/* Description */} {service.description && (

{service.description}

)} {/* Metadata row: category badge + port pill */}
{service.category && ( {toTitleCase(service.category)} )} {service.is_custom ? ( custom ) : null} {service.is_user_modified && !service.is_custom ? ( modified ) : null} {service.is_deprecated ? ( legacy ) : null} {uiPort && ( {uiIsHttps ? '🔒 ' : ''}:{uiPort} )} {service.available_update_version && !service.is_custom && ( )}
{/* Action buttons */}
{!service.installed && service.installation_status !== 'installing' && ( Install )} {service.installed ? ( <> {/* Open button — shown when the app has a default location or a user-set custom URL */} {(service.ui_location || service.custom_url) && ( Open )} {/* Manage dropdown */}
Manage {isDropdownOpen && (
{docLink && ( 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" > Docs )} {isStopped && ( } label="Start" onClick={onStart} /> )} {isRunning && ( } label="Stop" onClick={onStop} /> )} } label="Restart" onClick={onRestart} /> } label="Logs" onClick={onLogs} /> } label="Stats" onClick={onStats} /> } label="Edit" onClick={onEdit} /> } label="Set custom URL" onClick={onSetUrl} /> { migrationInstructionsHref ? ( 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" > {migrationInstructionsText || 'Migration instructions'} ) : (null) } {!service.is_custom && onToggleAutoUpdate ? ( autoUpdateMasterEnabled ? ( } label={`Auto-update: ${autoUpdateEnabled ? 'On' : 'Off'}`} onClick={() => onToggleAutoUpdate(!autoUpdateEnabled)} /> ) : ( } label="App auto-updates off — open Settings" onClick={() => router.visit('/settings/update')} /> ) ) : null} {service.available_update_version && !service.is_custom ? ( } label={`Update to ${service.available_update_version}`} onClick={onUpdateVersion} /> ) : null} {service.is_custom ? ( } label="Update (pull latest)" onClick={onUpdate} /> ) : null} } label="Force Reinstall" onClick={onReinstall} danger /> {service.is_custom ? ( } label="Delete" onClick={onDelete} danger /> ) : ( } label="Uninstall" onClick={onUninstall} danger /> )}
)}
) : null} {service.installation_status === 'installing' && (
In progress…
)}
) } function DropdownItem({ icon, label, onClick, danger = false, }: { icon: React.ReactNode label: string onClick: () => void danger?: boolean }) { return ( ) }