diff --git a/ui/components/add-provider-model-dialog.tsx b/ui/components/add-provider-model-dialog.tsx index 9407d39f..c9f6984d 100644 --- a/ui/components/add-provider-model-dialog.tsx +++ b/ui/components/add-provider-model-dialog.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; diff --git a/ui/components/app-page-shell.tsx b/ui/components/app-page-shell.tsx index 0f2e7026..29b97736 100644 --- a/ui/components/app-page-shell.tsx +++ b/ui/components/app-page-shell.tsx @@ -21,6 +21,7 @@ import { adminLogout } from '@/lib/api/services/auth'; import { Button } from '@/components/ui/button'; import { CurrencyToggle } from '@/components/currency-toggle'; import { ThemeToggle } from '@/components/theme-toggle'; +import { VersionStatus } from '@/components/version-status'; import { Sheet, SheetClose, @@ -110,6 +111,7 @@ export function AppPageShell({

Routstr Node

+ + + +
+
+
+ {renderStatusIcon(status, cn('h-4 w-4 shrink-0', colorClass))} + Node Version +
+

+ {statusDescription} +

+
+ +
+ +
+
+
+ + Current + + {versionLabel} +
+ {currentVersion?.commit ? ( +
+ + Commit + + + {currentVersion.commit} + +
+ ) : null} +
+ +
+
+ + Latest release + + + {releaseQuery.isLoading + ? 'loading…' + : releaseQuery.isError + ? 'unavailable' + : releaseRateLimited + ? 'rate-limited' + : (releaseQuery.data?.tag_name ?? 'unknown')} + +
+ {releaseQuery.data?.published_at ? ( +
+ + Published + + + {formatReleaseDate(releaseQuery.data.published_at)} + +
+ ) : null} +
+ + {releaseQuery.isError ? ( +

+ Failed to fetch latest release from GitHub. +

+ ) : releaseRateLimited ? ( +

+ GitHub rate limit reached. Try again later. +

+ ) : null} +
+ +
+ + View release changelog + + +
+
+ + ); +} diff --git a/ui/lib/utils/version.ts b/ui/lib/utils/version.ts new file mode 100644 index 00000000..66b8c25d --- /dev/null +++ b/ui/lib/utils/version.ts @@ -0,0 +1,72 @@ +export interface ParsedVersion { + raw: string; + base: string; + parts: readonly number[]; + commit: string | null; +} + +export type StatusKind = + | 'unknown' + | 'outdated' + | 'ahead' + | 'commit-drift' + | 'current'; + +export function parseVersion( + raw: string | undefined | null +): ParsedVersion | null { + if (!raw) return null; + const trimmed = raw.trim().replace(/^v/i, ''); + if (!trimmed) return null; + const [base, commitPart] = trimmed.split('+', 2); + const parts = (base ?? '') + .split('.') + .map((segment) => Number.parseInt(segment, 10)) + .filter((value) => Number.isFinite(value)); + if (parts.length === 0) return null; + return { + raw, + base: base ?? '', + parts, + commit: commitPart ?? null, + }; +} + +export function compareVersionParts( + a: readonly number[], + b: readonly number[] +): number { + const length = Math.max(a.length, b.length); + for (let i = 0; i < length; i += 1) { + const diff = (a[i] ?? 0) - (b[i] ?? 0); + if (diff !== 0) return diff; + } + return 0; +} + +export function deriveStatus( + current: ParsedVersion | null, + latest: ParsedVersion | null +): StatusKind { + if (!current || !latest) return 'unknown'; + const cmp = compareVersionParts(current.parts, latest.parts); + if (cmp < 0) return 'outdated'; + if (cmp > 0) return 'ahead'; + return current.commit ? 'commit-drift' : 'current'; +} + +export function formatReleaseDate(iso: string | undefined): string | null { + if (!iso) return null; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return null; + return date.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); +} + +export function formatVersionLabel(version: ParsedVersion | null): string { + if (!version) return 'unknown'; + return version.raw.startsWith('v') ? version.raw : `v${version.raw}`; +}