Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ui/components/add-provider-model-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
11 changes: 8 additions & 3 deletions ui/components/app-page-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -110,6 +111,7 @@ export function AppPageShell({
<h1 className='truncate text-lg font-semibold tracking-tight whitespace-nowrap'>
Routstr Node
</h1>
<VersionStatus className='mt-0.5' />
</div>
</div>
<Button
Expand Down Expand Up @@ -279,9 +281,12 @@ export function AppPageShell({
height={24}
className='rounded-sm'
/>
<p className='truncate text-base font-medium tracking-tight'>
Routstr Node
</p>
<div className='min-w-0'>
<p className='truncate text-base font-medium tracking-tight'>
Routstr Node
</p>
<VersionStatus className='mt-0.5' />
</div>
</div>
<SheetClose asChild>
<Button
Expand Down
282 changes: 282 additions & 0 deletions ui/components/version-status.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
'use client';

import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
AlertTriangleIcon,
CheckCircle2Icon,
ExternalLinkIcon,
InfoIcon,
Loader2Icon,
RefreshCwIcon,
} from 'lucide-react';
import { ConfigurationService } from '@/lib/api/services/configuration';
import { Button } from '@/components/ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import {
deriveStatus,
formatReleaseDate,
formatVersionLabel,
parseVersion,
type StatusKind,
} from '@/lib/utils/version';

interface NodeInfo {
version?: string;
}

interface GithubRelease {
tag_name: string;
name?: string;
html_url: string;
published_at?: string;
body?: string;
}

const NODE_QUERY_KEY = ['node-version'] as const;
const RELEASE_QUERY_KEY = ['routstr-latest-release'] as const;
const THIRTY_MINUTES = 30 * 60 * 1000;

const GITHUB_RELEASES_URL = `https://api.github.com/repos/Routstr/routstr-core/releases/latest`;
const RELEASES_PAGE_URL = `https://github.com/Routstr/routstr-core/releases`;

async function fetchNodeInfo(): Promise<NodeInfo> {
const baseUrl = ConfigurationService.getLocalBaseUrl().replace(/\/+$/, '');
const response = await fetch(`${baseUrl}/v1/info`, {
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
throw new Error('Unable to load node info');
}
return (await response.json()) as NodeInfo;
}

async function fetchLatestRelease(): Promise<GithubRelease | null> {
const response = await fetch(GITHUB_RELEASES_URL, {
headers: { Accept: 'application/vnd.github+json' },
});
if (response.status === 403 || response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`GitHub responded ${response.status}`);
}
return (await response.json()) as GithubRelease;
}

function pickColorClass(status: StatusKind): string {
if (status === 'outdated') return 'text-amber-600 dark:text-amber-400';
if (status === 'unknown') return 'text-muted-foreground';
if (status === 'ahead' || status === 'commit-drift') {
return 'text-sky-600 dark:text-sky-400';
}
return 'text-emerald-600 dark:text-emerald-400';
}

function renderStatusIcon(status: StatusKind, className: string) {
if (status === 'outdated') {
return <AlertTriangleIcon className={className} />;
}
if (status === 'commit-drift' || status === 'ahead' || status === 'unknown') {
return <InfoIcon className={className} />;
}
return <CheckCircle2Icon className={className} />;
}

function describeStatus(status: StatusKind): string {
if (status === 'outdated') return 'A newer release is available.';
if (status === 'commit-drift') {
return 'Running release version on a non-release commit.';
}
if (status === 'ahead') {
return 'Running ahead of the latest published release.';
}
if (status === 'current') return 'Up to date with the latest release.';
return 'Version status unavailable.';
}

interface VersionStatusProps {
variant?: 'expanded' | 'compact';
className?: string;
}

export function VersionStatus({
variant = 'expanded',
className,
}: VersionStatusProps) {
const queryClient = useQueryClient();

const nodeQuery = useQuery({
queryKey: NODE_QUERY_KEY,
queryFn: fetchNodeInfo,
staleTime: THIRTY_MINUTES,
retry: 1,
});

const releaseQuery = useQuery({
queryKey: RELEASE_QUERY_KEY,
queryFn: fetchLatestRelease,
staleTime: THIRTY_MINUTES,
refetchInterval: THIRTY_MINUTES,
refetchOnWindowFocus: false,
retry: 1,
});

const currentVersion = parseVersion(nodeQuery.data?.version);
const latestVersion = parseVersion(releaseQuery.data?.tag_name);
const status = deriveStatus(currentVersion, latestVersion);

const isRefreshing = releaseQuery.isFetching || nodeQuery.isFetching;

const handleRefresh = async (): Promise<void> => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: NODE_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: RELEASE_QUERY_KEY }),
]);
};

