diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cd980fe..bc03cb7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **licensing:** Admiral licenses (including lifetime) are now correctly identified; previously, Lemon Squeezy variant names containing "Admiral" were not matched, causing the tier to display as "Skipper" and Admiral features to remain locked * **licensing:** "Manage Subscription" button is now hidden for lifetime licenses, which have no billing portal by design * **licensing:** license card now shows "Duration: Lifetime" for lifetime licenses instead of an empty renewal date +* **fleet:** remote node capability detection now works reliably; `/api/meta` and `/api/health` are exempt from the global rate limiter so they are never blocked by proxied traffic, and a backend-side cache with stale-while-revalidate prevents transient failures from disabling capability-gated features (Auto-Update, Schedules, Audit, Console) on remote nodes +* **fleet:** failed capability fetches now retry after 30 seconds instead of being cached for the full 5-minute TTL ### Changed diff --git a/backend/src/index.ts b/backend/src/index.ts index 44b8a4bd..25535392 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -30,7 +30,7 @@ import { WebhookService } from './services/WebhookService'; import { SSOService } from './services/SSOService'; import { SchedulerService } from './services/SchedulerService'; import { RegistryService } from './services/RegistryService'; -import { CAPABILITIES, getSenchoVersion, fetchRemoteMeta, getActiveCapabilities } from './services/CapabilityRegistry'; +import { CAPABILITIES, getSenchoVersion, fetchRemoteMeta, getActiveCapabilities, type RemoteMeta } from './services/CapabilityRegistry'; import SelfUpdateService from './services/SelfUpdateService'; import semver from 'semver'; import { CronExpressionParser } from 'cron-parser'; @@ -149,6 +149,7 @@ const globalApiLimiter = rateLimit({ standardHeaders: true, legacyHeaders: false, message: { error: 'Too many requests. Please try again shortly.' }, + skip: (req: Request) => req.path === '/meta' || req.path === '/health', }); app.use('/api/', globalApiLimiter); @@ -5451,6 +5452,7 @@ app.delete('/api/nodes/:id', async (req: Request, res: Response) => { const id = parseInt(nodeIdParam); DatabaseService.getInstance().deleteNode(id); NodeRegistry.getInstance().evictConnection(id); + remoteMetaCache.delete(id); res.json({ success: true }); } catch (error: any) { console.error('Failed to delete node:', error); @@ -5471,8 +5473,11 @@ app.post('/api/nodes/:id/test', async (req: Request, res: Response) => { // Fetch capability metadata for a specific node. For local nodes, returns this // instance's capabilities directly. For remote nodes, relays GET /api/meta from -// the remote Sencho instance. Returns { version: null, capabilities: [] } on -// failure (old node without /api/meta, or node offline) — never an error status. +// the remote Sencho instance. Backend-side cache shields against rate limit +// contention on the remote; stale data is served on transient failures. +const remoteMetaCache = new Map(); +const REMOTE_META_CACHE_TTL = 3 * 60 * 1000; + app.get('/api/nodes/:id/meta', authMiddleware, async (req: Request, res: Response) => { try { const id = parseInt(req.params.id as string); @@ -5487,7 +5492,12 @@ app.get('/api/nodes/:id/meta', authMiddleware, async (req: Request, res: Respons return; } - // Remote node — relay GET /api/meta + const cached = remoteMetaCache.get(id); + if (cached && Date.now() - cached.fetchedAt < REMOTE_META_CACHE_TTL) { + res.json(cached.data); + return; + } + const baseUrl = node.api_url?.replace(/\/$/, ''); if (!baseUrl || !node.api_token) { res.json({ version: null, capabilities: [] }); @@ -5495,10 +5505,22 @@ app.get('/api/nodes/:id/meta', authMiddleware, async (req: Request, res: Respons } const meta = await fetchRemoteMeta(baseUrl, node.api_token); + + // A successful fetch always includes a version; null version means the remote + // was unreachable. Only cache successful responses so transient failures retry. + if (meta.version !== null) { + remoteMetaCache.set(id, { data: meta, fetchedAt: Date.now() }); + } else if (cached) { + cached.fetchedAt = Date.now(); + res.json(cached.data); + return; + } + res.json(meta); - } catch (error: any) { + } catch (error: unknown) { console.error('Failed to fetch node meta:', error); - res.status(500).json({ error: error.message || 'Failed to fetch node metadata' }); + const message = error instanceof Error ? error.message : 'Failed to fetch node metadata'; + res.status(500).json({ error: message }); } }); diff --git a/frontend/src/context/NodeContext.tsx b/frontend/src/context/NodeContext.tsx index 07634227..6ef75f2f 100644 --- a/frontend/src/context/NodeContext.tsx +++ b/frontend/src/context/NodeContext.tsx @@ -32,7 +32,8 @@ interface NodeContextType { refreshNodeMeta: (nodeId: number) => Promise; } -const META_CACHE_TTL = 5 * 60 * 1000; // 5 minutes +const META_CACHE_TTL = 5 * 60 * 1000; +const META_FAILURE_TTL = 30 * 1000; const NodeContext = createContext(undefined); @@ -50,7 +51,11 @@ export function NodeProvider({ children }: { children: React.ReactNode }) { const fetchNodeMeta = useCallback(async (nodeId: number) => { const cached = nodeMetaRef.current.get(nodeId); - if (cached && Date.now() - cached.fetchedAt < META_CACHE_TTL) return; + if (cached) { + // Use shorter TTL for failed fetches so we retry quickly after transient errors + const ttl = cached.capabilities.length > 0 ? META_CACHE_TTL : META_FAILURE_TTL; + if (Date.now() - cached.fetchedAt < ttl) return; + } try { const res = await apiFetch(`/nodes/${nodeId}/meta`, { localOnly: true });