fix(supply-depot): reintroduce app update UI
This commit is contained in:
parent
f488f08c96
commit
555ffa8790
|
|
@ -2,6 +2,7 @@ import { Head } from '@inertiajs/react'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
|
IconArrowUp,
|
||||||
IconBook,
|
IconBook,
|
||||||
IconBox,
|
IconBox,
|
||||||
IconBrandDocker,
|
IconBrandDocker,
|
||||||
|
|
@ -27,14 +28,24 @@ import CustomAppModal, { CustomAppInitial } from '~/components/CustomAppModal'
|
||||||
import ServiceLogsModal from '~/components/ServiceLogsModal'
|
import ServiceLogsModal from '~/components/ServiceLogsModal'
|
||||||
import ServiceStatsModal from '~/components/ServiceStatsModal'
|
import ServiceStatsModal from '~/components/ServiceStatsModal'
|
||||||
import StyledSectionHeader from '~/components/StyledSectionHeader'
|
import StyledSectionHeader from '~/components/StyledSectionHeader'
|
||||||
|
import UpdateServiceModal from '~/components/UpdateServiceModal'
|
||||||
import useErrorNotification from '~/hooks/useErrorNotification'
|
import useErrorNotification from '~/hooks/useErrorNotification'
|
||||||
|
import useInternetStatus from '~/hooks/useInternetStatus'
|
||||||
import useServiceInstallationActivity from '~/hooks/useServiceInstallationActivity'
|
import useServiceInstallationActivity from '~/hooks/useServiceInstallationActivity'
|
||||||
|
import { useTransmit } from 'react-adonis-transmit'
|
||||||
|
import { BROADCAST_CHANNELS } from '../../constants/broadcast'
|
||||||
import { ServiceSlim } from '../../types/services'
|
import { ServiceSlim } from '../../types/services'
|
||||||
import { getServiceLink } from '~/lib/navigation'
|
import { getServiceLink } from '~/lib/navigation'
|
||||||
import { getSupplyDepotDocLink } from '../../constants/supply_depot_docs'
|
import { getSupplyDepotDocLink } from '../../constants/supply_depot_docs'
|
||||||
import api from '~/lib/api'
|
import api from '~/lib/api'
|
||||||
import { toTitleCase } from '../../app/utils/misc'
|
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 = [
|
const CATEGORIES = [
|
||||||
{ id: 'all', label: 'All' },
|
{ id: 'all', label: 'All' },
|
||||||
{ id: 'installed', label: 'Installed' },
|
{ id: 'installed', label: 'Installed' },
|
||||||
|
|
@ -68,16 +79,20 @@ type Modal =
|
||||||
| { type: 'delete'; service: ServiceSlim }
|
| { type: 'delete'; service: ServiceSlim }
|
||||||
| { type: 'logs'; service: ServiceSlim }
|
| { type: 'logs'; service: ServiceSlim }
|
||||||
| { type: 'stats'; service: ServiceSlim }
|
| { type: 'stats'; service: ServiceSlim }
|
||||||
|
| { type: 'update'; service: ServiceSlim }
|
||||||
| null
|
| null
|
||||||
|
|
||||||
export default function SupplyDepotPage(props: { system: { services: ServiceSlim[] } }) {
|
export default function SupplyDepotPage(props: { system: { services: ServiceSlim[] } }) {
|
||||||
const { showError } = useErrorNotification()
|
const { showError } = useErrorNotification()
|
||||||
|
const { isOnline } = useInternetStatus()
|
||||||
|
const { subscribe } = useTransmit()
|
||||||
const installActivity = useServiceInstallationActivity()
|
const installActivity = useServiceInstallationActivity()
|
||||||
|
|
||||||
const [activeCategory, setActiveCategory] = useState('all')
|
const [activeCategory, setActiveCategory] = useState('all')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [modal, setModal] = useState<Modal>(null)
|
const [modal, setModal] = useState<Modal>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [checkingUpdates, setCheckingUpdates] = useState(false)
|
||||||
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
|
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
|
||||||
const [customAppOpen, setCustomAppOpen] = useState(false)
|
const [customAppOpen, setCustomAppOpen] = useState(false)
|
||||||
const [editApp, setEditApp] = useState<CustomAppInitial | null>(null)
|
const [editApp, setEditApp] = useState<CustomAppInitial | null>(null)
|
||||||
|
|
@ -101,6 +116,18 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
|
||||||
}
|
}
|
||||||
}, [installActivity])
|
}, [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
|
// Close dropdown on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClick(e: MouseEvent) {
|
function handleClick(e: MouseEvent) {
|
||||||
|
|
@ -200,6 +227,32 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
|
||||||
else setTimeout(() => window.location.reload(), 1500)
|
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() {
|
function handleCustomAppCreated() {
|
||||||
setCustomAppOpen(false)
|
setCustomAppOpen(false)
|
||||||
// Page will reload when installation completes via broadcast
|
// 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"
|
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>
|
</div>
|
||||||
|
<StyledButton
|
||||||
|
icon="IconRefreshAlert"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCheckUpdates}
|
||||||
|
loading={checkingUpdates}
|
||||||
|
disabled={checkingUpdates || !isOnline}
|
||||||
|
>
|
||||||
|
Check for Updates
|
||||||
|
</StyledButton>
|
||||||
<StyledButton
|
<StyledButton
|
||||||
icon="IconBrandDocker"
|
icon="IconBrandDocker"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -347,6 +409,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
|
||||||
onStats={() => setModal({ type: 'stats', service })}
|
onStats={() => setModal({ type: 'stats', service })}
|
||||||
onEdit={() => handleEdit(service)}
|
onEdit={() => handleEdit(service)}
|
||||||
onUpdate={() => handleUpdate(service)}
|
onUpdate={() => handleUpdate(service)}
|
||||||
|
onUpdateVersion={() => setModal({ type: 'update', service })}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -374,6 +437,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
|
||||||
onStats={() => setModal({ type: 'stats', service })}
|
onStats={() => setModal({ type: 'stats', service })}
|
||||||
onEdit={() => handleEdit(service)}
|
onEdit={() => handleEdit(service)}
|
||||||
onUpdate={() => handleUpdate(service)}
|
onUpdate={() => handleUpdate(service)}
|
||||||
|
onUpdateVersion={() => setModal({ type: 'update', service })}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -563,6 +627,22 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 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={() => setModal(null)}
|
||||||
|
onUpdate={(targetVersion) => {
|
||||||
|
const service = modal.service
|
||||||
|
setModal(null)
|
||||||
|
handleUpdateService(service, targetVersion)
|
||||||
|
}}
|
||||||
|
showError={showError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Custom app creation modal */}
|
{/* Custom app creation modal */}
|
||||||
<CustomAppModal
|
<CustomAppModal
|
||||||
open={customAppOpen}
|
open={customAppOpen}
|
||||||
|
|
@ -601,6 +681,7 @@ interface AppCardProps {
|
||||||
onStats: () => void
|
onStats: () => void
|
||||||
onEdit: () => void
|
onEdit: () => void
|
||||||
onUpdate: () => void
|
onUpdate: () => void
|
||||||
|
onUpdateVersion: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppCard({
|
function AppCard({
|
||||||
|
|
@ -618,6 +699,7 @@ function AppCard({
|
||||||
onStats,
|
onStats,
|
||||||
onEdit,
|
onEdit,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
|
onUpdateVersion,
|
||||||
}: AppCardProps) {
|
}: AppCardProps) {
|
||||||
const isRunning = service.status === 'running'
|
const isRunning = service.status === 'running'
|
||||||
const isStopped = service.installed && !isRunning
|
const isStopped = service.installed && !isRunning
|
||||||
|
|
@ -724,6 +806,17 @@ function AppCard({
|
||||||
{uiIsHttps ? '🔒 ' : ''}:{uiPort}
|
{uiIsHttps ? '🔒 ' : ''}:{uiPort}
|
||||||
</span>
|
</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-medium border border-desert-green-light bg-desert-green-lighter text-desert-green-dark cursor-pointer transition-colors hover:bg-desert-green-light"
|
||||||
|
>
|
||||||
|
<IconArrowUp className="h-3 w-3" />
|
||||||
|
Update available
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
|
|
@ -786,6 +879,13 @@ function AppCard({
|
||||||
<DropdownItem icon={<IconFileText className="h-4 w-4" />} label="Logs" onClick={onLogs} />
|
<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={<IconChartBar className="h-4 w-4" />} label="Stats" onClick={onStats} />
|
||||||
<DropdownItem icon={<IconPencil className="h-4 w-4" />} label="Edit" onClick={onEdit} />
|
<DropdownItem icon={<IconPencil className="h-4 w-4" />} label="Edit" onClick={onEdit} />
|
||||||
|
{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 ? (
|
{service.is_custom ? (
|
||||||
<DropdownItem icon={<IconCloudDownload className="h-4 w-4" />} label="Update (pull latest)" onClick={onUpdate} />
|
<DropdownItem icon={<IconCloudDownload className="h-4 w-4" />} label="Update (pull latest)" onClick={onUpdate} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue