mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-03-28 03:29:25 +01:00
feat: early access release channel
This commit is contained in:
parent
6817e2e47e
commit
efa57ec010
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
@ -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 >
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user