const colorClass = pickColorClass(status);
const versionLabel = currentVersion
? formatVersionLabel(currentVersion)
: nodeQuery.isLoading
? '…'
: 'unknown';

if (!nodeQuery.data && nodeQuery.isLoading && variant === 'expanded') {
return null;
}

const statusDescription = describeStatus(status);
const ariaLabel = `Node version ${versionLabel}. ${statusDescription} Click for details.`;

const releaseRateLimited = releaseQuery.data === null;

return (
<Popover>
<PopoverTrigger asChild>
<button
type='button'
onClick={(e) => e.stopPropagation()}
className={cn(
'hover:bg-accent/40 inline-flex items-center gap-1 rounded-md px-1 py-0.5 font-mono text-[10px] leading-tight transition-colors',
colorClass,
className
)}
title='View version details'
aria-label={ariaLabel}
>
{renderStatusIcon(status, 'h-3 w-3 shrink-0')}
<span className='truncate'>{versionLabel}</span>
</button>
</PopoverTrigger>
<PopoverContent
side='bottom'
align='start'
sideOffset={6}
className='w-72 p-3'
>
<div className='flex items-start justify-between gap-2'>
<div className='min-w-0 space-y-0.5'>
<div className='flex items-center gap-1.5 text-sm font-medium'>
{renderStatusIcon(status, cn('h-4 w-4 shrink-0', colorClass))}
Node Version
</div>
<p className='text-muted-foreground text-xs leading-snug'>
{statusDescription}
</p>
</div>
<Button
type='button'
variant='outline'
size='icon'
className='h-7 w-7 shrink-0'
onClick={handleRefresh}
disabled={isRefreshing}
title='Check for latest release'
>
{isRefreshing ? (
<Loader2Icon className='h-3.5 w-3.5 animate-spin' />
) : (
<RefreshCwIcon className='h-3.5 w-3.5' />
)}
<span className='sr-only'>Check for latest release</span>
</Button>
</div>

<div className='mt-3 space-y-2'>
<div className='border-border/60 bg-card/30 grid gap-1.5 rounded-md border p-2'>
<div className='flex items-center justify-between gap-3'>
<span className='text-muted-foreground text-[10px] tracking-wide uppercase'>
Current
</span>
<span className='font-mono text-xs'>{versionLabel}</span>
</div>
{currentVersion?.commit ? (
<div className='flex items-center justify-between gap-3'>
<span className='text-muted-foreground text-[10px] tracking-wide uppercase'>
Commit
</span>
<code className='font-mono text-[11px]'>
{currentVersion.commit}
</code>
</div>
) : null}
</div>

<div className='border-border/60 bg-card/30 grid gap-1.5 rounded-md border p-2'>
<div className='flex items-center justify-between gap-3'>
<span className='text-muted-foreground text-[10px] tracking-wide uppercase'>
Latest release
</span>
<span className='font-mono text-xs'>
{releaseQuery.isLoading
? 'loading…'
: releaseQuery.isError
? 'unavailable'
: releaseRateLimited
? 'rate-limited'
: (releaseQuery.data?.tag_name ?? 'unknown')}
</span>
</div>
{releaseQuery.data?.published_at ? (
<div className='flex items-center justify-between gap-3'>
<span className='text-muted-foreground text-[10px] tracking-wide uppercase'>
Published
</span>
<span className='text-[11px]'>
{formatReleaseDate(releaseQuery.data.published_at)}
</span>
</div>
) : null}
</div>

{releaseQuery.isError ? (
<p className='text-muted-foreground text-[11px]'>
Failed to fetch latest release from GitHub.
</p>
) : releaseRateLimited ? (
<p className='text-muted-foreground text-[11px]'>
GitHub rate limit reached. Try again later.
</p>
) : null}
</div>

<div className='border-border/60 mt-3 border-t pt-2'>
<a
href={releaseQuery.data?.html_url ?? RELEASES_PAGE_URL}
target='_blank'
rel='noopener noreferrer'
className='text-primary inline-flex items-center gap-1 text-xs hover:underline'
>
View release changelog
<ExternalLinkIcon className='h-3 w-3' />
</a>
</div>
</PopoverContent>
</Popover>
);
}
72 changes: 72 additions & 0 deletions ui/lib/utils/version.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
Loading