'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 { 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 { 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 ; } if (status === 'commit-drift' || status === 'ahead' || status === 'unknown') { return ; } return ; } 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 => { 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 (
{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}
); }