fix: cache docker list requests, aiAssistantName fetching, and ensure inertia used properly

This commit is contained in:
Jake Turner 2026-04-02 22:45:00 +00:00 committed by Jake Turner
parent 877fb1276a
commit cb4fa003a4
8 changed files with 125 additions and 57 deletions

View File

@ -21,6 +21,9 @@ export class DockerService {
private activeInstallations: Set<string> = new Set() private activeInstallations: Set<string> = new Set()
public static NOMAD_NETWORK = 'project-nomad_default' public static NOMAD_NETWORK = 'project-nomad_default'
private _servicesStatusCache: { data: { service_name: string; status: string }[]; expiresAt: number } | null = null
private _servicesStatusInflight: Promise<{ service_name: string; status: string }[]> | null = null
constructor() { constructor() {
// Support both Linux (production) and Windows (development with Docker Desktop) // Support both Linux (production) and Windows (development with Docker Desktop)
const isWindows = process.platform === 'win32' const isWindows = process.platform === 'win32'
@ -58,6 +61,7 @@ export class DockerService {
const dockerContainer = this.docker.getContainer(container.Id) const dockerContainer = this.docker.getContainer(container.Id)
if (action === 'stop') { if (action === 'stop') {
await dockerContainer.stop() await dockerContainer.stop()
this.invalidateServicesStatusCache()
return { return {
success: true, success: true,
message: `Service ${serviceName} stopped successfully`, message: `Service ${serviceName} stopped successfully`,
@ -70,11 +74,13 @@ export class DockerService {
if (isLegacy) { if (isLegacy) {
logger.info('[DockerService] Kiwix on legacy glob config — running migration instead of restart.') logger.info('[DockerService] Kiwix on legacy glob config — running migration instead of restart.')
await this.migrateKiwixToLibraryMode() await this.migrateKiwixToLibraryMode()
this.invalidateServicesStatusCache()
return { success: true, message: 'Kiwix migrated to library mode successfully.' } return { success: true, message: 'Kiwix migrated to library mode successfully.' }
} }
} }
await dockerContainer.restart() await dockerContainer.restart()
this.invalidateServicesStatusCache()
return { return {
success: true, success: true,
@ -91,6 +97,7 @@ export class DockerService {
} }
await dockerContainer.start() await dockerContainer.start()
this.invalidateServicesStatusCache()
return { return {
success: true, success: true,
@ -113,13 +120,37 @@ export class DockerService {
/** /**
* Fetches the status of all Docker containers related to Nomad services. (those prefixed with 'nomad_') * Fetches the status of all Docker containers related to Nomad services. (those prefixed with 'nomad_')
* Results are cached for 5 seconds and concurrent callers share a single in-flight request,
* preventing Docker socket congestion during rapid page navigation.
*/ */
async getServicesStatus(): Promise< async getServicesStatus(): Promise<{ service_name: string; status: string }[]> {
{ const now = Date.now()
service_name: string if (this._servicesStatusCache && now < this._servicesStatusCache.expiresAt) {
status: string return this._servicesStatusCache.data
}[] }
> { if (this._servicesStatusInflight) return this._servicesStatusInflight
this._servicesStatusInflight = this._fetchServicesStatus().then((data) => {
this._servicesStatusCache = { data, expiresAt: Date.now() + 5000 }
this._servicesStatusInflight = null
return data
}).catch((err) => {
this._servicesStatusInflight = null
throw err
})
return this._servicesStatusInflight
}
/**
* Invalidates the services status cache. Call this after any container state change
* (start, stop, restart, install, uninstall) so the next read reflects reality.
*/
invalidateServicesStatusCache() {
this._servicesStatusCache = null
this._servicesStatusInflight = null
}
private async _fetchServicesStatus(): Promise<{ service_name: string; status: string }[]> {
try { try {
const containers = await this.docker.listContainers({ all: true }) const containers = await this.docker.listContainers({ all: true })
const containerMap = new Map<string, Docker.ContainerInfo>() const containerMap = new Map<string, Docker.ContainerInfo>()
@ -363,6 +394,7 @@ export class DockerService {
service.installed = false service.installed = false
service.installation_status = 'installing' service.installation_status = 'installing'
await service.save() await service.save()
this.invalidateServicesStatusCache()
// Step 5: Recreate the container // Step 5: Recreate the container
this._broadcast(serviceName, 'recreating', `Recreating container...`) this._broadcast(serviceName, 'recreating', `Recreating container...`)
@ -569,6 +601,7 @@ export class DockerService {
service.installed = true service.installed = true
service.installation_status = 'idle' service.installation_status = 'idle'
await service.save() await service.save()
this.invalidateServicesStatusCache()
// Remove from active installs tracking // Remove from active installs tracking
this.activeInstallations.delete(service.service_name) this.activeInstallations.delete(service.service_name)

View File

@ -19,6 +19,7 @@ import env from '#start/env'
import KVStore from '#models/kv_store' import KVStore from '#models/kv_store'
import { KV_STORE_SCHEMA, KVStoreKey } from '../../types/kv_store.js' import { KV_STORE_SCHEMA, KVStoreKey } from '../../types/kv_store.js'
import { isNewerVersion } from '../utils/version.js' import { isNewerVersion } from '../utils/version.js'
import { invalidateAssistantNameCache } from '../../config/inertia.js'
@inject() @inject()
export class SystemService { export class SystemService {
@ -206,7 +207,7 @@ export class SystemService {
} }
async getServices({ installedOnly = true }: { installedOnly?: boolean }): Promise<ServiceSlim[]> { async getServices({ installedOnly = true }: { installedOnly?: boolean }): Promise<ServiceSlim[]> {
await this._syncContainersWithDatabase() // Sync up before fetching to ensure we have the latest status const statuses = await this._syncContainersWithDatabase() // Sync and reuse the fetched status list
const query = Service.query() const query = Service.query()
.orderBy('display_order', 'asc') .orderBy('display_order', 'asc')
@ -235,8 +236,6 @@ export class SystemService {
return [] return []
} }
const statuses = await this.dockerService.getServicesStatus()
const toReturn: ServiceSlim[] = [] const toReturn: ServiceSlim[] = []
for (const service of services) { for (const service of services) {
@ -638,6 +637,9 @@ export class SystemService {
} else { } else {
await KVStore.setValue(key, value) await KVStore.setValue(key, value)
} }
if (key === 'ai.assistantCustomName') {
invalidateAssistantNameCache()
}
} }
/** /**
@ -645,8 +647,9 @@ export class SystemService {
* It will mark services as not installed if their corresponding containers do not exist, regardless of their running state. * It will mark services as not installed if their corresponding containers do not exist, regardless of their running state.
* Handles cases where a container might have been manually removed, ensuring the database reflects the actual existence of containers. * Handles cases where a container might have been manually removed, ensuring the database reflects the actual existence of containers.
* Containers that exist but are stopped, paused, or restarting will still be considered installed. * Containers that exist but are stopped, paused, or restarting will still be considered installed.
* Returns the fetched service status list so callers can reuse it without a second Docker API call.
*/ */
private async _syncContainersWithDatabase() { private async _syncContainersWithDatabase(): Promise<{ service_name: string; status: string }[]> {
try { try {
const allServices = await Service.all() const allServices = await Service.all()
const serviceStatusList = await this.dockerService.getServicesStatus() const serviceStatusList = await this.dockerService.getServicesStatus()
@ -683,8 +686,11 @@ export class SystemService {
} }
} }
} }
return serviceStatusList
} catch (error) { } catch (error) {
logger.error('Error syncing containers with database:', error) logger.error('Error syncing containers with database:', error)
return []
} }
} }

View File

@ -13,7 +13,12 @@ const dbConfig = defineConfig({
user: env.get('DB_USER'), user: env.get('DB_USER'),
password: env.get('DB_PASSWORD'), password: env.get('DB_PASSWORD'),
database: env.get('DB_DATABASE'), database: env.get('DB_DATABASE'),
ssl: env.get('DB_SSL') ?? true, // Default to true ssl: env.get('DB_SSL') ? {} : false,
},
pool: {
min: 2,
max: 15,
acquireTimeoutMillis: 10000, // Fail fast (10s) instead of silently hanging for ~60s
}, },
migrations: { migrations: {
naturalSort: true, naturalSort: true,

View File

@ -3,6 +3,12 @@ import { SystemService } from '#services/system_service'
import { defineConfig } from '@adonisjs/inertia' import { defineConfig } from '@adonisjs/inertia'
import type { InferSharedProps } from '@adonisjs/inertia/types' import type { InferSharedProps } from '@adonisjs/inertia/types'
let _assistantNameCache: { value: string; expiresAt: number } | null = null
export function invalidateAssistantNameCache() {
_assistantNameCache = null
}
const inertiaConfig = defineConfig({ const inertiaConfig = defineConfig({
/** /**
* Path to the Edge view that will be used as the root view for Inertia responses * Path to the Edge view that will be used as the root view for Inertia responses
@ -16,8 +22,14 @@ const inertiaConfig = defineConfig({
appVersion: () => SystemService.getAppVersion(), appVersion: () => SystemService.getAppVersion(),
environment: process.env.NODE_ENV || 'production', environment: process.env.NODE_ENV || 'production',
aiAssistantName: async () => { aiAssistantName: async () => {
const now = Date.now()
if (_assistantNameCache && now < _assistantNameCache.expiresAt) {
return _assistantNameCache.value
}
const customName = await KVStore.getValue('ai.assistantCustomName') const customName = await KVStore.getValue('ai.assistantCustomName')
return (customName && customName.trim()) ? customName : 'AI Assistant' const value = (customName && customName.trim()) ? customName : 'AI Assistant'
_assistantNameCache = { value, expiresAt: now + 60_000 }
return value
}, },
}, },

View File

@ -2,7 +2,7 @@ import { useMemo, useState } from 'react'
import { Dialog, DialogBackdrop, DialogPanel, TransitionChild } from '@headlessui/react' import { Dialog, DialogBackdrop, DialogPanel, TransitionChild } from '@headlessui/react'
import classNames from '~/lib/classNames' import classNames from '~/lib/classNames'
import { IconArrowLeft, IconBug } from '@tabler/icons-react' import { IconArrowLeft, IconBug } from '@tabler/icons-react'
import { usePage } from '@inertiajs/react' import { Link, usePage } from '@inertiajs/react'
import { UsePageProps } from '../../types/system' import { UsePageProps } from '../../types/system'
import { IconMenu2, IconX } from '@tabler/icons-react' import { IconMenu2, IconX } from '@tabler/icons-react'
import ThemeToggle from '~/components/ThemeToggle' import ThemeToggle from '~/components/ThemeToggle'
@ -32,21 +32,29 @@ const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
}, []) }, [])
const ListItem = (item: SidebarItem) => { const ListItem = (item: SidebarItem) => {
const className = classNames(
item.current
? 'bg-desert-green text-white'
: 'text-text-primary hover:bg-desert-green-light hover:text-white',
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold'
)
const content = (
<>
{item.icon && <item.icon aria-hidden="true" className="size-6 shrink-0" />}
{item.name}
</>
)
return ( return (
<li key={item.name}> <li key={item.name}>
<a {item.target === '_blank' ? (
href={item.href} <a href={item.href} target="_blank" rel="noopener noreferrer" className={className}>
target={item.target} {content}
className={classNames( </a>
item.current ) : (
? 'bg-desert-green text-white' <Link href={item.href} className={className}>
: 'text-text-primary hover:bg-desert-green-light hover:text-white', {content}
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold' </Link>
)} )}
>
{item.icon && <item.icon aria-hidden="true" className="size-6 shrink-0" />}
{item.name}
</a>
</li> </li>
) )
} }
@ -66,13 +74,13 @@ const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
<ListItem key={item.name} {...item} current={currentPath === item.href} /> <ListItem key={item.name} {...item} current={currentPath === item.href} />
))} ))}
<li className="ml-2 mt-4"> <li className="ml-2 mt-4">
<a <Link
href="/home" href="/home"
className="flex flex-row items-center gap-x-3 text-desert-green text-sm font-semibold" className="flex flex-row items-center gap-x-3 text-desert-green text-sm font-semibold"
> >
<IconArrowLeft aria-hidden="true" className="size-6 shrink-0" /> <IconArrowLeft aria-hidden="true" className="size-6 shrink-0" />
Back to Home Back to Home
</a> </Link>
</li> </li>
</ul> </ul>
</li> </li>

View File

@ -4,7 +4,7 @@ import ChatButton from '~/components/chat/ChatButton'
import ChatModal from '~/components/chat/ChatModal' import ChatModal from '~/components/chat/ChatModal'
import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus' import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus'
import { SERVICE_NAMES } from '../../constants/service_names' import { SERVICE_NAMES } from '../../constants/service_names'
import { Link } from '@inertiajs/react' import { Link, router } from '@inertiajs/react'
import { IconArrowLeft } from '@tabler/icons-react' import { IconArrowLeft } from '@tabler/icons-react'
import classNames from 'classnames' import classNames from 'classnames'
@ -23,7 +23,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
)} )}
<div <div
className="p-2 flex gap-2 flex-col items-center justify-center cursor-pointer" className="p-2 flex gap-2 flex-col items-center justify-center cursor-pointer"
onClick={() => (window.location.href = '/home')} onClick={() => router.visit('/home')}
> >
<img src="/project_nomad_logo.webp" alt="Project Nomad Logo" className="h-40 w-40" /> <img src="/project_nomad_logo.webp" alt="Project Nomad Logo" className="h-40 w-40" />
<h1 className="text-5xl font-bold text-desert-green">Command Center</h1> <h1 className="text-5xl font-bold text-desert-green">Command Center</h1>

View File

@ -6,7 +6,7 @@ import {
IconSettings, IconSettings,
IconWifiOff, IconWifiOff,
} from '@tabler/icons-react' } from '@tabler/icons-react'
import { Head, usePage } from '@inertiajs/react' import { Head, Link, router, usePage } from '@inertiajs/react'
import AppLayout from '~/layouts/AppLayout' import AppLayout from '~/layouts/AppLayout'
import { getServiceLink } from '~/lib/navigation' import { getServiceLink } from '~/lib/navigation'
import { ServiceSlim } from '../../types/services' import { ServiceSlim } from '../../types/services'
@ -146,9 +146,7 @@ export default function Home(props: {
variant: 'primary', variant: 'primary',
children: 'Go to Settings', children: 'Go to Settings',
icon: 'IconSettings', icon: 'IconSettings',
onClick: () => { onClick: () => router.visit('/settings/update'),
window.location.href = '/settings/update'
},
}} }}
/> />
</div> </div>
@ -159,26 +157,34 @@ export default function Home(props: {
const isEasySetup = item.label === 'Easy Setup' const isEasySetup = item.label === 'Easy Setup'
const shouldHighlight = isEasySetup && shouldHighlightEasySetup const shouldHighlight = isEasySetup && shouldHighlightEasySetup
return ( const tileContent = (
<a key={item.label} href={item.to} target={item.target}> <div className="relative rounded border-desert-green border-2 bg-desert-green hover:bg-transparent hover:text-text-primary text-white transition-colors shadow-sm h-48 flex flex-col items-center justify-center cursor-pointer text-center px-4">
<div className="relative rounded border-desert-green border-2 bg-desert-green hover:bg-transparent hover:text-text-primary text-white transition-colors shadow-sm h-48 flex flex-col items-center justify-center cursor-pointer text-center px-4"> {shouldHighlight && (
{shouldHighlight && ( <span className="absolute top-2 right-2 flex items-center justify-center">
<span className="absolute top-2 right-2 flex items-center justify-center"> <span
<span className="animate-ping absolute inline-flex w-16 h-6 rounded-full bg-desert-orange-light opacity-75"
className="animate-ping absolute inline-flex w-16 h-6 rounded-full bg-desert-orange-light opacity-75" style={{ animationDuration: '1.5s' }}
style={{ animationDuration: '1.5s' }} ></span>
></span> <span className="relative inline-flex items-center rounded-full px-2.5 py-1 bg-desert-orange-light text-xs font-semibold text-white shadow-sm">
<span className="relative inline-flex items-center rounded-full px-2.5 py-1 bg-desert-orange-light text-xs font-semibold text-white shadow-sm"> Start here!
Start here!
</span>
</span> </span>
)} </span>
<div className="flex items-center justify-center mb-2">{item.icon}</div> )}
<h3 className="font-bold text-2xl">{item.label}</h3> <div className="flex items-center justify-center mb-2">{item.icon}</div>
{item.poweredBy && <p className="text-sm opacity-80">Powered by {item.poweredBy}</p>} <h3 className="font-bold text-2xl">{item.label}</h3>
<p className="xl:text-lg mt-2">{item.description}</p> {item.poweredBy && <p className="text-sm opacity-80">Powered by {item.poweredBy}</p>}
</div> <p className="xl:text-lg mt-2">{item.description}</p>
</div>
)
return item.target === '_blank' ? (
<a key={item.label} href={item.to} target="_blank" rel="noopener noreferrer">
{tileContent}
</a> </a>
) : (
<Link key={item.label} href={item.to}>
{tileContent}
</Link>
) )
})} })}
</div> </div>

View File

@ -1,5 +1,5 @@
import MapsLayout from '~/layouts/MapsLayout' import MapsLayout from '~/layouts/MapsLayout'
import { Head, Link } from '@inertiajs/react' import { Head, Link, router } from '@inertiajs/react'
import MapComponent from '~/components/maps/MapComponent' import MapComponent from '~/components/maps/MapComponent'
import StyledButton from '~/components/StyledButton' import StyledButton from '~/components/StyledButton'
import { IconArrowLeft } from '@tabler/icons-react' import { IconArrowLeft } from '@tabler/icons-react'
@ -42,9 +42,7 @@ export default function Maps(props: {
variant: 'secondary', variant: 'secondary',
children: 'Go to Map Settings', children: 'Go to Map Settings',
icon: 'IconSettings', icon: 'IconSettings',
onClick: () => { onClick: () => router.visit('/settings/maps'),
window.location.href = '/settings/maps'
},
}} }}
/> />
</div> </div>