diff --git a/CHANGELOG.md b/CHANGELOG.md index 374a78dc..23268830 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed * **fleet:** fix false "Update available" on remote nodes whose `api_url` has a trailing slash, causing `fetchRemoteMeta` to construct a double-slash URL that fails silently +* **fleet:** detect updates via GitHub Releases API instead of comparing against the gateway's own version. Previously, the local node could never appear outdated because it compared its version to itself. The Recheck button now invalidates the 30-minute version cache and fetches the actual latest release. ## [0.41.1](https://github.com/AnsoCode/Sencho/compare/v0.41.0...v0.41.1) (2026-04-08) diff --git a/backend/src/index.ts b/backend/src/index.ts index 01d30d60..dfbf57f1 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1122,6 +1122,58 @@ const UPDATE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes const UPDATE_TIMEOUT_MSG = 'Node did not come back online within 5 minutes.'; const EARLY_FAIL_MS = 180 * 1000; // 3 minutes before declaring a probable pull failure +// Latest Sencho version cache (fetched from GitHub Releases) +let latestVersionCache: { version: string; fetchedAt: number } | null = null; +let latestVersionInflight: Promise | null = null; +const LATEST_VERSION_CACHE_TTL = 30 * 60 * 1000; // 30 minutes + +async function fetchLatestSenchoVersion(): Promise { + try { + const res = await fetch('https://api.github.com/repos/AnsoCode/Sencho/releases/latest', { + headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'Sencho' }, + signal: AbortSignal.timeout(10000), + }); + if (!res.ok) return null; + const data = await res.json() as { tag_name?: string }; + const tag = data.tag_name?.replace(/^v/, '') ?? null; + return tag && semver.valid(tag) ? tag : null; + } catch { + return null; + } +} + +async function getLatestVersion(forceRefresh = false): Promise { + if ( + !forceRefresh && + latestVersionCache && + Date.now() - latestVersionCache.fetchedAt < LATEST_VERSION_CACHE_TTL + ) { + return latestVersionCache.version; + } + // Deduplicate concurrent requests (thundering herd protection) + if (!latestVersionInflight) { + latestVersionInflight = fetchLatestSenchoVersion().finally(() => { latestVersionInflight = null; }); + } + const version = await latestVersionInflight; + if (version) { + latestVersionCache = { version, fetchedAt: Date.now() }; + } + // On failure, return stale cache if available (graceful degradation) + return version ?? latestVersionCache?.version ?? null; +} + +/** Resolve the version to compare nodes against (latest from GitHub, or gateway fallback). */ +async function getCompareTarget(gatewayVersion: string | null) { + const latestVersion = await getLatestVersion(); + const latestValid = latestVersion !== null && isValidVersion(latestVersion); + return { + latestVersion, + latestValid, + compareVersion: latestValid ? latestVersion : gatewayVersion, + compareValid: latestValid || isValidVersion(gatewayVersion), + }; +} + function createTracker( status: UpdateTracker['status'], previousVersion: string | null, @@ -1277,6 +1329,8 @@ app.get('/api/fleet/update-status', async (_req: Request, res: Response): Promis const gatewayVersion = getSenchoVersion(); const gatewayValid = isValidVersion(gatewayVersion); + const { latestVersion, latestValid, compareVersion, compareValid } = await getCompareTarget(gatewayVersion); + const results = await Promise.allSettled( nodes.map(async (node) => { const tracker = updateTracker.get(node.id); @@ -1328,7 +1382,7 @@ app.get('/api/fleet/update-status', async (_req: Request, res: Response): Promis elapsed > 15_000 && isValidVersion(version) && gatewayValid && - !semver.lt(version, gatewayVersion!) + !semver.lt(version, compareVersion!) ) { // Signal 4: Remote is now at or above gateway version (after minimum processing time). // Catches fast restarts where the 5s polling interval misses the offline window @@ -1361,8 +1415,8 @@ app.get('/api/fleet/update-status', async (_req: Request, res: Response): Promis let updateAvailable = false; if (!isValidVersion(version)) { updateAvailable = node.type === 'remote'; - } else if (gatewayValid) { - updateAvailable = semver.lt(version, gatewayVersion!); + } else if (compareValid) { + updateAvailable = semver.lt(version, compareVersion!); } const currentTracker = updateTracker.get(node.id); @@ -1371,7 +1425,7 @@ app.get('/api/fleet/update-status', async (_req: Request, res: Response): Promis name: node.name, type: node.type, version, - latestVersion: gatewayVersion, + latestVersion: latestValid ? latestVersion : gatewayVersion, updateAvailable, updateStatus: currentTracker?.status ?? null, error: currentTracker?.error ?? null, @@ -1386,7 +1440,7 @@ app.get('/api/fleet/update-status', async (_req: Request, res: Response): Promis name: nodes[i].name, type: nodes[i].type, version: null, - latestVersion: gatewayVersion, + latestVersion: latestValid ? latestVersion : gatewayVersion, updateAvailable: false, updateStatus: null, }; @@ -1490,6 +1544,7 @@ app.post('/api/fleet/update-all', async (req: Request, res: Response): Promise { @@ -1512,7 +1567,7 @@ app.post('/api/fleet/update-all', async (req: Request, res: Response): Promise => { if (!requirePaid(req, res)) return; + // Pre-fetch fresh latest version so the next GET has up-to-date data + if (req.query.recheck === 'true') { + await getLatestVersion(true); + } for (const [nodeId, tracker] of updateTracker) { if (tracker.status === 'timeout' || tracker.status === 'failed' || tracker.status === 'completed') { updateTracker.delete(nodeId); diff --git a/docs/features/fleet-view.mdx b/docs/features/fleet-view.mdx index 1816e817..291d3e1f 100644 --- a/docs/features/fleet-view.mdx +++ b/docs/features/fleet-view.mdx @@ -139,10 +139,10 @@ Click **Check Updates** in the header to open the Node Updates modal. This lets The modal shows: - **Summary cards** at the top: counts of nodes that are Up to date, have updates Available, are currently Updating, or have Failed -- **Gateway version** label showing your primary instance's version +- **Latest version** label showing the newest available Sencho release (checked via GitHub Releases, cached for 30 minutes) - **Filter** search box to find specific nodes - **Node table** with columns: Node name, Type, Current version, Latest version, and Status (either an "Up to date" badge or an "Update" button) -- **Recheck** button to re-scan for available updates +- **Recheck** button to refresh the latest version from GitHub and re-scan for available updates - **Update All** button to trigger updates on all remote nodes that have a pending update When you click **Update** on a remote node, Sencho sends the update command to the remote instance. The remote pulls the latest Docker image, then spawns a short-lived helper container that performs the compose recreate. The node restarts with the new version, and the status badge transitions from "Updating" to "Updated" once the gateway detects the version change. @@ -178,7 +178,7 @@ Once the remote node is reachable and running a current Sencho version, its vers The **Update All** button only triggers updates on remote nodes that: -1. Report a valid version lower than your primary instance's version +1. Report a valid version lower than the latest available Sencho release 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/features/remote-updates.mdx b/docs/features/remote-updates.mdx index f3c1e65b..8a05655c 100644 --- a/docs/features/remote-updates.mdx +++ b/docs/features/remote-updates.mdx @@ -3,7 +3,7 @@ title: Remote Updates description: Check for outdated nodes and trigger over-the-air Sencho updates from the Fleet View. --- -Sencho can update remote nodes directly from the dashboard. When your primary instance is running a newer version than a remote node, a one-click update pulls the latest image and recreates the container automatically. +Sencho can update remote nodes directly from the dashboard. When a node is running an older version than the latest available release, a one-click update pulls the latest image and recreates the container automatically. This includes the local (gateway) node itself. Remote updates require a **Skipper** or **Admiral** license. @@ -30,7 +30,7 @@ Open the **Fleet** tab and click **Check Updates** in the header. This opens the The dialog includes: - **Summary cards** at the top showing counts of nodes that are Up to date, have updates Available, are currently Updating, or have Failed -- **Gateway version** label showing your primary instance's version +- **Latest version** label showing the newest available Sencho release - **Filter** search box to find specific nodes by name or type - **Node table** with columns for name, type, current version, latest version, and status @@ -38,7 +38,7 @@ Each node's status column shows one of: | Status | Meaning | |--------|---------| -| **Up to date** badge | Node is running the same version as the gateway | +| **Up to date** badge | Node is running the latest available version | | **Update** button | A newer version is available; click to update | | **Updating** badge | The node is pulling the new image and restarting | | **Updated** badge | The node came back online with the new version | @@ -104,7 +104,7 @@ Hovering over a failed or timed-out badge reveals the error message with details Node Updates dialog showing a failed node with retry and dismiss buttons -You can also click **Recheck** in the dialog footer to clear all failed and timed-out states at once and fetch fresh version information from every node. +You can also click **Recheck** in the dialog footer to clear all failed and timed-out states at once, refresh the cached latest version from GitHub, and fetch fresh version information from every node. Update tracking is stored in memory on the gateway. Restarting the gateway clears all update states, so any stuck "Timed out" or "Failed" badges will resolve on their own after a restart. diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index 69f386af..3d1f031b 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -1262,7 +1262,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { {gatewayLabel && (
- Gateway: {gatewayLabel} + Latest: {gatewayLabel}
)} @@ -1355,7 +1355,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { disabled={checkingUpdates} onClick={async () => { setCheckingUpdates(true); - await apiFetch('/fleet/update-status', { method: 'DELETE', localOnly: true }); + await apiFetch('/fleet/update-status?recheck=true', { method: 'DELETE', localOnly: true }); await fetchUpdateStatus(); setCheckingUpdates(false); }}