Compare commits

...

11 Commits

Author SHA1 Message Date
9qeklajc
89b84392a4 lint 2026-05-16 15:40:58 +02:00
9qeklajc
f37ff30598 display version 2026-05-16 15:35:16 +02:00
9qeklajc
a6e81a83cb Merge pull request #507 from Routstr/fix-provider-forwarding-upstream
Fix provider forwarding upstream
2026-05-15 20:45:19 +02:00
9qeklajc
e2143aa173 Revert "fix home nav"
This reverts commit 2eb257c2a6.
2026-05-14 15:51:20 +02:00
9qeklajc
2eb257c2a6 fix home nav 2026-05-14 15:47:59 +02:00
9qeklajc
490687bb71 Merge branch 'main' into fix-provider-forwarding-upstream 2026-05-14 15:40:52 +02:00
9qeklajc
966613847e update not found proxy 2026-05-14 15:40:49 +02:00
9qeklajc
d030d86f9a Merge pull request #506 from Routstr/fix-swapping
fix swapping
2026-05-14 14:32:39 +02:00
9qeklajc
d9ab46b3bb make sure when topup from untrusted mint to refund from primary mint 2026-05-14 13:52:25 +02:00
9qeklajc
6e596e3860 Merge pull request #504 from Routstr/do-not-create-empty-token
no key creation when refund
2026-05-13 22:48:16 +02:00
9qeklajc
9d5e14903b fix swapping 2026-05-13 21:49:03 +02:00
11 changed files with 608 additions and 93 deletions

View File

