From cb4fa003a4e2a524b7f3d7e968c972dd52d256f1 Mon Sep 17 00:00:00 2001 From: Jake Turner Date: Thu, 2 Apr 2026 22:45:00 +0000 Subject: [PATCH] fix: cache docker list requests, aiAssistantName fetching, and ensure inertia used properly --- admin/app/services/docker_service.ts | 47 +++++++++++++++++--- admin/app/services/system_service.ts | 14 ++++-- admin/config/database.ts | 7 ++- admin/config/inertia.ts | 14 +++++- admin/inertia/components/StyledSidebar.tsx | 40 ++++++++++------- admin/inertia/layouts/AppLayout.tsx | 4 +- admin/inertia/pages/home.tsx | 50 ++++++++++++---------- admin/inertia/pages/maps.tsx | 6 +-- 8 files changed, 125 insertions(+), 57 deletions(-) diff --git a/admin/app/services/docker_service.ts b/admin/app/services/docker_service.ts index ef7f3d1..2f0b6e8 100644 --- a/admin/app/services/docker_service.ts +++ b/admin/app/services/docker_service.ts @@ -19,7 +19,10 @@ import { KIWIX_LIBRARY_CMD } from '../../constants/kiwix.js' export class DockerService { public docker: Docker private activeInstallations: Set = 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() { // Support both Linux (production) and Windows (development with Docker Desktop) @@ -58,6 +61,7 @@ export class DockerService { const dockerContainer = this.docker.getContainer(container.Id) if (action === 'stop') { await dockerContainer.stop() + this.invalidateServicesStatusCache() return { success: true, message: `Service ${serviceName} stopped successfully`, @@ -70,11 +74,13 @@ export class DockerService { if (isLegacy) { logger.info('[DockerService] Kiwix on legacy glob config — running migration instead of restart.') await this.migrateKiwixToLibraryMode() + this.invalidateServicesStatusCache() return { success: true, message: 'Kiwix migrated to library mode successfully.' } } } await dockerContainer.restart() + this.invalidateServicesStatusCache() return { success: true, @@ -91,6 +97,7 @@ export class DockerService { } await dockerContainer.start() + this.invalidateServicesStatusCache() return { success: true, @@ -113,13 +120,37 @@ export class DockerService { /** * 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< - { - service_name: string - status: string - }[] - > { + async getServicesStatus(): Promise<{ service_name: string; status: string }[]> { + const now = Date.now() + if (this._servicesStatusCache && now < this._servicesStatusCache.expiresAt) { + 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 { const containers = await this.docker.listContainers({ all: true }) const containerMap = new Map() @@ -363,6 +394,7 @@ export class DockerService { service.installed = false service.installation_status = 'installing' await service.save() + this.invalidateServicesStatusCache() // Step 5: Recreate the container this._broadcast(serviceName, 'recreating', `Recreating container...`) @@ -569,6 +601,7 @@ export class DockerService { service.installed = true service.installation_status = 'idle' await service.save() + this.invalidateServicesStatusCache() // Remove from active installs tracking this.activeInstallations.delete(service.service_name) diff --git a/admin/app/services/system_service.ts b/admin/app/services/system_service.ts index 9f3bfdf..5701de3 100644 --- a/admin/app/services/system_service.ts +++ b/admin/app/services/system_service.ts @@ -19,6 +19,7 @@ import env from '#start/env' import KVStore from '#models/kv_store' import { KV_STORE_SCHEMA, KVStoreKey } from '../../types/kv_store.js' import { isNewerVersion } from '../utils/version.js' +import { invalidateAssistantNameCache } from '../../config/inertia.js' @inject() export class SystemService { @@ -206,7 +207,7 @@ export class SystemService { } async getServices({ installedOnly = true }: { installedOnly?: boolean }): Promise { - 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() .orderBy('display_order', 'asc') @@ -235,8 +236,6 @@ export class SystemService { return [] } - const statuses = await this.dockerService.getServicesStatus() - const toReturn: ServiceSlim[] = [] for (const service of services) { @@ -638,6 +637,9 @@ export class SystemService { } else { 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. * 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. + * 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 { const allServices = await Service.all() const serviceStatusList = await this.dockerService.getServicesStatus() @@ -683,8 +686,11 @@ export class SystemService { } } } + + return serviceStatusList } catch (error) { logger.error('Error syncing containers with database:', error) + return [] } } diff --git a/admin/config/database.ts b/admin/config/database.ts index 00ac054..9db7800 100644 --- a/admin/config/database.ts +++ b/admin/config/database.ts @@ -13,7 +13,12 @@ const dbConfig = defineConfig({ user: env.get('DB_USER'), password: env.get('DB_PASSWORD'), 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: { naturalSort: true, diff --git a/admin/config/inertia.ts b/admin/config/inertia.ts index 9b1b9b3..11ad747 100644 --- a/admin/config/inertia.ts +++ b/admin/config/inertia.ts @@ -3,6 +3,12 @@ import { SystemService } from '#services/system_service' import { defineConfig } from '@adonisjs/inertia' import type { InferSharedProps } from '@adonisjs/inertia/types' +let _assistantNameCache: { value: string; expiresAt: number } | null = null + +export function invalidateAssistantNameCache() { + _assistantNameCache = null +} + const inertiaConfig = defineConfig({ /** * 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(), environment: process.env.NODE_ENV || 'production', aiAssistantName: async () => { + const now = Date.now() + if (_assistantNameCache && now < _assistantNameCache.expiresAt) { + return _assistantNameCache.value + } 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 }, }, diff --git a/admin/inertia/components/StyledSidebar.tsx b/admin/inertia/components/StyledSidebar.tsx index ccf97db..ae7ca6f 100644 --- a/admin/inertia/components/StyledSidebar.tsx +++ b/admin/inertia/components/StyledSidebar.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from 'react' import { Dialog, DialogBackdrop, DialogPanel, TransitionChild } from '@headlessui/react' import classNames from '~/lib/classNames' import { IconArrowLeft, IconBug } from '@tabler/icons-react' -import { usePage } from '@inertiajs/react' +import { Link, usePage } from '@inertiajs/react' import { UsePageProps } from '../../types/system' import { IconMenu2, IconX } from '@tabler/icons-react' import ThemeToggle from '~/components/ThemeToggle' @@ -32,21 +32,29 @@ const StyledSidebar: React.FC = ({ title, items }) => { }, []) 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 &&