diff --git a/apps/web/content/docs/dev/updates/admin-update-alerts.webp b/apps/web/content/docs/dev/updates/admin-update-alerts.webp new file mode 100644 index 0000000000..24edc75eb6 Binary files /dev/null and b/apps/web/content/docs/dev/updates/admin-update-alerts.webp differ diff --git a/apps/web/content/docs/dev/updates/admin.mdx b/apps/web/content/docs/dev/updates/admin.mdx index 299d0a39f7..a5bc3095b1 100644 --- a/apps/web/content/docs/dev/updates/admin.mdx +++ b/apps/web/content/docs/dev/updates/admin.mdx @@ -32,11 +32,13 @@ Each run fetches every distinct manifest once, even when several plugins share i Staff with **Plugins → Check for plugin and core updates now** (`plugins.can_check_updates`) see a **Check for updates** button. It runs the same check as the cron job and refreshes the page when it finishes. -- Only one check runs at a time across all API instances. A second click while one runs says _A check is already running_. +- Only one check runs at a time across all API instances, and the daily check shares the same lease. A second click while one runs says _A check is already running_. +- A long check keeps its lease by renewing it every minute. If an API instance crashes mid-check, its lease runs out within five minutes and the next check takes over. +- If the server running a check can no longer confirm it holds the lease, the check stops and says _The check stopped before it finished_. Results stored before that are kept; press the button again a moment later. - A manual check can run once every two minutes. A click inside that window says how many seconds to wait. -- The endpoint is `POST /api/@vitnode/core/admin/plugins/updates/check`; it answers `409` while a check runs and `429` during the cooldown. +- The endpoint is `POST /api/@vitnode/core/admin/plugins/updates/check`. It answers `409` while a check runs (`"status": "running"`) or when the check was interrupted (`"status": "interrupted"`), and `429` during the cooldown. -Viewing the results only needs **Plugins → View installed plugins and their versions** (`plugins.can_view`). +Viewing the results only needs **Plugins → View installed plugins and their versions** (`plugins.can_view`). The same permission shows the [update count, security alert and System updates widget](/docs/dev/updates/alerts) everywhere else in the AdminCP. How the lease keeps checks exclusive is explained in [How update checks share one lease](/docs/dev/updates/lease). ## Read the results diff --git a/apps/web/content/docs/dev/updates/alerts.mdx b/apps/web/content/docs/dev/updates/alerts.mdx new file mode 100644 index 0000000000..e5f9a18af0 --- /dev/null +++ b/apps/web/content/docs/dev/updates/alerts.mdx @@ -0,0 +1,98 @@ +--- +title: Update alerts in the AdminCP +description: The update count next to System → Plugins, the security alert in the AdminCP header and the System updates dashboard widget - what each number means, how fresh it is and who sees it. +icon: BellRing +--- + +import { ImgDocs } from '@/components/fumadocs/img' + +import updateAlerts from './admin-update-alerts.webp' + +You do not have to open **System → Plugins** to notice an update. VitNode shows the results of the [update checks](/docs/dev/updates/admin) in three more places across the AdminCP: a count in the navigation, a security alert in the header and a dashboard widget. All three read the results already stored by the last check. Opening them never contacts a publisher and never starts a check. + + + +The versions and the security update in the screenshot are illustrative demo data. + +## Who sees the alerts + +Staff with **Plugins → View installed plugins and their versions** (`plugins.can_view`) see all three. Everyone else sees none of them, and their browser never asks for the data. Running a check still needs `plugins.can_check_updates` and stays a button on the Plugins page. + +## The count next to System → Plugins + +A number next to **System → Plugins** tells you how many packages have a newer release on their channel. Core counts as one package and every plugin counts once, even when its API and web app are registered separately. It is the same number of _Update available_ rows the Plugins page shows. + +- The count only appears when it is above zero. While the data loads, or if it cannot be loaded, the menu looks exactly as before. +- Security updates do not add to the count. They have their own alert in the header. +- When the **System** group is folded, the count moves to the **System** item. In the collapsed icon sidebar a small dot marks the icon, and the count sits next to **Plugins** in the menu that opens. + +## The security alert in the header + +When a security update affects something you run, a red pill appears in the AdminCP header, next to search, on every page: + +> **2 security updates** + +On a phone it shrinks to the shield and the number. Each component counts once, however many security updates match it. The pill has no close button on purpose: it disappears on its own once no evaluated finding applies to what you run - after you upgrade and redeploy, or after a check finds the publisher changed the affected range. It sits in the header, so it never pushes the page down. + +Click it to see what is waiting, in two groups: + +- **Security fixes** - every package a security update affects, with the version that fixes it. +- **Other updates** - packages with a newer release and no security finding. + +Each group lists up to four packages and then says how many more there are. A row, or **Open Plugins** at the bottom, takes you to System → Plugins, where each affected row names the security update and its fix. The list itself is only fetched when you open the pill, so the header stays as light as before. + +A publisher that cannot be reached never clears a warning. VitNode keeps the last known findings and adds _Based on the last known results_ under the list, so an outage at the publisher never looks like a fix. + +## The System updates widget + +The **System updates** widget is on the dashboard by default for staff who can view plugins. You can remove it or add it back from **Edit layout**, like any other widget. It reads like a small ledger, one row per fact: + +| Row | Meaning | +| -------------- | --------------------------------------------------------------------------------------------------------- | +| Status | The one-line verdict - see below. | +| VitNode | The core version you run and, after the arrow, the newer core release on your channel when one is known. | +| Plugin updates | Plugins with a newer release on their channel. Core is not included here. | +| Security | How many packages, core included, a security update affects - or _None_. | +| Last checked | How fresh the numbers are. | + +Before anything was checked, the numbers show a dash and _Unknown_ instead of `0`. + +### Status + +| Status | What it means | +| ------------------------ | ------------------------------------------------------------------------------------------------- | +| Security fix needed | At least one package you run is affected by a security update. | +| Updates available | A newer release exists for core or a plugin, and nothing is affected by a security update. | +| Up to date | Every installed package was checked and none has an update or a security warning. | +| 2 packages not confirmed | Nothing is outdated, but some packages could not be confirmed, for example they declare no update source. The Plugins page says why. | +| Checking... | A check is running. The widget, the count and the pill refresh every 15 seconds until it finishes. | +| Not checked yet | No check has run. The daily check runs overnight, or press **Check for updates** on the Plugins page. | +| No update sources | No installed package declares where its updates are published. | +| Checks turned off | The API config sets `updates.enabled` to `false`. | + +### Last checked + +| Value | What it means | +| -------------- | ----------------------------------------------------------------------------------------------------------------------- | +| 3 hours ago | The oldest stored result is that recent, so every package was checked at least that recently. | +| Failed 5 minutes ago | The latest check could not read a source. The numbers include the last known results for it. | +| Out of date | You redeployed, upgraded core or changed channel. Results are recomputed from the stored manifests until the next check. | +| Never | Nothing has been checked yet. | + +The time is the **oldest** successful result, not the newest. One package checked a minute ago does not prove the others were. + +## When update checks are turned off + +With `updates: { enabled: false }` in the API config, nothing is checked. The navigation count and the security pill stay hidden, and the widget only says that checks are turned off. Nothing stored earlier is shown as current. + +## How fresh the alerts are + +The alerts share one request per browser tab. It is refreshed when it is older than a minute and you come back to the tab, after **Check for updates** finishes, and every 15 seconds while a check is running. There is no other polling. + +## Check the result + +Press **Check for updates** on **System → Plugins**. When it finishes, the count next to **Plugins**, the security pill and the widget update together without reloading the page. diff --git a/apps/web/content/docs/dev/updates/index.mdx b/apps/web/content/docs/dev/updates/index.mdx index c3bac834ac..2cb875ffaa 100644 --- a/apps/web/content/docs/dev/updates/index.mdx +++ b/apps/web/content/docs/dev/updates/index.mdx @@ -4,7 +4,7 @@ description: How VitNode tells administrators about new versions and security up icon: RefreshCw --- -VitNode checks once a day whether a newer version of core or any installed plugin is published, and whether the version you run is affected by a published security update. The results appear in **AdminCP → System → Plugins**. +VitNode checks once a day whether a newer version of core or any installed plugin is published, and whether the version you run is affected by a published security update. The results appear in **AdminCP → System → Plugins**, as a count next to that menu item, as a security alert in the AdminCP header and in the **System updates** dashboard widget. Each package says where its **update manifest** lives - a public JSON file on GitHub Raw or any other HTTPS host. VitNode reads those files from the server. It does not ask npm, and it does not install, download or change anything. @@ -13,6 +13,8 @@ Each package says where its **update manifest** lives - a public JSON file on Gi | You want to... | Read | | ----------------------------------------------------------------------- | -------------------------------------------------------------------- | | See updates and security warnings, run a check, choose a channel | [Check for updates in the AdminCP](/docs/dev/updates/admin) | +| Understand the update count, security alert and dashboard widget | [Update alerts in the AdminCP](/docs/dev/updates/alerts) | +| Learn how checks stay exclusive across API instances | [How update checks share one lease](/docs/dev/updates/lease) | | Make your own plugin report updates | [Publish updates for your plugin](/docs/dev/updates/plugin-authors) | | Look up every field of the JSON format | [Update manifest reference](/docs/dev/updates/manifest) | | Warn installations about a vulnerability | [Publish a security update](/docs/dev/updates/security-updates) | diff --git a/apps/web/content/docs/dev/updates/lease.mdx b/apps/web/content/docs/dev/updates/lease.mdx new file mode 100644 index 0000000000..13a41e7ac0 --- /dev/null +++ b/apps/web/content/docs/dev/updates/lease.mdx @@ -0,0 +1,61 @@ +--- +title: How update checks share one lease +description: How VitNode keeps the daily and manual update checks exclusive across API instances with a renewable database lease, cancels a check that loses it, and fences every write so a stale owner cannot overwrite newer results. +icon: Lock +--- + +Every update check - the daily cron job and the **Check for updates** button - runs through one function, `runLockedUpdateCheck` in `@vitnode/core`. It takes a database lease first, so only one check runs at a time across all API instances. This page explains how that lease is kept, what happens when it is lost, and why an old owner can never write after a new one took over. + +## The lease lifecycle + +The lease is the single row `check` in `core_update_lock`. A check always goes through these steps, in order: + +1. **Acquire** the lease for 5 minutes. A manual check must also be past the two-minute cooldown, stamped in the same transaction. +2. **Start the heartbeat**, which renews the lease about once a minute. +3. **Run the check.** +4. **Stop the heartbeat** and wait for a renewal that is still on its way. +5. **Release** the lease, but only if it still belongs to this run. + +The heartbeat never overlaps itself: the next renewal is scheduled only after the previous one answered. A renewal only succeeds for the current owner while its lease has not expired, so an owner that let its lease run out cannot bring it back. + +All expiry decisions use the database clock (`clock_timestamp()` in UTC), never the API server's. Two instances with skewed clocks still agree on who holds the lease. + +## Recovery after a crash + +An instance that dies mid-check stops renewing. Its lease runs out at most five minutes after its last renewal, and the next daily run or manual check takes it over. The crashed owner cannot release, renew or write after that, even if it comes back to life. + +## Cancellation after the lease is lost + +The heartbeat aborts the check's `AbortSignal` with an `UpdateCheckLeaseLostError` when ownership cannot be confirmed: + +- a renewal answers that the lease is gone, +- a renewal fails, for example because the database is unreachable, +- or no renewal confirmed the lease before it could have expired. + +The signal reaches `fetchUpdateSource`. Requests in flight are aborted, queued sources never start, and nothing more is evaluated or stored. A cancelled request is not a publisher failure: the source keeps its last valid document and no error is recorded for it. Every started request is awaited before the check returns, and the heartbeat timers are cleared. + +`runLockedUpdateCheck` then returns `{ status: "lease_lost" }`. The cron job logs a warning, and `POST /api/@vitnode/core/admin/plugins/updates/check` answers `409` with `"status": "interrupted"`. + +## Fenced writes + +Renewing alone is not enough. A process paused by a long garbage collection or a slow network can wake up after its lease expired and another instance took over. Every write a check makes therefore goes through `withUpdateCheckLease`: + +```ts title="packages/vitnode/src/api/lib/updates/check.ts" +await withUpdateCheckLease(db, lease.owner, async tx => { + // write the source document and the results computed from it +}); +``` + +It opens a short transaction, takes `pg_advisory_xact_lock` on the lease name, locks the lease row with `SELECT ... FOR UPDATE`, and checks that the owner matches and the lease has not expired by the database clock. Only then does it run the writes, and it commits while still holding both locks. Otherwise it throws `UpdateCheckLeaseLostError` and writes nothing. + +Acquire, renew and release take the same two locks. So a new owner cannot take the lease between an old owner's ownership check and its commit: the takeover waits for that transaction, and the old owner's next write sees the new owner and is refused. This covers the source documents and HTTP validators, the results, recorded failures and the clean-up of removed plugins. + +The row lock also holds off an instance still running the previous release during a rolling deploy, since its lease statement locks the same row. + + + `withUpdateCheckLease` holds the lease lock until the callback returns. Fetch first, then write. A check writes one transaction per source - the source and its results commit together - and one more for the clean-up. + + +## The summary behind the AdminCP alerts + +`GET /api/@vitnode/core/admin/plugins/updates/summary` feeds the [navigation count, security alert and dashboard widget](/docs/dev/updates/alerts). It needs `plugins.can_view` and reads stored results only - the same `loadUpdatesOverview` the catalog uses, condensed by `summarizeUpdates`. Core and each plugin count as one component. `oldestSuccessAt` is the oldest successful result among packages with an update source, and `null` while any of them has none. The web app reads it through one shared query, `useAdminUpdateSummary`. diff --git a/apps/web/content/docs/dev/updates/meta.json b/apps/web/content/docs/dev/updates/meta.json index 122df555f3..7273704c96 100644 --- a/apps/web/content/docs/dev/updates/meta.json +++ b/apps/web/content/docs/dev/updates/meta.json @@ -5,10 +5,13 @@ "pages": [ "index", "admin", + "alerts", "---Publish updates---", "plugin-authors", "manifest", "security-updates", - "official-releases" + "official-releases", + "---How it works---", + "lease" ] } diff --git a/apps/web/src/locales/@vitnode/core/pl.json b/apps/web/src/locales/@vitnode/core/pl.json index 5d3b512a30..52103b7d13 100644 --- a/apps/web/src/locales/@vitnode/core/pl.json +++ b/apps/web/src/locales/@vitnode/core/pl.json @@ -220,7 +220,40 @@ "width": "Szerokość" }, "status_clean": "Brak zmian", - "status_unsaved": "Niezapisane zmiany" + "status_unsaved": "Niezapisane zmiany", + "system-updates": { + "title": "Aktualizacje systemu", + "desc": "Wersja rdzenia, dostępne aktualizacje i ostrzeżenia bezpieczeństwa z zapisanych sprawdzeń aktualizacji.", + "rows": { + "status": "Stan", + "core": "VitNode", + "plugin_updates": "Aktualizacje wtyczek", + "security": "Bezpieczeństwo", + "last_checked": "Ostatnie sprawdzenie" + }, + "status": { + "security": "Wymagana poprawka bezpieczeństwa", + "updates": "Dostępne aktualizacje", + "up_to_date": "Wszystko aktualne", + "unconfirmed": "{count, plural, one {# pakiet niepotwierdzony} few {# pakiety niepotwierdzone} other {# pakietów niepotwierdzonych}}", + "running": "Trwa sprawdzanie...", + "not_checked": "Jeszcze nie sprawdzono", + "unavailable": "Brak źródeł aktualizacji", + "disabled": "Sprawdzanie wyłączone" + }, + "core_update": "dostępna aktualizacja do {version}", + "security_affected": "{count, plural, one {# pakiet} few {# pakiety} other {# pakietów}}", + "security_none": "Brak", + "last_checked": { + "running": "W toku", + "failed": "Niepowodzenie ", + "stale": "Nieaktualne", + "never": "Nigdy" + }, + "unknown": "Nieznane", + "load_error": "Nie udało się wczytać podsumowania aktualizacji. Strona Wtyczki nadal pokazuje wszystkie wyniki.", + "view_plugins": "Zobacz wtyczki" + } } }, "debug": { @@ -459,6 +492,22 @@ "title": "Zaloguj się ponownie", "desc": "Wszystkie karty panelu administracyjnego zostały zamknięte, więc dla bezpieczeństwa wylogowaliśmy Cię." } + }, + "updates": { + "nav_badge": "{count, plural, one {# dostępna aktualizacja} few {# dostępne aktualizacje} other {# dostępnych aktualizacji}}", + "security_pill": { + "label": "{count, plural, one {# aktualizacja bezpieczeństwa} few {# aktualizacje bezpieczeństwa} other {# aktualizacji bezpieczeństwa}}", + "title": "{count, plural, one {# aktualizacja} few {# aktualizacje} other {# aktualizacji}}", + "checked": "Sprawdzono ", + "security": "Poprawki bezpieczeństwa", + "other": "Pozostałe aktualizacje", + "more": "i {count} więcej", + "entry_label": "{title}, aktualizacja do {version}", + "core": "VitNode", + "last_known": "Na podstawie ostatnich znanych wyników - najnowsze sprawdzenie nie mogło ich potwierdzić.", + "load_error": "Nie udało się wczytać listy aktualizacji. Strona Wtyczki nadal pokazuje wszystkie wyniki.", + "open_plugins": "Otwórz Wtyczki" + } } }, "navigation": { @@ -1045,6 +1094,10 @@ "error": { "title": "Nie udało się rozpocząć sprawdzania", "desc": "API nie odpowiedziało. Spróbuj ponownie za chwilę." + }, + "interrupted": { + "title": "Sprawdzanie zatrzymało się przed końcem", + "desc": "Ten serwer nie mógł potwierdzić, że nadal ma blokadę sprawdzania aktualizacji, więc przerwał pracę, zamiast ryzykować kolizję z innym sprawdzaniem. Zapisane wcześniej wyniki zostają - spróbuj ponownie za chwilę." } }, "status": { @@ -1068,7 +1121,6 @@ "stale": "Jeszcze nie sprawdzono dla tego wdrożenia - wyniki pochodzą z danych wydawcy pobranych .", "failed": "Ostatnie sprawdzanie nie powiodło się: {reason}", "failed_with_previous": "Ostatnie sprawdzanie nie powiodło się: {reason} Widoczny jest ostatni udany wynik z .", - "checked_at": "Sprawdzono ", "error": { "blocked_address": "źródło aktualizacji wskazuje na adres prywatny lub lokalny.", "http_error": "źródło aktualizacji odpowiedziało kodem HTTP {detail}.", diff --git a/packages/vitnode/src/api/lib/installed-plugins.ts b/packages/vitnode/src/api/lib/installed-plugins.ts index 50cd09bb5a..1b6f5e3ef4 100644 --- a/packages/vitnode/src/api/lib/installed-plugins.ts +++ b/packages/vitnode/src/api/lib/installed-plugins.ts @@ -1,5 +1,6 @@ import type { FrontendPluginInventory, + PluginAuthor, PluginManifest, } from "@/lib/plugin-manifest"; @@ -10,10 +11,7 @@ export interface InstalledPluginRegistration { version: null | string; } -export interface InstalledPluginAuthor { - name: string; - url: null | string; -} +export type InstalledPluginAuthor = Pick; export interface InstalledPlugin { author: InstalledPluginAuthor | null; diff --git a/packages/vitnode/src/api/lib/updates/check.test.ts b/packages/vitnode/src/api/lib/updates/check.test.ts index 28e2bdedce..ad4c67b58f 100644 --- a/packages/vitnode/src/api/lib/updates/check.test.ts +++ b/packages/vitnode/src/api/lib/updates/check.test.ts @@ -1,10 +1,14 @@ import type { drizzle } from "drizzle-orm/postgres-js"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { UpdateManifest } from "@/lib/updates/manifest"; -import { core_update_results, core_update_sources } from "@/database/updates"; +import { + core_update_lock, + core_update_results, + core_update_sources, +} from "@/database/updates"; import { createMemoryDb } from "@/tests/memory-db"; import type { FetchUpdateSource } from "./check"; @@ -12,7 +16,13 @@ import type { UpdateComponent } from "./components"; import { checkForUpdates, runLockedUpdateCheck } from "./check"; import { UpdateSourceError } from "./fetch-source"; -import { acquireUpdateCheckLease, readUpdateCheckLock } from "./lock"; +import { + acquireUpdateCheckLease, + readUpdateCheckLock, + releaseUpdateCheckLease, + UPDATE_CHECK_LEASE_MS, + UpdateCheckLeaseLostError, +} from "./lock"; type Db = ReturnType; @@ -96,9 +106,13 @@ const sources = ( ); }); -const setup = () => { +const OWNER = "run"; + +const setup = async () => { const memory = createMemoryDb(); const db = memory.db as unknown as Db; + await acquireUpdateCheckLease(db, { manual: false, owner: OWNER }); + const lost = new AbortController(); const check = async ( components: UpdateComponent[], fetchSource: FetchUpdateSource, @@ -110,14 +124,19 @@ const setup = () => { coreVersion: "2.0.0", db, fetchSource, + lease: { owner: OWNER, signal: lost.signal }, now, }); const result = (id: string) => memory.rows(core_update_results).find(row => row.componentId === id); const source = (url: string) => memory.rows(core_update_sources).find(row => row.url === url); + const stored = () => ({ + results: memory.rows(core_update_results), + sources: memory.rows(core_update_sources), + }); - return { check, db, memory, result, source }; + return { check, db, lost, memory, result, source, stored }; }; const BLOG = component("@acme/blog", ACME); @@ -125,7 +144,7 @@ const FORUM = component("@acme/forum", ACME, ["1.1.0"]); describe("checkForUpdates", () => { it("fetches a source shared by several plugins once, and evaluates each", async () => { - const { check, result } = setup(); + const { check, result } = await setup(); const fetchSource = sources({ [ACME]: fetched(acmeManifest()) }); const summary = await check([BLOG, FORUM], fetchSource); @@ -154,7 +173,7 @@ describe("checkForUpdates", () => { }); it("stores only the packages this install checks", async () => { - const { check, source } = setup(); + const { check, source } = await setup(); await check([BLOG], sources({ [ACME]: fetched(acmeManifest(), '"v1"') })); @@ -166,7 +185,7 @@ describe("checkForUpdates", () => { }); it("skips a component without an update source", async () => { - const { check, result } = setup(); + const { check, result } = await setup(); const fetchSource = sources({}); await expect( @@ -177,7 +196,7 @@ describe("checkForUpdates", () => { }); it("keeps one failing source from affecting the others", async () => { - const { check, result } = setup(); + const { check, result } = await setup(); const core = component("@vitnode/core", CORE, ["1.9.0"]); const summary = await check( @@ -221,7 +240,7 @@ describe("checkForUpdates", () => { { code: "unsupported_schema" }, ], ])("keeps the previous findings through %s", async (_, answer, error) => { - const { check, result, source } = setup(); + const { check, result, source } = await setup(); await check([BLOG], sources({ [ACME]: fetched(acmeManifest()) }), T1); await check([BLOG], sources({ [ACME]: answer }), T2); @@ -244,7 +263,7 @@ describe("checkForUpdates", () => { }); it("answers a 304 from the stored copy, evaluated for what runs now", async () => { - const { check, result } = setup(); + const { check, result } = await setup(); await check( [BLOG], sources({ [ACME]: fetched(acmeManifest(), '"v1"') }), @@ -255,6 +274,7 @@ describe("checkForUpdates", () => { await check([component("@acme/blog", ACME, ["1.1.0"])], fetchSource, T2); expect(fetchSource).toHaveBeenCalledWith(ACME, { + signal: expect.any(AbortSignal) as unknown, validators: { etag: '"v1"', lastModified: null }, }); expect(result("@acme/blog")).toMatchObject({ @@ -266,7 +286,7 @@ describe("checkForUpdates", () => { }); it("asks for the full document when the stored copy does not cover a new plugin", async () => { - const { check, result } = setup(); + const { check, result } = await setup(); await check( [BLOG], sources({ [ACME]: fetched(acmeManifest(), '"v1"') }), @@ -276,7 +296,10 @@ describe("checkForUpdates", () => { await check([BLOG, FORUM], fetchSource, T2); - expect(fetchSource).toHaveBeenCalledWith(ACME, { validators: null }); + expect(fetchSource).toHaveBeenCalledWith(ACME, { + signal: expect.any(AbortSignal) as unknown, + validators: null, + }); expect(result("@acme/forum")).toMatchObject({ lastError: { code: "http_error" }, result: null, @@ -284,7 +307,7 @@ describe("checkForUpdates", () => { }); it("forgets removed plugins and sources nothing points to", async () => { - const { check, memory } = setup(); + const { check, memory } = await setup(); const core = component("@vitnode/core", CORE, ["2.0.0"]); await check( [core, BLOG], @@ -306,7 +329,7 @@ describe("checkForUpdates", () => { }); it("forgets everything when nothing has an update source any more", async () => { - const { check, memory } = setup(); + const { check, memory } = await setup(); await check([BLOG], sources({ [ACME]: fetched(acmeManifest()) }), T1); await check([component("@acme/blog", null)], sources({}), T2); @@ -316,6 +339,89 @@ describe("checkForUpdates", () => { }); }); +const untilAborted: FetchUpdateSource = async (_, { signal }) => + await new Promise((_resolve, reject) => { + signal?.addEventListener("abort", () => { + reject(signal.reason as Error); + }); + }); + +describe("checkForUpdates once the lease is lost", () => { + const url = (index: number) => `https://p${index}.example.com/updates.json`; + + it("aborts requests in flight, never starts queued ones, and stores nothing", async () => { + const { check, lost, stored } = await setup(); + await check([BLOG], sources({ [ACME]: fetched(acmeManifest()) })); + const before = stored(); + const signals: AbortSignal[] = []; + let answerLate: ( + value: Awaited>, + ) => void = () => undefined; + const fetchSource = vi.fn(async (requested, options) => { + if (options.signal) signals.push(options.signal); + if (requested === ACME) { + return await new Promise(resolve => { + answerLate = resolve; + }); + } + + return await untilAborted(requested, options); + }); + const running = check( + [ + BLOG, + ...[1, 2, 3, 4, 5].map(index => component(`@p/${index}`, url(index))), + ], + fetchSource, + T2, + ); + let settled = false; + void running.catch(() => undefined).finally(() => (settled = true)); + await vi.waitFor(() => { + expect(signals).toHaveLength(4); + }); + + lost.abort(new UpdateCheckLeaseLostError()); + await new Promise(resolve => setImmediate(resolve)); + expect(signals.every(signal => signal.aborted)).toBe(true); + expect(settled).toBe(false); + answerLate(fetched(acmeManifest(), '"v2"')); + + await expect(running).rejects.toBeInstanceOf(UpdateCheckLeaseLostError); + expect(fetchSource).toHaveBeenCalledTimes(4); + expect(stored()).toEqual(before); + }); + + it.each([ + [ + "a fresh copy of a source", + [BLOG], + sources({ [ACME]: fetched(acmeManifest(["@acme/blog"]), '"v2"') }), + ], + [ + "a failed request", + [BLOG], + sources({ [ACME]: new UpdateSourceError("timeout") }), + ], + ["the clean-up", [component("@acme/blog", null)], sources({})], + ])( + "refuses %s from a run whose lease was taken over before it noticed", + async (_, components, fetchSource) => { + const { check, db, memory, stored } = await setup(); + await check([BLOG], sources({ [ACME]: fetched(acmeManifest()) })); + const before = stored(); + await releaseUpdateCheckLease(db, OWNER); + await acquireUpdateCheckLease(db, { manual: false, owner: "other" }); + + await expect(check(components, fetchSource, T2)).rejects.toBeInstanceOf( + UpdateCheckLeaseLostError, + ); + expect(stored()).toEqual(before); + expect(memory.rows(core_update_lock)).toMatchObject([{ owner: "other" }]); + }, + ); +}); + describe("runLockedUpdateCheck", () => { const options = (db: Db, fetchSource: FetchUpdateSource) => ({ channelOverride: null, @@ -326,13 +432,19 @@ describe("runLockedUpdateCheck", () => { now: T1, }); + const unleased = () => { + const memory = createMemoryDb(); + + return { db: memory.db as unknown as Db, memory }; + }; + + afterEach(() => { + vi.useRealTimers(); + }); + it("does not check while another instance holds the lease", async () => { - const { db } = setup(); - await acquireUpdateCheckLease(db, { - manual: false, - now: T1, - owner: "other", - }); + const { db } = unleased(); + await acquireUpdateCheckLease(db, { manual: false, owner: "other" }); const fetchSource = sources({}); await expect( @@ -342,13 +454,28 @@ describe("runLockedUpdateCheck", () => { }); it("gives the lease back after the check, even when it throws", async () => { - const { db } = setup(); + const { db } = unleased(); const failingDb = new Proxy(db, { get: (target, property, receiver) => - property === "delete" - ? () => { - throw new Error("database went away"); - } + property === "transaction" + ? async (run: (tx: unknown) => Promise) => + await target.transaction( + async tx => + await run( + new Proxy(tx, { + get: (inner, name, innerReceiver) => + name === "delete" + ? () => { + throw new Error("database went away"); + } + : (Reflect.get( + inner, + name, + innerReceiver, + ) as unknown), + }), + ), + ) : (Reflect.get(target, property, receiver) as unknown), }); @@ -358,13 +485,13 @@ describe("runLockedUpdateCheck", () => { manual: false, }), ).rejects.toThrow("database went away"); - await expect(readUpdateCheckLock(db, T1)).resolves.toMatchObject({ + await expect(readUpdateCheckLock(db)).resolves.toMatchObject({ running: false, }); }); it("returns the summary of a completed check", async () => { - const { db } = setup(); + const { db } = unleased(); await expect( runLockedUpdateCheck({ @@ -376,4 +503,85 @@ describe("runLockedUpdateCheck", () => { summary: { sources: 1, updates: 1 }, }); }); + + it("keeps a check that outlasts the first lease protected by renewing it", async () => { + vi.useFakeTimers({ now: T1 }); + const { db, memory } = unleased(); + let answer: (value: Awaited>) => void = () => + undefined; + const fetchSource = vi.fn( + async () => + await new Promise(resolve => { + answer = resolve; + }), + ); + const running = runLockedUpdateCheck({ + ...options(db, fetchSource), + manual: false, + }); + + await vi.advanceTimersByTimeAsync(UPDATE_CHECK_LEASE_MS + 60_000); + await expect( + acquireUpdateCheckLease(db, { manual: false, owner: "other" }), + ).resolves.toMatchObject({ acquired: false, reason: "running" }); + + answer(fetched(acmeManifest())); + await expect(running).resolves.toMatchObject({ status: "completed" }); + expect(memory.rows(core_update_results)).toHaveLength(1); + await expect(readUpdateCheckLock(db)).resolves.toMatchObject({ + running: false, + }); + expect(vi.getTimerCount()).toBe(0); + }); + + it("stops when a renewal fails, keeping what it stored before", async () => { + vi.useFakeTimers({ now: T1 }); + const { db, memory } = unleased(); + let offline = false; + const flakyDb = new Proxy(db, { + get: (target, property, receiver) => + property === "transaction" && offline + ? async () => await Promise.reject(new Error("connection reset")) + : (Reflect.get(target, property, receiver) as unknown), + }); + const fetchSource = vi.fn(untilAborted); + const running = runLockedUpdateCheck({ + ...options(flakyDb, fetchSource), + manual: false, + }); + await vi.waitFor(() => { + expect(fetchSource).toHaveBeenCalled(); + }); + + offline = true; + await vi.advanceTimersByTimeAsync(60_000); + + await expect(running).resolves.toEqual({ status: "lease_lost" }); + expect(fetchSource.mock.calls[0]?.[1].signal?.aborted).toBe(true); + expect(memory.rows(core_update_results)).toEqual([]); + expect(vi.getTimerCount()).toBe(0); + }); + + it("stops once another instance holds the lease, and leaves that lease alone", async () => { + vi.useFakeTimers({ now: T1 }); + const { db, memory } = unleased(); + const fetchSource = vi.fn(untilAborted); + const running = runLockedUpdateCheck({ + ...options(db, fetchSource), + manual: false, + }); + await vi.waitFor(() => { + expect(fetchSource).toHaveBeenCalled(); + }); + + const [{ owner }] = memory.rows(core_update_lock) as [{ owner: string }]; + await releaseUpdateCheckLease(db, owner); + await acquireUpdateCheckLease(db, { manual: false, owner: "other" }); + await vi.advanceTimersByTimeAsync(60_000); + + await expect(running).resolves.toEqual({ status: "lease_lost" }); + expect(memory.rows(core_update_lock)).toMatchObject([{ owner: "other" }]); + expect(memory.rows(core_update_results)).toEqual([]); + expect(vi.getTimerCount()).toBe(0); + }); }); diff --git a/packages/vitnode/src/api/lib/updates/check.ts b/packages/vitnode/src/api/lib/updates/check.ts index 7ae474cf2d..1677942e53 100644 --- a/packages/vitnode/src/api/lib/updates/check.ts +++ b/packages/vitnode/src/api/lib/updates/check.ts @@ -16,10 +16,14 @@ import type { import { evaluateUpdate } from "./evaluate"; import { fetchUpdateSource, UpdateSourceError } from "./fetch-source"; +import { startUpdateCheckHeartbeat } from "./heartbeat"; import { acquireUpdateCheckLease, releaseUpdateCheckLease, type UpdateCheckLease, + UpdateCheckLeaseLostError, + type UpdateCheckTx, + withUpdateCheckLease, } from "./lock"; type Db = ReturnType; @@ -30,7 +34,7 @@ const MAX_ERROR_DETAIL = 300; export type FetchUpdateSource = ( url: string, - options: Pick, + options: Pick, ) => Promise; export interface UpdateCheckSummary { @@ -68,26 +72,43 @@ const pick = ( schemaVersion: manifest.schemaVersion, }); -const mapLimited = async ( +const mapLimited = async ( items: readonly T[], limit: number, - run: (item: T) => Promise, -): Promise => { - const results: R[] = Array.from({ length: items.length }); + signal: AbortSignal, + run: (item: T, signal: AbortSignal) => Promise, +): Promise => { + const failed = new AbortController(); + const scoped = AbortSignal.any([signal, failed.signal]); let next = 0; const worker = async () => { while (next < items.length) { - const index = next++; - results[index] = await run(items[index]); + scoped.throwIfAborted(); + const item = items[next++]; + try { + await run(item, scoped); + } catch (error) { + failed.abort(error); + throw error; + } } }; - await Promise.all( + + const settled = await Promise.allSettled( Array.from({ length: Math.min(limit, items.length) }, worker), ); - - return results; + signal.throwIfAborted(); + const failure = settled.find( + (result): result is PromiseRejectedResult => result.status === "rejected", + ); + if (failure) throw failure.reason; }; +interface UpdateCheckLeaseScope { + owner: string; + signal: AbortSignal; +} + /** * The one check behind the daily cron job and the AdminCP button. Fetches every * distinct update source once, validates it, evaluates each component against @@ -101,6 +122,7 @@ export const checkForUpdates = async ({ coreVersion, db, fetchSource = async (url, options) => await fetchUpdateSource(url, options), + lease, now = new Date(), }: { channelOverride: null | UpdateChannel; @@ -108,21 +130,31 @@ export const checkForUpdates = async ({ coreVersion: null | string; db: Db; fetchSource?: FetchUpdateSource; + lease: UpdateCheckLeaseScope; now?: Date; }): Promise => { + const write = async ( + signal: AbortSignal, + run: (tx: UpdateCheckTx) => Promise, + ) => { + signal.throwIfAborted(); + await withUpdateCheckLease(db, lease.owner, run); + }; + const checked = components.filter( (component): component is UpdateComponent & { sourceUrl: string } => component.sourceUrl !== null, ); - const bySource = new Map(); + const bySource = new Map(); for (const component of checked) { bySource.set(component.sourceUrl, [ ...(bySource.get(component.sourceUrl) ?? []), - component.id, + component, ]); } const urls = [...bySource.keys()]; + lease.signal.throwIfAborted(); const stored = new Map( urls.length === 0 ? [] @@ -134,183 +166,201 @@ export const checkForUpdates = async ({ ).map(row => [row.url, row]), ); - const outcomes = new Map(); - await mapLimited(urls, SOURCE_CONCURRENCY, async url => { - const previous = stored.get(url); - const ids = bySource.get(url) ?? []; - // A 304 can only stand in for a copy that is stored and still covers every - // package asked about now; otherwise ask for the full document. - const reusable = - previous?.document !== null && - previous?.document !== undefined && - ids.every(id => Object.hasOwn(previous.document?.packages ?? {}, id)) - ? previous.document - : null; - - let outcome: SourceOutcome; - let validators: null | { - etag: null | string; - lastModified: null | string; - } = null; - try { - const response = await fetchSource(url, { - validators: - reusable === null || (!previous?.etag && !previous?.lastModified) - ? null - : { - etag: previous?.etag ?? null, - lastModified: previous?.lastModified ?? null, - }, - }); - - if (response.kind === "not_modified") { - outcome = - reusable === null - ? { - document: previous?.document ?? null, - error: { code: "http_error", detail: "unexpected 304" }, - } - : { document: reusable, error: null }; - validators = { - etag: previous?.etag ?? null, - lastModified: previous?.lastModified ?? null, - }; - } else { - const parsed = parseUpdateManifest(response.body); - if (parsed.ok) { - outcome = { - document: pick(parsed.manifest, ids), - error: null, - }; - validators = { - etag: response.etag, - lastModified: response.lastModified, - }; - } else { - outcome = { - document: previous?.document ?? null, - error: { - code: - parsed.reason === "unsupported_schema" - ? "unsupported_schema" - : "invalid_manifest", - detail: parsed.error.slice(0, MAX_ERROR_DETAIL), - }, - }; - } - } - } catch (error) { - outcome = { document: previous?.document ?? null, error: errorOf(error) }; - } - - outcomes.set(url, outcome); - - if (outcome.error === null) { - const values = { - document: outcome.document, - etag: validators?.etag ?? null, - lastAttemptAt: now, - lastError: null, - lastModified: validators?.lastModified ?? null, - lastSuccessAt: now, - }; - await db - .insert(core_update_sources) - .values({ url, ...values }) - .onConflictDoUpdate({ set: values, target: core_update_sources.url }); - } else { - await db - .insert(core_update_sources) - .values({ lastAttemptAt: now, lastError: outcome.error, url }) - .onConflictDoUpdate({ - set: { lastAttemptAt: now, lastError: outcome.error }, - target: core_update_sources.url, - }); - } - }); - const summary: UpdateCheckSummary = { securityUpdates: 0, components: checked.length, - failedSources: [...outcomes.values()].filter( - outcome => outcome.error !== null, - ).length, + failedSources: 0, sources: urls.length, updates: 0, }; - for (const component of checked) { - const outcome = outcomes.get(component.sourceUrl); - if (outcome === undefined) continue; - - if (outcome.error !== null) { - // The failure is recorded; what was found before - and the versions it - // was found for - stays exactly as it was. - await db - .insert(core_update_results) - .values({ - channel: null, - componentId: component.id, - coreVersion, - hasUnknownVersion: component.hasUnknownVersion, - lastAttemptAt: now, - lastError: outcome.error, - result: null, - sourceUrl: component.sourceUrl, - versions: component.versions, - }) - .onConflictDoUpdate({ - set: { lastAttemptAt: now, lastError: outcome.error }, - target: core_update_results.componentId, + await mapLimited( + urls, + SOURCE_CONCURRENCY, + lease.signal, + async (url, signal) => { + const previous = stored.get(url); + const members = bySource.get(url) ?? []; + const ids = members.map(component => component.id); + const reusable = + previous?.document !== null && + previous?.document !== undefined && + ids.every(id => Object.hasOwn(previous.document?.packages ?? {}, id)) + ? previous.document + : null; + + let outcome: SourceOutcome; + let validators: null | { + etag: null | string; + lastModified: null | string; + } = null; + try { + const response = await fetchSource(url, { + signal, + validators: + reusable === null || (!previous?.etag && !previous?.lastModified) + ? null + : { + etag: previous?.etag ?? null, + lastModified: previous?.lastModified ?? null, + }, }); - continue; - } - const result = evaluateUpdate({ - channelOverride, - coreVersion, - entry: outcome.document.packages[component.id], - installed: component, - isCore: component.kind === "core", - }); - summary.securityUpdates += result.securityUpdates.length; - if (result.status === "update_available") summary.updates += 1; - - const values = { - channel: result.channel, - coreVersion, - hasUnknownVersion: component.hasUnknownVersion, - lastAttemptAt: now, - lastError: null, - lastSuccessAt: now, - result, - sourceUrl: component.sourceUrl, - versions: component.versions, - }; - await db - .insert(core_update_results) - .values({ componentId: component.id, ...values }) - .onConflictDoUpdate({ - set: values, - target: core_update_results.componentId, + if (response.kind === "not_modified") { + outcome = + reusable === null + ? { + document: previous?.document ?? null, + error: { code: "http_error", detail: "unexpected 304" }, + } + : { document: reusable, error: null }; + validators = { + etag: previous?.etag ?? null, + lastModified: previous?.lastModified ?? null, + }; + } else { + const parsed = parseUpdateManifest(response.body); + if (parsed.ok) { + outcome = { + document: pick(parsed.manifest, ids), + error: null, + }; + validators = { + etag: response.etag, + lastModified: response.lastModified, + }; + } else { + outcome = { + document: previous?.document ?? null, + error: { + code: + parsed.reason === "unsupported_schema" + ? "unsupported_schema" + : "invalid_manifest", + detail: parsed.error.slice(0, MAX_ERROR_DETAIL), + }, + }; + } + } + } catch (error) { + signal.throwIfAborted(); + outcome = { + document: previous?.document ?? null, + error: errorOf(error), + }; + } + + const { document, error } = outcome; + const evaluated = + error === null + ? members.map(component => ({ + component, + result: evaluateUpdate({ + channelOverride, + coreVersion, + entry: document.packages[component.id], + installed: component, + isCore: component.kind === "core", + }), + })) + : []; + + await write(signal, async tx => { + if (error === null) { + const values = { + document, + etag: validators?.etag ?? null, + lastAttemptAt: now, + lastError: null, + lastModified: validators?.lastModified ?? null, + lastSuccessAt: now, + }; + await tx + .insert(core_update_sources) + .values({ url, ...values }) + .onConflictDoUpdate({ + set: values, + target: core_update_sources.url, + }); + } else { + await tx + .insert(core_update_sources) + .values({ lastAttemptAt: now, lastError: error, url }) + .onConflictDoUpdate({ + set: { lastAttemptAt: now, lastError: error }, + target: core_update_sources.url, + }); + + for (const component of members) { + await tx + .insert(core_update_results) + .values({ + channel: null, + componentId: component.id, + coreVersion, + hasUnknownVersion: component.hasUnknownVersion, + lastAttemptAt: now, + lastError: error, + result: null, + sourceUrl: component.sourceUrl, + versions: component.versions, + }) + .onConflictDoUpdate({ + set: { lastAttemptAt: now, lastError: error }, + target: core_update_results.componentId, + }); + } + } + + for (const { component, result } of evaluated) { + const values = { + channel: result.channel, + coreVersion, + hasUnknownVersion: component.hasUnknownVersion, + lastAttemptAt: now, + lastError: null, + lastSuccessAt: now, + result, + sourceUrl: component.sourceUrl, + versions: component.versions, + }; + await tx + .insert(core_update_results) + .values({ componentId: component.id, ...values }) + .onConflictDoUpdate({ + set: values, + target: core_update_results.componentId, + }); + } }); - } + + if (error !== null) summary.failedSources += 1; + for (const { result } of evaluated) { + summary.securityUpdates += result.securityUpdates.length; + if (result.status === "update_available") summary.updates += 1; + } + }, + ); // Removed plugins, and plugins that dropped their update source, leave no // result behind; neither does a source nothing points to any more. const checkedIds = checked.map(component => component.id); - await db - .delete(core_update_results) - .where( - checkedIds.length === 0 - ? undefined - : notInArray(core_update_results.componentId, checkedIds), - ); - await db - .delete(core_update_sources) - .where( - urls.length === 0 ? undefined : notInArray(core_update_sources.url, urls), - ); + await write(lease.signal, async tx => { + await tx + .delete(core_update_results) + .where( + checkedIds.length === 0 + ? undefined + : notInArray(core_update_results.componentId, checkedIds), + ); + await tx + .delete(core_update_sources) + .where( + urls.length === 0 + ? undefined + : notInArray(core_update_sources.url, urls), + ); + }); return summary; }; @@ -321,19 +371,17 @@ export type LockedUpdateCheck = retryAfterSeconds: number; status: "busy"; } - | { status: "completed"; summary: UpdateCheckSummary }; + | { status: "completed"; summary: UpdateCheckSummary } + | { status: "lease_lost" }; -/** Takes the shared lease, runs the check and always gives the lease back. */ export const runLockedUpdateCheck = async ({ manual, ...options -}: Parameters[0] & { +}: Omit[0], "lease"> & { manual: boolean; }): Promise => { - const lease = await acquireUpdateCheckLease(options.db, { - manual, - now: options.now, - }); + const confirmedAt = Date.now(); + const lease = await acquireUpdateCheckLease(options.db, { manual }); if (!lease.acquired) { return { reason: lease.reason, @@ -342,9 +390,26 @@ export const runLockedUpdateCheck = async ({ }; } + const heartbeat = startUpdateCheckHeartbeat({ + confirmedAt, + db: options.db, + owner: lease.owner, + }); try { - return { status: "completed", summary: await checkForUpdates(options) }; + return { + status: "completed", + summary: await checkForUpdates({ + ...options, + lease: { owner: lease.owner, signal: heartbeat.signal }, + }), + }; + } catch (error) { + if (error instanceof UpdateCheckLeaseLostError) { + return { status: "lease_lost" }; + } + throw error; } finally { - await releaseUpdateCheckLease(options.db, lease.owner); + await heartbeat.stop(); + await releaseUpdateCheckLease(options.db, lease.owner).catch(() => false); } }; diff --git a/packages/vitnode/src/api/lib/updates/fetch-source.test.ts b/packages/vitnode/src/api/lib/updates/fetch-source.test.ts index d7d5758270..8ff0c96914 100644 --- a/packages/vitnode/src/api/lib/updates/fetch-source.test.ts +++ b/packages/vitnode/src/api/lib/updates/fetch-source.test.ts @@ -296,6 +296,32 @@ describe("fetchUpdateSource", () => { ).rejects.toMatchObject({ reason: "timeout" }); }); + it("gives the caller's own reason back when the caller cancels, not a publisher failure", async () => { + const cancel = new AbortController(); + const reason = new Error("lease lost"); + const transport: UpdateSourceTransport = async (_, { signal }) => + await new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => { + reject(new Error("aborted")); + }); + cancel.abort(reason); + }); + + await expect( + fetchWith(URL_OK, transport, { signal: cancel.signal }), + ).rejects.toBe(reason); + }); + + it("never contacts a publisher for a check already cancelled", async () => { + const { requests, transport } = scripted(json(MANIFEST)); + const reason = new Error("lease lost"); + + await expect( + fetchWith(URL_OK, transport, { signal: AbortSignal.abort(reason) }), + ).rejects.toBe(reason); + expect(requests).toEqual([]); + }); + it.each([ [ "a connection the connect-time lookup blocked", diff --git a/packages/vitnode/src/api/lib/updates/fetch-source.ts b/packages/vitnode/src/api/lib/updates/fetch-source.ts index ac0573aa01..5465995fdf 100644 --- a/packages/vitnode/src/api/lib/updates/fetch-source.ts +++ b/packages/vitnode/src/api/lib/updates/fetch-source.ts @@ -211,6 +211,7 @@ export interface FetchUpdateSourceOptions { maxBytes?: number; maxRedirects?: number; resolveHost?: ResolveUpdateHost; + signal?: AbortSignal; timeoutMs?: number; transport?: UpdateSourceTransport; /** Sent as `If-None-Match` / `If-Modified-Since`. Only pass them when a validated copy is stored. */ @@ -223,12 +224,16 @@ export const fetchUpdateSource = async ( maxBytes = UPDATE_SOURCE_MAX_BYTES, maxRedirects = DEFAULT_MAX_REDIRECTS, resolveHost = resolveHostAddresses, + signal: cancel, timeoutMs = DEFAULT_TIMEOUT_MS, transport = httpsUpdateSourceTransport, validators = null, }: FetchUpdateSourceOptions = {}, ): Promise => { - const signal = AbortSignal.timeout(timeoutMs); + cancel?.throwIfAborted(); + const timeout = AbortSignal.timeout(timeoutMs); + const signal = + cancel === undefined ? timeout : AbortSignal.any([timeout, cancel]); const headers: Record = { accept: "application/json", "user-agent": "VitNode update check", @@ -242,7 +247,10 @@ export const fetchUpdateSource = async ( for (let hop = 0; hop <= maxRedirects; hop++) { await assertFetchableUrl(url, resolveHost); - if (signal.aborted) throw new UpdateSourceError("timeout"); + if (signal.aborted) { + cancel?.throwIfAborted(); + throw new UpdateSourceError("timeout"); + } let response: UpdateSourceTransportResponse; try { @@ -252,6 +260,7 @@ export const fetchUpdateSource = async ( signal, }); } catch (error) { + cancel?.throwIfAborted(); throw transportFailure(error, signal); } @@ -291,6 +300,7 @@ export const fetchUpdateSource = async ( bytes = await readCapped(response.body, maxBytes); } catch (error) { response.destroy(); + cancel?.throwIfAborted(); throw transportFailure(error, signal); } diff --git a/packages/vitnode/src/api/lib/updates/heartbeat.test.ts b/packages/vitnode/src/api/lib/updates/heartbeat.test.ts new file mode 100644 index 0000000000..85ff9c851f --- /dev/null +++ b/packages/vitnode/src/api/lib/updates/heartbeat.test.ts @@ -0,0 +1,170 @@ +import type { drizzle } from "drizzle-orm/postgres-js"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { UpdateCheckLeaseRenewal } from "./lock"; + +import { startUpdateCheckHeartbeat } from "./heartbeat"; +import { + UPDATE_CHECK_LEASE_MS, + UPDATE_CHECK_RENEW_INTERVAL_MS, + UpdateCheckLeaseLostError, +} from "./lock"; + +type Db = ReturnType; + +const INTERVAL = UPDATE_CHECK_RENEW_INTERVAL_MS; +const LEASE = UPDATE_CHECK_LEASE_MS; +const RENEWED: UpdateCheckLeaseRenewal = { status: "renewed" }; + +const deferred = () => { + let answer: (renewal: UpdateCheckLeaseRenewal) => void = () => undefined; + const promise = new Promise(resolve => { + answer = resolve; + }); + + return { answer, promise }; +}; + +const start = ( + renew: (...args: unknown[]) => Promise, +) => + startUpdateCheckHeartbeat({ + confirmedAt: Date.now(), + db: {} as Db, + owner: "a", + renew, + }); + +beforeEach(() => { + vi.useFakeTimers({ now: 0 }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("startUpdateCheckHeartbeat", () => { + it("keeps a check alive past the first lease while renewals succeed", async () => { + const renew = vi.fn(async () => await Promise.resolve(RENEWED)); + const heartbeat = start(renew); + + await vi.advanceTimersByTimeAsync(2 * LEASE); + + expect(renew).toHaveBeenCalledTimes((2 * LEASE) / INTERVAL); + expect(renew).toHaveBeenCalledWith({}, "a"); + expect(heartbeat.signal.aborted).toBe(false); + await heartbeat.stop(); + }); + + it("sends the next renewal only after the previous one answered", async () => { + const sentAt: number[] = []; + let inFlight = 0; + let mostInFlight = 0; + const renew = vi.fn(async () => { + sentAt.push(Date.now()); + inFlight += 1; + mostInFlight = Math.max(mostInFlight, inFlight); + await new Promise(resolve => setTimeout(resolve, 30_000)); + inFlight -= 1; + + return RENEWED; + }); + const heartbeat = start(renew); + + await vi.advanceTimersByTimeAsync(270_000); + + expect(sentAt).toEqual([60_000, 150_000, 240_000]); + expect(mostInFlight).toBe(1); + await heartbeat.stop(); + }); + + it.each([ + [ + "the lease was lost", + async () => await Promise.resolve({ status: "lost" as const }), + undefined, + ], + [ + "the renewal failed", + async () => await Promise.reject(new Error("connection reset")), + new Error("connection reset"), + ], + ])("stops the check when %s", async (_, answer, cause) => { + const renew = vi.fn(answer); + const heartbeat = start(renew); + + await vi.advanceTimersByTimeAsync(INTERVAL); + + expect(heartbeat.signal.aborted).toBe(true); + expect(heartbeat.signal.reason).toBeInstanceOf(UpdateCheckLeaseLostError); + expect((heartbeat.signal.reason as Error).cause).toEqual(cause); + expect(vi.getTimerCount()).toBe(0); + + await vi.advanceTimersByTimeAsync(2 * LEASE); + expect(renew).toHaveBeenCalledTimes(1); + await heartbeat.stop(); + }); + + it("stops the check once a renewal hangs past the last confirmed lease", async () => { + const hung = deferred(); + const renew = vi + .fn<() => Promise>() + .mockResolvedValueOnce(RENEWED) + .mockReturnValueOnce(hung.promise); + const heartbeat = start(renew); + + await vi.advanceTimersByTimeAsync(INTERVAL + LEASE - 1); + expect(heartbeat.signal.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(heartbeat.signal.reason).toBeInstanceOf(UpdateCheckLeaseLostError); + + hung.answer(RENEWED); + await heartbeat.stop(); + expect(vi.getTimerCount()).toBe(0); + expect(heartbeat.signal.aborted).toBe(true); + expect(renew).toHaveBeenCalledTimes(2); + }); + + it("waits for a renewal on its way when stopped, then leaves no timer behind", async () => { + const inFlight = deferred(); + const renew = vi.fn(async () => await inFlight.promise); + const heartbeat = start(renew); + await vi.advanceTimersByTimeAsync(INTERVAL); + + let stopped = false; + const stopping = heartbeat.stop().then(() => { + stopped = true; + }); + await vi.advanceTimersByTimeAsync(0); + expect(stopped).toBe(false); + + inFlight.answer(RENEWED); + await stopping; + expect(vi.getTimerCount()).toBe(0); + + await vi.advanceTimersByTimeAsync(2 * LEASE); + expect(renew).toHaveBeenCalledTimes(1); + expect(heartbeat.signal.aborted).toBe(false); + }); + + it("stops waiting for a renewal that never answers once the deadline passes", async () => { + const renew = vi.fn( + async () => await new Promise(() => undefined), + ); + const heartbeat = start(renew); + await vi.advanceTimersByTimeAsync(INTERVAL); + + let stopped = false; + const stopping = heartbeat.stop().then(() => { + stopped = true; + }); + await vi.advanceTimersByTimeAsync(LEASE - INTERVAL - 1); + expect(stopped).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + await stopping; + expect(heartbeat.signal.reason).toBeInstanceOf(UpdateCheckLeaseLostError); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/packages/vitnode/src/api/lib/updates/heartbeat.ts b/packages/vitnode/src/api/lib/updates/heartbeat.ts new file mode 100644 index 0000000000..0352b63420 --- /dev/null +++ b/packages/vitnode/src/api/lib/updates/heartbeat.ts @@ -0,0 +1,98 @@ +import type { drizzle } from "drizzle-orm/postgres-js"; + +import { + renewUpdateCheckLease, + UPDATE_CHECK_LEASE_MS, + UPDATE_CHECK_RENEW_INTERVAL_MS, + UpdateCheckLeaseLostError, +} from "./lock"; + +type Db = ReturnType; + +interface UpdateCheckHeartbeat { + signal: AbortSignal; + stop: () => Promise; +} + +export const startUpdateCheckHeartbeat = ({ + confirmedAt, + db, + owner, + renew = renewUpdateCheckLease, +}: { + confirmedAt: number; + db: Db; + owner: string; + renew?: typeof renewUpdateCheckLease; +}): UpdateCheckHeartbeat => { + const controller = new AbortController(); + let stopped = false; + let beatTimer: ReturnType | undefined; + let deadlineTimer: ReturnType | undefined; + let pending: Promise = Promise.resolve(); + + const lose = (cause?: unknown) => { + clearTimeout(beatTimer); + clearTimeout(deadlineTimer); + if (controller.signal.aborted) return; + controller.abort( + new UpdateCheckLeaseLostError( + cause === undefined ? undefined : { cause }, + ), + ); + }; + + const armDeadline = (from: number) => { + clearTimeout(deadlineTimer); + deadlineTimer = setTimeout( + () => lose(), + Math.max(0, from + UPDATE_CHECK_LEASE_MS - Date.now()), + ); + }; + + const beat = async () => { + const sentAt = Date.now(); + try { + const renewal = await renew(db, owner); + if (renewal.status === "lost") { + lose(); + + return; + } + } catch (error) { + lose(error); + + return; + } + if (stopped || controller.signal.aborted) return; + + armDeadline(sentAt); + schedule(); + }; + + const schedule = () => { + beatTimer = setTimeout(() => { + pending = beat(); + }, UPDATE_CHECK_RENEW_INTERVAL_MS); + }; + + armDeadline(confirmedAt); + schedule(); + + return { + signal: controller.signal, + stop: async () => { + stopped = true; + clearTimeout(beatTimer); + if (!controller.signal.aborted) { + const aborted = new Promise(resolve => { + controller.signal.addEventListener("abort", () => resolve(), { + once: true, + }); + }); + await Promise.race([pending, aborted]); + } + clearTimeout(deadlineTimer); + }, + }; +}; diff --git a/packages/vitnode/src/api/lib/updates/lock.postgres.test.ts b/packages/vitnode/src/api/lib/updates/lock.postgres.test.ts new file mode 100644 index 0000000000..2932d939fe --- /dev/null +++ b/packages/vitnode/src/api/lib/updates/lock.postgres.test.ts @@ -0,0 +1,339 @@ +import { sql } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { randomUUID } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import postgres from "postgres"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +import type { UpdateManifest } from "@/lib/updates/manifest"; + +import { + core_update_lock, + core_update_results, + core_update_sources, +} from "@/database/updates"; + +import type { FetchUpdateSource } from "./check"; +import type { UpdateComponent } from "./components"; + +import { checkForUpdates } from "./check"; +import { UpdateSourceError } from "./fetch-source"; +import { + acquireUpdateCheckLease, + releaseUpdateCheckLease, + renewUpdateCheckLease, + UPDATE_CHECK_LEASE_MS, + UpdateCheckLeaseLostError, + withUpdateCheckLease, +} from "./lock"; + +type Db = ReturnType; + +const URL_ENV = process.env.VITNODE_TEST_POSTGRES_URL; + +const MIGRATION = resolve( + import.meta.dirname, + "../../../../../../apps/api/migrations/20261015193600_add_core_update_checks/migration.sql", +); + +const connectionOptions = (raw: string) => { + const url = new URL(raw.replace(/@\//, "@localhost/")); + const param = (name: string) => url.searchParams.get(name); + + return { + database: decodeURIComponent(url.pathname.slice(1)), + host: param("host") ?? decodeURIComponent(url.hostname), + password: url.password ? decodeURIComponent(url.password) : undefined, + port: Number(param("port") ?? (url.port || 5432)), + user: decodeURIComponent(url.username) || "postgres", + }; +}; + +const ACME = "https://acme.example.com/updates.json"; +const T1 = new Date("2026-10-14T03:00:00Z"); +const T2 = new Date("2026-10-15T03:00:00Z"); + +const BLOG: UpdateComponent = { + hasUnknownVersion: false, + id: "@acme/blog", + kind: "plugin", + sourceUrl: ACME, + versions: ["1.0.0"], +}; + +const manifest = (stable: string): UpdateManifest => ({ + packages: { + "@acme/blog": { + channels: { stable }, + releases: { [stable]: {} }, + securityUpdates: [], + }, + }, + schemaVersion: 1, +}); + +const answer = + (result: Awaited> | Error): FetchUpdateSource => + async () => + result instanceof Error + ? await Promise.reject(result) + : await Promise.resolve(result); + +const fetched = (stable: string, etag: string) => + answer({ body: manifest(stable), etag, kind: "fetched", lastModified: null }); + +const until = async (check: () => Promise, timeoutMs = 2000) => { + const deadline = Date.now() + timeoutMs; + while (!(await check())) { + if (Date.now() > deadline) throw new Error("condition never held"); + await new Promise(next => setTimeout(next, 5)); + } +}; + +describe.skipIf(!URL_ENV)("update check lease on PostgreSQL", () => { + const schema = `vitnode_lease_${randomUUID().replaceAll("-", "")}`; + let client: postgres.Sql; + let db: Db; + + const expire = async (owner: string) => { + await db.execute( + sql`update core_update_lock set "expiresAt" = timezone('utc', clock_timestamp()) - interval '1 second' where owner = ${owner}`, + ); + }; + + const stored = async () => ({ + results: await db + .select() + .from(core_update_results) + .orderBy(core_update_results.componentId), + sources: await db + .select() + .from(core_update_sources) + .orderBy(core_update_sources.url), + }); + + const owner = async () => + ( + await db.select({ owner: core_update_lock.owner }).from(core_update_lock) + )[0]?.owner; + + const check = async ( + leaseOwner: string, + components: UpdateComponent[], + fetchSource: FetchUpdateSource, + now = T1, + ) => + await checkForUpdates({ + channelOverride: null, + components, + coreVersion: "2.0.0", + db, + fetchSource, + lease: { owner: leaseOwner, signal: new AbortController().signal }, + now, + }); + + beforeAll(async () => { + const admin = postgres({ + ...connectionOptions(URL_ENV ?? ""), + max: 1, + onnotice: () => undefined, + }); + await admin.unsafe(`create schema "${schema}"`); + await admin.end(); + + client = postgres({ + ...connectionOptions(URL_ENV ?? ""), + connection: { search_path: schema }, + max: 6, + onnotice: () => undefined, + }); + db = drizzle({ client }); + for (const statement of readFileSync(MIGRATION, "utf8").split( + "--> statement-breakpoint", + )) { + if (statement.trim()) await client.unsafe(statement); + } + }); + + afterAll(async () => { + if (!client) return; + await client.unsafe(`drop schema if exists "${schema}" cascade`); + await client.end(); + }); + + beforeEach(async () => { + await client.unsafe( + "truncate core_update_lock, core_update_results, core_update_sources", + ); + }); + + it("lets exactly one of many concurrent instances take the lease", async () => { + const owners = ["a", "b", "c", "d", "e"]; + + const results = await Promise.all( + owners.map( + async name => + await acquireUpdateCheckLease(db, { manual: false, owner: name }), + ), + ); + + const winners = results.flatMap(result => + result.acquired ? [result.owner] : [], + ); + expect(winners).toHaveLength(1); + expect(results.filter(result => !result.acquired)).toEqual( + owners.slice(1).map(() => expect.objectContaining({ reason: "running" })), + ); + await expect(owner()).resolves.toBe(winners[0]); + }); + + it("times the lease by the database clock in UTC, whatever the server's time zone", async () => { + const before = Date.now(); + await acquireUpdateCheckLease(db, { manual: false, owner: "a" }); + + const [row] = await db + .select({ expiresAt: core_update_lock.expiresAt }) + .from(core_update_lock); + + expect(row?.expiresAt?.getTime()).toBeGreaterThanOrEqual( + before + UPDATE_CHECK_LEASE_MS - 1000, + ); + expect(row?.expiresAt?.getTime()).toBeLessThanOrEqual( + Date.now() + UPDATE_CHECK_LEASE_MS + 1000, + ); + }); + + it("renews only for the owner, and never once its lease ran out", async () => { + await acquireUpdateCheckLease(db, { manual: false, owner: "a" }); + + await expect(renewUpdateCheckLease(db, "b")).resolves.toEqual({ + status: "lost", + }); + await expect(renewUpdateCheckLease(db, "a")).resolves.toMatchObject({ + status: "renewed", + }); + + await expire("a"); + await expect(renewUpdateCheckLease(db, "a")).resolves.toEqual({ + status: "lost", + }); + }); + + it("holds a takeover back while the old owner's write is open, then fences the old owner", async () => { + const events: string[] = []; + await acquireUpdateCheckLease(db, { + leaseMs: 50, + manual: false, + owner: "a", + }); + let finishWrite = () => {}; + const writing = new Promise(resolveWrite => { + finishWrite = resolveWrite; + }); + let insideWrite = () => {}; + const inside = new Promise(resolveInside => { + insideWrite = resolveInside; + }); + + const oldWrite = withUpdateCheckLease(db, "a", async tx => { + await tx + .insert(core_update_sources) + .values({ lastAttemptAt: T1, url: ACME }); + insideWrite(); + await writing; + }).then(() => events.push("a committed")); + let takeover: ReturnType | undefined; + try { + await inside; + await until(async () => { + const rows = await db.execute<{ expired: boolean }>( + sql`select "expiresAt" <= timezone('utc', clock_timestamp()) as expired from core_update_lock`, + ); + + return rows.some(row => row.expired); + }); + + takeover = acquireUpdateCheckLease(db, { + manual: false, + owner: "b", + }).then(result => { + events.push("b acquired"); + + return result; + }); + await until(async () => { + const [row] = await db.execute<{ waiting: number }>( + sql`select count(*)::int as waiting from pg_locks where locktype = 'advisory' and not granted`, + ); + + return row?.waiting === 1; + }); + expect(events).toEqual([]); + } finally { + finishWrite(); + } + await oldWrite; + await expect(takeover).resolves.toEqual({ acquired: true, owner: "b" }); + expect(events).toEqual(["a committed", "b acquired"]); + + await expect( + withUpdateCheckLease(db, "a", async tx => { + await tx.delete(core_update_sources); + }), + ).rejects.toBeInstanceOf(UpdateCheckLeaseLostError); + expect((await stored()).sources).toMatchObject([{ url: ACME }]); + }); + + it.each([ + ["a fresh copy of a source", [BLOG], fetched("1.2.0", '"v2"')], + ["a failed request", [BLOG], answer(new UpdateSourceError("timeout"))], + ["the clean-up", [{ ...BLOG, sourceUrl: null }], answer(new Error("no"))], + ])( + "refuses %s from an owner whose lease was taken over", + async (_, components, fetchSource) => { + await acquireUpdateCheckLease(db, { manual: false, owner: "a" }); + await check("a", [BLOG], fetched("1.1.0", '"v1"')); + const before = await stored(); + await expire("a"); + await acquireUpdateCheckLease(db, { manual: false, owner: "b" }); + + await expect( + check("a", components, fetchSource, T2), + ).rejects.toBeInstanceOf(UpdateCheckLeaseLostError); + await expect(stored()).resolves.toEqual(before); + }, + ); + + it("lets the owner that holds the lease write and clean up as usual", async () => { + await acquireUpdateCheckLease(db, { manual: false, owner: "b" }); + await check("b", [BLOG], fetched("1.1.0", '"v1"')); + await check("b", [BLOG], fetched("1.2.0", '"v2"'), T2); + + expect(await stored()).toMatchObject({ + results: [ + { + componentId: "@acme/blog", + lastSuccessAt: T2, + result: { latest: { version: "1.2.0" }, status: "update_available" }, + }, + ], + sources: [{ etag: '"v2"', lastSuccessAt: T2, url: ACME }], + }); + + await check("b", [{ ...BLOG, sourceUrl: null }], answer(new Error("no"))); + await expect(stored()).resolves.toEqual({ results: [], sources: [] }); + }); + + it("never frees a replacement owner's lease", async () => { + await acquireUpdateCheckLease(db, { manual: false, owner: "a" }); + await expire("a"); + await acquireUpdateCheckLease(db, { manual: false, owner: "b" }); + + await expect(releaseUpdateCheckLease(db, "a")).resolves.toBe(false); + await expect(owner()).resolves.toBe("b"); + await expect(releaseUpdateCheckLease(db, "b")).resolves.toBe(true); + await expect(owner()).resolves.toBeNull(); + }); +}); diff --git a/packages/vitnode/src/api/lib/updates/lock.test.ts b/packages/vitnode/src/api/lib/updates/lock.test.ts index 6cc03d8cd8..271bf75acd 100644 --- a/packages/vitnode/src/api/lib/updates/lock.test.ts +++ b/packages/vitnode/src/api/lib/updates/lock.test.ts @@ -1,8 +1,8 @@ import type { drizzle } from "drizzle-orm/postgres-js"; -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { core_update_lock } from "@/database/updates"; +import { core_update_lock, core_update_sources } from "@/database/updates"; import { createMemoryDb } from "@/tests/memory-db"; import { @@ -10,7 +10,10 @@ import { MANUAL_UPDATE_CHECK_COOLDOWN_MS, readUpdateCheckLock, releaseUpdateCheckLease, + renewUpdateCheckLease, UPDATE_CHECK_LEASE_MS, + UpdateCheckLeaseLostError, + withUpdateCheckLease, } from "./lock"; type Db = ReturnType; @@ -18,20 +21,32 @@ type Db = ReturnType; const T0 = new Date("2026-10-15T12:00:00Z"); const at = (ms: number) => new Date(T0.getTime() + ms); +const clockAt = (ms: number) => { + vi.setSystemTime(at(ms)); +}; + const memoryDb = () => { const memory = createMemoryDb(); return { ...memory, db: memory.db as unknown as Db }; }; +const take = async (db: Db, owner: string, manual = false) => + await acquireUpdateCheckLease(db, { manual, owner }); + +beforeEach(() => { + vi.useFakeTimers({ now: T0, toFake: ["Date"] }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + describe("acquireUpdateCheckLease", () => { it("lets only one of two racing checks take the lease", async () => { const { db } = memoryDb(); - const results = await Promise.all([ - acquireUpdateCheckLease(db, { manual: false, now: T0, owner: "a" }), - acquireUpdateCheckLease(db, { manual: false, now: T0, owner: "b" }), - ]); + const results = await Promise.all([take(db, "a"), take(db, "b")]); expect(results.filter(result => result.acquired)).toHaveLength(1); expect(results.find(result => !result.acquired)).toEqual({ @@ -43,78 +58,68 @@ describe("acquireUpdateCheckLease", () => { it("takes over a lease whose holder died and let it expire", async () => { const { db } = memoryDb(); - await acquireUpdateCheckLease(db, { manual: false, now: T0, owner: "a" }); - - await expect( - acquireUpdateCheckLease(db, { - manual: false, - now: at(UPDATE_CHECK_LEASE_MS), - owner: "b", - }), - ).resolves.toEqual({ acquired: true, owner: "b" }); + await take(db, "a"); + + clockAt(UPDATE_CHECK_LEASE_MS - 1); + await expect(take(db, "b")).resolves.toMatchObject({ acquired: false }); + clockAt(UPDATE_CHECK_LEASE_MS); + await expect(take(db, "b")).resolves.toEqual({ + acquired: true, + owner: "b", + }); }); it("is free again once the holder gives it back", async () => { const { db } = memoryDb(); - await acquireUpdateCheckLease(db, { manual: false, now: T0, owner: "a" }); + await take(db, "a"); await releaseUpdateCheckLease(db, "a"); - await expect( - acquireUpdateCheckLease(db, { manual: false, now: at(1000), owner: "b" }), - ).resolves.toEqual({ acquired: true, owner: "b" }); + clockAt(1000); + await expect(take(db, "b")).resolves.toEqual({ + acquired: true, + owner: "b", + }); }); it("keeps manual checks apart by the cooldown, but not the daily one", async () => { const { db } = memoryDb(); - await acquireUpdateCheckLease(db, { manual: true, now: T0, owner: "a" }); + await take(db, "a", true); await releaseUpdateCheckLease(db, "a"); - await expect( - acquireUpdateCheckLease(db, { - manual: true, - now: at(30_000), - owner: "b", - }), - ).resolves.toEqual({ + clockAt(30_000); + await expect(take(db, "b", true)).resolves.toEqual({ acquired: false, reason: "cooldown", retryAfterSeconds: MANUAL_UPDATE_CHECK_COOLDOWN_MS / 1000 - 30, }); - await expect( - acquireUpdateCheckLease(db, { - manual: false, - now: at(30_000), - owner: "c", - }), - ).resolves.toEqual({ acquired: true, owner: "c" }); + await expect(take(db, "c")).resolves.toEqual({ + acquired: true, + owner: "c", + }); }); it("allows a manual check again once the cooldown is over", async () => { const { db } = memoryDb(); - await acquireUpdateCheckLease(db, { manual: true, now: T0, owner: "a" }); + await take(db, "a", true); await releaseUpdateCheckLease(db, "a"); - await expect( - acquireUpdateCheckLease(db, { - manual: true, - now: at(MANUAL_UPDATE_CHECK_COOLDOWN_MS), - owner: "b", - }), - ).resolves.toEqual({ acquired: true, owner: "b" }); + clockAt(MANUAL_UPDATE_CHECK_COOLDOWN_MS); + await expect(take(db, "b", true)).resolves.toEqual({ + acquired: true, + owner: "b", + }); }); it("does not let the daily check reset the manual cooldown", async () => { const { db } = memoryDb(); - await acquireUpdateCheckLease(db, { manual: true, now: T0, owner: "a" }); + await take(db, "a", true); await releaseUpdateCheckLease(db, "a"); - await acquireUpdateCheckLease(db, { - manual: false, - now: at(10_000), - owner: "b", - }); + clockAt(10_000); + await take(db, "b"); await releaseUpdateCheckLease(db, "b"); - await expect(readUpdateCheckLock(db, at(20_000))).resolves.toEqual({ + clockAt(20_000); + await expect(readUpdateCheckLock(db)).resolves.toEqual({ lastManualAt: T0, running: false, }); @@ -122,39 +127,153 @@ describe("acquireUpdateCheckLease", () => { it("reports a running check to a manual request still in cooldown", async () => { const { db } = memoryDb(); - await acquireUpdateCheckLease(db, { manual: true, now: T0, owner: "a" }); + await take(db, "a", true); + + clockAt(1000); + await expect(take(db, "b", true)).resolves.toMatchObject({ + acquired: false, + reason: "running", + }); + }); +}); + +describe("renewUpdateCheckLease", () => { + it("pushes the holder's lease out from the database's now", async () => { + const { db } = memoryDb(); + await take(db, "a"); + + clockAt(UPDATE_CHECK_LEASE_MS - 1000); + await expect(renewUpdateCheckLease(db, "a")).resolves.toEqual({ + status: "renewed", + }); + + clockAt(UPDATE_CHECK_LEASE_MS + 1000); + await expect(take(db, "b")).resolves.toMatchObject({ + acquired: false, + reason: "running", + }); + }); - await expect( - acquireUpdateCheckLease(db, { manual: true, now: at(1000), owner: "b" }), - ).resolves.toMatchObject({ acquired: false, reason: "running" }); + it("never renews someone else's lease", async () => { + const { db, rows } = memoryDb(); + await take(db, "a"); + + await expect(renewUpdateCheckLease(db, "b")).resolves.toEqual({ + status: "lost", + }); + expect(rows(core_update_lock)).toMatchObject([ + { expiresAt: at(UPDATE_CHECK_LEASE_MS), owner: "a" }, + ]); + }); + + it("refuses an owner whose lease ran out, even before anyone took it over", async () => { + const { db, rows } = memoryDb(); + await take(db, "a"); + + clockAt(UPDATE_CHECK_LEASE_MS); + await expect(renewUpdateCheckLease(db, "a")).resolves.toEqual({ + status: "lost", + }); + expect(rows(core_update_lock)).toMatchObject([ + { expiresAt: at(UPDATE_CHECK_LEASE_MS), owner: "a" }, + ]); + }); +}); + +describe("withUpdateCheckLease", () => { + const writeSource = async (db: Db, owner: string) => + await withUpdateCheckLease(db, owner, async tx => { + await tx.insert(core_update_sources).values({ + lastAttemptAt: T0, + url: "https://acme.example.com/updates.json", + }); + + return "written"; + }); + + it("runs the write for the holder", async () => { + const { db, rows } = memoryDb(); + await take(db, "a"); + + await expect(writeSource(db, "a")).resolves.toBe("written"); + expect(rows(core_update_sources)).toHaveLength(1); + }); + + it.each([ + ["nobody holds the lease", async () => {}], + [ + "another instance holds it", + async (db: Db) => { + await take(db, "b"); + }, + ], + [ + "its own lease ran out", + async (db: Db) => { + await take(db, "a"); + clockAt(UPDATE_CHECK_LEASE_MS); + }, + ], + [ + "another instance took it over after expiry", + async (db: Db) => { + await take(db, "a"); + clockAt(UPDATE_CHECK_LEASE_MS); + await take(db, "b"); + }, + ], + ])("writes nothing when %s", async (_, prepare) => { + const { db, rows } = memoryDb(); + await prepare(db); + const write = vi.fn(); + + await expect(withUpdateCheckLease(db, "a", write)).rejects.toBeInstanceOf( + UpdateCheckLeaseLostError, + ); + expect(write).not.toHaveBeenCalled(); + expect(rows(core_update_sources)).toEqual([]); }); }); describe("releaseUpdateCheckLease", () => { it("only lets the holder give the lease back", async () => { const { db } = memoryDb(); - await acquireUpdateCheckLease(db, { manual: false, now: T0, owner: "a" }); + await take(db, "a"); + clockAt(1000); await expect(releaseUpdateCheckLease(db, "b")).resolves.toBe(false); - await expect(readUpdateCheckLock(db, at(1000))).resolves.toMatchObject({ + await expect(readUpdateCheckLock(db)).resolves.toMatchObject({ running: true, }); await expect(releaseUpdateCheckLease(db, "a")).resolves.toBe(true); - await expect(readUpdateCheckLock(db, at(1000))).resolves.toMatchObject({ + await expect(readUpdateCheckLock(db)).resolves.toMatchObject({ running: false, }); }); it("cannot give back a lease another instance took over after expiry", async () => { const { db, rows } = memoryDb(); - await acquireUpdateCheckLease(db, { manual: false, now: T0, owner: "a" }); - await acquireUpdateCheckLease(db, { - manual: false, - now: at(UPDATE_CHECK_LEASE_MS + 1), - owner: "b", - }); + await take(db, "a"); + clockAt(UPDATE_CHECK_LEASE_MS + 1); + await take(db, "b"); await expect(releaseUpdateCheckLease(db, "a")).resolves.toBe(false); expect(rows(core_update_lock)).toMatchObject([{ owner: "b" }]); }); }); + +describe("readUpdateCheckLock", () => { + it("stops reporting a check as running once its lease ran out", async () => { + const { db } = memoryDb(); + await take(db, "a"); + + clockAt(UPDATE_CHECK_LEASE_MS - 1); + await expect(readUpdateCheckLock(db)).resolves.toMatchObject({ + running: true, + }); + clockAt(UPDATE_CHECK_LEASE_MS); + await expect(readUpdateCheckLock(db)).resolves.toMatchObject({ + running: false, + }); + }); +}); diff --git a/packages/vitnode/src/api/lib/updates/lock.ts b/packages/vitnode/src/api/lib/updates/lock.ts index 83fe836965..6ca68ba315 100644 --- a/packages/vitnode/src/api/lib/updates/lock.ts +++ b/packages/vitnode/src/api/lib/updates/lock.ts @@ -1,34 +1,88 @@ import type { drizzle } from "drizzle-orm/postgres-js"; -import { and, eq, isNull, lte, or, sql } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import { randomUUID } from "node:crypto"; import { core_update_lock } from "@/database/updates"; type Db = ReturnType; +export type UpdateCheckTx = Parameters[0]>[0]; + const LOCK_NAME = "check"; -/** Longer than a check can take: every source has a 10 s timeout and they run four at a time. */ +const ADVISORY_KEY = `core_update_lock:${LOCK_NAME}`; + export const UPDATE_CHECK_LEASE_MS = 5 * 60_000; +export const UPDATE_CHECK_RENEW_INTERVAL_MS = 60_000; + export const MANUAL_UPDATE_CHECK_COOLDOWN_MS = 2 * 60_000; +export class UpdateCheckLeaseLostError extends Error { + constructor(options?: ErrorOptions) { + super("This instance no longer holds the update check lease", options); + this.name = "UpdateCheckLeaseLostError"; + } +} + export type UpdateCheckLease = | { acquired: false; reason: "cooldown"; retryAfterSeconds: number } | { acquired: false; reason: "running"; retryAfterSeconds: number } | { acquired: true; owner: string }; -const secondsUntil = (date: Date | null, now: Date): number => - date === null - ? 1 - : Math.max(1, Math.ceil((date.getTime() - now.getTime()) / 1000)); +export interface UpdateCheckLeaseRenewal { + status: "lost" | "renewed"; +} + +const databaseNow = () => + sql`timezone('utc', clock_timestamp())`.mapWith(core_update_lock.expiresAt); + +const secondsUntil = (date: Date, now: Date): number => + Math.max(1, Math.ceil((date.getTime() - now.getTime()) / 1000)); + +const lockLease = async (tx: UpdateCheckTx, { create = false } = {}) => { + await tx.execute( + sql`select pg_advisory_xact_lock(hashtext(${ADVISORY_KEY}))`, + ); + if (create) { + await tx + .insert(core_update_lock) + .values({ name: LOCK_NAME }) + .onConflictDoNothing({ target: core_update_lock.name }); + } + + const [row] = await tx + .select({ + expiresAt: core_update_lock.expiresAt, + lastManualAt: core_update_lock.lastManualAt, + now: databaseNow(), + owner: core_update_lock.owner, + }) + .from(core_update_lock) + .where(eq(core_update_lock.name, LOCK_NAME)) + .for("update"); + + return row ?? null; +}; + +type LeaseRow = Awaited>; + +type HeldLeaseRow = NonNullable & { expiresAt: Date; owner: string }; + +const isHeld = (row: LeaseRow): row is HeldLeaseRow => + row !== null && + row.owner !== null && + row.expiresAt !== null && + row.expiresAt > row.now; + +const isHeldBy = (row: LeaseRow, owner: string): boolean => + isHeld(row) && row.owner === owner; /** - * One statement, so two instances racing for the lease cannot both win: the - * insert creates the row, and the conflict branch only takes it over when no - * one holds it or the holder's lease ran out. A manual check also has to be - * past the cooldown, and stamps it in the same statement. + * Two instances racing for the lease cannot both win: the lease lock orders + * them, and the second sees the first one's lease. A manual check also has to + * be past the cooldown, and stamps it in the same transaction. */ export const acquireUpdateCheckLease = async ( db: Db, @@ -36,96 +90,105 @@ export const acquireUpdateCheckLease = async ( cooldownMs = MANUAL_UPDATE_CHECK_COOLDOWN_MS, leaseMs = UPDATE_CHECK_LEASE_MS, manual, - now = new Date(), owner = randomUUID(), }: { cooldownMs?: number; leaseMs?: number; manual: boolean; - now?: Date; owner?: string; }, -): Promise => { - const expiresAt = new Date(now.getTime() + leaseMs); - const free = or( - isNull(core_update_lock.owner), - isNull(core_update_lock.expiresAt), - lte(core_update_lock.expiresAt, now), - ); - const cooledDown = or( - isNull(core_update_lock.lastManualAt), - lte(core_update_lock.lastManualAt, new Date(now.getTime() - cooldownMs)), - ); - - const [taken] = await db - .insert(core_update_lock) - .values({ - expiresAt, - lastManualAt: manual ? now : null, - name: LOCK_NAME, - owner, - }) - .onConflictDoUpdate({ - set: { - expiresAt, - lastManualAt: manual ? now : sql`${core_update_lock.lastManualAt}`, +): Promise => + await db.transaction(async tx => { + const row = await lockLease(tx, { create: true }); + if (row === null) throw new Error("The update check lease row is missing"); + const { now } = row; + + if (isHeld(row)) { + return { + acquired: false, + reason: "running", + retryAfterSeconds: secondsUntil(row.expiresAt, now), + }; + } + + const cooledDownAt = + row.lastManualAt === null + ? null + : new Date(row.lastManualAt.getTime() + cooldownMs); + if (manual && cooledDownAt !== null && cooledDownAt > now) { + return { + acquired: false, + reason: "cooldown", + retryAfterSeconds: secondsUntil(cooledDownAt, now), + }; + } + + await tx + .update(core_update_lock) + .set({ + expiresAt: new Date(now.getTime() + leaseMs), owner, - }, - setWhere: manual ? and(free, cooledDown) : free, - target: core_update_lock.name, - }) - .returning({ owner: core_update_lock.owner }); - - if (taken?.owner === owner) return { acquired: true, owner }; + ...(manual ? { lastManualAt: now } : {}), + }) + .where(eq(core_update_lock.name, LOCK_NAME)); - const [row] = await db - .select() - .from(core_update_lock) - .where(eq(core_update_lock.name, LOCK_NAME)); + return { acquired: true, owner }; + }); - if ( - row?.owner !== null && - row?.owner !== undefined && - row.expiresAt !== null && - row.expiresAt > now - ) { - return { - acquired: false, - reason: "running", - retryAfterSeconds: secondsUntil(row.expiresAt, now), - }; - } +export const renewUpdateCheckLease = async ( + db: Db, + owner: string, +): Promise => + await db.transaction(async tx => { + const row = await lockLease(tx); + if (!isHeld(row) || row.owner !== owner) return { status: "lost" }; + + await tx + .update(core_update_lock) + .set({ expiresAt: new Date(row.now.getTime() + UPDATE_CHECK_LEASE_MS) }) + .where( + and( + eq(core_update_lock.name, LOCK_NAME), + eq(core_update_lock.owner, owner), + ), + ); + + return { status: "renewed" }; + }); + +export const withUpdateCheckLease = async ( + db: Db, + owner: string, + write: (tx: UpdateCheckTx) => Promise, +): Promise => + await db.transaction(async tx => { + const row = await lockLease(tx); + if (!isHeldBy(row, owner)) throw new UpdateCheckLeaseLostError(); - return { - acquired: false, - reason: "cooldown", - retryAfterSeconds: secondsUntil( - row?.lastManualAt - ? new Date(row.lastManualAt.getTime() + cooldownMs) - : null, - now, - ), - }; -}; + return await write(tx); + }); /** Only the instance that holds the lease can give it back. */ export const releaseUpdateCheckLease = async ( db: Db, owner: string, -): Promise => { - const released = await db - .update(core_update_lock) - .set({ expiresAt: null, owner: null }) - .where( - and( - eq(core_update_lock.name, LOCK_NAME), - eq(core_update_lock.owner, owner), - ), - ) - .returning({ name: core_update_lock.name }); - - return released.length > 0; -}; +): Promise => + await db.transaction(async tx => { + const row = await lockLease(tx); + if (row?.owner !== owner) return false; + + await tx + .update(core_update_lock) + .set({ expiresAt: null, owner: null }) + .where( + and( + eq(core_update_lock.name, LOCK_NAME), + eq(core_update_lock.owner, owner), + ), + ); + + return true; + }); export interface UpdateCheckLockState { lastManualAt: Date | null; @@ -134,19 +197,19 @@ export interface UpdateCheckLockState { export const readUpdateCheckLock = async ( db: Db, - now: Date = new Date(), ): Promise => { const [row] = await db - .select() + .select({ + expiresAt: core_update_lock.expiresAt, + lastManualAt: core_update_lock.lastManualAt, + now: databaseNow(), + owner: core_update_lock.owner, + }) .from(core_update_lock) .where(eq(core_update_lock.name, LOCK_NAME)); return { lastManualAt: row?.lastManualAt ?? null, - running: - row?.owner !== null && - row?.owner !== undefined && - row.expiresAt !== null && - row.expiresAt > now, + running: isHeld(row ?? null), }; }; diff --git a/packages/vitnode/src/api/lib/updates/status.test.ts b/packages/vitnode/src/api/lib/updates/status.test.ts index c99c25dccf..2b5fdc05fc 100644 --- a/packages/vitnode/src/api/lib/updates/status.test.ts +++ b/packages/vitnode/src/api/lib/updates/status.test.ts @@ -1,17 +1,19 @@ import type { drizzle } from "drizzle-orm/postgres-js"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { UpdateChannel, UpdateManifest } from "@/lib/updates/manifest"; import { createMemoryDb } from "@/tests/memory-db"; import type { UpdateComponent } from "./components"; +import type { UpdateEvaluation } from "./evaluate"; +import type { ComponentUpdateState, UpdatesOverview } from "./status"; import { checkForUpdates } from "./check"; import { UpdateSourceError } from "./fetch-source"; -import { acquireUpdateCheckLease } from "./lock"; -import { loadUpdatesOverview } from "./status"; +import { acquireUpdateCheckLease, releaseUpdateCheckLease } from "./lock"; +import { loadUpdatesOverview, summarizeUpdates } from "./status"; type Db = ReturnType; @@ -46,14 +48,28 @@ const blog = (overrides: Partial = {}): UpdateComponent => ({ ...overrides, }); +const OWNER = "check"; + +const leased = async ( + db: Db, + options: Omit[0], "db" | "lease">, +) => { + await acquireUpdateCheckLease(db, { manual: false, owner: OWNER }); + await checkForUpdates({ + ...options, + db, + lease: { owner: OWNER, signal: new AbortController().signal }, + }); + await releaseUpdateCheckLease(db, OWNER); +}; + /** A database holding one successful check of `blog()` on core 2.0.0. */ const checked = async () => { const db = createMemoryDb().db as unknown as Db; - await checkForUpdates({ + await leased(db, { channelOverride: null, components: [blog()], coreVersion: "2.0.0", - db, fetchSource: async () => await Promise.resolve({ body: MANIFEST, @@ -87,10 +103,13 @@ const overview = async ( coreVersion, db, enabled, - now: T2, }); describe("loadUpdatesOverview", () => { + afterEach(() => { + vi.useRealTimers(); + }); + it("shows the stored result while nothing changed", async () => { const db = await checked(); @@ -163,11 +182,10 @@ describe("loadUpdatesOverview", () => { it("keeps the previous result next to a failed check", async () => { const db = await checked(); - await checkForUpdates({ + await leased(db, { channelOverride: null, components: [blog()], coreVersion: "2.0.0", - db, fetchSource: async () => await Promise.reject(new UpdateSourceError("timeout")), now: T2, @@ -214,7 +232,8 @@ describe("loadUpdatesOverview", () => { it("reports a running check and the last manual one", async () => { const db = await checked(); - await acquireUpdateCheckLease(db, { manual: true, now: T2, owner: "a" }); + vi.useFakeTimers({ now: T2, toFake: ["Date"] }); + await acquireUpdateCheckLease(db, { manual: true, owner: "a" }); await expect(overview(db)).resolves.toMatchObject({ lastManualAt: T2, @@ -222,3 +241,134 @@ describe("loadUpdatesOverview", () => { }); }); }); + +describe("summarizeUpdates", () => { + const advisory = (id: string) => ({ + affected: "<9.0.0", + fixed: "9.0.0", + id, + url: `https://example.com/advisories/${id}`, + versions: ["1.0.0"], + }); + + const evaluation = ( + status: UpdateEvaluation["status"], + securityUpdates: UpdateEvaluation["securityUpdates"] = [], + latest: null | string = status === "update_available" ? "9.0.0" : null, + ): UpdateEvaluation => ({ + alternative: null, + channel: "stable", + compatibility: null, + latest: + latest === null + ? null + : { publishedAt: null, requiresCore: null, url: null, version: latest }, + securityUpdates, + status, + }); + + const state = ( + id: string, + overrides: Partial = {}, + ): ComponentUpdateState => ({ + id, + lastAttemptAt: T1, + lastError: null, + lastSuccessAt: T1, + result: evaluation("up_to_date"), + sourceUrl: ACME, + stale: false, + state: "checked", + ...overrides, + }); + + const summarize = ( + components: ComponentUpdateState[], + overrides: Partial = {}, + ) => + summarizeUpdates({ + channelOverride: null, + components, + enabled: true, + lastManualAt: null, + running: false, + ...overrides, + }); + + it("counts each component once, whatever it has to report", () => { + const summary = summarize([ + state("@vitnode/core", { + result: evaluation("update_available", [advisory("CORE-1")], "2.1.0"), + }), + state("@acme/blog", { + result: evaluation("update_available", [ + advisory("ACME-1"), + advisory("ACME-2"), + ]), + }), + state("@acme/forum", { result: evaluation("update_available") }), + state("@acme/wiki"), + ]); + + expect(summary).toMatchObject({ + affectedComponents: 2, + checkedComponents: 4, + core: { latestVersion: "2.1.0" }, + pluginUpdatesAvailable: 2, + unconfirmedComponents: 0, + updatesAvailable: 3, + }); + }); + + it("tells a stale, failed or never checked component from a confirmed one", () => { + const summary = summarize([ + state("@vitnode/core", { lastAttemptAt: T2 }), + state("@acme/blog", { + lastAttemptAt: T2, + lastError: { code: "timeout", detail: null }, + result: evaluation("update_available", [advisory("ACME-1")]), + }), + state("@acme/forum", { stale: true }), + state("@acme/wiki", { + lastAttemptAt: null, + lastSuccessAt: null, + result: null, + state: "not_checked", + }), + state("@acme/local", { + lastAttemptAt: null, + lastSuccessAt: null, + result: null, + sourceUrl: null, + state: "unavailable", + }), + state("@acme/odd", { result: evaluation("version_unknown") }), + ]); + + expect(summary).toMatchObject({ + affectedComponents: 1, + checkedComponents: 4, + core: { latestVersion: null }, + failedComponents: 1, + lastAttemptAt: T2, + notCheckedComponents: 1, + oldestSuccessAt: null, + stale: true, + unconfirmedComponents: 3, + }); + }); + + it("dates the summary by the oldest success once every checkable component has one", () => { + const summary = summarize([ + state("@vitnode/core", { lastSuccessAt: T2 }), + state("@acme/blog", { lastSuccessAt: T1 }), + state("@acme/local", { + lastSuccessAt: null, + result: null, + state: "unavailable", + }), + ]); + + expect(summary.oldestSuccessAt).toEqual(T1); + }); +}); diff --git a/packages/vitnode/src/api/lib/updates/status.ts b/packages/vitnode/src/api/lib/updates/status.ts index c8b1ac1d5a..d5cf37e4a0 100644 --- a/packages/vitnode/src/api/lib/updates/status.ts +++ b/packages/vitnode/src/api/lib/updates/status.ts @@ -53,16 +53,14 @@ export const loadUpdatesOverview = async ({ coreVersion, db, enabled, - now = new Date(), }: { channelOverride: null | UpdateChannel; components: readonly UpdateComponent[]; coreVersion: null | string; db: Db; enabled: boolean; - now?: Date; }): Promise => { - const lock = await readUpdateCheckLock(db, now); + const lock = await readUpdateCheckLock(db); const unavailable = (component: UpdateComponent): ComponentUpdateState => ({ id: component.id, lastAttemptAt: null, @@ -149,3 +147,75 @@ export const loadUpdatesOverview = async ({ running: lock.running, }; }; + +export interface UpdatesSummary { + affectedComponents: number; + checkedComponents: number; + core: { latestVersion: null | string }; + enabled: boolean; + failedComponents: number; + lastAttemptAt: Date | null; + notCheckedComponents: number; + oldestSuccessAt: Date | null; + pluginUpdatesAvailable: number; + running: boolean; + stale: boolean; + unconfirmedComponents: number; + updatesAvailable: number; +} + +const isUpdateAvailable = (component: ComponentUpdateState): boolean => + component.result?.status === "update_available"; + +export const summarizeUpdates = (overview: UpdatesOverview): UpdatesSummary => { + const { components } = overview; + const [core, ...plugins] = components; + const checkable = components.filter( + component => component.state !== "unavailable", + ); + const successes = checkable.flatMap(component => + component.lastSuccessAt === null ? [] : [component.lastSuccessAt], + ); + const attempts = components.flatMap(component => + component.lastAttemptAt === null ? [] : [component.lastAttemptAt], + ); + + return { + affectedComponents: components.filter( + component => (component.result?.securityUpdates.length ?? 0) > 0, + ).length, + checkedComponents: components.filter( + component => component.state === "checked", + ).length, + core: { + latestVersion: + core !== undefined && isUpdateAvailable(core) + ? (core.result?.latest?.version ?? null) + : null, + }, + enabled: overview.enabled, + failedComponents: components.filter( + component => component.lastError !== null, + ).length, + lastAttemptAt: + attempts.length === 0 + ? null + : new Date(Math.max(...attempts.map(date => date.getTime()))), + notCheckedComponents: components.filter( + component => component.state === "not_checked", + ).length, + oldestSuccessAt: + successes.length === 0 || successes.length !== checkable.length + ? null + : new Date(Math.min(...successes.map(date => date.getTime()))), + pluginUpdatesAvailable: plugins.filter(isUpdateAvailable).length, + running: overview.running, + stale: components.some(component => component.stale), + unconfirmedComponents: components.filter( + component => + component.result?.status !== "up_to_date" && + component.result?.status !== "update_available", + ).length, + updatesAvailable: components.filter(isUpdateAvailable).length, + }; +}; diff --git a/packages/vitnode/src/api/modules/admin/plugins/plugins.admin.module.ts b/packages/vitnode/src/api/modules/admin/plugins/plugins.admin.module.ts index 837591c3d7..cfcf58730c 100644 --- a/packages/vitnode/src/api/modules/admin/plugins/plugins.admin.module.ts +++ b/packages/vitnode/src/api/modules/admin/plugins/plugins.admin.module.ts @@ -2,6 +2,7 @@ import { buildModule } from "@/api/builders"; import { checkPluginUpdatesAdminRoute } from "./routes/check-updates.route"; import { listPluginsAdminRoute } from "./routes/list.route"; +import { pluginUpdatesSummaryAdminRoute } from "./routes/updates-summary.route"; import { pluginUpdatesAdminRoute } from "./routes/updates.route"; export const pluginsAdminModule = buildModule({ @@ -9,6 +10,7 @@ export const pluginsAdminModule = buildModule({ routes: [ listPluginsAdminRoute, pluginUpdatesAdminRoute, + pluginUpdatesSummaryAdminRoute, checkPluginUpdatesAdminRoute, ], }); diff --git a/packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.test.ts b/packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.test.ts index 12f95b6e95..2d15752c5d 100644 --- a/packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.test.ts +++ b/packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.test.ts @@ -10,6 +10,7 @@ import { acquireUpdateCheckLease, releaseUpdateCheckLease, } from "@/api/lib/updates/lock"; +import { core_update_lock } from "@/database/updates"; import { createTestCache } from "@/tests/cache"; import { createMemoryDb } from "@/tests/memory-db"; import { grantStaffPermissions } from "@/tests/staff-permissions"; @@ -34,11 +35,16 @@ const checkAs = async ( { enabled = true, prepare, - }: { enabled?: boolean; prepare?: (db: Db) => Promise } = {}, + wrap = db => db, + }: { + enabled?: boolean; + prepare?: (db: Db) => Promise; + wrap?: (db: Db) => Db; + } = {}, ) => { const cache = createTestCache(); await grantStaffPermissions(cache, { permissions, userId: ADMIN.id }); - const db = createMemoryDb().db as unknown as Db; + const db = wrap(createMemoryDb().db as unknown as Db); await prepare?.(db); const app = new OpenAPIHono(); @@ -124,6 +130,41 @@ describe("POST /admin/plugins/updates/check", () => { expect(Number(response.headers.get("Retry-After"))).toBeGreaterThan(0); }); + it("answers 409 when the check lost its lease midway", async () => { + const takenOverMidCheck = (db: Db): Db => { + let transactions = 0; + + return new Proxy(db, { + get: (target, property, receiver) => + property === "transaction" + ? async (...args: Parameters) => { + transactions += 1; + if (transactions === 2) { + const [lease] = await target + .select({ owner: core_update_lock.owner }) + .from(core_update_lock); + await releaseUpdateCheckLease(target, lease?.owner ?? ""); + await acquireUpdateCheckLease(target, { + manual: false, + owner: "other", + }); + } + + return await target.transaction(...args); + } + : (Reflect.get(target, property, receiver) as unknown), + }); + }; + + const response = await checkAs(CAN_CHECK, { wrap: takenOverMidCheck }); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + retryAfterSeconds: null, + status: "interrupted", + }); + }); + it("answers 409 while update checks are turned off", async () => { const response = await checkAs(CAN_CHECK, { enabled: false }); diff --git a/packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.ts b/packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.ts index 0acc48b643..dafcfed323 100644 --- a/packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.ts +++ b/packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.ts @@ -4,7 +4,7 @@ import { buildRoute } from "@/api/builders"; import { runUpdateCheckFor } from "@/api/lib/updates/context"; const busySchema = z.object({ - status: z.enum(["cooldown", "disabled", "running"]), + status: z.enum(["cooldown", "disabled", "interrupted", "running"]), retryAfterSeconds: z.number().nullable(), }); @@ -37,7 +37,8 @@ export const checkPluginUpdatesAdminRoute = buildRoute({ 403: { description: "Access Denied" }, 409: { content: { "application/json": { schema: busySchema } }, - description: "A check is already running, or checks are turned off", + description: + "A check is already running, checks are turned off, or the check stopped because this instance lost its lease", }, 429: { content: { "application/json": { schema: busySchema } }, @@ -59,6 +60,13 @@ export const checkPluginUpdatesAdminRoute = buildRoute({ return c.json({ ...outcome.summary, status: "completed" as const }, 200); } + if (outcome.status === "lease_lost") { + return c.json( + { retryAfterSeconds: null, status: "interrupted" as const }, + 409, + ); + } + c.header("Retry-After", String(outcome.retryAfterSeconds)); return outcome.reason === "running" diff --git a/packages/vitnode/src/api/modules/admin/plugins/routes/updates-summary.route.test.ts b/packages/vitnode/src/api/modules/admin/plugins/routes/updates-summary.route.test.ts new file mode 100644 index 0000000000..6eb8db063e --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/plugins/routes/updates-summary.route.test.ts @@ -0,0 +1,154 @@ +import type { drizzle } from "drizzle-orm/postgres-js"; +import type { Context } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { describe, expect, it } from "vitest"; + +import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; +import type { UpdateComponent } from "@/api/lib/updates/components"; + +import { checkForUpdates } from "@/api/lib/updates/check"; +import { + acquireUpdateCheckLease, + releaseUpdateCheckLease, +} from "@/api/lib/updates/lock"; +import { core_update_lock } from "@/database/updates"; +import { createTestCache } from "@/tests/cache"; +import { createMemoryDb } from "@/tests/memory-db"; +import { grantStaffPermissions } from "@/tests/staff-permissions"; + +import { pluginUpdatesSummaryAdminRoute } from "./updates-summary.route"; + +type Db = ReturnType; + +const ADMIN = { id: 1, roleId: 1 }; +const CORE_SOURCE = "https://vitnode.example.com/updates.json"; +const ACME_SOURCE = "https://acme.example.com/updates.json"; +const CHECKED_AT = new Date("2026-10-15T03:00:00Z"); + +const advisory = (id: string) => ({ + affected: "<1.1.0", + fixed: "1.1.0", + id, + url: `https://acme.example.com/advisories/${id}`, +}); + +const COMPONENTS: UpdateComponent[] = [ + { + hasUnknownVersion: false, + id: "@vitnode/core", + kind: "core", + sourceUrl: CORE_SOURCE, + versions: ["2.0.0"], + }, + { + hasUnknownVersion: false, + id: "@acme/blog", + kind: "plugin", + sourceUrl: ACME_SOURCE, + versions: ["1.0.1", "1.0.0"], + }, + { + hasUnknownVersion: false, + id: "@acme/local", + kind: "plugin", + sourceUrl: null, + versions: ["1.0.0"], + }, +]; + +const MANIFESTS: Record = { + [ACME_SOURCE]: { + packages: { + "@acme/blog": { + channels: { stable: "1.1.0" }, + releases: { "1.1.0": {} }, + securityUpdates: [advisory("ACME-1"), advisory("ACME-2")], + }, + }, + schemaVersion: 1, + }, + [CORE_SOURCE]: { + packages: { + "@vitnode/core": { + channels: { stable: "2.1.0" }, + releases: { "2.1.0": {} }, + }, + }, + schemaVersion: 1, + }, +}; + +const requestAs = async (permissions: PermissionsStaffArgs[]) => { + const cache = createTestCache(); + await grantStaffPermissions(cache, { permissions, userId: ADMIN.id }); + const memory = createMemoryDb(); + const db = memory.db as unknown as Db; + await acquireUpdateCheckLease(db, { manual: false, owner: "cron" }); + await checkForUpdates({ + channelOverride: null, + components: COMPONENTS, + coreVersion: "2.0.0", + db, + fetchSource: async url => + await Promise.resolve({ + body: MANIFESTS[url], + etag: null, + kind: "fetched" as const, + lastModified: null, + }), + lease: { owner: "cron", signal: new AbortController().signal }, + now: CHECKED_AT, + }); + await releaseUpdateCheckLease(db, "cron"); + const lockBefore = memory.rows(core_update_lock); + + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + c.set("admin", { user: ADMIN } as unknown as Context["var"]["admin"]); + c.set("cache", cache); + c.set("db", db); + c.set("core", { + updates: { + channelOverride: null, + components: COMPONENTS, + coreVersion: "2.0.0", + enabled: true, + }, + } as unknown as Context["var"]["core"]); + await next(); + }); + app.openapi( + pluginUpdatesSummaryAdminRoute.route, + pluginUpdatesSummaryAdminRoute.handler, + ); + + return { + lockBefore, + memory, + response: await app.request("/updates/summary"), + }; +}; + +describe("GET /admin/plugins/updates/summary", () => { + it("summarises the stored results without starting a check", async () => { + const { lockBefore, memory, response } = await requestAs([ + { module: "plugins", permission: "can_view", plugin: "@vitnode/core" }, + ]); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + core: { latestVersion: "2.1.0", version: "2.0.0" }, + oldestSuccessAt: CHECKED_AT.toISOString(), + }); + expect(memory.rows(core_update_lock)).toEqual(lockBefore); + }); + + it("refuses an administrator without the plugins permission", async () => { + const { response } = await requestAs([ + { module: "system", permission: "can_view", plugin: "@vitnode/core" }, + ]); + + expect(response.status).toBe(403); + }); +}); diff --git a/packages/vitnode/src/api/modules/admin/plugins/routes/updates-summary.route.ts b/packages/vitnode/src/api/modules/admin/plugins/routes/updates-summary.route.ts new file mode 100644 index 0000000000..8af2b6a3c6 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/plugins/routes/updates-summary.route.ts @@ -0,0 +1,47 @@ +import { buildRoute } from "@/api/builders"; +import { + loadUpdatesOverview, + summarizeUpdates, +} from "@/api/lib/updates/status"; + +import { iso, updatesSummarySchema } from "./updates.schema"; + +export const pluginUpdatesSummaryAdminRoute = buildRoute({ + adminStaffPermission: { module: "plugins", permission: "can_view" }, + route: { + method: "get", + description: + "Counts behind the AdminCP update badge, security pill and dashboard widget, from stored results only. Never contacts a publisher or starts a check.", + path: "/updates/summary", + responses: { + 200: { + content: { "application/json": { schema: updatesSummarySchema } }, + description: "Update summary", + }, + 403: { description: "Access Denied" }, + }, + }, + handler: async c => { + const { channelOverride, components, coreVersion, enabled } = + c.get("core").updates; + const summary = summarizeUpdates( + await loadUpdatesOverview({ + channelOverride, + components, + coreVersion, + db: c.get("db"), + enabled, + }), + ); + + return c.json( + { + ...summary, + core: { ...summary.core, version: coreVersion }, + lastAttemptAt: iso(summary.lastAttemptAt), + oldestSuccessAt: iso(summary.oldestSuccessAt), + }, + 200, + ); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/plugins/routes/updates.route.test.ts b/packages/vitnode/src/api/modules/admin/plugins/routes/updates.route.test.ts index 536cf7354f..8cdd75cf63 100644 --- a/packages/vitnode/src/api/modules/admin/plugins/routes/updates.route.test.ts +++ b/packages/vitnode/src/api/modules/admin/plugins/routes/updates.route.test.ts @@ -8,6 +8,10 @@ import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; import type { UpdateComponent } from "@/api/lib/updates/components"; import { checkForUpdates } from "@/api/lib/updates/check"; +import { + acquireUpdateCheckLease, + releaseUpdateCheckLease, +} from "@/api/lib/updates/lock"; import { createTestCache } from "@/tests/cache"; import { createMemoryDb } from "@/tests/memory-db"; import { grantStaffPermissions } from "@/tests/staff-permissions"; @@ -41,6 +45,7 @@ const requestAs = async (permissions: PermissionsStaffArgs[]) => { const cache = createTestCache(); await grantStaffPermissions(cache, { permissions, userId: ADMIN.id }); const db = createMemoryDb().db as unknown as Db; + await acquireUpdateCheckLease(db, { manual: false, owner: "cron" }); await checkForUpdates({ channelOverride: null, components: COMPONENTS, @@ -61,8 +66,10 @@ const requestAs = async (permissions: PermissionsStaffArgs[]) => { kind: "fetched" as const, lastModified: null, }), + lease: { owner: "cron", signal: new AbortController().signal }, now: CHECKED_AT, }); + await releaseUpdateCheckLease(db, "cron"); const app = new OpenAPIHono(); app.use("*", async (c, next) => { diff --git a/packages/vitnode/src/api/modules/admin/plugins/routes/updates.schema.ts b/packages/vitnode/src/api/modules/admin/plugins/routes/updates.schema.ts index d92223c4f3..4bd83277e9 100644 --- a/packages/vitnode/src/api/modules/admin/plugins/routes/updates.schema.ts +++ b/packages/vitnode/src/api/modules/admin/plugins/routes/updates.schema.ts @@ -60,7 +60,8 @@ export const updatesOverviewSchema = z.object({ plugins: z.array(componentUpdateSchema), }); -const iso = (date: Date | null): null | string => date?.toISOString() ?? null; +export const iso = (date: Date | null): null | string => + date?.toISOString() ?? null; export const serializeComponentUpdate = ( state: ComponentUpdateState, @@ -69,3 +70,22 @@ export const serializeComponentUpdate = ( lastAttemptAt: iso(state.lastAttemptAt), lastSuccessAt: iso(state.lastSuccessAt), }); + +export const updatesSummarySchema = z.object({ + enabled: z.boolean(), + running: z.boolean(), + checkedComponents: z.number(), + updatesAvailable: z.number(), + pluginUpdatesAvailable: z.number(), + affectedComponents: z.number(), + unconfirmedComponents: z.number(), + notCheckedComponents: z.number(), + failedComponents: z.number(), + stale: z.boolean(), + oldestSuccessAt: z.string().nullable(), + lastAttemptAt: z.string().nullable(), + core: z.object({ + version: z.string().nullable(), + latestVersion: z.string().nullable(), + }), +}); diff --git a/packages/vitnode/src/api/modules/cron/cron/check-updates.cron.ts b/packages/vitnode/src/api/modules/cron/cron/check-updates.cron.ts index 96792fa90f..52c9f40e14 100644 --- a/packages/vitnode/src/api/modules/cron/cron/check-updates.cron.ts +++ b/packages/vitnode/src/api/modules/cron/cron/check-updates.cron.ts @@ -13,6 +13,16 @@ export const checkUpdatesCron = buildCron({ const outcome = await runUpdateCheckFor(c, { manual: false }); + if (outcome.status === "lease_lost") { + await c + .get("log") + .warn( + "Update check stopped: this instance could not confirm it still holds the update check lease. Results stored before that are kept; the next run checks again.", + ); + + return; + } + if (outcome.status !== "completed") { // Someone pressed "Check for updates" a moment ago; that run covers this one. return; diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 6d61cd5aa6..d308a1890c 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -2662,6 +2662,39 @@ "select": "Select {title}", "properties": { "title": "{title} properties" + }, + "system-updates": { + "title": "System updates", + "desc": "Core version, available updates and security warnings from the stored update checks.", + "rows": { + "status": "Status", + "core": "VitNode", + "plugin_updates": "Plugin updates", + "security": "Security", + "last_checked": "Last checked" + }, + "status": { + "security": "Security fix needed", + "updates": "Updates available", + "up_to_date": "Up to date", + "unconfirmed": "{count, plural, one {# package not confirmed} other {# packages not confirmed}}", + "running": "Checking...", + "not_checked": "Not checked yet", + "unavailable": "No update sources", + "disabled": "Checks turned off" + }, + "core_update": "update to {version} available", + "security_affected": "{count, plural, one {# package} other {# packages}}", + "security_none": "None", + "last_checked": { + "running": "In progress", + "failed": "Failed ", + "stale": "Out of date", + "never": "Never" + }, + "unknown": "Unknown", + "load_error": "Could not load the update summary. The Plugins page still shows every result.", + "view_plugins": "View plugins" } } }, @@ -2721,6 +2754,22 @@ "title": "Please sign in again", "desc": "All AdminCP tabs were closed, so we signed you out to keep your account safe." } + }, + "updates": { + "nav_badge": "{count, plural, one {# update available} other {# updates available}}", + "security_pill": { + "label": "{count, plural, one {# security update} other {# security updates}}", + "title": "{count, plural, one {# update} other {# updates}}", + "checked": "Checked ", + "security": "Security fixes", + "other": "Other updates", + "more": "and {count} more", + "entry_label": "{title}, update to {version}", + "core": "VitNode", + "last_known": "Based on the last known results - the latest check could not confirm them.", + "load_error": "Could not load the update list. The Plugins page still shows every result.", + "open_plugins": "Open Plugins" + } } }, "advanced": { @@ -3791,6 +3840,10 @@ "error": { "title": "The check could not be started", "desc": "The API did not answer. Try again in a moment." + }, + "interrupted": { + "title": "The check stopped before it finished", + "desc": "This server could not confirm it still holds the update check lease, so it stopped instead of risking a clash with another check. Results stored before that are kept - try again in a moment." } }, "status": { @@ -3814,7 +3867,6 @@ "stale": "Not checked for this deployment yet - showing results from publisher data fetched .", "failed": "Last check failed: {reason}", "failed_with_previous": "Last check failed: {reason} Showing the last successful result, from .", - "checked_at": "Checked ", "error": { "blocked_address": "the update source points to a private or local address.", "http_error": "the update source answered HTTP {detail}.", diff --git a/packages/vitnode/src/tanstack/admin/dashboard/widgets.tsx b/packages/vitnode/src/tanstack/admin/dashboard/widgets.tsx index 323d7f850a..e84daf25cb 100644 --- a/packages/vitnode/src/tanstack/admin/dashboard/widgets.tsx +++ b/packages/vitnode/src/tanstack/admin/dashboard/widgets.tsx @@ -1,4 +1,4 @@ -import { NotebookPenIcon, SendIcon } from "lucide-react"; +import { NotebookPenIcon, RefreshCwIcon, SendIcon } from "lucide-react"; import type { AdminDashboardWidget, @@ -8,6 +8,7 @@ import type { import { NotesWidget } from "@/views/admin/views/core/dashboard/widgets/notes/notes-widget"; import { SendNotificationAction } from "@/views/admin/views/core/dashboard/widgets/send-notification/send-notification"; import { SendNotificationSettings } from "@/views/admin/views/core/dashboard/widgets/send-notification/send-notification-settings"; +import { SystemUpdatesWidget } from "@/views/admin/views/core/dashboard/widgets/system-updates/system-updates-widget"; import { useAdminUser } from "../permissions"; @@ -28,6 +29,15 @@ const SendNotificationBrowserWidget = ({ }; export const coreDashboardBrowserWidgets: AdminDashboardWidget[] = [ + { + id: "system-updates", + component: SystemUpdatesWidget, + icon: , + defaultSpan: 2, + defaultRows: 1, + defaultEnabled: true, + permission: { module: "plugins", permission: "can_view" }, + }, { id: "notes", component: NotesWidget, diff --git a/packages/vitnode/src/tanstack/admin/shell.tsx b/packages/vitnode/src/tanstack/admin/shell.tsx index be3028a845..d0012b84a3 100644 --- a/packages/vitnode/src/tanstack/admin/shell.tsx +++ b/packages/vitnode/src/tanstack/admin/shell.tsx @@ -11,8 +11,10 @@ import { useSidebar, } from "@/components/ui/sidebar"; import { NavSidebarAdminContent } from "@/views/admin/layouts/sidebar/nav/nav-content"; +import { AdminUpdateNavBadges } from "@/views/admin/layouts/sidebar/nav/update-nav-badges"; import { SidebarAdminContent } from "@/views/admin/layouts/sidebar/sidebar-content"; import { AdminSidebarToggle } from "@/views/admin/layouts/sidebar/sidebar-toggle"; +import { AdminUpdateSecurityPill } from "@/views/admin/layouts/update-security-pill"; import { RouteMessages } from "../i18n/route-messages"; import { RouteGuardPending } from "../pending/guard-pending"; @@ -77,7 +79,9 @@ const AdminShellFrame = ({ return ( }> - + + + @@ -86,6 +90,7 @@ const AdminShellFrame = ({
{breadcrumb}
+
diff --git a/packages/vitnode/src/tests/memory-db.ts b/packages/vitnode/src/tests/memory-db.ts index 6293d49035..88a93141ce 100644 --- a/packages/vitnode/src/tests/memory-db.ts +++ b/packages/vitnode/src/tests/memory-db.ts @@ -527,7 +527,15 @@ const call = ( } if (node.args === "*") return unsupported(`${node.name}(*)`); const args = node.args.map(argument => evaluate(argument, scope, group)); - if (node.name === "now" && args.length === 0) return new Date(); + if ( + (node.name === "now" || node.name === "clock_timestamp") && + args.length === 0 + ) { + return new Date(); + } + if (node.name === "timezone" && args.length === 2) { + return args[1] instanceof Date ? args[1] : unsupported("timezone()"); + } if (node.name === "lower") { return isMissing(args[0]) ? null : textOf(args[0]).toLowerCase(); } diff --git a/packages/vitnode/src/tests/plugin-updates.ts b/packages/vitnode/src/tests/plugin-updates.ts new file mode 100644 index 0000000000..0a176ee492 --- /dev/null +++ b/packages/vitnode/src/tests/plugin-updates.ts @@ -0,0 +1,75 @@ +import type { + AdminComponentUpdate, + AdminPluginUpdates, +} from "@/views/admin/views/core/system/plugins/plugin-updates-query"; +import type { AdminInstalledPlugin } from "@/views/admin/views/core/system/plugins/plugins-query"; + +type UpdateResult = NonNullable; + +export const securityFinding = ( + fixed: null | string, +): UpdateResult["securityUpdates"][number] => ({ + affected: "<9.9.9", + fixed, + id: `ACME-${fixed ?? "none"}`, + url: "https://acme.example.com/advisories/1", + versions: ["1.0.0"], +}); + +export const componentUpdate = ( + id: string, + result: null | Partial = {}, +): AdminComponentUpdate => ({ + id, + lastAttemptAt: "2026-10-15T03:00:00.000Z", + lastError: null, + lastSuccessAt: "2026-10-15T03:00:00.000Z", + result: + result === null + ? null + : { + alternative: null, + channel: "stable", + compatibility: "compatible", + latest: { + publishedAt: null, + requiresCore: null, + url: null, + version: "1.1.0", + }, + securityUpdates: [], + status: "update_available", + ...result, + }, + sourceUrl: "https://acme.example.com/updates.json", + stale: false, + state: "checked", +}); + +export const pluginUpdates = ( + core: AdminComponentUpdate, + plugins: AdminComponentUpdate[] = [], +): AdminPluginUpdates => ({ + channelOverride: null, + core, + enabled: true, + lastManualAt: null, + manualCooldownSeconds: 0, + plugins, + running: false, +}); + +export const installedPlugin = ( + id: string, + title: string, +): AdminInstalledPlugin => ({ + author: null, + description: null, + icon: null, + id, + license: null, + registrations: { api: null, frontend: null }, + title, + version: "1.0.0", + versionMismatch: false, +}); diff --git a/packages/vitnode/src/tests/update-summary.ts b/packages/vitnode/src/tests/update-summary.ts new file mode 100644 index 0000000000..79d14391d1 --- /dev/null +++ b/packages/vitnode/src/tests/update-summary.ts @@ -0,0 +1,20 @@ +import type { AdminUpdateSummary } from "@/views/admin/views/core/system/plugins/update-summary-query"; + +export const updateSummary = ( + overrides: Partial = {}, +): AdminUpdateSummary => ({ + affectedComponents: 0, + checkedComponents: 3, + core: { latestVersion: null, version: "2.0.0" }, + enabled: true, + failedComponents: 0, + lastAttemptAt: "2026-10-15T03:00:00.000Z", + notCheckedComponents: 0, + oldestSuccessAt: "2026-10-15T03:00:00.000Z", + pluginUpdatesAvailable: 0, + running: false, + stale: false, + unconfirmedComponents: 0, + updatesAvailable: 0, + ...overrides, +}); diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/item-content.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/item-content.tsx index a2195ca518..661f9c1630 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/item-content.tsx +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/item-content.tsx @@ -28,6 +28,12 @@ import type { AdminNavItem, AdminNavSubItem } from "./nav-model"; import { AdminLink } from "../../admin-link"; import { navItemActivity } from "./nav-active"; +import { + NavBadge, + NavBadgeDot, + sumAdminNavBadges, + useAdminNavBadges, +} from "./nav-badges"; export interface ItemNavAdminContentProps extends AdminNavItem { pathname: string; @@ -52,6 +58,7 @@ export const ItemNavAdminContent = ({ href, items, }); + const badges = useAdminNavBadges(); const [open, setOpen] = useState(hasActiveChild); @@ -66,12 +73,30 @@ export const ItemNavAdminContent = ({ if (isMobile) toggleSidebar(); }; + const isIconOnly = state === "collapsed" && !isMobile; + const badge = items.length + ? isIconOnly || !open + ? sumAdminNavBadges( + badges, + items.map(item => item.href), + ) + : undefined + : badges[href]; + const content = ( <> - {icon ?? } + {isIconOnly && badge ? ( + + {icon ?? } + + + ) : ( + (icon ?? ) + )} {title} + {isIconOnly ? null : } ); @@ -95,7 +120,7 @@ export const ItemNavAdminContent = ({ ); } - if (state === "collapsed" && !isMobile) { + if (isIconOnly) { return ( @@ -126,6 +151,7 @@ export const ItemNavAdminContent = ({ } > {item.title} + ))} @@ -150,7 +176,12 @@ export const ItemNavAdminContent = ({ } > {content} - + {item.title} + ))} diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-badges.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-badges.tsx new file mode 100644 index 0000000000..c890a30e49 --- /dev/null +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-badges.tsx @@ -0,0 +1,58 @@ +import React from "react"; + +export interface AdminNavBadge { + count: number; + label: string; +} + +export type AdminNavBadges = Readonly>>; + +const NO_BADGES: AdminNavBadges = {}; + +const AdminNavBadgesContext = React.createContext(NO_BADGES); + +export const AdminNavBadgesProvider = ({ + children, + value, +}: { + children: React.ReactNode; + value: AdminNavBadges; +}) => {children}; + +export const useAdminNavBadges = (): AdminNavBadges => + React.use(AdminNavBadgesContext); + +export const sumAdminNavBadges = ( + badges: AdminNavBadges, + hrefs: readonly string[], +): AdminNavBadge | undefined => { + const found = hrefs.flatMap(href => { + const badge = badges[href]; + + return badge === undefined ? [] : [badge]; + }); + if (found.length === 0) return undefined; + + return { + count: found.reduce((total, badge) => total + badge.count, 0), + label: found.map(badge => badge.label).join(", "), + }; +}; + +export const NavBadge = ({ badge }: { badge: AdminNavBadge | undefined }) => + badge === undefined ? null : ( + + {badge.count} + {badge.label} + + ); + +export const NavBadgeDot = ({ badge }: { badge: AdminNavBadge }) => ( + <> + + {badge.label} + +); diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx index 0eb19ac687..122cf9eba6 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx @@ -158,6 +158,8 @@ const core = ( plugin: manifest.id, }); +export const ADMIN_PLUGINS_HREF = "/admin/core/system/plugins"; + /** Core's own group, before the permission filter. */ const coreNavGroup = (): AdminNavGroupDeclaration => ({ id: "core", @@ -175,7 +177,7 @@ const coreNavGroup = (): AdminNavGroupDeclaration => ({ items: [ { title: key("admin.global.nav.system.plugins"), - href: "/admin/core/system/plugins", + href: ADMIN_PLUGINS_HREF, permission: core("plugins"), }, { diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/update-nav-badges.test.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/update-nav-badges.test.tsx new file mode 100644 index 0000000000..043bbd8ed1 --- /dev/null +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/update-nav-badges.test.tsx @@ -0,0 +1,73 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { StaffPermissionSet } from "@/api/lib/permission-staff"; +import type { AdminUpdateSummary } from "@/views/admin/views/core/system/plugins/update-summary-query"; + +import { AdminStaffPermissionProvider } from "@/components/staff-permission/provider"; +import { updateSummary as summary } from "@/tests/update-summary"; +import { updateSummaryQueryKey } from "@/views/admin/views/core/system/plugins/update-summary-query"; + +import { NavBadge, useAdminNavBadges } from "./nav-badges"; +import { ADMIN_PLUGINS_HREF } from "./nav-model"; +import { AdminUpdateNavBadges } from "./update-nav-badges"; + +const LABEL = "admin.global.updates.nav_badge"; + +const PluginsItemBadge = () => ( + +); + +const renderBadges = ( + data: AdminUpdateSummary, + permissions: StaffPermissionSet = { permissions: [], root: true }, +) => { + const queryClient = new QueryClient(); + queryClient.setQueryData(updateSummaryQueryKey, data); + + return render( + + + + + + + , + ); +}; + +describe("AdminUpdateNavBadges", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("counts the available updates next to Plugins", () => { + renderBadges(summary({ updatesAvailable: 3 })); + + expect(screen.getByText("3")).toBeTruthy(); + expect(screen.getByText(LABEL)).toBeTruthy(); + }); + + it.each([ + ["nothing to update", summary(), undefined], + [ + "checks turned off", + summary({ enabled: false, updatesAvailable: 3 }), + undefined, + ], + [ + "staff who cannot view plugins", + summary({ updatesAvailable: 3 }), + { permissions: [], root: false }, + ], + ])("shows no badge for %s", (_, data, permissions) => { + renderBadges(data, permissions); + + expect(screen.queryByText(LABEL)).toBeNull(); + }); +}); diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/update-nav-badges.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/update-nav-badges.tsx new file mode 100644 index 0000000000..9275a2fd0a --- /dev/null +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/update-nav-badges.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { useTranslations } from "use-intl"; + +import { useAdminUpdateSummary } from "@/views/admin/views/core/system/plugins/update-summary-query"; + +import type { AdminNavBadges } from "./nav-badges"; + +import { AdminNavBadgesProvider } from "./nav-badges"; +import { ADMIN_PLUGINS_HREF } from "./nav-model"; + +export const AdminUpdateNavBadges = ({ + children, +}: { + children: React.ReactNode; +}) => { + const t = useTranslations("admin.global.updates"); + const { data } = useAdminUpdateSummary(); + const count = data?.enabled ? data.updatesAvailable : 0; + + const badges = React.useMemo( + () => + count > 0 + ? { + [ADMIN_PLUGINS_HREF]: { + count, + label: t("nav_badge", { count }), + }, + } + : {}, + [count, t], + ); + + return ( + {children} + ); +}; diff --git a/packages/vitnode/src/views/admin/layouts/update-entries.test.ts b/packages/vitnode/src/views/admin/layouts/update-entries.test.ts new file mode 100644 index 0000000000..8d2e5d6763 --- /dev/null +++ b/packages/vitnode/src/views/admin/layouts/update-entries.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import { + componentUpdate, + installedPlugin, + pluginUpdates, + securityFinding, +} from "@/tests/plugin-updates"; + +import { updateGroupsOf } from "./update-entries"; + +describe("updateGroupsOf", () => { + it("splits core and plugins into security and other updates", () => { + const groups = updateGroupsOf({ + coreTitle: "VitNode", + plugins: [installedPlugin("@acme/blog", "Blog")], + updates: pluginUpdates(componentUpdate("core"), [ + componentUpdate("@acme/blog", { + securityUpdates: [securityFinding(null), securityFinding("1.0.1")], + }), + componentUpdate("@acme/shop", { + latest: null, + securityUpdates: [securityFinding(null)], + status: "up_to_date", + }), + componentUpdate("@acme/up-to-date", { status: "up_to_date" }), + componentUpdate("@acme/unchecked", null), + ]), + }); + + expect(groups).toEqual({ + other: [ + { id: "core", security: false, title: "VitNode", version: "1.1.0" }, + ], + security: [ + { id: "@acme/blog", security: true, title: "Blog", version: "1.1.0" }, + { + id: "@acme/shop", + security: true, + title: "@acme/shop", + version: null, + }, + ], + }); + }); + + it("falls back to the first fixed version when no newer release is available", () => { + const groups = updateGroupsOf({ + coreTitle: "VitNode", + plugins: undefined, + updates: pluginUpdates( + componentUpdate("core", { + securityUpdates: [securityFinding(null), securityFinding("2.0.1")], + status: "up_to_date", + }), + ), + }); + + expect(groups.security[0]?.version).toBe("2.0.1"); + }); +}); diff --git a/packages/vitnode/src/views/admin/layouts/update-entries.ts b/packages/vitnode/src/views/admin/layouts/update-entries.ts new file mode 100644 index 0000000000..65b8419005 --- /dev/null +++ b/packages/vitnode/src/views/admin/layouts/update-entries.ts @@ -0,0 +1,64 @@ +import type { + AdminComponentUpdate, + AdminPluginUpdates, +} from "@/views/admin/views/core/system/plugins/plugin-updates-query"; +import type { AdminInstalledPlugins } from "@/views/admin/views/core/system/plugins/plugins-query"; + +export interface UpdateEntry { + id: string; + security: boolean; + title: string; + version: null | string; +} + +interface UpdateGroups { + other: UpdateEntry[]; + security: UpdateEntry[]; +} + +const entryOf = ( + update: AdminComponentUpdate, + title: string, +): null | UpdateEntry => { + const result = update.result; + if (result === null) return null; + + const security = result.securityUpdates.length > 0; + const available = result.status === "update_available"; + if (!security && !available) return null; + + return { + id: update.id, + security, + title, + version: + (available ? result.latest?.version : null) ?? + result.securityUpdates.find(finding => finding.fixed !== null)?.fixed ?? + null, + }; +}; + +export const updateGroupsOf = ({ + coreTitle, + plugins, + updates, +}: { + coreTitle: string; + plugins: AdminInstalledPlugins["plugins"] | undefined; + updates: AdminPluginUpdates; +}): UpdateGroups => { + const titleById = new Map( + (plugins ?? []).map(plugin => [plugin.id, plugin.title]), + ); + const entries = [ + entryOf(updates.core, coreTitle), + ...updates.plugins.map(update => + entryOf(update, titleById.get(update.id) ?? update.id), + ), + ].filter(entry => entry !== null); + + return { + other: entries.filter(entry => !entry.security), + security: entries.filter(entry => entry.security), + }; +}; diff --git a/packages/vitnode/src/views/admin/layouts/update-security-pill.test.tsx b/packages/vitnode/src/views/admin/layouts/update-security-pill.test.tsx new file mode 100644 index 0000000000..760ee71b9d --- /dev/null +++ b/packages/vitnode/src/views/admin/layouts/update-security-pill.test.tsx @@ -0,0 +1,114 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { IntlProvider } from "use-intl"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { AdminUpdateSummary } from "@/views/admin/views/core/system/plugins/update-summary-query"; + +import { AdminStaffPermissionProvider } from "@/components/staff-permission/provider"; +import { + componentUpdate, + installedPlugin, + pluginUpdates, + securityFinding, +} from "@/tests/plugin-updates"; +import { updateSummary as summary } from "@/tests/update-summary"; +import { pluginUpdatesQueryKey } from "@/views/admin/views/core/system/plugins/plugin-updates-query"; +import { installedPluginsQueryKey } from "@/views/admin/views/core/system/plugins/plugins-query"; +import { updateSummaryQueryKey } from "@/views/admin/views/core/system/plugins/update-summary-query"; + +import { AdminUpdateSecurityPill } from "./update-security-pill"; + +const T = "admin.global.updates.security_pill"; + +const SECURE_PLUGINS = ["a", "b", "c", "d", "e"].map(id => + componentUpdate(`@acme/${id}`, { + securityUpdates: [securityFinding("2.0.0")], + }), +); + +const renderPill = async (data: AdminUpdateSummary) => { + const queryClient = new QueryClient(); + queryClient.setQueryData(updateSummaryQueryKey, data); + queryClient.setQueryData( + pluginUpdatesQueryKey, + pluginUpdates(componentUpdate("core"), SECURE_PLUGINS), + ); + queryClient.setQueryData(installedPluginsQueryKey, { + core: { author: null, license: null, version: "2.0.0" }, + frontendInventory: true, + plugins: [installedPlugin("@acme/a", "Alpha")], + }); + const router = createRouter({ + history: createMemoryHistory({ initialEntries: ["/admin"] }), + routeTree: createRootRoute({ + component: () => ( + <> +

page

+ + + ), + }), + }); + + render( + + + + + + + , + ); + await screen.findByText("page"); +}; + +describe("AdminUpdateSecurityPill", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it.each([ + ["nothing is affected", summary()], + [ + "checks are turned off", + summary({ affectedComponents: 1, enabled: false }), + ], + ])("stays hidden when %s", async (_, data) => { + await renderPill(data); + + expect(screen.queryByRole("button", { name: `${T}.label` })).toBeNull(); + }); + + it("lists the security and other updates once opened", async () => { + await renderPill(summary({ affectedComponents: 5 })); + + fireEvent.click(screen.getByRole("button", { name: `${T}.label` })); + + const security = await screen.findByRole("region", { + name: `${T}.security 5`, + }); + expect(within(security).getByText("Alpha")).toBeTruthy(); + expect(within(security).getByText("@acme/d")).toBeTruthy(); + expect(within(security).queryByText("@acme/e")).toBeNull(); + expect(within(security).getByText(`${T}.more`)).toBeTruthy(); + expect( + within(screen.getByRole("region", { name: `${T}.other 1` })).getByText( + `${T}.core`, + ), + ).toBeTruthy(); + expect( + screen.getByRole("link", { name: `${T}.open_plugins` }), + ).toBeTruthy(); + }); +}); diff --git a/packages/vitnode/src/views/admin/layouts/update-security-pill.tsx b/packages/vitnode/src/views/admin/layouts/update-security-pill.tsx new file mode 100644 index 0000000000..623db9d454 --- /dev/null +++ b/packages/vitnode/src/views/admin/layouts/update-security-pill.tsx @@ -0,0 +1,228 @@ +import type { LucideIcon } from "lucide-react"; + +import { useQuery } from "@tanstack/react-query"; +import { cn } from "cn"; +import { DownloadIcon, ShieldAlertIcon, TriangleAlertIcon } from "lucide-react"; +import React from "react"; +import { useTranslations } from "use-intl"; + +import { DateFormat } from "@/components/date-format"; +import { + Popover, + PopoverContent, + PopoverTitle, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Skeleton } from "@/components/ui/skeleton"; +import { pluginUpdatesQueryOptions } from "@/views/admin/views/core/system/plugins/plugin-updates-query"; +import { installedPluginsQueryOptions } from "@/views/admin/views/core/system/plugins/plugins-query"; +import { + type AdminUpdateSummary, + useAdminUpdateSummary, +} from "@/views/admin/views/core/system/plugins/update-summary-query"; + +import type { UpdateEntry } from "./update-entries"; + +import { AdminLink } from "./admin-link"; +import { ADMIN_PLUGINS_HREF } from "./sidebar/nav/nav-model"; +import { updateGroupsOf } from "./update-entries"; + +const GROUP_LIMIT = 4; + +const UpdateGroup = ({ + entries, + icon: Icon, + id, + onNavigate, + title, + tone, +}: { + entries: UpdateEntry[]; + icon: LucideIcon; + id: string; + onNavigate: () => void; + title: string; + tone: "destructive" | "muted"; +}) => { + const t = useTranslations("admin.global.updates.security_pill"); + + if (entries.length === 0) return null; + + const shown = entries.slice(0, GROUP_LIMIT); + const hidden = entries.length - shown.length; + + return ( +
+

+ + {title} + {entries.length} +

+
    + {shown.map(entry => ( +
  • + + {entry.title} + {entry.version === null ? null : ( + + {entry.version} + + )} + +
  • + ))} +
+ {hidden > 0 ? ( +

+ {t("more", { count: hidden })} +

+ ) : null} +
+ ); +}; + +const UpdateGroupsSkeleton = () => ( +
+ + + + +
+); + +const SecurityPillContent = ({ + onNavigate, + summary, +}: { + onNavigate: () => void; + summary: AdminUpdateSummary; +}) => { + const t = useTranslations("admin.global.updates.security_pill"); + const updates = useQuery(pluginUpdatesQueryOptions()); + const installed = useQuery(installedPluginsQueryOptions()); + const lastKnown = summary.failedComponents > 0 || summary.stale; + const { lastAttemptAt } = summary; + + const groups = React.useMemo( + () => + updates.data === undefined + ? null + : updateGroupsOf({ + coreTitle: t("core"), + plugins: installed.data?.plugins, + updates: updates.data, + }), + [installed.data, t, updates.data], + ); + const total = + groups === null ? null : groups.security.length + groups.other.length; + + return ( +
+
+ + {total === null + ? t("label", { count: summary.affectedComponents }) + : t("title", { count: total })} + + {lastAttemptAt === null ? null : ( +

+ {t.rich("checked", { + date: () => , + })} +

+ )} +
+ + {updates.isError ? ( +

+ {t("load_error")} +

+ ) : groups === null ? ( + + ) : ( +
+ + +
+ )} + + {lastKnown ? ( +

+ + {t("last_known")} +

+ ) : null} + + + {t("open_plugins")} + +
+ ); +}; + +export const AdminUpdateSecurityPill = () => { + const t = useTranslations("admin.global.updates.security_pill"); + const { data } = useAdminUpdateSummary(); + const [open, setOpen] = React.useState(false); + + if (!data?.enabled || data.affectedComponents === 0) return null; + + const label = t("label", { count: data.affectedComponents }); + + return ( + + + + + {data.affectedComponents} + + + {label} + + + + setOpen(false)} summary={data} /> + + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/system-updates-widget.test.tsx b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/system-updates-widget.test.tsx new file mode 100644 index 0000000000..e5ae132112 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/system-updates-widget.test.tsx @@ -0,0 +1,94 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { render, screen } from "@testing-library/react"; +import { IntlProvider } from "use-intl"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { AdminUpdateSummary } from "@/views/admin/views/core/system/plugins/update-summary-query"; + +import { AdminStaffPermissionProvider } from "@/components/staff-permission/provider"; +import { updateSummary as summary } from "@/tests/update-summary"; +import { updateSummaryQueryKey } from "@/views/admin/views/core/system/plugins/update-summary-query"; + +import { SystemUpdatesWidget } from "./system-updates-widget"; + +const T = "admin.dashboard.widgets.system-updates"; + +const renderWidget = async (data?: AdminUpdateSummary) => { + const queryClient = new QueryClient(); + if (data !== undefined) queryClient.setQueryData(updateSummaryQueryKey, data); + const router = createRouter({ + history: createMemoryHistory({ initialEntries: ["/admin"] }), + routeTree: createRootRoute({ component: SystemUpdatesWidget }), + }); + + render( + + + + + + + , + ); + await screen.findByText(`${T}.view_plugins`); +}; + +describe("SystemUpdatesWidget", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it.each([ + [ + "nothing was checked yet", + summary({ + checkedComponents: 0, + lastAttemptAt: null, + notCheckedComponents: 3, + oldestSuccessAt: null, + }), + 2, + ], + ["checks are turned off", summary({ enabled: false }), 3], + ])( + "shows the counts as unknown, never 0, while %s", + async (_, data, unknown) => { + await renderWidget(data); + + expect(screen.getAllByText(`${T}.unknown`)).toHaveLength(unknown); + expect(screen.queryByText("0")).toBeNull(); + expect(screen.queryByText(`${T}.security_none`)).toBeNull(); + }, + ); + + it("shows the checked counts and the newer core version", async () => { + await renderWidget( + summary({ + affectedComponents: 1, + core: { latestVersion: "2.1.0", version: "2.0.0" }, + pluginUpdatesAvailable: 2, + }), + ); + + expect(screen.getByText("2")).toBeTruthy(); + expect(screen.getByText(`${T}.core_update`)).toBeTruthy(); + expect(screen.getByText(`${T}.security_affected`)).toBeTruthy(); + expect(screen.queryByText(`${T}.unknown`)).toBeNull(); + }); + + it("points to the plugins page when the summary cannot load", async () => { + await renderWidget(); + + expect(await screen.findByText(`${T}.load_error`)).toBeTruthy(); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/system-updates-widget.tsx b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/system-updates-widget.tsx new file mode 100644 index 0000000000..38e1702952 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/system-updates-widget.tsx @@ -0,0 +1,191 @@ +import { cn } from "cn"; +import { + ArrowRightIcon, + ShieldAlertIcon, + TriangleAlertIcon, +} from "lucide-react"; +import { useTranslations } from "use-intl"; + +import { DateFormat } from "@/components/date-format"; +import { AdminLink } from "@/views/admin/layouts/admin-link"; +import { ADMIN_PLUGINS_HREF } from "@/views/admin/layouts/sidebar/nav/nav-model"; +import { + type AdminUpdateSummary, + useAdminUpdateSummary, +} from "@/views/admin/views/core/system/plugins/update-summary-query"; + +import type { UpdateStatus } from "./update-status"; + +import { WidgetContentSkeleton } from "../../grid/widget-skeleton"; +import { + compactVersion, + isKnownUpdateStatus, + updateStatusOf, +} from "./update-status"; + +const STATUS_TONE: Record = { + disabled: "text-muted-foreground", + not_checked: "text-muted-foreground", + running: "text-muted-foreground", + security: "text-destructive", + unavailable: "text-muted-foreground", + unconfirmed: "text-muted-foreground", + up_to_date: "text-success", + updates: "text-primary", +}; + +const STATUS_TEXT_TONE: Partial> = { + security: STATUS_TONE.security, + up_to_date: STATUS_TONE.up_to_date, + updates: STATUS_TONE.updates, +}; + +const Row = ({ + children, + label, +}: { + children: React.ReactNode; + label: string; +}) => ( +
+
{label}
+
+ {children} +
+
+); + +const Unknown = () => { + const t = useTranslations("admin.dashboard.widgets.system-updates"); + + return ( + <> + - + {t("unknown")} + + ); +}; + +const Warning = ({ children }: { children: React.ReactNode }) => ( + + + {children} + +); + +const LastChecked = ({ summary }: { summary: AdminUpdateSummary }) => { + const t = useTranslations( + "admin.dashboard.widgets.system-updates.last_checked", + ); + const date = (value: string) => () => ; + + if (!summary.enabled) return ; + if (summary.running) return t("running"); + if (summary.failedComponents > 0 && summary.lastAttemptAt !== null) { + return ( + + {t.rich("failed", { date: date(summary.lastAttemptAt) })} + + ); + } + if (summary.stale) return {t("stale")}; + + const checkedAt = summary.oldestSuccessAt ?? summary.lastAttemptAt; + + return checkedAt === null ? t("never") : ; +}; + +export const SystemUpdatesWidget = () => { + const t = useTranslations("admin.dashboard.widgets.system-updates"); + const { data, isError } = useAdminUpdateSummary(); + + if (isError) { + return ( +
+

+ + {t("load_error")} +

+ +
+ ); + } + if (data === undefined) return ; + + const status = updateStatusOf(data); + const known = isKnownUpdateStatus(status); + + return ( +
+
+ + + + {t(`status.${status}`, { count: data.unconfirmedComponents })} + + + + {data.core.version === null ? ( + + ) : ( + {data.core.version} + )} + {known && data.core.latestVersion !== null ? ( + + + → {compactVersion(data.core.version, data.core.latestVersion)} + + + {t("core_update", { version: data.core.latestVersion })} + + + ) : null} + + + {known ? data.pluginUpdatesAvailable : } + + + {!known ? ( + + ) : data.affectedComponents > 0 ? ( + + + {t("security_affected", { count: data.affectedComponents })} + + ) : ( + t("security_none") + )} + + + + +
+
+ +
+
+ ); +}; + +const ViewPlugins = () => { + const t = useTranslations("admin.dashboard.widgets.system-updates"); + + return ( + + {t("view_plugins")} + + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/update-status.test.ts b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/update-status.test.ts new file mode 100644 index 0000000000..b52680e226 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/update-status.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { updateSummary as summary } from "@/tests/update-summary"; + +import { compactVersion, updateStatusOf } from "./update-status"; + +describe("updateStatusOf", () => { + it.each([ + ["disabled", summary({ enabled: false, running: true })], + [ + "running", + summary({ checkedComponents: 0, notCheckedComponents: 2, running: true }), + ], + ["not_checked", summary({ checkedComponents: 0, notCheckedComponents: 2 })], + ["unavailable", summary({ checkedComponents: 0 })], + ["security", summary({ affectedComponents: 1, updatesAvailable: 2 })], + ["updates", summary({ unconfirmedComponents: 1, updatesAvailable: 2 })], + ["unconfirmed", summary({ unconfirmedComponents: 1 })], + ["up_to_date", summary()], + ])("returns %s", (status, data) => { + expect(updateStatusOf(data)).toBe(status); + }); +}); + +describe("compactVersion", () => { + it.each([ + ["2.0.0-canary.4", "2.0.0-canary.7", "canary.7"], + ["1.9.0-canary.4", "2.0.0-canary.7", "2.0.0-canary.7"], + ["2.0.0", "2.1.0", "2.1.0"], + ["2.0.0", "2.0.0-canary.7", "2.0.0-canary.7"], + [null, "2.0.0-canary.7", "2.0.0-canary.7"], + ])("shortens %s -> %s to %s", (current, latest, expected) => { + expect(compactVersion(current, latest)).toBe(expected); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/update-status.ts b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/update-status.ts new file mode 100644 index 0000000000..59425e7dca --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/update-status.ts @@ -0,0 +1,47 @@ +import { parse } from "semver"; + +import type { AdminUpdateSummary } from "@/views/admin/views/core/system/plugins/update-summary-query"; + +export type UpdateStatus = + | "disabled" + | "not_checked" + | "running" + | "security" + | "unavailable" + | "unconfirmed" + | "up_to_date" + | "updates"; + +export const updateStatusOf = (summary: AdminUpdateSummary): UpdateStatus => { + if (!summary.enabled) return "disabled"; + if (summary.running) return "running"; + if (summary.checkedComponents === 0) { + return summary.notCheckedComponents > 0 ? "not_checked" : "unavailable"; + } + if (summary.affectedComponents > 0) return "security"; + if (summary.updatesAvailable > 0) return "updates"; + if (summary.unconfirmedComponents > 0) return "unconfirmed"; + + return "up_to_date"; +}; + +export const isKnownUpdateStatus = (status: UpdateStatus): boolean => + status === "security" || + status === "updates" || + status === "unconfirmed" || + status === "up_to_date"; + +export const compactVersion = (current: null | string, latest: string) => { + const next = parse(latest); + const installed = current === null ? null : parse(current); + + return next !== null && + installed !== null && + next.prerelease.length > 0 && + installed.prerelease.length > 0 && + next.major === installed.major && + next.minor === installed.minor && + next.patch === installed.patch + ? next.prerelease.join(".") + : latest; +}; diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/check-updates-button.test.tsx b/packages/vitnode/src/views/admin/views/core/system/plugins/check-updates-button.test.tsx new file mode 100644 index 0000000000..c0cb3e185f --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/check-updates-button.test.tsx @@ -0,0 +1,60 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import type { CheckPluginUpdatesResult } from "./plugin-updates-query"; + +import { AdminStaffPermissionProvider } from "@/components/staff-permission/provider"; +import { updateSummary } from "@/tests/update-summary"; + +import { CheckUpdatesButton } from "./check-updates-button"; +import { + updateSummaryQueryKey, + useAdminUpdateSummary, +} from "./update-summary-query"; + +const SummaryReader = () => { + useAdminUpdateSummary(); + + return null; +}; + +describe("CheckUpdatesButton", () => { + it.each([ + { failedSources: 0, securityUpdates: 0, status: "completed", updates: 2 }, + { retryAfterSeconds: null, status: "interrupted" }, + ])( + "asks for the shared update summary again after a $status check", + async result => { + const queryClient = new QueryClient(); + queryClient.setQueryData(updateSummaryQueryKey, updateSummary()); + const refetched: unknown[] = []; + queryClient.getQueryCache().subscribe(event => { + if (event.type === "updated" && event.action.type === "fetch") { + refetched.push(event.query.queryKey); + } + }); + + render( + + + + await Promise.resolve(result)} + /> + + , + ); + expect(refetched).toEqual([]); + + const button = screen.getByRole("button", { + name: "admin.system.plugins.updates.check.button", + }); + fireEvent.submit(button.closest("form") as HTMLFormElement); + + await waitFor(() => { + expect(refetched).toContainEqual(updateSummaryQueryKey); + }); + }, + ); +}); diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/check-updates-button.tsx b/packages/vitnode/src/views/admin/views/core/system/plugins/check-updates-button.tsx index b0c92ab467..285bb4f2fa 100644 --- a/packages/vitnode/src/views/admin/views/core/system/plugins/check-updates-button.tsx +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/check-updates-button.tsx @@ -59,6 +59,15 @@ export const CheckUpdatesButton = ({ case "disabled": toast.error(t("disabled.title"), { description: t("disabled.desc") }); + return; + case "interrupted": + await queryClient.invalidateQueries({ + queryKey: pluginUpdatesQueryKey, + }); + toast.warning(t("interrupted.title"), { + description: t("interrupted.desc"), + }); + return; case "running": await queryClient.invalidateQueries({ diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-list.tsx b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-list.tsx index 5b68d241d5..d89edd7464 100644 --- a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-list.tsx +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-list.tsx @@ -1,15 +1,5 @@ import { cn } from "cn"; -import { - ArrowUpCircleIcon, - ChevronDownIcon, - CircleCheckIcon, - CircleDashedIcon, - CircleHelpIcon, - CircleOffIcon, - PackageIcon, - ShieldAlertIcon, - TriangleAlertIcon, -} from "lucide-react"; +import { ChevronDownIcon, PackageIcon, TriangleAlertIcon } from "lucide-react"; import { useTranslations } from "use-intl"; import { DateFormat } from "@/components/date-format"; @@ -27,11 +17,11 @@ import { FrameTitle, } from "@/components/ui/frame"; +import type { PluginRow } from "./plugin-rows"; import type { PluginTone } from "./plugin-tone"; -import type { AdminComponentUpdate } from "./plugin-updates-query"; -import type { AdminInstalledPlugin, AdminPluginAuthor } from "./plugins-query"; import { PluginIcon } from "./plugin-icon"; +import { PLUGIN_TONE_STYLE } from "./plugin-tone"; import { ExternalLink, PluginUpdateStatus, @@ -41,59 +31,9 @@ import { } from "./plugin-update-status"; import { PluginVersion } from "./plugin-version"; -export interface PluginRow { - author: AdminPluginAuthor | null; - description: null | string; - icon: null | string; - id: string; - kind: "core" | "plugin"; - license: null | string; - registrations: AdminInstalledPlugin["registrations"] | null; - title: string; - tone: null | PluginTone; - update: AdminComponentUpdate | undefined; - version: null | string; - versionMismatch: boolean; -} - -const TONE_BADGE = { - security: { - icon: ShieldAlertIcon, - className: "bg-destructive/10 text-destructive dark:bg-destructive/20", - }, - error: { - icon: TriangleAlertIcon, - className: "bg-warn/10 text-warn dark:bg-warn/20", - }, - update: { - icon: ArrowUpCircleIcon, - className: "bg-primary/10 text-primary dark:bg-primary/20", - }, - unknown: { - icon: CircleHelpIcon, - className: "bg-muted text-muted-foreground", - }, - pending: { - icon: CircleDashedIcon, - className: "bg-muted text-muted-foreground", - }, - ok: { - icon: CircleCheckIcon, - className: "bg-success/10 text-success dark:bg-success/20", - }, - off: { - icon: CircleOffIcon, - className: "bg-muted text-muted-foreground", - }, - disabled: { - icon: CircleOffIcon, - className: "bg-muted text-muted-foreground", - }, -} as const; - const StatusBadge = ({ tone }: { tone: PluginTone }) => { const t = useTranslations("admin.system.plugins.badge"); - const { className, icon: Icon } = TONE_BADGE[tone]; + const { className, icon: Icon } = PLUGIN_TONE_STYLE[tone]; return ( @@ -221,36 +161,34 @@ const Panel = ({ }) => { const t = useTranslations("admin.system.plugins"); const { api, frontend } = row.registrations ?? { api: null, frontend: null }; - const showMismatch = - row.versionMismatch && !!api?.version && !!frontend?.version; + const mismatch = + row.versionMismatch && api?.version && frontend?.version + ? { api: api.version, frontend: frontend.version } + : null; const showSecurity = (row.update?.result?.securityUpdates.length ?? 0) > 0; const showStatus = row.update !== undefined && (!enabled || row.update.state !== "unavailable"); - const hasStatus = showMismatch || showSecurity || showStatus; + const hasStatus = mismatch !== null || showSecurity || showStatus; return (
{hasStatus ? (
- {showMismatch ? ( + {mismatch === null ? null : (

- {t("mismatch", { - api: api?.version ?? "", - frontend: frontend?.version ?? "", - })} + {t("mismatch", mismatch)}

- ) : null} + )} {showStatus ? ( ) : null} @@ -341,7 +279,9 @@ export const PluginList = ({ {t("title")} - {t("desc", { count: rows.length - 1 })} + {t("desc", { + count: rows.filter(row => row.kind === "plugin").length, + })} }> diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-rows.ts b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-rows.ts index f0f77de7db..95652f79fd 100644 --- a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-rows.ts +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-rows.ts @@ -1,9 +1,31 @@ -import type { PluginRow } from "./plugin-list"; -import type { AdminPluginUpdates } from "./plugin-updates-query"; -import type { AdminInstalledPlugins } from "./plugins-query"; +import type { PluginTone } from "./plugin-tone"; +import type { + AdminComponentUpdate, + AdminPluginUpdates, +} from "./plugin-updates-query"; +import type { + AdminInstalledPlugin, + AdminInstalledPlugins, + AdminPluginAuthor, +} from "./plugins-query"; import { pluginToneOf } from "./plugin-tone"; +export interface PluginRow { + author: AdminPluginAuthor | null; + description: null | string; + icon: null | string; + id: string; + kind: "core" | "plugin"; + license: null | string; + registrations: AdminInstalledPlugin["registrations"] | null; + title: string; + tone: null | PluginTone; + update: AdminComponentUpdate | undefined; + version: null | string; + versionMismatch: boolean; +} + export const pluginRowsOf = ({ core, data, diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-tone.ts b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-tone.ts index bd9857a47e..311f6aa123 100644 --- a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-tone.ts +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-tone.ts @@ -1,17 +1,64 @@ +import type { LucideIcon } from "lucide-react"; + +import { + ArrowUpCircleIcon, + CircleCheckIcon, + CircleDashedIcon, + CircleHelpIcon, + CircleOffIcon, + ShieldAlertIcon, + TriangleAlertIcon, +} from "lucide-react"; + import type { AdminComponentUpdate } from "./plugin-updates-query"; -export const PLUGIN_TONES = [ - "security", - "error", - "update", - "unknown", - "pending", - "ok", - "off", - "disabled", -] as const; +export type PluginTone = + | "disabled" + | "error" + | "off" + | "ok" + | "pending" + | "security" + | "unknown" + | "update"; -export type PluginTone = (typeof PLUGIN_TONES)[number]; +export const PLUGIN_TONE_STYLE: Record< + PluginTone, + { className: string; icon: LucideIcon } +> = { + security: { + icon: ShieldAlertIcon, + className: "bg-destructive/10 text-destructive dark:bg-destructive/20", + }, + error: { + icon: TriangleAlertIcon, + className: "bg-warn/10 text-warn dark:bg-warn/20", + }, + update: { + icon: ArrowUpCircleIcon, + className: "bg-primary/10 text-primary dark:bg-primary/20", + }, + unknown: { + icon: CircleHelpIcon, + className: "bg-muted text-muted-foreground", + }, + pending: { + icon: CircleDashedIcon, + className: "bg-muted text-muted-foreground", + }, + ok: { + icon: CircleCheckIcon, + className: "bg-success/10 text-success dark:bg-success/20", + }, + off: { + icon: CircleOffIcon, + className: "bg-muted text-muted-foreground", + }, + disabled: { + icon: CircleOffIcon, + className: "bg-muted text-muted-foreground", + }, +}; export const pluginToneOf = ( update: AdminComponentUpdate | undefined, diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-update-status.test.tsx b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-update-status.test.tsx index fcd569c18f..2d7506b628 100644 --- a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-update-status.test.tsx +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-update-status.test.tsx @@ -146,7 +146,6 @@ describe("PluginUpdateStatus", () => { expect(screen.getByText(`${T}.status.update_available`)).toBeTruthy(); expect(screen.getByText(`${T}.failed_with_previous`)).toBeTruthy(); - expect(screen.queryByText(`${T}.checked_at`)).toBeNull(); }); it("reports a check that failed before anything was found", () => { @@ -167,13 +166,6 @@ describe("PluginUpdateStatus", () => { renderStatus(update({ stale: true })); expect(screen.getByText(`${T}.stale`)).toBeTruthy(); - expect(screen.queryByText(`${T}.checked_at`)).toBeNull(); - }); - - it("says when the last check is current", () => { - renderStatus(update()); - - expect(screen.getByText(`${T}.checked_at`)).toBeTruthy(); }); it.each([ diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-update-status.tsx b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-update-status.tsx index fe48bdb158..591d3abf06 100644 --- a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-update-status.tsx +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-update-status.tsx @@ -201,7 +201,7 @@ const ResultLines = ({ ); case "update_available": { - const latest = result.latest; + const { alternative, latest } = result; if (latest === null) return null; return ( @@ -246,22 +246,22 @@ const ResultLines = ({ {t("updates.compatibility.unknown")} ) : null} - {result.alternative === null ? null : ( + {alternative === null ? null : (

{t.rich("updates.compatibility.alternative", { version: () => ( ), })} - {safeHttpsHref(result.alternative.url) === null ? null : ( + {safeHttpsHref(alternative.url) === null ? null : ( <> · - + {t("updates.release_notes")} @@ -288,13 +288,10 @@ const ResultLines = ({ export const PluginUpdateStatus = ({ coreVersion, enabled, - showCheckedAt = true, update, }: { coreVersion: string; enabled: boolean; - /** Off where the same date is already shown nearby. */ - showCheckedAt?: boolean; update: AdminComponentUpdate | undefined; }) => { const t = useTranslations("admin.system.plugins"); @@ -345,18 +342,6 @@ export const PluginUpdateStatus = ({ : t("updates.failed", { reason })} )} - - {showCheckedAt && - update.state === "checked" && - !update.stale && - lastError === null && - update.lastSuccessAt !== null ? ( -

- {t.rich("updates.checked_at", { - date: () => , - })} -

- ) : null}
); }; diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-updates-query.ts b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-updates-query.ts index 62e5ff436d..1e741ef891 100644 --- a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-updates-query.ts +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-updates-query.ts @@ -55,7 +55,7 @@ export type CheckPluginUpdatesResult = } | { retryAfterSeconds: null | number; - status: "cooldown" | "disabled" | "running"; + status: "cooldown" | "disabled" | "interrupted" | "running"; } | { status: "error" }; diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-version.tsx b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-version.tsx index 73d1645673..0f240226c1 100644 --- a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-version.tsx +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-version.tsx @@ -6,7 +6,7 @@ import { Badge } from "@/components/ui/badge"; import { unstableChannelLabel, unstableChannelOf } from "./unstable-channel"; -export const UnstableChannelTag = ({ version }: { version: null | string }) => { +const UnstableChannelTag = ({ version }: { version: string }) => { const t = useTranslations("admin.system.plugins.unstable"); const channel = unstableChannelOf(version); if (channel === null) return null; @@ -15,7 +15,7 @@ export const UnstableChannelTag = ({ version }: { version: null | string }) => { return ( diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-status.tsx b/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-status.tsx index 0cc3a5b7a1..362980286b 100644 --- a/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-status.tsx +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-status.tsx @@ -1,64 +1,20 @@ import { cn } from "cn"; -import { - ArrowUpCircleIcon, - CircleCheckIcon, - CircleDashedIcon, - CircleOffIcon, - ShieldAlertIcon, - TriangleAlertIcon, -} from "lucide-react"; import { useFormatter, useTranslations } from "use-intl"; import { DateFormat } from "@/components/date-format"; -import type { PluginRow } from "./plugin-list"; +import type { PluginRow } from "./plugin-rows"; +import type { PluginTone } from "./plugin-tone"; import type { AdminPluginUpdates, CheckPluginUpdates, } from "./plugin-updates-query"; import { CheckUpdatesButton } from "./check-updates-button"; +import { PLUGIN_TONE_STYLE } from "./plugin-tone"; import { ExternalLink } from "./plugin-update-status"; -type StatusKind = - | "disabled" - | "error" - | "ok" - | "pending" - | "security" - | "unavailable" - | "update"; - -const KIND_ICON = { - disabled: { - icon: CircleOffIcon, - className: "bg-muted text-muted-foreground", - }, - error: { - icon: TriangleAlertIcon, - className: "bg-warn/10 text-warn dark:bg-warn/20", - }, - ok: { - icon: CircleCheckIcon, - className: "bg-success/10 text-success dark:bg-success/20", - }, - pending: { - icon: CircleDashedIcon, - className: "bg-muted text-muted-foreground", - }, - security: { - icon: ShieldAlertIcon, - className: "bg-destructive/10 text-destructive dark:bg-destructive/20", - }, - unavailable: { - icon: CircleOffIcon, - className: "bg-muted text-muted-foreground", - }, - update: { - icon: ArrowUpCircleIcon, - className: "bg-primary/10 text-primary dark:bg-primary/20", - }, -} as const; +type StatusKind = "unavailable" | Exclude; const statusKindOf = ( rows: PluginRow[], @@ -141,9 +97,11 @@ const StatusBody = ({ return (

{format.list( - available.map( - row => `${row.title} ${row.update?.result?.latest?.version ?? ""}`, - ), + available.map(row => { + const latest = row.update?.result?.latest; + + return latest ? `${row.title} ${latest.version}` : row.title; + }), )}

); @@ -183,7 +141,8 @@ export const PluginsStatus = ({ }) => { const t = useTranslations("admin.system.plugins.status"); const kind = statusKindOf(rows, updates); - const { className, icon: Icon } = KIND_ICON[kind]; + const { className, icon: Icon } = + PLUGIN_TONE_STYLE[kind === "unavailable" ? "off" : kind]; const securityCount = rows.reduce( (sum, row) => sum + (row.update?.result?.securityUpdates.length ?? 0), 0, diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/unstable-channel.ts b/packages/vitnode/src/views/admin/views/core/system/plugins/unstable-channel.ts index bd3b3eaf3f..cacb7014d0 100644 --- a/packages/vitnode/src/views/admin/views/core/system/plugins/unstable-channel.ts +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/unstable-channel.ts @@ -5,9 +5,7 @@ const LABELS: Record = { rc: "RC", }; -export const unstableChannelOf = (version: null | string): null | string => { - if (version === null) return null; - +export const unstableChannelOf = (version: string): null | string => { const [tag] = prerelease(version) ?? []; if (tag === undefined) return null; if (typeof tag === "number") return "prerelease"; diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/update-summary-query.ts b/packages/vitnode/src/views/admin/views/core/system/plugins/update-summary-query.ts new file mode 100644 index 0000000000..9772492f14 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/update-summary-query.ts @@ -0,0 +1,61 @@ +import type { z } from "zod"; + +import { queryOptions, useQuery } from "@tanstack/react-query"; + +import type { updatesSummarySchema } from "@/api/modules/admin/plugins/routes/updates.schema"; + +import { useAdminStaffPermission } from "@/components/staff-permission/provider"; +import { manifest } from "@/manifest"; +import { fetcher } from "@/tanstack/fetcher"; +import { AdminRequestError } from "@/views/admin/admin-request"; + +import { pluginUpdatesQueryKey } from "./plugin-updates-query"; + +export type AdminUpdateSummary = z.infer; + +export const fetchUpdateSummary = async (): Promise => { + const response = await fetcher({ + plugin: manifest.id, + method: "get", + module: "admin/plugins", + path: "/updates/summary", + }); + + if (!response.ok) { + throw new AdminRequestError(response.status, "the update summary"); + } + + return await response.json(); +}; + +export const updateSummaryQueryKey = [ + ...pluginUpdatesQueryKey, + "summary", +] as const; + +const RUNNING_REFETCH_MS = 15_000; + +export const updateSummaryQueryOptions = () => + queryOptions({ + queryFn: fetchUpdateSummary, + queryKey: updateSummaryQueryKey, + retry: false, + staleTime: 60_000, + refetchOnWindowFocus: true, + refetchInterval: query => + query.state.data?.running === true ? RUNNING_REFETCH_MS : false, + }); + +export const useAdminUpdateSummary = () => { + const canView = useAdminStaffPermission({ + plugin: manifest.id, + module: "plugins", + permission: "can_view", + }); + const { data, isError } = useQuery({ + ...updateSummaryQueryOptions(), + enabled: canView, + }); + + return { data: canView ? data : undefined, isError: canView && isError }; +}; diff --git a/packages/vitnode/vitest.config.ts b/packages/vitnode/vitest.config.ts index 078911bad0..b2739bb89e 100644 --- a/packages/vitnode/vitest.config.ts +++ b/packages/vitnode/vitest.config.ts @@ -38,6 +38,7 @@ const INTEGRATION = [ "scripts/cli/commands/start.test.ts", "scripts/cli/dev/package-watch.test.ts", "scripts/watch-plugin-manifest.test.ts", + "src/api/lib/updates/lock.postgres.test.ts", ]; export default defineConfig({