@@ -354,21 +354,26 @@ async def refund_wallet_endpoint(
)
# --- MINT: balance is locked at zero, safe to create the refund token ---
# Proofs from untrusted mints are swapped to primary_mint on receive.
# Use primary_mint unless key.refund_mint_url is an explicitly trusted mint.
effective_refund_mint = (
key.refund_mint_url
if key.refund_mint_url and key.refund_mint_url in settings.cashu_mints
else settings.primary_mint
)
try:
if key.refund_address:
from .core.settings import settings as global_settings
await send_to_lnurl(
remaining_balance,
key.refund_currency or "sat",
key.refund_mint_url or global_settings.primary_mint,
effective_refund_mint,
key.refund_address,
)
result = {"recipient": key.refund_address}
else:
refund_currency = key.refund_currency or "sat"
token = await send_token(
remaining_balance, refund_currency, key.refund_mint_url
remaining_balance, refund_currency, effective_refund_mint
)
result = {"token": token}
@@ -396,15 +401,26 @@ async def refund_wallet_endpoint(
# Minting failed — restore the debited balance
await _restore_balance(session, key.hashed_key, pre_debit_balance, pre_debit_reserved, key.refund_mint_url or "")
error_msg = str(e)
logger.error(
"refund_wallet_endpoint: mint/send failed",
extra={
"error": error_msg,
"error_type": type(e).__name__,
"hashed_key": key.hashed_key,
"remaining_balance": remaining_balance,
"refund_currency": key.refund_currency,
"refund_mint_url": key.refund_mint_url,
"has_refund_address": bool(key.refund_address),
},
)
if (
"mint" in error_msg.lower()
or "connection" in error_msg.lower()
or isinstance(e, Exception)
and "ConnectError" in str(type(e))
or "ConnectError" in str(type(e))
):
raise HTTPException(status_code=503, detail="Mint service unavailable")
raise HTTPException(status_code=503, detail=f"Mint service unavailable: {error_msg}")
else:
raise HTTPException(status_code=500, detail="Refund failed")
raise HTTPException(status_code=500, detail=f"Refund failed: {error_msg}")
await _refund_cache_set(bearer_value, result)

View File

@@ -30,6 +30,7 @@ from .db import create_session, init_db, run_migrations
from .exceptions import general_exception_handler, http_exception_handler
from .logging import get_logger, setup_logging
from .middleware import LoggingMiddleware
from .not_found import _NOT_FOUND_HTML, not_found_catch_all # noqa: F401
from .settings import SettingsService
from .settings import settings as global_settings
from .version import __version__

55
routstr/core/not_found.py Normal file
View File

@@ -0,0 +1,55 @@
"""Shared 404 handler used by the proxy catch-all and tests."""
from __future__ import annotations
from pathlib import Path
from fastapi import Request
from fastapi.responses import HTMLResponse, JSONResponse, Response
_NOT_FOUND_HTML_FILE = Path(__file__).parent.parent.parent / "ui_out" / "404.html"
def _read_not_found_html() -> str | None:
try:
return _NOT_FOUND_HTML_FILE.read_text(encoding="utf-8")
except OSError:
return None
_NOT_FOUND_HTML: str | None = _read_not_found_html()
def build_not_found_response(request: Request, path: str) -> Response:
"""Return a 404 response.
HTML 404 page only for GET requests from browsers (Accept: text/html).
All POST requests and API clients receive a JSON 404.
"""
accept = request.headers.get("accept", "").lower()
prefers_html = (
request.method == "GET"
and "text/html" in accept
and "application/json" not in accept
)
request_id = getattr(request.state, "request_id", "unknown")
if prefers_html and _NOT_FOUND_HTML is not None:
return HTMLResponse(content=_NOT_FOUND_HTML, status_code=404)
return JSONResponse(
status_code=404,
content={
"error": {
"message": f"Path '/{path}' not found",
"type": "not_found",
"code": 404,
},
"request_id": request_id,
},
)
async def not_found_catch_all(request: Request, path: str) -> Response:
"""ASGI handler form of :func:`build_not_found_response`."""
return build_not_found_response(request, path)

View File

@@ -1,9 +1,8 @@
import json
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
from fastapi.responses import Response, StreamingResponse
from sqlmodel import select
from .algorithm import create_model_mappings
@@ -18,6 +17,7 @@ from .core.db import (
get_session,
)
from .core.exceptions import UpstreamError
from .core.not_found import build_not_found_response
from .core.settings import settings
from .payment.helpers import (
calculate_discounted_max_cost,
@@ -151,50 +151,30 @@ async def refresh_model_maps_periodically() -> None:
)
_API_PATH_PREFIXES = ("v1/", "responses")
_NOT_FOUND_HTML_FILE = Path(__file__).parent.parent / "ui_out" / "404.html"
def _read_not_found_html() -> str | None:
try:
return _NOT_FOUND_HTML_FILE.read_text(encoding="utf-8")
except OSError:
return None
_NOT_FOUND_HTML: str | None = _read_not_found_html()
def _build_not_found_response(request: Request, path: str) -> Response:
"""Return a 404 for unknown paths.
"""
accept = request.headers.get("accept", "").lower()
prefers_json = "application/json" in accept and "text/html" not in accept
request_id = getattr(request.state, "request_id", "unknown")
if not prefers_json and _NOT_FOUND_HTML is not None:
return HTMLResponse(content=_NOT_FOUND_HTML, status_code=404)
return JSONResponse(
status_code=404,
content={
"error": {
"message": f"Path '/{path}' not found",
"type": "not_found",
"code": 404,
},
"request_id": request_id,
},
)
_API_PATH_PREFIXES = (
"v1/",
"responses",
"chat/",
"completions",
"models",
"embeddings",
"audio/",
"images/",
"moderations",
"providers",
)
@proxy_router.api_route("/{path:path}", methods=["GET", "POST"], response_model=None)
async def proxy(
request: Request, path: str, session: AsyncSession = Depends(get_session)
) -> Response | StreamingResponse:
if not path.startswith(_API_PATH_PREFIXES):
return _build_not_found_response(request, path)
# GET requests must hit a known API prefix; otherwise return a 404 (HTML
# for browsers, JSON for API clients). POST requests are always forwarded
# so that OpenAI-style endpoints work with or without the `v1/` prefix
# (e.g. `/chat/completions` as well as `/v1/chat/completions`).
if request.method == "GET" and not path.startswith(_API_PATH_PREFIXES):
return build_not_found_response(request, path)
headers = dict(request.headers)

View File

@@ -58,17 +58,56 @@ async def recieve_token(
async def send(amount: int, unit: str, mint_url: str | None = None) -> tuple[int, str]:
"""Internal send function - returns amount and serialized token"""
wallet: Wallet = await get_wallet(mint_url or settings.primary_mint, unit)
proofs = get_proofs_per_mint_and_unit(
wallet, mint_url or settings.primary_mint, unit
effective_mint_url = mint_url or settings.primary_mint
wallet: Wallet = await get_wallet(effective_mint_url, unit)
proofs = get_proofs_per_mint_and_unit(wallet, effective_mint_url, unit)
proofs_for_mint = sum(p.amount for p in proofs)
# Fallback: proofs from untrusted source mints are swapped to primary_mint
# during receive, so the user's preferred refund_mint_url may have no proofs
# even though the global wallet has the balance.
if proofs_for_mint < amount and effective_mint_url != settings.primary_mint:
logger.info(
f"send: insufficient proofs at {effective_mint_url} "
f"(have {proofs_for_mint}, need {amount}), falling back to primary_mint={settings.primary_mint}"
)
effective_mint_url = settings.primary_mint
wallet = await get_wallet(effective_mint_url, unit)
proofs = get_proofs_per_mint_and_unit(wallet, effective_mint_url, unit)
proofs_for_mint = sum(p.amount for p in proofs)
all_mint_urls = list({k.mint_url for k in wallet.keysets.values()})
proof_summary = {
f"{k.mint_url}/{k.unit.name}": sum(p.amount for p in wallet.proofs if p.id == k.id)
for k in wallet.keysets.values()
}
# Show ALL proofs in DB by keyset_id, regardless of whether the loaded wallet
# knows about that keyset. This reveals proofs orphaned under stale keysets.
raw_proofs_by_keyset: dict[str, int] = {}
for p in wallet.proofs:
raw_proofs_by_keyset[p.id] = raw_proofs_by_keyset.get(p.id, 0) + p.amount
logger.info(
f"send: proof inventory | mint={effective_mint_url} unit={unit} amount={amount} "
f"primary_mint={settings.primary_mint} proofs_for_mint={proofs_for_mint} "
f"all_mints={all_mint_urls} by_keyset={proof_summary} "
f"raw_proofs_by_keyset_id={raw_proofs_by_keyset} "
f"total_wallet_proofs={sum(p.amount for p in wallet.proofs)}"
)
# Reserve proofs only after serialization succeeds — if serialize_proofs or
# swap_to_send fails mid-way, proofs stay unreserved so dashboard balance
# doesn't go negative.
send_proofs, _ = await wallet.select_to_send(
proofs, amount, set_reserved=True, include_fees=False
)
token = await wallet.serialize_proofs(
send_proofs, include_dleq=False, legacy=False, memo=None
proofs, amount, set_reserved=False, include_fees=False
)
try:
token = await wallet.serialize_proofs(
send_proofs, include_dleq=False, legacy=False, memo=None
)
except Exception:
await wallet.set_reserved_for_send(send_proofs, reserved=False)
raise
await wallet.set_reserved_for_send(send_proofs, reserved=True)
return amount, token
@@ -83,10 +122,11 @@ async def _calculate_swap_amount(
token_mint_url: str,
token_wallet: Wallet,
primary_wallet: Wallet,
proofs: list,
) -> int:
"""
Calculate the amount to mint on the primary mint after accounting for
potential swap fees (melt fees) on the foreign mint.
melt fees and NUT-02 input fees on the foreign mint.
"""
if settings.primary_mint_unit == "sat":
receive_amount = amount_msat // 1000
@@ -113,10 +153,11 @@ async def _calculate_swap_amount(
dummy_melt_quote = await token_wallet.melt_quote(dummy_mint_quote.request)
fee_reserve = dummy_melt_quote.fee_reserve
input_fees = token_wallet.get_fees_for_proofs(proofs)
if token_unit == "sat":
fee_msat = fee_reserve * 1000
fee_msat = (fee_reserve + input_fees) * 1000
else:
fee_msat = fee_reserve
fee_msat = fee_reserve + input_fees
amount_msat_after_fee = amount_msat - fee_msat
@@ -126,13 +167,14 @@ async def _calculate_swap_amount(
minted_amount = int(amount_msat_after_fee)
if minted_amount <= 0:
raise ValueError(f"Fees ({fee_reserve} {token_unit}) exceed token amount")
raise ValueError(f"Fees ({fee_reserve + input_fees} {token_unit}) exceed token amount")
logger.info(
"swap_to_primary_mint: fee estimation result",
extra={
"token_amount_sat": amount_msat // 1000,
"estimated_fee_sat": fee_msat // 1000,
"input_fees": input_fees,
"minted_amount": minted_amount,
"minted_unit": settings.primary_mint_unit,
},
@@ -193,6 +235,7 @@ async def swap_to_primary_mint(
token_obj.mint,
token_wallet,
primary_wallet,
token_obj.proofs,
)
mint_quote = await primary_wallet.request_mint(minted_amount)
@@ -202,13 +245,15 @@ async def swap_to_primary_mint(
)
melt_quote = await token_wallet.melt_quote(mint_quote.request)
total_needed = melt_quote.amount + melt_quote.fee_reserve
input_fees = token_wallet.get_fees_for_proofs(token_obj.proofs)
total_needed = melt_quote.amount + melt_quote.fee_reserve + input_fees
logger.info(
"swap_to_primary_mint: melt quote received",
extra={
"melt_quote_id": melt_quote.quote,
"melt_amount": melt_quote.amount,
"melt_fee_reserve": melt_quote.fee_reserve,
"input_fees": input_fees,
"total_needed": total_needed,
"token_amount": token_amount,
},
@@ -221,6 +266,7 @@ async def swap_to_primary_mint(
"token_amount": token_amount,
"melt_amount": melt_quote.amount,
"melt_fee_reserve": melt_quote.fee_reserve,
"input_fees": input_fees,
"total_needed": total_needed,
"shortfall": total_needed - token_amount,
},
@@ -228,7 +274,7 @@ async def swap_to_primary_mint(
raise ValueError(
f"Token amount ({token_amount} {token_obj.unit}) is insufficient to cover "
f"melt fees. Needed: {total_needed} {token_obj.unit} "
f"(amount: {melt_quote.amount} + fee: {melt_quote.fee_reserve})"
f"(amount: {melt_quote.amount} + fee: {melt_quote.fee_reserve} + input_fees: {input_fees})"
)
try:
@@ -259,19 +305,66 @@ async def swap_to_primary_mint(
extra={"minted_amount": minted_amount, "mint_quote_id": mint_quote.quote},
)
await primary_wallet.load_proofs(reload=True)
pre_mint_balance = primary_wallet.available_balance.amount
try:
_ = await primary_wallet.mint(minted_amount, quote_id=mint_quote.quote)
except Exception as e:
logger.error(
"swap_to_primary_mint: mint on primary failed after successful melt",
extra={
"error": str(e),
"error_type": type(e).__name__,
"minted_amount": minted_amount,
"mint_quote_id": mint_quote.quote,
},
)
raise
if "11003" in str(e) or "outputs already signed" in str(e).lower():
# Previous mint call signed outputs at the mint but failed before
# bump_secret_derivation ran locally. Recover orphaned proofs and
# advance the counter so the next request derives fresh secrets.
logger.warning(
"swap_to_primary_mint: outputs already signed — recovering orphaned proofs",
extra={"mint_quote_id": mint_quote.quote, "minted_amount": minted_amount},
)
try:
for keyset_id in primary_wallet.keysets:
await primary_wallet.restore_tokens_for_keyset(keyset_id, to=1, batch=25)
await primary_wallet.load_proofs(reload=True)
post_recovery_balance = primary_wallet.available_balance.amount
balance_gained = post_recovery_balance - pre_mint_balance
logger.info(
"swap_to_primary_mint: recovery scan completed",
extra={
"pre_mint_balance": pre_mint_balance,
"post_recovery_balance": post_recovery_balance,
"balance_gained": balance_gained,
"expected": minted_amount,
},
)
if balance_gained < minted_amount:
# Recovery scan ran but did NOT restore the orphaned proofs
# (mint reports them as spent — they're stuck). Refuse to
# credit the API key balance for proofs we don't actually hold.
raise ValueError(
f"Swap recovery failed: mint signed outputs but proofs are "
f"unrecoverable (mint reports them spent). "
f"Expected {minted_amount}, recovered {balance_gained}. "
f"Local wallet DB ('.wallet/') state is corrupted — "
f"the counter for keyset is stuck at a bad index range."
)
except ValueError:
raise
except Exception as recovery_err:
logger.error(
"swap_to_primary_mint: recovery failed",
extra={"error": str(recovery_err)},
)
raise ValueError(
f"Mint on primary failed and recovery unsuccessful: {e}"
) from e
else:
logger.error(
"swap_to_primary_mint: mint on primary failed after successful melt",
extra={
"error": str(e),
"error_type": type(e).__name__,
"minted_amount": minted_amount,
"mint_quote_id": mint_quote.quote,
},
)
raise
logger.info(
"swap_to_primary_mint: completed successfully",
@@ -445,7 +538,7 @@ async def fetch_all_balances(
"unit": unit,
"wallet_balance": proofs_balance,
"user_balance": user_balance,
"owner_balance": proofs_balance - user_balance,
"owner_balance": proofs_balance - user_balance if proofs_balance != 0 else 0,
}
return result
except Exception as e:
@@ -493,7 +586,9 @@ async def fetch_all_balances(
total_wallet_balance_sats += proofs_balance_sats
total_user_balance_sats += user_balance_sats
owner_balance = total_wallet_balance_sats - total_user_balance_sats
owner_balance = 0
if total_wallet_balance_sats != 0:
owner_balance = total_wallet_balance_sats - total_user_balance_sats
return (
balance_details,

View File

@@ -1,4 +1,4 @@
"""Tests for the built-in 404 handler in routstr.proxy."""
"""Tests for the app-level 404 handler in routstr.core.main."""
from __future__ import annotations
@@ -6,18 +6,22 @@ import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routstr import proxy
from routstr.proxy import proxy_router
from routstr.core import main as core_main
def _make_app() -> FastAPI:
app = FastAPI()
app.include_router(proxy_router)
app.add_api_route(
"/{path:path}",
core_main.not_found_catch_all,
methods=["GET", "POST"],
include_in_schema=False,
)
return app
@pytest.mark.skipif(
proxy._NOT_FOUND_HTML is None,
core_main._NOT_FOUND_HTML is None,
reason="UI bundle (ui_out/404.html) not present in this environment",
)
def test_unknown_path_returns_html_404_for_browser() -> None:
@@ -41,24 +45,26 @@ def test_unknown_path_returns_json_404_for_api_client() -> None:
assert "/some/random/page" in payload["error"]["message"]
def test_root_path_returns_404_for_proxy_router() -> None:
def test_root_path_returns_404() -> None:
client = TestClient(_make_app())
response = client.get("/", headers={"accept": "application/json"})
assert response.status_code == 404
def test_v1_path_is_not_intercepted_by_404_handler() -> None:
"""Paths starting with v1/ must reach the proxy logic, not the 404 handler."""
client = TestClient(_make_app(), raise_server_exceptions=False)
response = client.get("/v1/anything")
if response.status_code == 404:
# Any 404 here must come from inner proxy logic, not our HTML page.
assert "<!DOCTYPE html>" not in response.text
def test_json_returned_when_ui_html_missing(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(proxy, "_NOT_FOUND_HTML", None)
from routstr.core import not_found as nf
monkeypatch.setattr(nf, "_NOT_FOUND_HTML", None)
client = TestClient(_make_app())
response = client.get("/some/random/page", headers={"accept": "text/html"})
assert response.status_code == 404
assert response.headers["content-type"].startswith("application/json")
def test_post_unknown_path_returns_json_even_for_browser() -> None:
client = TestClient(_make_app())
response = client.post("/some/random/page", headers={"accept": "text/html"})
assert response.status_code == 404
assert response.headers["content-type"].startswith("application/json")
payload = response.json()
assert payload["error"]["type"] == "not_found"

View File

@@ -123,6 +123,7 @@ async def test_swap_to_primary_mint_insufficient_for_fees() -> None:
mock_token_wallet = Mock()
mock_token_wallet.load_mint = AsyncMock()
mock_token_wallet.load_proofs = AsyncMock()
mock_token_wallet.get_fees_for_proofs = Mock(return_value=0)
mock_primary_wallet = Mock()
mock_primary_wallet.load_mint = AsyncMock()
@@ -166,6 +167,7 @@ async def test_swap_to_primary_mint_melt_error_wrapped() -> None:
mock_token_wallet = Mock()
mock_token_wallet.load_mint = AsyncMock()
mock_token_wallet.load_proofs = AsyncMock()
mock_token_wallet.get_fees_for_proofs = Mock(return_value=0)
mock_primary_wallet = Mock()
mock_primary_wallet.load_mint = AsyncMock()
@@ -261,6 +263,7 @@ async def test_swap_to_primary_mint_success() -> None:
mock_token_wallet = Mock()
mock_token_wallet.load_mint = AsyncMock()
mock_token_wallet.load_proofs = AsyncMock()
mock_token_wallet.get_fees_for_proofs = Mock(return_value=0)
mock_primary_wallet = Mock()
mock_primary_wallet.load_mint = AsyncMock()

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';

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,
@@ -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
@@ -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

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
ui/lib/utils/version.ts Normal file
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}`;
}