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 env from '#start/env'
|
||||||
import KVStore from '#models/kv_store'
|
import KVStore from '#models/kv_store'
|
||||||
import { KVStoreKey } from '../../types/kv_store.js'
|
import { KVStoreKey } from '../../types/kv_store.js'
|
||||||
import { parseBoolean } from '../utils/misc.js'
|
|
||||||
|
|
||||||
@inject()
|
@inject()
|
||||||
export class SystemService {
|
export class SystemService {
|
||||||
|
|
@ -306,33 +306,39 @@ export class SystemService {
|
||||||
if (!force) {
|
if (!force) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
updateAvailable: parseBoolean(cachedUpdateAvailable || "false"),
|
updateAvailable: cachedUpdateAvailable ?? false,
|
||||||
currentVersion,
|
currentVersion,
|
||||||
latestVersion: cachedLatestVersion || '',
|
latestVersion: cachedLatestVersion || '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await axios.get(
|
const earlyAccess = (await KVStore.getValue('system.earlyAccess')) ?? false
|
||||||
'https://api.github.com/repos/Crosstalk-Solutions/project-nomad/releases/latest',
|
|
||||||
{
|
|
||||||
headers: { Accept: 'application/vnd.github+json' },
|
|
||||||
timeout: 5000,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!response || !response.data?.tag_name) {
|
let latestVersion: string
|
||||||
throw new Error('Invalid response from GitHub API')
|
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'
|
const updateAvailable = process.env.NODE_ENV === 'development'
|
||||||
? false
|
? false
|
||||||
: this.isNewerVersion(latestVersion, currentVersion.trim())
|
: this.isNewerVersion(latestVersion, currentVersion.trim())
|
||||||
|
|
||||||
// Cache the results in KVStore for frontend checks
|
// 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)
|
await KVStore.setValue('system.latestVersion', latestVersion)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -473,19 +479,28 @@ export class SystemService {
|
||||||
* @returns true if version1 is newer than version2
|
* @returns true if version1 is newer than version2
|
||||||
*/
|
*/
|
||||||
private isNewerVersion(version1: string, version2: string): boolean {
|
private isNewerVersion(version1: string, version2: string): boolean {
|
||||||
const v1Parts = version1.split('.').map((part) => parseInt(part, 10))
|
const [base1, pre1] = version1.split('-')
|
||||||
const v2Parts = version2.split('.').map((part) => parseInt(part, 10))
|
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 maxLen = Math.max(v1Parts.length, v2Parts.length)
|
||||||
const v1Part = v1Parts[i] || 0
|
for (let i = 0; i < maxLen; i++) {
|
||||||
const v2Part = v2Parts[i] || 0
|
const a = v1Parts[i] || 0
|
||||||
|
const b = v2Parts[i] || 0
|
||||||
if (v1Part > v2Part) return true
|
if (a > b) return true
|
||||||
if (v1Part < v2Part) return false
|
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";
|
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 type { ContentUpdateCheckResult, ResourceUpdateInfo } from '../../../types/collections'
|
||||||
import api from '~/lib/api'
|
import api from '~/lib/api'
|
||||||
import Input from '~/components/inputs/Input'
|
import Input from '~/components/inputs/Input'
|
||||||
|
import Switch from '~/components/inputs/Switch'
|
||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation } from '@tanstack/react-query'
|
||||||
import { useNotifications } from '~/context/NotificationContext'
|
import { useNotifications } from '~/context/NotificationContext'
|
||||||
|
import { useSystemSetting } from '~/hooks/useSystemSetting'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
updateAvailable: boolean
|
||||||
|
latestVersion: string
|
||||||
|
currentVersion: string
|
||||||
|
earlyAccess: boolean
|
||||||
|
}
|
||||||
|
|
||||||
function ContentUpdatesSection() {
|
function ContentUpdatesSection() {
|
||||||
const { addNotification } = useNotifications()
|
const { addNotification } = useNotifications()
|
||||||
|
|
@ -99,7 +108,7 @@ function ContentUpdatesSection() {
|
||||||
<StyledSectionHeader title="Content Updates" />
|
<StyledSectionHeader title="Content Updates" />
|
||||||
|
|
||||||
<div className="bg-white rounded-lg border shadow-md overflow-hidden p-6">
|
<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">
|
<p className="text-desert-stone-dark">
|
||||||
Check if newer versions of your installed ZIM files and maps are available.
|
Check if newer versions of your installed ZIM files and maps are available.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -119,7 +128,7 @@ function ContentUpdatesSection() {
|
||||||
title="Update Check Issue"
|
title="Update Check Issue"
|
||||||
message={checkResult.error}
|
message={checkResult.error}
|
||||||
variant="bordered"
|
variant="bordered"
|
||||||
className="mb-4"
|
className="my-4"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -129,7 +138,7 @@ function ContentUpdatesSection() {
|
||||||
title="All Content Up to Date"
|
title="All Content Up to Date"
|
||||||
message="All your installed content is running the latest available version."
|
message="All your installed content is running the latest available version."
|
||||||
variant="bordered"
|
variant="bordered"
|
||||||
className="mb-4"
|
className="my-4"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -164,11 +173,10 @@ function ContentUpdatesSection() {
|
||||||
title: 'Type',
|
title: 'Type',
|
||||||
render: (record) => (
|
render: (record) => (
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${record.resource_type === 'zim'
|
||||||
record.resource_type === 'zim'
|
? 'bg-blue-100 text-blue-800'
|
||||||
? 'bg-blue-100 text-blue-800'
|
: 'bg-emerald-100 text-emerald-800'
|
||||||
: 'bg-emerald-100 text-emerald-800'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{record.resource_type === 'zim' ? 'ZIM' : 'Map'}
|
{record.resource_type === 'zim' ? 'ZIM' : 'Map'}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -215,13 +223,7 @@ function ContentUpdatesSection() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SystemUpdatePage(props: {
|
export default function SystemUpdatePage(props: { system: Props }) {
|
||||||
system: {
|
|
||||||
updateAvailable: boolean
|
|
||||||
latestVersion: string
|
|
||||||
currentVersion: string
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
const { addNotification } = useNotifications()
|
const { addNotification } = useNotifications()
|
||||||
|
|
||||||
const [isUpdating, setIsUpdating] = useState(false)
|
const [isUpdating, setIsUpdating] = useState(false)
|
||||||
|
|
@ -230,9 +232,16 @@ export default function SystemUpdatePage(props: {
|
||||||
const [showLogs, setShowLogs] = useState(false)
|
const [showLogs, setShowLogs] = useState(false)
|
||||||
const [logs, setLogs] = useState<string>('')
|
const [logs, setLogs] = useState<string>('')
|
||||||
const [email, setEmail] = useState('')
|
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 [showConnectionLostNotice, setShowConnectionLostNotice] = useState(false)
|
||||||
|
|
||||||
|
const earlyAccessSetting = useSystemSetting({
|
||||||
|
key: 'system.earlyAccess', initialData: {
|
||||||
|
key: 'system.earlyAccess',
|
||||||
|
value: props.system.earlyAccess,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isUpdating) return
|
if (!isUpdating) return
|
||||||
|
|
||||||
|
|
@ -243,7 +252,7 @@ export default function SystemUpdatePage(props: {
|
||||||
throw new Error('Failed to fetch update status')
|
throw new Error('Failed to fetch update status')
|
||||||
}
|
}
|
||||||
setUpdateStatus(response)
|
setUpdateStatus(response)
|
||||||
|
|
||||||
// If we can connect again, hide the connection lost notice
|
// If we can connect again, hide the connection lost notice
|
||||||
setShowConnectionLostNotice(false)
|
setShowConnectionLostNotice(false)
|
||||||
|
|
||||||
|
|
@ -363,6 +372,20 @@ export default function SystemUpdatePage(props: {
|
||||||
return <IconCircleCheck className="h-16 w-16 text-desert-olive" />
|
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({
|
const subscribeToReleaseNotesMutation = useMutation({
|
||||||
mutationKey: ['subscribeToReleaseNotes'],
|
mutationKey: ['subscribeToReleaseNotes'],
|
||||||
mutationFn: (email: string) => api.subscribeToReleaseNotes(email),
|
mutationFn: (email: string) => api.subscribeToReleaseNotes(email),
|
||||||
|
|
@ -594,6 +617,18 @@ export default function SystemUpdatePage(props: {
|
||||||
variant="solid"
|
variant="solid"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 />
|
<ContentUpdatesSection />
|
||||||
<div className="bg-white rounded-lg border shadow-md overflow-hidden py-6 mt-12">
|
<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">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div >
|
||||||
</SettingsLayout>
|
</SettingsLayout >
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ export const KV_STORE_SCHEMA = {
|
||||||
'rag.docsEmbedded': 'boolean',
|
'rag.docsEmbedded': 'boolean',
|
||||||
'system.updateAvailable': 'boolean',
|
'system.updateAvailable': 'boolean',
|
||||||
'system.latestVersion': 'string',
|
'system.latestVersion': 'string',
|
||||||
|
'system.earlyAccess': 'boolean',
|
||||||
'ui.hasVisitedEasySetup': 'boolean',
|
'ui.hasVisitedEasySetup': 'boolean',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user