mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 17:08:10 +00:00
fix(fleet): resolve remote node capability detection failures (#388)
* fix(licensing): backward-compatible tier/variant enforcement and self-healing variant detection
Accept legacy tier ('pro') and variant ('personal', 'team') names from older
remote nodes, normalizing them to current values ('paid', 'skipper', 'admiral')
in authMiddleware. This fixes distributed license enforcement failing between
v0.38.3 and v0.38.0 nodes due to the tier rename in v0.38.1.
Also fixes:
- Self-healing getVariant() that cross-checks stored variant_type against
product/variant name metadata on every call, correcting stale cached values
from previous buggy resolution logic
- Unguarded API responses in ResourcesView causing potential t.map crashes
- Fleet update status now polls on a 120s interval (was only fetched on mount)
- fetchRemoteMeta failures now logged for diagnosability
* fix(fleet): resolve remote node capability detection failures
Exempt /api/meta and /api/health from the global rate limiter so
capability fetches are never blocked by proxied traffic. Add
backend-side caching (3-min TTL) with stale-while-revalidate to
absorb transient failures. Shorten frontend failure cache to 30s
for faster recovery. Evict meta cache on node deletion.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+28
-6
@@ -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<number, { data: RemoteMeta; fetchedAt: number }>();
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -32,7 +32,8 @@ interface NodeContextType {
|
||||
refreshNodeMeta: (nodeId: number) => Promise<void>;
|
||||
}
|
||||
|
||||
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<NodeContextType | undefined>(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 });
|
||||
|
||||
Reference in New Issue
Block a user