feat: early access release channel

This commit is contained in:
Jake Turner 2026-03-04 02:57:10 +00:00 committed by Jake Turner
parent 6817e2e47e
commit efa57ec010
4 changed files with 98 additions and 47 deletions

View File

@ -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
}
}

View File

@ -1,3 +1,3 @@
import { KVStoreKey } from "../types/kv_store.js";
export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'ui.hasVisitedEasySetup'];
export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'ui.hasVisitedEasySetup', 'system.earlyAccess'];

View File

@ -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() {
<StyledSectionHeader title="Content Updates" />
<div className="bg-white rounded-lg border shadow-md overflow-hidden p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center justify-between">
<p className="text-desert-stone-dark">
Check if newer versions of your installed ZIM files and maps are available.
</p>
@ -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) => (
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${
record.resource_type === 'zim'
? 'bg-blue-100 text-blue-800'
: 'bg-emerald-100 text-emerald-800'
}`}
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${record.resource_type === 'zim'
? 'bg-blue-100 text-blue-800'
: 'bg-emerald-100 text-emerald-800'
}`}
>
{record.resource_type === 'zim' ? 'ZIM' : 'Map'}
</span>
@ -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<string>('')
const [email, setEmail] = useState('')
const [versionInfo, setVersionInfo] = useState(props.system)
const [versionInfo, setVersionInfo] = useState<Omit<Props, 'earlyAccess'>>(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 <IconCircleCheck className="h-16 w-16 text-desert-olive" />
}
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"
/>
</div>
<StyledSectionHeader title="Early Access" className="mt-8" />
<div className="bg-white rounded-lg border shadow-md overflow-hidden mt-6 p-6">
<Switch
checked={earlyAccessSetting.data?.value || false}
onChange={(newVal) => {
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."
/>
</div>
<ContentUpdatesSection />
<div className="bg-white rounded-lg border shadow-md overflow-hidden py-6 mt-12">
<div className="flex flex-col md:flex-row justify-between items-center p-8 gap-y-8 md:gap-y-0 gap-x-8">
@ -666,7 +701,7 @@ export default function SystemUpdatePage(props: {
</div>
)}
</main>
</div>
</SettingsLayout>
</div >
</SettingsLayout >
)
}

View File

@ -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