Compare commits

...

5 Commits

Author SHA1 Message Date
Shroominic
8b5524f720 feat: add option to disable testnut mock upstream 2026-01-05 13:11:41 +01:00
9qeklajc
eeb70e4ee5 Merge pull request #289 from Routstr/282-better-filltering
#282 more filter options
2026-01-03 23:27:22 +01:00
9qeklajc
a3b410b467 Merge pull request #284 from Routstr/274-do-not-charge-when-empty-content
#274 do not charge user for empty response by upstream
2026-01-03 23:26:54 +01:00
9qeklajc
bdf0e2c192 #282 more filter options 2026-01-03 22:49:35 +01:00
9qeklajc
334453f934 #274 do not change user for empty response by upstream 2026-01-03 21:27:19 +01:00
11 changed files with 658 additions and 26 deletions

View File

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

View File

@@ -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()

View File

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

View File

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

View File

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

View File

@@ -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"]

View File

@@ -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")

View File

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

View File

@@ -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 &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>
<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>
<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>
<div className='space-y-2'>
<Label htmlFor='request-id'>Request ID</Label>
<Input

View File

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

View File

@@ -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;
}