diff --git a/CHANGELOG.md b/CHANGELOG.md index ef91ec57..c5c700c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -* **fleet:** node switcher and fleet overview no longer display "vunknown" for remote nodes running older images; invalid version strings are filtered from the UI +* **fleet:** resolve root cause of "vunknown" version display across all UI surfaces. The Dockerfile was missing a `COPY package.json` in the backend build stage, causing version resolution to fail at runtime. Backend now returns `null` instead of the string `"unknown"` for unresolvable versions. Frontend guards all version display points (Fleet Overview cards, update buttons, Check for Updates modal). Fleet update logic correctly flags remote nodes with unresolvable versions as potentially outdated instead of silently marking them up to date. Consolidated version validation into shared utilities (`isValidVersion`, `formatVersion`) across both frontend and backend. ## [0.39.1](https://github.com/AnsoCode/Sencho/compare/v0.39.0...v0.39.1) (2026-04-06) diff --git a/Dockerfile b/Dockerfile index 789fc85b..394728ee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,6 +33,8 @@ RUN npm config set fetch-retry-maxtimeout 120000 && \ npm install COPY backend/ ./ +# prebuild hook (generate-version.js) reads the root package.json for the app version +COPY package.json /app/package.json RUN npm run build # Stage 3: Production dependencies (cross-compiled - NO QEMU execution) diff --git a/backend/src/index.ts b/backend/src/index.ts index 25535392..c78e1e9a 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, type RemoteMeta } from './services/CapabilityRegistry'; +import { CAPABILITIES, getSenchoVersion, isValidVersion, fetchRemoteMeta, getActiveCapabilities, type RemoteMeta } from './services/CapabilityRegistry'; import SelfUpdateService from './services/SelfUpdateService'; import semver from 'semver'; import { CronExpressionParser } from 'cron-parser'; @@ -1251,6 +1251,7 @@ app.get('/api/fleet/update-status', async (_req: Request, res: Response): Promis const db = DatabaseService.getInstance(); const nodes = db.getNodes(); const gatewayVersion = getSenchoVersion(); + const gatewayValid = isValidVersion(gatewayVersion); const results = await Promise.allSettled( nodes.map(async (node) => { @@ -1273,6 +1274,14 @@ app.get('/api/fleet/update-status', async (_req: Request, res: Response): Promis } } + // Assume remote nodes are outdated when their version is unresolvable + let updateAvailable = false; + if (!isValidVersion(version)) { + updateAvailable = node.type === 'remote'; + } else if (gatewayValid) { + updateAvailable = semver.lt(version, gatewayVersion!); + } + const currentTracker = updateTracker.get(node.id); return { nodeId: node.id, @@ -1280,9 +1289,7 @@ app.get('/api/fleet/update-status', async (_req: Request, res: Response): Promis type: node.type, version, latestVersion: gatewayVersion, - updateAvailable: version === null - ? (node.type === 'remote') // Remote node without /api/meta is pre-capability-negotiation — definitely outdated - : (version !== gatewayVersion && !!semver.valid(version) && semver.lt(version, gatewayVersion)), + updateAvailable, updateStatus: currentTracker?.status ?? null, }; }) @@ -1391,7 +1398,10 @@ app.post('/api/fleet/update-all', async (req: Request, res: Response): Promise { const meta = await fetchRemoteMeta(node.api_url!, node.api_token!); - if (!meta.version || !semver.valid(meta.version) || !semver.lt(meta.version, gatewayVersion) || !meta.capabilities.includes('self-update')) { + if (!meta.capabilities.includes('self-update')) { + return { name: node.name, triggered: false }; + } + if (isValidVersion(meta.version) && isValidVersion(gatewayVersion) && !semver.lt(meta.version, gatewayVersion)) { return { name: node.name, triggered: false }; } const response = await fetch(`${node.api_url!.replace(/\/$/, '')}/api/system/update`, { diff --git a/backend/src/services/CapabilityRegistry.ts b/backend/src/services/CapabilityRegistry.ts index 240c24eb..b50b83e6 100644 --- a/backend/src/services/CapabilityRegistry.ts +++ b/backend/src/services/CapabilityRegistry.ts @@ -1,6 +1,7 @@ import axios from 'axios'; import path from 'path'; import fs from 'fs'; +import semver from 'semver'; import { SENCHO_VERSION } from '../generated/version'; /** @@ -35,8 +36,13 @@ export const CAPABILITIES = [ export type Capability = (typeof CAPABILITIES)[number]; +/** Returns true when the string is a usable semver version. */ +export function isValidVersion(v: string | null | undefined): v is string { + return !!v && v !== 'unknown' && v !== '0.0.0-dev' && !!semver.valid(v); +} + // Resolved once per process at import time, then cached. -function resolveVersion(): string { +function resolveVersion(): string | null { if (SENCHO_VERSION !== '0.0.0-dev') return SENCHO_VERSION; // Fallback for manual ts-node runs without the predev hook. @@ -49,12 +55,13 @@ function resolveVersion(): string { } catch { /* not found, keep walking */ } dir = path.dirname(dir); } - return 'unknown'; + console.warn('[CapabilityRegistry] Could not resolve Sencho version from any source'); + return null; } const cachedVersion = resolveVersion(); -export function getSenchoVersion(): string { +export function getSenchoVersion(): string | null { return cachedVersion; } @@ -83,8 +90,9 @@ export async function fetchRemoteMeta(baseUrl: string, apiToken: string): Promis headers: { Authorization: `Bearer ${apiToken}` }, timeout: 5000, }); + const rawVersion: string | undefined = res.data.version; return { - version: res.data.version ?? null, + version: isValidVersion(rawVersion) ? rawVersion : null, capabilities: Array.isArray(res.data.capabilities) ? res.data.capabilities : [], }; } catch (err) { diff --git a/docs/features/fleet-view.mdx b/docs/features/fleet-view.mdx index 31348260..8b539dd3 100644 --- a/docs/features/fleet-view.mdx +++ b/docs/features/fleet-view.mdx @@ -156,3 +156,25 @@ Fleet View queries all registered nodes in parallel. Each node responds independ Fleet View always runs on your primary (local) Sencho instance. It is never proxied through a remote node. + +--- + +## Troubleshooting + +### Version shows as "unknown" for a remote node + +This means the remote node's `/api/meta` endpoint did not return a valid version. Common causes: + +- **Remote node is offline or unreachable.** Check that the node's API URL and token are correct in the node configuration. +- **Remote node is running an older Sencho version** that predates the version reporting feature. Update the remote node manually (pull the latest Docker image and restart the container) to restore version reporting. + +Once the remote node is reachable and running a current Sencho version, its version will appear automatically on the next fleet refresh. + +### "Update All" says no updates available + +The **Update All** button only triggers updates on remote nodes that: + +1. Report a valid version lower than your primary instance's version +2. Support the self-update capability (requires running in Docker) + +If a remote node's version is unresolvable ("unknown"), the **Update** button on its individual card will still be available, but **Update All** requires both versions to be known for a safe comparison. diff --git a/docs/images/fleet-view/fleet-node-updates.png b/docs/images/fleet-view/fleet-node-updates.png index 2f8f23d2..e1a07932 100644 Binary files a/docs/images/fleet-view/fleet-node-updates.png and b/docs/images/fleet-view/fleet-node-updates.png differ diff --git a/docs/images/fleet-view/fleet-overview.png b/docs/images/fleet-view/fleet-overview.png index 75c38949..754d6799 100644 Binary files a/docs/images/fleet-view/fleet-overview.png and b/docs/images/fleet-view/fleet-overview.png differ diff --git a/frontend/src/components/CapabilityGate.tsx b/frontend/src/components/CapabilityGate.tsx index a68a6b87..1e683758 100644 --- a/frontend/src/components/CapabilityGate.tsx +++ b/frontend/src/components/CapabilityGate.tsx @@ -2,6 +2,7 @@ import { type ReactNode } from 'react'; import { Unplug } from 'lucide-react'; import { useNodes } from '@/context/NodeContext'; import type { Capability } from '@/lib/capabilities'; +import { isValidVersion } from '@/lib/version'; interface CapabilityGateProps { capability: Capability; @@ -15,8 +16,7 @@ export function CapabilityGate({ capability, featureName = 'This feature', child if (hasCapability(capability)) return <>{children}; const nodeName = activeNode?.name ?? 'this node'; - const hasValidVersion = activeNodeMeta?.version && activeNodeMeta.version !== 'unknown' && activeNodeMeta.version !== '0.0.0-dev'; - const versionHint = hasValidVersion + const versionHint = isValidVersion(activeNodeMeta?.version) ? `${nodeName} is running v${activeNodeMeta.version}` : `${nodeName} does not support this capability`; @@ -28,7 +28,7 @@ export function CapabilityGate({ capability, featureName = 'This feature', child
- {featureName} is not available — {versionHint} + {featureName} is not available: {versionHint}
diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 81acce5d..436b4fc6 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -26,6 +26,7 @@ import { LabelPill, LabelDot, type Label as StackLabel } from './LabelPill'; import { LabelAssignPopover } from './LabelAssignPopover'; import { UserProfileDropdown } from './UserProfileDropdown'; import { apiFetch, fetchForNode } from '@/lib/api'; +import { isValidVersion } from '@/lib/version'; import { toast } from '@/components/ui/toast-store'; import { Label } from './ui/label'; import { Command, CommandInput, CommandList, CommandItem } from './ui/command'; @@ -1350,7 +1351,7 @@ export default function EditorLayout() { node.status === 'offline' ? 'bg-red-500' : 'bg-gray-400' }`} /> {node.name} - {meta?.version && meta.version !== 'unknown' && meta.version !== '0.0.0-dev' && ( + {isValidVersion(meta?.version) && ( v{meta.version} diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index a434f2a9..0bb0e639 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -28,6 +28,7 @@ import FleetSnapshots from './FleetSnapshots'; import { toast } from '@/components/ui/toast-store'; import { LabelDot, type Label as StackLabel } from './LabelPill'; import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox'; +import { formatVersion } from '@/lib/version'; // --- Types --- @@ -68,7 +69,7 @@ interface NodeUpdateStatus { name: string; type: 'local' | 'remote'; version: string | null; - latestVersion: string; + latestVersion: string | null; updateAvailable: boolean; updateStatus: 'updating' | 'completed' | 'timeout' | 'failed' | null; } @@ -363,6 +364,8 @@ function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updating const [loadingStacks, setLoadingStacks] = useState(false); const isOnline = node.status === 'online'; + const formattedVersion = formatVersion(updateStatus?.version); + const formattedLatest = formatVersion(updateStatus?.latestVersion); const cpuPercent = getNodeCpu(node); const memPercent = getNodeMem(node); const diskPercent = getNodeDisk(node); @@ -411,9 +414,9 @@ function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updating {node.type} - {updateStatus?.version && ( + {formattedVersion && ( - v{updateStatus.version} + {formattedVersion} )} {updateStatus?.updateStatus && } @@ -498,7 +501,7 @@ function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updating {updatingNodeId === node.id ? ( <>Triggering... ) : ( - <>Update to v{updateStatus.latestVersion} + <>{formattedLatest ? `Update to ${formattedLatest}` : 'Update'} )} @@ -1140,6 +1143,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { const failed = updateStatuses.filter(s => s.updateStatus === 'failed' || s.updateStatus === 'timeout').length; const q = modalSearch.toLowerCase(); const filtered = q ? updateStatuses.filter(s => s.name.toLowerCase().includes(q) || s.type.includes(q)) : updateStatuses; + const gatewayLabel = formatVersion(updateStatuses[0]?.latestVersion); return ( <> @@ -1182,9 +1186,11 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { className="h-8 pl-8 text-xs" /> -
- Gateway: v{updateStatuses[0]?.latestVersion} -
+ {gatewayLabel && ( +
+ Gateway: {gatewayLabel} +
+ )} {/* Table header */} @@ -1218,12 +1224,12 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { {/* Current version */} - {s.version && s.version !== 'unknown' && s.version !== '0.0.0-dev' ? `v${s.version}` : unknown} + {formatVersion(s.version) ?? unknown} {/* Latest version */} - v{s.latestVersion} + {formatVersion(s.latestVersion) ?? unknown} {/* Status / Action */} diff --git a/frontend/src/lib/version.ts b/frontend/src/lib/version.ts new file mode 100644 index 00000000..2bae93fc --- /dev/null +++ b/frontend/src/lib/version.ts @@ -0,0 +1,10 @@ +/** Returns true when the string is a displayable version (not a placeholder or missing). */ +export function isValidVersion(v: string | null | undefined): v is string { + return !!v && v !== 'unknown' && v !== '0.0.0-dev'; +} + +/** Format a version string for display, returning null for invalid/missing values. */ +export function formatVersion(v: string | null | undefined): string | null { + if (!isValidVersion(v)) return null; + return `v${v}`; +}