Compare commits

...
Author SHA1 Message Date
redshift 70ef3c357c fixed the encoding bug. 2026-05-19 10:53:59 +08:00
redshift e4165f1dab passing through tee get requests 2026-05-19 10:53:59 +08:00
9qeklajcandGitHub 6a2abd3439 Merge pull request #515 from Routstr/fix-value-not-set-issue
fix missing field initialization
2026-05-17 14:46:33 +02:00
9qeklajc 1e4ed75179 fix missing field initialization 2026-05-17 14:43:50 +02:00
9qeklajcandGitHub 3a1902b2b5 Merge pull request #512 from Routstr/sidebar-display-logo
better logo display
2026-05-16 23:56:20 +02:00
9qeklajc ba1978f830 better logo display 2026-05-16 23:52:03 +02:00
9qeklajcandGitHub 2f51d62a46 Merge pull request #511 from Routstr/enforce-provider-fee-calculation
enforce fee calculation
2026-05-16 23:17:13 +02:00
9qeklajc 1ca344ba7b enforce fee calculation 2026-05-16 23:13:47 +02:00
9qeklajcandGitHub dda9b848ba Merge pull request #510 from Routstr/display-correct-version
display correct commit
2026-05-16 21:14:26 +02:00
9qeklajc 033d8c5a38 display correct commit 2026-05-16 19:10:45 +02:00
9qeklajcandGitHub e73cdffdc5 Merge pull request #508 from Routstr/display-official-version
display version
2026-05-16 15:46:43 +02:00
9qeklajcandGitHub e32b332eb5 Merge pull request #509 from Routstr/reduce-verbose-logs
less verbose logs and more precise
2026-05-16 15:41:20 +02:00
9qeklajc 89b84392a4 lint 2026-05-16 15:40:58 +02:00
9qeklajc f37ff30598 display version 2026-05-16 15:35:16 +02:00
9qeklajc 547f45c185 less verbose logs and more precise 2026-05-16 15:29:41 +02:00
9qeklajcandGitHub a6e81a83cb Merge pull request #507 from Routstr/fix-provider-forwarding-upstream
Fix provider forwarding upstream
2026-05-15 20:45:19 +02:00
8 changed files with 589 additions and 104 deletions
-5
View File
@@ -57,11 +57,6 @@ def _on_tagged_release() -> bool:
described = _run_git("describe", "--tags", "--exact-match", "HEAD")
if described and described.lstrip("v") == BASE_VERSION:
return True
current_branch = _run_git("rev-parse", "--abbrev-ref", "HEAD")
if current_branch == "main":
branch_tag = _run_git("describe", "--tags", "--abbrev=0", "HEAD")
if branch_tag and branch_tag.lstrip("v") == BASE_VERSION:
return True
return False
+20
View File
@@ -336,6 +336,22 @@ def _get_pricing_rates(
raise ValueError("Invalid pricing data") from e
def _resolve_provider_fee(model_id: str) -> float:
"""Resolve the provider fee multiplier for the given model id.
Falls back to 1.0 (no markup) when the provider cannot be resolved so
the USD cost path never silently double-applies or omits the fee.
"""
from ..proxy import get_provider_for_model
if not model_id:
return 1.0
providers = get_provider_for_model(model_id)
if not providers:
return 1.0
return float(providers[0].provider_fee)
def _calculate_from_usd_cost(
usd_cost: float,
input_usd: float,
@@ -347,6 +363,10 @@ def _calculate_from_usd_cost(
response_data: dict,
) -> CostData:
"""Calculate cost from USD figures, deriving input/output split from tokens."""
provider_fee = _resolve_provider_fee(response_data.get("model", ""))
usd_cost = usd_cost * provider_fee
input_usd = input_usd * provider_fee
output_usd = output_usd * provider_fee
sats_per_usd = 1.0 / sats_usd_price()
cost_in_sats = usd_cost * sats_per_usd
cost_in_msats = math.ceil(cost_in_sats * 1000)
+68 -9
View File
@@ -162,6 +162,7 @@ _API_PATH_PREFIXES = (
"images/",
"moderations",
"providers",
"tee/",
)
@@ -182,6 +183,40 @@ async def proxy(
request_body = await request.body()
request_body_dict = parse_request_body_json(request_body, path)
# /tee/* GET requests (e.g. attestation) don't map to models — just
# forward to all enabled upstreams without model/cost/auth lookups.
if request.method == "GET" and path.startswith("tee/"):
all_upstreams = _upstreams
last_error_response = None
for i, upstream in enumerate(all_upstreams):
try:
headers = upstream.prepare_headers(dict(request.headers))
response = await upstream.forward_get_request(request, path, headers)
if response.status_code in [502, 429] and i < len(all_upstreams) - 1:
logger.warning(
"Upstream %s returned %s for tee GET %s, trying next",
upstream.provider_type,
response.status_code,
path,
)
continue
return response
except UpstreamError as e:
logger.warning(
"Upstream %s failed for tee GET %s: %s",
upstream.provider_type,
path,
e,
)
if i == len(all_upstreams) - 1:
last_error_response = create_error_response(
"upstream_error", str(e), 502, request=request
)
continue
return last_error_response or create_error_response(
"upstream_error", "All upstreams failed", 502, request=request
)
if is_responses_api:
model_id = extract_model_from_responses_request(request_body_dict)
else:
@@ -232,7 +267,15 @@ async def proxy(
)
except UpstreamError as e:
logger.warning(
f"Upstream {upstream.provider_type} failed (x-cashu): {e}"
"Upstream %s failed (x-cashu) for model=%s: %s",
upstream.provider_type,
model_id,
e,
extra={
"provider": upstream.provider_type,
"model": model_id,
"status_code": e.status_code,
},
)
if i == len(upstreams) - 1:
last_error = e
@@ -377,10 +420,14 @@ async def proxy(
)
logger.warning(
f"Upstream {upstream.provider_type} returned {response.status_code}, trying next provider",
"Upstream %s returned %s for model=%s, trying next provider",
upstream.provider_type,
response.status_code,
model_id,
extra={
"status_code": response.status_code,
"upstream": upstream.provider_type,
"provider": upstream.provider_type,
"model": model_id,
},
)
continue
@@ -388,16 +435,20 @@ async def proxy(
# 4xx error (user error), or other non-retryable error, or last provider failed
await revert_pay_for_request(key, session, max_cost_for_model)
logger.warning(
"Upstream request failed, revert payment",
"Upstream request failed, revert payment "
"(provider=%s model=%s status=%s path=%s)",
upstream.provider_type,
model_id,
response.status_code,
path,
extra={
"status_code": response.status_code,
"path": path,
"provider": upstream.provider_type,
"model": model_id,
"key_hash": key.hashed_key[:8] + "...",
"key_balance": key.balance,
"max_cost_for_model": max_cost_for_model,
"upstream_headers": response.headers
if hasattr(response, "headers")
else None,
},
)
return response
@@ -406,8 +457,16 @@ async def proxy(
except UpstreamError as e:
logger.warning(
f"Upstream {upstream.provider_type} failed: {e}",
extra={"retry": i < len(upstreams) - 1},
"Upstream %s failed for model=%s: %s",
upstream.provider_type,
model_id,
e,
extra={
"provider": upstream.provider_type,
"model": model_id,
"status_code": e.status_code,
"retry": i < len(upstreams) - 1,
},
)
# If this was the last provider
+104 -66
View File
@@ -533,10 +533,13 @@ class BaseUpstreamProvider:
pass
async def forward_upstream_error_response(
self, request: Request, path: str, upstream_response: httpx.Response
self,
request: Request,
path: str,
upstream_response: httpx.Response,
model_id: str | None = None,
) -> Response:
"""Log upstream errors and forward the response in a JSON envelope.
"""
"""Log upstream errors and forward the response in a JSON envelope."""
status_code = upstream_response.status_code
headers = dict(upstream_response.headers)
content_type = headers.get("content-type") or headers.get("Content-Type", "")
@@ -561,10 +564,16 @@ class BaseUpstreamProvider:
is_json_body = _is_json_content_type(content_type)
logger.warning(
"Forwarding upstream error response",
"Upstream %s returned %s for model=%s path=%s: %s",
self.provider_type,
status_code,
model_id or "unknown",
path,
(message or body_preview or "<empty>")[:300],
extra={
"path": path,
"provider": self.provider_type,
"model": model_id or "unknown",
"upstream_status": status_code,
"upstream_code": upstream_code,
"upstream_content_type": content_type,
@@ -655,7 +664,7 @@ class BaseUpstreamProvider:
Returns:
StreamingResponse with cost data injected at the end
"""
logger.info(
logger.debug(
"Processing streaming chat completion",
extra={
"key_hash": key.hashed_key[:8] + "...",
@@ -867,7 +876,7 @@ class BaseUpstreamProvider:
Returns:
Response with cost data added to JSON body
"""
logger.info(
logger.debug(
"Processing non-streaming chat completion",
extra={
"key_hash": key.hashed_key[:8] + "...",
@@ -926,7 +935,7 @@ class BaseUpstreamProvider:
response_json["cost"]["sats_cost"] = cost_data.get("total_msats", 0) // 1000
response_json["cost"]["remaining_balance_msats"] = remaining_balance_msats
logger.info(
logger.debug(
"Payment adjustment completed for non-streaming",
extra={
"key_hash": key.hashed_key[:8] + "...",
@@ -1003,7 +1012,7 @@ class BaseUpstreamProvider:
Returns:
StreamingResponse with cost data injected at the end
"""
logger.info(
logger.debug(
"Processing streaming Responses API completion",
extra={
"key_hash": key.hashed_key[:8] + "...",
@@ -1239,7 +1248,7 @@ class BaseUpstreamProvider:
Returns:
Response with cost data added to JSON body
"""
logger.info(
logger.debug(
"Processing non-streaming Responses API completion",
extra={
"key_hash": key.hashed_key[:8] + "...",
@@ -1301,7 +1310,7 @@ class BaseUpstreamProvider:
response_json["cost"]["sats_cost"] = cost_data.get("total_msats", 0) // 1000
response_json["cost"]["remaining_balance_msats"] = remaining_balance_msats
logger.info(
logger.debug(
"Payment adjustment completed for non-streaming Responses API",
extra={
"key_hash": key.hashed_key[:8] + "...",
@@ -1382,7 +1391,7 @@ class BaseUpstreamProvider:
session,
max_cost,
)
logger.info(
logger.debug(
"Finalized generic streaming payment in background",
extra={
"path": path,
@@ -2224,15 +2233,15 @@ class BaseUpstreamProvider:
transformed_body = self.prepare_request_body(request_body, model_obj)
logger.info(
logger.debug(
"Forwarding request to upstream",
extra={
"url": url,
"method": request.method,
"path": path,
"model": original_model_id or "unknown",
"provider": self.provider_type,
"key_hash": key.hashed_key[:8] + "...",
"key_balance": key.balance,
"has_request_body": request_body is not None,
},
)
@@ -2265,41 +2274,43 @@ class BaseUpstreamProvider:
stream=True,
)
if response.status_code != 200:
logger.error(
"Received upstream response",
extra={
"reason_phrase": response.reason_phrase,
"status_code": response.status_code,
"path": path,
"key_hash": key.hashed_key[:8] + "...",
"content_type": response.headers.get("content-type", "unknown"),
},
)
else:
logger.info(
"Received upstream response",
extra={
"reason_phrase": response.reason_phrase,
"status_code": response.status_code,
"path": path,
"key_hash": key.hashed_key[:8] + "...",
"content_type": response.headers.get("content-type", "unknown"),
},
)
if response.status_code != 200:
if response.status_code >= 500:
try:
body_bytes = await response.aread()
except Exception:
body_bytes = b""
body_preview = body_bytes.decode(
"utf-8", errors="ignore"
).strip()[:500]
logger.error(
"Upstream %s returned %s for model=%s path=%s: %s",
self.provider_type,
response.status_code,
original_model_id or "unknown",
path,
body_preview or "<empty>",
extra={
"provider": self.provider_type,
"model": original_model_id or "unknown",
"status_code": response.status_code,
"reason_phrase": response.reason_phrase,
"path": path,
"body_preview": body_preview,
},
)
await response.aclose()
await client.aclose()
raise UpstreamError(
f"Upstream returned status {response.status_code}",
f"Upstream {self.provider_type} returned {response.status_code} "
f"for model {original_model_id or 'unknown'}: "
f"{body_preview[:200] or '<empty>'}",
status_code=response.status_code,
)
try:
mapped_error = await self.forward_upstream_error_response(
request, path, response
request, path, response, model_id=original_model_id
)
finally:
await response.aclose()
@@ -2544,15 +2555,15 @@ class BaseUpstreamProvider:
transformed_body = self.prepare_responses_request_body(request_body, model_obj)
logger.info(
logger.debug(
"Forwarding Responses API request to upstream",
extra={
"url": url,
"method": request.method,
"path": path,
"model": original_model_id or "unknown",
"provider": self.provider_type,
"key_hash": key.hashed_key[:8] + "...",
"key_balance": key.balance,
"has_request_body": request_body is not None,
},
)
@@ -2585,28 +2596,42 @@ class BaseUpstreamProvider:
stream=True,
)
logger.info(
"Received upstream Responses API response",
extra={
"status_code": response.status_code,
"path": path,
"key_hash": key.hashed_key[:8] + "...",
"content_type": response.headers.get("content-type", "unknown"),
},
)
if response.status_code != 200:
if response.status_code >= 500:
try:
body_bytes = await response.aread()
except Exception:
body_bytes = b""
body_preview = body_bytes.decode(
"utf-8", errors="ignore"
).strip()[:500]
logger.error(
"Upstream %s returned %s for model=%s path=%s: %s",
self.provider_type,
response.status_code,
original_model_id or "unknown",
path,
body_preview or "<empty>",
extra={
"provider": self.provider_type,
"model": original_model_id or "unknown",
"status_code": response.status_code,
"path": path,
"body_preview": body_preview,
},
)
await response.aclose()
await client.aclose()
raise UpstreamError(
f"Upstream returned status {response.status_code}",
f"Upstream {self.provider_type} returned {response.status_code} "
f"for model {original_model_id or 'unknown'}: "
f"{body_preview[:200] or '<empty>'}",
status_code=response.status_code,
)
try:
mapped_error = await self.forward_upstream_error_response(
request, path, response
request, path, response, model_id=original_model_id
)
finally:
await response.aclose()
@@ -2751,9 +2776,14 @@ class BaseUpstreamProvider:
path = self.normalize_request_path(path)
url = self.build_request_url(path)
logger.info(
logger.debug(
"Forwarding GET request to upstream",
extra={"url": url, "method": request.method, "path": path},
extra={
"url": url,
"method": request.method,
"path": path,
"provider": self.provider_type,
},
)
async with httpx.AsyncClient(
@@ -2771,9 +2801,13 @@ class BaseUpstreamProvider:
),
)
logger.info(
"GET request forwarded successfully",
extra={"path": path, "status_code": response.status_code},
logger.debug(
"GET request forwarded",
extra={
"path": path,
"status_code": response.status_code,
"provider": self.provider_type,
},
)
if response.status_code != 200:
try:
@@ -2784,10 +2818,13 @@ class BaseUpstreamProvider:
await response.aclose()
return mapped
response_headers = dict(response.headers)
response_headers.pop("content-encoding", None)
response_headers.pop("content-length", None)
return StreamingResponse(
response.aiter_bytes(),
status_code=response.status_code,
headers=dict(response.headers),
headers=response_headers,
)
except Exception as exc:
tb = traceback.format_exc()
@@ -3007,6 +3044,7 @@ class BaseUpstreamProvider:
usage_data = None
model = None
cost_data: CostData | MaxCostData | None = None
lines = content_str.strip().split("\n")
for line in lines:
@@ -3060,7 +3098,7 @@ class BaseUpstreamProvider:
raise ValueError(f"Invalid unit: {unit}")
if refund_amount > 0:
logger.info(
logger.debug(
"Processing refund for streaming response",
extra={
"original_amount": amount,
@@ -3207,7 +3245,7 @@ class BaseUpstreamProvider:
else:
raise ValueError(f"Invalid unit: {unit}")
logger.info(
logger.debug(
"Processing non-streaming response cost calculation",
extra={
"original_amount": amount,
@@ -3603,7 +3641,7 @@ class BaseUpstreamProvider:
Returns:
Response or StreamingResponse from upstream with refund if applicable
"""
logger.info(
logger.debug(
"Processing X-Cashu payment for Responses API",
extra={
"path": path,
@@ -4035,7 +4073,7 @@ class BaseUpstreamProvider:
raise ValueError(f"Invalid unit: {unit}")
if refund_amount > 0:
logger.info(
logger.debug(
"Processing refund for streaming Responses API response",
extra={
"original_amount": amount,
@@ -4171,7 +4209,7 @@ class BaseUpstreamProvider:
else:
raise ValueError(f"Invalid unit: {unit}")
logger.info(
logger.debug(
"Processing non-streaming Responses API cost calculation",
extra={
"original_amount": amount,
@@ -4272,7 +4310,7 @@ class BaseUpstreamProvider:
Returns:
Response or StreamingResponse from upstream with refund if applicable
"""
logger.info(
logger.debug(
"Processing X-Cashu payment request",
extra={
"path": path,
+1 -1
View File
@@ -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';
+42 -23
View File
@@ -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,
@@ -90,8 +91,18 @@ export function AppPageShell({
isSidebarCollapsed && 'px-0'
)}
>
<div className='flex items-center gap-2'>
<div className='flex min-w-0 flex-1 items-center gap-2 overflow-hidden'>
<div
className={cn(
'flex items-center gap-2',
isSidebarCollapsed && 'justify-center'
)}
>
<div
className={cn(
'flex min-w-0 items-center gap-2 overflow-hidden',
!isSidebarCollapsed && 'flex-1'
)}
>
<Image
src='/icon.ico'
alt='Routstr Node'
@@ -110,26 +121,9 @@ 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
variant='ghost'
size='icon'
className={cn(
'text-muted-foreground hover:text-foreground h-8 w-8 shrink-0 transition-transform duration-300 ease-in-out',
isSidebarCollapsed ? 'mx-auto' : '-mr-1 ml-auto'
)}
onClick={() => setIsSidebarCollapsed((current) => !current)}
>
{isSidebarCollapsed ? (
<PanelLeftOpenIcon className='h-4 w-4' />
) : (
<PanelLeftCloseIcon className='h-4 w-4' />
)}
<span className='sr-only'>
{isSidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
</span>
</Button>
</div>
</div>
@@ -230,6 +224,28 @@ export function AppPageShell({
</Button>
</div>
)}
<Button
variant='ghost'
size={isSidebarCollapsed ? 'icon' : 'sm'}
className={cn(
'text-muted-foreground hover:text-foreground transition-[width,padding] duration-300 ease-in-out',
isSidebarCollapsed
? 'mx-auto h-8 w-8'
: 'h-8 w-full justify-start gap-1.5 rounded-md px-2.5 text-[11px]'
)}
onClick={() => setIsSidebarCollapsed((current) => !current)}
>
{isSidebarCollapsed ? (
<PanelLeftOpenIcon className='h-4 w-4' />
) : (
<PanelLeftCloseIcon className='h-4 w-4' />
)}
{isSidebarCollapsed ? (
<span className='sr-only'>Expand sidebar</span>
) : (
'Collapse'
)}
</Button>
</div>
</aside>
@@ -279,9 +295,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
+282
View File
@@ -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
View File
@@ -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}`;
}