Compare commits

...
Author SHA1 Message Date
9qeklajc 7d48b36be8 add missing refund button 2026-03-13 22:23:50 +01:00
9qeklajcandGitHub 8b3fdaa545 Merge pull request #403 from Routstr/fix-admin-routstr-balance-timeout
Handle Routstr admin balance timeouts
2026-03-13 21:13:19 +01:00
9qeklajcandGitHub b43d57df23 Merge pull request #402 from Routstr/fix/admin-routstr-balance-timeout
fix: handle Routstr balance timeouts
2026-03-13 21:12:14 +01:00
9qeklajcandGitHub b9a5a9276e Merge pull request #401 from Routstr/fix/routstr-provider-sats-balance
fix: show Routstr provider balances in sats
2026-03-13 21:11:15 +01:00
9qeklajc a79fdf7212 clean up 2026-03-13 21:09:51 +01:00
Shroominic 9560050946 test: type routstr topup async client mock 2026-03-13 19:08:45 +08:00
Shroominic dd88e9b172 test: annotate admin balance integration session 2026-03-13 18:51:30 +08:00
Shroominic e31b45fa9e test: type dummy async client exit hook 2026-03-13 18:51:30 +08:00
Shroominic 1ed8b29d64 style: format provider balance placeholder 2026-03-13 18:51:30 +08:00
Shroominic 59f8d31719 Handle Routstr admin balance timeouts 2026-03-13 18:45:18 +08:00
Shroominic 93a368b1a2 fix(admin): handle routstr balance timeouts 2026-03-13 18:39:06 +08:00
Shroominic d3dd346853 fix: remove unrelated balance endpoint changes 2026-03-13 18:20:08 +08:00
Shroominic 0198569a9a fix: retry transient Routstr top-up invoice failures 2026-03-13 18:17:19 +08:00
Shroominic 7e648cb5c2 fix: use sats for Routstr top-up amounts 2026-03-13 17:46:57 +08:00
Shroominic deb75624f3 fix: show Routstr provider balances in sats 2026-03-13 17:36:52 +08:00
9qeklajcandGitHub 1fa71ee806 Merge pull request #396 from Routstr/v0.4.0-ui-update
V0.4.0 UI update
2026-03-11 22:48:05 +01:00
9qeklajc 97d164e181 fix build 2026-03-11 22:46:23 +01:00
9qeklajc 9f3c915dec fmt 2026-03-11 22:45:13 +01:00
9qeklajc 4ff6218140 Merge branch 'v0.4.0' into v0.4.0-ui-update
# Conflicts:
#	routstr/core/log_manager.py
#	ui/app/page.tsx
#	ui/app/providers/page.tsx
2026-03-11 22:44:56 +01:00
9qeklajc 67ea6aec43 Fix TypeScript error in chart.tsx key prop 2026-03-11 22:10:29 +01:00
9qeklajc 41f346417a Regenerate pnpm-lock.yaml for GitHub Actions 2026-03-11 22:06:05 +01:00
9qeklajcandGitHub 4857d741de Merge pull request #370 from Routstr/add-routstr-provider
Add routstr provider
2026-03-11 21:30:19 +01:00
Evan Yang 812f50ea28 Fix ESLint peer dependency for npm install 2026-03-10 11:43:56 +08:00
Evan Yang 5aa902fd4a Clean up 2026-03-09 20:47:50 +08:00
Evan Yang db144e0903 Trim analytics backend changes from UI branch 2026-03-09 20:09:08 +08:00
Evan Yang e20b20dbca Restore dashboard analytics data path 2026-03-09 16:57:15 +08:00
Evan Yang aa700443d3 Remove unrelated file changes 2026-03-09 15:56:27 +08:00
Evan Yang 7e89b5bc6d Remove unrelated non-UI branch changes 2026-03-09 15:37:13 +08:00
Evan Yang 8dbf036558 Remove model usage analytics from UI update branch 2026-03-09 15:26:07 +08:00
Evan Yang 20b4c3a641 Restore analytics dashboard and polish mobile shell 2026-03-07 18:52:17 +08:00
Evan Yang 88f7ee734f Fix dashboard auth loading state on split UI branch 2026-03-07 18:16:29 +08:00
Evan Yang 326a0086b7 Fix chart container sizing warnings 2026-03-07 18:15:44 +08:00
Evan Yang eb46651373 Fix UI formatting for CI 2026-03-07 18:15:44 +08:00
Evan Yang 226188e22d Refine admin UI and models routing 2026-03-07 18:15:44 +08:00
Evan Yang 89fc48eea2 Refine model pricing and mobile list layout 2026-03-07 18:15:29 +08:00
Evan Yang f66d98e6b5 chore(release): bump version to 0.4.0 2026-03-07 18:15:29 +08:00
Evan Yang cfea4f9cd1 Update Node.js version in GitHub Actions workflow from 18 to 20 2026-03-07 18:15:29 +08:00
Evan Yang c74a877ab5 Fix build and formatting 2026-03-07 18:15:29 +08:00
Evan Yang 83a76e86fe Update dashboard UI/UX 2026-03-07 18:15:29 +08:00
164 changed files with 16655 additions and 12087 deletions
+1 -1
View File
@@ -67,7 +67,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
node-version: "20"
cache: "pnpm"
cache-dependency-path: ui/pnpm-lock.yaml
+104 -56
View File
@@ -1,3 +1,4 @@
import asyncio
import json
import secrets
from datetime import datetime, timezone
@@ -865,12 +866,6 @@ async def initiate_provider_topup(
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
upstream_instance = _instantiate_provider(provider)
if not upstream_instance:
raise HTTPException(
status_code=400, detail="Could not instantiate provider"
)
try:
logger.info(
f"Initiating top-up for provider {provider_id}",
@@ -884,39 +879,69 @@ async def initiate_provider_topup(
async with httpx.AsyncClient() as client:
clean_url = provider.base_url.rstrip("/")
# Proxy the request to upstream Routstr
# Use the actual API key from the database
resp = await client.post(
f"{clean_url}/v1/balance/lightning/invoice",
json={
"amount_sats": int(payload.amount),
"purpose": "topup",
"api_key": provider.api_key,
},
headers={"Authorization": f"Bearer {provider.api_key}"} if provider.api_key else {},
request_json = {
"amount_sats": int(payload.amount),
"purpose": "topup",
"api_key": provider.api_key,
}
headers = (
{"Authorization": f"Bearer {provider.api_key}"}
if provider.api_key
else {}
)
if resp.status_code == 200:
data = resp.json()
return {
"ok": True,
"topup_data": {
"payment_request": data.get("bolt11"),
"invoice_id": data.get("invoice_id"),
"status": "pending",
},
}
else:
logger.error(f"Upstream topup request failed: {resp.text}")
# Check if it's JSON error
try:
error_detail = resp.json()
except Exception:
error_detail = resp.text
raise HTTPException(
status_code=resp.status_code, detail=error_detail
last_status_code = 500
last_error_detail: object = "Failed to create top-up invoice"
# Some upstream Routstr nodes fail the first invoice request after warm-up
# and succeed immediately on retry. Retry once here so the UI stays single-click.
for attempt in range(2):
resp = await client.post(
f"{clean_url}/v1/balance/lightning/invoice",
json=request_json,
headers=headers,
)
if resp.status_code == 200:
data = resp.json()
return {
"ok": True,
"topup_data": {
"payment_request": data.get("bolt11"),
"invoice_id": data.get("invoice_id"),
"status": "pending",
},
}
logger.error(
f"Upstream topup request failed: {resp.text}",
extra={
"provider_id": provider_id,
"attempt": attempt + 1,
"status_code": resp.status_code,
},
)
try:
last_error_detail = resp.json()
except Exception:
last_error_detail = resp.text
last_status_code = resp.status_code
if resp.status_code < 500 or attempt == 1:
break
await asyncio.sleep(0.2)
raise HTTPException(
status_code=last_status_code, detail=last_error_detail
)
upstream_instance = _instantiate_provider(provider)
if not upstream_instance:
raise HTTPException(
status_code=400, detail="Could not instantiate provider"
)
topup_data = await upstream_instance.initiate_topup(payload.amount)
logger.info(
@@ -1025,15 +1050,46 @@ async def get_provider_balance(provider_id: int) -> dict[str, object]:
if provider.provider_type == "routstr":
import httpx
async with httpx.AsyncClient() as client:
clean_url = provider.base_url.rstrip("/")
headers = {}
if provider.api_key:
headers["Authorization"] = f"Bearer {provider.api_key}"
resp = await client.get(
f"{clean_url}/v1/balance/info",
headers=headers,
)
clean_url = provider.base_url.rstrip("/")
headers = {}
if provider.api_key:
headers["Authorization"] = f"Bearer {provider.api_key}"
async with httpx.AsyncClient(timeout=10.0) as client:
try:
resp = await client.get(
f"{clean_url}/v1/balance/info",
headers=headers,
)
except httpx.TimeoutException as exc:
logger.error(
"Timed out fetching Routstr provider balance",
extra={
"provider_id": provider_id,
"base_url": clean_url,
"upstream_url": f"{clean_url}/v1/balance/info",
"error": str(exc),
},
)
raise HTTPException(
status_code=504,
detail="Timed out contacting upstream Routstr provider",
) from exc
except httpx.RequestError as exc:
logger.error(
"Failed to fetch Routstr provider balance",
extra={
"provider_id": provider_id,
"base_url": clean_url,
"upstream_url": f"{clean_url}/v1/balance/info",
"error": str(exc),
},
)
raise HTTPException(
status_code=502,
detail="Failed to contact upstream Routstr provider",
) from exc
if resp.status_code == 200:
data = resp.json()
# Return balance in sats
@@ -1081,9 +1137,7 @@ async def get_usage_metrics(
interval: int = Query(
default=15, ge=1, le=1440, description="Time interval in minutes"
),
hours: int = Query(
default=24, ge=1, le=168, description="Hours of history to analyze"
),
hours: int = Query(default=24, ge=1, description="Hours of history to analyze"),
) -> dict:
"""Get usage metrics aggregated by time interval."""
return log_manager.get_usage_metrics(interval=interval, hours=hours)
@@ -1092,9 +1146,7 @@ async def get_usage_metrics(
@admin_router.get("/api/usage/summary", dependencies=[Depends(require_admin_api)])
async def get_usage_summary(
request: Request,
hours: int = Query(
default=24, ge=1, le=168, description="Hours of history to analyze"
),
hours: int = Query(default=24, ge=1, description="Hours of history to analyze"),
) -> dict:
"""Get summary statistics for the specified time period."""
return log_manager.get_usage_summary(hours=hours)
@@ -1103,9 +1155,7 @@ async def get_usage_summary(
@admin_router.get("/api/usage/error-details", dependencies=[Depends(require_admin_api)])
async def get_error_details(
request: Request,
hours: int = Query(
default=24, ge=1, le=168, description="Hours of history to analyze"
),
hours: int = Query(default=24, ge=1, description="Hours of history to analyze"),
limit: int = Query(
default=100, ge=1, le=1000, description="Maximum number of errors to return"
),
@@ -1119,9 +1169,7 @@ async def get_error_details(
)
async def get_revenue_by_model(
request: Request,
hours: int = Query(
default=24, ge=1, le=168, description="Hours of history to analyze"
),
hours: int = Query(default=24, ge=1, description="Hours of history to analyze"),
limit: int = Query(
default=20, ge=1, le=100, description="Maximum number of models to return"
),
+275 -154
View File
@@ -36,6 +36,7 @@ class LogManager:
log_files = []
cutoff_date = None
cutoff_timestamp_str: str | None = None
if specific_date:
log_file = self.logs_dir / f"app_{specific_date}.log"
@@ -79,6 +80,7 @@ class LogManager:
# If we only care about hours back, we can optimize file selection
if hours_back is not None:
cutoff_date = datetime.now(timezone.utc) - timedelta(hours=hours_back)
cutoff_timestamp_str = cutoff_date.strftime("%Y-%m-%d %H:%M:%S")
filtered_files = []
for log_path in log_files:
try:
@@ -101,27 +103,20 @@ class LogManager:
for log_file in log_files:
try:
with open(log_file, "r") as f:
# For reverse search, we might want to read lines in reverse?
# But usually logs are append-only.
# If reverse_files is True, we iterate files newest to oldest.
# But lines within file are still oldest to newest unless we reverse them.
lines = f.readlines()
if reverse_files:
lines.reverse()
lines_iter = reversed(f.readlines()) if reverse_files else f
for line in lines:
for line in lines_iter:
try:
entry = json.loads(line.strip())
if cutoff_date:
if cutoff_timestamp_str:
timestamp_str = entry.get("asctime", "")
if not timestamp_str:
if (
not isinstance(timestamp_str, str)
or len(timestamp_str) != 19
):
continue
log_time = datetime.strptime(
timestamp_str, "%Y-%m-%d %H:%M:%S"
)
log_time = log_time.replace(tzinfo=timezone.utc)
if log_time < cutoff_date:
if timestamp_str < cutoff_timestamp_str:
continue
yield entry
@@ -198,7 +193,7 @@ class LogManager:
methods: list[str] | None = None,
endpoints: list[str] | None = None,
) -> bool:
if level and log_data.get("levelname", "").upper() != level.upper():
if level and str(log_data.get("levelname", "")).upper() != level.upper():
return False
if request_id and log_data.get("request_id") != request_id:
@@ -248,6 +243,65 @@ class LogManager:
return True
def _bucket_key_for_timestamp(
self, timestamp_str: str, interval_minutes: int
) -> str | None:
if len(timestamp_str) != 19:
return None
if timestamp_str[10] != " ":
return None
try:
hour = int(timestamp_str[11:13])
minute = int(timestamp_str[14:16])
except (TypeError, ValueError):
return None
total_minutes = hour * 60 + minute
rounded_minutes = (total_minutes // interval_minutes) * interval_minutes
rounded_hour = rounded_minutes // 60
rounded_minute = rounded_minutes % 60
return f"{timestamp_str[:10]} {rounded_hour:02d}:{rounded_minute:02d}:00"
def _extract_success_metrics(
self, entry: dict[str, Any], message: str
) -> tuple[bool, float, int, int]:
# Use auth settlement logs as the canonical successful request signal.
logger_name = str(entry.get("name", ""))
if not logger_name.startswith("routstr.auth"):
return False, 0.0, 0, 0
input_tokens = self._parse_token_count(entry.get("input_tokens", 0))
output_tokens = self._parse_token_count(entry.get("output_tokens", 0))
if "calculated token-based cost" in message:
token_cost = entry.get("token_cost", 0)
if isinstance(token_cost, (int, float)) and token_cost > 0:
return True, float(token_cost), input_tokens, output_tokens
return True, 0.0, input_tokens, output_tokens
if "max cost payment finalized" in message:
charged_amount = entry.get("charged_amount", 0)
if isinstance(charged_amount, (int, float)) and charged_amount > 0:
return True, float(charged_amount), input_tokens, output_tokens
return True, 0.0, input_tokens, output_tokens
return False, 0.0, 0, 0
def _parse_token_count(self, value: Any) -> int:
if isinstance(value, bool):
return 0
if isinstance(value, int):
return max(0, value)
if isinstance(value, float):
return max(0, int(value))
if isinstance(value, str):
try:
return max(0, int(float(value)))
except ValueError:
return 0
return 0
def get_usage_summary(self, hours: int = 24) -> dict:
entries = list(
self._yield_log_entries(
@@ -265,37 +319,24 @@ class LogManager:
return self._aggregate_metrics_by_time(entries, interval, hours)
def get_error_details(self, hours: int = 24, limit: int = 100) -> dict:
errors: list[dict] = []
# Iterate newest to oldest for errors?
# yield_log_entries sorts files by name (date) ascending by default.
# usage stats logic usually expects ascending time for aggregation (though dictionaries don't care).
# For error details "last N errors", we probably want newest first.
errors: list[dict[str, Any]] = []
# Using list() loads everything into memory, which is what PR 229 did.
# For optimization, we could use reverse iterator.
for entry in self._yield_log_entries(hours_back=hours):
if str(entry.get("levelname", "")).upper() != "ERROR":
continue
# Let's just stick to PR 229 logic which filters 'ERROR' level.
errors.append(
{
"timestamp": entry.get("asctime", ""),
"message": entry.get("message", ""),
"error_type": entry.get("error_type", "unknown"),
"pathname": entry.get("pathname", ""),
"lineno": entry.get("lineno", 0),
"request_id": entry.get("request_id", ""),
}
)
entries = self._yield_log_entries(
hours_back=hours, window_center=datetime.now(timezone.utc)
) # oldest to newest
for entry in entries:
if entry.get("levelname", "").upper() == "ERROR":
timestamp_str = entry.get("asctime", "")
errors.append(
{
"timestamp": timestamp_str,
"message": entry.get("message", ""),
"error_type": entry.get("error_type", "unknown"),
"pathname": entry.get("pathname", ""),
"lineno": entry.get("lineno", 0),
"request_id": entry.get("request_id", ""),
}
)
# Sort reverse time
errors.sort(key=lambda x: x["timestamp"], reverse=True)
errors.sort(key=lambda x: str(x["timestamp"]), reverse=True)
return {"errors": errors[:limit], "total_count": len(errors)}
def get_revenue_by_model(self, hours: int = 24, limit: int = 20) -> dict:
@@ -321,23 +362,22 @@ class LogManager:
if not isinstance(model, str):
model = "unknown"
message = entry.get("message", "").lower()
message = str(entry.get("message", "")).lower()
if "received proxy request" in message:
completed, revenue_msats, _, _ = self._extract_success_metrics(
entry, message
)
if completed:
model_stats[model]["requests"] += 1
if (
"completed for streaming" in message
or "completed for non-streaming" in message
):
model_stats[model]["successful"] += 1
cost_data = entry.get("cost_data")
if isinstance(cost_data, dict):
actual_cost = cost_data.get("total_msats", 0)
if isinstance(actual_cost, (int, float)) and actual_cost > 0:
model_stats[model]["revenue_msats"] += actual_cost
if revenue_msats > 0:
model_stats[model]["revenue_msats"] += revenue_msats
if "revert payment" in message or "upstream request failed" in message:
failed = (
"revert payment" in message or "upstream request failed" in message
)
if failed:
model_stats[model]["requests"] += 1
model_stats[model]["failed"] += 1
if "revert payment" in message:
max_cost = entry.get("max_cost_for_model", 0)
@@ -386,83 +426,16 @@ class LogManager:
"total_models": len(models),
}
def _calculate_summary_stats(self, entries: list[dict]) -> dict:
stats: dict[str, Any] = {
"total_entries": 0,
"total_requests": 0,
"successful_chat_completions": 0,
"failed_requests": 0,
"total_errors": 0,
"total_warnings": 0,
"payment_processed": 0,
"upstream_errors": 0,
"unique_models": set(),
"error_types": defaultdict(int),
"revenue_msats": 0.0,
"refunds_msats": 0.0,
}
for entry in entries:
try:
stats["total_entries"] += 1
message = entry.get("message", "").lower()
level = entry.get("levelname", "").upper()
if level == "ERROR":
stats["total_errors"] += 1
if "error_type" in entry:
stats["error_types"][str(entry["error_type"])] += 1
elif level == "WARNING":
stats["total_warnings"] += 1
if "received proxy request" in message:
stats["total_requests"] += 1
if (
"completed for streaming" in message
or "completed for non-streaming" in message
):
stats["successful_chat_completions"] += 1
if "upstream request failed" in message or "revert payment" in message:
stats["failed_requests"] += 1
if "payment processed successfully" in message:
stats["payment_processed"] += 1
if "upstream" in message and level == "ERROR":
stats["upstream_errors"] += 1
if "model" in entry:
model = entry["model"]
if isinstance(model, str) and model != "unknown":
stats["unique_models"].add(model)
if (
"completed for streaming" in message
or "completed for non-streaming" in message
):
cost_data = entry.get("cost_data")
if isinstance(cost_data, dict):
actual_cost = cost_data.get("total_msats", 0)
if isinstance(actual_cost, (int, float)) and actual_cost > 0:
stats["revenue_msats"] += float(actual_cost)
if "revert payment" in message:
max_cost = entry.get("max_cost_for_model", 0)
if isinstance(max_cost, (int, float)) and max_cost > 0:
stats["refunds_msats"] += float(max_cost)
except Exception:
continue
def _build_summary_response(self, stats: dict[str, Any]) -> dict[str, Any]:
revenue_sats = stats["revenue_msats"] / 1000
refunds_sats = stats["refunds_msats"] / 1000
net_revenue_sats = revenue_sats - refunds_sats
total_requests = stats["total_requests"]
successful = stats["successful_chat_completions"]
input_tokens = stats["input_tokens"]
output_tokens = stats["output_tokens"]
total_tokens = stats["total_tokens"]
return {
"total_entries": stats["total_entries"],
@@ -476,6 +449,18 @@ class LogManager:
"unique_models_count": len(stats["unique_models"]),
"unique_models": sorted(list(stats["unique_models"])),
"error_types": dict(stats["error_types"]),
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": total_tokens,
"avg_input_tokens_per_completion": (
input_tokens / successful if successful > 0 else 0
),
"avg_output_tokens_per_completion": (
output_tokens / successful if successful > 0 else 0
),
"avg_total_tokens_per_completion": (
total_tokens / successful if successful > 0 else 0
),
"success_rate": (successful / total_requests * 100)
if total_requests > 0
else 0,
@@ -495,62 +480,198 @@ class LogManager:
),
}
def _calculate_summary_stats(self, entries: list[dict]) -> dict:
stats: dict[str, Any] = {
"total_entries": 0,
"total_requests": 0,
"successful_chat_completions": 0,
"failed_requests": 0,
"total_errors": 0,
"total_warnings": 0,
"payment_processed": 0,
"upstream_errors": 0,
"unique_models": set(),
"error_types": defaultdict(int),
"revenue_msats": 0.0,
"refunds_msats": 0.0,
"input_tokens": 0,
"output_tokens": 0,
"total_tokens": 0,
}
for entry in entries:
try:
stats["total_entries"] += 1
message = str(entry.get("message", "")).lower()
level = str(entry.get("levelname", "")).upper()
if level == "ERROR":
stats["total_errors"] += 1
if "error_type" in entry:
stats["error_types"][str(entry["error_type"])] += 1
elif level == "WARNING":
stats["total_warnings"] += 1
completed, revenue_msats, input_tokens, output_tokens = (
self._extract_success_metrics(entry, message)
)
if completed:
stats["total_requests"] += 1
stats["successful_chat_completions"] += 1
stats["input_tokens"] += input_tokens
stats["output_tokens"] += output_tokens
stats["total_tokens"] += input_tokens + output_tokens
failed = (
"upstream request failed" in message
or "revert payment" in message
)
if failed:
stats["total_requests"] += 1
stats["failed_requests"] += 1
if "payment processed successfully" in message:
stats["payment_processed"] += 1
if "upstream" in message and level == "ERROR":
stats["upstream_errors"] += 1
if "model" in entry:
model = entry["model"]
if isinstance(model, str) and model != "unknown":
stats["unique_models"].add(model)
if completed and revenue_msats > 0:
stats["revenue_msats"] += revenue_msats
if "revert payment" in message:
max_cost = entry.get("max_cost_for_model", 0)
if isinstance(max_cost, (int, float)) and max_cost > 0:
stats["refunds_msats"] += float(max_cost)
except Exception:
continue
return self._build_summary_response(stats)
def _aggregate_metrics_by_time(
self, entries: list[dict], interval_minutes: int, hours_back: int
) -> dict:
time_buckets: dict[str, dict[str, Any]] = defaultdict(
lambda: {"requests": 0, "errors": 0, "revenue_msats": 0.0}
lambda: {
"total_requests": 0,
"successful_chat_completions": 0,
"failed_requests": 0,
"errors": 0,
"warnings": 0,
"payment_processed": 0,
"upstream_errors": 0,
"revenue_msats": 0.0,
"refunds_msats": 0.0,
"input_tokens": 0,
"output_tokens": 0,
"total_tokens": 0,
}
)
for entry in entries:
try:
timestamp_str = entry.get("asctime", "")
if not timestamp_str:
if not isinstance(timestamp_str, str):
continue
log_time = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S")
log_time = log_time.replace(tzinfo=timezone.utc)
# Round down to nearest interval
minutes = log_time.minute
rounded_minutes = (minutes // interval_minutes) * interval_minutes
bucket_time = log_time.replace(
minute=rounded_minutes, second=0, microsecond=0
bucket_key = self._bucket_key_for_timestamp(
timestamp_str, interval_minutes
)
bucket_key = bucket_time.strftime("%Y-%m-%d %H:%M:%S")
if not bucket_key:
continue
bucket = time_buckets[bucket_key]
message = entry.get("message", "").lower()
level = entry.get("levelname", "").upper()
message = str(entry.get("message", "")).lower()
level = str(entry.get("levelname", "")).upper()
if "received proxy request" in message:
bucket["requests"] += 1
completed, revenue_msats, input_tokens, output_tokens = (
self._extract_success_metrics(entry, message)
)
if completed:
bucket["total_requests"] += 1
bucket["successful_chat_completions"] += 1
bucket["input_tokens"] += input_tokens
bucket["output_tokens"] += output_tokens
bucket["total_tokens"] += input_tokens + output_tokens
if level == "ERROR":
bucket["errors"] += 1
if "upstream" in message:
bucket["upstream_errors"] += 1
elif level == "WARNING":
bucket["warnings"] += 1
if (
"completed for streaming" in message
or "completed for non-streaming" in message
):
cost_data = entry.get("cost_data")
if isinstance(cost_data, dict):
actual_cost = cost_data.get("total_msats", 0)
if isinstance(actual_cost, (int, float)) and actual_cost > 0:
bucket["revenue_msats"] += float(actual_cost)
failed = (
"upstream request failed" in message
or "revert payment" in message
)
if failed:
bucket["total_requests"] += 1
bucket["failed_requests"] += 1
if "payment processed successfully" in message:
bucket["payment_processed"] += 1
if completed and revenue_msats > 0:
bucket["revenue_msats"] += revenue_msats
if "revert payment" in message:
max_cost = entry.get("max_cost_for_model", 0)
if isinstance(max_cost, (int, float)) and max_cost > 0:
bucket["refunds_msats"] += float(max_cost)
except Exception:
continue
result = []
for bucket_key in sorted(time_buckets.keys()):
result.append({"timestamp": bucket_key, **time_buckets[bucket_key]})
bucket = dict(time_buckets[bucket_key])
# Backward-compatible alias for any callers still reading "requests".
bucket["requests"] = bucket["total_requests"]
result.append({"timestamp": bucket_key, **bucket})
totals = {
"total_requests": 0,
"successful_chat_completions": 0,
"failed_requests": 0,
"errors": 0,
"warnings": 0,
"payment_processed": 0,
"upstream_errors": 0,
"revenue_msats": 0.0,
"refunds_msats": 0.0,
"input_tokens": 0,
"output_tokens": 0,
"total_tokens": 0,
}
for bucket in result:
totals["total_requests"] += int(bucket["total_requests"])
totals["successful_chat_completions"] += int(
bucket["successful_chat_completions"]
)
totals["failed_requests"] += int(bucket["failed_requests"])
totals["errors"] += int(bucket["errors"])
totals["warnings"] += int(bucket["warnings"])
totals["payment_processed"] += int(bucket["payment_processed"])
totals["upstream_errors"] += int(bucket["upstream_errors"])
totals["revenue_msats"] += float(bucket["revenue_msats"])
totals["refunds_msats"] += float(bucket["refunds_msats"])
totals["input_tokens"] += int(bucket["input_tokens"])
totals["output_tokens"] += int(bucket["output_tokens"])
totals["total_tokens"] += int(bucket["total_tokens"])
return {
"metrics": result,
"interval_minutes": interval_minutes,
"hours_back": hours_back,
"total_buckets": len(result),
"totals": totals,
}
+1 -1
View File
@@ -83,7 +83,7 @@ class RoutstrUpstreamProvider(BaseUpstreamProvider):
Balance in satoshis, or None if failed
"""
url = f"{self.base_url}/v1/balance/info"
headers = {"Authorization": f"Bearer {self.api_key}"}
headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
async with httpx.AsyncClient() as client:
try:
@@ -0,0 +1,80 @@
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.core.admin import admin_sessions
from routstr.core.db import UpstreamProviderRow
async def _create_routstr_provider() -> UpstreamProviderRow:
return UpstreamProviderRow(
provider_type="routstr",
base_url="https://upstream.example",
api_key="",
enabled=True,
)
def _admin_headers() -> dict[str, str]:
token = "test-admin-token"
admin_sessions[token] = int(
(datetime.now(timezone.utc) + timedelta(minutes=5)).timestamp()
)
return {"Authorization": f"Bearer {token}"}
@pytest.mark.integration
@pytest.mark.asyncio
async def test_admin_routstr_provider_balance_timeout_returns_504(
integration_client: httpx.AsyncClient,
integration_session: AsyncSession,
) -> None:
provider = await _create_routstr_provider()
integration_session.add(provider)
await integration_session.commit()
await integration_session.refresh(provider)
request = httpx.Request("GET", f"{provider.base_url}/v1/balance/info")
timeout_error = httpx.ConnectTimeout("Connect timeout", request=request)
with patch(
"httpx.AsyncHTTPTransport.handle_async_request",
new=AsyncMock(side_effect=timeout_error),
):
response = await integration_client.get(
f"/admin/api/upstream-providers/{provider.id}/balance",
headers=_admin_headers(),
)
assert response.status_code == 504
assert response.json()["detail"] == "Timed out contacting upstream Routstr provider"
@pytest.mark.integration
@pytest.mark.asyncio
async def test_admin_routstr_provider_balance_request_error_returns_502(
integration_client: httpx.AsyncClient,
integration_session: AsyncSession,
) -> None:
provider = await _create_routstr_provider()
integration_session.add(provider)
await integration_session.commit()
await integration_session.refresh(provider)
request = httpx.Request("GET", f"{provider.base_url}/v1/balance/info")
request_error = httpx.ConnectError("Connection failed", request=request)
with patch(
"httpx.AsyncHTTPTransport.handle_async_request",
new=AsyncMock(side_effect=request_error),
):
response = await integration_client.get(
f"/admin/api/upstream-providers/{provider.id}/balance",
headers=_admin_headers(),
)
assert response.status_code == 502
assert response.json()["detail"] == "Failed to contact upstream Routstr provider"
@@ -3,12 +3,16 @@ Integration tests for provider management functionality.
Tests GET /v1/providers/ endpoint for listing and managing providers.
"""
import time
from types import TracebackType
from typing import Any, Generator
from unittest.mock import patch
import pytest
from httpx import AsyncClient
from routstr.core.admin import admin_sessions
from routstr.core.db import UpstreamProviderRow
from routstr.nostr.discovery import _PROVIDERS_CACHE
from .utils import ResponseValidator
@@ -678,3 +682,88 @@ async def test_no_database_changes_during_provider_operations(
assert final_diff["api_keys"]["added"] == []
assert final_diff["api_keys"]["modified"] == []
assert final_diff["api_keys"]["removed"] == []
@pytest.mark.integration
@pytest.mark.asyncio
async def test_admin_routstr_topup_retries_transient_upstream_failure(
integration_client: AsyncClient,
integration_session: Any,
) -> None:
admin_token = "test-admin-token"
admin_sessions[admin_token] = int(time.time()) + 3600
integration_client.headers["Authorization"] = f"Bearer {admin_token}"
provider = UpstreamProviderRow(
provider_type="routstr",
base_url="https://node.example",
api_key="sk-upstream-test",
enabled=True,
provider_fee=1.01,
)
integration_session.add(provider)
await integration_session.commit()
await integration_session.refresh(provider)
class MockResponse:
def __init__(self, status_code: int, data: dict[str, Any] | None = None):
self.status_code = status_code
self._data = data or {}
self.text = str(self._data)
def json(self) -> dict[str, Any]:
return self._data
class MockAsyncClient:
def __init__(self) -> None:
self.calls = 0
async def __aenter__(self) -> "MockAsyncClient":
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
return None
async def post(
self, url: str, json: dict[str, Any], headers: dict[str, str]
) -> MockResponse:
self.calls += 1
assert url == "https://node.example/v1/balance/lightning/invoice"
assert json["amount_sats"] == 10
assert json["purpose"] == "topup"
assert json["api_key"] == "sk-upstream-test"
assert headers["Authorization"] == "Bearer sk-upstream-test"
if self.calls == 1:
return MockResponse(500, {"detail": "warmup failure"})
return MockResponse(
200,
{
"bolt11": "lnbc1testinvoice",
"invoice_id": "invoice-123",
},
)
mock_client = MockAsyncClient()
try:
with patch("httpx.AsyncClient", return_value=mock_client):
response = await integration_client.post(
f"/admin/api/upstream-providers/{provider.id}/topup",
json={"amount": 10},
)
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
assert data["topup_data"]["payment_request"] == "lnbc1testinvoice"
assert data["topup_data"]["invoice_id"] == "invoice-123"
assert mock_client.calls == 2
finally:
admin_sessions.pop(admin_token, None)
+76
View File
@@ -0,0 +1,76 @@
from types import TracebackType
from unittest.mock import Mock
import httpx
import pytest
from routstr.upstream.routstr import RoutstrUpstreamProvider
class DummyAsyncClient:
def __init__(self, response: Mock | None = None, error: Exception | None = None):
self.response = response
self.error = error
self.calls: list[dict[str, object]] = []
async def __aenter__(self) -> "DummyAsyncClient":
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> bool:
return False
async def get(
self, url: str, headers: dict[str, str], timeout: float
) -> Mock:
self.calls.append({"url": url, "headers": headers, "timeout": timeout})
if self.error is not None:
raise self.error
assert self.response is not None
return self.response
@pytest.mark.asyncio
async def test_get_balance_omits_auth_header_when_api_key_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
response = Mock()
response.json.return_value = {"balance_msats": 42000}
response.raise_for_status.return_value = None
client = DummyAsyncClient(response=response)
monkeypatch.setattr("routstr.upstream.routstr.httpx.AsyncClient", lambda: client)
provider = RoutstrUpstreamProvider(base_url="https://node.example", api_key="")
balance = await provider.get_balance()
assert balance == 42.0
assert client.calls == [
{
"url": "https://node.example/v1/balance/info",
"headers": {},
"timeout": 10.0,
}
]
@pytest.mark.asyncio
async def test_get_balance_returns_none_on_connect_timeout(
monkeypatch: pytest.MonkeyPatch,
) -> None:
client = DummyAsyncClient(error=httpx.ConnectTimeout("timed out"))
monkeypatch.setattr("routstr.upstream.routstr.httpx.AsyncClient", lambda: client)
provider = RoutstrUpstreamProvider(
base_url="https://node.example",
api_key="secret",
)
balance = await provider.get_balance()
assert balance is None
-11
View File
@@ -1,11 +0,0 @@
{
"extends": [
"next",
"next/core-web-vitals",
"eslint:recommended",
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"plugins": ["react", "@typescript-eslint"]
}
+1
View File
@@ -42,3 +42,4 @@ next-env.d.ts
# favicon conflicts
/app/favicon.ico
.pnpm-store/
+5
View File
@@ -0,0 +1,5 @@
pnpm-lock.yaml
.next
node_modules
out
next-env.d.ts
+21 -35
View File
@@ -2,12 +2,11 @@
import { useCurrencyStore } from '@/lib/stores/currency';
import { useQuery } from '@tanstack/react-query';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { DetailedWalletBalance } from '@/components/detailed-wallet-balance';
import { TemporaryBalances } from '@/components/temporary-balances';
import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate';
import { AppPageShell } from '@/components/app-page-shell';
import { PageHeader } from '@/components/page-header';
export default function BalancesPage() {
const { displayUnit } = useCurrencyStore();
@@ -22,39 +21,26 @@ export default function BalancesPage() {
const usdPerSat = btcUsdPrice ? btcToSatsRate(btcUsdPrice) : null;
return (
<SidebarProvider>
<AppSidebar variant='inset' />
<SidebarInset className='p-0'>
<SiteHeader />
<div className='container max-w-6xl px-4 py-8 md:px-6 lg:px-8'>
<div className='mb-8 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
<div>
<h1 className='text-3xl font-bold tracking-tight'>Balances</h1>
<p className='text-muted-foreground mt-2'>
Monitor and manage wallet balances
</p>
</div>
{/* Global currency toggle is now in SiteHeader */}
</div>
<AppPageShell contentClassName='mx-auto w-full max-w-5xl'>
<div className='space-y-6'>
<PageHeader
title='Balances'
description='Monitor and manage wallet balances across cashu mints and temporary stores.'
/>
<div className='grid gap-6'>
<div className='col-span-full'>
<DetailedWalletBalance
refreshInterval={30000}
displayUnit={displayUnit}
usdPerSat={usdPerSat}
/>
</div>
<div className='col-span-full'>
<TemporaryBalances
refreshInterval={60000}
displayUnit={displayUnit}
usdPerSat={usdPerSat}
/>
</div>
</div>
<div className='grid gap-6'>
<DetailedWalletBalance
refreshInterval={30000}
displayUnit={displayUnit}
usdPerSat={usdPerSat}
/>
<TemporaryBalances
refreshInterval={60000}
displayUnit={displayUnit}
usdPerSat={usdPerSat}
/>
</div>
</SidebarInset>
</SidebarProvider>
</div>
</AppPageShell>
);
}
+138 -178
View File
@@ -1,13 +1,133 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
@custom-variant dark (&:is(.dark *, .red *));
:root {
--radius: 0.625rem;
--font-geist-sans:
ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica,
Arial, 'Apple Color Emoji', 'Segoe UI Emoji';
--font-geist-mono:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
.red {
--background: oklch(0.14 0.03 24);
--foreground: oklch(0.9 0.05 28);
--card: oklch(0.17 0.04 24);
--card-foreground: oklch(0.9 0.05 28);
--popover: oklch(0.17 0.04 24);
--popover-foreground: oklch(0.9 0.05 28);
--primary: oklch(0.78 0.14 25);
--primary-foreground: oklch(0.14 0.03 24);
--secondary: oklch(0.22 0.05 24);
--secondary-foreground: oklch(0.9 0.05 28);
--muted: oklch(0.22 0.05 24);
--muted-foreground: oklch(0.72 0.04 26);
--accent: oklch(0.25 0.08 25);
--accent-foreground: oklch(0.92 0.05 28);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(0.72 0.11 25 / 24%);
--input: oklch(0.75 0.12 25 / 28%);
--ring: oklch(0.62 0.12 24);
}
html.red {
color-scheme: dark;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: Geist, sans-serif;
--font-mono: Geist Mono, monospace;
--color-muted: var(--muted);
--color-accent: var(--accent);
--color-border: var(--border);
--color-card: var(--card);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-ring: var(--ring);
--color-input: var(--input);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-muted-foreground: var(--muted-foreground);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -21,194 +141,34 @@
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--font-serif: Georgia, serif;
--radius: 0.5rem;
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
--tracking-normal: var(--tracking-normal);
--shadow-2xl: var(--shadow-2xl);
--shadow-xl: var(--shadow-xl);
--shadow-lg: var(--shadow-lg);
--shadow-md: var(--shadow-md);
--shadow: var(--shadow);
--shadow-sm: var(--shadow-sm);
--shadow-xs: var(--shadow-xs);
--shadow-2xs: var(--shadow-2xs);
--spacing: var(--spacing);
--letter-spacing: var(--letter-spacing);
--shadow-offset-y: var(--shadow-offset-y);
--shadow-offset-x: var(--shadow-offset-x);
--shadow-spread: var(--shadow-spread);
--shadow-blur: var(--shadow-blur);
--shadow-opacity: var(--shadow-opacity);
--color-shadow-color: var(--shadow-color);
--color-destructive-foreground: var(--destructive-foreground);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
:root {
--radius: 0.5rem;
--background: oklch(0.99 0 0);
--foreground: oklch(0 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0 0 0);
--popover: oklch(0.99 0 0);
--popover-foreground: oklch(0 0 0);
--primary: oklch(0 0 0);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.94 0 0);
--secondary-foreground: oklch(0 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.44 0 0);
--accent: oklch(0.94 0 0);
--accent-foreground: oklch(0 0 0);
--destructive: oklch(0.63 0.19 23.03);
--border: oklch(0.92 0 0);
--input: oklch(0.94 0 0);
--ring: oklch(0 0 0);
--chart-1: oklch(0.81 0.17 75.35);
--chart-2: oklch(0.55 0.22 264.53);
--chart-3: oklch(0.72 0 0);
--chart-4: oklch(0.92 0 0);
--chart-5: oklch(0.56 0 0);
--sidebar: oklch(0.99 0 0);
--sidebar-foreground: oklch(0 0 0);
--sidebar-primary: oklch(0 0 0);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.94 0 0);
--sidebar-accent-foreground: oklch(0 0 0);
--sidebar-border: oklch(0.94 0 0);
--sidebar-ring: oklch(0 0 0);
--destructive-foreground: oklch(1 0 0);
--font-sans: Geist, sans-serif;
--font-serif: Georgia, serif;
--font-mono: Geist Mono, monospace;
--shadow-color: hsl(0 0% 0%);
--shadow-opacity: 0.18;
--shadow-blur: 2px;
--shadow-spread: 0px;
--shadow-offset-x: 0px;
--shadow-offset-y: 1px;
--letter-spacing: 0em;
--spacing: 0.25rem;
--shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
--shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
--shadow-sm:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
--shadow:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
--shadow-md:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18);
--shadow-lg:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18);
--shadow-xl:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18);
--shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45);
--tracking-normal: 0em;
body {
font-family: var(--font-sans);
}
.dark {
--background: oklch(0 0 0);
--foreground: oklch(1 0 0);
--card: oklch(0.14 0 0);
--card-foreground: oklch(1 0 0);
--popover: oklch(0.18 0 0);
--popover-foreground: oklch(1 0 0);
--primary: oklch(1 0 0);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.25 0 0);
--secondary-foreground: oklch(1 0 0);
--muted: oklch(0.23 0 0);
--muted-foreground: oklch(0.72 0 0);
--accent: oklch(0.32 0 0);
--accent-foreground: oklch(1 0 0);
--destructive: oklch(0.69 0.2 23.91);
--border: oklch(0.26 0 0);
--input: oklch(0.32 0 0);
--ring: oklch(0.72 0 0);
--chart-1: oklch(0.81 0.17 75.35);
--chart-2: oklch(0.58 0.21 260.84);
--chart-3: oklch(0.56 0 0);
--chart-4: oklch(0.44 0 0);
--chart-5: oklch(0.92 0 0);
--sidebar: oklch(0.18 0 0);
--sidebar-foreground: oklch(1 0 0);
--sidebar-primary: oklch(1 0 0);
--sidebar-primary-foreground: oklch(0 0 0);
--sidebar-accent: oklch(0.32 0 0);
--sidebar-accent-foreground: oklch(1 0 0);
--sidebar-border: oklch(0.32 0 0);
--sidebar-ring: oklch(0.72 0 0);
--destructive-foreground: oklch(0 0 0);
--radius: 0.5rem;
--font-sans: Geist, sans-serif;
--font-serif: Georgia, serif;
--font-mono: Geist Mono, monospace;
--shadow-color: hsl(0 0% 0%);
--shadow-opacity: 0.18;
--shadow-blur: 2px;
--shadow-spread: 0px;
--shadow-offset-x: 0px;
--shadow-offset-y: 1px;
--letter-spacing: 0em;
--spacing: 0.25rem;
--shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
--shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
--shadow-sm:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
--shadow:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
--shadow-md:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18);
--shadow-lg:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18);
--shadow-xl:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18);
--shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45);
html,
body {
max-width: 100%;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
letter-spacing: var(--tracking-normal);
}
}
/* Custom animations */
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
button,
[type='button'],
[type='submit'],
[type='reset'],
[role='button'] {
cursor: pointer;
}
}
.animate-shimmer {
animation: shimmer 2s infinite;
}
+3 -16
View File
@@ -1,23 +1,10 @@
import type { Metadata } from 'next';
import { Geist, Geist_Mono } from 'next/font/google';
import { GeistMono } from 'geist/font/mono';
import { GeistSans } from 'geist/font/sans';
import './globals.css';
import { Providers } from './providers';
import { SuppressHydrationWarning } from '@/components/suppress-hydration-warning';
const geistSans = Geist({
variable: '--font-geist-sans',
subsets: ['latin'],
preload: false,
display: 'swap',
});
const geistMono = Geist_Mono({
variable: '--font-geist-mono',
subsets: ['latin'],
preload: false,
display: 'swap',
});
export const metadata: Metadata = {
title: 'Routstr',
description: 'Routstr model management',
@@ -34,7 +21,7 @@ export default function RootLayout({
return (
<html lang='en' suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
className={`${GeistSans.variable} ${GeistMono.variable} font-sans antialiased`}
>
<SuppressHydrationWarning>
<Providers>{children}</Providers>
+38 -53
View File
@@ -5,16 +5,10 @@ import type { ChangeEvent, FormEvent, ReactElement } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { adminLogin } from '@/lib/api/services/auth';
import { ConfigurationService } from '@/lib/api/services/configuration';
import { toast } from 'sonner';
import { AuthPageShell } from '@/components/auth-page-shell';
export default function AdminLoginPage(): ReactElement {
const router = useRouter();
@@ -78,51 +72,42 @@ export default function AdminLoginPage(): ReactElement {
};
return (
<div className='bg-background text-foreground flex min-h-screen items-center justify-center px-4 py-12'>
<Card className='border-border/60 bg-card/90 w-full max-w-md border shadow-2xl shadow-black/30 backdrop-blur'>
<CardHeader className='space-y-1'>
<CardTitle className='text-center text-2xl font-bold'>
Admin Login
</CardTitle>
<CardDescription className='text-center'>
Enter your admin password to access the dashboard
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className='space-y-4'>
{allowCustomBaseUrl && (
<div className='space-y-2'>
<Input
type='text'
placeholder='API URL (https://api.example.com)'
value={baseUrl}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
setBaseUrl(event.target.value)
}
disabled={isLoading}
required
/>
</div>
)}
<div className='space-y-2'>
<Input
type='password'
placeholder='Admin Password'
value={password}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
setPassword(event.target.value)
}
disabled={isLoading}
autoFocus
required
/>
</div>
<Button type='submit' className='w-full' disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Login'}
</Button>
</form>
</CardContent>
</Card>
</div>
<AuthPageShell
title='Admin Login'
description='Enter your admin password to access the dashboard.'
>
<form onSubmit={handleSubmit} className='space-y-4'>
{allowCustomBaseUrl && (
<div className='space-y-2'>
<Input
type='text'
placeholder='API URL (https://api.example.com)'
value={baseUrl}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
setBaseUrl(event.target.value)
}
disabled={isLoading}
required
/>
</div>
)}
<div className='space-y-2'>
<Input
type='password'
placeholder='Admin Password'
value={password}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
setPassword(event.target.value)
}
disabled={isLoading}
autoFocus
required
/>
</div>
<Button type='submit' className='w-full' disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Login'}
</Button>
</form>
</AuthPageShell>
);
}
+8 -33
View File
@@ -10,18 +10,8 @@ import {
} from '@/components/ui/dialog';
import { Copy, Check } from 'lucide-react';
import { useState } from 'react';
interface LogEntry {
asctime: string;
name: string;
levelname: string;
message: string;
pathname: string;
lineno: number;
version: string;
request_id: string;
[key: string]: string | number | object | undefined;
}
import { getLogLevelBadgeVariant } from '@/lib/utils/log-level';
import type { LogEntry } from './types';
interface LogDetailsDialogProps {
log: LogEntry | null;
@@ -29,24 +19,6 @@ interface LogDetailsDialogProps {
onClose: () => void;
}
const getLevelColor = (level: string): string => {
switch (level.toUpperCase()) {
case 'TRACE':
case 'DEBUG':
return 'bg-gray-100 text-gray-800 border-gray-200';
case 'INFO':
return 'bg-blue-100 text-blue-800 border-blue-200';
case 'WARNING':
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
case 'ERROR':
return 'bg-red-100 text-red-800 border-red-200';
case 'CRITICAL':
return 'bg-purple-100 text-purple-800 border-purple-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
export function LogDetailsDialog({
log,
isOpen,
@@ -79,10 +51,13 @@ export function LogDetailsDialog({
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className='max-h-[90vh] w-[95vw] max-w-[95vw] overflow-hidden'>
<DialogContent className='max-h-[92svh] w-full max-w-none overflow-hidden md:max-w-4xl'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Badge variant='outline' className={getLevelColor(log.levelname)}>
<Badge
variant={getLogLevelBadgeVariant(log.levelname)}
className='uppercase'
>
{log.levelname}
</Badge>
<span>Log Entry Details</span>
@@ -92,7 +67,7 @@ export function LogDetailsDialog({
</DialogDescription>
</DialogHeader>
<ScrollArea className='h-[75vh] w-full overflow-x-auto'>
<ScrollArea className='h-[70svh] w-full overflow-x-auto sm:h-[75vh]'>
<div className='space-y-6'>
<div>
<h4 className='mb-2 text-sm font-medium'>Message</h4>
+45 -88
View File
@@ -1,41 +1,13 @@
import { Badge } from '@/components/ui/badge';
import { Eye } from 'lucide-react';
interface LogEntry {
asctime: string;
name: string;
levelname: string;
message: string;
pathname: string;
lineno: number;
version: string;
request_id: string;
[key: string]: string | number | object | undefined;
}
import { ChevronRight } from 'lucide-react';
import { getLogLevelBadgeVariant } from '@/lib/utils/log-level';
import type { LogEntry } from './types';
interface LogEntryCardProps {
entry: LogEntry;
onClick: (entry: LogEntry) => void;
}
const getLevelColor = (level: string): string => {
switch (level.toUpperCase()) {
case 'TRACE':
case 'DEBUG':
return 'bg-gray-100 text-gray-800 border-gray-200';
case 'INFO':
return 'bg-blue-100 text-blue-800 border-blue-200';
case 'WARNING':
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
case 'ERROR':
return 'bg-red-100 text-red-800 border-red-200';
case 'CRITICAL':
return 'bg-purple-100 text-purple-800 border-purple-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
export function LogEntryCard({ entry, onClick }: LogEntryCardProps) {
const extraFields = Object.keys(entry).filter(
(key) =>
@@ -50,73 +22,58 @@ export function LogEntryCard({ entry, onClick }: LogEntryCardProps) {
'request_id',
].includes(key)
);
const hasRequestId =
Boolean(entry.request_id) && entry.request_id !== 'no-request-id';
const shortPath = entry.pathname.split('/').pop() || entry.pathname;
return (
<div
className='bg-card hover:bg-accent/50 group mb-4 cursor-pointer overflow-hidden rounded-lg border p-3 transition-colors duration-200 sm:p-4'
className='bg-card hover:bg-accent/35 group mb-2 cursor-pointer rounded-lg border p-2.5 transition-colors duration-150 sm:p-3'
onClick={() => onClick(entry)}
>
<div className='mb-3 flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div className='flex min-w-0 flex-wrap items-center gap-2'>
<Badge variant='outline' className={getLevelColor(entry.levelname)}>
{entry.levelname}
</Badge>
<span className='text-muted-foreground truncate text-xs sm:text-sm'>
{entry.asctime}
</span>
<Badge variant='secondary' className='truncate text-xs'>
{entry.name}
</Badge>
</div>
<div className='flex min-w-0 items-center gap-2'>
<div className='text-muted-foreground truncate text-xs'>
{entry.pathname}:{entry.lineno}
</div>
<Eye className='text-muted-foreground h-4 w-4 flex-shrink-0 opacity-0 transition-opacity group-hover:opacity-100' />
</div>
</div>
<div className='mb-2 line-clamp-3 overflow-hidden font-mono text-xs break-words sm:text-sm'>
{entry.message}
</div>
{entry.request_id && entry.request_id !== 'no-request-id' && (
<div className='mb-2 min-w-0'>
<div className='inline-block max-w-full'>
<Badge variant='outline' className='text-xs'>
<span className='inline-block max-w-[250px] truncate sm:max-w-[400px]'>
Request ID: {entry.request_id}
</span>
<div className='flex min-w-0 items-start justify-between gap-2'>
<div className='min-w-0 flex-1 space-y-1.5'>
<div className='flex min-w-0 flex-wrap items-center gap-1.5'>
<Badge
variant={getLogLevelBadgeVariant(entry.levelname)}
className='h-5 px-1.5 text-[10px] uppercase'
>
{entry.levelname}
</Badge>
<span className='text-muted-foreground text-[11px]'>
{entry.asctime}
</span>
<Badge
variant='secondary'
className='h-5 max-w-[11rem] truncate px-1.5 text-[10px]'
>
{entry.name}
</Badge>
</div>
</div>
)}
{extraFields.length > 0 && (
<div className='mt-3 min-w-0 border-t pt-3'>
<div className='mb-2 text-xs font-medium'>Additional Fields:</div>
<div className='grid grid-cols-1 gap-2'>
{extraFields.slice(0, 4).map((key) => (
<div
key={key}
className='min-w-0 overflow-hidden text-xs break-words'
>
<span className='font-medium break-all'>{key}:</span>{' '}
<span className='text-muted-foreground break-all'>
{typeof entry[key] === 'object'
? JSON.stringify(entry[key])
: String(entry[key])}
</span>
</div>
))}
{extraFields.length > 4 && (
<div className='text-muted-foreground text-xs'>
...and {extraFields.length - 4} more fields
</div>
)}
<p className='line-clamp-1 font-mono text-xs break-words sm:text-sm'>
{entry.message}
</p>
<div className='text-muted-foreground flex min-w-0 flex-wrap items-center gap-1.5 text-[11px]'>
{hasRequestId ? (
<span className='inline-block max-w-[14rem] truncate rounded border px-1.5 py-0.5 font-mono text-[10px] sm:max-w-[20rem]'>
{entry.request_id}
</span>
) : null}
<span className='truncate'>
{shortPath}:{entry.lineno}
</span>
{extraFields.length > 0 ? (
<span>{extraFields.length} extra</span>
) : null}
</div>
</div>
)}
<div className='pt-0.5'>
<ChevronRight className='text-muted-foreground h-4 w-4 opacity-50 transition-opacity group-hover:opacity-90' />
</div>
</div>
</div>
);
}
+122 -429
View File
@@ -1,3 +1,11 @@
import {
useEffect,
useState,
type ChangeEvent,
type KeyboardEvent,
} from 'react';
import { format } from 'date-fns';
import { CalendarIcon, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Card,
@@ -21,20 +29,8 @@ import {
PopoverTrigger,
} from '@/components/ui/popover';
import { Calendar } from '@/components/ui/calendar';
import { Badge } from '@/components/ui/badge';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Checkbox } from '@/components/ui/checkbox';
import { CalendarIcon, Filter, X, Plus } from 'lucide-react';
import { useState, useEffect } from 'react';
import { format } from 'date-fns';
import { cn } from '@/lib/utils';
import { MultiSelectCommandFilter } from './multi-select-command-filter';
interface LogFiltersProps {
selectedDate: string;
@@ -97,32 +93,12 @@ const ENDPOINT_OPTIONS = [
'/embeddings/models',
];
interface FilterBadgeProps {
value: string;
onRemove: (value: string) => void;
}
function FilterBadge({ value, onRemove }: FilterBadgeProps) {
return (
<Badge
variant='secondary'
className='flex items-center gap-1 px-1 font-normal'
>
{value}
<button
type='button'
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRemove(value);
}}
className='hover:bg-muted-foreground/20 rounded-full'
>
<X className='h-3 w-3' />
</button>
</Badge>
);
}
const STATUS_4XX_CODES = STATUS_CODE_OPTIONS.filter((code) =>
code.startsWith('4')
);
const STATUS_5XX_CODES = STATUS_CODE_OPTIONS.filter((code) =>
code.startsWith('5')
);
export function LogFilters({
selectedDate,
@@ -151,7 +127,7 @@ export function LogFilters({
const [isCustom, setIsCustom] = useState<boolean>(!isPreset);
const [date, setDate] = useState<Date | undefined>(
selectedDate && selectedDate !== 'all'
? new Date(selectedDate + 'T00:00:00')
? new Date(`${selectedDate}T00:00:00`)
: undefined
);
@@ -162,6 +138,7 @@ export function LogFilters({
useEffect(() => {
const currentIsPreset = PRESET_LIMITS.includes(limit.toString());
setIsCustom(!currentIsPreset);
if (!currentIsPreset) {
setCustomLimit(limit.toString());
}
@@ -170,88 +147,79 @@ export function LogFilters({
useEffect(() => {
if (selectedDate === 'all' || !selectedDate) {
setDate(undefined);
} else {
const d = new Date(selectedDate + 'T00:00:00');
setDate(isNaN(d.getTime()) ? undefined : d);
return;
}
const parsedDate = new Date(`${selectedDate}T00:00:00`);
setDate(Number.isNaN(parsedDate.getTime()) ? undefined : parsedDate);
}, [selectedDate]);
const handleLimitChange = (value: string) => {
if (value === 'custom') {
setIsCustom(true);
setCustomLimit(limit.toString());
} else {
setIsCustom(false);
setCustomLimit('');
onLimitChange(Number(value));
return;
}
setIsCustom(false);
setCustomLimit('');
onLimitChange(Number(value));
};
const handleCustomLimitChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setCustomLimit(value);
const handleCustomLimitChange = (event: ChangeEvent<HTMLInputElement>) => {
setCustomLimit(event.target.value);
};
const handleCustomLimitApply = () => {
const numValue = parseInt(customLimit);
if (!isNaN(numValue) && numValue > 0) {
onLimitChange(numValue);
} else {
setIsCustom(false);
setCustomLimit('');
onLimitChange(100);
const numericValue = Number.parseInt(customLimit, 10);
if (!Number.isNaN(numericValue) && numericValue > 0) {
onLimitChange(numericValue);
return;
}
setIsCustom(false);
setCustomLimit('');
onLimitChange(100);
};
const handleCustomLimitKeyDown = (
e: React.KeyboardEvent<HTMLInputElement>
) => {
if (e.key === 'Enter') {
const handleCustomLimitKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
handleCustomLimitApply();
}
};
const handleDateSelect = (selectedDate: Date | undefined) => {
setDate(selectedDate);
if (selectedDate) {
onDateChange(format(selectedDate, 'yyyy-MM-dd'));
} else {
onDateChange('all');
}
};
const handleDateSelect = (nextDate: Date | undefined) => {
setDate(nextDate);
const toggleSelection = (
current: string[],
value: string,
onChange: (val: string[]) => void
) => {
if (current.includes(value)) {
onChange(current.filter((v) => v !== value));
} else {
onChange([...current, value]);
if (nextDate) {
onDateChange(format(nextDate, 'yyyy-MM-dd'));
return;
}
onDateChange('all');
};
const handleQuickStatusCode = (range: '4xx' | '5xx') => {
const codes = STATUS_CODE_OPTIONS.filter((c) => c.startsWith(range[0]));
const newSelection = new Set([...selectedStatusCodes]);
const allIncluded = codes.every((c) => selectedStatusCodes.includes(c));
const rangeCodes = range === '4xx' ? STATUS_4XX_CODES : STATUS_5XX_CODES;
const nextSelection = new Set(selectedStatusCodes);
const allSelected = rangeCodes.every((code) =>
selectedStatusCodes.includes(code)
);
if (allIncluded) {
codes.forEach((c) => newSelection.delete(c));
if (allSelected) {
rangeCodes.forEach((code) => nextSelection.delete(code));
} else {
codes.forEach((c) => newSelection.add(c));
rangeCodes.forEach((code) => nextSelection.add(code));
}
onStatusCodesChange(Array.from(newSelection));
onStatusCodesChange(Array.from(nextSelection));
};
return (
<Card className='mb-6'>
<CardHeader>
<CardTitle className='flex items-center gap-2'>
<Filter className='h-5 w-5' />
Filters
</CardTitle>
<CardTitle>Filters</CardTitle>
<CardDescription>
Filter logs by date, level, request ID, text search, status code,
method, endpoint and limit
@@ -314,336 +282,62 @@ export function LogFilters({
</Select>
</div>
<div className='space-y-2'>
<Label>Status Codes</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
className='w-full justify-start text-left font-normal'
>
<div className='flex flex-wrap gap-1'>
{selectedStatusCodes.length > 0 ? (
selectedStatusCodes.map((code) => (
<FilterBadge
key={code}
value={code}
onRemove={(val) =>
toggleSelection(
selectedStatusCodes,
val,
onStatusCodesChange
)
}
/>
))
) : (
<span className='text-muted-foreground'>All codes</span>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className='w-64 p-0' align='start'>
<Command>
<CommandInput
placeholder='Search or add status code...'
value={statusSearch}
onValueChange={setStatusSearch}
/>
<CommandList>
{selectedStatusCodes.length > 0 && (
<CommandGroup heading='Selected'>
{selectedStatusCodes.map((code) => (
<CommandItem
key={`selected-${code}`}
onSelect={() =>
toggleSelection(
selectedStatusCodes,
code,
onStatusCodesChange
)
}
>
<Checkbox checked={true} className='mr-2' />
{code}
</CommandItem>
))}
</CommandGroup>
)}
{statusSearch &&
!STATUS_CODE_OPTIONS.includes(statusSearch) &&
!selectedStatusCodes.includes(statusSearch) && (
<CommandGroup heading='Custom'>
<CommandItem
onSelect={() => {
if (/^\d+$/.test(statusSearch)) {
toggleSelection(
selectedStatusCodes,
statusSearch,
onStatusCodesChange
);
setStatusSearch('');
}
}}
>
<Plus className='mr-2 h-4 w-4' />
Add &quot;{statusSearch}&quot;
</CommandItem>
</CommandGroup>
)}
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading='Quick Filters'>
<CommandItem
onSelect={() => handleQuickStatusCode('4xx')}
>
<Checkbox
checked={STATUS_CODE_OPTIONS.filter((c) =>
c.startsWith('4')
).every((c) => selectedStatusCodes.includes(c))}
className='mr-2'
/>
4xx Errors
</CommandItem>
<CommandItem
onSelect={() => handleQuickStatusCode('5xx')}
>
<Checkbox
checked={STATUS_CODE_OPTIONS.filter((c) =>
c.startsWith('5')
).every((c) => selectedStatusCodes.includes(c))}
className='mr-2'
/>
5xx Errors
</CommandItem>
</CommandGroup>
<CommandGroup heading='Common Codes'>
{STATUS_CODE_OPTIONS.filter(
(code) => !selectedStatusCodes.includes(code)
).map((code) => (
<CommandItem
key={code}
onSelect={() =>
toggleSelection(
selectedStatusCodes,
code,
onStatusCodesChange
)
}
>
<Checkbox checked={false} className='mr-2' />
{code}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<MultiSelectCommandFilter
label='Status Codes'
emptyLabel='All codes'
selectedValues={selectedStatusCodes}
onSelectedValuesChange={onStatusCodesChange}
options={STATUS_CODE_OPTIONS}
searchValue={statusSearch}
onSearchValueChange={setStatusSearch}
searchPlaceholder='Search or add status code...'
popoverClassName='w-[min(16rem,calc(100vw-2rem))] p-0'
optionsGroupLabel='Common Codes'
canAddCustom={(value) => /^\d+$/.test(value)}
quickFilters={[
{
label: '4xx Errors',
checked: STATUS_4XX_CODES.every((code) =>
selectedStatusCodes.includes(code)
),
onSelect: () => handleQuickStatusCode('4xx'),
},
{
label: '5xx Errors',
checked: STATUS_5XX_CODES.every((code) =>
selectedStatusCodes.includes(code)
),
onSelect: () => handleQuickStatusCode('5xx'),
},
]}
/>
<div className='space-y-2'>
<Label>HTTP Methods</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
className='w-full justify-start text-left font-normal'
>
<div className='flex flex-wrap gap-1'>
{selectedMethods.length > 0 ? (
selectedMethods.map((method) => (
<FilterBadge
key={method}
value={method}
onRemove={(val) =>
toggleSelection(
selectedMethods,
val,
onMethodsChange
)
}
/>
))
) : (
<span className='text-muted-foreground'>All methods</span>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className='w-64 p-0' align='start'>
<Command>
<CommandInput
placeholder='Search or add method...'
value={methodSearch}
onValueChange={setMethodSearch}
/>
<CommandList>
{selectedMethods.length > 0 && (
<CommandGroup heading='Selected'>
{selectedMethods.map((method) => (
<CommandItem
key={`selected-${method}`}
onSelect={() =>
toggleSelection(
selectedMethods,
method,
onMethodsChange
)
}
>
<Checkbox checked={true} className='mr-2' />
{method}
</CommandItem>
))}
</CommandGroup>
)}
{methodSearch &&
!METHOD_OPTIONS.includes(methodSearch.toUpperCase()) &&
!selectedMethods.includes(methodSearch.toUpperCase()) && (
<CommandGroup heading='Custom'>
<CommandItem
onSelect={() => {
toggleSelection(
selectedMethods,
methodSearch.toUpperCase(),
onMethodsChange
);
setMethodSearch('');
}}
>
<Plus className='mr-2 h-4 w-4' />
Add &quot;{methodSearch.toUpperCase()}&quot;
</CommandItem>
</CommandGroup>
)}
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{METHOD_OPTIONS.filter(
(method) => !selectedMethods.includes(method)
).map((method) => (
<CommandItem
key={method}
onSelect={() =>
toggleSelection(
selectedMethods,
method,
onMethodsChange
)
}
>
<Checkbox checked={false} className='mr-2' />
{method}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<MultiSelectCommandFilter
label='HTTP Methods'
emptyLabel='All methods'
selectedValues={selectedMethods}
onSelectedValuesChange={onMethodsChange}
options={METHOD_OPTIONS}
searchValue={methodSearch}
onSearchValueChange={setMethodSearch}
searchPlaceholder='Search or add method...'
popoverClassName='w-[min(16rem,calc(100vw-2rem))] p-0'
optionsGroupLabel='Methods'
normalizeCustomValue={(value) => value.toUpperCase()}
/>
<div className='space-y-2'>
<Label>Endpoints</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
className='w-full justify-start text-left font-normal'
>
<div className='flex flex-wrap gap-1 overflow-hidden'>
{selectedEndpoints.length > 0 ? (
selectedEndpoints.map((endpoint) => (
<FilterBadge
key={endpoint}
value={endpoint}
onRemove={(val) =>
toggleSelection(
selectedEndpoints,
val,
onEndpointsChange
)
}
/>
))
) : (
<span className='text-muted-foreground'>
All endpoints
</span>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className='w-80 p-0' align='start'>
<Command>
<CommandInput
placeholder='Search or add endpoint pattern...'
value={endpointSearch}
onValueChange={setEndpointSearch}
/>
<CommandList>
{selectedEndpoints.length > 0 && (
<CommandGroup heading='Selected'>
{selectedEndpoints.map((endpoint) => (
<CommandItem
key={`selected-${endpoint}`}
onSelect={() =>
toggleSelection(
selectedEndpoints,
endpoint,
onEndpointsChange
)
}
>
<Checkbox checked={true} className='mr-2' />
{endpoint}
</CommandItem>
))}
</CommandGroup>
)}
{endpointSearch &&
!ENDPOINT_OPTIONS.includes(endpointSearch) &&
!selectedEndpoints.includes(endpointSearch) && (
<CommandGroup heading='Custom'>
<CommandItem
onSelect={() => {
toggleSelection(
selectedEndpoints,
endpointSearch,
onEndpointsChange
);
setEndpointSearch('');
}}
>
<Plus className='mr-2 h-4 w-4' />
Add &quot;{endpointSearch}&quot;
</CommandItem>
</CommandGroup>
)}
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading='Common Endpoints'>
{ENDPOINT_OPTIONS.filter(
(endpoint) => !selectedEndpoints.includes(endpoint)
).map((endpoint) => (
<CommandItem
key={endpoint}
onSelect={() =>
toggleSelection(
selectedEndpoints,
endpoint,
onEndpointsChange
)
}
>
<Checkbox checked={false} className='mr-2' />
{endpoint}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<MultiSelectCommandFilter
label='Endpoints'
emptyLabel='All endpoints'
selectedValues={selectedEndpoints}
onSelectedValuesChange={onEndpointsChange}
options={ENDPOINT_OPTIONS}
searchValue={endpointSearch}
onSearchValueChange={setEndpointSearch}
searchPlaceholder='Search or add endpoint pattern...'
popoverClassName='w-[min(20rem,calc(100vw-2rem))] p-0'
optionsGroupLabel='Common Endpoints'
/>
<div className='space-y-2'>
<Label htmlFor='request-id'>Request ID</Label>
@@ -652,7 +346,7 @@ export function LogFilters({
type='text'
placeholder='Search by request ID'
value={requestId}
onChange={(e) => onRequestIdChange(e.target.value)}
onChange={(event) => onRequestIdChange(event.target.value)}
/>
</div>
@@ -668,14 +362,14 @@ export function LogFilters({
type='text'
placeholder='Search in message and name'
value={searchText}
onChange={(e) => onSearchTextChange(e.target.value)}
onChange={(event) => onSearchTextChange(event.target.value)}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='limit'>Limit</Label>
{isCustom ? (
<div className='flex gap-2'>
<div className='flex flex-col gap-2 sm:flex-row'>
<Input
id='limit'
type='number'
@@ -686,7 +380,7 @@ export function LogFilters({
onKeyDown={handleCustomLimitKeyDown}
onBlur={handleCustomLimitApply}
autoFocus
className='flex-1'
className='flex-1 sm:flex-auto'
/>
<Button
type='button'
@@ -695,6 +389,7 @@ export function LogFilters({
onClick={() => {
setIsCustom(false);
setCustomLimit('');
if (!isPreset) {
onLimitChange(100);
}
@@ -712,12 +407,11 @@ export function LogFilters({
<SelectValue placeholder='Select limit' />
</SelectTrigger>
<SelectContent>
<SelectItem value='25'>25</SelectItem>
<SelectItem value='50'>50</SelectItem>
<SelectItem value='100'>100</SelectItem>
<SelectItem value='200'>200</SelectItem>
<SelectItem value='500'>500</SelectItem>
<SelectItem value='1000'>1000</SelectItem>
{PRESET_LIMITS.map((preset) => (
<SelectItem key={preset} value={preset}>
{preset}
</SelectItem>
))}
<SelectItem value='custom'>Custom...</SelectItem>
</SelectContent>
</Select>
@@ -727,8 +421,7 @@ export function LogFilters({
)}
</div>
<div className='space-y-2'>
<Label>&nbsp;</Label>
<div className='flex items-end sm:col-span-2 lg:col-span-1'>
<Button
onClick={onClearFilters}
variant='outline'
+182
View File
@@ -0,0 +1,182 @@
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Badge } from '@/components/ui/badge';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Checkbox } from '@/components/ui/checkbox';
import { Plus, X } from 'lucide-react';
interface QuickFilterOption {
label: string;
checked: boolean;
onSelect: () => void;
}
interface MultiSelectCommandFilterProps {
label: string;
emptyLabel: string;
selectedValues: string[];
onSelectedValuesChange: (values: string[]) => void;
options: string[];
searchValue: string;
onSearchValueChange: (value: string) => void;
searchPlaceholder: string;
popoverClassName?: string;
selectedGroupLabel?: string;
customGroupLabel?: string;
quickGroupLabel?: string;
optionsGroupLabel?: string;
quickFilters?: QuickFilterOption[];
normalizeCustomValue?: (value: string) => string;
canAddCustom?: (value: string) => boolean;
}
function FilterBadge({ value }: { value: string }) {
return (
<Badge
variant='secondary'
className='flex items-center gap-1 px-1 font-normal'
>
{value}
<X className='h-3 w-3 opacity-70' aria-hidden='true' />
</Badge>
);
}
export function MultiSelectCommandFilter({
label,
emptyLabel,
selectedValues,
onSelectedValuesChange,
options,
searchValue,
onSearchValueChange,
searchPlaceholder,
popoverClassName = 'w-64 p-0',
selectedGroupLabel = 'Selected',
customGroupLabel = 'Custom',
quickGroupLabel = 'Quick Filters',
optionsGroupLabel = 'Options',
quickFilters = [],
normalizeCustomValue,
canAddCustom,
}: MultiSelectCommandFilterProps) {
const toggleSelection = (value: string) => {
if (selectedValues.includes(value)) {
onSelectedValuesChange(selectedValues.filter((item) => item !== value));
return;
}
onSelectedValuesChange([...selectedValues, value]);
};
const normalizedSearch = normalizeCustomValue
? normalizeCustomValue(searchValue)
: searchValue;
const canShowCustomAction =
normalizedSearch.length > 0 &&
!options.includes(normalizedSearch) &&
!selectedValues.includes(normalizedSearch) &&
(canAddCustom ? canAddCustom(normalizedSearch) : true);
return (
<div className='space-y-2'>
<Label>{label}</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
className='w-full justify-start text-left font-normal'
>
<div className='flex flex-wrap gap-1 overflow-hidden'>
{selectedValues.length > 0 ? (
selectedValues.map((value) => (
<FilterBadge key={value} value={value} />
))
) : (
<span className='text-muted-foreground'>{emptyLabel}</span>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className={popoverClassName} align='start'>
<Command>
<CommandInput
placeholder={searchPlaceholder}
value={searchValue}
onValueChange={onSearchValueChange}
/>
<CommandList>
{selectedValues.length > 0 && (
<CommandGroup heading={selectedGroupLabel}>
{selectedValues.map((value) => (
<CommandItem
key={`selected-${value}`}
onSelect={() => toggleSelection(value)}
>
<Checkbox checked={true} className='mr-2' />
{value}
</CommandItem>
))}
</CommandGroup>
)}
{canShowCustomAction && (
<CommandGroup heading={customGroupLabel}>
<CommandItem
onSelect={() => {
toggleSelection(normalizedSearch);
onSearchValueChange('');
}}
>
<Plus className='mr-2 h-4 w-4' />
Add &quot;{normalizedSearch}&quot;
</CommandItem>
</CommandGroup>
)}
<CommandEmpty>No results found.</CommandEmpty>
{quickFilters.length > 0 && (
<CommandGroup heading={quickGroupLabel}>
{quickFilters.map((filter) => (
<CommandItem key={filter.label} onSelect={filter.onSelect}>
<Checkbox checked={filter.checked} className='mr-2' />
{filter.label}
</CommandItem>
))}
</CommandGroup>
)}
<CommandGroup heading={optionsGroupLabel}>
{options
.filter((option) => !selectedValues.includes(option))
.map((option) => (
<CommandItem
key={option}
onSelect={() => toggleSelection(option)}
>
<Checkbox checked={false} className='mr-2' />
{option}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
}
+121 -115
View File
@@ -2,8 +2,6 @@
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { Button } from '@/components/ui/button';
import {
Card,
@@ -14,9 +12,18 @@ import {
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty';
import { Skeleton } from '@/components/ui/skeleton';
import { FileText, RefreshCw } from 'lucide-react';
import { apiClient } from '@/lib/api/client';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { AppPageShell } from '@/components/app-page-shell';
import { PageHeader } from '@/components/page-header';
import { LogEntry, LogsResponse } from './types';
import { LogFilters } from './log-filters';
import { LogEntryCard } from './log-entry-card';
@@ -135,126 +142,125 @@ export default function LogsPage() {
setIsDialogOpen(true);
};
const hasActiveFilters =
selectedDate !== 'all' ||
selectedLevel !== 'all' ||
Boolean(requestId) ||
Boolean(searchText) ||
selectedStatusCodes.length > 0 ||
selectedMethods.length > 0 ||
selectedEndpoints.length > 0;
const activeFilterDescription = [
selectedDate !== 'all' ? `date ${selectedDate}` : null,
selectedLevel !== 'all' ? `level ${selectedLevel}` : null,
requestId ? `request ID ${requestId}` : null,
searchText ? `text "${searchText}"` : null,
selectedStatusCodes.length > 0
? `status ${selectedStatusCodes.join(', ')}`
: null,
selectedMethods.length > 0 ? `method ${selectedMethods.join(', ')}` : null,
selectedEndpoints.length > 0
? `endpoint ${selectedEndpoints.join(', ')}`
: null,
]
.filter(Boolean)
.join(' • ');
return (
<SidebarProvider>
<AppSidebar variant='inset' />
<SidebarInset className='overflow-x-hidden p-0'>
<SiteHeader />
<div className='container max-w-6xl overflow-x-hidden px-3 py-4 sm:px-4 sm:py-8 md:px-6 lg:px-8'>
<div className='mb-6 flex flex-col gap-3 sm:mb-8 sm:gap-4 lg:flex-row lg:items-start lg:justify-between'>
<div>
<h1 className='flex items-center gap-2 text-2xl font-bold tracking-tight sm:text-3xl'>
<FileText className='h-6 w-6 sm:h-8 sm:w-8' />
System Logs
</h1>
<p className='text-muted-foreground mt-1 text-sm sm:mt-2 sm:text-base'>
View and filter application logs
</p>
</div>
<Button
onClick={() => refetchLogs()}
variant='outline'
size='sm'
className='self-start'
>
<AppPageShell contentClassName='mx-auto w-full max-w-5xl overflow-x-hidden'>
<div className='space-y-6'>
<PageHeader
title='System Logs'
description='View and filter application logs.'
actions={
<Button onClick={() => refetchLogs()} variant='outline' size='sm'>
<RefreshCw className='mr-2 h-4 w-4' />
Refresh
</Button>
</div>
}
/>
<LogFilters
selectedDate={selectedDate}
selectedLevel={selectedLevel}
requestId={requestId}
searchText={searchText}
selectedStatusCodes={selectedStatusCodes}
selectedMethods={selectedMethods}
selectedEndpoints={selectedEndpoints}
limit={limit}
onDateChange={setSelectedDate}
onLevelChange={setSelectedLevel}
onRequestIdChange={setRequestId}
onSearchTextChange={setSearchText}
onStatusCodesChange={setSelectedStatusCodes}
onMethodsChange={setSelectedMethods}
onEndpointsChange={setSelectedEndpoints}
onLimitChange={setLimit}
onClearFilters={handleClearFilters}
/>
<LogFilters
selectedDate={selectedDate}
selectedLevel={selectedLevel}
requestId={requestId}
searchText={searchText}
selectedStatusCodes={selectedStatusCodes}
selectedMethods={selectedMethods}
selectedEndpoints={selectedEndpoints}
limit={limit}
onDateChange={setSelectedDate}
onLevelChange={setSelectedLevel}
onRequestIdChange={setRequestId}
onSearchTextChange={setSearchText}
onStatusCodesChange={setSelectedStatusCodes}
onMethodsChange={setSelectedMethods}
onEndpointsChange={setSelectedEndpoints}
onLimitChange={setLimit}
onClearFilters={handleClearFilters}
/>
<Card>
<CardHeader>
<CardTitle className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'>
<span className='text-lg sm:text-xl'>Log Entries</span>
{logsData && (
<Badge variant='secondary' className='text-xs sm:text-sm'>
{logsData.logs.length} entries
</Badge>
)}
</CardTitle>
{(selectedDate !== 'all' ||
selectedLevel !== 'all' ||
requestId ||
searchText ||
selectedStatusCodes.length > 0 ||
selectedMethods.length > 0 ||
selectedEndpoints.length > 0) && (
<CardDescription className='text-xs sm:text-sm'>
Showing logs
{selectedDate !== 'all' && ` for ${selectedDate}`}
{selectedLevel !== 'all' && ` with level ${selectedLevel}`}
{requestId && ` with request ID ${requestId}`}
{searchText && ` matching "${searchText}"`}
{selectedStatusCodes.length > 0 &&
` with status ${selectedStatusCodes.join(', ')}`}
{selectedMethods.length > 0 &&
` with method ${selectedMethods.join(', ')}`}
{selectedEndpoints.length > 0 &&
` with endpoint ${selectedEndpoints.join(', ')}`}
</CardDescription>
<Card>
<CardHeader>
<div className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'>
<CardTitle>Log Entries</CardTitle>
{logsData && (
<Badge variant='secondary'>
{logsData.logs.length} entries
</Badge>
)}
</CardHeader>
<CardContent className='overflow-hidden p-3 sm:p-6'>
{isLoading ? (
<div className='flex items-center justify-center py-8'>
<RefreshCw className='h-6 w-6 animate-spin' />
<span className='ml-2 text-sm sm:text-base'>
Loading logs...
</span>
</div>
{hasActiveFilters && (
<CardDescription>
Showing logs filtered by {activeFilterDescription}
</CardDescription>
)}
</CardHeader>
<CardContent className='overflow-hidden'>
{isLoading ? (
<div className='space-y-2'>
{Array.from({ length: 8 }).map((_, index) => (
<Skeleton
key={`logs-loading-${index}`}
className='h-16 w-full rounded-lg'
/>
))}
</div>
) : logsData?.logs && logsData.logs.length > 0 ? (
<ScrollArea className='h-[55svh] min-h-[420px] w-full sm:h-[600px]'>
<div className='space-y-2 pr-3'>
{logsData.logs.map((entry, index) => (
<LogEntryCard
key={`${entry.request_id}-${entry.asctime}-${entry.lineno}-${index}`}
entry={entry}
onClick={handleLogClick}
/>
))}
</div>
) : logsData?.logs && logsData.logs.length > 0 ? (
<>
<ScrollArea className='h-[500px] w-full sm:h-[600px]'>
<div className='space-y-2 pr-3'>
{logsData.logs.map((entry, index) => (
<LogEntryCard
key={`${entry.request_id}-${entry.asctime}-${entry.lineno}-${index}`}
entry={entry}
onClick={handleLogClick}
/>
))}
</div>
</ScrollArea>
</>
) : (
<div className='text-muted-foreground py-8 text-center'>
<FileText className='mx-auto mb-4 h-10 w-10 opacity-50 sm:h-12 sm:w-12' />
<p className='text-sm sm:text-base'>No log entries found</p>
<p className='text-xs sm:text-sm'>
Try adjusting your filters or check back later
</p>
</div>
)}
</CardContent>
</Card>
</ScrollArea>
) : (
<Empty className='py-8'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<FileText className='h-4 w-4' />
</EmptyMedia>
<EmptyTitle>No log entries found</EmptyTitle>
<EmptyDescription>
Try adjusting your filters or check back later.
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</CardContent>
</Card>
<LogDetailsDialog
log={selectedLog}
isOpen={isDialogOpen}
onClose={() => setIsDialogOpen(false)}
/>
</div>
</SidebarInset>
</SidebarProvider>
<LogDetailsDialog
log={selectedLog}
isOpen={isDialogOpen}
onClose={() => setIsDialogOpen(false)}
/>
</div>
</AppPageShell>
);
}
-329
View File
@@ -1,329 +0,0 @@
'use client';
import { ModelSelector } from '@/components/ModelSelector';
import { ModelTester } from '@/components/ModelTester';
import { ApiEndpointTester } from '@/components/ApiEndpointTester';
import { ModelSearchFilter } from '@/components/ModelSearchFilter';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useQuery } from '@tanstack/react-query';
import { AdminService } from '@/lib/api/services/admin';
import { Skeleton } from '@/components/ui/skeleton';
import { AlertCircle, Users, Globe } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { useMemo, useState } from 'react';
import type { Model } from '@/lib/api/schemas/models';
import { groupAndSortModelsByProvider } from '@/lib/utils/modelSort';
export default function ModelsPage() {
const [filteredModels, setFilteredModels] = useState<Model[]>([]);
const {
data: modelsData,
isLoading: isLoadingModels,
error: modelsError,
} = useQuery({
queryKey: ['admin-models-with-providers'],
queryFn: () => AdminService.getModelsWithProviders(),
refetchOnWindowFocus: false,
});
const { models = [], groups = [] } = modelsData || {};
const groupedModels = useMemo(() => {
return groupAndSortModelsByProvider(models);
}, [models]);
const groupDataMap = useMemo(() => {
return new Map(groups.map((group) => [group.provider, group]));
}, [groups]);
const providerInfo = useMemo(() => {
const allProviders = new Set([
...Object.keys(groupedModels),
...groups.map((g) => g.provider),
]);
console.log(allProviders);
return Array.from(allProviders).map((provider) => {
const providerModels = groupedModels[provider] || [];
const groupData = groupDataMap.get(provider);
const activeModels = providerModels.filter(
(m) => m.isEnabled && !m.soft_deleted
).length;
const totalModels = providerModels.length;
return {
provider,
activeModels,
totalModels,
groupData,
hasGroupUrl: !!groupData?.group_url,
hasGroupApiKey: !!groupData?.group_api_key,
};
});
}, [groupedModels, groupDataMap, groups]);
return (
<SidebarProvider>
<AppSidebar variant='inset' />
<SidebarInset>
<SiteHeader />
<div className='flex flex-1 flex-col'>
<div className='@container/main flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8'>
<div className='mb-6 flex items-center justify-between'>
<h1 className='text-2xl font-bold tracking-tight'>
Model Management & API Testing
</h1>
</div>
<Tabs defaultValue='manage' className='w-full'>
<TabsList className='grid w-full grid-cols-3'>
<TabsTrigger value='manage'>Manage Models</TabsTrigger>
{/*<TabsTrigger value='test-basic'>Basic Testing</TabsTrigger>
<TabsTrigger value='test-api'>API Endpoints</TabsTrigger> */}
</TabsList>
<TabsContent value='manage' className='space-y-4'>
<div className='text-muted-foreground text-sm'>
Manage your AI models organized by provider groups. Configure
API keys, and organize models by provider groups.
</div>
{isLoadingModels ? (
<div className='space-y-4'>
<Skeleton className='h-[60px] w-full' />
<Skeleton className='h-[400px] w-full' />
</div>
) : modelsError ? (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
Failed to load models. Please try refreshing the page.
</AlertDescription>
</Alert>
) : (
<Tabs defaultValue='all' className='w-full'>
<div className='space-y-4'>
{/* Provider Tabs Navigation */}
<div className='overflow-x-auto rounded-lg border p-1'>
<TabsList className='grid w-full max-w-full min-w-max auto-cols-fr grid-flow-col gap-1 sm:gap-2'>
<TabsTrigger
value='all'
className='flex items-center gap-1 text-xs whitespace-nowrap sm:gap-2 sm:text-sm'
>
<Globe className='h-3 w-3 sm:h-4 sm:w-4' />
<span className='hidden sm:inline'>All Models</span>
<span className='sm:hidden'>All</span>
<Badge variant='secondary' className='ml-1 text-xs'>
{models.length}
</Badge>
</TabsTrigger>
{providerInfo.map(
({ provider, activeModels, totalModels }) => (
<TabsTrigger
key={provider}
value={provider}
className='flex min-w-fit items-center gap-1 text-xs whitespace-nowrap sm:gap-2 sm:text-sm'
>
<Users className='h-3 w-3 sm:h-4 sm:w-4' />
<span className='max-w-20 truncate sm:max-w-none'>
{provider}
</span>
<div className='flex items-center gap-1'>
<Badge
variant='secondary'
className='ml-1 text-xs'
>
{activeModels}/{totalModels}
</Badge>
</div>
</TabsTrigger>
)
)}
</TabsList>
</div>
{/* All Models Tab */}
<TabsContent value='all'>
<div className='space-y-4'>
<div className='text-muted-foreground text-sm'>
Overview of all models across all provider groups.
</div>
<ModelSearchFilter
models={models}
onFilteredModelsChange={setFilteredModels}
/>
<ModelSelector
filteredModels={filteredModels}
showDeleteAllButton={true}
/>
</div>
</TabsContent>
{providerInfo.map(
({ provider, totalModels, groupData }) => {
const providerModels = groupedModels[provider] || [];
return (
<TabsContent key={provider} value={provider}>
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<div>
<h3 className='flex items-center gap-2 text-lg font-semibold'>
<Users className='h-5 w-5' />
{provider}
</h3>
<div className='text-muted-foreground flex items-center gap-4 text-sm'>
{providerModels.filter(
(m) => m.soft_deleted
).length > 0 && (
<span className='text-orange-600'>
{
providerModels.filter(
(m) => m.soft_deleted
).length
}{' '}
disabled
</span>
)}
{groupData?.group_url && (
<span className='flex items-center gap-1'>
<Globe className='h-3 w-3' />
{groupData.group_url}
</span>
)}
{totalModels === 0 && (
<span className='text-muted-foreground'>
No models configured
</span>
)}
</div>
</div>
</div>
{totalModels === 0 && (
<Alert className='mb-4'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
<div className='space-y-2'>
<p className='font-medium'>
No models found for this provider
</p>
<div className='space-y-1 text-sm'>
<p className='font-medium'>
Common issues:
</p>
<ul className='ml-2 list-inside list-disc space-y-1'>
<li>
<strong>API credentials:</strong>{' '}
Check if the API key is correct
and has the right permissions
</li>
<li>
<strong>Base URL:</strong> Verify
the base URL is correct for your
provider
</li>
<li>
<strong>Network access:</strong>{' '}
Ensure the server can reach the
provider&apos;s API endpoint
</li>
<li>
<strong>Provider status:</strong>{' '}
The upstream provider might be
temporarily unavailable
</li>
</ul>
{groupData?.group_url && (
<p className='text-muted-foreground mt-2 text-xs'>
Current endpoint:{' '}
<code className='bg-muted rounded px-1'>
{groupData.group_url}
</code>
</p>
)}
</div>
</div>
</AlertDescription>
</Alert>
)}
<ModelSelector
filterProvider={provider}
groupData={groupData}
showProviderActions={true}
showDeleteAllButton={false}
/>
</div>
</TabsContent>
);
}
)}
</div>
</Tabs>
)}
</TabsContent>
<TabsContent value='test-basic' className='space-y-4'>
<div className='text-muted-foreground text-sm'>
Test model credentials and connectivity with basic chat
completion requests through the secure proxy (resolves CORS
and Docker network issues). Models can be tested even without
API keys configured (useful for free models or when
authentication is handled elsewhere).
</div>
{isLoadingModels ? (
<div className='space-y-4'>
<Skeleton className='h-[200px] w-full' />
<Skeleton className='h-[100px] w-full' />
</div>
) : modelsError ? (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
Failed to load models for testing. Please try refreshing
the page.
</AlertDescription>
</Alert>
) : (
<ModelTester models={models} />
)}
</TabsContent>
<TabsContent value='test-api' className='space-y-4'>
<div className='text-muted-foreground text-sm'>
Comprehensive testing of all OpenAI API endpoints including
chat completions, embeddings, image generation, audio
synthesis, and model listing through the secure proxy
(resolves CORS and Docker network issues). Models can be
tested with or without API keys configured.
</div>
{isLoadingModels ? (
<div className='space-y-4'>
<Skeleton className='h-[300px] w-full' />
<Skeleton className='h-[200px] w-full' />
</div>
) : modelsError ? (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
Failed to load models for API testing. Please try
refreshing the page.
</AlertDescription>
</Alert>
) : (
<ApiEndpointTester models={models} />
)}
</TabsContent>
</Tabs>
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+253
View File
@@ -0,0 +1,253 @@
'use client';
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { AlertCircle } from 'lucide-react';
import type { Model } from '@/lib/api/schemas/models';
import { AdminService } from '@/lib/api/services/admin';
import { groupAndSortModelsByProvider } from '@/lib/utils/model-sort';
import { AppPageShell } from '@/components/app-page-shell';
import { PageHeader } from '@/components/page-header';
import { ModelSelector } from '@/components/model-selector';
import { ModelTester } from '@/components/model-tester';
import { ApiEndpointTester } from '@/components/api-endpoint-tester';
import { ModelSearchFilter } from '@/components/model-search-filter';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
export default function ModelsPage() {
const [filteredModels, setFilteredModels] = useState<Model[] | undefined>(
undefined
);
const [selectedProviderScope, setSelectedProviderScope] =
useState<string>('all');
const {
data: modelsData,
isLoading: isLoadingModels,
error: modelsError,
} = useQuery({
queryKey: ['admin-models-with-providers'],
queryFn: () => AdminService.getModelsWithProviders(),
refetchOnWindowFocus: false,
});
const { models = [], groups = [] } = modelsData || {};
const groupedModels = useMemo(
() => groupAndSortModelsByProvider(models),
[models]
);
const groupDataMap = useMemo(
() => new Map(groups.map((group) => [group.provider, group])),
[groups]
);
const providerInfo = useMemo(() => {
const allProviders = new Set([
...Object.keys(groupedModels),
...groups.map((group) => group.provider),
]);
return Array.from(allProviders)
.map((provider) => {
const providerModels = groupedModels[provider] || [];
const groupData = groupDataMap.get(provider);
return {
provider,
totalModels: providerModels.length,
disabledModels: providerModels.filter((model) => model.soft_deleted)
.length,
groupData,
};
})
.sort((a, b) => a.provider.localeCompare(b.provider));
}, [groupDataMap, groupedModels, groups]);
const activeProviderScope = useMemo(() => {
if (selectedProviderScope === 'all') {
return 'all';
}
const providerExists = providerInfo.some(
(provider) => provider.provider === selectedProviderScope
);
return providerExists ? selectedProviderScope : 'all';
}, [providerInfo, selectedProviderScope]);
const selectedProviderGroup =
activeProviderScope === 'all'
? undefined
: groupDataMap.get(activeProviderScope);
const scopedModels = useMemo(() => {
if (activeProviderScope === 'all') {
return models;
}
return models.filter((model) => model.provider === activeProviderScope);
}, [activeProviderScope, models]);
return (
<AppPageShell contentClassName='mx-auto w-full max-w-5xl'>
<div className='space-y-3 sm:space-y-4'>
<PageHeader
title='Model Management'
description='Manage provider model catalogs and validate endpoints from one place.'
/>
<Tabs defaultValue='manage' className='w-full gap-3 sm:gap-4'>
<TabsList
variant='line'
className='w-full snap-x snap-mandatory justify-start gap-0.5 overflow-x-auto whitespace-nowrap [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
>
<TabsTrigger
value='manage'
className='h-9 snap-start px-2 text-[13px] sm:h-10 sm:px-2.5 sm:text-sm'
>
Manage Models
</TabsTrigger>
<TabsTrigger
value='test-basic'
className='h-9 snap-start px-2 text-[13px] sm:h-10 sm:px-2.5 sm:text-sm'
>
Basic Testing
</TabsTrigger>
<TabsTrigger
value='test-api'
className='h-9 snap-start px-2 text-[13px] sm:h-10 sm:px-2.5 sm:text-sm'
>
API Endpoints
</TabsTrigger>
</TabsList>
<TabsContent value='manage' className='mt-0'>
{isLoadingModels ? (
<div className='space-y-4'>
<Skeleton className='h-16 w-full' />
<Skeleton className='h-[420px] w-full' />
</div>
) : modelsError ? (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
Failed to load models. Please try refreshing the page.
</AlertDescription>
</Alert>
) : (
<div className='space-y-3 sm:space-y-4'>
<div className='flex flex-col gap-2 sm:gap-2.5 md:flex-row md:items-center'>
<Select
value={activeProviderScope}
onValueChange={(value) => {
setSelectedProviderScope(value);
setFilteredModels(undefined);
}}
>
<SelectTrigger className='h-8 w-full md:w-[220px]'>
<SelectValue placeholder='Provider scope' />
</SelectTrigger>
<SelectContent align='start'>
<SelectItem value='all'>
All providers ({models.length})
</SelectItem>
{providerInfo.map(({ provider, totalModels }) => (
<SelectItem key={provider} value={provider}>
{provider} ({totalModels})
</SelectItem>
))}
</SelectContent>
</Select>
<ModelSearchFilter
models={scopedModels}
onFilteredModelsChange={setFilteredModels}
className='w-full min-w-0 flex-1'
/>
</div>
<ModelSelector
filterProvider={
activeProviderScope === 'all'
? undefined
: activeProviderScope
}
groupData={selectedProviderGroup}
filteredModels={filteredModels}
showDeleteAllButton={activeProviderScope === 'all'}
/>
</div>
)}
</TabsContent>
<TabsContent value='test-basic' className='mt-0 space-y-3'>
<div className='space-y-1'>
<h3 className='text-base font-semibold'>
Basic Credential Testing
</h3>
<p className='text-muted-foreground text-sm'>
Run chat-completion checks through the secure proxy to validate
model credentials and endpoint connectivity.
</p>
</div>
{isLoadingModels ? (
<div className='space-y-4'>
<Skeleton className='h-[220px] w-full' />
<Skeleton className='h-[120px] w-full' />
</div>
) : modelsError ? (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
Failed to load models for testing. Please try refreshing the
page.
</AlertDescription>
</Alert>
) : (
<ModelTester models={models} />
)}
</TabsContent>
<TabsContent value='test-api' className='mt-0 space-y-3'>
<div className='space-y-1'>
<h3 className='text-base font-semibold'>
OpenAI Endpoint Testing
</h3>
<p className='text-muted-foreground text-sm'>
Validate chat, embeddings, image, audio, and model-listing
endpoints through the secure proxy.
</p>
</div>
{isLoadingModels ? (
<div className='space-y-4'>
<Skeleton className='h-[320px] w-full' />
<Skeleton className='h-[220px] w-full' />
</div>
) : modelsError ? (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
Failed to load models for API testing. Please try refreshing
the page.
</AlertDescription>
</Alert>
) : (
<ApiEndpointTester models={models} />
)}
</TabsContent>
</Tabs>
</div>
</AppPageShell>
);
}
+999 -288
View File
File diff suppressed because it is too large Load Diff
+12 -9
View File
@@ -3,10 +3,11 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState, type ReactNode } from 'react';
import { Toaster } from 'sonner';
import { AuthProvider } from '@/lib/auth/AuthContext';
import { ProtectedRoute } from '@/lib/auth/ProtectedRoute';
import { AuthProvider } from '@/lib/auth/auth-context';
import { ProtectedRoute } from '@/lib/auth/protected-route';
import { ThemeProvider } from '@/components/theme-provider';
import { TooltipProvider } from '@/components/ui/tooltip';
import { Toaster } from '@/components/ui/sonner';
interface ProvidersProps {
children: ReactNode;
@@ -34,12 +35,14 @@ export function Providers({ children }: ProvidersProps) {
enableSystem
disableTransitionOnChange
>
<AuthProvider>
<ProtectedRoute>
{children}
<Toaster position='top-right' />
</ProtectedRoute>
</AuthProvider>
<TooltipProvider>
<AuthProvider>
<ProtectedRoute>
{children}
<Toaster position='top-right' />
</ProtectedRoute>
</AuthProvider>
</TooltipProvider>
<ReactQueryDevtools initialIsOpen={false} />
</ThemeProvider>
</QueryClientProvider>
+280 -810
View File
File diff suppressed because it is too large Load Diff
+22 -29
View File
@@ -4,37 +4,30 @@ import * as React from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ServerConfigSettings } from '@/components/settings/server-config-settings';
import { AdminSettings } from '@/components/settings/admin-settings';
import { SiteHeader } from '@/components/site-header';
import { AppSidebar } from '@/components/app-sidebar';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { Toaster } from 'sonner';
import { AppPageShell } from '@/components/app-page-shell';
import { PageHeader } from '@/components/page-header';
export default function SettingsPage() {
return (
<SidebarProvider>
<AppSidebar variant='inset' />
<SidebarInset>
<SiteHeader />
<div className='flex flex-1 flex-col'>
<div className='@container/main flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8'>
<div className='flex items-center'>
<h1 className='text-2xl font-bold tracking-tight'>Settings</h1>
</div>
<Tabs defaultValue='admin' className='w-full'>
<TabsList className='mb-4'>
<TabsTrigger value='admin'>Admin Settings</TabsTrigger>
</TabsList>
<TabsContent value='server'>
<ServerConfigSettings />
</TabsContent>
<TabsContent value='admin'>
<AdminSettings />
</TabsContent>
</Tabs>
</div>
</div>
</SidebarInset>
<Toaster />
</SidebarProvider>
<AppPageShell contentClassName='mx-auto w-full max-w-5xl'>
<div className='space-y-6'>
<PageHeader
title='Settings'
description='Manage admin authentication, service metadata, and upstream forwarding.'
/>
<Tabs defaultValue='admin' className='w-full'>
<TabsList variant='line' className='mb-4 w-full'>
<TabsTrigger value='admin'>Admin Settings</TabsTrigger>
<TabsTrigger value='server'>Server Config</TabsTrigger>
</TabsList>
<TabsContent value='server'>
<ServerConfigSettings />
</TabsContent>
<TabsContent value='admin'>
<AdminSettings />
</TabsContent>
</Tabs>
</div>
</AppPageShell>
);
}
-353
View File
@@ -1,353 +0,0 @@
'use client';
import { useState } from 'react';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { useQuery } from '@tanstack/react-query';
import { Skeleton } from '@/components/ui/skeleton';
import {
AlertCircle,
Copy,
RefreshCw,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { toast } from 'sonner';
import { apiClient } from '@/lib/api/client';
interface Transaction {
id: string;
created_at: string;
token: string;
amount: string;
}
interface PaginatedTransactionsResponse {
transactions: Transaction[];
total: number;
page: number;
per_page: number;
total_pages: number;
}
const TransactionService = {
getAllTransactions: async (): Promise<Transaction[]> => {
try {
const response = await apiClient.get<Transaction[]>('/api/transactions');
return response || [];
} catch (error) {
console.error('Failed to fetch transactions:', error);
throw new Error('Failed to fetch transactions');
}
},
getPaginatedTransactions: async (
page: number,
perPage: number
): Promise<PaginatedTransactionsResponse> => {
try {
const response = await apiClient.get<PaginatedTransactionsResponse>(
`/api/transactions/paginated/${page}/${perPage}`
);
return response;
} catch (error) {
console.error('Failed to fetch paginated transactions:', error);
throw new Error('Failed to fetch paginated transactions');
}
},
getRecentTransactions: async (limit: number): Promise<Transaction[]> => {
try {
const response = await apiClient.get<Transaction[]>(
`/api/transactions/recent/${limit}`
);
return response || [];
} catch (error) {
console.error('Failed to fetch recent transactions:', error);
throw new Error('Failed to fetch recent transactions');
}
},
};
export default function TransactionsPage() {
const [currentPage, setCurrentPage] = useState(1);
const perPage = 20;
// Fetch paginated transactions data
const {
data: paginationData,
isLoading,
error,
refetch,
} = useQuery({
queryKey: ['transactions', currentPage, perPage],
queryFn: () =>
TransactionService.getPaginatedTransactions(currentPage, perPage),
refetchOnWindowFocus: false,
retry: 1,
staleTime: 30000, // 30 seconds
});
const transactions = paginationData?.transactions || [];
const totalPages = paginationData?.total_pages || 0;
const total = paginationData?.total || 0;
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
const formatAmount = (amount: string) => {
return `${parseInt(amount).toLocaleString()} msats`;
};
const truncateToken = (token: string) => {
if (token.length <= 20) return token;
return `${token.slice(0, 10)}...${token.slice(-10)}`;
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
toast.success('Token copied to clipboard!');
} catch (error) {
console.error('Failed to copy to clipboard:', error);
toast.error('Failed to copy token');
}
};
const goToPage = (page: number) => {
if (page >= 1 && page <= totalPages) {
setCurrentPage(page);
}
};
const goToPrevious = () => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
}
};
const goToNext = () => {
if (currentPage < totalPages) {
setCurrentPage(currentPage + 1);
}
};
return (
<TooltipProvider>
<SidebarProvider>
<AppSidebar variant='inset' />
<SidebarInset>
<SiteHeader />
<div className='flex flex-1 flex-col'>
<div className='@container/main flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8'>
<div className='mb-6 flex items-center justify-between'>
<div>
<h1 className='text-2xl font-bold tracking-tight'>
Transaction History
</h1>
<p className='text-muted-foreground text-sm'>
View all Cashu token transactions processed by the system
</p>
</div>
<Button
onClick={() => refetch()}
variant='outline'
size='sm'
disabled={isLoading}
>
<RefreshCw
className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`}
/>
Refresh
</Button>
</div>
{isLoading ? (
<div className='space-y-4'>
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className='h-[120px] w-full' />
))}
</div>
) : error ? (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
Failed to load transactions.{' '}
{error instanceof Error
? error.message
: 'Please check if the server is running and try refreshing the page.'}
</AlertDescription>
</Alert>
) : transactions.length === 0 ? (
<div className='py-8 text-center'>
<p className='text-muted-foreground'>
No transactions found.
</p>
</div>
) : (
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<div className='text-muted-foreground text-sm'>
Showing {(currentPage - 1) * perPage + 1} to{' '}
{Math.min(currentPage * perPage, total)} of {total}{' '}
transactions
</div>
<div className='text-muted-foreground text-sm'>
Page {currentPage} of {totalPages}
</div>
</div>
<div className='rounded-md border'>
<Table>
<TableHeader>
<TableRow>
<TableHead className='w-[100px]'>ID</TableHead>
<TableHead>Date & Time</TableHead>
<TableHead>Amount</TableHead>
<TableHead className='w-[400px]'>
Cashu Token
</TableHead>
<TableHead className='w-[60px]'>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transactions.map((transaction) => (
<TableRow key={transaction.id}>
<TableCell className='font-mono text-xs'>
{transaction.id.slice(0, 8)}
</TableCell>
<TableCell>
<div className='text-sm'>
{formatDate(transaction.created_at)}
</div>
</TableCell>
<TableCell>
<Badge variant='secondary'>
{formatAmount(transaction.amount)}
</Badge>
</TableCell>
<TableCell>
<div className='flex items-center gap-2'>
<Tooltip>
<TooltipTrigger asChild>
<p className='max-w-[300px] cursor-pointer truncate rounded px-1 py-0.5 font-mono text-xs hover:bg-gray-100'>
{truncateToken(transaction.token)}
</p>
</TooltipTrigger>
<TooltipContent className='max-w-md break-all'>
<p className='font-mono text-xs'>
{transaction.token}
</p>
</TooltipContent>
</Tooltip>
</div>
</TableCell>
<TableCell>
<Button
variant='ghost'
size='sm'
onClick={() =>
copyToClipboard(transaction.token)
}
className='h-8 w-8 p-0'
>
<Copy className='h-4 w-4' />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className='flex items-center justify-between'>
<div className='text-muted-foreground text-sm'>
Page {currentPage} of {totalPages}
</div>
<div className='flex items-center space-x-2'>
<Button
variant='outline'
size='sm'
onClick={goToPrevious}
disabled={currentPage === 1}
>
<ChevronLeft className='mr-1 h-4 w-4' />
Previous
</Button>
{/* Page Numbers */}
<div className='flex items-center space-x-1'>
{Array.from(
{ length: Math.min(5, totalPages) },
(_, i) => {
const pageNumber =
currentPage <= 3
? i + 1
: currentPage >= totalPages - 2
? totalPages - 4 + i
: currentPage - 2 + i;
if (pageNumber < 1 || pageNumber > totalPages)
return null;
return (
<Button
key={pageNumber}
variant={
currentPage === pageNumber
? 'default'
: 'outline'
}
size='sm'
onClick={() => goToPage(pageNumber)}
className='h-9 w-9 p-0'
>
{pageNumber}
</Button>
);
}
)}
</div>
<Button
variant='outline'
size='sm'
onClick={goToNext}
disabled={currentPage === totalPages}
>
Next
<ChevronRight className='ml-1 h-4 w-4' />
</Button>
</div>
</div>
)}
</div>
)}
</div>
</div>
</SidebarInset>
</SidebarProvider>
</TooltipProvider>
);
}
+19 -15
View File
@@ -3,30 +3,34 @@
import { Button } from '@/components/ui/button';
import { useRouter } from 'next/navigation';
import { ShieldAlertIcon } from 'lucide-react';
import { AuthPageShell } from '@/components/auth-page-shell';
export default function UnauthorizedPage() {
const router = useRouter();
return (
<div className='bg-background flex min-h-screen flex-col items-center justify-center p-4'>
<div className='flex max-w-md flex-col items-center space-y-6 text-center'>
<ShieldAlertIcon className='text-destructive h-24 w-24' />
<h1 className='text-4xl font-bold'>Access Denied</h1>
<p className='text-muted-foreground text-lg'>
You don&apos;t have permission to access this page. Please contact
your administrator if you believe this is an error.
<AuthPageShell
title='Access Denied'
description="You don't have permission to access this page."
>
<div className='flex flex-col items-center gap-6'>
<ShieldAlertIcon className='text-destructive h-20 w-20' />
<p className='text-muted-foreground text-center text-sm'>
Contact your administrator if you believe this is an error.
</p>
<div className='flex gap-4'>
<Button onClick={() => router.push('/')}>Go to Dashboard</Button>
<Button variant='outline' onClick={() => router.back()}>
<div className='flex w-full flex-col gap-3 sm:w-auto sm:flex-row'>
<Button onClick={() => router.push('/')} className='w-full sm:w-auto'>
Go to Dashboard
</Button>
<Button
variant='outline'
onClick={() => router.back()}
className='w-full sm:w-auto'
>
Go Back
</Button>
</div>
</div>
</div>
</AuthPageShell>
);
}
+5 -3
View File
@@ -1,15 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"style": "radix-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "stone",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
@@ -17,5 +19,5 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
"registries": {}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { ManualModelSchema, type ManualModel } from '@/lib/api/schemas/models';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -45,9 +46,10 @@ export function AddModelForm({
isOpen,
}: AddModelFormProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
type ManualModelInput = z.input<typeof ManualModelSchema>;
const form = useForm<ManualModel>({
resolver: zodResolver(ManualModelSchema) as any, // eslint-disable-line @typescript-eslint/no-explicit-any
const form = useForm<ManualModelInput, unknown, ManualModel>({
resolver: zodResolver(ManualModelSchema),
defaultValues: {
name: '',
full_name: '',
+699
View File
@@ -0,0 +1,699 @@
import type { ChangeEvent, Dispatch, SetStateAction } from 'react';
import Image from 'next/image';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { Image as ImageIcon, List, Mic, MicOff, Volume2 } from 'lucide-react';
import type { EndpointType } from '@/components/api-endpoint-types';
interface ApiEndpointFormProps {
selectedEndpoint: EndpointType;
maxTokens: number;
setMaxTokens: Dispatch<SetStateAction<number>>;
temperature: number;
setTemperature: Dispatch<SetStateAction<number>>;
systemMessage: string;
setSystemMessage: Dispatch<SetStateAction<string>>;
userMessage: string;
setUserMessage: Dispatch<SetStateAction<string>>;
visionMaxTokens: number;
setVisionMaxTokens: Dispatch<SetStateAction<number>>;
visionTemperature: number;
setVisionTemperature: Dispatch<SetStateAction<number>>;
visionSystemMessage: string;
setVisionSystemMessage: Dispatch<SetStateAction<string>>;
visionUserMessage: string;
setVisionUserMessage: Dispatch<SetStateAction<string>>;
imageDetail: 'low' | 'high' | 'auto';
setImageDetail: Dispatch<SetStateAction<'low' | 'high' | 'auto'>>;
selectedImage: File | null;
imagePreviewUrl: string | null;
onImageUpload: (event: ChangeEvent<HTMLInputElement>) => void;
onRemoveImage: () => void;
embeddingInput: string;
setEmbeddingInput: Dispatch<SetStateAction<string>>;
encodingFormat: 'float' | 'base64';
setEncodingFormat: Dispatch<SetStateAction<'float' | 'base64'>>;
imageCount: number;
setImageCount: Dispatch<SetStateAction<number>>;
imageSize: '256x256' | '512x512' | '1024x1024' | '1792x1024' | '1024x1792';
setImageSize: Dispatch<
SetStateAction<
'256x256' | '512x512' | '1024x1024' | '1792x1024' | '1024x1792'
>
>;
imageQuality: 'standard' | 'hd';
setImageQuality: Dispatch<SetStateAction<'standard' | 'hd'>>;
imageStyle: 'vivid' | 'natural';
setImageStyle: Dispatch<SetStateAction<'vivid' | 'natural'>>;
imagePrompt: string;
setImagePrompt: Dispatch<SetStateAction<string>>;
speechVoice: 'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer';
setSpeechVoice: Dispatch<
SetStateAction<'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer'>
>;
speechFormat: 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm';
setSpeechFormat: Dispatch<
SetStateAction<'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm'>
>;
speechSpeed: number;
setSpeechSpeed: Dispatch<SetStateAction<number>>;
speechInput: string;
setSpeechInput: Dispatch<SetStateAction<string>>;
audioTranscriptionPrompt: string;
setAudioTranscriptionPrompt: Dispatch<SetStateAction<string>>;
audioTemperature: number;
setAudioTemperature: Dispatch<SetStateAction<number>>;
audioLanguage: string;
setAudioLanguage: Dispatch<SetStateAction<string>>;
audioResponseFormat: 'json' | 'text' | 'srt' | 'verbose_json' | 'vtt';
setAudioResponseFormat: Dispatch<
SetStateAction<'json' | 'text' | 'srt' | 'verbose_json' | 'vtt'>
>;
isRecording: boolean;
onStartRecording: () => void;
onStopRecording: () => void;
onAudioUpload: (event: ChangeEvent<HTMLInputElement>) => void;
recordedAudio: File | null;
recordingUrl: string | null;
onRemoveAudio: () => void;
}
export function ApiEndpointForm({
selectedEndpoint,
maxTokens,
setMaxTokens,
temperature,
setTemperature,
systemMessage,
setSystemMessage,
userMessage,
setUserMessage,
visionMaxTokens,
setVisionMaxTokens,
visionTemperature,
setVisionTemperature,
visionSystemMessage,
setVisionSystemMessage,
visionUserMessage,
setVisionUserMessage,
imageDetail,
setImageDetail,
selectedImage,
imagePreviewUrl,
onImageUpload,
onRemoveImage,
embeddingInput,
setEmbeddingInput,
encodingFormat,
setEncodingFormat,
imageCount,
setImageCount,
imageSize,
setImageSize,
imageQuality,
setImageQuality,
imageStyle,
setImageStyle,
imagePrompt,
setImagePrompt,
speechVoice,
setSpeechVoice,
speechFormat,
setSpeechFormat,
speechSpeed,
setSpeechSpeed,
speechInput,
setSpeechInput,
audioTranscriptionPrompt,
setAudioTranscriptionPrompt,
audioTemperature,
setAudioTemperature,
audioLanguage,
setAudioLanguage,
audioResponseFormat,
setAudioResponseFormat,
isRecording,
onStartRecording,
onStopRecording,
onAudioUpload,
recordedAudio,
recordingUrl,
onRemoveAudio,
}: ApiEndpointFormProps) {
switch (selectedEndpoint) {
case 'chat-completions':
return (
<div className='space-y-4'>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='max-tokens'>Max Tokens</Label>
<Input
id='max-tokens'
type='number'
min={1}
max={4000}
value={maxTokens}
onChange={(event) =>
setMaxTokens(parseInt(event.target.value) || 150)
}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='temperature'>Temperature</Label>
<Input
id='temperature'
type='number'
min={0}
max={2}
step={0.1}
value={temperature}
onChange={(event) =>
setTemperature(parseFloat(event.target.value) || 0.7)
}
/>
</div>
</div>
<div className='space-y-2'>
<Label htmlFor='system-message'>System Message (Optional)</Label>
<Textarea
id='system-message'
placeholder='Enter system message...'
value={systemMessage}
onChange={(event) => setSystemMessage(event.target.value)}
rows={2}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='user-message'>Test Message</Label>
<Textarea
id='user-message'
placeholder='Enter your test message...'
value={userMessage}
onChange={(event) => setUserMessage(event.target.value)}
rows={3}
/>
</div>
</div>
);
case 'vision-chat':
return (
<div className='space-y-4'>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='vision-max-tokens'>Max Tokens</Label>
<Input
id='vision-max-tokens'
type='number'
min={1}
max={4000}
value={visionMaxTokens}
onChange={(event) =>
setVisionMaxTokens(parseInt(event.target.value) || 300)
}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='vision-temperature'>Temperature</Label>
<Input
id='vision-temperature'
type='number'
min={0}
max={2}
step={0.1}
value={visionTemperature}
onChange={(event) =>
setVisionTemperature(parseFloat(event.target.value) || 0.7)
}
/>
</div>
</div>
<div className='space-y-2'>
<Label htmlFor='vision-system-message'>
System Message (Optional)
</Label>
<Textarea
id='vision-system-message'
placeholder='Enter system message...'
value={visionSystemMessage}
onChange={(event) => setVisionSystemMessage(event.target.value)}
rows={2}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='vision-user-message'>Test Message</Label>
<Textarea
id='vision-user-message'
placeholder='Enter your test message...'
value={visionUserMessage}
onChange={(event) => setVisionUserMessage(event.target.value)}
rows={3}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='image-detail'>Image Detail</Label>
<Select
value={imageDetail}
onValueChange={(value: 'low' | 'high' | 'auto') =>
setImageDetail(value)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='low'>Low</SelectItem>
<SelectItem value='high'>High</SelectItem>
<SelectItem value='auto'>Auto</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='image-upload'>Upload Image (Optional)</Label>
<Input
type='file'
accept='image/*'
onChange={onImageUpload}
className='cursor-pointer'
/>
{selectedImage && (
<div className='mt-2 flex flex-wrap items-center gap-2'>
<ImageIcon className='text-muted-foreground h-5 w-5' />
<span className='text-muted-foreground text-sm'>
Selected image: {selectedImage.name}
</span>
<Button
variant='outline'
size='sm'
onClick={onRemoveImage}
className='ml-0 sm:ml-auto'
>
Remove
</Button>
</div>
)}
{imagePreviewUrl && (
<div className='relative mt-2 aspect-square w-32 overflow-hidden rounded-md border'>
<Image
src={imagePreviewUrl}
alt='Image preview'
fill
className='object-cover'
/>
</div>
)}
</div>
</div>
);
case 'embeddings':
return (
<div className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='encoding-format'>Encoding Format</Label>
<Select
value={encodingFormat}
onValueChange={(value: 'float' | 'base64') =>
setEncodingFormat(value)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='float'>Float</SelectItem>
<SelectItem value='base64'>Base64</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='embedding-input'>Text Input</Label>
<Textarea
id='embedding-input'
placeholder='Enter text to generate embeddings for...'
value={embeddingInput}
onChange={(event) => setEmbeddingInput(event.target.value)}
rows={4}
/>
</div>
</div>
);
case 'images':
return (
<div className='space-y-4'>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3'>
<div className='space-y-2'>
<Label htmlFor='image-count'>Number of Images</Label>
<Input
id='image-count'
type='number'
min={1}
max={10}
value={imageCount}
onChange={(event) =>
setImageCount(parseInt(event.target.value) || 1)
}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='image-size'>Size</Label>
<Select
value={imageSize}
onValueChange={(
value:
| '256x256'
| '512x512'
| '1024x1024'
| '1792x1024'
| '1024x1792'
) => setImageSize(value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='256x256'>256x256</SelectItem>
<SelectItem value='512x512'>512x512</SelectItem>
<SelectItem value='1024x1024'>1024x1024</SelectItem>
<SelectItem value='1792x1024'>1792x1024</SelectItem>
<SelectItem value='1024x1792'>1024x1792</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='image-quality'>Quality</Label>
<Select
value={imageQuality}
onValueChange={(value: 'standard' | 'hd') =>
setImageQuality(value)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='standard'>Standard</SelectItem>
<SelectItem value='hd'>HD</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className='space-y-2'>
<Label htmlFor='image-style'>Style</Label>
<Select
value={imageStyle}
onValueChange={(value: 'vivid' | 'natural') =>
setImageStyle(value)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='vivid'>Vivid</SelectItem>
<SelectItem value='natural'>Natural</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='image-prompt'>Image Prompt</Label>
<Textarea
id='image-prompt'
placeholder='Describe the image you want to generate...'
value={imagePrompt}
onChange={(event) => setImagePrompt(event.target.value)}
rows={3}
/>
</div>
</div>
);
case 'audio-speech':
return (
<div className='space-y-4'>
<div className='grid grid-cols-1 gap-4 md:grid-cols-3'>
<div className='space-y-2'>
<Label htmlFor='speech-voice'>Voice</Label>
<Select
value={speechVoice}
onValueChange={(
value:
| 'alloy'
| 'echo'
| 'fable'
| 'onyx'
| 'nova'
| 'shimmer'
) => setSpeechVoice(value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='alloy'>Alloy</SelectItem>
<SelectItem value='echo'>Echo</SelectItem>
<SelectItem value='fable'>Fable</SelectItem>
<SelectItem value='onyx'>Onyx</SelectItem>
<SelectItem value='nova'>Nova</SelectItem>
<SelectItem value='shimmer'>Shimmer</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='speech-format'>Response Format</Label>
<Select
value={speechFormat}
onValueChange={(
value: 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm'
) => setSpeechFormat(value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='mp3'>MP3</SelectItem>
<SelectItem value='opus'>Opus</SelectItem>
<SelectItem value='aac'>AAC</SelectItem>
<SelectItem value='flac'>FLAC</SelectItem>
<SelectItem value='wav'>WAV</SelectItem>
<SelectItem value='pcm'>PCM</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='speech-speed'>Speed</Label>
<Input
id='speech-speed'
type='number'
min={0.25}
max={4.0}
step={0.25}
value={speechSpeed}
onChange={(event) =>
setSpeechSpeed(parseFloat(event.target.value) || 1.0)
}
/>
</div>
</div>
<div className='space-y-2'>
<Label htmlFor='speech-input'>Text to Synthesize</Label>
<Textarea
id='speech-input'
placeholder='Enter text to convert to speech...'
value={speechInput}
onChange={(event) => setSpeechInput(event.target.value)}
rows={4}
/>
</div>
</div>
);
case 'audio-transcription':
return (
<div className='space-y-4'>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='audio-transcription-prompt'>
Prompt (Optional)
</Label>
<Textarea
id='audio-transcription-prompt'
placeholder='Enter a prompt for the transcription...'
value={audioTranscriptionPrompt}
onChange={(event) =>
setAudioTranscriptionPrompt(event.target.value)
}
rows={2}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='audio-transcription-temperature'>
Temperature
</Label>
<Input
id='audio-transcription-temperature'
type='number'
min={0}
max={1}
step={0.1}
value={audioTemperature}
onChange={(event) =>
setAudioTemperature(parseFloat(event.target.value) || 0.0)
}
/>
</div>
</div>
<div className='space-y-2'>
<Label htmlFor='audio-transcription-language'>
Language (Optional)
</Label>
<Input
id='audio-transcription-language'
placeholder='e.g., en-US, fr-FR'
value={audioLanguage}
onChange={(event) => setAudioLanguage(event.target.value)}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='audio-transcription-response-format'>
Response Format
</Label>
<Select
value={audioResponseFormat}
onValueChange={(
value: 'json' | 'text' | 'srt' | 'verbose_json' | 'vtt'
) => setAudioResponseFormat(value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='json'>JSON</SelectItem>
<SelectItem value='text'>Text</SelectItem>
<SelectItem value='srt'>SRT</SelectItem>
<SelectItem value='verbose_json'>Verbose JSON</SelectItem>
<SelectItem value='vtt'>VTT</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-4'>
<div className='space-y-2'>
<Label>Voice Recording</Label>
<div className='flex flex-wrap items-center gap-2'>
<Button
type='button'
variant={isRecording ? 'destructive' : 'default'}
size='sm'
onClick={isRecording ? onStopRecording : onStartRecording}
className='flex items-center gap-2'
>
{isRecording ? (
<>
<MicOff className='h-4 w-4' />
Stop Recording
</>
) : (
<>
<Mic className='h-4 w-4' />
Start Recording
</>
)}
</Button>
{isRecording && (
<Badge variant='destructive' className='gap-1.5'>
<span className='h-2 w-2 animate-pulse rounded-full bg-current' />
Recording...
</Badge>
)}
</div>
</div>
<div className='space-y-2'>
<Label htmlFor='audio-transcription-upload'>
Or Upload Audio File
</Label>
<Input
type='file'
accept='audio/*'
onChange={onAudioUpload}
className='cursor-pointer'
/>
{recordedAudio && (
<div className='mt-2 flex flex-wrap items-center gap-2'>
<Volume2 className='text-muted-foreground h-5 w-5' />
<span className='text-muted-foreground text-sm'>
Selected audio: {recordedAudio.name}
</span>
<Button
variant='outline'
size='sm'
onClick={onRemoveAudio}
className='ml-0 sm:ml-auto'
>
Remove
</Button>
</div>
)}
{recordingUrl && (
<div className='bg-muted mt-2 rounded-md p-4'>
<audio controls src={recordingUrl} className='w-full'>
Your browser does not support the audio element.
</audio>
</div>
)}
</div>
</div>
</div>
);
case 'models':
return (
<div className='space-y-4'>
<Alert>
<List className='h-4 w-4' />
<AlertDescription>
This endpoint lists all available models from the provider. No
additional parameters are required.
</AlertDescription>
</Alert>
</div>
);
default:
return null;
}
}
+306
View File
@@ -0,0 +1,306 @@
import Image from 'next/image';
import { Label } from '@/components/ui/label';
export interface ChatCompletionResponse {
id: string;
object: string;
created: number;
model: string;
choices: {
index: number;
message: {
role: string;
content: string;
};
finish_reason: string;
}[];
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
export interface EmbeddingResponse {
object: string;
data: {
object: string;
index: number;
embedding: number[];
}[];
model: string;
usage: {
prompt_tokens: number;
total_tokens: number;
};
}
export interface ImageGenerationResponse {
created: number;
data: {
url: string;
revised_prompt?: string;
}[];
}
export interface AudioResponse {
type: 'audio';
url: string;
size: number;
}
export interface AudioTranscriptionResponse {
text: string;
}
export interface ModelsListResponse {
object: string;
data: {
id: string;
object?: string;
created?: number;
}[];
}
export type ApiResponse =
| ChatCompletionResponse
| EmbeddingResponse
| ImageGenerationResponse
| AudioResponse
| AudioTranscriptionResponse
| ModelsListResponse;
const isAudioResponse = (response: ApiResponse): response is AudioResponse => {
return 'type' in response && response.type === 'audio';
};
const isChatCompletionResponse = (
response: ApiResponse
): response is ChatCompletionResponse => {
return 'choices' in response;
};
const isEmbeddingResponse = (
response: ApiResponse
): response is EmbeddingResponse => {
return (
'data' in response &&
Array.isArray(response.data) &&
response.data.length > 0 &&
'embedding' in response.data[0]
);
};
const isImageGenerationResponse = (
response: ApiResponse
): response is ImageGenerationResponse => {
return (
'data' in response &&
Array.isArray(response.data) &&
response.data.length > 0 &&
'url' in response.data[0]
);
};
const isModelsListResponse = (
response: ApiResponse
): response is ModelsListResponse => {
return (
'data' in response &&
Array.isArray(response.data) &&
response.data.length > 0 &&
'id' in response.data[0]
);
};
const isAudioTranscriptionResponse = (
response: ApiResponse
): response is AudioTranscriptionResponse => {
return 'text' in response;
};
export function ApiEndpointResponse({
response,
}: {
response: ApiResponse | null;
}) {
if (!response) {
return null;
}
if (isAudioResponse(response)) {
return (
<div className='space-y-4'>
<div className='space-y-2'>
<Label>Generated Audio</Label>
<div className='bg-muted rounded-md p-4'>
<audio controls src={response.url} className='w-full'>
Your browser does not support the audio element.
</audio>
<p className='text-muted-foreground mt-2 text-sm'>
Audio file size: {(response.size / 1024).toFixed(2)} KB
</p>
</div>
</div>
</div>
);
}
if (isAudioTranscriptionResponse(response)) {
return (
<div className='space-y-4'>
<div className='space-y-2'>
<Label>Transcription Result</Label>
<div className='bg-muted rounded-md p-4'>
<p className='text-sm whitespace-pre-wrap'>
{response.text || 'No transcription result available'}
</p>
</div>
</div>
</div>
);
}
if (isChatCompletionResponse(response)) {
return (
<div className='space-y-4'>
<div className='space-y-2'>
<Label>Model Response</Label>
<div className='bg-muted rounded-md p-4'>
<p className='text-sm whitespace-pre-wrap'>
{response.choices?.[0]?.message?.content ||
'No content in response'}
</p>
</div>
</div>
{response.usage && (
<div className='space-y-2'>
<Label>Usage Statistics</Label>
<div className='grid grid-cols-1 gap-2 text-sm sm:grid-cols-3 sm:gap-4'>
<div className='bg-muted rounded p-2 text-center'>
<div className='font-semibold'>
{response.usage.prompt_tokens}
</div>
<div className='text-muted-foreground'>Prompt Tokens</div>
</div>
<div className='bg-muted rounded p-2 text-center'>
<div className='font-semibold'>
{response.usage.completion_tokens}
</div>
<div className='text-muted-foreground'>Completion Tokens</div>
</div>
<div className='bg-muted rounded p-2 text-center'>
<div className='font-semibold'>
{response.usage.total_tokens}
</div>
<div className='text-muted-foreground'>Total Tokens</div>
</div>
</div>
</div>
)}
</div>
);
}
if (isEmbeddingResponse(response)) {
return (
<div className='space-y-4'>
<div className='space-y-2'>
<Label>Embedding Vector</Label>
<div className='bg-muted rounded-md p-4'>
<p className='text-muted-foreground mb-2 text-sm'>
Generated {response.data?.[0]?.embedding?.length || 0} dimensional
embedding vector
</p>
<details className='group'>
<summary className='hover:text-foreground cursor-pointer text-sm'>
Show first 10 values
</summary>
<pre className='mt-2 text-xs'>
{JSON.stringify(
response.data?.[0]?.embedding?.slice(0, 10),
null,
2
)}
...
</pre>
</details>
</div>
</div>
{response.usage && (
<div className='space-y-2'>
<Label>Usage Statistics</Label>
<div className='bg-muted rounded p-2 text-center text-sm'>
<div className='font-semibold'>{response.usage.total_tokens}</div>
<div className='text-muted-foreground'>Total Tokens</div>
</div>
</div>
)}
</div>
);
}
if (isImageGenerationResponse(response)) {
return (
<div className='space-y-4'>
<div className='space-y-2'>
<Label>Generated Images</Label>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
{response.data?.map((image, index: number) => (
<div key={index} className='space-y-2'>
<div className='relative aspect-square w-full'>
<Image
src={image.url}
alt={`Generated image ${index + 1}`}
fill
className='rounded-md border object-cover'
unoptimized={true}
/>
</div>
{image.revised_prompt && (
<p className='text-muted-foreground text-xs'>
Revised prompt: {image.revised_prompt}
</p>
)}
</div>
))}
</div>
</div>
</div>
);
}
if (isModelsListResponse(response)) {
return (
<div className='space-y-4'>
<div className='space-y-2'>
<Label>Available Models</Label>
<div className='max-h-60 overflow-auto'>
<div className='grid gap-2'>
{response.data?.map((model) => (
<div key={model.id} className='bg-muted rounded-md p-3'>
<div className='font-medium'>{model.id}</div>
{model.object && (
<div className='text-muted-foreground text-sm'>
Type: {model.object}
</div>
)}
{model.created && (
<div className='text-muted-foreground text-sm'>
Created:{' '}
{new Date(model.created * 1000).toLocaleDateString()}
</div>
)}
</div>
))}
</div>
</div>
</div>
</div>
);
}
return null;
}
+726
View File
@@ -0,0 +1,726 @@
'use client';
import React, { useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { type Model } from '@/lib/api/schemas/models';
import { ModelService } from '@/lib/api/services/models';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Loader2,
Send,
CheckCircle,
XCircle,
Info,
Key,
Globe,
} from 'lucide-react';
import { toast } from 'sonner';
import {
ApiEndpointResponse,
type ApiResponse,
} from '@/components/api-endpoint-response';
import { ApiEndpointForm } from '@/components/api-endpoint-form';
import {
API_ENDPOINTS,
DEFAULT_REQUESTS,
type EndpointType,
type ChatCompletionRequest,
type EmbeddingRequest,
type ImageGenerationRequest,
type AudioSpeechRequest,
type AudioTranscriptionRequest,
type EndpointRequestData,
} from '@/components/api-endpoint-types';
interface ApiEndpointTesterProps {
models: Model[];
}
export function ApiEndpointTester({ models }: ApiEndpointTesterProps) {
const [selectedModelId, setSelectedModelId] = useState<string>('');
const [selectedEndpoint, setSelectedEndpoint] =
useState<EndpointType>('chat-completions');
const [response, setResponse] = useState<ApiResponse | null>(null);
const [error, setError] = useState<string | null>(null);
// Chat Completions state
const [systemMessage, setSystemMessage] = useState(
DEFAULT_REQUESTS['chat-completions'].systemMessage
);
const [userMessage, setUserMessage] = useState(
DEFAULT_REQUESTS['chat-completions'].userMessage
);
const [maxTokens, setMaxTokens] = useState(
DEFAULT_REQUESTS['chat-completions'].maxTokens
);
const [temperature, setTemperature] = useState(
DEFAULT_REQUESTS['chat-completions'].temperature
);
// Vision Chat state
const [visionSystemMessage, setVisionSystemMessage] = useState(
DEFAULT_REQUESTS['vision-chat'].systemMessage
);
const [visionUserMessage, setVisionUserMessage] = useState(
DEFAULT_REQUESTS['vision-chat'].userMessage
);
const [visionMaxTokens, setVisionMaxTokens] = useState(
DEFAULT_REQUESTS['vision-chat'].maxTokens
);
const [visionTemperature, setVisionTemperature] = useState(
DEFAULT_REQUESTS['vision-chat'].temperature
);
const [imageDetail, setImageDetail] = useState<'low' | 'high' | 'auto'>(
DEFAULT_REQUESTS['vision-chat'].imageDetail
);
const [selectedImage, setSelectedImage] = useState<File | null>(null);
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
// Voice Recording state
const [isRecording, setIsRecording] = useState(false);
const [recordedAudio, setRecordedAudio] = useState<File | null>(null);
const [recordingUrl, setRecordingUrl] = useState<string | null>(null);
const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(
null
);
const [audioTranscriptionPrompt, setAudioTranscriptionPrompt] = useState(
DEFAULT_REQUESTS['audio-transcription'].prompt
);
const [audioResponseFormat, setAudioResponseFormat] = useState<
'json' | 'text' | 'srt' | 'verbose_json' | 'vtt'
>(DEFAULT_REQUESTS['audio-transcription'].response_format);
const [audioTemperature, setAudioTemperature] = useState(
DEFAULT_REQUESTS['audio-transcription'].temperature
);
const [audioLanguage, setAudioLanguage] = useState(
DEFAULT_REQUESTS['audio-transcription'].language
);
// Embeddings state
const [embeddingInput, setEmbeddingInput] = useState(
DEFAULT_REQUESTS.embeddings.input
);
const [encodingFormat, setEncodingFormat] = useState<'float' | 'base64'>(
DEFAULT_REQUESTS.embeddings.encoding_format
);
// Image Generation state
const [imagePrompt, setImagePrompt] = useState(
DEFAULT_REQUESTS.images.prompt
);
const [imageCount, setImageCount] = useState(DEFAULT_REQUESTS.images.n);
const [imageSize, setImageSize] = useState<
'256x256' | '512x512' | '1024x1024' | '1792x1024' | '1024x1792'
>(DEFAULT_REQUESTS.images.size);
const [imageQuality, setImageQuality] = useState<'standard' | 'hd'>(
DEFAULT_REQUESTS.images.quality
);
const [imageStyle, setImageStyle] = useState<'vivid' | 'natural'>(
DEFAULT_REQUESTS.images.style
);
// Audio Speech state
const [speechInput, setSpeechInput] = useState(
DEFAULT_REQUESTS['audio-speech'].input
);
const [speechVoice, setSpeechVoice] = useState<
'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer'
>(DEFAULT_REQUESTS['audio-speech'].voice);
const [speechFormat, setSpeechFormat] = useState<
'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm'
>(DEFAULT_REQUESTS['audio-speech'].response_format);
const [speechSpeed, setSpeechSpeed] = useState(
DEFAULT_REQUESTS['audio-speech'].speed
);
// Fetch model groups for API key resolution
const { data: groups = [] } = useQuery({
queryKey: ['model-groups'],
queryFn: () => ModelService.getModelGroups(),
refetchOnWindowFocus: false,
});
const selectedModel = models.find((model) => model.id === selectedModelId);
// Image upload handler
const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
if (file.type.startsWith('image/')) {
setSelectedImage(file);
const previewUrl = URL.createObjectURL(file);
setImagePreviewUrl(previewUrl);
} else {
toast.error('Please select a valid image file');
}
}
};
// Audio file upload handler
const handleAudioUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
if (file.type.startsWith('audio/')) {
setRecordedAudio(file);
const audioUrl = URL.createObjectURL(file);
setRecordingUrl(audioUrl);
} else {
toast.error('Please select a valid audio file');
}
}
};
// Voice recording functions
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream);
const chunks: BlobPart[] = [];
recorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunks.push(event.data);
}
};
recorder.onstop = () => {
const blob = new Blob(chunks, { type: 'audio/webm' });
const file = new File([blob], 'recording.webm', { type: 'audio/webm' });
setRecordedAudio(file);
const audioUrl = URL.createObjectURL(blob);
setRecordingUrl(audioUrl);
// Stop all tracks to release microphone
stream.getTracks().forEach((track) => track.stop());
};
recorder.start();
setMediaRecorder(recorder);
setIsRecording(true);
toast.success('Recording started');
} catch {
toast.error(
'Failed to start recording. Please check microphone permissions.'
);
}
};
const stopRecording = () => {
if (mediaRecorder) {
mediaRecorder.stop();
setMediaRecorder(null);
setIsRecording(false);
toast.success('Recording stopped');
}
};
// Convert file to base64 for vision API
const fileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result as string);
reader.onerror = (error) => reject(error);
});
};
// Get effective API key and endpoint URL for the selected model
const getModelCredentials = (model: Model) => {
const group = groups.find((g) => g.provider === model.provider);
// Determine API key (individual takes precedence over group)
const apiKey = model.api_key || group?.group_api_key;
// Determine base endpoint URL
let baseUrl = model.url;
// If model URL is relative and group has a base URL, combine them
if (model.url.startsWith('/') && group?.group_url) {
baseUrl = `${group.group_url.replace(/\/$/, '')}${model.url}`;
}
// Remove any existing endpoint path to get base URL
baseUrl = baseUrl.replace(/\/v1\/.*$/, '').replace(/\/$/, '');
return {
apiKey,
baseUrl,
group,
};
};
const buildEndpointUrl = (baseUrl: string, endpointPath: string) => {
return `${baseUrl}${endpointPath}`;
};
const buildRequest = async (): Promise<EndpointRequestData> => {
if (!selectedModel) return null;
switch (selectedEndpoint) {
case 'chat-completions':
const messages = [];
if (systemMessage.trim()) {
messages.push({
role: 'system' as const,
content: systemMessage.trim(),
});
}
messages.push({ role: 'user' as const, content: userMessage.trim() });
return {
model: selectedModel.name,
messages,
max_tokens: maxTokens,
temperature: temperature,
} as ChatCompletionRequest;
case 'vision-chat':
if (!selectedImage) {
throw new Error('Please select an image for vision analysis');
}
const imageBase64 = await fileToBase64(selectedImage);
const visionMessages = [];
if (visionSystemMessage.trim()) {
visionMessages.push({
role: 'system' as const,
content: visionSystemMessage.trim(),
});
}
visionMessages.push({
role: 'user' as const,
content: [
{
type: 'text' as const,
text: visionUserMessage.trim(),
},
{
type: 'image_url' as const,
image_url: {
url: imageBase64,
detail: imageDetail,
},
},
],
});
return {
model: selectedModel.name,
messages: visionMessages,
max_tokens: visionMaxTokens,
temperature: visionTemperature,
} as ChatCompletionRequest;
case 'embeddings':
return {
model: selectedModel.name,
input: embeddingInput,
encoding_format: encodingFormat,
} as EmbeddingRequest;
case 'images':
return {
model: selectedModel.name,
prompt: imagePrompt,
n: imageCount,
size: imageSize,
quality: imageQuality,
style: imageStyle,
} as ImageGenerationRequest;
case 'audio-speech':
return {
model: selectedModel.name,
input: speechInput,
voice: speechVoice,
response_format: speechFormat,
speed: speechSpeed,
} as AudioSpeechRequest;
case 'audio-transcription':
if (!recordedAudio) {
throw new Error(
'Please record or upload an audio file for transcription'
);
}
return {
model: selectedModel.name,
file: recordedAudio,
prompt: audioTranscriptionPrompt.trim() || undefined,
response_format: audioResponseFormat,
temperature: audioTemperature,
language: audioLanguage.trim() || undefined,
} as AudioTranscriptionRequest;
case 'models':
return null; // No request body needed for models endpoint
default:
return null;
}
};
const testEndpointMutation = useMutation({
mutationFn: async (requestData: EndpointRequestData) => {
if (!selectedModel) {
throw new Error('No model selected');
}
setError(null);
setResponse(null);
try {
const response = await ModelService.testModel(
selectedModel.id,
selectedEndpoint,
requestData
);
if (!response.success) {
throw new Error(response.error || 'Test failed');
}
return response.data as ApiResponse;
} catch (err: unknown) {
const errorMessage =
err instanceof Error
? err.message
: 'Failed to test endpoint via proxy';
throw new Error(errorMessage);
}
},
onSuccess: (data) => {
setResponse(data);
toast.success(
`${API_ENDPOINTS[selectedEndpoint].name} test completed successfully!`
);
},
onError: (err: Error) => {
const errorMessage = err?.message || 'Unknown error occurred';
setError(errorMessage);
toast.error(
`${API_ENDPOINTS[selectedEndpoint].name} test failed: ${errorMessage}`
);
},
});
const handleTest = async () => {
if (!selectedModel) {
toast.error('Please select a model to test');
return;
}
// Validate required fields based on endpoint
if (selectedEndpoint === 'chat-completions' && !userMessage.trim()) {
toast.error('Please enter a test message');
return;
}
if (selectedEndpoint === 'vision-chat') {
if (!visionUserMessage.trim()) {
toast.error('Please enter a test message');
return;
}
if (!selectedImage) {
toast.error('Please select an image for vision analysis');
return;
}
}
if (selectedEndpoint === 'embeddings' && !embeddingInput.trim()) {
toast.error('Please enter text for embedding');
return;
}
if (selectedEndpoint === 'images' && !imagePrompt.trim()) {
toast.error('Please enter an image prompt');
return;
}
if (selectedEndpoint === 'audio-speech' && !speechInput.trim()) {
toast.error('Please enter text for speech synthesis');
return;
}
if (selectedEndpoint === 'audio-transcription' && !recordedAudio) {
toast.error('Please record or upload an audio file for transcription');
return;
}
const requestData = await buildRequest();
testEndpointMutation.mutate(requestData);
};
const enabledModels = models.filter((model) => model.isEnabled);
const credentials = selectedModel ? getModelCredentials(selectedModel) : null;
const endpointUrl = credentials
? buildEndpointUrl(
credentials.baseUrl,
API_ENDPOINTS[selectedEndpoint].path
)
: '';
return (
<Card className='w-full'>
<CardHeader>
<CardTitle>API Endpoint Tester</CardTitle>
<CardDescription>
Comprehensive testing of OpenAI-compatible API endpoints through the
secure proxy (resolves CORS and network issues)
</CardDescription>
</CardHeader>
<CardContent className='space-y-6'>
{/* Model and Endpoint Selection */}
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='model-select'>Select Model</Label>
<Select value={selectedModelId} onValueChange={setSelectedModelId}>
<SelectTrigger id='model-select'>
<SelectValue placeholder='Choose a model to test...' />
</SelectTrigger>
<SelectContent>
{enabledModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
<div className='flex items-center gap-2'>
<span>{model.name}</span>
<Badge variant='outline' className='text-xs'>
{model.provider}
</Badge>
{model.is_free && (
<Badge variant='secondary' className='text-xs'>
Free
</Badge>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='endpoint-select'>API Endpoint</Label>
<Select
value={selectedEndpoint}
onValueChange={(value: EndpointType) =>
setSelectedEndpoint(value)
}
>
<SelectTrigger id='endpoint-select'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(API_ENDPOINTS).map(([key, endpoint]) => {
const Icon = endpoint.icon;
return (
<SelectItem key={key} value={key}>
<div className='flex items-center gap-2'>
<Icon className='h-4 w-4' />
<span>{endpoint.name}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
</div>
{/* Model Information */}
{selectedModel && credentials && (
<div className='text-muted-foreground bg-muted space-y-2 rounded-md p-3 text-sm'>
<div className='flex items-center gap-2'>
<Globe className='h-4 w-4' />
<span className='break-all'>
<strong>Endpoint:</strong> {endpointUrl}
</span>
</div>
<div className='flex items-center gap-2'>
<Key className='h-4 w-4' />
<span>
<strong>API Key:</strong>{' '}
{credentials.apiKey
? `${credentials.apiKey.substring(0, 8)}...`
: 'Not configured'}
</span>
<Badge
variant={credentials.apiKey ? 'default' : 'destructive'}
className='text-xs'
>
{selectedModel.api_key_type || 'Unknown'}
</Badge>
</div>
<div>
<span>
<strong>Provider:</strong> {selectedModel.provider}
</span>
</div>
<div>
<span>
<strong>Description:</strong>{' '}
{API_ENDPOINTS[selectedEndpoint].description}
</span>
</div>
{!credentials.apiKey && (
<Alert variant='default' className='mt-2'>
<Info className='h-4 w-4' />
<AlertDescription>
No API key configured for this model. Testing may still work
if the model is free or if authentication is handled
elsewhere. For models requiring authentication, please add an
API key to the model or its provider group.
</AlertDescription>
</Alert>
)}
</div>
)}
<ApiEndpointForm
selectedEndpoint={selectedEndpoint}
maxTokens={maxTokens}
setMaxTokens={setMaxTokens}
temperature={temperature}
setTemperature={setTemperature}
systemMessage={systemMessage}
setSystemMessage={setSystemMessage}
userMessage={userMessage}
setUserMessage={setUserMessage}
visionMaxTokens={visionMaxTokens}
setVisionMaxTokens={setVisionMaxTokens}
visionTemperature={visionTemperature}
setVisionTemperature={setVisionTemperature}
visionSystemMessage={visionSystemMessage}
setVisionSystemMessage={setVisionSystemMessage}
visionUserMessage={visionUserMessage}
setVisionUserMessage={setVisionUserMessage}
imageDetail={imageDetail}
setImageDetail={setImageDetail}
selectedImage={selectedImage}
imagePreviewUrl={imagePreviewUrl}
onImageUpload={handleImageUpload}
onRemoveImage={() => {
setSelectedImage(null);
setImagePreviewUrl(null);
}}
embeddingInput={embeddingInput}
setEmbeddingInput={setEmbeddingInput}
encodingFormat={encodingFormat}
setEncodingFormat={setEncodingFormat}
imageCount={imageCount}
setImageCount={setImageCount}
imageSize={imageSize}
setImageSize={setImageSize}
imageQuality={imageQuality}
setImageQuality={setImageQuality}
imageStyle={imageStyle}
setImageStyle={setImageStyle}
imagePrompt={imagePrompt}
setImagePrompt={setImagePrompt}
speechVoice={speechVoice}
setSpeechVoice={setSpeechVoice}
speechFormat={speechFormat}
setSpeechFormat={setSpeechFormat}
speechSpeed={speechSpeed}
setSpeechSpeed={setSpeechSpeed}
speechInput={speechInput}
setSpeechInput={setSpeechInput}
audioTranscriptionPrompt={audioTranscriptionPrompt}
setAudioTranscriptionPrompt={setAudioTranscriptionPrompt}
audioTemperature={audioTemperature}
setAudioTemperature={setAudioTemperature}
audioLanguage={audioLanguage}
setAudioLanguage={setAudioLanguage}
audioResponseFormat={audioResponseFormat}
setAudioResponseFormat={setAudioResponseFormat}
isRecording={isRecording}
onStartRecording={startRecording}
onStopRecording={stopRecording}
onAudioUpload={handleAudioUpload}
recordedAudio={recordedAudio}
recordingUrl={recordingUrl}
onRemoveAudio={() => {
setRecordedAudio(null);
setRecordingUrl(null);
}}
/>
{/* Test Button */}
<Button
onClick={handleTest}
disabled={!selectedModelId || testEndpointMutation.isPending}
className='w-full'
>
{testEndpointMutation.isPending ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Testing {API_ENDPOINTS[selectedEndpoint].name}...
</>
) : (
<>
<Send className='mr-2 h-4 w-4' />
Test {API_ENDPOINTS[selectedEndpoint].name}
</>
)}
</Button>
{/* Results */}
{error && (
<Alert variant='destructive'>
<XCircle className='h-4 w-4' />
<AlertDescription>
<strong>Test Failed:</strong> {error}
</AlertDescription>
</Alert>
)}
{response && (
<Alert>
<CheckCircle className='h-4 w-4' />
<AlertDescription>
<strong>Test Successful!</strong>{' '}
{API_ENDPOINTS[selectedEndpoint].name} endpoint responded
correctly.
</AlertDescription>
</Alert>
)}
<ApiEndpointResponse response={response} />
{response && (
<div className='space-y-2'>
<Label>Raw Response</Label>
<details className='group'>
<summary className='text-muted-foreground hover:text-foreground cursor-pointer text-sm'>
<Info className='mr-1 inline h-4 w-4' />
Show detailed response data
</summary>
<pre className='bg-muted mt-2 max-h-60 overflow-auto rounded-md p-4 text-xs'>
{JSON.stringify(response, null, 2)}
</pre>
</details>
</div>
)}
</CardContent>
</Card>
);
}
+156
View File
@@ -0,0 +1,156 @@
import {
FileText,
Eye,
List,
Image as ImageIcon,
Mic,
Volume2,
} from 'lucide-react';
export const API_ENDPOINTS = {
'chat-completions': {
name: 'Chat Completions',
path: '/chat/completions',
icon: FileText,
description: 'Test conversational AI with chat completion requests',
},
'vision-chat': {
name: 'Vision Chat (Image + Text)',
path: '/chat/completions',
icon: Eye,
description: 'Analyze images with text prompts using vision models',
},
embeddings: {
name: 'Embeddings',
path: '/embeddings',
icon: List,
description: 'Generate embeddings for text input',
},
images: {
name: 'Image Generation',
path: '/images/generations',
icon: ImageIcon,
description: 'Generate images from text prompts',
},
'audio-speech': {
name: 'Text-to-Speech',
path: '/audio/speech',
icon: Mic,
description: 'Convert text to speech audio',
},
'audio-transcription': {
name: 'Audio Transcription',
path: '/audio/transcriptions',
icon: Volume2,
description: 'Transcribe audio files to text',
},
models: {
name: 'List Models',
path: '/models',
icon: List,
description: 'List all available models from the provider',
},
} as const;
export type EndpointType = keyof typeof API_ENDPOINTS;
export interface ChatCompletionRequest {
model: string;
messages: {
role: 'system' | 'user' | 'assistant';
content:
| string
| Array<{
type: 'text' | 'image_url';
text?: string;
image_url?: {
url: string;
detail?: 'low' | 'high' | 'auto';
};
}>;
}[];
max_tokens?: number;
temperature?: number;
}
export interface EmbeddingRequest {
model: string;
input: string | string[];
encoding_format?: 'float' | 'base64';
}
export interface ImageGenerationRequest {
model?: string;
prompt: string;
n?: number;
size?: '256x256' | '512x512' | '1024x1024' | '1792x1024' | '1024x1792';
quality?: 'standard' | 'hd';
style?: 'vivid' | 'natural';
}
export interface AudioSpeechRequest {
model: string;
input: string;
voice: 'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer';
response_format?: 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm';
speed?: number;
}
export interface AudioTranscriptionRequest {
model: string;
file: File;
prompt?: string;
response_format?: 'json' | 'text' | 'srt' | 'verbose_json' | 'vtt';
temperature?: number;
language?: string;
}
export const DEFAULT_REQUESTS = {
'chat-completions': {
systemMessage: 'You are a helpful assistant. Please respond concisely.',
userMessage:
'Hello! Can you tell me what model you are and confirm that you are working correctly?',
maxTokens: 150,
temperature: 0.7,
},
'vision-chat': {
systemMessage:
'You are a helpful assistant that can analyze images. Please describe what you see.',
userMessage:
'What do you see in this image? Please provide a detailed description.',
maxTokens: 300,
temperature: 0.7,
imageDetail: 'auto' as const,
},
embeddings: {
input: 'The quick brown fox jumps over the lazy dog.',
encoding_format: 'float' as const,
},
images: {
prompt: 'A beautiful sunset over a mountain landscape',
n: 1,
size: '1024x1024' as const,
quality: 'standard' as const,
style: 'vivid' as const,
},
'audio-speech': {
input: 'Hello, this is a test of the text-to-speech functionality.',
voice: 'alloy' as const,
response_format: 'mp3' as const,
speed: 1.0,
},
'audio-transcription': {
prompt: 'This is a test transcription.',
response_format: 'json' as const,
temperature: 0.0,
language: '',
},
};
export type EndpointRequestData =
| ChatCompletionRequest
| EmbeddingRequest
| ImageGenerationRequest
| AudioSpeechRequest
| AudioTranscriptionRequest
| null;
+351
View File
@@ -0,0 +1,351 @@
'use client';
import { useState, type ReactNode } from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import {
DatabaseIcon,
FileTextIcon,
LayoutDashboardIcon,
LogOutIcon,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
ServerIcon,
SettingsIcon,
WalletIcon,
} from 'lucide-react';
import Image from 'next/image';
import { toast } from 'sonner';
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 {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetTitle,
} from '@/components/ui/sheet';
import { cn } from '@/lib/utils';
interface AppPageShellProps {
children: ReactNode;
className?: string;
contentClassName?: string;
}
const NAV_ITEMS = [
{ title: 'Dashboard', url: '/', icon: LayoutDashboardIcon },
{ title: 'Balances', url: '/balances', icon: WalletIcon },
{ title: 'Logs', url: '/logs', icon: FileTextIcon },
{ title: 'Models', url: '/models', icon: DatabaseIcon },
{ title: 'Providers', url: '/providers', icon: ServerIcon },
{ title: 'Settings', url: '/settings', icon: SettingsIcon },
] as const;
function isActivePath(pathname: string, itemUrl: string): boolean {
if (itemUrl === '/') {
return pathname === '/';
}
return pathname === itemUrl || pathname.startsWith(`${itemUrl}/`);
}
export function AppPageShell({
children,
className,
contentClassName,
}: AppPageShellProps) {
const pathname = usePathname();
const router = useRouter();
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const handleLogout = async (): Promise<void> => {
try {
await adminLogout();
toast.success('Logged out successfully');
router.push('/login');
} catch (error) {
console.error('Logout error:', error);
toast.error('Failed to logout');
}
};
return (
<div className='bg-background text-foreground min-h-dvh overflow-x-clip md:h-screen md:overflow-hidden'>
<div className='flex min-h-dvh w-full min-w-0 overflow-x-clip md:h-full'>
<aside
className={cn(
'border-border/60 hidden shrink-0 border-r py-5 transition-[width,padding] duration-300 ease-in-out md:flex md:h-full md:flex-col md:overflow-y-auto',
isSidebarCollapsed ? 'w-16 px-2' : 'w-60 px-4'
)}
>
<div
className={cn(
'px-1 transition-[padding] duration-300 ease-in-out',
isSidebarCollapsed && 'px-0'
)}
>
<div className='flex items-center gap-2'>
<div className='flex min-w-0 flex-1 items-center gap-2 overflow-hidden'>
<Image
src='/icon.ico'
alt='Routstr Node'
width={24}
height={24}
className='shrink-0 rounded-sm'
/>
<div
className={cn(
'min-w-0 overflow-hidden transition-[max-width,opacity,transform] duration-300 ease-in-out',
isSidebarCollapsed
? 'max-w-0 -translate-x-1 opacity-0'
: 'max-w-[11rem] translate-x-0 opacity-100'
)}
>
<h1 className='truncate text-lg font-semibold tracking-tight whitespace-nowrap'>
Routstr Node
</h1>
</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>
<nav
className={cn(
'mt-5',
isSidebarCollapsed
? 'flex flex-col items-center space-y-2'
: 'space-y-1.5'
)}
>
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const active = isActivePath(pathname, item.url);
return (
<Button
key={item.url}
asChild
variant={active ? 'outline' : 'ghost'}
className={cn(
'h-10 rounded-lg transition-[width,padding] duration-300 ease-in-out',
isSidebarCollapsed
? 'mx-auto w-10 justify-center px-0'
: 'w-full justify-start'
)}
>
<Link href={item.url}>
<Icon className='h-4 w-4 shrink-0' />
<span
className={cn(
'overflow-hidden whitespace-nowrap transition-[max-width,opacity,margin] duration-300 ease-in-out',
isSidebarCollapsed
? 'ml-0 max-w-0 opacity-0'
: 'ml-0.5 max-w-[9rem] opacity-100'
)}
>
{item.title}
</span>
</Link>
</Button>
);
})}
</nav>
<div
className={cn(
'mt-auto pt-3 transition-[padding] duration-300 ease-in-out',
isSidebarCollapsed
? 'flex flex-col items-center space-y-1.5'
: 'space-y-2'
)}
>
{isSidebarCollapsed ? (
<>
<CurrencyToggle
compact
menuSide='right'
menuAlign='start'
className='mx-auto'
/>
<ThemeToggle
compact
menuSide='right'
menuAlign='start'
className='mx-auto'
/>
<Button
variant='ghost'
size='sm'
onClick={handleLogout}
className='text-muted-foreground hover:text-foreground mx-auto h-8 w-10 justify-center px-0'
>
<LogOutIcon className='h-4 w-4' />
<span className='sr-only'>Logout</span>
</Button>
</>
) : (
<div className='border-border/60 bg-card/30 space-y-1 rounded-lg border p-1'>
<CurrencyToggle
menuSide='right'
menuAlign='start'
className='text-foreground/90 hover:bg-accent/35 border-border/60 bg-background/25 h-8 w-full justify-between rounded-md px-2.5 text-[11px]'
/>
<ThemeToggle
menuSide='right'
menuAlign='start'
className='text-foreground/90 hover:bg-accent/35 border-border/60 bg-background/25 h-8 w-full justify-between rounded-md px-2.5 text-[11px]'
/>
<Button
variant='ghost'
size='sm'
onClick={handleLogout}
className='text-muted-foreground hover:bg-destructive/10 hover:text-destructive h-8 w-full justify-start gap-1.5 rounded-md px-2.5 text-[11px]'
>
<LogOutIcon className='h-4 w-4' />
Logout
</Button>
</div>
)}
</div>
</aside>
<section className='relative flex w-full min-w-0 flex-1 flex-col overflow-x-clip md:h-full md:min-h-0'>
<div className='bg-background/80 supports-[backdrop-filter]:bg-background/72 sticky top-0 z-30 flex items-center gap-2 px-3 py-2 backdrop-blur-xl md:hidden'>
<Button
type='button'
variant='outline'
size='sm'
className='h-9 gap-2 rounded-lg px-3'
onClick={() => setIsMobileSidebarOpen(true)}
>
<PanelLeftOpenIcon className='h-4 w-4' />
Menu
</Button>
</div>
<main
className={cn(
'w-full min-w-0 flex-1 overflow-x-clip p-3 pb-4 sm:p-4 md:min-h-0 md:overflow-y-auto md:p-6 md:pb-6',
contentClassName,
className
)}
>
{children}
</main>
</section>
</div>
<Sheet open={isMobileSidebarOpen} onOpenChange={setIsMobileSidebarOpen}>
<SheetContent
side='left'
showCloseButton={false}
className='w-[min(88vw,18rem)] p-0 md:hidden'
>
<SheetTitle className='sr-only'>Navigation sidebar</SheetTitle>
<SheetDescription className='sr-only'>
Browse admin pages and access sidebar controls.
</SheetDescription>
<div className='flex h-full min-h-0 flex-col'>
<div className='border-border/60 px-4 pt-4 pb-3'>
<div className='flex items-center justify-between gap-3'>
<div className='flex min-w-0 items-center gap-2'>
<Image
src='/icon.ico'
alt='Routstr Node'
width={24}
height={24}
className='rounded-sm'
/>
<p className='truncate text-base font-medium tracking-tight'>
Routstr Node
</p>
</div>
<SheetClose asChild>
<Button
variant='ghost'
size='icon'
className='text-muted-foreground hover:text-foreground h-8 w-8 shrink-0'
>
<PanelLeftCloseIcon className='h-4 w-4' />
<span className='sr-only'>Close sidebar</span>
</Button>
</SheetClose>
</div>
</div>
<div className='flex h-full min-h-0 flex-col px-3 pb-3'>
<nav className='space-y-1.5 py-3'>
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const active = isActivePath(pathname, item.url);
return (
<Button
key={`mobile-sidebar-${item.url}`}
asChild
variant={active ? 'outline' : 'ghost'}
className='h-10 w-full justify-start rounded-lg'
>
<Link
href={item.url}
onClick={() => setIsMobileSidebarOpen(false)}
>
<Icon className='h-4 w-4' />
{item.title}
</Link>
</Button>
);
})}
</nav>
<div className='border-border/60 bg-card/30 mt-auto space-y-1 rounded-lg border p-1'>
<CurrencyToggle
menuSide='right'
menuAlign='start'
className='text-foreground/90 hover:bg-accent/35 border-border/60 bg-background/25 h-9 w-full justify-between rounded-md px-2.5 text-[11px]'
/>
<ThemeToggle
menuSide='right'
menuAlign='start'
className='text-foreground/90 hover:bg-accent/35 border-border/60 bg-background/25 h-9 w-full justify-between rounded-md px-2.5 text-[11px]'
/>
<Button
variant='ghost'
size='sm'
onClick={async () => {
setIsMobileSidebarOpen(false);
await handleLogout();
}}
className='text-muted-foreground hover:bg-destructive/10 hover:text-destructive h-9 w-full justify-start gap-1.5 rounded-md px-2.5 text-[11px]'
>
<LogOutIcon className='h-4 w-4' />
Logout
</Button>
</div>
</div>
</div>
</SheetContent>
</Sheet>
</div>
);
}
+55 -19
View File
@@ -2,6 +2,7 @@
import * as React from 'react';
import {
ExternalLinkIcon,
FileTextIcon,
DatabaseIcon,
LayoutDashboardIcon,
@@ -10,15 +11,18 @@ import {
WalletIcon,
} from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { NavSecondary } from '@/components/nav-secondary';
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
const data = {
@@ -48,7 +52,7 @@ const data = {
},
{
title: 'Models',
url: '/model',
url: '/models',
icon: DatabaseIcon,
},
{
@@ -68,29 +72,61 @@ const data = {
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
return (
<Sidebar collapsible='offcanvas' {...props}>
<SidebarHeader>
<SidebarHeader className='px-3 pt-4 pb-3'>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
asChild
className='data-[slot=sidebar-menu-button]:!p-1.5'
>
<div className='flex items-center gap-2'>
<Image
src='/icon.ico'
alt='Routstr Node'
width={24}
height={24}
className='rounded'
/>
<span className='text-base font-semibold'>Routstr Node</span>
<div className='flex items-center gap-2 px-2 py-1'>
<Image
src='/icon.ico'
alt='Routstr Node'
width={24}
height={24}
className='rounded'
/>
<div className='space-y-0.5'>
<p className='text-sm font-semibold tracking-tight'>
Routstr Node
</p>
<p className='text-muted-foreground text-[11px]'>
Admin dashboard
</p>
</div>
</SidebarMenuButton>
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent className='flex-1 overflow-y-auto'>
<NavSecondary items={data.navSecondary} className='mt-auto' />
<SidebarContent className='flex-1 overflow-y-auto px-2 pb-2'>
<NavSecondary items={data.navSecondary} />
<SidebarGroup className='mt-auto px-0 pt-2 pb-0'>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild className='h-10 rounded-lg px-3'>
<Link
href='https://docs.routstr.com'
target='_blank'
rel='noreferrer'
>
<span>Docs</span>
<ExternalLinkIcon className='ml-auto h-3.5 w-3.5' />
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild className='h-10 rounded-lg px-3'>
<Link
href='https://chat.routstr.com'
target='_blank'
rel='noreferrer'
>
<span>Chat App</span>
<ExternalLinkIcon className='ml-auto h-3.5 w-3.5' />
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
{/*
<SidebarFooter>
+38
View File
@@ -0,0 +1,38 @@
'use client';
import type { ReactNode } from 'react';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
interface AuthPageShellProps {
title: string;
description: string;
children: ReactNode;
}
export function AuthPageShell({
title,
description,
children,
}: AuthPageShellProps) {
return (
<div className='bg-background text-foreground flex min-h-dvh items-center justify-center px-4 py-10 sm:py-12'>
<Card className='w-full max-w-md'>
<CardHeader className='space-y-1 pb-4'>
<CardTitle className='text-center text-2xl font-bold'>
{title}
</CardTitle>
<CardDescription className='text-center'>
{description}
</CardDescription>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
</div>
);
}
@@ -119,7 +119,7 @@ export function BatchOverrideDialog({
value={jsonInput}
onChange={(e) => setJsonInput(e.target.value)}
placeholder={JSON.stringify(sampleJson, null, 2)}
className='min-h-[400px] font-mono text-xs'
className='min-h-[260px] font-mono text-xs sm:min-h-[400px]'
/>
</div>
+2 -2
View File
@@ -125,11 +125,11 @@ const chartConfig = {
},
desktop: {
label: 'Desktop',
color: 'hsl(var(--chart-1))',
color: 'var(--chart-1)',
},
mobile: {
label: 'Mobile',
color: 'hsl(var(--chart-2))',
color: 'var(--chart-2)',
},
} satisfies ChartConfig;
+20 -20
View File
@@ -12,6 +12,8 @@ import {
} from '@/components/ui/card';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Key,
Copy,
@@ -224,7 +226,7 @@ export function ChildKeyCreator({
</div>
{costPerKeyMsats !== undefined && (
<div className='text-right'>
<p className='text-muted-foreground text-[0.65rem] tracking-wide uppercase'>
<p className='text-muted-foreground text-[0.65rem] tracking-wide'>
Unit Cost
</p>
<p className='text-primary text-sm font-bold'>
@@ -238,9 +240,9 @@ export function ChildKeyCreator({
<div className='space-y-4'>
{baseUrl && (
<div className='space-y-2'>
<label className='text-muted-foreground text-[0.7rem] tracking-wider uppercase'>
<Label className='text-muted-foreground text-[0.7rem] tracking-wider'>
Parent API Key
</label>
</Label>
<Input
value={activeApiKey}
onChange={(e) => handleApiKeyChange(e.target.value)}
@@ -268,9 +270,9 @@ export function ChildKeyCreator({
)}
<div className='flex flex-col gap-4 sm:flex-row sm:items-end'>
<div className='w-full space-y-2 sm:w-32'>
<label className='text-muted-foreground text-[0.7rem] tracking-wider uppercase'>
<Label className='text-muted-foreground text-[0.7rem] tracking-wider'>
Number of keys
</label>
</Label>
<Input
type='number'
min={1}
@@ -368,12 +370,12 @@ export function ChildKeyCreator({
{newKeys.length > 0 && (
<div className='mt-6 space-y-4'>
<Alert className='border-green-200 bg-green-50 dark:border-green-900/20 dark:bg-green-900/10'>
<AlertTitle className='text-green-800 dark:text-green-400'>
<Alert>
<AlertTitle>
{newKeys.length} New API Key{newKeys.length > 1 ? 's' : ''}{' '}
Generated
</AlertTitle>
<AlertDescription className='text-green-700 dark:text-green-500'>
<AlertDescription>
Copy {newKeys.length > 1 ? 'these keys' : 'this key'} now.
You won&apos;t be able to see them again.
{resultInfo && (
@@ -387,14 +389,14 @@ export function ChildKeyCreator({
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-xs font-medium uppercase'>
<span className='text-muted-foreground text-xs font-medium'>
Generated Keys ({newKeys.length})
</span>
{newKeys.length > 1 && (
<Button
variant='ghost'
size='sm'
className='h-7 text-[10px] uppercase'
className='h-7 text-[10px]'
onClick={copyAllToClipboard}
>
<Copy className='mr-1 h-3 w-3' />
@@ -418,7 +420,7 @@ export function ChildKeyCreator({
onClick={() => copyToClipboard(key)}
>
{copiedKey === key ? (
<Check className='h-3.5 w-3.5 text-green-500' />
<Check className='h-3.5 w-3.5' />
) : (
<Copy className='h-3.5 w-3.5 opacity-50 group-hover:opacity-100' />
)}
@@ -430,15 +432,15 @@ export function ChildKeyCreator({
{newKeys.length > 3 && (
<div className='space-y-2'>
<label className='text-muted-foreground text-[0.7rem] tracking-wider uppercase'>
<Label className='text-muted-foreground text-[0.7rem] tracking-wider'>
Bulk Export (All Keys)
</label>
</Label>
<div className='relative'>
<textarea
<Textarea
readOnly
value={newKeys.join('\n')}
rows={Math.min(newKeys.length, 6)}
className='bg-muted/30 w-full rounded-md border p-3 font-mono text-[10px] focus:outline-none'
className='bg-muted/30 font-mono text-[10px] leading-relaxed'
/>
<Button
size='sm'
@@ -468,9 +470,9 @@ export function ChildKeyCreator({
<CardContent>
<div className='space-y-4'>
<div className='space-y-2'>
<label className='text-muted-foreground text-[0.7rem] tracking-wider uppercase'>
<Label className='text-muted-foreground text-[0.7rem] tracking-wider'>
Child API Key
</label>
</Label>
<Input
value={childKeyToCheck}
onChange={(e) => setChildKeyToCheck(e.target.value)}
@@ -531,9 +533,7 @@ export function ChildKeyCreator({
<Badge variant='destructive'>Expired</Badge>
)}
{!keyStatus.is_drained && !keyStatus.is_expired && (
<Badge className='bg-green-600 hover:bg-green-700'>
Active
</Badge>
<Badge>Active</Badge>
)}
</div>
</div>
@@ -218,11 +218,11 @@ export function CollectModelsDialog({
{!isLoadingModels && selectedProvider && remoteModels.length > 0 && (
<>
<div className='flex items-center justify-between'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div className='text-sm font-medium'>
{remoteModels.length} models available
</div>
<div className='flex gap-2'>
<div className='flex flex-wrap gap-2'>
<Button
variant='outline'
size='sm'
@@ -2,7 +2,7 @@
import React from 'react';
import { type Model } from '@/lib/api/schemas/models';
import { CostCalculator } from '@/components/CostCalculator';
import { CostCalculator } from '@/components/cost-calculator';
import {
Dialog,
DialogContent,
@@ -5,8 +5,9 @@ import { type Model } from '@/lib/api/schemas/models';
import {
calculateRequestCost,
estimateMinimumTokensForCost,
formatCost,
} from '@/lib/services/costValidation';
} from '@/lib/services/cost-validation';
import { formatUsdAmountForDisplayUnit } from '@/lib/currency';
import { useDisplayCurrency } from '@/lib/hooks/use-display-currency';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -21,6 +22,7 @@ interface CostCalculatorProps {
}
export function CostCalculator({ model }: CostCalculatorProps) {
const { displayUnit, usdPerSat } = useDisplayCurrency();
const [inputTokens, setInputTokens] = useState<number>(100);
const [outputTokens, setOutputTokens] = useState<number>(100);
@@ -39,6 +41,8 @@ export function CostCalculator({ model }: CostCalculatorProps) {
}, [model]);
const hasMinimumCost = model.min_cost_per_request > 0;
const formatDisplayCost = (amountUsd: number) =>
formatUsdAmountForDisplayUnit(amountUsd, displayUnit, usdPerSat);
return (
<div className='space-y-6'>
@@ -75,33 +79,33 @@ export function CostCalculator({ model }: CostCalculatorProps) {
<div className='flex justify-between'>
<span>Input Cost ({inputTokens.toLocaleString()} tokens):</span>
<span className='font-mono'>
{formatCost(costCalculation.inputCost)}
{formatDisplayCost(costCalculation.inputCost)}
</span>
</div>
<div className='flex justify-between'>
<span>Output Cost ({outputTokens.toLocaleString()} tokens):</span>
<span className='font-mono'>
{formatCost(costCalculation.outputCost)}
{formatDisplayCost(costCalculation.outputCost)}
</span>
</div>
<hr className='my-2' />
<div className='flex justify-between'>
<span>Base Cost:</span>
<span className='font-mono'>
{formatCost(costCalculation.baseCost)}
{formatDisplayCost(costCalculation.baseCost)}
</span>
</div>
<div className='flex justify-between'>
<span>Minimum Cost per Request:</span>
<span className='font-mono'>
{formatCost(costCalculation.minCostPerRequest)}
{formatDisplayCost(costCalculation.minCostPerRequest)}
</span>
</div>
<hr className='my-2' />
<div className='flex justify-between font-medium'>
<span>Final Cost:</span>
<span className='font-mono text-lg'>
{formatCost(costCalculation.finalCost)}
{formatDisplayCost(costCalculation.finalCost)}
</span>
</div>
</div>
@@ -110,16 +114,12 @@ export function CostCalculator({ model }: CostCalculatorProps) {
{/* Minimum Cost Alert */}
{hasMinimumCost && (
<Alert
className={
costCalculation.isMinimumApplied
? 'border-amber-200 bg-amber-50'
: 'border-green-200 bg-green-50'
}
variant={costCalculation.isMinimumApplied ? 'destructive' : 'default'}
>
{costCalculation.isMinimumApplied ? (
<AlertTriangle className='h-4 w-4 text-amber-600' />
<AlertTriangle className='h-4 w-4' />
) : (
<CheckCircle className='h-4 w-4 text-green-600' />
<CheckCircle className='h-4 w-4' />
)}
<AlertTitle>
{costCalculation.isMinimumApplied
@@ -128,8 +128,8 @@ export function CostCalculator({ model }: CostCalculatorProps) {
</AlertTitle>
<AlertDescription>
{costCalculation.isMinimumApplied
? `The calculated cost (${formatCost(costCalculation.baseCost)}) is below the minimum, so the minimum cost of ${formatCost(costCalculation.minCostPerRequest)} is applied.`
: `The calculated cost (${formatCost(costCalculation.baseCost)}) meets the minimum requirement of ${formatCost(costCalculation.minCostPerRequest)}.`}
? `The calculated cost (${formatDisplayCost(costCalculation.baseCost)}) is below the minimum, so the minimum cost of ${formatDisplayCost(costCalculation.minCostPerRequest)} is applied.`
: `The calculated cost (${formatDisplayCost(costCalculation.baseCost)}) meets the minimum requirement of ${formatDisplayCost(costCalculation.minCostPerRequest)}.`}
</AlertDescription>
</Alert>
)}
@@ -207,16 +207,20 @@ export function CostCalculator({ model }: CostCalculatorProps) {
<div className='space-y-2 text-sm'>
<div className='flex justify-between'>
<span>Input cost per 1M tokens:</span>
<span className='font-mono'>{formatCost(model.input_cost)}</span>
<span className='font-mono'>
{formatDisplayCost(model.input_cost)}
</span>
</div>
<div className='flex justify-between'>
<span>Output cost per 1M tokens:</span>
<span className='font-mono'>{formatCost(model.output_cost)}</span>
<span className='font-mono'>
{formatDisplayCost(model.output_cost)}
</span>
</div>
<div className='flex justify-between'>
<span>Minimum cost per request:</span>
<span className='font-mono'>
{formatCost(model.min_cost_per_request)}
{formatDisplayCost(model.min_cost_per_request)}
</span>
</div>
</div>
+70 -37
View File
@@ -1,20 +1,45 @@
'use client';
import { useCurrencyStore } from '@/lib/stores/currency';
import { useQuery } from '@tanstack/react-query';
import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate';
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ChevronsUpDownIcon, CoinsIcon } from 'lucide-react';
import { useCurrencyStore } from '@/lib/stores/currency';
import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate';
import type { DisplayUnit } from '@/lib/types/units';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { Coins } from 'lucide-react';
import { cn } from '@/lib/utils';
export function CurrencyToggle() {
interface CurrencyToggleProps {
className?: string;
compact?: boolean;
menuSide?: 'top' | 'right' | 'bottom' | 'left';
menuAlign?: 'start' | 'center' | 'end';
}
const UNIT_OPTIONS: Array<{ value: DisplayUnit; label: string }> = [
{ value: 'msat', label: 'mSAT' },
{ value: 'sat', label: 'sat' },
{ value: 'usd', label: 'USD' },
];
function getLabel(unit: DisplayUnit): string {
const option = UNIT_OPTIONS.find((item) => item.value === unit);
return option?.label ?? unit;
}
export function CurrencyToggle({
className,
compact = false,
menuSide = 'bottom',
menuAlign = 'end',
}: CurrencyToggleProps) {
const { displayUnit, setDisplayUnit } = useCurrencyStore();
const { data: btcUsdPrice } = useQuery({
@@ -32,47 +57,55 @@ export function CurrencyToggle() {
}
}, [displayUnit, usdPerSat, setDisplayUnit]);
const getLabel = (unit: DisplayUnit) => {
switch (unit) {
case 'msat':
return 'mSAT';
case 'sat':
return 'sat';
case 'usd':
return 'USD';
default:
return unit;
}
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
variant='outline'
size='sm'
className='h-9 w-9 w-auto gap-2 px-0 px-3 font-normal'
className={cn(
'border-border/60 bg-background/65 text-muted-foreground hover:text-foreground rounded-md',
compact
? 'h-8 w-10 justify-center px-0'
: 'h-8 justify-between gap-2',
className
)}
>
<Coins className='h-4 w-4' />
<span className='hidden sm:inline-block'>
{getLabel(displayUnit)}
<span className='inline-flex min-w-0 items-center gap-1.5'>
<CoinsIcon className='h-3.5 w-3.5 shrink-0' />
{compact ? null : (
<span className='truncate text-[11px] font-medium uppercase'>
{getLabel(displayUnit)}
</span>
)}
</span>
<span className='uppercase sm:hidden'>{displayUnit}</span>
{compact ? (
<span className='sr-only'>Currency: {getLabel(displayUnit)}</span>
) : (
<ChevronsUpDownIcon className='h-3.5 w-3.5 opacity-70' />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem onClick={() => setDisplayUnit('msat')}>
Millisatoshis (mSAT)
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDisplayUnit('sat')}>
Satoshis (sat)
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setDisplayUnit('usd')}
disabled={!usdPerSat}
<DropdownMenuContent side={menuSide} align={menuAlign}>
<DropdownMenuRadioGroup
value={displayUnit}
onValueChange={(value) => {
if (value !== 'msat' && value !== 'sat' && value !== 'usd') return;
if (value === 'usd' && !usdPerSat) return;
setDisplayUnit(value);
}}
>
US Dollar (USD)
</DropdownMenuItem>
{UNIT_OPTIONS.map((option) => (
<DropdownMenuRadioItem
key={option.value}
value={option.value}
disabled={option.value === 'usd' && !usdPerSat}
className='uppercase'
>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
+18 -16
View File
@@ -21,7 +21,8 @@ export function DashboardBalanceSummary({
queryFn: async () => {
return WalletService.getDetailedBalances();
},
refetchInterval: 30000,
refetchInterval: 900000,
staleTime: 300000,
});
const calculateTotals = (balances: BalanceDetail[]) => {
@@ -59,37 +60,38 @@ export function DashboardBalanceSummary({
title: 'Your Balance',
value: formatAmount(totals.totalOwner),
icon: Coins,
color: 'text-green-600',
bgColor: 'bg-green-100 dark:bg-green-900/20',
color: 'text-green-600 dark:text-green-300',
},
{
title: 'Total Wallet',
value: formatAmount(totals.totalWallet),
icon: Wallet,
color: 'text-blue-600',
bgColor: 'bg-blue-100 dark:bg-blue-900/20',
color: 'text-blue-600 dark:text-blue-300',
},
{
title: 'User Balance',
value: formatAmount(totals.totalUser),
icon: User,
color: 'text-purple-600',
bgColor: 'bg-purple-100 dark:bg-purple-900/20',
color: 'text-purple-600 dark:text-purple-300',
},
];
return (
<div className='grid gap-4 md:grid-cols-3'>
<div className='grid grid-cols-2 gap-2.5 max-[359px]:grid-cols-1 sm:gap-3 lg:grid-cols-3'>
{cards.map((card) => (
<Card key={card.title}>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-sm font-medium'>{card.title}</CardTitle>
<div className={`rounded-full p-2 ${card.bgColor}`}>
<card.icon className={`h-4 w-4 ${card.color}`} />
</div>
<Card key={card.title} size='sm'>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-1'>
<CardTitle className='text-muted-foreground text-[11px] font-medium sm:text-sm'>
{card.title}
</CardTitle>
<span className='inline-flex size-6 items-center justify-center sm:size-7'>
<card.icon className={`size-3.5 sm:size-4 ${card.color}`} />
</span>
</CardHeader>
<CardContent>
<div className='text-2xl font-bold'>{card.value}</div>
<CardContent className='pt-0'>
<div className='text-base font-semibold break-words tabular-nums sm:text-xl'>
{card.value}
</div>
</CardContent>
</Card>
))}
+1 -5
View File
@@ -190,11 +190,7 @@ const columns: ColumnDef<z.infer<typeof schema>>[] = [
variant='outline'
className='text-muted-foreground flex gap-1 px-1.5 [&_svg]:size-3'
>
{row.original.status === 'Done' ? (
<CheckCircle2Icon className='text-green-500 dark:text-green-400' />
) : (
<LoaderIcon />
)}
{row.original.status === 'Done' ? <CheckCircle2Icon /> : <LoaderIcon />}
{row.original.status}
</Badge>
),
+244 -159
View File
@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Loader2, RefreshCw, AlertCircle, Wallet } from 'lucide-react';
import { RefreshCw, AlertCircle, Wallet, User, Coins } from 'lucide-react';
import { WalletService, BalanceDetail } from '@/lib/api/services/wallet';
import {
Card,
@@ -12,6 +12,23 @@ import {
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty';
import { Skeleton } from '@/components/ui/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { WithdrawModal } from '@/components/withdraw-modal';
import { cn } from '@/lib/utils';
import type { DisplayUnit } from '@/lib/types/units';
@@ -66,18 +83,46 @@ export function DetailedWalletBalance({
? calculateTotals(data)
: { totalWallet: 0, totalUser: 0, totalOwner: 0 };
const rows = (data ?? [])
.filter(
(detail) =>
(detail.wallet_balance && detail.wallet_balance > 0) || detail.error
)
.map((detail, index) => {
const walletMsat = convertToMsat(detail.wallet_balance || 0, detail.unit);
const userMsat = convertToMsat(detail.user_balance || 0, detail.unit);
const ownerMsat = convertToMsat(detail.owner_balance || 0, detail.unit);
return {
key: `${detail.mint_url}-${detail.unit}-${index}`,
detail,
walletMsat,
userMsat,
ownerMsat,
};
});
const formatMintLabel = (detail: BalanceDetail) =>
`${detail.mint_url.replace('https://', '').replace('http://', '')}${detail.unit.toUpperCase()}`;
return (
<>
<Card className='h-full w-full shadow-sm'>
<Card>
<CardHeader className='pb-4'>
<div className='flex items-center justify-between'>
<CardTitle className='text-xl'>Cashu Wallet Balance</CardTitle>
<div className='flex gap-2'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
<div className='space-y-1.5'>
<CardTitle>Cashu Wallet Balance</CardTitle>
<CardDescription>
Detailed balance breakdown by mint and currency
</CardDescription>
</div>
<div className='flex w-full gap-2 sm:w-auto'>
<Button
variant='default'
size='sm'
onClick={() => setWithdrawModalOpen(true)}
disabled={isLoading || !data || data.length === 0}
className='flex-1 sm:flex-none'
>
<Wallet className='mr-2 h-4 w-4' />
Withdraw
@@ -87,7 +132,6 @@ export function DetailedWalletBalance({
size='icon'
onClick={() => refetch()}
disabled={isLoading || isFetching}
className='h-8 w-8'
>
<RefreshCw
className={cn(
@@ -99,177 +143,218 @@ export function DetailedWalletBalance({
</Button>
</div>
</div>
<CardDescription>
Detailed balance breakdown by mint and currency
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className='flex items-center justify-center py-8'>
<Loader2 className='text-primary h-8 w-8 animate-spin' />
<div className='space-y-4'>
<div className='grid gap-3 md:grid-cols-3'>
{Array.from({ length: 3 }).map((_, index) => (
<Card key={`wallet-stat-skeleton-${index}`}>
<CardHeader className='space-y-2 pb-1'>
<Skeleton className='h-3.5 w-28' />
<Skeleton className='h-3 w-8' />
</CardHeader>
<CardContent className='pt-0'>
<Skeleton className='h-7 w-24' />
</CardContent>
</Card>
))}
</div>
<Skeleton className='h-3 w-52' />
<div className='space-y-2'>
{Array.from({ length: 5 }).map((_, index) => (
<Skeleton
key={`wallet-row-skeleton-${index}`}
className='h-11 w-full'
/>
))}
</div>
</div>
) : isError ? (
<div className='bg-destructive/10 text-destructive flex items-center space-x-2 rounded-md p-4'>
<Alert variant='destructive'>
<AlertCircle className='h-5 w-5' />
<span>Error loading balance: {(error as Error).message}</span>
</div>
<AlertDescription>
Error loading balance: {(error as Error).message}
</AlertDescription>
</Alert>
) : (
<div className='space-y-6'>
<div className='space-y-3'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Your Balance (Total)
</span>
<span className='text-2xl font-bold text-green-600'>
{formatAmount(totals.totalOwner)}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Total Wallet
</span>
<span className='text-lg font-semibold'>
{formatAmount(totals.totalWallet)}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
User Balance
</span>
<span className='text-lg font-semibold'>
{formatAmount(totals.totalUser)}
</span>
</div>
<div className='grid gap-3 md:grid-cols-3'>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-muted-foreground text-sm font-medium'>
Your Balance (Total)
</CardTitle>
<span className='inline-flex size-8 items-center justify-center'>
<Coins className='size-4 text-green-600 dark:text-green-300' />
</span>
</CardHeader>
<CardContent className='pt-0'>
<p className='text-primary text-2xl font-semibold tracking-tight tabular-nums'>
{formatAmount(totals.totalOwner)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-muted-foreground text-sm font-medium'>
Total Wallet
</CardTitle>
<span className='inline-flex size-8 items-center justify-center'>
<Wallet className='size-4 text-blue-600 dark:text-blue-300' />
</span>
</CardHeader>
<CardContent className='pt-0'>
<p className='text-2xl font-semibold tracking-tight tabular-nums'>
{formatAmount(totals.totalWallet)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-muted-foreground text-sm font-medium'>
User Balance
</CardTitle>
<span className='inline-flex size-8 items-center justify-center'>
<User className='size-4 text-purple-600 dark:text-purple-300' />
</span>
</CardHeader>
<CardContent className='pt-0'>
<p className='text-2xl font-semibold tracking-tight tabular-nums'>
{formatAmount(totals.totalUser)}
</p>
</CardContent>
</Card>
</div>
<p className='text-muted-foreground text-xs'>
Your balance = Total wallet - User balance
</p>
<div className='overflow-hidden rounded-lg border'>
{/* Desktop Table Header */}
<div className='bg-muted hidden grid-cols-4 gap-2 p-3 text-sm font-semibold md:grid'>
<div>Mint / Unit</div>
<div className='text-right'>Wallet</div>
<div className='text-right'>Users</div>
<div className='text-right'>Owner</div>
</div>
{data && data.length > 0 ? (
data
.filter(
(detail) =>
(detail.wallet_balance && detail.wallet_balance > 0) ||
detail.error
)
.map((detail, index) => {
const walletMsat = convertToMsat(
detail.wallet_balance || 0,
detail.unit
);
const userMsat = convertToMsat(
detail.user_balance || 0,
detail.unit
);
const ownerMsat = convertToMsat(
detail.owner_balance || 0,
detail.unit
);
return (
<div
key={index}
className={cn(
'border-t p-3 text-sm',
detail.error && 'bg-destructive/10 text-destructive'
)}
>
{/* Desktop Layout */}
<div className='hidden grid-cols-4 gap-2 md:grid'>
<div className='text-xs break-all'>
{detail.mint_url
.replace('https://', '')
.replace('http://', '')}{' '}
{detail.unit.toUpperCase()}
</div>
<div className='text-right font-mono'>
{detail.error
? 'error'
: formatAmount(walletMsat)}
</div>
<div className='text-right font-mono'>
{detail.error ? '-' : formatAmount(userMsat)}
</div>
<div
{rows.length > 0 ? (
<>
<div className='hidden md:block'>
<Table>
<TableHeader>
<TableRow>
<TableHead>Mint / Unit</TableHead>
<TableHead className='text-right'>Wallet</TableHead>
<TableHead className='text-right'>Users</TableHead>
<TableHead className='text-right'>Owner</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map(
({
key,
detail,
walletMsat,
userMsat,
ownerMsat,
}) => (
<TableRow
key={key}
className={cn(
'text-right font-mono',
!detail.error &&
ownerMsat > 0 &&
'font-semibold text-green-600'
detail.error &&
'bg-destructive/10 text-destructive'
)}
>
{detail.error ? '-' : formatAmount(ownerMsat)}
</div>
</div>
{/* Mobile Layout */}
<div className='space-y-3 md:hidden'>
<div className='space-y-1'>
<span className='text-muted-foreground text-xs font-medium'>
Mint / Unit
</span>
<div className='font-mono text-xs break-all'>
{detail.mint_url
.replace('https://', '')
.replace('http://', '')}{' '}
{detail.unit.toUpperCase()}
</div>
</div>
<div className='grid grid-cols-3 gap-2'>
<div className='space-y-1'>
<div className='text-muted-foreground text-xs font-medium'>
Wallet
</div>
<div className='truncate font-mono text-sm'>
{detail.error
? 'error'
: formatAmount(walletMsat)}
</div>
</div>
<div className='space-y-1'>
<div className='text-muted-foreground text-xs font-medium'>
Users
</div>
<div className='truncate font-mono text-sm'>
{detail.error ? '-' : formatAmount(userMsat)}
</div>
</div>
<div className='space-y-1'>
<div className='text-muted-foreground text-xs font-medium'>
Owner
</div>
<div
className={cn(
'truncate font-mono text-sm',
!detail.error &&
ownerMsat > 0 &&
'font-semibold text-green-600'
)}
>
{detail.error ? '-' : formatAmount(ownerMsat)}
</div>
</div>
</div>
</div>
</div>
);
})
) : (
<div className='text-muted-foreground p-4 text-center text-sm'>
No balances to display
<TableCell className='max-w-md font-mono text-xs break-all whitespace-normal'>
{formatMintLabel(detail)}
</TableCell>
<TableCell className='text-right font-mono'>
{detail.error
? 'error'
: formatAmount(walletMsat)}
</TableCell>
<TableCell className='text-right font-mono'>
{detail.error ? '-' : formatAmount(userMsat)}
</TableCell>
<TableCell
className={cn(
'text-right font-mono',
!detail.error &&
ownerMsat > 0 &&
'text-primary font-semibold'
)}
>
{detail.error ? '-' : formatAmount(ownerMsat)}
</TableCell>
</TableRow>
)
)}
</TableBody>
</Table>
</div>
)}
</div>
<div className='space-y-2 md:hidden'>
{rows.map(
({ key, detail, walletMsat, userMsat, ownerMsat }) => (
<Card
key={`${key}-mobile`}
className={cn(
detail.error &&
'border-destructive/40 bg-destructive/5'
)}
>
<CardHeader className='p-4 pb-2'>
<CardDescription className='font-mono text-xs break-all'>
{formatMintLabel(detail)}
</CardDescription>
</CardHeader>
<CardContent className='grid grid-cols-1 gap-2 p-4 pt-0 sm:grid-cols-3 sm:gap-3'>
<div>
<p className='text-muted-foreground text-xs'>
Wallet
</p>
<p className='font-mono text-sm'>
{detail.error
? 'error'
: formatAmount(walletMsat)}
</p>
</div>
<div>
<p className='text-muted-foreground text-xs'>
Users
</p>
<p className='font-mono text-sm'>
{detail.error ? '-' : formatAmount(userMsat)}
</p>
</div>
<div>
<p className='text-muted-foreground text-xs'>
Owner
</p>
<p
className={cn(
'font-mono text-sm',
!detail.error &&
ownerMsat > 0 &&
'text-primary font-semibold'
)}
>
{detail.error ? '-' : formatAmount(ownerMsat)}
</p>
</div>
</CardContent>
</Card>
)
)}
</div>
</>
) : (
<Empty className='py-6'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<Wallet className='h-4 w-4' />
</EmptyMedia>
<EmptyTitle>No balances to display</EmptyTitle>
<EmptyDescription>
Wallet balances will appear here after funds are
available.
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</div>
)}
</CardContent>
+2 -5
View File
@@ -4,7 +4,7 @@ import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader2, Copy, Check, SendIcon } from 'lucide-react';
import { Loader2, Copy, Check } from 'lucide-react';
import { toast } from 'sonner';
import { useQueryClient } from '@tanstack/react-query';
import { QRCodeSVG } from 'qrcode.react';
@@ -101,10 +101,7 @@ export function EcashRedeem() {
return (
<Card className='h-full w-full shadow-sm'>
<CardHeader>
<div className='flex items-center space-x-2'>
<SendIcon className='text-primary h-5 w-5' />
<CardTitle>Send eCash</CardTitle>
</div>
<CardTitle>Send eCash</CardTitle>
<CardDescription>
Generate a token to send eCash to someone
</CardDescription>
@@ -27,6 +27,7 @@ import {
FormMessage,
} from '@/components/ui/form';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import { Users, Key, Loader2, Globe, AlertTriangle, Info } from 'lucide-react';
import { toast } from 'sonner';
import { Alert, AlertDescription } from '@/components/ui/alert';
@@ -122,9 +123,9 @@ export function EditGroupForm({
<div className='space-y-2 text-sm'>
<div className='flex justify-between'>
<span>Models using group API key:</span>
<span className='font-medium text-blue-600'>
<Badge variant='secondary' className='tabular-nums'>
{modelsWithoutKeys}
</span>
</Badge>
</div>
</div>
</div>
@@ -138,18 +139,18 @@ export function EditGroupForm({
<div className='space-y-2 text-sm'>
<div className='flex justify-between'>
<span>Models using group URL:</span>
<span className='font-medium text-blue-600'>
<Badge variant='secondary' className='tabular-nums'>
{modelsUsingGroupUrl}
</span>
</Badge>
</div>
<div className='flex justify-between'>
<span>Models with individual URLs:</span>
<span className='font-medium text-green-600'>
<Badge variant='outline' className='tabular-nums'>
{models.length - modelsUsingGroupUrl}
</span>
</Badge>
</div>
{groupSettings?.group_url && (
<div className='mt-2 rounded bg-blue-50 p-2 text-xs'>
<div className='bg-muted mt-2 rounded p-2 text-xs'>
<span className='font-medium'>Current group URL:</span>{' '}
{groupSettings.group_url}
</div>
@@ -163,26 +164,22 @@ export function EditGroupForm({
<div className='max-h-32 overflow-y-auto'>
<div className='flex flex-wrap gap-2'>
{models.map((model) => (
<span
<Badge
key={model.id}
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${
model.api_key
? 'bg-green-100 text-green-800'
: 'bg-blue-100 text-blue-800'
}`}
variant={model.api_key ? 'default' : 'secondary'}
className='max-w-full'
title={
model.api_key
? 'Has individual API key and URL'
: 'Uses group API key and URL'
}
>
{model.name}
{model.api_key && ' 🔑'}
<span className='truncate'>{model.name}</span>
{model.api_key && <Key className='ml-1 h-3 w-3' />}
{model.url &&
!model.url.startsWith('/') &&
model.api_key &&
' 🌐'}
</span>
model.api_key && <Globe className='ml-1 h-3 w-3' />}
</Badge>
))}
</div>
</div>
@@ -310,28 +307,30 @@ export function EditGroupForm({
)}
/>
<div className='rounded-md border border-amber-200 bg-amber-50 p-4'>
<p className='text-sm text-amber-800'>
<strong>How group settings work:</strong>
</p>
<ul className='mt-2 list-inside list-disc space-y-1 text-sm text-amber-800'>
<li>
Models with individual API keys and URLs will keep their
specific settings
</li>
<li>
Models without individual settings will use the group defaults
</li>
<li>
Removing the group URL makes models fall back to the system
default endpoint
</li>
<li>
You can use &quot;Apply Group Settings&quot; to force models
to use group configurations
</li>
</ul>
</div>
<Alert>
<Info className='h-4 w-4' />
<AlertDescription>
<p className='font-medium'>How group settings work:</p>
<ul className='mt-2 list-inside list-disc space-y-1 text-sm'>
<li>
Models with individual API keys and URLs will keep their
specific settings
</li>
<li>
Models without individual settings will use the group
defaults
</li>
<li>
Removing the group URL makes models fall back to the system
default endpoint
</li>
<li>
You can use &quot;Apply Group Settings&quot; to force models
to use group configurations
</li>
</ul>
</AlertDescription>
</Alert>
<div className='flex justify-end gap-2 pt-4'>
<Button
@@ -5,7 +5,7 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { type Model } from '@/lib/api/schemas/models';
import { AdminService } from '@/lib/api/services/admin';
import { AdminService, type AdminModel } from '@/lib/api/services/admin';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
@@ -32,9 +32,9 @@ import { Switch } from '@/components/ui/switch';
const EditModelFormSchema = z.object({
name: z.string().min(1, 'Name is required'),
description: z.string().optional(),
context_length: z.coerce.number().min(0),
prompt: z.coerce.number().min(0),
completion: z.coerce.number().min(0),
context_length: z.number().min(0),
prompt: z.number().min(0),
completion: z.number().min(0),
enabled: z.boolean(),
});
@@ -47,6 +47,29 @@ const roundToFiveDecimals = (value: number | undefined | null): number => {
return Math.round(value * 100000) / 100000;
};
const toNumber = (value: unknown, fallback = 0): number => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return fallback;
};
const toStringArray = (value: unknown, fallback: string[]): string[] => {
if (!Array.isArray(value)) {
return fallback;
}
const filtered = value.filter(
(item): item is string => typeof item === 'string'
);
return filtered.length > 0 ? filtered : fallback;
};
interface EditModelFormProps {
model: Model;
providerId?: number;
@@ -82,6 +105,81 @@ interface AdminModelData {
enabled: boolean;
}
const normalizeAdminModelData = (
adminModel: AdminModel,
fallbackModel: Model,
providerId: number
): AdminModelData => {
const pricingRecord =
adminModel.pricing && typeof adminModel.pricing === 'object'
? (adminModel.pricing as Record<string, unknown>)
: {};
const architectureRecord =
adminModel.architecture && typeof adminModel.architecture === 'object'
? (adminModel.architecture as Record<string, unknown>)
: {};
return {
id: adminModel.id,
name: adminModel.name,
description: adminModel.description || '',
created: toNumber(adminModel.created, Math.floor(Date.now() / 1000)),
context_length: Math.max(
0,
Math.trunc(
toNumber(adminModel.context_length, fallbackModel.contextLength || 4096)
)
),
architecture: {
modality:
typeof architectureRecord.modality === 'string'
? architectureRecord.modality
: fallbackModel.modelType || 'text',
input_modalities: toStringArray(architectureRecord.input_modalities, [
fallbackModel.modelType || 'text',
]),
output_modalities: toStringArray(architectureRecord.output_modalities, [
fallbackModel.modelType || 'text',
]),
tokenizer:
typeof architectureRecord.tokenizer === 'string'
? architectureRecord.tokenizer
: '',
instruct_type:
typeof architectureRecord.instruct_type === 'string'
? architectureRecord.instruct_type
: null,
},
pricing: {
prompt: roundToFiveDecimals(
toNumber(pricingRecord.prompt, fallbackModel.input_cost)
),
completion: roundToFiveDecimals(
toNumber(pricingRecord.completion, fallbackModel.output_cost)
),
request: toNumber(pricingRecord.request, 0),
image: toNumber(pricingRecord.image, 0),
web_search: toNumber(pricingRecord.web_search, 0),
internal_reasoning: toNumber(pricingRecord.internal_reasoning, 0),
},
per_request_limits:
adminModel.per_request_limits === null ||
adminModel.per_request_limits === undefined
? adminModel.per_request_limits
: null,
top_provider:
adminModel.top_provider === null || adminModel.top_provider === undefined
? adminModel.top_provider
: null,
upstream_provider_id:
typeof adminModel.upstream_provider_id === 'number'
? adminModel.upstream_provider_id
: providerId,
enabled: adminModel.enabled !== false,
};
};
export function EditModelForm({
model,
providerId,
@@ -114,29 +212,28 @@ export function EditModelForm({
}
try {
console.log('Loading admin model:', {
providerId,
modelId: model.full_name,
});
const adminModel = await AdminService.getProviderModel(
providerId,
model.id
);
setAdminModelData(adminModel as AdminModelData);
const normalizedAdminModel = normalizeAdminModelData(
adminModel,
model,
providerId
);
setAdminModelData(normalizedAdminModel);
setIsNewOverride(false);
form.reset({
name: adminModel.name,
description: adminModel.description || '',
context_length: adminModel.context_length,
prompt: roundToFiveDecimals(adminModel.pricing.prompt),
completion: roundToFiveDecimals(adminModel.pricing.completion),
enabled: adminModel.enabled !== false,
name: normalizedAdminModel.name,
description: normalizedAdminModel.description || '',
context_length: normalizedAdminModel.context_length,
prompt: normalizedAdminModel.pricing.prompt,
completion: normalizedAdminModel.pricing.completion,
enabled: normalizedAdminModel.enabled !== false,
});
} catch (error: unknown) {
console.log('Model not in database, will create new override:', error);
} catch {
setIsNewOverride(true);
setAdminModelData({
id: model.full_name,
@@ -238,11 +335,9 @@ export function EditModelForm({
};
if (isNewOverride) {
console.log('Creating new model override');
await AdminService.createProviderModel(providerId, payload);
toast.success('Model override created successfully!');
} else {
console.log('Updating existing model override');
await AdminService.updateProviderModel(
providerId,
adminModelData.id,
@@ -318,7 +413,19 @@ export function EditModelForm({
type='number'
min='0'
placeholder='4096'
{...field}
value={field.value ?? ''}
onChange={(e) => {
const value = e.target.value;
field.onChange(
value === '' ? 0 : parseInt(value, 10) || 0
);
}}
onBlur={(e) => {
const value = parseInt(e.target.value, 10);
field.onChange(
Number.isNaN(value) ? 0 : Math.max(0, value)
);
}}
className='w-full'
/>
</FormControl>
+2 -2
View File
@@ -38,8 +38,8 @@ export function ErrorDetailsTable({ errors }: ErrorDetailsTableProps) {
<CardTitle>Recent Errors ({errors.length})</CardTitle>
</CardHeader>
<CardContent>
<div className='max-h-[400px] overflow-y-auto'>
<Table>
<div className='max-h-[420px] max-w-full overflow-y-auto'>
<Table className='min-w-[640px] sm:min-w-[760px]'>
<TableHeader>
<TableRow>
<TableHead>Timestamp</TableHead>
+32 -15
View File
@@ -1,5 +1,13 @@
import { Zap, Calendar, Shield } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
interface KeyOptionsProps {
balanceLimit: string;
@@ -24,48 +32,57 @@ export function KeyOptions({
<div className='grid gap-4 sm:grid-cols-3'>
{showBalanceLimit && (
<div className='space-y-2'>
<label className='text-muted-foreground flex items-center gap-1.5 text-[0.7rem] tracking-wider uppercase'>
<Label className='text-muted-foreground flex items-center gap-1.5 text-[0.7rem] tracking-wider uppercase'>
<Zap className='h-3 w-3' />
Balance Limit (mSats)
</label>
</Label>
<Input
type='number'
placeholder='No limit'
value={balanceLimit}
onChange={(e) => setBalanceLimit(e.target.value)}
className='h-9 text-xs'
name='balance_limit_msats'
autoComplete='off'
/>
</div>
)}
<div className='space-y-2'>
<label className='text-muted-foreground flex items-center gap-1.5 text-[0.7rem] tracking-wider uppercase'>
<Label className='text-muted-foreground flex items-center gap-1.5 text-[0.7rem] tracking-wider uppercase'>
<Calendar className='h-3 w-3' />
Validity Date
</label>
</Label>
<Input
type='date'
value={validityDate}
onChange={(e) => setValidityDate(e.target.value)}
className='h-9 text-xs'
name='validity_date'
/>
</div>
<div className='space-y-2'>
<label className='text-muted-foreground flex items-center gap-1.5 text-[0.7rem] tracking-wider uppercase'>
<Label className='text-muted-foreground flex items-center gap-1.5 text-[0.7rem] tracking-wider uppercase'>
<Shield className='h-3 w-3' />
Reset Policy
</label>
<select
value={balanceLimitReset}
onChange={(e) => setBalanceLimitReset(e.target.value)}
className='bg-background border-input flex h-9 w-full rounded-md border px-3 py-1 text-xs shadow-sm transition-colors'
</Label>
<Select
value={balanceLimitReset || 'none'}
onValueChange={(value) =>
setBalanceLimitReset(value === 'none' ? '' : value)
}
>
<option value=''>None</option>
<option value='daily'>Daily</option>
<option value='weekly'>Weekly</option>
<option value='monthly'>Monthly</option>
</select>
<SelectTrigger className='h-9 w-full text-xs'>
<SelectValue placeholder='None' />
</SelectTrigger>
<SelectContent>
<SelectItem value='none'>None</SelectItem>
<SelectItem value='daily'>Daily</SelectItem>
<SelectItem value='weekly'>Weekly</SelectItem>
<SelectItem value='monthly'>Monthly</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
+9 -43
View File
@@ -7,6 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { WalletBalanceStats } from './wallet-balance-stats';
import type { WalletSnapshot, ChildKeyInfo } from './key-info-details';
import type { RefundReceipt } from './cashu-payment-workflow';
@@ -65,10 +66,6 @@ async function fetchWalletInfo(
};
}
function formatMsats(msats: number): string {
return new Intl.NumberFormat('en-US').format(msats);
}
function formatSats(msats: number): string {
return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
}
@@ -175,17 +172,14 @@ export function ApiKeyManager({
return (
<Card>
<CardHeader className='space-y-1'>
<CardTitle className='flex items-center gap-2 text-xl'>
<RefreshCcw className='text-primary h-5 w-5' />
API Key Management
</CardTitle>
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
<CardTitle className='text-xl'>API Key Management</CardTitle>
<p className='text-muted-foreground text-xs tracking-wide'>
Manage your existing API keys and balances
</p>
</CardHeader>
<CardContent className='space-y-6'>
<section className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
<span>Manage existing key</span>
{walletInfo && (
<span className='text-primary'>
@@ -226,43 +220,15 @@ export function ApiKeyManager({
{showManageDetails && (
<div className='space-y-4'>
<div className='grid gap-3 sm:grid-cols-2'>
<div className='rounded-lg border p-3'>
<p className='text-muted-foreground text-[0.65rem] tracking-wide uppercase'>
Spendable
</p>
<p className='text-xl font-semibold'>
{walletInfo
? `${formatSats(walletInfo.balanceMsats)} sats`
: '—'}
</p>
{walletInfo && (
<p className='text-muted-foreground text-xs'>
{formatMsats(walletInfo.balanceMsats)} msats
</p>
)}
</div>
<div className='rounded-lg border p-3'>
<p className='text-muted-foreground text-[0.65rem] tracking-wide uppercase'>
Reserved
</p>
<p className='text-xl font-semibold'>
{walletInfo
? `${formatSats(walletInfo.reservedMsats)} sats`
: '—'}
</p>
{walletInfo && (
<p className='text-muted-foreground text-xs'>
{formatMsats(walletInfo.reservedMsats)} msats
</p>
)}
</div>
</div>
<WalletBalanceStats
balanceMsats={walletInfo?.balanceMsats}
reservedMsats={walletInfo?.reservedMsats}
/>
<Separator />
<div className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
<span>Refund remaining balance</span>
</header>
<div className='flex flex-wrap gap-2'>
@@ -1,7 +1,7 @@
'use client';
import { type JSX, useCallback, useState } from 'react';
import { Copy, KeyRound, RefreshCcw, Trash2 } from 'lucide-react';
import { Copy, RefreshCcw, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -9,6 +9,7 @@ import { Textarea } from '@/components/ui/textarea';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { KeyOptions } from '@/components/key-options';
import { WalletBalanceStats } from './wallet-balance-stats';
import type { ChildKeyInfo, WalletSnapshot } from './key-info-details';
export type RefundReceipt = {
@@ -74,10 +75,6 @@ async function fetchWalletInfo(
};
}
function formatMsats(msats: number): string {
return new Intl.NumberFormat('en-US').format(msats);
}
function formatSats(msats: number): string {
return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
}
@@ -308,17 +305,14 @@ export function CashuPaymentWorkflow({
return (
<Card>
<CardHeader className='space-y-1'>
<CardTitle className='flex items-center gap-2 text-xl'>
<KeyRound className='text-primary h-5 w-5' />
API key workflow
</CardTitle>
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
<CardTitle className='text-xl'>API key workflow</CardTitle>
<p className='text-muted-foreground text-xs tracking-wide'>
Sections expand as soon as you interact
</p>
</CardHeader>
<CardContent className='space-y-6'>
<section className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
<span>1 · Create key</span>
{showCreateDetails && (
<span className='text-primary'>Cashu token detected</span>
@@ -362,7 +356,7 @@ export function CashuPaymentWorkflow({
<Separator />
<section className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
<span>2 · Manage key</span>
{walletInfo && (
<span className='text-primary'>
@@ -401,45 +395,17 @@ export function CashuPaymentWorkflow({
</div>
</div>
{showManageDetails && (
<div className='grid gap-3 sm:grid-cols-2'>
<div className='rounded-lg border p-3'>
<p className='text-muted-foreground text-[0.65rem] tracking-wide uppercase'>
Spendable
</p>
<p className='text-xl font-semibold'>
{walletInfo
? `${formatSats(walletInfo.balanceMsats)} sats`
: '—'}
</p>
{walletInfo && (
<p className='text-muted-foreground text-xs'>
{formatMsats(walletInfo.balanceMsats)} msats
</p>
)}
</div>
<div className='rounded-lg border p-3'>
<p className='text-muted-foreground text-[0.65rem] tracking-wide uppercase'>
Reserved
</p>
<p className='text-xl font-semibold'>
{walletInfo
? `${formatSats(walletInfo.reservedMsats)} sats`
: '—'}
</p>
{walletInfo && (
<p className='text-muted-foreground text-xs'>
{formatMsats(walletInfo.reservedMsats)} msats
</p>
)}
</div>
</div>
<WalletBalanceStats
balanceMsats={walletInfo?.balanceMsats}
reservedMsats={walletInfo?.reservedMsats}
/>
)}
</section>
<Separator />
<section className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
<span>3 · Top up</span>
{showTopupDetails && (
<span className={canTopup ? 'text-primary' : 'text-destructive'}>
@@ -482,7 +448,7 @@ export function CashuPaymentWorkflow({
<Separator />
<section className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
<span>4 · Refund</span>
</header>
<div className='flex flex-wrap gap-2'>
+57 -25
View File
@@ -2,13 +2,15 @@
import { type JSX, useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Bolt, Copy, RefreshCcw, ShieldCheck, Terminal } from 'lucide-react';
import { Bolt, Copy, RefreshCcw, Terminal } from 'lucide-react';
import { toast } from 'sonner';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ConfigurationService } from '@/lib/api/services/configuration';
import {
@@ -165,7 +167,7 @@ export function CheatSheet(): JSX.Element {
const refundToken = refundReceipt?.token ?? null;
return (
<div className='from-background via-background to-muted min-h-screen bg-gradient-to-b'>
<div className='bg-background min-h-screen'>
<main className='mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-10 sm:px-6 lg:px-8'>
<section className='relative space-y-3 text-center md:text-left'>
<div className='absolute top-0 right-0 hidden md:block'>
@@ -173,7 +175,7 @@ export function CheatSheet(): JSX.Element {
<a href='/login'>Admin</a>
</Button>
</div>
<div className='text-muted-foreground inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[0.65rem] tracking-wider uppercase'>
<div className='text-muted-foreground inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[0.65rem] tracking-wider'>
<Bolt className='text-primary h-4 w-4' />
Routstr cheat sheet
</div>
@@ -191,11 +193,8 @@ export function CheatSheet(): JSX.Element {
<Card>
<CardHeader className='flex flex-row items-start justify-between gap-4'>
<div>
<CardTitle className='flex items-center gap-2 text-lg'>
<ShieldCheck className='text-primary h-4 w-4' />
Node identity
</CardTitle>
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
<CardTitle className='text-lg'>Node identity</CardTitle>
<p className='text-muted-foreground text-xs tracking-wide'>
/v1/info snapshot
</p>
</div>
@@ -212,9 +211,44 @@ export function CheatSheet(): JSX.Element {
</CardHeader>
<CardContent className='space-y-4'>
{isInfoLoading && (
<p className='text-muted-foreground text-sm'>
Loading node profile
</p>
<div className='space-y-4'>
<div className='space-y-2'>
<Skeleton className='h-8 w-56 max-w-full' />
<Skeleton className='h-4 w-80 max-w-full' />
</div>
<dl className='grid gap-4 sm:grid-cols-2'>
<div className='space-y-2'>
<Skeleton className='h-3 w-16' />
<Skeleton className='h-5 w-24' />
</div>
<div className='space-y-2'>
<Skeleton className='h-3 w-12' />
<Skeleton className='h-5 w-40 max-w-full' />
</div>
<div className='space-y-2 sm:col-span-2'>
<Skeleton className='h-3 w-12' />
<Skeleton className='h-5 w-full max-w-[26rem]' />
</div>
<div className='space-y-2 sm:col-span-2'>
<Skeleton className='h-3 w-10' />
<div className='flex items-center gap-2'>
<Skeleton className='h-5 max-w-[28rem] flex-1' />
<Skeleton className='h-7 w-7 rounded-md' />
</div>
</div>
</dl>
<div className='space-y-2'>
<Skeleton className='h-3 w-20' />
<div className='flex flex-wrap gap-2'>
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton
key={`mint-skeleton-${index}`}
className='h-6 w-28 rounded-full'
/>
))}
</div>
</div>
</div>
)}
{isInfoError && !isInfoLoading && (
<p className='text-destructive text-sm'>
@@ -231,7 +265,7 @@ export function CheatSheet(): JSX.Element {
</div>
<dl className='grid gap-4 sm:grid-cols-2'>
<div>
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
<dt className='text-muted-foreground text-xs tracking-wide'>
Version
</dt>
<dd className='text-base font-medium'>
@@ -239,7 +273,7 @@ export function CheatSheet(): JSX.Element {
</dd>
</div>
<div>
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
<dt className='text-muted-foreground text-xs tracking-wide'>
HTTP
</dt>
<dd className='text-base font-medium break-all'>
@@ -248,7 +282,7 @@ export function CheatSheet(): JSX.Element {
</div>
{nodeInfo.onion_url && (
<div className='sm:col-span-2'>
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
<dt className='text-muted-foreground text-xs tracking-wide'>
Onion
</dt>
<dd className='text-base font-medium break-all'>
@@ -258,7 +292,7 @@ export function CheatSheet(): JSX.Element {
)}
{nodeInfo.npub && (
<div className='sm:col-span-2'>
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
<dt className='text-muted-foreground text-xs tracking-wide'>
npub
</dt>
<dd className='flex items-center gap-2 font-mono text-sm break-all'>
@@ -276,7 +310,7 @@ export function CheatSheet(): JSX.Element {
)}
</dl>
<div className='space-y-2'>
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
<p className='text-muted-foreground text-xs tracking-wide'>
Cashu mints
</p>
<div className='flex flex-wrap gap-2'>
@@ -304,11 +338,8 @@ export function CheatSheet(): JSX.Element {
<Card>
<CardHeader className='flex flex-row items-center justify-between'>
<CardTitle className='flex items-center gap-2 text-lg'>
<Terminal className='text-primary h-4 w-4' />
Quick docs
</CardTitle>
<span className='text-muted-foreground text-xs tracking-wide uppercase'>
<CardTitle className='text-lg'>Quick docs</CardTitle>
<span className='text-muted-foreground text-xs tracking-wide'>
curl-ready
</span>
</CardHeader>
@@ -329,11 +360,12 @@ export function CheatSheet(): JSX.Element {
<Copy className='h-4 w-4' />
</Button>
</div>
<div className='bg-muted rounded-lg p-4 font-mono text-sm leading-6'>
<pre className='break-all whitespace-pre-wrap'>
<ScrollArea className='bg-muted w-full rounded-lg border'>
<pre className='w-max min-w-full p-4 font-mono text-sm leading-6 whitespace-pre'>
{curlSnippet}
</pre>
</div>
<ScrollBar orientation='horizontal' />
</ScrollArea>
<div className='flex gap-2'>
<Button
variant='secondary'
@@ -398,7 +430,7 @@ export function CheatSheet(): JSX.Element {
</CardHeader>
<CardContent className='space-y-2'>
<div className='bg-muted/30 space-y-2 rounded-lg border p-4'>
<div className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<div className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
<span>Cashu refund token</span>
<Button
variant='outline'
+11 -29
View File
@@ -1,15 +1,7 @@
'use client';
import { type JSX, useState, useCallback, useEffect } from 'react';
import {
Copy,
RefreshCcw,
ShieldCheck,
History,
Users,
RotateCcw,
KeyRound,
} from 'lucide-react';
import { Copy, RefreshCcw, RotateCcw } from 'lucide-react';
import { toast } from 'sonner';
import {
Card,
@@ -154,10 +146,7 @@ export function KeyInfoDetails({
<div className='space-y-6'>
<Card>
<CardHeader className='space-y-1'>
<CardTitle className='flex items-center gap-2 text-xl'>
<KeyRound className='text-primary h-5 w-5' />
Key Information
</CardTitle>
<CardTitle className='text-xl'>Key Information</CardTitle>
<CardDescription>
Enter an API key to view its balance, consumption, and child keys.
</CardDescription>
@@ -202,10 +191,7 @@ export function KeyInfoDetails({
<div className='grid gap-4 md:grid-cols-2'>
<Card>
<CardHeader className='pb-2'>
<CardTitle className='flex items-center gap-2 text-lg'>
<ShieldCheck className='text-primary h-5 w-5' />
Status & Identity
</CardTitle>
<CardTitle className='text-lg'>Status & Identity</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex items-center justify-between'>
@@ -216,7 +202,7 @@ export function KeyInfoDetails({
</div>
{walletInfo.parentKey && (
<div className='space-y-1'>
<span className='text-muted-foreground text-xs tracking-wider uppercase'>
<span className='text-muted-foreground text-xs tracking-wider'>
Parent Key
</span>
<div className='flex items-center gap-2'>
@@ -255,10 +241,7 @@ export function KeyInfoDetails({
<Card>
<CardHeader className='pb-2'>
<CardTitle className='flex items-center gap-2 text-lg'>
<History className='text-primary h-5 w-5' />
Consumption
</CardTitle>
<CardTitle className='text-lg'>Consumption</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex items-center justify-between'>
@@ -313,8 +296,7 @@ export function KeyInfoDetails({
walletInfo.childKeys.length > 0 && (
<Card>
<CardHeader>
<CardTitle className='flex items-center gap-2 text-lg'>
<Users className='text-primary h-5 w-5' />
<CardTitle className='text-lg'>
Child Keys ({walletInfo.childKeys.length})
</CardTitle>
<CardDescription>
@@ -359,7 +341,7 @@ export function KeyInfoDetails({
</div>
<div className='grid grid-cols-2 gap-4 text-xs sm:grid-cols-5'>
<div>
<p className='text-muted-foreground text-[0.6rem] tracking-wider uppercase'>
<p className='text-muted-foreground text-[0.6rem] tracking-wider'>
Requests
</p>
<p className='font-mono font-medium'>
@@ -367,7 +349,7 @@ export function KeyInfoDetails({
</p>
</div>
<div>
<p className='text-muted-foreground text-[0.6rem] tracking-wider uppercase'>
<p className='text-muted-foreground text-[0.6rem] tracking-wider'>
Spent
</p>
<p className='font-mono font-medium'>
@@ -375,7 +357,7 @@ export function KeyInfoDetails({
</p>
</div>
<div>
<p className='text-muted-foreground text-[0.6rem] tracking-wider uppercase'>
<p className='text-muted-foreground text-[0.6rem] tracking-wider'>
Limit
</p>
<p className='font-mono font-medium'>
@@ -385,7 +367,7 @@ export function KeyInfoDetails({
</p>
</div>
<div>
<p className='text-muted-foreground text-[0.6rem] tracking-wider uppercase'>
<p className='text-muted-foreground text-[0.6rem] tracking-wider'>
Policy
</p>
<p className='font-medium capitalize'>
@@ -393,7 +375,7 @@ export function KeyInfoDetails({
</p>
</div>
<div>
<p className='text-muted-foreground text-[0.6rem] tracking-wider uppercase'>
<p className='text-muted-foreground text-[0.6rem] tracking-wider'>
Expires
</p>
<p className='font-medium'>
@@ -2,7 +2,7 @@
import { type JSX, useCallback, useState } from 'react';
import Image from 'next/image';
import { Copy, Zap, CheckCircle, Loader2 } from 'lucide-react';
import { Copy, CheckCircle, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import QRCode from 'qrcode';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Separator } from '@/components/ui/separator';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { KeyOptions } from '@/components/key-options';
import type { WalletSnapshot } from './key-info-details';
@@ -35,6 +36,24 @@ interface LightningPaymentWorkflowProps {
onApiKeyCreated?: (apiKey: string, walletInfo: WalletSnapshot) => void;
}
interface InvoiceDetailsCardProps {
label: string;
amountSats: number;
bolt11: string;
qrCode: string;
waiting: boolean;
helperText: string;
onCopy: () => void;
}
interface ApiKeyResultAlertProps {
title: string;
description: string;
apiKey: string;
onCopy: () => void;
onDismiss: () => void;
}
function formatSats(msats: number): string {
return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
}
@@ -56,6 +75,113 @@ async function generateQRCodeSVG(text: string): Promise<string> {
}
}
function InvoiceDetailsCard({
label,
amountSats,
bolt11,
qrCode,
waiting,
helperText,
onCopy,
}: InvoiceDetailsCardProps): JSX.Element {
return (
<Card className='bg-muted/30 shadow-none'>
<CardContent className='space-y-3 p-4'>
<div className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
<span>
{label} ({amountSats} sats)
</span>
<Button
variant='outline'
size='sm'
className='gap-1 text-xs'
onClick={onCopy}
>
<Copy className='h-3 w-3' />
Copy
</Button>
</div>
{waiting && (
<Alert>
<Loader2 className='h-4 w-4 animate-spin' />
<AlertDescription>Waiting for payment...</AlertDescription>
</Alert>
)}
{qrCode && (
<div className='flex justify-center'>
<Image
src={qrCode}
alt='QR Code'
className='h-48 w-48'
width={192}
height={192}
unoptimized
/>
</div>
)}
<Textarea
value={bolt11}
readOnly
rows={4}
className='font-mono text-xs'
/>
<p className='text-muted-foreground text-center text-xs'>
{helperText}
</p>
</CardContent>
</Card>
);
}
function ApiKeyResultAlert({
title,
description,
apiKey,
onCopy,
onDismiss,
}: ApiKeyResultAlertProps): JSX.Element {
return (
<Alert>
<CheckCircle className='h-4 w-4' />
<AlertTitle className='flex items-center justify-between gap-2'>
<span>{title}</span>
<Button
variant='outline'
size='sm'
className='gap-1 text-xs'
onClick={onCopy}
>
<Copy className='h-3 w-3' />
Copy
</Button>
</AlertTitle>
<AlertDescription className='space-y-3'>
<Textarea
value={apiKey}
readOnly
rows={2}
className='font-mono text-xs'
/>
<div className='flex items-center justify-between gap-2'>
<span>{description}</span>
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs'
onClick={onDismiss}
>
Dismiss
</Button>
</div>
</AlertDescription>
</Alert>
);
}
export function LightningPaymentWorkflow({
baseUrl,
onApiKeyCreated,
@@ -388,17 +514,14 @@ export function LightningPaymentWorkflow({
return (
<Card>
<CardHeader className='space-y-1'>
<CardTitle className='flex items-center gap-2 text-xl'>
<Zap className='text-primary h-5 w-5' />
Lightning Payment Workflow
</CardTitle>
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
<CardTitle className='text-xl'>Lightning Payment Workflow</CardTitle>
<p className='text-muted-foreground text-xs tracking-wide'>
Create and manage API keys using Lightning Network payments
</p>
</CardHeader>
<CardContent className='space-y-6'>
<section className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
<span>1 · Create key with Lightning</span>
{showCreateDetails && (
<span className='text-primary'>Amount specified</span>
@@ -434,95 +557,25 @@ export function LightningPaymentWorkflow({
</Button>
{createInvoice && (
<div className='bg-muted/30 space-y-3 rounded-lg border p-4'>
<div className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<span>
Lightning Invoice ({createInvoice.amount_sats} sats)
</span>
<Button
variant='outline'
size='sm'
className='gap-1 text-xs'
onClick={() => handleCopy(createInvoice.bolt11)}
>
<Copy className='h-3 w-3' />
Copy
</Button>
</div>
{isWaitingPayment && (
<div className='flex items-center justify-center gap-2 rounded-lg border border-orange-200 bg-orange-50 p-3 dark:border-orange-800 dark:bg-orange-950'>
<Loader2 className='h-4 w-4 animate-spin text-orange-600' />
<span className='text-sm font-medium text-orange-800 dark:text-orange-200'>
Waiting for payment...
</span>
</div>
)}
{createQRCode && (
<div className='flex justify-center'>
<Image
src={createQRCode}
alt='QR Code'
className='h-48 w-48'
width={192}
height={192}
unoptimized
/>
</div>
)}
<Textarea
value={createInvoice.bolt11}
readOnly
rows={4}
className='font-mono text-xs'
/>
<p className='text-muted-foreground text-center text-xs'>
Scan QR code or copy invoice. Payment will be detected
automatically.
</p>
</div>
<InvoiceDetailsCard
label='Lightning Invoice'
amountSats={createInvoice.amount_sats}
bolt11={createInvoice.bolt11}
qrCode={createQRCode}
waiting={isWaitingPayment}
helperText='Scan QR code or copy invoice. Payment will be detected automatically.'
onCopy={() => handleCopy(createInvoice.bolt11)}
/>
)}
{createdApiKey && (
<div className='space-y-3 rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-800 dark:bg-green-950'>
<div className='flex items-center justify-between text-[0.7rem] tracking-wider text-green-700 uppercase dark:text-green-300'>
<div className='flex items-center gap-2'>
<CheckCircle className='h-4 w-4' />
<span>API Key Created Successfully</span>
</div>
<Button
variant='outline'
size='sm'
className='gap-1 border-green-300 text-xs hover:bg-green-100 dark:border-green-700 dark:hover:bg-green-900'
onClick={() => handleCopy(createdApiKey)}
>
<Copy className='h-3 w-3' />
Copy
</Button>
</div>
<Textarea
value={createdApiKey}
readOnly
rows={2}
className='border-green-200 bg-white font-mono text-xs dark:border-green-800 dark:bg-green-950/50'
/>
<div className='flex items-center justify-between text-xs text-green-700 dark:text-green-300'>
<span>Your API key is ready to use!</span>
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs text-green-700 hover:text-green-800 dark:text-green-300 dark:hover:text-green-200'
onClick={() => setCreatedApiKey('')}
>
Dismiss
</Button>
</div>
</div>
<ApiKeyResultAlert
title='API Key Created Successfully'
description='Your API key is ready to use!'
apiKey={createdApiKey}
onCopy={() => handleCopy(createdApiKey)}
onDismiss={() => setCreatedApiKey('')}
/>
)}
</div>
</div>
@@ -531,7 +584,7 @@ export function LightningPaymentWorkflow({
<Separator />
<section className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
<span>2 · Top up existing key</span>
{showTopupDetails && (
<span className='text-primary'>Ready to create invoice</span>
@@ -567,93 +620,25 @@ export function LightningPaymentWorkflow({
</Button>
{topupInvoice && (
<div className='bg-muted/30 space-y-3 rounded-lg border p-4'>
<div className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<span>Topup Invoice ({topupInvoice.amount_sats} sats)</span>
<Button
variant='outline'
size='sm'
className='gap-1 text-xs'
onClick={() => handleCopy(topupInvoice.bolt11)}
>
<Copy className='h-3 w-3' />
Copy
</Button>
</div>
{topupQRCode && (
<div className='flex justify-center'>
<Image
src={topupQRCode}
alt='QR Code'
className='h-48 w-48'
width={192}
height={192}
unoptimized
/>
</div>
)}
<Textarea
value={topupInvoice.bolt11}
readOnly
rows={4}
className='font-mono text-xs'
/>
<p className='text-muted-foreground text-center text-xs'>
Scan QR code or copy invoice. Balance will be added
automatically.
</p>
{isWaitingTopupPayment && (
<div className='flex items-center justify-center gap-2 rounded-lg border border-orange-200 bg-orange-50 p-3 dark:border-orange-800 dark:bg-orange-950'>
<Loader2 className='h-4 w-4 animate-spin text-orange-600' />
<span className='text-sm font-medium text-orange-800 dark:text-orange-200'>
Waiting for payment...
</span>
</div>
)}
</div>
<InvoiceDetailsCard
label='Topup Invoice'
amountSats={topupInvoice.amount_sats}
bolt11={topupInvoice.bolt11}
qrCode={topupQRCode}
waiting={isWaitingTopupPayment}
helperText='Scan QR code or copy invoice. Balance will be added automatically.'
onCopy={() => handleCopy(topupInvoice.bolt11)}
/>
)}
{topupApiKeyResult && (
<div className='space-y-3 rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950'>
<div className='flex items-center justify-between text-[0.7rem] tracking-wider text-blue-700 uppercase dark:text-blue-300'>
<div className='flex items-center gap-2'>
<CheckCircle className='h-4 w-4' />
<span>Topup Successful</span>
</div>
<Button
variant='outline'
size='sm'
className='gap-1 border-blue-300 text-xs hover:bg-blue-100 dark:border-blue-700 dark:hover:bg-blue-900'
onClick={() => handleCopy(topupApiKeyResult)}
>
<Copy className='h-3 w-3' />
Copy
</Button>
</div>
<Textarea
value={topupApiKeyResult}
readOnly
rows={2}
className='border-blue-200 bg-white font-mono text-xs dark:border-blue-800 dark:bg-blue-950/50'
/>
<div className='flex items-center justify-between text-xs text-blue-700 dark:text-blue-300'>
<span>Balance has been added to your API key!</span>
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs text-blue-700 hover:text-blue-800 dark:text-blue-300 dark:hover:text-blue-200'
onClick={() => setTopupApiKeyResult('')}
>
Dismiss
</Button>
</div>
</div>
<ApiKeyResultAlert
title='Topup Successful'
description='Balance has been added to your API key!'
apiKey={topupApiKeyResult}
onCopy={() => handleCopy(topupApiKeyResult)}
onDismiss={() => setTopupApiKeyResult('')}
/>
)}
</div>
)}
@@ -662,7 +647,7 @@ export function LightningPaymentWorkflow({
<Separator />
<section className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
<span>3 · Recover from invoice</span>
{showRecoverDetails && (
<span className='text-primary'>Invoice provided</span>
@@ -693,42 +678,13 @@ export function LightningPaymentWorkflow({
)}
{recoveredApiKey && (
<div className='space-y-3 rounded-lg border border-purple-200 bg-purple-50 p-4 dark:border-purple-800 dark:bg-purple-950'>
<div className='flex items-center justify-between text-[0.7rem] tracking-wider text-purple-700 uppercase dark:text-purple-300'>
<div className='flex items-center gap-2'>
<CheckCircle className='h-4 w-4' />
<span>API Key Recovered</span>
</div>
<Button
variant='outline'
size='sm'
className='gap-1 border-purple-300 text-xs hover:bg-purple-100 dark:border-purple-700 dark:hover:bg-purple-900'
onClick={() => handleCopy(recoveredApiKey)}
>
<Copy className='h-3 w-3' />
Copy
</Button>
</div>
<Textarea
value={recoveredApiKey}
readOnly
rows={2}
className='border-purple-200 bg-white font-mono text-xs dark:border-purple-800 dark:bg-purple-950/50'
/>
<div className='flex items-center justify-between text-xs text-purple-700 dark:text-purple-300'>
<span>Your recovered API key is ready to use!</span>
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs text-purple-700 hover:text-purple-800 dark:text-purple-300 dark:hover:text-purple-200'
onClick={() => setRecoveredApiKey('')}
>
Dismiss
</Button>
</div>
</div>
<ApiKeyResultAlert
title='API Key Recovered'
description='Your recovered API key is ready to use!'
apiKey={recoveredApiKey}
onCopy={() => handleCopy(recoveredApiKey)}
onDismiss={() => setRecoveredApiKey('')}
/>
)}
</section>
</CardContent>
@@ -0,0 +1,63 @@
'use client';
import {
Card,
CardContent,
CardDescription,
CardHeader,
} from '@/components/ui/card';
interface WalletBalanceStatsProps {
balanceMsats?: number;
reservedMsats?: number;
}
function formatMsats(msats: number): string {
return new Intl.NumberFormat('en-US').format(msats);
}
function formatSats(msats: number): string {
return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
}
function WalletMetricCard({
label,
valueMsats,
}: {
label: string;
valueMsats?: number;
}) {
const hasValue = typeof valueMsats === 'number';
return (
<Card className='shadow-none'>
<CardHeader className='p-3 pb-2'>
<CardDescription className='text-[0.65rem] tracking-wide'>
{label}
</CardDescription>
</CardHeader>
<CardContent className='px-3 pt-0 pb-3'>
<p className='text-xl font-semibold tabular-nums'>
{hasValue ? `${formatSats(valueMsats)} sats` : '-'}
</p>
{hasValue && (
<p className='text-muted-foreground text-xs tabular-nums'>
{formatMsats(valueMsats)} msats
</p>
)}
</CardContent>
</Card>
);
}
export function WalletBalanceStats({
balanceMsats,
reservedMsats,
}: WalletBalanceStatsProps) {
return (
<div className='grid gap-3 sm:grid-cols-2'>
<WalletMetricCard label='Spendable' valueMsats={balanceMsats} />
<WalletMetricCard label='Reserved' valueMsats={reservedMsats} />
</div>
);
}
+419
View File
@@ -0,0 +1,419 @@
import type { Model } from '@/lib/api/schemas/models';
import type { DisplayUnit } from '@/lib/types/units';
import { formatUsdAmountForDisplayUnit } from '@/lib/currency';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
ArrowRight,
AudioLines,
Ban,
CheckCircle,
Edit3,
FileText,
ImageIcon,
Layers3,
MoreVertical,
Trash2,
Type,
Video,
Waypoints,
} from 'lucide-react';
interface ModelItemCardProps {
model: Model;
displayUnit: DisplayUnit;
usdPerSat: number | null;
isSelected: boolean;
hasEffectiveApiKey: boolean;
hasIndividualSettings: boolean;
onHoverStart: () => void;
onHoverEnd: () => void;
onToggleSelection: () => void;
onEdit: () => void;
onOverride: () => void;
onDisable: () => void;
onEnable: () => void;
onDelete: () => void;
}
type ModelModality =
| 'text'
| 'image'
| 'file'
| 'audio'
| 'video'
| 'embedding'
| 'multimodal';
const MODALITY_ORDER: ModelModality[] = [
'text',
'image',
'file',
'audio',
'video',
'embedding',
'multimodal',
];
function extractModalities(part: string): ModelModality[] {
const normalized = part.toLowerCase();
const modalities = MODALITY_ORDER.filter((modality) =>
normalized.includes(modality)
);
return modalities.length > 0 ? modalities : ['text'];
}
function getModelTypeParts(modelType: string): {
inputs: ModelModality[];
outputs: ModelModality[];
} {
const [inputPart, outputPart] = modelType
.split('->')
.map((part) => part.trim());
return {
inputs: extractModalities(inputPart || modelType),
outputs: outputPart ? extractModalities(outputPart) : [],
};
}
function ModelTypeIcons({ modelType }: { modelType: string }) {
const { inputs, outputs } = getModelTypeParts(modelType);
const renderIcon = (modality: ModelModality, index: number) => {
const props = {
className: 'h-3 w-3 shrink-0',
'aria-hidden': true as const,
};
switch (modality) {
case 'image':
return <ImageIcon key={`${modality}-${index}`} {...props} />;
case 'file':
return <FileText key={`${modality}-${index}`} {...props} />;
case 'audio':
return <AudioLines key={`${modality}-${index}`} {...props} />;
case 'video':
return <Video key={`${modality}-${index}`} {...props} />;
case 'embedding':
return <Waypoints key={`${modality}-${index}`} {...props} />;
case 'multimodal':
return <Layers3 key={`${modality}-${index}`} {...props} />;
case 'text':
default:
return <Type key={`${modality}-${index}`} {...props} />;
}
};
return (
<span
className='text-muted-foreground inline-flex max-w-full min-w-0 items-center gap-0.5 overflow-hidden'
title={modelType}
aria-label={modelType}
>
{inputs.map(renderIcon)}
{outputs.length > 0 && (
<>
<ArrowRight className='h-2.5 w-2.5 shrink-0 opacity-55' aria-hidden />
{outputs.map(renderIcon)}
</>
)}
<span className='sr-only'>{modelType}</span>
</span>
);
}
export function ModelItemCard({
model,
displayUnit,
usdPerSat,
isSelected,
hasEffectiveApiKey,
hasIndividualSettings,
onHoverStart,
onHoverEnd,
onToggleSelection,
onEdit,
onOverride,
onDisable,
onEnable,
onDelete,
}: ModelItemCardProps) {
const isDisabled = Boolean(model.soft_deleted);
const statusLabel = isDisabled ? 'Disabled' : 'Enabled';
const isFreeModel = model.is_free;
const statusDotClass = cn(
'inline-block h-2.5 w-2.5 shrink-0 rounded-full',
isDisabled
? 'bg-muted-foreground/50'
: 'bg-emerald-500 shadow-[0_0_0_3px_rgba(16,185,129,0.14)]'
);
const modelSourceLabel =
model.api_key_type === 'remote' ? 'Remote' : 'Database';
const keySourceLabel = !hasEffectiveApiKey
? 'No API key'
: hasIndividualSettings
? 'Individual key'
: 'Group key';
const inputPriceLabel = formatUsdAmountForDisplayUnit(
model.input_cost,
displayUnit,
usdPerSat
);
const outputPriceLabel = formatUsdAmountForDisplayUnit(
model.output_cost,
displayUnit,
usdPerSat
);
const pricingLabel = isFreeModel
? 'Free'
: `Input ${inputPriceLabel} · Output ${outputPriceLabel}`;
const renderActionsMenu = (className: string) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='icon-xs'
onClick={(event) => event.stopPropagation()}
className={className}
aria-label={`Model actions for ${model.name}`}
title={`Model actions for ${model.name}`}
>
<MoreVertical className='text-muted-foreground hover:text-foreground h-4 w-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-52'>
{model.api_key_type !== 'remote' && (
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation();
onEdit();
}}
>
<Edit3 className='mr-2 h-4 w-4' />
Edit Model
</DropdownMenuItem>
)}
{model.api_key_type === 'remote' && (
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation();
onOverride();
}}
>
<Edit3 className='mr-2 h-4 w-4' />
Override
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{model.soft_deleted ? (
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation();
onEnable();
}}
className='text-foreground'
>
<CheckCircle className='mr-2 h-4 w-4' />
Enable Model
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation();
onDisable();
}}
className='text-foreground'
>
<Ban className='mr-2 h-4 w-4' />
Disable Model
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
className='text-destructive focus:text-destructive'
>
<Trash2 className='mr-2 h-4 w-4' />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
return (
<Card
className={cn(
"after:bg-border/80 relative overflow-hidden rounded-none border-0 bg-transparent py-0 shadow-none ring-0 transition-colors duration-150 after:absolute after:right-3 after:bottom-0 after:left-3 after:h-px after:content-[''] last:after:hidden md:after:hidden",
'hover:bg-muted/25',
isSelected && 'bg-primary/12',
model.soft_deleted && 'bg-muted/20 opacity-80'
)}
onMouseEnter={onHoverStart}
onMouseLeave={onHoverEnd}
>
<div className='px-2 py-2 md:flex md:items-center md:gap-2 md:py-1'>
<div className='hidden md:flex md:items-center'>
<Checkbox
checked={isSelected}
onCheckedChange={onToggleSelection}
onClick={(event) => event.stopPropagation()}
className='border-border/90 data-checked:ring-primary/40 size-4 flex-shrink-0 data-checked:ring-2'
/>
</div>
<div className='min-w-0 flex-1 px-0.5 text-left'>
<div className='hidden min-w-0 items-center gap-x-3 gap-y-1 md:grid md:grid-cols-[minmax(210px,1.45fr)_112px_96px_120px] lg:grid-cols-[minmax(230px,1.65fr)_128px_96px_120px_minmax(220px,1fr)]'>
<div className='min-w-0'>
<div
className='flex min-w-0 items-center gap-2'
title={statusLabel}
aria-label={statusLabel}
>
<span className={statusDotClass} />
<h3
className={cn(
'min-w-0 truncate text-sm font-medium',
model.soft_deleted && 'text-muted-foreground'
)}
title={model.name}
>
{model.name}
</h3>
<span className='sr-only'>{statusLabel}</span>
</div>
</div>
<span
className='hidden min-w-0 overflow-hidden md:flex md:items-center'
title={model.modelType}
>
<ModelTypeIcons modelType={model.modelType} />
</span>
<span
className='text-muted-foreground hidden truncate text-xs md:block'
title={modelSourceLabel}
>
{modelSourceLabel}
</span>
<span
className={cn(
'text-muted-foreground hidden truncate text-xs md:block',
!hasEffectiveApiKey && 'text-destructive'
)}
title={keySourceLabel}
>
{keySourceLabel}
</span>
<span
className='text-muted-foreground hidden truncate text-right text-xs whitespace-nowrap lg:block'
title={pricingLabel}
>
{pricingLabel}
</span>
</div>
<div className='grid grid-cols-[16px_minmax(0,1fr)] gap-x-2.5 gap-y-2.5 md:hidden'>
<Checkbox
checked={isSelected}
onCheckedChange={onToggleSelection}
onClick={(event) => event.stopPropagation()}
className='border-border/90 data-checked:ring-primary/40 mt-0.5 size-4 flex-shrink-0 self-start data-checked:ring-2'
/>
<div className='grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-2'>
<div
className='flex min-w-0 items-center gap-2.5'
title={statusLabel}
aria-label={statusLabel}
>
<span className={statusDotClass} />
<h3
className={cn(
'line-clamp-2 min-w-0 flex-1 text-[15px] leading-5 font-medium',
model.soft_deleted && 'text-muted-foreground'
)}
title={model.name}
>
{model.name}
</h3>
<span className='sr-only'>{statusLabel}</span>
</div>
{renderActionsMenu(
'hover:bg-muted/50 dark:hover:bg-muted/80 -mr-1 h-7 w-7 shrink-0 self-start'
)}
</div>
<div aria-hidden />
<div className='text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-1 text-[12px]'>
<span
className='text-foreground/80 inline-flex items-center'
title={model.modelType}
>
<ModelTypeIcons modelType={model.modelType} />
</span>
<span className='text-border/80' aria-hidden>
·
</span>
<span className='inline-flex items-center'>
{modelSourceLabel}
</span>
<span
className={cn(
'inline-flex items-center',
!hasEffectiveApiKey && 'text-destructive'
)}
>
<span className='text-border/80 mr-2' aria-hidden>
·
</span>
{keySourceLabel}
</span>
</div>
<div aria-hidden />
{isFreeModel ? (
<div className='text-muted-foreground text-sm font-medium'>
Free
</div>
) : (
<div className='border-border/45 bg-background/20 grid grid-cols-2 overflow-hidden rounded-lg border'>
<div className='px-3 py-2.5'>
<div className='text-muted-foreground text-[11px]'>Input</div>
<div className='pt-0.5 text-[15px] font-medium tabular-nums'>
{inputPriceLabel}
</div>
</div>
<div className='border-border/45 border-l px-3 py-2.5'>
<div className='text-muted-foreground text-[11px]'>
Output
</div>
<div className='pt-0.5 text-[15px] font-medium tabular-nums'>
{outputPriceLabel}
</div>
</div>
</div>
)}
</div>
</div>
<div className='hidden md:block'>
{renderActionsMenu(
'hover:bg-muted/50 dark:hover:bg-muted/80 h-7 w-7 shrink-0 md:h-6 md:w-6'
)}
</div>
</div>
</Card>
);
}
+243
View File
@@ -0,0 +1,243 @@
import { useMemo } from 'react';
import type { Model } from '@/lib/api/schemas/models';
import type { AdminModelGroup } from '@/lib/api/services/admin';
import type { DisplayUnit } from '@/lib/types/units';
import { ModelItemCard } from '@/components/model-item-card';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
CheckSquare,
Edit3,
Globe,
Key,
MoreVertical,
RefreshCw,
} from 'lucide-react';
interface ModelProviderSectionProps {
provider: string;
providerModels: Model[];
displayUnit: DisplayUnit;
usdPerSat: number | null;
filterProvider?: string;
groupData?: AdminModelGroup;
selectedModels: Set<string>;
onSelectProviderModels: () => void;
onDeselectProviderModels: () => void;
onEditGroup: () => void;
onRefreshProviderModels: () => void;
onDeleteAllProviderModels: () => void;
onModelHover: (modelId: string | null) => void;
onModelToggleSelection: (modelId: string) => void;
onEditModel: (model: Model) => void;
onOverrideModel: (model: Model) => void;
onEnableModel: (modelId: string) => void;
onDisableModel: (modelId: string) => void;
onDeleteModel: (modelId: string) => void;
hasEffectiveApiKey: (model: Model) => boolean;
hasIndividualSettings: (model: Model) => boolean;
}
export function ModelProviderSection({
provider,
providerModels,
displayUnit,
usdPerSat,
filterProvider,
groupData,
selectedModels,
onSelectProviderModels,
onDeselectProviderModels,
onEditGroup,
onRefreshProviderModels,
onDeleteAllProviderModels,
onModelHover,
onModelToggleSelection,
onEditModel,
onOverrideModel,
onEnableModel,
onDisableModel,
onDeleteModel,
hasEffectiveApiKey,
hasIndividualSettings,
}: ModelProviderSectionProps) {
const allProviderSelected = providerModels.every((model) =>
selectedModels.has(model.id)
);
const someProviderSelected = providerModels.some((model) =>
selectedModels.has(model.id)
);
const keyedProviderModels = useMemo(() => {
const seenKeys = new Map<string, number>();
return providerModels.map((model) => {
const baseKey = [
provider,
model.id,
model.provider_id ?? '',
model.full_name,
model.api_key_type,
].join('::');
const seenCount = seenKeys.get(baseKey) ?? 0;
seenKeys.set(baseKey, seenCount + 1);
return {
model,
renderKey:
seenCount === 0 ? baseKey : `${baseKey}::duplicate-${seenCount}`,
};
});
}, [provider, providerModels]);
if (filterProvider) {
return (
<div className='bg-card/35 border-border/70 md:divide-border/75 overflow-hidden rounded-lg border md:divide-y'>
{keyedProviderModels.map(({ model, renderKey }) => (
<ModelItemCard
key={renderKey}
model={model}
displayUnit={displayUnit}
usdPerSat={usdPerSat}
isSelected={selectedModels.has(model.id)}
hasEffectiveApiKey={hasEffectiveApiKey(model)}
hasIndividualSettings={hasIndividualSettings(model)}
onHoverStart={() => onModelHover(model.id)}
onHoverEnd={() => onModelHover(null)}
onToggleSelection={() => onModelToggleSelection(model.id)}
onEdit={() => onEditModel(model)}
onOverride={() => onOverrideModel(model)}
onDisable={() => onDisableModel(model.id)}
onEnable={() => onEnableModel(model.id)}
onDelete={() => onDeleteModel(model.id)}
/>
))}
</div>
);
}
return (
<Card className='overflow-hidden'>
<CardHeader className='px-3 pb-2.5 sm:px-6 sm:pb-3'>
<div className='flex items-start justify-between gap-2 sm:gap-3'>
<div className='flex min-w-0 items-start gap-2.5 sm:gap-3'>
<Checkbox
checked={
allProviderSelected
? true
: someProviderSelected
? 'indeterminate'
: false
}
onCheckedChange={(checked) => {
if (checked === true) {
onSelectProviderModels();
return;
}
onDeselectProviderModels();
}}
className='border-border/90 data-checked:ring-primary/35 mt-0.5 size-4 data-checked:ring-2 sm:mt-1 sm:size-5'
aria-label={`Select models for provider ${provider}`}
/>
<div className='min-w-0'>
<CardTitle className='flex items-baseline gap-1.5 text-base sm:text-lg'>
<span className='truncate'>{provider}</span>
<span className='text-muted-foreground text-sm font-normal'>
({providerModels.length} models)
</span>
</CardTitle>
<CardDescription className='mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs sm:text-sm'>
{groupData?.group_url ? (
<span className='inline-flex items-center gap-1 break-all'>
<Globe className='h-3 w-3' />
{groupData.group_url}
</span>
) : (
'Using default endpoint'
)}
{groupData?.group_api_key && (
<span className='flex items-center gap-1'>
<Key className='h-3 w-3' />
Group API Key
</span>
)}
</CardDescription>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='icon'
className='h-7 w-7 sm:h-8 sm:w-8'
aria-label={`Provider actions for ${provider}`}
title={`Provider actions for ${provider}`}
>
<MoreVertical className='h-4 w-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-64 sm:w-72'>
<DropdownMenuItem onClick={onEditGroup}>
<Edit3 className='mr-2 h-4 w-4' />
Edit Group
</DropdownMenuItem>
<DropdownMenuItem onClick={onSelectProviderModels}>
<CheckSquare className='mr-2 h-4 w-4' />
Select All
</DropdownMenuItem>
<DropdownMenuItem onClick={onRefreshProviderModels}>
<RefreshCw className='mr-2 h-4 w-4' />
Refresh Models
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={onDeleteAllProviderModels}
className='text-muted-foreground focus:text-foreground'
>
Delete all overrides
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent className='px-3 pt-0 pb-3 sm:px-6 sm:pb-6'>
<div className='bg-card/35 border-border/70 md:divide-border/75 overflow-hidden rounded-lg border md:divide-y'>
{keyedProviderModels.map(({ model, renderKey }) => (
<ModelItemCard
key={renderKey}
model={model}
displayUnit={displayUnit}
usdPerSat={usdPerSat}
isSelected={selectedModels.has(model.id)}
hasEffectiveApiKey={hasEffectiveApiKey(model)}
hasIndividualSettings={hasIndividualSettings(model)}
onHoverStart={() => onModelHover(model.id)}
onHoverEnd={() => onModelHover(null)}
onToggleSelection={() => onModelToggleSelection(model.id)}
onEdit={() => onEditModel(model)}
onOverride={() => onOverrideModel(model)}
onDisable={() => onDisableModel(model.id)}
onEnable={() => onEnableModel(model.id)}
onDelete={() => onDeleteModel(model.id)}
/>
))}
</div>
</CardContent>
</Card>
);
}
@@ -2,6 +2,7 @@
import React, { useState, useMemo } from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
@@ -97,17 +98,22 @@ export function ModelSearchFilter({
const hasActiveFilters =
searchQuery.trim() !== '' || sortOption !== 'name-asc';
const searchInputId = 'model-search-filter-input';
return (
<div
className={cn(
'flex flex-col gap-4 sm:flex-row sm:items-center sm:gap-3',
'flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center',
className
)}
>
<div className='relative flex-1'>
<div className='relative w-full min-w-0 flex-1'>
<Label htmlFor={searchInputId} className='sr-only'>
Search models
</Label>
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
id={searchInputId}
placeholder='Search models by name, provider, or description...'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
@@ -115,32 +121,36 @@ export function ModelSearchFilter({
/>
</div>
<div className='flex items-center gap-2'>
<Select
value={sortOption}
onValueChange={(value: SortOption) => setSortOption(value)}
>
<SelectTrigger className='w-full sm:w-[180px]'>
<Filter className='mr-2 h-4 w-4' />
<SelectValue placeholder='Sort by' />
</SelectTrigger>
<SelectContent>
<SelectItem value='name-asc'>Name (A-Z)</SelectItem>
<SelectItem value='name-desc'>Name (Z-A)</SelectItem>
<SelectItem value='price-asc'>Price (Low to High)</SelectItem>
<SelectItem value='price-desc'>Price (High to Low)</SelectItem>
</SelectContent>
</Select>
<div className='flex items-center gap-2 sm:shrink-0'>
<div className='min-w-0 flex-1 sm:flex-none'>
<Select
value={sortOption}
onValueChange={(value: SortOption) => setSortOption(value)}
>
<SelectTrigger className='h-8 w-full sm:w-[170px]'>
<Filter className='mr-2 h-4 w-4' />
<SelectValue placeholder='Sort by' />
</SelectTrigger>
<SelectContent>
<SelectItem value='name-asc'>Name (A-Z)</SelectItem>
<SelectItem value='name-desc'>Name (Z-A)</SelectItem>
<SelectItem value='price-asc'>Price (Low to High)</SelectItem>
<SelectItem value='price-desc'>Price (High to Low)</SelectItem>
</SelectContent>
</Select>
</div>
{hasActiveFilters && (
<Button
variant='outline'
size='icon'
variant='ghost'
size='sm'
onClick={clearFilters}
className='h-9 w-9'
className='h-8 shrink-0 px-2 sm:size-8 sm:px-0'
aria-label='Clear model filters'
title='Clear filters'
>
<X className='h-4 w-4' />
<X className='hidden h-4 w-4 sm:block' />
<span className='text-xs sm:hidden'>Clear</span>
</Button>
)}
</div>
File diff suppressed because it is too large Load Diff
@@ -6,6 +6,7 @@ import { type Model } from '@/lib/api/schemas/models';
import { ModelService } from '@/lib/api/services/models';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Card,
@@ -131,13 +132,10 @@ export function ModelTester({ models }: ModelTesterProps) {
setResponse(null);
try {
console.log(`Testing model via proxy: ${selectedModel.name}`);
console.log('Request payload:', request);
const response = await ModelService.testModel(
selectedModel.id,
'chat-completions',
request as unknown as Record<string, unknown>
request
);
if (!response.success) {
@@ -205,10 +203,7 @@ export function ModelTester({ models }: ModelTesterProps) {
return (
<Card className='w-full'>
<CardHeader>
<CardTitle className='flex items-center gap-2'>
<Send className='h-5 w-5' />
Model Credential Tester
</CardTitle>
<CardTitle>Model Credential Tester</CardTitle>
<CardDescription>
Test model functionality by sending chat completion requests through
the secure proxy (resolves CORS and network issues)
@@ -245,7 +240,7 @@ export function ModelTester({ models }: ModelTesterProps) {
<div className='text-muted-foreground bg-muted space-y-2 rounded-md p-3 text-sm'>
<div className='flex items-center gap-2'>
<Globe className='h-4 w-4' />
<span>
<span className='break-all'>
<strong>Endpoint:</strong> {credentials.endpointUrl}
</span>
</div>
@@ -301,19 +296,19 @@ export function ModelTester({ models }: ModelTesterProps) {
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='max-tokens'>Max Tokens</Label>
<input
<Input
id='max-tokens'
type='number'
min={1}
max={4000}
value={maxTokens}
onChange={(e) => setMaxTokens(parseInt(e.target.value) || 150)}
className='border-input bg-background w-full rounded-md border px-3 py-2 text-sm'
name='max_tokens'
/>
</div>
<div className='space-y-2'>
<Label htmlFor='temperature'>Temperature</Label>
<input
<Input
id='temperature'
type='number'
min={0}
@@ -323,7 +318,7 @@ export function ModelTester({ models }: ModelTesterProps) {
onChange={(e) =>
setTemperature(parseFloat(e.target.value) || 0.7)
}
className='border-input bg-background w-full rounded-md border px-3 py-2 text-sm'
name='temperature'
/>
</div>
</div>
@@ -406,7 +401,7 @@ export function ModelTester({ models }: ModelTesterProps) {
{response.usage && (
<div className='space-y-2'>
<Label>Usage Statistics</Label>
<div className='grid grid-cols-3 gap-4 text-sm'>
<div className='grid grid-cols-1 gap-2 text-sm sm:grid-cols-3 sm:gap-4'>
<div className='bg-muted rounded p-2 text-center'>
<div className='font-semibold'>
{response.usage.prompt_tokens}
+16 -3
View File
@@ -2,6 +2,7 @@
import Link from 'next/link';
import { type LucideIcon } from 'lucide-react';
import { usePathname } from 'next/navigation';
import {
SidebarGroup,
@@ -20,13 +21,25 @@ export function NavMain({
icon: LucideIcon;
}[];
}) {
const pathname = usePathname();
return (
<SidebarGroup>
<SidebarGroup className='px-0'>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenu className='gap-1.5'>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton tooltip={item.title} asChild>
<SidebarMenuButton
tooltip={item.title}
asChild
className='h-11 rounded-xl px-3 text-[0.95rem]'
isActive={
item.url === '/'
? pathname === '/'
: pathname === item.url ||
pathname.startsWith(`${item.url}/`)
}
>
<Link href={item.url}>
<item.icon />
<span>{item.title}</span>
+18 -3
View File
@@ -3,6 +3,7 @@
import * as React from 'react';
import Link from 'next/link';
import { LucideIcon } from 'lucide-react';
import { usePathname } from 'next/navigation';
import {
SidebarGroup,
@@ -11,9 +12,11 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { cn } from '@/lib/utils';
export function NavSecondary({
items,
className,
...props
}: {
items: {
@@ -22,13 +25,25 @@ export function NavSecondary({
icon: LucideIcon;
}[];
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
const pathname = usePathname();
return (
<SidebarGroup {...props}>
<SidebarGroup className={cn('px-0', className)} {...props}>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenu className='gap-1.5'>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild tooltip={item.title}>
<SidebarMenuButton
asChild
tooltip={item.title}
className='h-11 rounded-xl px-3 text-[0.95rem]'
isActive={
item.url === '/'
? pathname === '/'
: pathname === item.url ||
pathname.startsWith(`${item.url}/`)
}
>
<Link href={item.url}>
<item.icon />
<span>{item.title}</span>
+1 -1
View File
@@ -24,7 +24,7 @@ import {
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar';
import { useAuth } from '@/lib/auth/AuthContext';
import { useAuth } from '@/lib/auth/auth-context';
import { useRouter } from 'next/navigation';
type NavUserProps = {
+50
View File
@@ -0,0 +1,50 @@
'use client';
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
interface PageHeaderProps {
title: string;
description?: string;
actions?: ReactNode;
icon?: ReactNode;
className?: string;
}
export function PageHeader({
title,
description,
actions,
icon,
className,
}: PageHeaderProps) {
return (
<div
className={cn(
'flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between',
className
)}
>
<div className='min-w-0 space-y-1'>
<div className='flex items-center gap-2'>
{icon ? (
<span className='text-muted-foreground shrink-0'>{icon}</span>
) : null}
<h2 className='text-xl font-semibold tracking-tight sm:text-2xl'>
{title}
</h2>
</div>
{description ? (
<p className='text-muted-foreground text-sm leading-relaxed'>
{description}
</p>
) : null}
</div>
{actions ? (
<div className='flex w-full shrink-0 flex-wrap items-center gap-2 sm:w-auto sm:justify-end [&>*]:w-full sm:[&>*]:w-auto'>
{actions}
</div>
) : null}
</div>
);
}
+349
View File
@@ -0,0 +1,349 @@
'use client';
import Image from 'next/image';
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { CheckCircle2 } from 'lucide-react';
import { toast } from 'sonner';
import { AdminService } from '@/lib/api/services/admin';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
interface ProviderBalanceProps {
providerId: number;
platformUrl?: string | null;
isRoutstr?: boolean;
nodeUrl?: string;
}
export function ProviderBalance({
providerId,
platformUrl,
isRoutstr = false,
nodeUrl,
}: ProviderBalanceProps) {
const [isTopupDialogOpen, setIsTopupDialogOpen] = useState(false);
const [topupAmount, setTopupAmount] = useState('');
const [topupError, setTopupError] = useState('');
const [isHovered, setIsHovered] = useState(false);
const [invoiceData, setInvoiceData] = useState<{
payment_request: string;
invoice_id: string;
} | null>(null);
const [paymentStatus, setPaymentStatus] = useState<'pending' | 'paid' | null>(
null
);
const queryClient = useQueryClient();
const {
data: balanceData,
isLoading,
error,
} = useQuery({
queryKey: ['provider-balance', providerId],
queryFn: () => AdminService.getProviderBalance(providerId),
refetchInterval: 30000,
refetchOnWindowFocus: true,
retry: 1,
});
const { data: statusData } = useQuery({
queryKey: ['topup-status', providerId, invoiceData?.invoice_id],
queryFn: () =>
AdminService.checkTopupStatus(providerId, invoiceData!.invoice_id),
enabled: !!invoiceData && paymentStatus === 'pending',
refetchInterval: 2000,
});
useEffect(() => {
if (statusData?.paid === true) {
setPaymentStatus('paid');
queryClient.invalidateQueries({
queryKey: ['provider-balance', providerId],
});
toast.success('Payment received!', {
description: 'Your balance has been updated.',
});
}
}, [statusData, queryClient, providerId]);
const topupMutation = useMutation({
mutationFn: async (amount: number) => {
const result = await AdminService.initiateProviderTopup(
providerId,
amount
);
return result;
},
onSuccess: (data) => {
if (data?.topup_data?.payment_request && data?.topup_data?.invoice_id) {
setInvoiceData({
payment_request: data.topup_data.payment_request as string,
invoice_id: data.topup_data.invoice_id as string,
});
setPaymentStatus('pending');
} else {
toast.error('No invoice returned from provider');
setIsTopupDialogOpen(false);
}
},
onError: (error: Error) => {
toast.error(`Failed to initiate top-up: ${error.message}`);
},
});
const handleTopup = () => {
const amount = Number(topupAmount);
if (Number.isNaN(amount)) {
setTopupError(
isRoutstr
? 'Please enter a valid amount in sats'
: 'Please enter a valid amount'
);
return;
}
if (isRoutstr) {
if (!Number.isInteger(amount) || amount < 1) {
setTopupError('Amount must be a whole number of sats');
return;
}
} else if (amount < 1 || amount > 500) {
setTopupError('Amount must be between $1 and $500');
return;
}
topupMutation.mutate(amount);
};
const handleTopUpClick = () => {
if (
platformUrl &&
(platformUrl.includes('openrouter.ai') ||
platformUrl.includes('openai.com'))
) {
window.open(platformUrl, '_blank');
return;
}
setIsTopupDialogOpen(true);
};
const handleCloseDialog = () => {
setIsTopupDialogOpen(false);
setTopupAmount('');
setTopupError('');
setInvoiceData(null);
setPaymentStatus(null);
};
if (isLoading) {
return <Skeleton className='h-9 w-24' />;
}
if (
error ||
!balanceData?.ok ||
balanceData.balance_data === undefined ||
balanceData.balance_data === null
) {
return null;
}
const balance = balanceData.balance_data;
let displayValue = 'N/A';
if (typeof balance === 'number') {
displayValue = isRoutstr
? `${balance.toLocaleString()} sats`
: `$${balance.toFixed(2)}`;
} else if (balance && typeof balance === 'object') {
const b = balance as Record<string, unknown>;
if (typeof b.balance === 'number') {
displayValue = isRoutstr
? `${b.balance.toLocaleString()} sats`
: `$${b.balance.toFixed(2)}`;
} else if (typeof b.balance === 'string') {
displayValue = b.balance;
} else if (b.amount !== undefined) {
const amount = Number(b.amount);
if (!Number.isNaN(amount)) {
displayValue = isRoutstr
? `${amount.toLocaleString()} sats`
: `$${amount.toFixed(2)}`;
}
}
}
return (
<>
<Button
variant='outline'
size='sm'
onClick={handleTopUpClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className='w-full sm:w-auto'
>
<span className={isHovered ? undefined : 'font-mono'}>
{isHovered ? 'Top Up' : displayValue}
</span>
</Button>
<Dialog open={isTopupDialogOpen} onOpenChange={handleCloseDialog}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>
{paymentStatus === 'paid'
? 'Payment Confirmed!'
: 'Top Up Balance'}
</DialogTitle>
<DialogDescription>
{paymentStatus === 'paid'
? 'Your account balance has been updated.'
: invoiceData
? 'Scan the QR code or copy the Lightning invoice to pay.'
: isRoutstr
? `Top up your balance on node ${nodeUrl || ''}`.trim()
: 'Enter the amount you want to add to your account balance.'}
</DialogDescription>
</DialogHeader>
{paymentStatus === 'paid' ? (
<div className='flex flex-col items-center gap-4 py-6'>
<Badge className='gap-1.5 px-3 py-1'>
<CheckCircle2 className='h-4 w-4' />
Top-up Successful
</Badge>
<p className='text-muted-foreground text-center text-sm'>
Your provider balance has been updated.
</p>
</div>
) : invoiceData ? (
<div className='flex flex-col items-center gap-4 py-4'>
<div className='rounded-lg border p-2'>
<Image
src={`https://api.qrserver.com/v1/create-qr-code/?size=256x256&data=${encodeURIComponent(
invoiceData.payment_request
)}`}
alt='Lightning invoice QR code'
width={256}
height={256}
className='h-56 w-56 sm:h-64 sm:w-64'
unoptimized
/>
</div>
<div className='w-full space-y-2'>
<Label htmlFor='invoice'>Lightning Invoice</Label>
<div className='flex flex-col gap-2 sm:flex-row'>
<Input
id='invoice'
value={invoiceData.payment_request}
readOnly
className='font-mono text-xs'
/>
<Button
size='sm'
variant='outline'
onClick={() => {
navigator.clipboard.writeText(
invoiceData.payment_request
);
toast.success('Invoice copied to clipboard!');
}}
className='w-full sm:w-auto'
>
Copy
</Button>
</div>
</div>
{paymentStatus === 'pending' && (
<p className='text-muted-foreground text-center text-sm'>
Waiting for payment...
</p>
)}
</div>
) : (
<div className='grid gap-4 py-4'>
<div className='grid gap-2'>
<Label htmlFor='topup_amount'>
{isRoutstr ? 'Amount (sats)' : 'Amount (USD)'}
</Label>
<Input
id='topup_amount'
type='number'
placeholder={
isRoutstr ? 'Enter amount in sats' : 'Enter amount (1-500)'
}
value={topupAmount}
onChange={(e) => {
setTopupAmount(e.target.value);
setTopupError('');
}}
min='1'
max={isRoutstr ? undefined : '500'}
step={isRoutstr ? '1' : '0.01'}
/>
{isRoutstr && (
<p className='text-muted-foreground text-sm'>
The invoice amount will be created in sats.
</p>
)}
{topupError && (
<p className='text-destructive text-sm'>{topupError}</p>
)}
</div>
</div>
)}
<DialogFooter>
{paymentStatus === 'paid' ? (
<Button onClick={handleCloseDialog} className='w-full'>
Done
</Button>
) : invoiceData ? (
<Button
variant='outline'
onClick={handleCloseDialog}
className='w-full'
>
Cancel
</Button>
) : (
<>
<Button
variant='outline'
onClick={handleCloseDialog}
className='w-full sm:w-auto'
>
Cancel
</Button>
<Button
onClick={handleTopup}
disabled={topupMutation.isPending || !topupAmount}
className='w-full sm:w-auto'
>
{topupMutation.isPending
? 'Processing...'
: 'Generate Invoice'}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
+292
View File
@@ -0,0 +1,292 @@
import type {
AdminModel,
ProviderModels,
UpstreamProvider,
} from '@/lib/api/services/admin';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
ChevronDown,
ChevronUp,
Database,
Pencil,
Trash2,
Key,
RotateCcw,
} from 'lucide-react';
import { ProviderBalance } from '@/components/provider-balance';
import { ProviderModelsPanel } from '@/components/provider-models-panel';
import { RoutstrCreateKeySection } from '@/components/providers/RoutstrCreateKeySection';
import { RoutstrProviderService } from '@/lib/api/services/routstr-provider';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
interface ProviderCardProps {
provider: UpstreamProvider;
isExpanded: boolean;
canShowBalance: boolean;
platformUrl: string | null;
isModelsLoading: boolean;
providerModels: ProviderModels | null;
isDeletingModel: boolean;
onToggleExpansion: () => void;
onEditProvider: () => void;
onDeleteProvider: () => void;
onBatchOverride: () => void;
onAddModel: () => void;
onEditModel: (model: AdminModel) => void;
onDeleteModel: (modelId: string) => void;
onOverrideModel: (model: AdminModel) => void;
onUpdateApiKey: (newKey: string) => void;
availableMints: string[];
}
export function ProviderCard({
provider,
isExpanded,
canShowBalance,
platformUrl,
isModelsLoading,
providerModels,
isDeletingModel,
onToggleExpansion,
onEditProvider,
onDeleteProvider,
onBatchOverride,
onAddModel,
onEditModel,
onDeleteModel,
onOverrideModel,
onUpdateApiKey,
}: ProviderCardProps) {
const queryClient = useQueryClient();
const [isKeyModalOpen, setIsKeyModalOpen] = useState(false);
const hasDetails = Boolean(provider.api_version) || isExpanded;
const isRoutstr = provider.provider_type === 'routstr';
const refundMutation = useMutation({
mutationFn: () => RoutstrProviderService.refundBalance(provider.id),
onSuccess: (data) => {
if (data.ok) {
toast.success('Refund successful', { description: data.message });
queryClient.invalidateQueries({
queryKey: ['provider-balance', provider.id],
});
queryClient.invalidateQueries({ queryKey: ['balances'] });
} else {
toast.error('Refund failed', { description: data.message });
}
},
onError: (error: Error) => {
toast.error(`Refund error: ${error.message}`);
},
});
return (
<Card>
<CardHeader>
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
<div className='min-w-0 flex-1'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center'>
<CardTitle className='truncate'>
{provider.provider_type}
</CardTitle>
<Badge
variant={provider.enabled ? 'default' : 'secondary'}
className='w-fit'
>
{provider.enabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<CardDescription className='break-all'>
{provider.base_url}
</CardDescription>
</div>
<div className='grid w-full grid-cols-2 gap-2 sm:flex sm:w-auto sm:flex-wrap sm:items-center sm:justify-end'>
{canShowBalance && provider.api_key && (
<div
className={cn(
'col-span-2 sm:col-auto',
provider.provider_type === 'routstr' && 'col-span-1'
)}
>
<ProviderBalance
providerId={provider.id}
platformUrl={platformUrl}
isRoutstr={provider.provider_type === 'routstr'}
nodeUrl={provider.base_url}
/>
</div>
)}
{isRoutstr && (
<Button
variant='outline'
size='sm'
onClick={() => setIsKeyModalOpen(true)}
className={cn(
'justify-center gap-1.5',
canShowBalance && provider.api_key
? 'col-span-1'
: 'col-span-2 sm:col-auto'
)}
>
<Key className='h-4 w-4' />
<span>New Key</span>
</Button>
)}
{isRoutstr && provider.api_key && (
<Button
variant='outline'
size='sm'
onClick={() => refundMutation.mutate()}
disabled={refundMutation.isPending}
className='justify-center gap-1.5 text-orange-600 hover:text-orange-700 dark:text-orange-400'
title='Refund balance to local wallet'
>
<RotateCcw
className={cn(
'h-4 w-4',
refundMutation.isPending && 'animate-spin'
)}
/>
<span>Refund</span>
</Button>
)}
<Button
variant='outline'
size='sm'
onClick={onToggleExpansion}
className='col-span-2 justify-between sm:col-auto sm:justify-center'
>
<span className='inline-flex items-center gap-1.5'>
<Database className='h-4 w-4' />
<span>Models</span>
</span>
{isExpanded ? (
<ChevronUp className='h-4 w-4' />
) : (
<ChevronDown className='h-4 w-4' />
)}
</Button>
<Button
variant='outline'
size='sm'
onClick={onEditProvider}
className='justify-center gap-1.5'
>
<Pencil className='h-4 w-4' />
<span>Edit</span>
</Button>
<Button
variant='outline'
size='sm'
onClick={onDeleteProvider}
className='text-destructive hover:text-destructive justify-center gap-1.5'
>
<Trash2 className='h-4 w-4' />
<span>Delete</span>
</Button>
</div>
</div>
</CardHeader>
<Dialog open={isKeyModalOpen} onOpenChange={setIsKeyModalOpen}>
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle>
{provider.api_key
? 'Create New Key on Upstream Node'
: 'Create API Key'}
</DialogTitle>
<DialogDescription>
{provider.api_key
? 'Create a new API key on the upstream node. The remaining balance on the current key will be automatically refunded to your local wallet before it is replaced.'
: 'Create an API key on the upstream Routstr node to enable balance, top-up, and refund operations.'}
</DialogDescription>
</DialogHeader>
<div className='py-4'>
<RoutstrCreateKeySection
baseUrl={provider.base_url || ''}
onApiKeyCreated={async (newApiKey) => {
if (provider.api_key) {
try {
const result = await RoutstrProviderService.refundBalance(
provider.id
);
if (result.ok) {
toast.success('Old key refunded', {
description: result.message,
});
} else {
toast.warning('Refund skipped', {
description: result.message,
});
}
} catch (error) {
toast.warning(
`Could not refund old key: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
onUpdateApiKey(newApiKey);
setIsKeyModalOpen(false);
}}
/>
</div>
</DialogContent>
</Dialog>
{hasDetails ? (
<CardContent>
<div className='space-y-3'>
{provider.api_version && (
<div className='flex flex-col gap-1 text-sm sm:flex-row sm:items-center sm:justify-between'>
<span className='text-muted-foreground'>API Version:</span>
<span className='font-mono break-all'>
{provider.api_version}
</span>
</div>
)}
{isExpanded && (
<div className='mt-3 border-t pt-3'>
<ProviderModelsPanel
isLoading={isModelsLoading}
providerModels={providerModels}
onBatchOverride={onBatchOverride}
onAddModel={onAddModel}
onEditModel={onEditModel}
onDeleteModel={onDeleteModel}
onOverrideModel={onOverrideModel}
isDeletingModel={isDeletingModel}
/>
</div>
)}
</div>
</CardContent>
) : null}
</Card>
);
}
@@ -0,0 +1,91 @@
import type { Dispatch, SetStateAction } from 'react';
import type {
CreateUpstreamProvider,
ProviderType,
} from '@/lib/api/services/admin';
import { Button } from '@/components/ui/button';
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ProviderFormFields } from '@/components/provider-form-fields';
interface ProviderFormDialogContentProps {
mode: 'create' | 'edit';
title: string;
description: string;
submitLabel: string;
submittingLabel: string;
formData: CreateUpstreamProvider;
setFormData: Dispatch<SetStateAction<CreateUpstreamProvider>>;
providerTypes: ProviderType[];
providerFeePlaceholder: string;
docsLinkClassName: string;
canCreateAccount: boolean;
isCreatingAccount: boolean;
onCreateAccount: () => void;
onCancel: () => void;
onSubmit: () => void;
isSubmitting: boolean;
availableMints: string[];
}
export function ProviderFormDialogContent({
mode,
title,
description,
submitLabel,
submittingLabel,
formData,
setFormData,
providerTypes,
providerFeePlaceholder,
docsLinkClassName,
canCreateAccount,
isCreatingAccount,
onCreateAccount,
onCancel,
onSubmit,
isSubmitting,
availableMints,
}: ProviderFormDialogContentProps) {
return (
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<ProviderFormFields
mode={mode}
formData={formData}
setFormData={setFormData}
providerTypes={providerTypes}
providerFeePlaceholder={providerFeePlaceholder}
docsLinkClassName={docsLinkClassName}
canCreateAccount={canCreateAccount}
isCreatingAccount={isCreatingAccount}
onCreateAccount={onCreateAccount}
availableMints={availableMints}
/>
<DialogFooter>
<Button
variant='outline'
onClick={onCancel}
className='w-full sm:w-auto'
>
Cancel
</Button>
<Button
onClick={onSubmit}
disabled={isSubmitting}
className='w-full sm:w-auto'
>
{isSubmitting ? submittingLabel : submitLabel}
</Button>
</DialogFooter>
</DialogContent>
);
}
+241
View File
@@ -0,0 +1,241 @@
'use client';
import type { Dispatch, SetStateAction } from 'react';
import type {
CreateUpstreamProvider,
ProviderType,
} from '@/lib/api/services/admin';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { RoutstrNodeSettings } from '@/components/providers/RoutstrNodeSettings';
import { RoutstrCreateKeySection } from '@/components/providers/RoutstrCreateKeySection';
interface ProviderFormFieldsProps {
mode: 'create' | 'edit';
formData: CreateUpstreamProvider;
setFormData: Dispatch<SetStateAction<CreateUpstreamProvider>>;
providerTypes: ProviderType[];
providerFeePlaceholder: string;
docsLinkClassName: string;
canCreateAccount: boolean;
isCreatingAccount: boolean;
onCreateAccount: () => void;
availableMints: string[];
}
export function ProviderFormFields({
mode,
formData,
setFormData,
providerTypes,
providerFeePlaceholder,
docsLinkClassName,
canCreateAccount,
isCreatingAccount,
onCreateAccount,
availableMints,
}: ProviderFormFieldsProps) {
const idPrefix = mode === 'edit' ? 'edit_' : '';
const providerType = providerTypes.find(
(pt) => pt.id === formData.provider_type
);
const hasFixedBaseUrl = providerType?.fixed_base_url || false;
const platformUrl = providerType?.platform_url || null;
const getDefaultBaseUrl = (type: string) => {
const selectedType = providerTypes.find((pt) => pt.id === type);
return selectedType?.default_base_url || '';
};
const isGenericType = (type: ProviderType) =>
type.id.toLowerCase() === 'generic';
const nonGenericTypes = providerTypes.filter((type) => !isGenericType(type));
const genericType = providerTypes.find((type) => isGenericType(type));
const apiKeyLabel =
mode === 'edit' ? 'API Key (leave blank to keep current)' : 'API Key';
const apiKeyPlaceholder =
mode === 'edit' ? 'Leave blank to keep current' : 'sk-...';
return (
<div className='grid gap-4 py-4'>
<div className='grid gap-2'>
<Label htmlFor={`${idPrefix}provider_type`}>Provider Type</Label>
<Select
value={formData.provider_type}
onValueChange={(value) => {
setFormData((prev) => ({
...prev,
provider_type: value,
base_url: getDefaultBaseUrl(value),
provider_fee: value === 'openrouter' ? 1.06 : 1.01,
}));
}}
>
<SelectTrigger id={`${idPrefix}provider_type`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{nonGenericTypes.map((type) => (
<SelectItem key={type.id} value={type.id}>
{type.name}
</SelectItem>
))}
{genericType ? (
<>
{nonGenericTypes.length > 0 ? (
<SelectSeparator className='my-1.5 opacity-70' />
) : null}
<SelectItem value={genericType.id} className='font-medium'>
Custom
</SelectItem>
</>
) : null}
</SelectContent>
</Select>
</div>
{formData.provider_type === 'routstr' && (
<RoutstrNodeSettings
settings={formData.provider_settings || {}}
onSettingsChange={(settings) =>
setFormData((prev) => ({
...prev,
provider_settings: settings,
}))
}
availableMints={availableMints}
idPrefix={mode === 'edit' ? 'edit' : ''}
/>
)}
<div className='grid gap-2'>
<Label htmlFor={`${idPrefix}base_url`}>Base URL</Label>
<Input
id={`${idPrefix}base_url`}
value={formData.base_url}
onChange={(e) =>
setFormData((prev) => ({ ...prev, base_url: e.target.value }))
}
placeholder='https://api.example.com/v1'
disabled={hasFixedBaseUrl}
className={hasFixedBaseUrl ? 'cursor-not-allowed opacity-60' : ''}
/>
</div>
{formData.provider_type !== 'routstr' && (
<div className='grid gap-2'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<Label htmlFor={`${idPrefix}api_key`}>{apiKeyLabel}</Label>
{mode === 'create' && canCreateAccount ? (
<Button
type='button'
variant='outline'
size='sm'
onClick={onCreateAccount}
disabled={isCreatingAccount}
className='h-6 w-full text-xs sm:w-auto'
>
{isCreatingAccount ? 'Creating...' : 'Create Account'}
</Button>
) : (
platformUrl && (
<a
href={platformUrl}
target='_blank'
rel='noopener noreferrer'
className={`${docsLinkClassName} break-all`}
>
Get Your API Key Here
</a>
)
)}
</div>
<Input
id={`${idPrefix}api_key`}
type='password'
value={formData.api_key}
onChange={(e) =>
setFormData((prev) => ({ ...prev, api_key: e.target.value }))
}
placeholder={apiKeyPlaceholder}
/>
</div>
)}
{formData.provider_type === 'azure' && (
<div className='grid gap-2'>
<Label htmlFor={`${idPrefix}api_version`}>API Version</Label>
<Input
id={`${idPrefix}api_version`}
value={formData.api_version || ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
api_version: e.target.value || null,
}))
}
placeholder='2024-02-15-preview'
/>
</div>
)}
<div className='flex items-center space-x-2'>
<Switch
id={`${idPrefix}enabled`}
checked={formData.enabled}
onCheckedChange={(checked) =>
setFormData((prev) => ({ ...prev, enabled: checked }))
}
/>
<Label htmlFor={`${idPrefix}enabled`}>Enabled</Label>
</div>
<div className='grid gap-2'>
<Label htmlFor={`${idPrefix}provider_fee`}>
Provider Fee (Multiplier)
</Label>
<Input
id={`${idPrefix}provider_fee`}
type='number'
step='0.001'
min='1.0'
value={formData.provider_fee || ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
provider_fee: e.target.value
? parseFloat(e.target.value)
: undefined,
}))
}
placeholder={providerFeePlaceholder}
/>
<p className='text-muted-foreground text-xs'>
1.01 means +1% e.g. currency exchange, card fees, etc.
</p>
</div>
{mode === 'create' && formData.provider_type === 'routstr' && (
<RoutstrCreateKeySection
baseUrl={formData.base_url || ''}
onApiKeyCreated={(newApiKey) => {
setFormData((prev) => ({
...prev,
api_key: newApiKey,
}));
}}
/>
)}
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { Badge } from '@/components/ui/badge';
import type { AdminModel } from '@/lib/api/services/admin';
import type { ReactNode } from 'react';
interface ProviderModelRowProps {
model: AdminModel;
showEnabledState?: boolean;
actions?: ReactNode;
}
export function ProviderModelRow({
model,
showEnabledState = false,
actions,
}: ProviderModelRowProps) {
return (
<div className='hover:bg-accent flex flex-col gap-2 rounded-lg border p-3 transition-colors sm:flex-row sm:items-center sm:justify-between'>
<div className='min-w-0 flex-1'>
<div className='flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-2'>
<span className='truncate font-mono text-sm font-medium'>
{model.id}
</span>
{showEnabledState && (
<Badge
variant={model.enabled ? 'default' : 'secondary'}
className='w-fit text-xs'
>
{model.enabled ? 'Enabled' : 'Disabled'}
</Badge>
)}
</div>
<div className='text-muted-foreground mt-1 text-xs break-words'>
{model.description || model.name}
</div>
</div>
<div className='flex flex-wrap items-center gap-2 sm:flex-nowrap'>
<span className='text-muted-foreground text-xs'>
{model.context_length
? `${model.context_length.toLocaleString()} tokens`
: '-'}
</span>
{actions}
</div>
</div>
);
}
+168
View File
@@ -0,0 +1,168 @@
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Database, Pencil, Plus, Trash2 } from 'lucide-react';
import type { AdminModel, ProviderModels } from '@/lib/api/services/admin';
import { ProviderModelRow } from '@/components/provider-model-row';
interface ProviderModelsPanelProps {
isLoading: boolean;
providerModels: ProviderModels | null;
onBatchOverride: () => void;
onAddModel: () => void;
onEditModel: (model: AdminModel) => void;
onDeleteModel: (modelId: string) => void;
onOverrideModel: (model: AdminModel) => void;
isDeletingModel: boolean;
}
export function ProviderModelsPanel({
isLoading,
providerModels,
onBatchOverride,
onAddModel,
onEditModel,
onDeleteModel,
onOverrideModel,
isDeletingModel,
}: ProviderModelsPanelProps) {
if (isLoading) {
return (
<div className='space-y-2'>
<Skeleton className='h-[40px] w-full' />
<Skeleton className='h-[40px] w-full' />
</div>
);
}
if (!providerModels) {
return null;
}
return (
<Tabs
defaultValue={
providerModels.remote_models.length > 0 ? 'provided' : 'custom'
}
className='w-full'
>
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger value='provided' className='text-xs sm:text-sm'>
<span className='hidden sm:inline'>Provided Models</span>
<span className='sm:hidden'>Provided</span>
<Badge variant='secondary' className='ml-1 text-xs sm:ml-2'>
{providerModels.remote_models.length}
</Badge>
</TabsTrigger>
<TabsTrigger value='custom' className='text-xs sm:text-sm'>
<span className='hidden sm:inline'>Custom Models</span>
<span className='sm:hidden'>Custom</span>
<Badge variant='secondary' className='ml-1 text-xs sm:ml-2'>
{providerModels.db_models.length}
</Badge>
</TabsTrigger>
</TabsList>
<TabsContent value='custom' className='mt-4 space-y-2'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
{providerModels.db_models.length > 0 && (
<p className='text-muted-foreground text-sm'>
Custom models override or extend the provider&apos;s catalog.
</p>
)}
<div className='flex w-full flex-wrap gap-2 sm:w-auto sm:justify-end'>
<Button
variant='outline'
size='sm'
onClick={onBatchOverride}
className='w-full sm:w-auto'
>
<Database className='mr-2 h-4 w-4' />
Batch Override
</Button>
<Button
variant='outline'
size='sm'
onClick={onAddModel}
className='w-full sm:w-auto'
>
<Plus className='mr-2 h-4 w-4' />
Add Custom Model
</Button>
</div>
</div>
{providerModels.db_models.length === 0 ? (
<div className='text-muted-foreground py-4 text-center text-sm'>
No custom models configured
</div>
) : (
<div className='space-y-2'>
{providerModels.db_models.map((model) => (
<ProviderModelRow
key={model.id}
model={model}
showEnabledState
actions={
<>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() => onEditModel(model)}
>
<Pencil className='h-4 w-4' />
</Button>
<Button
variant='ghost'
size='icon'
className='text-destructive hover:text-destructive h-8 w-8'
onClick={() => onDeleteModel(model.id)}
disabled={isDeletingModel}
>
<Trash2 className='h-4 w-4' />
</Button>
</>
}
/>
))}
</div>
)}
</TabsContent>
<TabsContent value='provided' className='mt-4 space-y-2'>
{providerModels.remote_models.length > 0 ? (
<>
<p className='text-muted-foreground mb-3 text-sm'>
Models automatically discovered from the provider&apos;s catalog.
</p>
<div className='space-y-2'>
{providerModels.remote_models.map((model) => (
<ProviderModelRow
key={model.id}
model={model}
actions={
<Button
variant='outline'
size='sm'
className='h-7 w-full text-xs sm:w-auto'
onClick={() => onOverrideModel(model)}
>
<Plus className='mr-1 h-3 w-3' />
Override
</Button>
}
/>
))}
</div>
</>
) : (
<div className='text-muted-foreground py-4 text-center text-sm'>
No provided models available
</div>
)}
</TabsContent>
</Tabs>
);
}
-123
View File
@@ -1,123 +0,0 @@
'use client';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { ModelRevenueData } from '@/lib/api/services/admin';
import { useCurrencyStore } from '@/lib/stores/currency';
import { useQuery } from '@tanstack/react-query';
import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate';
import { formatFromMsat, convertToMsat } from '@/lib/currency';
interface RevenueByModelTableProps {
models: ModelRevenueData[];
totalRevenue: number;
}
export function RevenueByModelTable({
models,
totalRevenue,
}: RevenueByModelTableProps) {
const { displayUnit } = useCurrencyStore();
const { data: btcUsdPrice } = useQuery({
queryKey: ['btc-usd-price'],
queryFn: fetchBtcUsdPrice,
refetchInterval: 120_000,
staleTime: 60_000,
});
const usdPerSat = btcUsdPrice ? btcToSatsRate(btcUsdPrice) : null;
const formatAmount = (sats: number) =>
formatFromMsat(convertToMsat(sats, 'sat'), displayUnit, usdPerSat);
return (
<Card>
<CardHeader>
<CardTitle>Revenue by Model</CardTitle>
<p className='text-muted-foreground text-sm'>
Total Revenue:{' '}
<span className='text-foreground font-mono font-medium'>
{formatAmount(totalRevenue)}
</span>
</p>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Model</TableHead>
<TableHead className='text-right'>Requests</TableHead>
<TableHead className='text-right'>Successful</TableHead>
<TableHead className='text-right'>Failed</TableHead>
<TableHead className='text-right'>Revenue</TableHead>
<TableHead className='w-[100px]'>Share</TableHead>
<TableHead className='text-right'>Refunds</TableHead>
<TableHead className='text-right'>Net Revenue</TableHead>
<TableHead className='text-right'>Avg/Request</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{models.length === 0 ? (
<TableRow>
<TableCell
colSpan={9}
className='text-muted-foreground text-center'
>
No model data available
</TableCell>
</TableRow>
) : (
models.map((model) => {
const share =
totalRevenue > 0
? (model.revenue_sats / totalRevenue) * 100
: 0;
return (
<TableRow key={model.model}>
<TableCell className='font-medium'>{model.model}</TableCell>
<TableCell className='text-right font-mono'>
{model.requests}
</TableCell>
<TableCell className='text-right font-mono text-green-600'>
{model.successful}
</TableCell>
<TableCell className='text-right font-mono text-red-600'>
{model.failed}
</TableCell>
<TableCell className='text-right font-mono'>
{formatAmount(model.revenue_sats)}
</TableCell>
<TableCell>
<div className='flex items-center gap-2'>
<Progress value={share} className='h-2' />
<span className='text-muted-foreground w-8 text-right text-xs'>
{share.toFixed(0)}%
</span>
</div>
</TableCell>
<TableCell className='text-right font-mono text-red-500'>
{formatAmount(model.refunds_sats)}
</TableCell>
<TableCell className='text-right font-mono font-semibold'>
{formatAmount(model.net_revenue_sats)}
</TableCell>
<TableCell className='text-muted-foreground text-right font-mono'>
{formatAmount(model.avg_revenue_per_request)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
+233 -49
View File
@@ -16,7 +16,8 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { AlertCircle, Save, RefreshCw, Eye, EyeOff } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { AlertCircle, Eye, EyeOff } from 'lucide-react';
import { toast } from 'sonner';
import { Switch } from '@/components/ui/switch';
@@ -63,6 +64,7 @@ interface PasswordData {
export function AdminSettings() {
const [settings, setSettings] = useState<SettingsData>({});
const [initialSettings, setInitialSettings] = useState<SettingsData>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string>('');
@@ -87,11 +89,11 @@ export function AdminSettings() {
setError('');
const data = await AdminService.getSettings();
setSettings(data as SettingsData);
setInitialSettings(data as SettingsData);
} catch (err) {
const message =
err instanceof Error ? err.message : 'Failed to load settings';
setError(message);
toast.error(message);
} finally {
setLoading(false);
}
@@ -104,6 +106,7 @@ export function AdminSettings() {
const updatedData = await AdminService.updateSettings(settings);
setSettings(updatedData as SettingsData);
setInitialSettings(updatedData as SettingsData);
toast.success('Settings saved successfully');
} catch (err) {
const message =
@@ -154,6 +157,15 @@ export function AdminSettings() {
}
};
const clearPasswordForm = () => {
setPasswordData({
current_password: '',
new_password: '',
confirm_password: '',
});
setPasswordError('');
};
const handleInputChange = (field: string, value: unknown) => {
setSettings((prev) => ({
...prev,
@@ -206,7 +218,7 @@ export function AdminSettings() {
return (
<div key={field} className='space-y-2'>
<Label htmlFor={field}>{label}</Label>
<div className='flex gap-2'>
<div className='flex flex-col gap-2 sm:flex-row'>
<Input
id={field}
type={showSecrets ? 'text' : 'password'}
@@ -315,23 +327,112 @@ export function AdminSettings() {
);
};
const normalizeForCompare = (value: unknown): unknown => {
if (value === null || value === undefined) {
return '';
}
if (Array.isArray(value)) {
return value;
}
if (typeof value === 'object') {
return JSON.stringify(value);
}
return value;
};
const areValuesEqual = (a: unknown, b: unknown): boolean => {
const normalizedA = normalizeForCompare(a);
const normalizedB = normalizeForCompare(b);
if (Array.isArray(normalizedA) && Array.isArray(normalizedB)) {
return JSON.stringify(normalizedA) === JSON.stringify(normalizedB);
}
return normalizedA === normalizedB;
};
const hasFieldChanged = (key: string): boolean =>
!areValuesEqual(settings[key], initialSettings[key]);
const basicInfoChanged = [
'name',
'description',
'http_url',
'onion_url',
].some(hasFieldChanged);
const nostrChanged = ['npub', 'nsec'].some(hasFieldChanged);
const cashuMintsChanged = hasFieldChanged('cashu_mints');
const relaysChanged = hasFieldChanged('relays');
const advancedKeys = Object.keys(settings).filter(
(key) => !HANDLED_KEYS.includes(key) && !IGNORED_KEYS.includes(key)
);
const advancedChanged = advancedKeys.some(hasFieldChanged);
const hasPasswordChanges = Boolean(
passwordData.current_password ||
passwordData.new_password ||
passwordData.confirm_password
);
const resetFields = (keys: string[]) => {
setSettings((prev) => {
const next = { ...prev };
keys.forEach((key) => {
next[key] = initialSettings[key];
});
return next;
});
};
const resetBasicInfo = () =>
resetFields(['name', 'description', 'http_url', 'onion_url']);
const resetNostr = () => resetFields(['npub', 'nsec']);
const resetCashuMints = () => {
resetFields(['cashu_mints']);
setNewMint('');
};
const resetRelays = () => {
resetFields(['relays']);
setNewRelay('');
};
const resetAdvanced = () => resetFields(advancedKeys);
if (loading) {
return (
<div className='flex items-center justify-center py-8'>
<div className='text-muted-foreground'>Loading settings...</div>
<div className='space-y-4'>
<div className='space-y-2'>
<Skeleton className='h-7 w-48' />
<Skeleton className='h-4 w-64 max-w-full' />
</div>
<Card>
<CardHeader className='space-y-2'>
<Skeleton className='h-5 w-40' />
<Skeleton className='h-4 w-72 max-w-full' />
</CardHeader>
<CardContent className='space-y-3'>
<Skeleton className='h-10 w-full' />
<Skeleton className='h-20 w-full' />
<Skeleton className='h-10 w-full' />
</CardContent>
</Card>
<Card>
<CardHeader className='space-y-2'>
<Skeleton className='h-5 w-36' />
<Skeleton className='h-4 w-64 max-w-full' />
</CardHeader>
<CardContent className='space-y-3'>
<Skeleton className='h-10 w-full' />
<Skeleton className='h-10 w-full' />
</CardContent>
</Card>
</div>
);
}
return (
<>
<div className='mb-6'>
<h2 className='text-xl font-semibold tracking-tight'>Admin Settings</h2>
<p className='text-muted-foreground'>
Configure your Routstr node settings
</p>
</div>
{error && (
<Alert variant='destructive' className='mb-6'>
<AlertCircle className='h-4 w-4' />
@@ -389,6 +490,22 @@ export function AdminSettings() {
/>
</div>
</CardContent>
{basicInfoChanged ? (
<CardFooter className='justify-start'>
<div className='flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center'>
<Button
variant='outline'
onClick={resetBasicInfo}
disabled={loading || saving}
>
Cancel
</Button>
<Button onClick={handleSave} disabled={loading || saving}>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
</CardFooter>
) : null}
</Card>
{/* Nostr Configuration */}
@@ -411,6 +528,22 @@ export function AdminSettings() {
</div>
{renderSecretField('nsec', 'Private Key (nsec)', 'nsec1...')}
</CardContent>
{nostrChanged ? (
<CardFooter className='justify-start'>
<div className='flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center'>
<Button
variant='outline'
onClick={resetNostr}
disabled={loading || saving}
>
Cancel
</Button>
<Button onClick={handleSave} disabled={loading || saving}>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
</CardFooter>
) : null}
</Card>
{/* Cashu Mints */}
@@ -424,14 +557,18 @@ export function AdminSettings() {
<CardContent className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='newMint'>Add Mint URL</Label>
<div className='flex gap-2'>
<div className='flex flex-col gap-2 sm:flex-row'>
<Input
id='newMint'
value={newMint}
onChange={(e) => setNewMint(e.target.value)}
placeholder='https://mint.example.com'
/>
<Button onClick={addMint} disabled={!newMint.trim()}>
<Button
onClick={addMint}
disabled={!newMint.trim()}
className='sm:w-auto'
>
Add Mint
</Button>
</div>
@@ -444,13 +581,14 @@ export function AdminSettings() {
{settings.cashu_mints.map((mint, index) => (
<div
key={index}
className='flex items-center gap-2 rounded border p-2'
className='flex flex-col gap-2 rounded border p-2 sm:flex-row sm:items-center'
>
<span className='flex-1 text-sm'>{mint}</span>
<Button
variant='outline'
size='sm'
onClick={() => removeMint(index)}
className='w-full sm:w-auto'
>
Remove
</Button>
@@ -460,6 +598,22 @@ export function AdminSettings() {
</div>
)}
</CardContent>
{cashuMintsChanged ? (
<CardFooter className='justify-start'>
<div className='flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center'>
<Button
variant='outline'
onClick={resetCashuMints}
disabled={loading || saving}
>
Cancel
</Button>
<Button onClick={handleSave} disabled={loading || saving}>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
</CardFooter>
) : null}
</Card>
{/* Relays */}
@@ -473,14 +627,18 @@ export function AdminSettings() {
<CardContent className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='newRelay'>Add Relay URL</Label>
<div className='flex gap-2'>
<div className='flex flex-col gap-2 sm:flex-row'>
<Input
id='newRelay'
value={newRelay}
onChange={(e) => setNewRelay(e.target.value)}
placeholder='wss://relay.example.com'
/>
<Button onClick={addRelay} disabled={!newRelay.trim()}>
<Button
onClick={addRelay}
disabled={!newRelay.trim()}
className='sm:w-auto'
>
Add Relay
</Button>
</div>
@@ -493,13 +651,14 @@ export function AdminSettings() {
{settings.relays.map((relay, index) => (
<div
key={index}
className='flex items-center gap-2 rounded border p-2'
className='flex flex-col gap-2 rounded border p-2 sm:flex-row sm:items-center'
>
<span className='flex-1 text-sm'>{relay}</span>
<Button
variant='outline'
size='sm'
onClick={() => removeRelay(index)}
className='w-full sm:w-auto'
>
Remove
</Button>
@@ -509,6 +668,22 @@ export function AdminSettings() {
</div>
)}
</CardContent>
{relaysChanged ? (
<CardFooter className='justify-start'>
<div className='flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center'>
<Button
variant='outline'
onClick={resetRelays}
disabled={loading || saving}
>
Cancel
</Button>
<Button onClick={handleSave} disabled={loading || saving}>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
</CardFooter>
) : null}
</Card>
{/* Other Settings */}
@@ -527,23 +702,22 @@ export function AdminSettings() {
)
.map((key) => renderDynamicField(key, settings[key]))}
</CardContent>
</Card>
<Card className='mt-6'>
<CardFooter className='flex justify-between'>
<Button
variant='outline'
onClick={loadSettings}
disabled={loading || saving}
>
<RefreshCw className='mr-2 h-4 w-4' />
Reload
</Button>
<Button onClick={handleSave} disabled={loading || saving}>
<Save className='mr-2 h-4 w-4' />
{saving ? 'Saving...' : 'Save Settings'}
</Button>
</CardFooter>
{advancedChanged ? (
<CardFooter className='justify-start'>
<div className='flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center'>
<Button
variant='outline'
onClick={resetAdvanced}
disabled={loading || saving}
>
Cancel
</Button>
<Button onClick={handleSave} disabled={loading || saving}>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
</CardFooter>
) : null}
</Card>
{/* Password Change */}
@@ -610,20 +784,30 @@ export function AdminSettings() {
/>
</div>
</CardContent>
<CardFooter>
<Button
onClick={handlePasswordUpdate}
disabled={
passwordSaving ||
!passwordData.current_password ||
!passwordData.new_password ||
!passwordData.confirm_password
}
>
<Save className='mr-2 h-4 w-4' />
{passwordSaving ? 'Updating...' : 'Update Password'}
</Button>
</CardFooter>
{hasPasswordChanges ? (
<CardFooter className='justify-start'>
<div className='flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center'>
<Button
variant='outline'
onClick={clearPasswordForm}
disabled={passwordSaving}
>
Cancel
</Button>
<Button
onClick={handlePasswordUpdate}
disabled={
passwordSaving ||
!passwordData.current_password ||
!passwordData.new_password ||
!passwordData.confirm_password
}
>
{passwordSaving ? 'Saving...' : 'Save'}
</Button>
</div>
</CardFooter>
) : null}
</Card>
</div>
</>
@@ -3,7 +3,7 @@
import * as React from 'react';
import { useState } from 'react';
import { ConfigurationService } from '@/lib/api/services/configuration';
import { useConfiguration } from '@/lib/hooks/useConfiguration';
import { useConfiguration } from '@/lib/hooks/use-configuration';
import {
Card,
CardContent,
@@ -16,7 +16,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { CheckCircle, XCircle, AlertCircle } from 'lucide-react';
import { CheckCircle, XCircle, AlertCircle, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
export function ServerConfigSettings() {
@@ -70,29 +70,29 @@ export function ServerConfigSettings() {
const renderStatusBadge = () => {
if (connectionStatus === 'idle') {
return (
<Badge variant='outline' className='flex items-center gap-1'>
<AlertCircle className='h-4 w-4 text-yellow-500' />
<Badge variant='secondary' className='flex items-center gap-1'>
<AlertCircle className='h-4 w-4' />
Not tested
</Badge>
);
} else if (connectionStatus === 'testing') {
return (
<Badge variant='outline' className='flex items-center gap-1'>
<AlertCircle className='h-4 w-4 animate-pulse text-blue-500' />
<Loader2 className='h-4 w-4 animate-spin' />
Testing...
</Badge>
);
} else if (connectionStatus === 'success') {
return (
<Badge variant='outline' className='flex items-center gap-1'>
<CheckCircle className='h-4 w-4 text-green-500' />
<Badge variant='default' className='flex items-center gap-1'>
<CheckCircle className='h-4 w-4' />
Connected
</Badge>
);
} else {
return (
<Badge variant='outline' className='flex items-center gap-1'>
<XCircle className='h-4 w-4 text-red-500' />
<Badge variant='destructive' className='flex items-center gap-1'>
<XCircle className='h-4 w-4' />
Failed
</Badge>
);
@@ -101,47 +101,43 @@ export function ServerConfigSettings() {
return (
<>
<div className='mb-6 flex items-center justify-between'>
<h2 className='text-xl font-semibold tracking-tight'>
External Server Configuration
</h2>
{isSyncing && (
<Badge variant='outline' className='flex items-center gap-1'>
<AlertCircle className='h-4 w-4 animate-spin text-blue-500' />
Syncing...
</Badge>
)}
</div>
<Card>
<CardHeader>
<div className='flex items-center justify-between'>
<div>
<div className='flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between'>
<div className='min-w-0'>
<CardTitle>Request Forwarding Settings</CardTitle>
<CardDescription>
Configure external server endpoint and authentication for API
request forwarding
</CardDescription>
</div>
{config.enabled && renderStatusBadge()}
<div className='flex items-center gap-2'>
{isSyncing && (
<Badge variant='outline' className='flex items-center gap-1'>
<Loader2 className='h-4 w-4 animate-spin' />
Syncing...
</Badge>
)}
{config.enabled && renderStatusBadge()}
</div>
</div>
</CardHeader>
<CardContent className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='server-endpoint'>Server Endpoint URL</Label>
<div className='relative'>
<div className='relative flex flex-col gap-2 sm:block'>
<Input
id='server-endpoint'
placeholder='https://ecash.routstr.info'
value={config.endpoint}
onChange={(e) => handleConfigChange('endpoint', e.target.value)}
className='pr-24'
className='sm:pr-24'
/>
<Button
type='button'
variant='ghost'
size='sm'
className='absolute top-0 right-0 h-full px-3 py-2 text-xs'
className='h-8 w-full px-3 py-2 text-xs sm:absolute sm:top-0 sm:right-0 sm:h-full sm:w-auto'
onClick={() =>
handleConfigChange('endpoint', 'https://ecash.routstr.info')
}
@@ -151,15 +147,18 @@ export function ServerConfigSettings() {
</div>
</div>
</CardContent>
<CardFooter className='flex justify-between'>
<CardFooter className='flex flex-col gap-2 sm:flex-row sm:justify-between'>
<Button
variant='outline'
onClick={testConnection}
disabled={!config.endpoint || connectionStatus === 'testing'}
className='w-full sm:w-auto'
>
Test Connection
</Button>
<Button onClick={saveConfiguration}>Save Configuration</Button>
<Button onClick={saveConfiguration} className='w-full sm:w-auto'>
Save Configuration
</Button>
</CardFooter>
</Card>
</>
+49 -13
View File
@@ -4,14 +4,46 @@ import { Separator } from '@/components/ui/separator';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { Button } from '@/components/ui/button';
import { LogOut } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { usePathname, useRouter } from 'next/navigation';
import { adminLogout } from '@/lib/api/services/auth';
import { toast } from 'sonner';
import { ThemeToggle } from '@/components/theme-toggle';
import { CurrencyToggle } from '@/components/currency-toggle';
const PAGE_META: Record<string, { title: string; description: string }> = {
'/': {
title: 'Dashboard',
description: 'Usage, errors, revenue, and system health.',
},
'/balances': {
title: 'Balances',
description: 'Wallet and temporary balance overview.',
},
'/logs': {
title: 'System Logs',
description: 'Inspect request and application logs.',
},
'/models': {
title: 'Models',
description: 'Manage model catalog and provider mappings.',
},
'/providers': {
title: 'Providers',
description: 'Configure upstream providers and model sync.',
},
'/settings': {
title: 'Settings',
description: 'Admin, routing, and system-level configuration.',
},
};
export function SiteHeader() {
const router = useRouter();
const pathname = usePathname();
const meta = PAGE_META[pathname] ?? {
title: 'Routstr Node',
description: 'Administration panel',
};
const handleLogout = async () => {
try {
@@ -25,24 +57,28 @@ export function SiteHeader() {
};
return (
<header className='flex h-12 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12'>
<div className='flex w-full items-center justify-between gap-1 px-4 lg:gap-2 lg:px-6'>
<div className='flex items-center gap-1 lg:gap-2'>
<SidebarTrigger className='-ml-1' />
<Separator
orientation='vertical'
className='mx-2 data-[orientation=vertical]:h-4 lg:hidden'
/>
<h1 className='text-base font-medium lg:hidden'>Routstr Node</h1>
<header className='border-border/60 bg-background/80 sticky top-0 z-20 border-b backdrop-blur-xl'>
<div className='flex h-16 items-center justify-between gap-3 px-3 sm:px-5 md:px-6'>
<div className='flex min-w-0 items-center gap-2'>
<SidebarTrigger className='shrink-0 md:-ml-1' />
<Separator orientation='vertical' className='hidden h-5 md:block' />
<div className='min-w-0'>
<h1 className='truncate text-sm font-semibold tracking-tight sm:text-base'>
{meta.title}
</h1>
<p className='text-muted-foreground hidden truncate text-xs sm:block'>
{meta.description}
</p>
</div>
</div>
<div className='flex items-center gap-2'>
<div className='flex shrink-0 items-center gap-1.5'>
<CurrencyToggle />
<ThemeToggle />
<Button
variant='ghost'
variant='outline'
size='sm'
onClick={handleLogout}
className='gap-2'
className='h-8 gap-2 px-2.5 sm:px-3'
>
<LogOut className='h-4 w-4' />
<span className='hidden sm:inline'>Logout</span>
+355 -298
View File
@@ -3,7 +3,6 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
Loader2,
RefreshCw,
AlertCircle,
Key,
@@ -20,10 +19,75 @@ import {
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty';
import { Skeleton } from '@/components/ui/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { cn } from '@/lib/utils';
import type { DisplayUnit } from '@/lib/types/units';
import { formatFromMsat } from '@/lib/currency';
function getTotals(balances: TemporaryBalance[]) {
let totalBalance = 0;
let totalSpent = 0;
let totalRequests = 0;
balances.forEach((balance) => {
if (!balance.parent_key_hash) {
totalBalance += balance.balance || 0;
}
totalSpent += balance.total_spent || 0;
totalRequests += balance.total_requests || 0;
});
return { totalBalance, totalSpent, totalRequests };
}
function buildHierarchicalData(
allBalances: TemporaryBalance[],
filteredBalances: TemporaryBalance[]
) {
const parents = filteredBalances.filter((item) => !item.parent_key_hash);
const result: Array<TemporaryBalance & { isChild?: boolean }> = [];
parents.forEach((parent) => {
result.push(parent);
const children = allBalances.filter(
(item) => item.parent_key_hash === parent.hashed_key
);
children.forEach((child) => {
result.push({ ...child, isChild: true });
});
});
const orphans = filteredBalances.filter(
(item) =>
item.parent_key_hash &&
!result.some((r) => r.hashed_key === item.hashed_key)
);
result.push(...orphans.map((item) => ({ ...item, isChild: true })));
return result;
}
export function TemporaryBalances({
refreshInterval = 10000,
displayUnit,
@@ -37,9 +101,7 @@ export function TemporaryBalances({
const { data, isLoading, isError, error, isFetching, refetch } = useQuery({
queryKey: ['temporary-balances'],
queryFn: async () => {
return AdminService.getTemporaryBalances();
},
queryFn: async () => AdminService.getTemporaryBalances(),
refetchInterval: refreshInterval,
});
@@ -54,336 +116,331 @@ export function TemporaryBalances({
)
: [];
const calculateTotals = (balances: TemporaryBalance[]) => {
let totalBalance = 0;
let totalSpent = 0;
let totalRequests = 0;
balances.forEach((balance) => {
// Only count parents for total balance to avoid double counting
// since child keys use parent balance
if (!balance.parent_key_hash) {
totalBalance += balance.balance || 0;
}
totalSpent += balance.total_spent || 0;
totalRequests += balance.total_requests || 0;
});
return { totalBalance, totalSpent, totalRequests };
};
const totals = data
? calculateTotals(data)
? getTotals(data)
: { totalBalance: 0, totalSpent: 0, totalRequests: 0 };
// Group parents and children
const hierarchicalData = (() => {
if (!data) return [];
const parents = filteredData.filter((item) => !item.parent_key_hash);
const result: (TemporaryBalance & { isChild?: boolean })[] = [];
parents.forEach((parent) => {
result.push(parent);
const children = data.filter(
(item) => item.parent_key_hash === parent.hashed_key
);
children.forEach((child) => {
result.push({ ...child, isChild: true });
});
});
// Add children whose parents didn't match the search or aren't in the list
const orphans = filteredData.filter(
(item) =>
item.parent_key_hash &&
!result.some((r) => r.hashed_key === item.hashed_key)
);
result.push(...orphans.map((o) => ({ ...o, isChild: true })));
return result;
})();
const rows = data ? buildHierarchicalData(data, filteredData) : [];
return (
<>
<Card className='h-full w-full shadow-sm'>
<CardHeader className='pb-4'>
<div className='flex items-center justify-between'>
<CardTitle className='flex items-center gap-2 text-xl'>
<Key className='h-5 w-5' />
Temporary Balances
</CardTitle>
<div className='flex flex-col gap-2 sm:flex-row sm:gap-2'>
<div className='relative flex-1 sm:flex-initial'>
<input
type='text'
placeholder='Search by key or address...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='focus:ring-primary/20 w-full rounded-md border py-2 pr-3 pl-8 text-sm focus:ring-2 focus:outline-none sm:w-64'
<Card>
<CardHeader className='pb-4'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
<div className='space-y-1.5'>
<CardTitle>Temporary Balances</CardTitle>
<CardDescription className='max-w-2xl'>
API keys with their current balances and usage statistics
</CardDescription>
</div>
<div className='flex w-full gap-2 sm:w-auto sm:pt-0.5'>
<div className='min-w-0 flex-1 sm:w-72 sm:flex-none'>
<Input
type='text'
placeholder='Search by key or address...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
name='temporary_balance_search'
autoComplete='off'
/>
</div>
<Button
variant='ghost'
size='icon'
onClick={() => refetch()}
disabled={isLoading || isFetching}
>
<RefreshCw
className={cn(
'h-4 w-4',
(isFetching || isLoading) && 'animate-spin'
)}
/>
<span className='sr-only'>Refresh temporary balances</span>
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className='space-y-4'>
<div className='grid gap-3 md:grid-cols-3'>
{Array.from({ length: 3 }).map((_, index) => (
<Card key={`temp-stat-skeleton-${index}`}>
<CardHeader className='space-y-2 pb-1'>
<Skeleton className='h-3.5 w-24' />
<Skeleton className='h-3 w-8' />
</CardHeader>
<CardContent className='pt-0'>
<Skeleton className='h-7 w-24' />
</CardContent>
</Card>
))}
</div>
<div className='space-y-2'>
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton
key={`temp-row-skeleton-${index}`}
className='h-11 w-full'
/>
<Key className='text-muted-foreground absolute top-2.5 left-2 h-4 w-4' />
</div>
<Button
variant='ghost'
size='icon'
onClick={() => refetch()}
disabled={isLoading || isFetching}
className='h-8 w-8'
>
<RefreshCw
className={cn(
'h-4 w-4',
(isFetching || isLoading) && 'animate-spin'
)}
/>
<span className='sr-only'>Refresh temporary balances</span>
</Button>
))}
</div>
</div>
<CardDescription>
API keys with their current balances and usage statistics
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className='flex items-center justify-center py-8'>
<Loader2 className='text-primary h-8 w-8 animate-spin' />
</div>
) : isError ? (
<div className='bg-destructive/10 text-destructive flex items-center space-x-2 rounded-md p-4'>
<AlertCircle className='h-5 w-5' />
<span>
Error loading temporary balances: {(error as Error).message}
</span>
</div>
) : (
<div className='space-y-6'>
{/* Summary Cards */}
<div className='mb-6 grid grid-cols-1 gap-4 md:grid-cols-3'>
<div className='rounded-lg border border-blue-200 bg-gradient-to-r from-blue-50 to-indigo-50 p-4'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<DollarSign className='h-5 w-5 text-blue-600' />
<span className='text-sm font-medium text-blue-800'>
Total Balance
</span>
</div>
</div>
<div className='mt-2 text-2xl font-bold text-blue-900'>
) : isError ? (
<Alert variant='destructive'>
<AlertCircle className='h-5 w-5' />
<AlertDescription>
Error loading temporary balances: {(error as Error).message}
</AlertDescription>
</Alert>
) : (
<div className='space-y-6'>
<div className='grid gap-3 md:grid-cols-3'>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-muted-foreground text-sm font-medium'>
Total Balance
</CardTitle>
<span className='inline-flex size-8 items-center justify-center'>
<DollarSign className='size-4 text-green-600 dark:text-green-300' />
</span>
</CardHeader>
<CardContent className='pt-0'>
<p className='text-2xl font-semibold tracking-tight tabular-nums'>
{formatBalance(totals.totalBalance)}
</div>
</div>
<div className='rounded-lg border border-green-200 bg-gradient-to-r from-green-50 to-emerald-50 p-4'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Activity className='h-5 w-5 text-green-600' />
<span className='text-sm font-medium text-green-800'>
Total Spent
</span>
</div>
</div>
<div className='mt-2 text-2xl font-bold text-green-900'>
</p>
</CardContent>
</Card>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-muted-foreground text-sm font-medium'>
Total Spent
</CardTitle>
<span className='inline-flex size-8 items-center justify-center'>
<Activity className='size-4 text-blue-600 dark:text-blue-300' />
</span>
</CardHeader>
<CardContent className='pt-0'>
<p className='text-2xl font-semibold tracking-tight tabular-nums'>
{formatBalance(totals.totalSpent)}
</div>
</div>
<div className='rounded-lg border border-purple-200 bg-gradient-to-r from-purple-50 to-pink-50 p-4'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Key className='h-5 w-5 text-purple-600' />
<span className='text-sm font-medium text-purple-800'>
Total Requests
</span>
</div>
</div>
<div className='mt-2 text-2xl font-bold text-purple-900'>
</p>
</CardContent>
</Card>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-muted-foreground text-sm font-medium'>
Total Requests
</CardTitle>
<span className='inline-flex size-8 items-center justify-center'>
<Key className='size-4 text-purple-600 dark:text-purple-300' />
</span>
</CardHeader>
<CardContent className='pt-0'>
<p className='text-2xl font-semibold tracking-tight tabular-nums'>
{totals.totalRequests.toLocaleString()}
</div>
</div>
</div>
</p>
</CardContent>
</Card>
</div>
{/* Table */}
<div className='overflow-hidden rounded-lg border'>
{/* Desktop Table Header */}
<div className='bg-muted hidden grid-cols-6 gap-2 p-3 text-sm font-semibold md:grid'>
<div>Hashed Key</div>
<div className='text-right'>Balance</div>
<div className='text-right'>Total Spent</div>
<div className='text-right'>Total Requests</div>
<div>Refund Address</div>
<div className='text-right'>Expiry Time</div>
{rows.length > 0 ? (
<>
<div className='hidden md:block'>
<Table>
<TableHeader>
<TableRow>
<TableHead>Hashed Key</TableHead>
<TableHead className='text-right'>Balance</TableHead>
<TableHead className='text-right'>
Total Spent
</TableHead>
<TableHead className='text-right'>
Total Requests
</TableHead>
<TableHead>Refund Address</TableHead>
<TableHead className='text-right'>
Expiry Time
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((balance, index) => (
<TableRow
key={`${balance.hashed_key}-${balance.parent_key_hash ?? 'root'}-${index}`}
className={cn(
balance.balance === 0 &&
!balance.isChild &&
'opacity-60',
balance.isChild && 'bg-muted/30'
)}
>
<TableCell className='max-w-[16rem] font-mono text-xs break-all whitespace-normal'>
<div className='flex items-center gap-2'>
{balance.isChild && (
<Badge
variant='outline'
className='h-4 px-1 text-[10px] uppercase'
>
Child
</Badge>
)}
<span>{balance.hashed_key}</span>
</div>
</TableCell>
<TableCell className='text-right font-mono'>
{balance.isChild ? (
<span className='text-muted-foreground italic'>
(Parent)
</span>
) : (
formatBalance(balance.balance)
)}
</TableCell>
<TableCell className='text-right font-mono'>
{formatBalance(balance.total_spent)}
</TableCell>
<TableCell className='text-right font-mono'>
{balance.total_requests.toLocaleString()}
</TableCell>
<TableCell className='max-w-[14rem] font-mono text-xs break-all whitespace-normal'>
{balance.refund_address || '-'}
</TableCell>
<TableCell className='text-right font-mono text-xs'>
{balance.key_expiry_time ? (
<div className='inline-flex items-center justify-end gap-1'>
<Clock className='h-3 w-3' />
<span>
{new Date(
balance.key_expiry_time * 1000
).toLocaleDateString()}
</span>
</div>
) : (
'-'
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{hierarchicalData.length > 0 ? (
hierarchicalData.map((balance, index) => (
<div
key={index}
<div className='space-y-2 md:hidden'>
{rows.map((balance, index) => (
<Card
key={`${balance.hashed_key}-${balance.parent_key_hash ?? 'root'}-mobile-${index}`}
className={cn(
'hover:bg-muted/50 border-t p-3 text-sm transition-colors',
balance.balance === 0 &&
!balance.isChild &&
'opacity-60',
balance.isChild &&
'ml-4 border-l-2 border-l-blue-200 bg-blue-50/30'
'opacity-80',
balance.isChild && 'bg-muted/30'
)}
>
{/* Desktop Layout */}
<div className='hidden grid-cols-6 gap-2 md:grid'>
<div className='flex max-w-48 items-center gap-2 truncate font-mono text-xs break-all'>
<CardHeader className='p-4 pb-2'>
<div className='flex items-center justify-between gap-2'>
<CardDescription className='font-mono text-xs break-all'>
{balance.hashed_key}
</CardDescription>
{balance.isChild && (
<span className='rounded bg-blue-100 px-1 py-0.5 text-[10px] font-bold text-blue-700 uppercase'>
<Badge
variant='outline'
className='h-4 px-1.5 text-[10px] uppercase'
>
Child
</span>
)}
{balance.hashed_key}
</div>
<div className='text-right font-mono'>
{balance.isChild ? (
<span className='text-muted-foreground italic'>
(Parent)
</span>
) : (
formatBalance(balance.balance)
</Badge>
)}
</div>
<div className='text-right font-mono'>
{formatBalance(balance.total_spent)}
</CardHeader>
<CardContent className='grid grid-cols-2 gap-3 p-4 pt-0'>
<div>
<p className='text-muted-foreground text-xs'>
Balance
</p>
<p className='font-mono text-sm'>
{balance.isChild
? '(Uses Parent)'
: formatBalance(balance.balance)}
</p>
</div>
<div className='text-right font-mono'>
{balance.total_requests.toLocaleString()}
<div>
<p className='text-muted-foreground text-xs'>Spent</p>
<p className='font-mono text-sm'>
{formatBalance(balance.total_spent)}
</p>
</div>
<div className='max-w-32 truncate font-mono text-xs break-all'>
{balance.refund_address || '-'}
<div>
<p className='text-muted-foreground text-xs'>
Requests
</p>
<p className='font-mono text-sm'>
{balance.total_requests.toLocaleString()}
</p>
</div>
<div className='text-right font-mono text-xs'>
{balance.key_expiry_time ? (
<div className='flex items-center justify-end gap-1'>
<Clock className='h-3 w-3' />
<span>
<div>
<p className='text-muted-foreground text-xs'>
Expires
</p>
<p className='font-mono text-xs'>
{balance.key_expiry_time ? (
<span className='inline-flex items-center gap-1'>
<Clock className='h-3 w-3' />
{new Date(
balance.key_expiry_time * 1000
).toLocaleDateString()}
</span>
</div>
) : (
'-'
)}
) : (
'-'
)}
</p>
</div>
</div>
{/* Mobile Layout */}
<div className='space-y-3 md:hidden'>
<div className='flex items-center justify-between'>
<div className='space-y-1'>
<span className='text-muted-foreground text-xs font-medium'>
{balance.isChild ? 'Child Key' : 'Key'}
</span>
<div className='font-mono text-xs break-all'>
{balance.hashed_key}
</div>
</div>
{balance.isChild && (
<span className='rounded bg-blue-100 px-1.5 py-0.5 text-[10px] font-bold text-blue-700 uppercase'>
Child
</span>
)}
</div>
<div className='grid grid-cols-2 gap-3'>
<div className='space-y-1'>
<div className='text-muted-foreground text-xs font-medium'>
Balance
</div>
<div className='truncate font-mono text-sm'>
{balance.isChild ? (
<span className='text-muted-foreground text-xs italic'>
(Uses Parent)
</span>
) : (
formatBalance(balance.balance)
)}
</div>
</div>
<div className='space-y-1'>
<div className='text-muted-foreground text-xs font-medium'>
Spent
</div>
<div className='truncate font-mono text-sm'>
{formatBalance(balance.total_spent)}
</div>
</div>
</div>
<div className='grid grid-cols-2 gap-3'>
<div className='space-y-1'>
<div className='text-muted-foreground text-xs font-medium'>
Requests
</div>
<div className='truncate font-mono text-sm'>
{balance.total_requests.toLocaleString()}
</div>
</div>
<div className='space-y-1'>
<div className='text-muted-foreground text-xs font-medium'>
Expires
</div>
<div className='font-mono text-xs'>
{balance.key_expiry_time ? (
<div className='flex items-center gap-1'>
<Clock className='h-3 w-3 flex-shrink-0' />
<span className='truncate'>
{new Date(
balance.key_expiry_time * 1000
).toLocaleDateString()}
</span>
</div>
) : (
'-'
)}
</div>
</div>
</div>
{balance.refund_address && (
<div className='space-y-1 border-t pt-2'>
<div className='text-muted-foreground text-xs font-medium'>
<div className='col-span-2'>
<p className='text-muted-foreground text-xs'>
Refund Address
</div>
<div className='font-mono text-xs break-all'>
</p>
<p className='font-mono text-xs break-all'>
{balance.refund_address}
</div>
</p>
</div>
)}
</div>
</div>
))
) : (
<div className='text-muted-foreground p-8 text-center text-sm'>
{searchTerm ? (
<div className='flex flex-col items-center gap-2'>
<AlertCircle className='h-8 w-8' />
<span>No temporary balances match your search</span>
</div>
) : (
<div className='flex flex-col items-center gap-2'>
<Key className='h-8 w-8' />
<span>No temporary balances found</span>
</div>
)}
</div>
)}
</div>
{data && data.length > 0 && (
<div className='text-muted-foreground mt-4 text-xs'>
Showing {filteredData.length} of {data.length} temporary
balances
</CardContent>
</Card>
))}
</div>
)}
</div>
)}
</CardContent>
</Card>
</>
</>
) : (
<Empty className='py-8'>
<EmptyHeader>
<EmptyMedia variant='icon'>
{searchTerm ? (
<AlertCircle className='h-4 w-4' />
) : (
<Key className='h-4 w-4' />
)}
</EmptyMedia>
<EmptyTitle>
{searchTerm
? 'No temporary balances match your search'
: 'No temporary balances found'}
</EmptyTitle>
<EmptyDescription>
{searchTerm
? 'Try a different key hash or refund address.'
: 'Temporary balances will appear here once API keys are used.'}
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
{data && data.length > 0 && (
<p className='text-muted-foreground text-xs'>
Showing {filteredData.length} of {data.length} temporary
balances
</p>
)}
</div>
)}
</CardContent>
</Card>
);
}
+112 -15
View File
@@ -1,24 +1,121 @@
'use client';
import * as React from 'react';
import { Moon, Sun } from 'lucide-react';
import { useEffect, useState } from 'react';
import { ChevronsUpDownIcon, Monitor, Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
export function ThemeToggle() {
const { setTheme, theme } = useTheme();
interface ThemeToggleProps {
className?: string;
compact?: boolean;
menuSide?: 'top' | 'right' | 'bottom' | 'left';
menuAlign?: 'start' | 'center' | 'end';
}
type ThemeMode = 'light' | 'dark' | 'system';
const THEME_OPTIONS: Array<{
value: ThemeMode;
label: string;
icon: typeof Sun;
}> = [
{ value: 'dark', label: 'Dark', icon: Moon },
{ value: 'light', label: 'Light', icon: Sun },
{ value: 'system', label: 'System', icon: Monitor },
];
function isThemeMode(value: string | undefined): value is ThemeMode {
return value === 'light' || value === 'dark' || value === 'system';
}
function getThemeOption(theme: ThemeMode) {
return (
<Button
variant='ghost'
size='sm'
onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
className='gap-2'
>
<Sun className='h-4 w-4 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90' />
<Moon className='h-4 w-4 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0' />
<span className='sr-only'>Toggle theme</span>
</Button>
THEME_OPTIONS.find((option) => option.value === theme) ?? THEME_OPTIONS[2]
);
}
export function ThemeToggle({
className,
compact = false,
menuSide = 'bottom',
menuAlign = 'end',
}: ThemeToggleProps) {
const { setTheme, theme } = useTheme();
const [mounted, setMounted] = useState(false);
const [fallbackTheme, setFallbackTheme] = useState<ThemeMode>('system');
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (isThemeMode(theme)) {
setFallbackTheme(theme);
}
}, [theme]);
const activeTheme = mounted && isThemeMode(theme) ? theme : fallbackTheme;
const activeOption = getThemeOption(activeTheme);
const ActiveIcon = activeOption.icon;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='outline'
size='sm'
className={cn(
'border-border/60 bg-background/65 text-muted-foreground hover:text-foreground rounded-md',
compact
? 'h-8 w-10 justify-center px-0'
: 'h-8 justify-between gap-2',
className
)}
disabled={!mounted}
>
<span className='inline-flex min-w-0 items-center gap-1.5'>
<ActiveIcon className='h-3.5 w-3.5 shrink-0' />
{compact ? null : (
<span className='truncate text-[11px] font-medium'>
{activeOption.label}
</span>
)}
</span>
{compact ? (
<span className='sr-only'>Theme: {activeOption.label}</span>
) : (
<ChevronsUpDownIcon className='h-3.5 w-3.5 opacity-70' />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side={menuSide} align={menuAlign}>
<DropdownMenuRadioGroup
value={activeTheme}
onValueChange={(value) => {
if (!isThemeMode(value)) return;
setTheme(value);
}}
>
{THEME_OPTIONS.map((option) => {
const Icon = option.icon;
return (
<DropdownMenuRadioItem key={option.value} value={option.value}>
<Icon className='h-3.5 w-3.5' />
{option.label}
</DropdownMenuRadioItem>
);
})}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
+70
View File
@@ -0,0 +1,70 @@
'use client';
import React from 'react';
import { cn } from '@/lib/utils';
interface ModalShellProps {
open: boolean;
onClose?: () => void;
children: React.ReactNode;
overlayClassName?: string;
contentClassName?: string;
contentStyle?: React.CSSProperties;
closeOnOverlayClick?: boolean;
closeOnAnyClick?: boolean;
stopPropagation?: boolean;
contentRole?: React.AriaRole;
contentAriaLabel?: string;
}
export const ModalShell: React.FC<ModalShellProps> = ({
open,
onClose,
children,
overlayClassName,
contentClassName,
contentStyle,
closeOnOverlayClick = false,
closeOnAnyClick = false,
stopPropagation,
contentRole = 'dialog',
contentAriaLabel,
}) => {
if (!open) return null;
const shouldStopPropagation = stopPropagation ?? !closeOnAnyClick;
const handleOverlayMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
if (!onClose) return;
if (closeOnAnyClick) {
onClose();
return;
}
if (closeOnOverlayClick && event.target === event.currentTarget) {
onClose();
}
};
return (
<div
className={cn(
'fixed inset-0 flex items-center justify-center',
overlayClassName
)}
onMouseDown={handleOverlayMouseDown}
>
<div
className={contentClassName}
role={contentRole}
aria-modal='true'
aria-label={contentAriaLabel}
style={contentStyle}
onMouseDown={
shouldStopPropagation ? (event) => event.stopPropagation() : undefined
}
>
{children}
</div>
</div>
);
};
+49
View File
@@ -0,0 +1,49 @@
'use client';
import React from 'react';
import { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/drawer';
import { ModalShell } from '@/components/ui/ModalShell';
import { useMediaQuery } from '@/hooks/useMediaQuery';
interface SettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
isMobile?: boolean;
title?: string;
}
export default function SettingsDialog({
open,
onOpenChange,
children,
isMobile: propIsMobile,
title = 'Dialog',
}: SettingsDialogProps) {
const mediaQueryIsMobile = useMediaQuery('(max-width: 640px)');
const isMobile = propIsMobile ?? mediaQueryIsMobile;
if (isMobile) {
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className='p-4'>
<DrawerTitle className='sr-only'>{title}</DrawerTitle>
{children}
</DrawerContent>
</Drawer>
);
}
return (
<ModalShell
open={open}
onClose={() => onOpenChange(false)}
overlayClassName='fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4'
contentClassName='bg-card border border-border rounded-lg p-6 w-full max-w-md'
closeOnOverlayClick
contentAriaLabel={title}
>
{children}
</ModalShell>
);
}
+87
View File
@@ -0,0 +1,87 @@
'use client';
import * as React from 'react';
import { Accordion as AccordionPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
import { ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
function Accordion({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return (
<AccordionPrimitive.Root
data-slot='accordion'
className={cn('flex w-full flex-col', className)}
{...props}
/>
);
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot='accordion-item'
className={cn('not-last:border-b', className)}
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className='flex'>
<AccordionPrimitive.Trigger
data-slot='accordion-trigger'
className={cn(
'focus-visible:ring-ring/50 focus-visible:border-ring focus-visible:after:border-ring **:data-[slot=accordion-trigger-icon]:text-muted-foreground group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4',
className
)}
{...props}
>
{children}
<ChevronDownIcon
data-slot='accordion-trigger-icon'
className='pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden'
/>
<ChevronUpIcon
data-slot='accordion-trigger-icon'
className='pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline'
/>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot='accordion-content'
className='data-open:animate-accordion-down data-closed:animate-accordion-up overflow-hidden text-sm'
{...props}
>
<div
className={cn(
'[&_a]:hover:text-foreground h-(--radix-accordion-content-height) pt-0 pb-2.5 [&_a]:underline [&_a]:underline-offset-3 [&_p:not(:last-child)]:mb-4',
className
)}
>
{children}
</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
+69 -27
View File
@@ -1,10 +1,10 @@
'use client';
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { AlertDialog as AlertDialogPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
import { Button } from '@/components/ui/button';
function AlertDialog({
...props
@@ -36,7 +36,7 @@ function AlertDialogOverlay({
<AlertDialogPrimitive.Overlay
data-slot='alert-dialog-overlay'
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs',
className
)}
{...props}
@@ -46,15 +46,19 @@ function AlertDialogOverlay({
function AlertDialogContent({
className,
size = 'default',
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: 'default' | 'sm';
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot='alert-dialog-content'
data-size={size}
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-background ring-foreground/10 group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl p-4 ring-1 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm',
className
)}
{...props}
@@ -70,7 +74,10 @@ function AlertDialogHeader({
return (
<div
data-slot='alert-dialog-header'
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
className={cn(
'grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]',
className
)}
{...props}
/>
);
@@ -84,7 +91,23 @@ function AlertDialogFooter({
<div
data-slot='alert-dialog-footer'
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
'bg-muted/50 -mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end',
className
)}
{...props}
/>
);
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='alert-dialog-media'
className={cn(
"bg-muted mb-2 inline-flex size-10 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...props}
@@ -99,7 +122,10 @@ function AlertDialogTitle({
return (
<AlertDialogPrimitive.Title
data-slot='alert-dialog-title'
className={cn('text-lg font-semibold', className)}
className={cn(
'text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2',
className
)}
{...props}
/>
);
@@ -112,7 +138,10 @@ function AlertDialogDescription({
return (
<AlertDialogPrimitive.Description
data-slot='alert-dialog-description'
className={cn('text-muted-foreground text-sm', className)}
className={cn(
'text-muted-foreground *:[a]:hover:text-foreground text-sm text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3',
className
)}
{...props}
/>
);
@@ -120,38 +149,51 @@ function AlertDialogDescription({
function AlertDialogAction({
className,
variant = 'default',
size = 'default',
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, 'variant' | 'size'>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot='alert-dialog-action'
className={cn(className)}
{...props}
/>
</Button>
);
}
function AlertDialogCancel({
className,
variant = 'outline',
size = 'default',
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, 'variant' | 'size'>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: 'outline' }), className)}
{...props}
/>
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot='alert-dialog-cancel'
className={cn(className)}
{...props}
/>
</Button>
);
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
};
+15 -5
View File
@@ -4,13 +4,13 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
"grid gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4 w-full relative group/alert",
{
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
'text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current',
},
},
defaultVariants: {
@@ -39,7 +39,7 @@ function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
<div
data-slot='alert-title'
className={cn(
'col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight',
'[&_a]:hover:text-foreground font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3',
className
)}
{...props}
@@ -55,7 +55,7 @@ function AlertDescription({
<div
data-slot='alert-description'
className={cn(
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
'text-muted-foreground [&_a]:hover:text-foreground text-sm text-balance md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_p:not(:last-child)]:mb-4',
className
)}
{...props}
@@ -63,4 +63,14 @@ function AlertDescription({
);
}
export { Alert, AlertTitle, AlertDescription };
function AlertAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='alert-action'
className={cn('absolute top-2 right-2', className)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription, AlertAction };
+11
View File
@@ -0,0 +1,11 @@
'use client';
import { AspectRatio as AspectRatioPrimitive } from 'radix-ui';
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot='aspect-ratio' {...props} />;
}
export { AspectRatio };
+65 -6
View File
@@ -1,19 +1,23 @@
'use client';
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { Avatar as AvatarPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
function Avatar({
className,
size = 'default',
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: 'default' | 'sm' | 'lg';
}) {
return (
<AvatarPrimitive.Root
data-slot='avatar'
data-size={size}
className={cn(
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
'after:border-border group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten',
className
)}
{...props}
@@ -28,7 +32,10 @@ function AvatarImage({
return (
<AvatarPrimitive.Image
data-slot='avatar-image'
className={cn('aspect-square size-full', className)}
className={cn(
'aspect-square size-full rounded-full object-cover',
className
)}
{...props}
/>
);
@@ -42,7 +49,7 @@ function AvatarFallback({
<AvatarPrimitive.Fallback
data-slot='avatar-fallback'
className={cn(
'bg-muted flex size-full items-center justify-center rounded-full',
'bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs',
className
)}
{...props}
@@ -50,4 +57,56 @@ function AvatarFallback({
);
}
export { Avatar, AvatarImage, AvatarFallback };
function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot='avatar-badge'
className={cn(
'bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none',
'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden',
'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2',
'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
className
)}
{...props}
/>
);
}
function AvatarGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='avatar-group'
className={cn(
'*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2',
className
)}
{...props}
/>
);
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='avatar-group-count'
className={cn(
'bg-muted text-muted-foreground ring-background relative flex size-8 shrink-0 items-center justify-center rounded-full text-sm ring-2 group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3',
className
)}
{...props}
/>
);
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
};
+24 -16
View File
@@ -1,25 +1,24 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { Slot } from 'radix-ui';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
'h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive overflow-hidden group/badge',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
success:
'border-transparent bg-green-500 text-white hover:bg-green-500/80',
warning:
'border-transparent bg-yellow-500 text-white hover:bg-yellow-500/80',
error: 'border-transparent bg-red-500 text-white hover:bg-red-500/80',
'bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20',
outline:
'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
ghost:
'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
link: 'text-primary underline-offset-4 hover:underline',
},
},
defaultVariants: {
@@ -28,13 +27,22 @@ const badgeVariants = cva(
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({
className,
variant = 'default',
asChild = false,
...props
}: React.ComponentProps<'span'> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : 'span';
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
<Comp
data-slot='badge'
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
+119
View File
@@ -0,0 +1,119 @@
import * as React from 'react';
import { Slot } from 'radix-ui';
import { cn } from '@/lib/utils';
import { ChevronRightIcon, MoreHorizontalIcon } from 'lucide-react';
function Breadcrumb({ className, ...props }: React.ComponentProps<'nav'>) {
return (
<nav
aria-label='breadcrumb'
data-slot='breadcrumb'
className={cn(className)}
{...props}
/>
);
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
return (
<ol
data-slot='breadcrumb-list'
className={cn(
'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm wrap-break-word',
className
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot='breadcrumb-item'
className={cn('inline-flex items-center gap-1', className)}
{...props}
/>
);
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<'a'> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : 'a';
return (
<Comp
data-slot='breadcrumb-link'
className={cn('hover:text-foreground transition-colors', className)}
{...props}
/>
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot='breadcrumb-page'
role='link'
aria-disabled='true'
aria-current='page'
className={cn('text-foreground font-normal', className)}
{...props}
/>
);
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<'li'>) {
return (
<li
data-slot='breadcrumb-separator'
role='presentation'
aria-hidden='true'
className={cn('[&>svg]:size-3.5', className)}
{...props}
>
{children ?? <ChevronRightIcon />}
</li>
);
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot='breadcrumb-ellipsis'
role='presentation'
aria-hidden='true'
className={cn(
'flex size-5 items-center justify-center [&>svg]:size-4',
className
)}
{...props}
>
<MoreHorizontalIcon />
<span className='sr-only'>More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};
+83
View File
@@ -0,0 +1,83 @@
import { cva, type VariantProps } from 'class-variance-authority';
import { Slot } from 'radix-ui';
import { cn } from '@/lib/utils';
import { Separator } from '@/components/ui/separator';
const buttonGroupVariants = cva(
"has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg flex w-fit items-stretch *:focus-visible:z-10 *:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none',
vertical:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none',
},
},
defaultVariants: {
orientation: 'horizontal',
},
}
);
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role='group'
data-slot='button-group'
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
);
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<'div'> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : 'div';
return (
<Comp
className={cn(
"bg-muted flex items-center gap-2 rounded-lg border px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function ButtonGroupSeparator({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot='button-group-separator'
orientation={orientation}
className={cn(
'bg-input relative self-stretch data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto',
className
)}
{...props}
/>
);
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
};
+24 -16
View File
@@ -1,31 +1,37 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { Slot } from 'radix-ui';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
'border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground',
secondary:
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground',
destructive:
'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
default:
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3',
icon: 'size-8',
'icon-xs':
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
'icon-sm':
'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
'icon-lg': 'size-9',
},
},
defaultVariants: {
@@ -37,19 +43,21 @@ const buttonVariants = cva(
function Button({
className,
variant,
size,
variant = 'default',
size = 'default',
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
const Comp = asChild ? Slot.Root : 'button';
return (
<Comp
data-slot='button'
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
+198 -41
View File
@@ -1,72 +1,229 @@
'use client';
import * as React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { DayPicker } from 'react-day-picker';
import {
DayPicker,
getDefaultClassNames,
type DayButton,
type Locale,
} from 'react-day-picker';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
import { Button, buttonVariants } from '@/components/ui/button';
import {
ChevronLeftIcon,
ChevronRightIcon,
ChevronDownIcon,
} from 'lucide-react';
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = 'label',
buttonVariant = 'ghost',
locale,
formatters,
components,
...props
}: CalendarProps) {
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>['variant'];
}) {
const defaultClassNames = getDefaultClassNames();
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn('p-3', className)}
className={cn(
'bg-background group/calendar p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
locale={locale}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString(locale?.code, { month: 'short' }),
...formatters,
}}
classNames={{
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
month: 'space-y-4',
caption: 'flex justify-center pt-1 relative items-center',
caption_label: 'text-sm font-medium',
nav: 'space-x-1 flex items-center',
root: cn('w-fit', defaultClassNames.root),
months: cn(
'flex gap-4 flex-col md:flex-row relative',
defaultClassNames.months
),
month: cn('flex flex-col w-full gap-4', defaultClassNames.month),
nav: cn(
'flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between',
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute left-1'
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute right-1'
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
defaultClassNames.button_next
),
month_caption: cn(
'flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)',
defaultClassNames.month_caption
),
dropdowns: cn(
'w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5',
defaultClassNames.dropdowns
),
dropdown_root: cn(
'relative rounded-(--cell-radius)',
defaultClassNames.dropdown_root
),
dropdown: cn(
'absolute bg-popover inset-0 opacity-0',
defaultClassNames.dropdown
),
caption_label: cn(
'select-none font-medium',
captionLayout === 'label'
? 'text-sm'
: 'rounded-(--cell-radius) flex items-center gap-1 text-sm [&>svg]:text-muted-foreground [&>svg]:size-3.5',
defaultClassNames.caption_label
),
table: 'w-full border-collapse',
weekdays: cn('flex', defaultClassNames.weekdays),
weekday: cn(
'text-muted-foreground rounded-(--cell-radius) flex-1 font-normal text-[0.8rem] select-none',
defaultClassNames.weekday
),
week: cn('flex w-full mt-2', defaultClassNames.week),
week_number_header: cn(
'select-none w-(--cell-size)',
defaultClassNames.week_number_header
),
week_number: cn(
'text-[0.8rem] select-none text-muted-foreground',
defaultClassNames.week_number
),
month_grid: 'w-full border-collapse space-y-1',
weekdays: 'flex',
weekday:
'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
week: 'flex w-full mt-2',
day: cn(
buttonVariants({ variant: 'ghost' }),
'h-9 w-9 p-0 font-normal aria-selected:opacity-100'
'relative w-full rounded-(--cell-radius) h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius) group/day aspect-square select-none',
props.showWeekNumber
? '[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)'
: '[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)',
defaultClassNames.day
),
day_button: 'h-9 w-9 p-0 font-normal',
range_end: 'day-range-end',
selected:
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
today: 'bg-accent text-accent-foreground',
outside:
'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
disabled: 'text-muted-foreground opacity-50',
range_middle:
'aria-selected:bg-accent aria-selected:text-accent-foreground',
hidden: 'invisible',
range_start: cn(
'rounded-l-(--cell-radius) bg-muted relative after:bg-muted after:absolute after:inset-y-0 after:w-4 after:right-0 z-0 isolate',
defaultClassNames.range_start
),
range_middle: cn('rounded-none', defaultClassNames.range_middle),
range_end: cn(
'rounded-r-(--cell-radius) bg-muted relative after:bg-muted after:absolute after:inset-y-0 after:w-4 after:left-0 z-0 isolate',
defaultClassNames.range_end
),
today: cn(
'bg-muted text-foreground rounded-(--cell-radius) data-[selected=true]:rounded-none',
defaultClassNames.today
),
outside: cn(
'text-muted-foreground aria-selected:text-muted-foreground',
defaultClassNames.outside
),
disabled: cn(
'text-muted-foreground opacity-50',
defaultClassNames.disabled
),
hidden: cn('invisible', defaultClassNames.hidden),
...classNames,
}}
components={{
Chevron: ({ orientation }) => {
if (orientation === 'left') {
return <ChevronLeft className='h-4 w-4' />;
}
return <ChevronRight className='h-4 w-4' />;
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot='calendar'
ref={rootRef}
className={cn(className)}
{...props}
/>
);
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === 'left') {
return (
<ChevronLeftIcon className={cn('size-4', className)} {...props} />
);
}
if (orientation === 'right') {
return (
<ChevronRightIcon
className={cn('size-4', className)}
{...props}
/>
);
}
return (
<ChevronDownIcon className={cn('size-4', className)} {...props} />
);
},
DayButton: ({ ...props }) => (
<CalendarDayButton locale={locale} {...props} />
),
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className='flex size-(--cell-size) items-center justify-center text-center'>
{children}
</div>
</td>
);
},
...components,
}}
{...props}
/>
);
}
Calendar.displayName = 'Calendar';
export { Calendar };
function CalendarDayButton({
className,
day,
modifiers,
locale,
...props
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<Button
ref={ref}
variant='ghost'
size='icon'
data-day={day.date.toLocaleDateString(locale?.code)}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-foreground relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) [&>span]:text-xs [&>span]:opacity-70',
defaultClassNames.day,
className
)}
{...props}
/>
);
}
export { Calendar, CalendarDayButton };
+17 -6
View File
@@ -2,12 +2,17 @@ import * as React from 'react';
import { cn } from '@/lib/utils';
function Card({ className, ...props }: React.ComponentProps<'div'>) {
function Card({
className,
size = 'default',
...props
}: React.ComponentProps<'div'> & { size?: 'default' | 'sm' }) {
return (
<div
data-slot='card'
data-size={size}
className={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
'ring-foreground/10 bg-card text-card-foreground group/card flex flex-col gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl',
className
)}
{...props}
@@ -20,7 +25,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
<div
data-slot='card-header'
className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
'group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3',
className
)}
{...props}
@@ -32,7 +37,10 @@ function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-title'
className={cn('leading-none font-semibold', className)}
className={cn(
'text-base leading-snug font-medium group-data-[size=sm]/card:text-sm',
className
)}
{...props}
/>
);
@@ -65,7 +73,7 @@ function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-content'
className={cn('px-6', className)}
className={cn('px-4 group-data-[size=sm]/card:px-3', className)}
{...props}
/>
);
@@ -75,7 +83,10 @@ function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-footer'
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
className={cn(
'bg-muted/50 flex items-center rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3',
className
)}
{...props}
/>
);
+8 -7
View File
@@ -4,10 +4,10 @@ import * as React from 'react';
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from 'embla-carousel-react';
import { ArrowLeft, ArrowRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
@@ -174,7 +174,7 @@ function CarouselItem({ className, ...props }: React.ComponentProps<'div'>) {
function CarouselPrevious({
className,
variant = 'outline',
size = 'icon',
size = 'icon-sm',
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
@@ -185,7 +185,7 @@ function CarouselPrevious({
variant={variant}
size={size}
className={cn(
'absolute size-8 rounded-full',
'absolute touch-manipulation rounded-full',
orientation === 'horizontal'
? 'top-1/2 -left-12 -translate-y-1/2'
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
@@ -195,7 +195,7 @@ function CarouselPrevious({
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<ChevronLeftIcon />
<span className='sr-only'>Previous slide</span>
</Button>
);
@@ -204,7 +204,7 @@ function CarouselPrevious({
function CarouselNext({
className,
variant = 'outline',
size = 'icon',
size = 'icon-sm',
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel();
@@ -215,7 +215,7 @@ function CarouselNext({
variant={variant}
size={size}
className={cn(
'absolute size-8 rounded-full',
'absolute touch-manipulation rounded-full',
orientation === 'horizontal'
? 'top-1/2 -right-12 -translate-y-1/2'
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
@@ -225,7 +225,7 @@ function CarouselNext({
onClick={scrollNext}
{...props}
>
<ArrowRight />
<ChevronRightIcon />
<span className='sr-only'>Next slide</span>
</Button>
);
@@ -238,4 +238,5 @@ export {
CarouselItem,
CarouselPrevious,
CarouselNext,
useCarousel,
};
+167 -91
View File
@@ -48,22 +48,84 @@ function ChartContainer({
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`;
const containerRef = React.useRef<HTMLDivElement>(null);
const [containerSize, setContainerSize] = React.useState({
width: 0,
height: 0,
});
React.useLayoutEffect(() => {
const element = containerRef.current;
if (!element) {
return;
}
const updateSize = (width: number, height: number) => {
setContainerSize((current) => {
if (current.width === width && current.height === height) {
return current;
}
return { width, height };
});
};
const measure = () => {
const rect = element.getBoundingClientRect();
updateSize(rect.width, rect.height);
};
measure();
if (typeof ResizeObserver === 'undefined') {
return;
}
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) {
return;
}
updateSize(entry.contentRect.width, entry.contentRect.height);
});
observer.observe(element);
return () => observer.disconnect();
}, []);
const hasUsableSize = containerSize.width > 0 && containerSize.height > 0;
const initialDimension = React.useMemo(
() => ({
width: Math.max(1, Math.round(containerSize.width)),
height: Math.max(1, Math.round(containerSize.height)),
}),
[containerSize.height, containerSize.width]
);
return (
<ChartContext.Provider value={{ config }}>
<div
ref={containerRef}
data-slot='chart'
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border aspect-video min-h-0 min-w-0 text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
{hasUsableSize ? (
<RechartsPrimitive.ResponsiveContainer
width='100%'
height='100%'
minWidth={0}
minHeight={0}
initialDimension={initialDimension}
>
{children}
</RechartsPrimitive.ResponsiveContainer>
) : null}
</div>
</ChartContext.Provider>
);
@@ -103,6 +165,8 @@ ${colorConfig
};
const ChartTooltip = RechartsPrimitive.Tooltip;
type ChartValueType = number | string | ReadonlyArray<number | string>;
type ChartNameType = number | string;
function ChartTooltipContent({
active,
@@ -118,7 +182,9 @@ function ChartTooltipContent({
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
}: Partial<
RechartsPrimitive.TooltipContentProps<ChartValueType, ChartNameType>
> &
React.ComponentProps<'div'> & {
hideLabel?: boolean;
hideIndicator?: boolean;
@@ -173,76 +239,82 @@ function ChartTooltipContent({
return (
<div
className={cn(
'border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl',
'border-border/50 bg-background grid min-w-32 items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl',
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className='grid gap-1.5'>
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
{payload
.filter((item) => item.type !== 'none')
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
const rawValue = item.value;
const numericValue =
typeof rawValue === 'number' ? rawValue : Number(rawValue);
const displayValue = Number.isFinite(numericValue)
? numericValue.toLocaleString()
: '-';
return (
<div
key={item.dataKey}
className={cn(
'[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5',
indicator === 'dot' && 'items-center'
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
'shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)',
{
'h-2.5 w-2.5': indicator === 'dot',
'w-1': indicator === 'line',
'w-0 border-[1.5px] border-dashed bg-transparent':
indicator === 'dashed',
'my-0.5': nestLabel && indicator === 'dashed',
return (
<div
key={key}
className={cn(
'[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5',
indicator === 'dot' && 'items-center'
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
'shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)',
{
'h-2.5 w-2.5': indicator === 'dot',
'w-1': indicator === 'line',
'w-0 border-[1.5px] border-dashed bg-transparent':
indicator === 'dashed',
'my-0.5': nestLabel && indicator === 'dashed',
}
)}
style={
{
'--color-bg': indicatorColor,
'--color-border': indicatorColor,
} as React.CSSProperties
}
)}
style={
{
'--color-bg': indicatorColor,
'--color-border': indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
'flex flex-1 justify-between leading-none',
nestLabel ? 'items-end' : 'items-center'
/>
)
)}
>
<div className='grid gap-1.5'>
{nestLabel ? tooltipLabel : null}
<span className='text-muted-foreground'>
{itemConfig?.label || item.name}
<div
className={cn(
'grid min-w-0 flex-1 grid-cols-[minmax(0,1fr)_auto] gap-x-4 leading-none',
nestLabel ? 'items-end' : 'items-center'
)}
>
<div className='grid min-w-0 gap-1.5'>
{nestLabel ? tooltipLabel : null}
<span className='text-muted-foreground truncate pr-1'>
{itemConfig?.label || item.name}
</span>
</div>
<span className='text-foreground ml-1 text-right font-mono font-medium tabular-nums'>
{displayValue}
</span>
</div>
{item.value && (
<span className='text-foreground font-mono font-medium tabular-nums'>
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</>
)}
</div>
);
})}
</div>
</div>
);
@@ -257,7 +329,10 @@ function ChartLegendContent({
verticalAlign = 'bottom',
nameKey,
}: React.ComponentProps<'div'> &
Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & {
Pick<
RechartsPrimitive.DefaultLegendContentProps,
'payload' | 'verticalAlign'
> & {
hideIcon?: boolean;
nameKey?: string;
}) {
@@ -275,36 +350,37 @@ function ChartLegendContent({
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
{payload
.filter((item) => item.type !== 'none')
.map((item) => {
const key = `${nameKey || item.dataKey || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
'[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3'
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className='h-2 w-2 shrink-0 rounded-[2px]'
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
return (
<div
key={item.value}
className={cn(
'[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3'
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className='h-2 w-2 shrink-0 rounded-[2px]'
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
+5 -5
View File
@@ -1,10 +1,10 @@
'use client';
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { CheckIcon } from 'lucide-react';
import { Checkbox as CheckboxPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
import { CheckIcon } from 'lucide-react';
function Checkbox({
className,
@@ -14,16 +14,16 @@ function Checkbox({
<CheckboxPrimitive.Root
data-slot='checkbox'
className={cn(
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
'border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot='checkbox-indicator'
className='flex items-center justify-center text-current transition-none'
className='grid place-content-center text-current transition-none [&>svg]:size-3.5'
>
<CheckIcon className='size-3.5' />
<CheckIcon />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
+1 -1
View File
@@ -1,6 +1,6 @@
'use client';
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
import { Collapsible as CollapsiblePrimitive } from 'radix-ui';
function Collapsible({
...props
+302
View File
@@ -0,0 +1,302 @@
'use client';
import * as React from 'react';
import { Combobox as ComboboxPrimitive } from '@base-ui/react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from '@/components/ui/input-group';
import { ChevronDownIcon, XIcon, CheckIcon } from 'lucide-react';
const Combobox = ComboboxPrimitive.Root;
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot='combobox-value' {...props} />;
}
function ComboboxTrigger({
className,
children,
...props
}: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot='combobox-trigger'
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
{children}
<ChevronDownIcon className='text-muted-foreground pointer-events-none size-4' />
</ComboboxPrimitive.Trigger>
);
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot='combobox-clear'
render={<InputGroupButton variant='ghost' size='icon-xs' />}
className={cn(className)}
{...props}
>
<XIcon className='pointer-events-none' />
</ComboboxPrimitive.Clear>
);
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean;
showClear?: boolean;
}) {
return (
<InputGroup className={cn('w-auto', className)}>
<ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
/>
<InputGroupAddon align='inline-end'>
{showTrigger && (
<InputGroupButton
size='icon-xs'
variant='ghost'
asChild
data-slot='input-group-button'
className='group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent'
disabled={disabled}
>
<ComboboxTrigger />
</InputGroupButton>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
);
}
function ComboboxContent({
className,
side = 'bottom',
sideOffset = 6,
align = 'start',
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
'side' | 'align' | 'sideOffset' | 'alignOffset' | 'anchor'
>) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className='isolate z-50'
>
<ComboboxPrimitive.Popup
data-slot='combobox-content'
data-chips={!!anchor}
className={cn(
'bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:border-input/30 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg shadow-md ring-1 duration-100 data-[chips=true]:min-w-(--anchor-width) *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:shadow-none',
className
)}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
);
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot='combobox-list'
className={cn(
'no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0',
className
)}
{...props}
/>
);
}
function ComboboxItem({
className,
children,
...props
}: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot='combobox-item'
className={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
render={
<span className='pointer-events-none absolute right-2 flex size-4 items-center justify-center' />
}
>
<CheckIcon className='pointer-events-none' />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
);
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group
data-slot='combobox-group'
className={cn(className)}
{...props}
/>
);
}
function ComboboxLabel({
className,
...props
}: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot='combobox-label'
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...props}
/>
);
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return (
<ComboboxPrimitive.Collection data-slot='combobox-collection' {...props} />
);
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot='combobox-empty'
className={cn(
'text-muted-foreground hidden w-full justify-center py-2 text-center text-sm group-data-empty/combobox-content:flex',
className
)}
{...props}
/>
);
}
function ComboboxSeparator({
className,
...props
}: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot='combobox-separator'
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot='combobox-chips'
className={cn(
'dark:bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive dark:has-aria-invalid:border-destructive/50 flex min-h-8 flex-wrap items-center gap-1 rounded-lg border bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:ring-3 has-aria-invalid:ring-3 has-data-[slot=combobox-chip]:px-1',
className
)}
{...props}
/>
);
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean;
}) {
return (
<ComboboxPrimitive.Chip
data-slot='combobox-chip'
className={cn(
'bg-muted text-foreground flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm px-1.5 text-xs font-medium whitespace-nowrap has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0',
className
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant='ghost' size='icon-xs' />}
className='-ml-1 opacity-50 hover:opacity-100'
data-slot='combobox-chip-remove'
>
<XIcon className='pointer-events-none' />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
);
}
function ComboboxChipsInput({
className,
...props
}: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot='combobox-chip-input'
className={cn('min-w-16 flex-1 outline-none', className)}
{...props}
/>
);
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null);
}
export {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxGroup,
ComboboxLabel,
ComboboxCollection,
ComboboxEmpty,
ComboboxSeparator,
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
};
+25 -78
View File
@@ -2,16 +2,8 @@
import * as React from 'react';
import { Command as CommandPrimitive } from 'cmdk';
import { SearchIcon } from 'lucide-react';
import { Search } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
function Command({
className,
@@ -29,30 +21,6 @@ function Command({
);
}
function CommandDialog({
title = 'Command Palette',
description = 'Search for a command to run...',
children,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
}) {
return (
<Dialog {...props}>
<DialogHeader className='sr-only'>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent className='overflow-hidden p-0'>
<Command className='[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5'>
{children}
</Command>
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
@@ -60,13 +28,13 @@ function CommandInput({
return (
<div
data-slot='command-input-wrapper'
className='flex h-9 items-center gap-2 border-b px-3'
className='border-border flex items-center border-b px-2.5'
>
<SearchIcon className='size-4 shrink-0 opacity-50' />
<Search className='text-muted-foreground h-4 w-4 shrink-0' />
<CommandPrimitive.Input
data-slot='command-input'
className={cn(
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
'placeholder:text-muted-foreground flex h-9 w-full rounded-md bg-transparent py-2 pl-2 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
@@ -82,10 +50,7 @@ function CommandList({
return (
<CommandPrimitive.List
data-slot='command-list'
className={cn(
'max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto',
className
)}
className={cn('max-h-72 overflow-x-hidden overflow-y-auto', className)}
{...props}
/>
);
@@ -97,7 +62,7 @@ function CommandEmpty({
return (
<CommandPrimitive.Empty
data-slot='command-empty'
className='py-6 text-center text-sm'
className='text-muted-foreground py-6 text-center text-sm'
{...props}
/>
);
@@ -111,7 +76,23 @@ function CommandGroup({
<CommandPrimitive.Group
data-slot='command-group'
className={cn(
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1 [&_[cmdk-group-heading]]:text-xs',
className
)}
{...props}
/>
);
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot='command-item'
className={cn(
'data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none select-none',
className
)}
{...props}
@@ -132,46 +113,12 @@ function CommandSeparator({
);
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot='command-item'
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot='command-shortcut'
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandShortcut,
CommandList,
CommandSeparator,
};

Some files were not shown because too many files have changed in this diff Show More