mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-22 12:22:20 +00:00
Compare commits
5 Commits
optimize-s
...
feat/testn
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b5524f720 | ||
|
|
eeb70e4ee5 | ||
|
|
a3b410b467 | ||
|
|
bdf0e2c192 | ||
|
|
334453f934 |
@@ -3080,6 +3080,9 @@ async def get_logs_api(
|
||||
level: str | None = None,
|
||||
request_id: str | None = None,
|
||||
search: str | None = None,
|
||||
status_codes: str | None = Query(None, description="Comma-separated status codes"),
|
||||
methods: str | None = Query(None, description="Comma-separated HTTP methods"),
|
||||
endpoints: str | None = Query(None, description="Comma-separated endpoints"),
|
||||
limit: int = 100,
|
||||
) -> dict[str, object]:
|
||||
"""
|
||||
@@ -3090,16 +3093,32 @@ async def get_logs_api(
|
||||
level: Filter by log level
|
||||
request_id: Filter by request ID
|
||||
search: Search text in message and name fields (case-insensitive)
|
||||
status_codes: Comma-separated list of HTTP status codes
|
||||
methods: Comma-separated list of HTTP methods
|
||||
endpoints: Comma-separated list of endpoints
|
||||
limit: Maximum number of entries to return
|
||||
|
||||
Returns:
|
||||
Dict containing logs and filter metadata
|
||||
"""
|
||||
status_code_list = None
|
||||
if status_codes:
|
||||
try:
|
||||
status_code_list = [int(s.strip()) for s in status_codes.split(",")]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
method_list = [m.strip() for m in methods.split(",")] if methods else None
|
||||
endpoint_list = [e.strip() for e in endpoints.split(",")] if endpoints else None
|
||||
|
||||
log_entries = log_manager.search_logs(
|
||||
date=date,
|
||||
level=level,
|
||||
request_id=request_id,
|
||||
search_text=search,
|
||||
status_codes=status_code_list,
|
||||
methods=method_list,
|
||||
endpoints=endpoint_list,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
@@ -3110,6 +3129,9 @@ async def get_logs_api(
|
||||
"level": level,
|
||||
"request_id": request_id,
|
||||
"search": search,
|
||||
"status_codes": status_codes,
|
||||
"methods": methods,
|
||||
"endpoints": endpoints,
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
|
||||
@@ -105,6 +105,9 @@ class LogManager:
|
||||
level: str | None = None,
|
||||
request_id: str | None = None,
|
||||
search_text: str | None = None,
|
||||
status_codes: list[int] | None = None,
|
||||
methods: list[str] | None = None,
|
||||
endpoints: list[str] | None = None,
|
||||
limit: int = 100,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
@@ -134,7 +137,13 @@ class LogManager:
|
||||
|
||||
for log_data in iterator:
|
||||
if not self._matches_filters(
|
||||
log_data, level, request_id, search_text_lower
|
||||
log_data,
|
||||
level,
|
||||
request_id,
|
||||
search_text_lower,
|
||||
status_codes,
|
||||
methods,
|
||||
endpoints,
|
||||
):
|
||||
continue
|
||||
|
||||
@@ -153,6 +162,9 @@ class LogManager:
|
||||
level: str | None,
|
||||
request_id: str | None,
|
||||
search_text_lower: str | None,
|
||||
status_codes: list[int] | None = None,
|
||||
methods: list[str] | None = None,
|
||||
endpoints: list[str] | None = None,
|
||||
) -> bool:
|
||||
if level and log_data.get("levelname", "").upper() != level.upper():
|
||||
return False
|
||||
@@ -160,6 +172,36 @@ class LogManager:
|
||||
if request_id and log_data.get("request_id") != request_id:
|
||||
return False
|
||||
|
||||
if status_codes:
|
||||
entry_status = log_data.get("status_code")
|
||||
if entry_status is not None:
|
||||
try:
|
||||
if int(entry_status) not in status_codes:
|
||||
return False
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
|
||||
if methods:
|
||||
entry_method = log_data.get("method", "").upper()
|
||||
if entry_method not in [m.upper() for m in methods]:
|
||||
return False
|
||||
|
||||
if endpoints:
|
||||
entry_path = log_data.get("path", "")
|
||||
matched = False
|
||||
for endpoint in endpoints:
|
||||
clean_endpoint = endpoint.lstrip("/")
|
||||
if entry_path.startswith(clean_endpoint):
|
||||
matched = True
|
||||
break
|
||||
if clean_endpoint in entry_path:
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
return False
|
||||
|
||||
if search_text_lower:
|
||||
message = str(log_data.get("message", "")).lower()
|
||||
name = str(log_data.get("name", "")).lower()
|
||||
|
||||
@@ -47,6 +47,9 @@ class Settings(BaseSettings):
|
||||
receive_ln_address: str = Field(default="", env="RECEIVE_LN_ADDRESS")
|
||||
primary_mint: str = Field(default="", env="PRIMARY_MINT_URL")
|
||||
primary_mint_unit: str = Field(default="sat", env="PRIMARY_MINT_UNIT")
|
||||
disable_testnut_mock_upstream: bool = Field(
|
||||
default=False, env="DISABLE_TESTNUT_MOCK_UPSTREAM"
|
||||
)
|
||||
|
||||
# Pricing
|
||||
# Default behavior: derive pricing from MODELS
|
||||
|
||||
@@ -48,13 +48,6 @@ async def calculate_cost( # todo: can be sync
|
||||
},
|
||||
)
|
||||
|
||||
cost_data = MaxCostData(
|
||||
base_msats=max_cost,
|
||||
input_msats=0,
|
||||
output_msats=0,
|
||||
total_msats=max_cost,
|
||||
)
|
||||
|
||||
if "usage" not in response_data or response_data["usage"] is None:
|
||||
logger.warning(
|
||||
"No usage data in response, using base cost only",
|
||||
@@ -63,7 +56,12 @@ async def calculate_cost( # todo: can be sync
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
return cost_data
|
||||
return MaxCostData(
|
||||
base_msats=0,
|
||||
input_msats=0,
|
||||
output_msats=0,
|
||||
total_msats=0,
|
||||
)
|
||||
|
||||
usage_data = response_data["usage"]
|
||||
|
||||
@@ -178,7 +176,12 @@ async def calculate_cost( # todo: can be sync
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
return cost_data
|
||||
return MaxCostData(
|
||||
base_msats=max_cost,
|
||||
input_msats=0,
|
||||
output_msats=0,
|
||||
total_msats=max_cost,
|
||||
)
|
||||
|
||||
input_tokens = usage_data.get("prompt_tokens", 0)
|
||||
output_tokens = usage_data.get("completion_tokens", 0)
|
||||
@@ -192,8 +195,16 @@ async def calculate_cost( # todo: can be sync
|
||||
)
|
||||
|
||||
# added for response api
|
||||
input_tokens = input_tokens if input_tokens != 0 else response_data.get("usage", {}).get("input_tokens", 0)
|
||||
output_tokens = output_tokens if output_tokens != 0 else response_data.get("usage", {}).get("output_tokens", 0)
|
||||
input_tokens = (
|
||||
input_tokens
|
||||
if input_tokens != 0
|
||||
else response_data.get("usage", {}).get("input_tokens", 0)
|
||||
)
|
||||
output_tokens = (
|
||||
output_tokens
|
||||
if output_tokens != 0
|
||||
else response_data.get("usage", {}).get("output_tokens", 0)
|
||||
)
|
||||
|
||||
input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
|
||||
|
||||
|
||||
@@ -148,7 +148,10 @@ async def proxy(
|
||||
else:
|
||||
model_id = request_body_dict.get("model", "unknown")
|
||||
|
||||
if "https://testnut.cashu.space" in settings.cashu_mints:
|
||||
if (
|
||||
"https://testnut.cashu.space" in settings.cashu_mints
|
||||
and not settings.disable_testnut_mock_upstream
|
||||
):
|
||||
try:
|
||||
token_str = None
|
||||
if x_cashu_header := headers.get("x-cashu"):
|
||||
@@ -357,7 +360,7 @@ def extract_model_from_responses_request(request_body_dict: dict[str, Any]) -> s
|
||||
|
||||
logger.warning(
|
||||
"No model found in Responses API request",
|
||||
extra={"body_keys": list(request_body_dict.keys())}
|
||||
extra={"body_keys": list(request_body_dict.keys())},
|
||||
)
|
||||
return "unknown"
|
||||
|
||||
|
||||
@@ -234,7 +234,11 @@ class BaseUpstreamProvider:
|
||||
)
|
||||
|
||||
# Handle model in input field (alternative format)
|
||||
if "input" in data and isinstance(data["input"], dict) and "model" in data["input"]:
|
||||
if (
|
||||
"input" in data
|
||||
and isinstance(data["input"], dict)
|
||||
and "model" in data["input"]
|
||||
):
|
||||
original_model = model_obj.id
|
||||
transformed_model = self.transform_model_name(original_model)
|
||||
data["input"]["model"] = transformed_model
|
||||
@@ -779,8 +783,13 @@ class BaseUpstreamProvider:
|
||||
|
||||
# Track reasoning tokens for Responses API
|
||||
if usage := obj.get("usage", {}):
|
||||
if isinstance(usage, dict) and "reasoning_tokens" in usage:
|
||||
reasoning_tokens += usage.get("reasoning_tokens", 0)
|
||||
if (
|
||||
isinstance(usage, dict)
|
||||
and "reasoning_tokens" in usage
|
||||
):
|
||||
reasoning_tokens += usage.get(
|
||||
"reasoning_tokens", 0
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
except Exception:
|
||||
@@ -933,8 +942,8 @@ class BaseUpstreamProvider:
|
||||
"model": response_json.get("model", "unknown"),
|
||||
"has_usage": "usage" in response_json,
|
||||
"has_reasoning_tokens": "usage" in response_json
|
||||
and isinstance(response_json.get("usage"), dict)
|
||||
and "reasoning_tokens" in response_json["usage"],
|
||||
and isinstance(response_json.get("usage"), dict)
|
||||
and "reasoning_tokens" in response_json["usage"],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2503,7 +2512,10 @@ class BaseUpstreamProvider:
|
||||
usage_data = data_json["usage"]
|
||||
model = data_json.get("model")
|
||||
# Track reasoning tokens for Responses API
|
||||
if isinstance(usage_data, dict) and "reasoning_tokens" in usage_data:
|
||||
if (
|
||||
isinstance(usage_data, dict)
|
||||
and "reasoning_tokens" in usage_data
|
||||
):
|
||||
reasoning_tokens = usage_data.get("reasoning_tokens", 0)
|
||||
elif "model" in data_json and not model:
|
||||
model = data_json["model"]
|
||||
|
||||
@@ -218,7 +218,10 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
|
||||
results = await asyncio.gather(*tasks)
|
||||
upstreams = [p for p in results if p is not None]
|
||||
|
||||
if "https://testnut.cashu.space" in settings.cashu_mints:
|
||||
if (
|
||||
"https://testnut.cashu.space" in settings.cashu_mints
|
||||
and not settings.disable_testnut_mock_upstream
|
||||
):
|
||||
from .fake import MockUpstreamProvider
|
||||
|
||||
mock_provider = MockUpstreamProvider("mock", "mock")
|
||||
|
||||
@@ -313,7 +313,10 @@ async def periodic_payout() -> None:
|
||||
try:
|
||||
async with db.create_session() as session:
|
||||
for mint_url in settings.cashu_mints:
|
||||
if mint_url == "https://testnut.cashu.space":
|
||||
if (
|
||||
mint_url == "https://testnut.cashu.space"
|
||||
and not settings.disable_testnut_mock_upstream
|
||||
):
|
||||
continue
|
||||
for unit in ["sat", "msat"]:
|
||||
wallet = await get_wallet(mint_url, unit)
|
||||
|
||||
@@ -21,7 +21,17 @@ import {
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { CalendarIcon, Filter, X } from 'lucide-react';
|
||||
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';
|
||||
@@ -31,11 +41,17 @@ interface LogFiltersProps {
|
||||
selectedLevel: string;
|
||||
requestId: string;
|
||||
searchText: string;
|
||||
selectedStatusCodes: string[];
|
||||
selectedMethods: string[];
|
||||
selectedEndpoints: string[];
|
||||
limit: number;
|
||||
onDateChange: (date: string) => void;
|
||||
onLevelChange: (level: string) => void;
|
||||
onRequestIdChange: (requestId: string) => void;
|
||||
onSearchTextChange: (searchText: string) => void;
|
||||
onStatusCodesChange: (statusCodes: string[]) => void;
|
||||
onMethodsChange: (methods: string[]) => void;
|
||||
onEndpointsChange: (endpoints: string[]) => void;
|
||||
onLimitChange: (limit: number) => void;
|
||||
onClearFilters: () => void;
|
||||
}
|
||||
@@ -43,16 +59,87 @@ interface LogFiltersProps {
|
||||
const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'];
|
||||
const PRESET_LIMITS = ['25', '50', '100', '200', '500', '1000'];
|
||||
|
||||
const STATUS_CODE_OPTIONS = [
|
||||
'200',
|
||||
'201',
|
||||
'204',
|
||||
'400',
|
||||
'401',
|
||||
'402',
|
||||
'403',
|
||||
'404',
|
||||
'422',
|
||||
'429',
|
||||
'500',
|
||||
'502',
|
||||
'503',
|
||||
'504',
|
||||
];
|
||||
|
||||
const METHOD_OPTIONS = [
|
||||
'GET',
|
||||
'POST',
|
||||
'PUT',
|
||||
'DELETE',
|
||||
'PATCH',
|
||||
'OPTIONS',
|
||||
'HEAD',
|
||||
];
|
||||
|
||||
const ENDPOINT_OPTIONS = [
|
||||
'/chat/completions',
|
||||
'/v1/chat/completions',
|
||||
'/models',
|
||||
'/v1/models',
|
||||
'/responses',
|
||||
'/v1/responses',
|
||||
'v1/embeddings/models',
|
||||
'/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>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogFilters({
|
||||
selectedDate,
|
||||
selectedLevel,
|
||||
requestId,
|
||||
searchText,
|
||||
selectedStatusCodes,
|
||||
selectedMethods,
|
||||
selectedEndpoints,
|
||||
limit,
|
||||
onDateChange,
|
||||
onLevelChange,
|
||||
onRequestIdChange,
|
||||
onSearchTextChange,
|
||||
onStatusCodesChange,
|
||||
onMethodsChange,
|
||||
onEndpointsChange,
|
||||
onLimitChange,
|
||||
onClearFilters,
|
||||
}: LogFiltersProps) {
|
||||
@@ -68,6 +155,10 @@ export function LogFilters({
|
||||
: undefined
|
||||
);
|
||||
|
||||
const [statusSearch, setStatusSearch] = useState('');
|
||||
const [methodSearch, setMethodSearch] = useState('');
|
||||
const [endpointSearch, setEndpointSearch] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const currentIsPreset = PRESET_LIMITS.includes(limit.toString());
|
||||
setIsCustom(!currentIsPreset);
|
||||
@@ -129,6 +220,31 @@ export function LogFilters({
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelection = (
|
||||
current: string[],
|
||||
value: string,
|
||||
onChange: (val: string[]) => void
|
||||
) => {
|
||||
if (current.includes(value)) {
|
||||
onChange(current.filter((v) => v !== value));
|
||||
} else {
|
||||
onChange([...current, value]);
|
||||
}
|
||||
};
|
||||
|
||||
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));
|
||||
|
||||
if (allIncluded) {
|
||||
codes.forEach((c) => newSelection.delete(c));
|
||||
} else {
|
||||
codes.forEach((c) => newSelection.add(c));
|
||||
}
|
||||
onStatusCodesChange(Array.from(newSelection));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className='mb-6'>
|
||||
<CardHeader>
|
||||
@@ -137,7 +253,8 @@ export function LogFilters({
|
||||
Filters
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Filter logs by date, level, request ID, text search, and limit
|
||||
Filter logs by date, level, request ID, text search, status code,
|
||||
method, endpoint and limit
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -197,6 +314,337 @@ 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 "{statusSearch}"
|
||||
</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>
|
||||
|
||||
<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 "{methodSearch.toUpperCase()}"
|
||||
</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>
|
||||
|
||||
<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 "{endpointSearch}"
|
||||
</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>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='request-id'>Request ID</Label>
|
||||
<Input
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { SiteHeader } from '@/components/site-header';
|
||||
@@ -22,15 +22,66 @@ import { LogFilters } from './log-filters';
|
||||
import { LogEntryCard } from './log-entry-card';
|
||||
import { LogDetailsDialog } from './log-details-dialog';
|
||||
|
||||
const STORAGE_KEY = 'routstr-log-filters';
|
||||
|
||||
export default function LogsPage() {
|
||||
const [selectedDate, setSelectedDate] = useState<string>('all');
|
||||
const [selectedLevel, setSelectedLevel] = useState<string>('all');
|
||||
const [requestId, setRequestId] = useState<string>('');
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [selectedStatusCodes, setSelectedStatusCodes] = useState<string[]>([]);
|
||||
const [selectedMethods, setSelectedMethods] = useState<string[]>([]);
|
||||
const [selectedEndpoints, setSelectedEndpoints] = useState<string[]>([]);
|
||||
const [limit, setLimit] = useState<number>(100);
|
||||
const [selectedLog, setSelectedLog] = useState<LogEntry | null>(null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
|
||||
|
||||
// Load filters from localStorage on mount
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.selectedDate) setSelectedDate(parsed.selectedDate);
|
||||
if (parsed.selectedLevel) setSelectedLevel(parsed.selectedLevel);
|
||||
if (parsed.requestId) setRequestId(parsed.requestId);
|
||||
if (parsed.searchText) setSearchText(parsed.searchText);
|
||||
if (parsed.selectedStatusCodes)
|
||||
setSelectedStatusCodes(parsed.selectedStatusCodes);
|
||||
if (parsed.selectedMethods) setSelectedMethods(parsed.selectedMethods);
|
||||
if (parsed.selectedEndpoints)
|
||||
setSelectedEndpoints(parsed.selectedEndpoints);
|
||||
if (parsed.limit) setLimit(parsed.limit);
|
||||
} catch (e) {
|
||||
console.error('Failed to load filters from localStorage', e);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save filters to localStorage whenever they change
|
||||
useEffect(() => {
|
||||
const filters = {
|
||||
selectedDate,
|
||||
selectedLevel,
|
||||
requestId,
|
||||
searchText,
|
||||
selectedStatusCodes,
|
||||
selectedMethods,
|
||||
selectedEndpoints,
|
||||
limit,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(filters));
|
||||
}, [
|
||||
selectedDate,
|
||||
selectedLevel,
|
||||
requestId,
|
||||
searchText,
|
||||
selectedStatusCodes,
|
||||
selectedMethods,
|
||||
selectedEndpoints,
|
||||
limit,
|
||||
]);
|
||||
|
||||
const {
|
||||
data: logsData,
|
||||
refetch: refetchLogs,
|
||||
@@ -42,6 +93,9 @@ export default function LogsPage() {
|
||||
selectedLevel,
|
||||
requestId,
|
||||
searchText,
|
||||
selectedStatusCodes,
|
||||
selectedMethods,
|
||||
selectedEndpoints,
|
||||
limit,
|
||||
],
|
||||
queryFn: () =>
|
||||
@@ -50,6 +104,16 @@ export default function LogsPage() {
|
||||
level: selectedLevel === 'all' ? undefined : selectedLevel,
|
||||
request_id: requestId || undefined,
|
||||
search: searchText || undefined,
|
||||
status_codes:
|
||||
selectedStatusCodes.length > 0
|
||||
? selectedStatusCodes.join(',')
|
||||
: undefined,
|
||||
methods:
|
||||
selectedMethods.length > 0 ? selectedMethods.join(',') : undefined,
|
||||
endpoints:
|
||||
selectedEndpoints.length > 0
|
||||
? selectedEndpoints.join(',')
|
||||
: undefined,
|
||||
limit: limit,
|
||||
}),
|
||||
refetchInterval: 30000,
|
||||
@@ -60,6 +124,9 @@ export default function LogsPage() {
|
||||
setSelectedLevel('all');
|
||||
setRequestId('');
|
||||
setSearchText('');
|
||||
setSelectedStatusCodes([]);
|
||||
setSelectedMethods([]);
|
||||
setSelectedEndpoints([]);
|
||||
setLimit(100);
|
||||
};
|
||||
|
||||
@@ -100,11 +167,17 @@ export default function LogsPage() {
|
||||
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}
|
||||
/>
|
||||
@@ -122,13 +195,22 @@ export default function LogsPage() {
|
||||
{(selectedDate !== 'all' ||
|
||||
selectedLevel !== 'all' ||
|
||||
requestId ||
|
||||
searchText) && (
|
||||
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>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
@@ -17,6 +17,9 @@ export interface LogsResponse {
|
||||
level: string | null;
|
||||
request_id: string | null;
|
||||
search: string | null;
|
||||
status_codes: string | null;
|
||||
methods: string | null;
|
||||
endpoints: string | null;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user