mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
fix(fleet): detect updates via GitHub Releases instead of gateway self-comparison (#454)
* fix(fleet): detect updates via GitHub Releases instead of gateway self-comparison The fleet update check compared each node's version against the gateway's own version, so the local node could never appear outdated. Now fetches the actual latest release from GitHub Releases API with a 30-minute in-memory cache and thundering-herd protection. The Recheck button invalidates this cache via ?recheck=true to force a fresh lookup. * docs(fleet): update docs to reflect GitHub Releases version detection Replace "Gateway version" references with "Latest version" to match the new label. Document that version comparison uses the latest GitHub release rather than the gateway's own version, and that Recheck refreshes the cached latest version.
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
+65
-6
@@ -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<string | null> | null = null;
|
||||
const LATEST_VERSION_CACHE_TTL = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
async function fetchLatestSenchoVersion(): Promise<string | null> {
|
||||
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<string | null> {
|
||||
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<v
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodes = db.getNodes();
|
||||
const gatewayVersion = getSenchoVersion();
|
||||
const { compareVersion, compareValid } = await getCompareTarget(gatewayVersion);
|
||||
|
||||
// Filter to eligible candidates, then trigger all in parallel
|
||||
const candidates = nodes.filter(node => {
|
||||
@@ -1512,7 +1567,7 @@ app.post('/api/fleet/update-all', async (req: Request, res: Response): Promise<v
|
||||
if (!meta.capabilities.includes('self-update')) {
|
||||
return { name: node.name, triggered: false };
|
||||
}
|
||||
if (isValidVersion(meta.version) && isValidVersion(gatewayVersion) && !semver.lt(meta.version, gatewayVersion)) {
|
||||
if (isValidVersion(meta.version) && compareValid && !semver.lt(meta.version, compareVersion!)) {
|
||||
return { name: node.name, triggered: false };
|
||||
}
|
||||
const response = await fetch(`${node.api_url!.replace(/\/$/, '')}/api/system/update`, {
|
||||
@@ -1562,6 +1617,10 @@ app.delete('/api/fleet/nodes/:nodeId/update-status', async (req: Request, res: R
|
||||
// Clear all terminal (timed-out, failed, completed) tracker entries at once
|
||||
app.delete('/api/fleet/update-status', async (req: Request, res: Response): Promise<void> => {
|
||||
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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Note>
|
||||
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
|
||||
<img src="/images/fleet-view/fleet-node-failed.png" alt="Node Updates dialog showing a failed node with retry and dismiss buttons" />
|
||||
</Frame>
|
||||
|
||||
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.
|
||||
|
||||
<Note>
|
||||
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.
|
||||
|
||||
@@ -1262,7 +1262,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
</div>
|
||||
{gatewayLabel && (
|
||||
<div className="text-[11px] text-muted-foreground shrink-0">
|
||||
Gateway: <span className="font-mono tabular-nums text-foreground">{gatewayLabel}</span>
|
||||
Latest: <span className="font-mono tabular-nums text-foreground">{gatewayLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user