From 97e17b86640b024e51f0156fdd7665b607082f98 Mon Sep 17 00:00:00 2001 From: jakeaturner Date: Sat, 6 Jun 2026 17:40:21 +0000 Subject: [PATCH] fix(supply-depot): reintroduce app update UI --- admin/inertia/pages/supply-depot.tsx | 100 +++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/admin/inertia/pages/supply-depot.tsx b/admin/inertia/pages/supply-depot.tsx index a33aba1..887a7fa 100644 --- a/admin/inertia/pages/supply-depot.tsx +++ b/admin/inertia/pages/supply-depot.tsx @@ -2,6 +2,7 @@ import { Head } from '@inertiajs/react' import { useEffect, useRef, useState } from 'react' import { IconAlertTriangle, + IconArrowUp, IconBook, IconBox, IconBrandDocker, @@ -27,14 +28,24 @@ import CustomAppModal, { CustomAppInitial } from '~/components/CustomAppModal' 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 useInternetStatus from '~/hooks/useInternetStatus' 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' +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' }, @@ -68,16 +79,20 @@ type Modal = | { type: 'delete'; 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 { isOnline } = useInternetStatus() + const { subscribe } = useTransmit() const installActivity = useServiceInstallationActivity() 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) @@ -101,6 +116,18 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim } }, [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) { @@ -200,6 +227,32 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim 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.') + } + function handleCustomAppCreated() { setCustomAppOpen(false) // Page will reload when installation completes via broadcast @@ -287,6 +340,15 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim 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 + setModal({ type: 'stats', service })} onEdit={() => handleEdit(service)} onUpdate={() => handleUpdate(service)} + onUpdateVersion={() => setModal({ type: 'update', service })} /> ))} @@ -374,6 +437,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim onStats={() => setModal({ type: 'stats', service })} onEdit={() => handleEdit(service)} onUpdate={() => handleUpdate(service)} + onUpdateVersion={() => setModal({ type: 'update', service })} /> ))} @@ -563,6 +627,22 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim /> )} + {/* Versioned update modal (curated apps with an available update) */} + {modal?.type === 'update' && ( + setModal(null)} + onUpdate={(targetVersion) => { + const service = modal.service + setModal(null) + handleUpdateService(service, targetVersion) + }} + showError={showError} + /> + )} + {/* Custom app creation modal */} void onEdit: () => void onUpdate: () => void + onUpdateVersion: () => void } function AppCard({ @@ -618,6 +699,7 @@ function AppCard({ onStats, onEdit, onUpdate, + onUpdateVersion, }: AppCardProps) { const isRunning = service.status === 'running' const isStopped = service.installed && !isRunning @@ -724,6 +806,17 @@ function AppCard({ {uiIsHttps ? '🔒 ' : ''}:{uiPort} )} + {service.available_update_version && !service.is_custom && ( + + )} {/* Action buttons */} @@ -786,6 +879,13 @@ function AppCard({ } label="Logs" onClick={onLogs} /> } label="Stats" onClick={onStats} /> } label="Edit" onClick={onEdit} /> + {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}