mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-04-03 23:36:17 +02:00
fix: cache docker list requests, aiAssistantName fetching, and ensure inertia used properly
This commit is contained in:
parent
877fb1276a
commit
cb4fa003a4
|
|
@ -19,7 +19,10 @@ import { KIWIX_LIBRARY_CMD } from '../../constants/kiwix.js'
|
|||
export class DockerService {
|
||||
public docker: Docker
|
||||
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() {
|
||||
// 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<string, Docker.ContainerInfo>()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<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()
|
||||
.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 []
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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<StyledSidebarProps> = ({ 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 && <item.icon aria-hidden="true" className="size-6 shrink-0" />}
|
||||
{item.name}
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<li key={item.name}>
|
||||
<a
|
||||
href={item.href}
|
||||
target={item.target}
|
||||
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'
|
||||
)}
|
||||
>
|
||||
{item.icon && <item.icon aria-hidden="true" className="size-6 shrink-0" />}
|
||||
{item.name}
|
||||
</a>
|
||||
{item.target === '_blank' ? (
|
||||
<a href={item.href} target="_blank" rel="noopener noreferrer" className={className}>
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
<Link href={item.href} className={className}>
|
||||
{content}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
|
@ -66,13 +74,13 @@ const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
|
|||
<ListItem key={item.name} {...item} current={currentPath === item.href} />
|
||||
))}
|
||||
<li className="ml-2 mt-4">
|
||||
<a
|
||||
<Link
|
||||
href="/home"
|
||||
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" />
|
||||
Back to Home
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import ChatButton from '~/components/chat/ChatButton'
|
|||
import ChatModal from '~/components/chat/ChatModal'
|
||||
import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus'
|
||||
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 classNames from 'classnames'
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
|
|||
)}
|
||||
<div
|
||||
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" />
|
||||
<h1 className="text-5xl font-bold text-desert-green">Command Center</h1>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
IconSettings,
|
||||
IconWifiOff,
|
||||
} from '@tabler/icons-react'
|
||||
import { Head, usePage } from '@inertiajs/react'
|
||||
import { Head, Link, router, usePage } from '@inertiajs/react'
|
||||
import AppLayout from '~/layouts/AppLayout'
|
||||
import { getServiceLink } from '~/lib/navigation'
|
||||
import { ServiceSlim } from '../../types/services'
|
||||
|
|
@ -146,9 +146,7 @@ export default function Home(props: {
|
|||
variant: 'primary',
|
||||
children: 'Go to Settings',
|
||||
icon: 'IconSettings',
|
||||
onClick: () => {
|
||||
window.location.href = '/settings/update'
|
||||
},
|
||||
onClick: () => router.visit('/settings/update'),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -159,26 +157,34 @@ export default function Home(props: {
|
|||
const isEasySetup = item.label === 'Easy Setup'
|
||||
const shouldHighlight = isEasySetup && shouldHighlightEasySetup
|
||||
|
||||
return (
|
||||
<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">
|
||||
{shouldHighlight && (
|
||||
<span className="absolute top-2 right-2 flex items-center justify-center">
|
||||
<span
|
||||
className="animate-ping absolute inline-flex w-16 h-6 rounded-full bg-desert-orange-light opacity-75"
|
||||
style={{ animationDuration: '1.5s' }}
|
||||
></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">
|
||||
Start here!
|
||||
</span>
|
||||
const tileContent = (
|
||||
<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 && (
|
||||
<span className="absolute top-2 right-2 flex items-center justify-center">
|
||||
<span
|
||||
className="animate-ping absolute inline-flex w-16 h-6 rounded-full bg-desert-orange-light opacity-75"
|
||||
style={{ animationDuration: '1.5s' }}
|
||||
></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">
|
||||
Start here!
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center justify-center mb-2">{item.icon}</div>
|
||||
<h3 className="font-bold text-2xl">{item.label}</h3>
|
||||
{item.poweredBy && <p className="text-sm opacity-80">Powered by {item.poweredBy}</p>}
|
||||
<p className="xl:text-lg mt-2">{item.description}</p>
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center justify-center mb-2">{item.icon}</div>
|
||||
<h3 className="font-bold text-2xl">{item.label}</h3>
|
||||
{item.poweredBy && <p className="text-sm opacity-80">Powered by {item.poweredBy}</p>}
|
||||
<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>
|
||||
) : (
|
||||
<Link key={item.label} href={item.to}>
|
||||
{tileContent}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 StyledButton from '~/components/StyledButton'
|
||||
import { IconArrowLeft } from '@tabler/icons-react'
|
||||
|
|
@ -42,9 +42,7 @@ export default function Maps(props: {
|
|||
variant: 'secondary',
|
||||
children: 'Go to Map Settings',
|
||||
icon: 'IconSettings',
|
||||
onClick: () => {
|
||||
window.location.href = '/settings/maps'
|
||||
},
|
||||
onClick: () => router.visit('/settings/maps'),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user