fix(fleet): resolve version detection pipeline for Docker builds (#402)

* fix(fleet): resolve version detection pipeline for Docker builds

The Dockerfile backend-builder stage was missing a COPY of the root
package.json, causing generate-version.js to fall back to "0.0.0-dev"
at build time. At runtime, the filesystem walk also failed (root
package.json not in the final image), producing the string "unknown"
which the frontend rendered as "vunknown".

Changes:
- Dockerfile: copy root package.json into backend-builder stage
- CapabilityRegistry: return null (not "unknown") for unresolvable
  versions; add isValidVersion() type guard; normalize remote meta
  responses to strip "unknown"/"0.0.0-dev" sentinel values
- Fleet endpoints: hoist gateway version validation outside per-node
  loops; treat unresolvable remote versions as "potentially outdated"
  instead of silently marking them up to date
- FleetView: guard all version display points (card badge, update
  button, gateway label, modal columns) via shared formatVersion()
- EditorLayout, CapabilityGate: use shared isValidVersion utility
- New frontend/src/lib/version.ts shared utility
- Docs: add troubleshooting section for version display edge cases
- Screenshots: updated Fleet Overview and Node Updates modal

* docs: update fleet node updates screenshot with live remote node
This commit is contained in:
Anso
2026-04-06 03:03:56 -04:00
committed by GitHub
parent 9002ff95e1
commit a55d1245f8
11 changed files with 82 additions and 23 deletions
+1 -1
View File
@@ -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)
+2
View File
@@ -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)
+15 -5
View File
@@ -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<v
const results = await Promise.allSettled(candidates.map(async (node) => {
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`, {
+12 -4
View File
@@ -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) {
+22
View File
@@ -156,3 +156,25 @@ Fleet View queries all registered nodes in parallel. Each node responds independ
<Note>
Fleet View always runs on your primary (local) Sencho instance. It is never proxied through a remote node.
</Note>
---
## 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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 58 KiB

+3 -3
View File
@@ -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
<div className="absolute inset-0 flex items-start justify-center pt-8">
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-muted/80 border border-border text-muted-foreground text-xs">
<Unplug className="w-3 h-3" strokeWidth={1.5} />
{featureName} is not available {versionHint}
{featureName} is not available: {versionHint}
</div>
</div>
</div>
+2 -1
View File
@@ -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'
}`} />
<span>{node.name}</span>
{meta?.version && meta.version !== 'unknown' && meta.version !== '0.0.0-dev' && (
{isValidVersion(meta?.version) && (
<span className="font-mono text-[10px] tabular-nums text-muted-foreground/60 ml-auto">
v{meta.version}
</span>
+15 -9
View File
@@ -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
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 shrink-0">
{node.type}
</Badge>
{updateStatus?.version && (
{formattedVersion && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 font-mono tabular-nums shrink-0">
v{updateStatus.version}
{formattedVersion}
</Badge>
)}
{updateStatus?.updateStatus && <UpdateStatusBadge status={updateStatus.updateStatus} />}
@@ -498,7 +501,7 @@ function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updating
{updatingNodeId === node.id ? (
<><Loader2 className="w-3 h-3 mr-1.5 animate-spin" />Triggering...</>
) : (
<><Download className="w-3 h-3 mr-1.5" strokeWidth={1.5} />Update to v{updateStatus.latestVersion}</>
<><Download className="w-3 h-3 mr-1.5" strokeWidth={1.5} />{formattedLatest ? `Update to ${formattedLatest}` : 'Update'}</>
)}
</Button>
</div>
@@ -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"
/>
</div>
<div className="text-[11px] text-muted-foreground shrink-0">
Gateway: <span className="font-mono tabular-nums text-foreground">v{updateStatuses[0]?.latestVersion}</span>
</div>
{gatewayLabel && (
<div className="text-[11px] text-muted-foreground shrink-0">
Gateway: <span className="font-mono tabular-nums text-foreground">{gatewayLabel}</span>
</div>
)}
</div>
{/* Table header */}
@@ -1218,12 +1224,12 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
{/* Current version */}
<span className="text-xs font-mono tabular-nums text-muted-foreground">
{s.version && s.version !== 'unknown' && s.version !== '0.0.0-dev' ? `v${s.version}` : <span className="text-muted-foreground/50 italic text-[10px]">unknown</span>}
{formatVersion(s.version) ?? <span className="text-muted-foreground/50 italic text-[10px]">unknown</span>}
</span>
{/* Latest version */}
<span className="text-xs font-mono tabular-nums">
v{s.latestVersion}
{formatVersion(s.latestVersion) ?? <span className="text-muted-foreground/50 italic text-[10px]">unknown</span>}
</span>
{/* Status / Action */}
+10
View File
@@ -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}`;
}