From efa57ec010cfb24c336a664758bc78adb7308798 Mon Sep 17 00:00:00 2001 From: Jake Turner Date: Wed, 4 Mar 2026 02:57:10 +0000 Subject: [PATCH] feat: early access release channel --- admin/app/services/system_service.ts | 69 ++++++++++++++--------- admin/constants/kv_store.ts | 2 +- admin/inertia/pages/settings/update.tsx | 73 ++++++++++++++++++------- admin/types/kv_store.ts | 1 + 4 files changed, 98 insertions(+), 47 deletions(-) diff --git a/admin/app/services/system_service.ts b/admin/app/services/system_service.ts index 60b51b7..fc0b5db 100644 --- a/admin/app/services/system_service.ts +++ b/admin/app/services/system_service.ts @@ -13,7 +13,7 @@ import axios from 'axios' import env from '#start/env' import KVStore from '#models/kv_store' import { KVStoreKey } from '../../types/kv_store.js' -import { parseBoolean } from '../utils/misc.js' + @inject() export class SystemService { @@ -306,33 +306,39 @@ export class SystemService { if (!force) { return { success: true, - updateAvailable: parseBoolean(cachedUpdateAvailable || "false"), + updateAvailable: cachedUpdateAvailable ?? false, currentVersion, latestVersion: cachedLatestVersion || '', } } - const response = await axios.get( - 'https://api.github.com/repos/Crosstalk-Solutions/project-nomad/releases/latest', - { - headers: { Accept: 'application/vnd.github+json' }, - timeout: 5000, - } - ) + const earlyAccess = (await KVStore.getValue('system.earlyAccess')) ?? false - if (!response || !response.data?.tag_name) { - throw new Error('Invalid response from GitHub API') + let latestVersion: string + if (earlyAccess) { + const response = await axios.get( + 'https://api.github.com/repos/Crosstalk-Solutions/project-nomad/releases', + { headers: { Accept: 'application/vnd.github+json' }, timeout: 5000 } + ) + if (!response?.data?.length) throw new Error('No releases found') + latestVersion = response.data[0].tag_name.replace(/^v/, '').trim() + } else { + const response = await axios.get( + 'https://api.github.com/repos/Crosstalk-Solutions/project-nomad/releases/latest', + { headers: { Accept: 'application/vnd.github+json' }, timeout: 5000 } + ) + if (!response?.data?.tag_name) throw new Error('Invalid response from GitHub API') + latestVersion = response.data.tag_name.replace(/^v/, '').trim() } - const latestVersion = response.data.tag_name.replace(/^v/, '').trim() // Remove leading 'v' and whitespace - logger.info(`Current version: ${currentVersion}, Latest version: ${latestVersion}`) + logger.info(`Current version: ${currentVersion}, Latest version: ${latestVersion}`) - const updateAvailable = process.env.NODE_ENV === 'development' - ? false - : this.isNewerVersion(latestVersion, currentVersion.trim()) + const updateAvailable = process.env.NODE_ENV === 'development' + ? false + : this.isNewerVersion(latestVersion, currentVersion.trim()) // Cache the results in KVStore for frontend checks - await KVStore.setValue('system.updateAvailable', updateAvailable.toString()) + await KVStore.setValue('system.updateAvailable', updateAvailable) await KVStore.setValue('system.latestVersion', latestVersion) return { @@ -473,19 +479,28 @@ export class SystemService { * @returns true if version1 is newer than version2 */ private isNewerVersion(version1: string, version2: string): boolean { - const v1Parts = version1.split('.').map((part) => parseInt(part, 10)) - const v2Parts = version2.split('.').map((part) => parseInt(part, 10)) + const [base1, pre1] = version1.split('-') + const [base2, pre2] = version2.split('-') - const maxLength = Math.max(v1Parts.length, v2Parts.length) + const v1Parts = base1.split('.').map((p) => parseInt(p, 10) || 0) + const v2Parts = base2.split('.').map((p) => parseInt(p, 10) || 0) - for (let i = 0; i < maxLength; i++) { - const v1Part = v1Parts[i] || 0 - const v2Part = v2Parts[i] || 0 - - if (v1Part > v2Part) return true - if (v1Part < v2Part) return false + const maxLen = Math.max(v1Parts.length, v2Parts.length) + for (let i = 0; i < maxLen; i++) { + const a = v1Parts[i] || 0 + const b = v2Parts[i] || 0 + if (a > b) return true + if (a < b) return false } - return false // Versions are equal + // Base versions equal — GA > RC, RC.n+1 > RC.n + if (!pre1 && pre2) return true // v1 is GA, v2 is RC → v1 is newer + if (pre1 && !pre2) return false // v1 is RC, v2 is GA → v2 is newer + if (!pre1 && !pre2) return false // both GA, equal + + // Both prerelease: compare numeric suffix (e.g. "rc.2" vs "rc.1") + const pre1Num = parseInt(pre1.split('.')[1], 10) || 0 + const pre2Num = parseInt(pre2.split('.')[1], 10) || 0 + return pre1Num > pre2Num } } diff --git a/admin/constants/kv_store.ts b/admin/constants/kv_store.ts index e8df201..59ae9c9 100644 --- a/admin/constants/kv_store.ts +++ b/admin/constants/kv_store.ts @@ -1,3 +1,3 @@ import { KVStoreKey } from "../types/kv_store.js"; -export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'ui.hasVisitedEasySetup']; \ No newline at end of file +export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'ui.hasVisitedEasySetup', 'system.earlyAccess']; \ No newline at end of file diff --git a/admin/inertia/pages/settings/update.tsx b/admin/inertia/pages/settings/update.tsx index 7ba088a..8f6b212 100644 --- a/admin/inertia/pages/settings/update.tsx +++ b/admin/inertia/pages/settings/update.tsx @@ -11,8 +11,17 @@ import { SystemUpdateStatus } from '../../../types/system' import type { ContentUpdateCheckResult, ResourceUpdateInfo } from '../../../types/collections' import api from '~/lib/api' import Input from '~/components/inputs/Input' +import Switch from '~/components/inputs/Switch' import { useMutation } from '@tanstack/react-query' import { useNotifications } from '~/context/NotificationContext' +import { useSystemSetting } from '~/hooks/useSystemSetting' + +type Props = { + updateAvailable: boolean + latestVersion: string + currentVersion: string + earlyAccess: boolean +} function ContentUpdatesSection() { const { addNotification } = useNotifications() @@ -99,7 +108,7 @@ function ContentUpdatesSection() {
-
+

Check if newer versions of your installed ZIM files and maps are available.

@@ -119,7 +128,7 @@ function ContentUpdatesSection() { title="Update Check Issue" message={checkResult.error} variant="bordered" - className="mb-4" + className="my-4" /> )} @@ -129,7 +138,7 @@ function ContentUpdatesSection() { title="All Content Up to Date" message="All your installed content is running the latest available version." variant="bordered" - className="mb-4" + className="my-4" /> )} @@ -164,11 +173,10 @@ function ContentUpdatesSection() { title: 'Type', render: (record) => ( {record.resource_type === 'zim' ? 'ZIM' : 'Map'} @@ -215,13 +223,7 @@ function ContentUpdatesSection() { ) } -export default function SystemUpdatePage(props: { - system: { - updateAvailable: boolean - latestVersion: string - currentVersion: string - } -}) { +export default function SystemUpdatePage(props: { system: Props }) { const { addNotification } = useNotifications() const [isUpdating, setIsUpdating] = useState(false) @@ -230,9 +232,16 @@ export default function SystemUpdatePage(props: { const [showLogs, setShowLogs] = useState(false) const [logs, setLogs] = useState('') const [email, setEmail] = useState('') - const [versionInfo, setVersionInfo] = useState(props.system) + const [versionInfo, setVersionInfo] = useState>(props.system) const [showConnectionLostNotice, setShowConnectionLostNotice] = useState(false) + const earlyAccessSetting = useSystemSetting({ + key: 'system.earlyAccess', initialData: { + key: 'system.earlyAccess', + value: props.system.earlyAccess, + } + }) + useEffect(() => { if (!isUpdating) return @@ -243,7 +252,7 @@ export default function SystemUpdatePage(props: { throw new Error('Failed to fetch update status') } setUpdateStatus(response) - + // If we can connect again, hide the connection lost notice setShowConnectionLostNotice(false) @@ -363,6 +372,20 @@ export default function SystemUpdatePage(props: { return } + const updateSettingMutation = useMutation({ + mutationFn: async ({ key, value }: { key: string; value: boolean }) => { + return await api.updateSetting(key, value) + }, + onSuccess: () => { + addNotification({ message: 'Setting updated successfully.', type: 'success' }) + earlyAccessSetting.refetch() + }, + onError: (error) => { + console.error('Error updating setting:', error) + addNotification({ message: 'There was an error updating the setting. Please try again.', type: 'error' }) + }, + }) + const subscribeToReleaseNotesMutation = useMutation({ mutationKey: ['subscribeToReleaseNotes'], mutationFn: (email: string) => api.subscribeToReleaseNotes(email), @@ -594,6 +617,18 @@ export default function SystemUpdatePage(props: { variant="solid" />
+ +
+ { + updateSettingMutation.mutate({ key: 'system.earlyAccess', value: newVal }) + }} + disabled={updateSettingMutation.isPending} + label="Enable Early Access" + description="Receive release candidate (RC) versions before they are officially released. Note: RC versions may contain bugs and are not recommended for environments where stability and data integrity are critical." + /> +
@@ -666,7 +701,7 @@ export default function SystemUpdatePage(props: {
)} -
- +
+ ) } diff --git a/admin/types/kv_store.ts b/admin/types/kv_store.ts index c60adad..37634ca 100644 --- a/admin/types/kv_store.ts +++ b/admin/types/kv_store.ts @@ -4,6 +4,7 @@ export const KV_STORE_SCHEMA = { 'rag.docsEmbedded': 'boolean', 'system.updateAvailable': 'boolean', 'system.latestVersion': 'string', + 'system.earlyAccess': 'boolean', 'ui.hasVisitedEasySetup': 'boolean', } as const