mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 08:27:42 +00:00
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:
+1
-1
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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`, {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user