diff --git a/backend/src/index.ts b/backend/src/index.ts index 47e07675..44b8a4bd 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -25,7 +25,7 @@ import { ImageUpdateService } from './services/ImageUpdateService'; import { templateService } from './services/TemplateService'; import { ErrorParser } from './utils/ErrorParser'; import { NodeRegistry } from './services/NodeRegistry'; -import { LicenseService, type LicenseTier, type LicenseVariant, isLicenseTier, isLicenseVariant, PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from './services/LicenseService'; +import { LicenseService, type LicenseTier, type LicenseVariant, isLicenseTier, isLicenseVariant, normalizeTier, normalizeVariant, PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from './services/LicenseService'; import { WebhookService } from './services/WebhookService'; import { SSOService } from './services/SSOService'; import { SchedulerService } from './services/SchedulerService'; @@ -310,10 +310,10 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction): const tierHeader = req.headers[PROXY_TIER_HEADER] as string | undefined; const variantHeader = req.headers[PROXY_VARIANT_HEADER] as string | undefined; if (isLicenseTier(tierHeader)) { - req.proxyTier = tierHeader; + req.proxyTier = normalizeTier(tierHeader); } if (isLicenseVariant(variantHeader)) { - req.proxyVariant = variantHeader; + req.proxyVariant = normalizeVariant(variantHeader); } else if (variantHeader === '') { req.proxyVariant = null; } diff --git a/backend/src/services/CapabilityRegistry.ts b/backend/src/services/CapabilityRegistry.ts index a919144e..9705a94e 100644 --- a/backend/src/services/CapabilityRegistry.ts +++ b/backend/src/services/CapabilityRegistry.ts @@ -66,7 +66,8 @@ export async function fetchRemoteMeta(baseUrl: string, apiToken: string): Promis version: res.data.version ?? null, capabilities: Array.isArray(res.data.capabilities) ? res.data.capabilities : [], }; - } catch { + } catch (err) { + console.warn(`[CapabilityRegistry] Failed to fetch meta from ${baseUrl}:`, (err as Error).message); return { version: null, capabilities: [] }; } } diff --git a/backend/src/services/LicenseService.ts b/backend/src/services/LicenseService.ts index fd10b653..e378d46d 100644 --- a/backend/src/services/LicenseService.ts +++ b/backend/src/services/LicenseService.ts @@ -10,12 +10,28 @@ export type LicenseVariant = 'skipper' | 'admiral' | null; const VALID_TIERS: readonly string[] = ['community', 'paid'] satisfies readonly LicenseTier[]; const VALID_VARIANTS: readonly string[] = ['skipper', 'admiral'] satisfies readonly LicenseVariant[]; -export function isLicenseTier(value: unknown): value is LicenseTier { - return typeof value === 'string' && (VALID_TIERS as readonly string[]).includes(value); +// Legacy names from pre-0.38.1 versions. Accepted on input and normalized to current names. +const LEGACY_TIER_MAP: Record = { pro: 'paid' }; +const LEGACY_VARIANT_MAP: Record> = { personal: 'skipper', team: 'admiral' }; + +/** Check if value is a recognized tier (current or legacy name). */ +export function isLicenseTier(value: unknown): value is string { + return typeof value === 'string' && ((VALID_TIERS as readonly string[]).includes(value) || value in LEGACY_TIER_MAP); } -export function isLicenseVariant(value: unknown): value is Exclude { - return typeof value === 'string' && (VALID_VARIANTS as readonly string[]).includes(value); +/** Check if value is a recognized variant (current or legacy name). */ +export function isLicenseVariant(value: unknown): value is string { + return typeof value === 'string' && ((VALID_VARIANTS as readonly string[]).includes(value) || value in LEGACY_VARIANT_MAP); +} + +/** Normalize a tier value, mapping legacy names to current equivalents. Must be called after isLicenseTier validation. */ +export function normalizeTier(value: string): LicenseTier { + return LEGACY_TIER_MAP[value] ?? (value as LicenseTier); +} + +/** Normalize a variant value, mapping legacy names to current equivalents. Must be called after isLicenseVariant validation. */ +export function normalizeVariant(value: string): Exclude { + return LEGACY_VARIANT_MAP[value] ?? (value as Exclude); } /** Header names used for Distributed License Enforcement between nodes. */ @@ -220,26 +236,34 @@ export class LicenseService { /** * Get the license variant (skipper or admiral) from stored metadata. * Trial licenses default to "skipper"; Admiral features require an Admiral license. - * Reads the pre-resolved `license_variant_type` from DB. Falls back to name-based - * resolution for pre-existing installs, then persists the result so the fallback - * only runs once. + * + * Self-healing: on every call, cross-checks the stored variant_type against what + * resolveVariantType() produces from the current product/variant names. If they + * disagree (e.g. stale cache from a previous buggy version), re-resolves and + * persists the corrected value. */ public getVariant(): LicenseVariant { const db = DatabaseService.getInstance(); const status = db.getSystemState('license_status'); if (status === 'trial') return 'skipper'; - // Primary path: read the pre-resolved type stored at activation/validation - const storedType = db.getSystemState('license_variant_type'); - if (isLicenseVariant(storedType)) return storedType; - - // Backward compat: resolve from variant/product name for pre-existing installs const variantName = db.getSystemState('license_variant_name'); - if (!variantName) return null; const productName = db.getSystemState('license_product_name') || undefined; - const resolved = this.resolveVariantType(variantName, productName); - db.setSystemState('license_variant_type', resolved); - return resolved; + const storedType = db.getSystemState('license_variant_type'); + + // Self-heal: if we have source metadata, always cross-check the stored type + if (variantName) { + const resolved = this.resolveVariantType(variantName, productName); + if (resolved !== storedType) { + db.setSystemState('license_variant_type', resolved); + } + return resolved; + } + + // No variant name available; trust stored type if valid + if (isLicenseVariant(storedType)) return normalizeVariant(storedType); + + return null; } /** diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index 0fe64ca2..10921038 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -707,9 +707,10 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { // Paid tier: auto-refresh every 30s useEffect(() => { if (!isPaid) return; - const interval = setInterval(() => fetchOverview(), 30000); - return () => clearInterval(interval); - }, [isPaid, fetchOverview]); + const overviewInterval = setInterval(fetchOverview, 30000); + const updateInterval = setInterval(fetchUpdateStatus, 120000); + return () => { clearInterval(overviewInterval); clearInterval(updateInterval); }; + }, [isPaid, fetchOverview, fetchUpdateStatus]); // Fast poll (5s) when any node is actively updating — uses ref to avoid interval thrashing const hasUpdatingRef = useRef(false); diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index 779d97b0..562efdee 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -387,13 +387,17 @@ export default function ResourcesView() { apiFetch('/system/orphans'), ]); - setUsage(await usageRes.json()); - const resources = await resourcesRes.json(); - setImages(resources.images ?? []); - setVolumes(resources.volumes ?? []); - setNetworks(resources.networks ?? []); - setOrphans(await orphansRes.json()); - setSelectedOrphans([]); + if (usageRes.ok) setUsage(await usageRes.json()); + if (resourcesRes.ok) { + const resources = await resourcesRes.json(); + setImages(resources.images ?? []); + setVolumes(resources.volumes ?? []); + setNetworks(resources.networks ?? []); + } + if (orphansRes.ok) { + setOrphans(await orphansRes.json()); + setSelectedOrphans([]); + } } catch (err) { console.error('Failed to fetch data', err); toast.error('Failed to load resources data');