feat: add filter toggle dashboard card (frontend)
**New Components:** - FiltersConfigCard — Card with toggle switches for all 11 entry filters - Switch UI component (shadcn/ui with Radix) - useFilterConfig hook — Fetch & update filters via API - FilterConfig types **Features:** - Live toggle switches for 11 filters (flash_crash, regime, risk, session, signal_combination, h1_bias, time_filter, cooldown, etc.) - Real-time updates — no page refresh needed - Shows enabled/disabled count badge - Filter descriptions + last updated timestamp - Auto-refresh every 30 seconds - Refresh button with loading state - Error handling + retry **Integration:** - Added @radix-ui/react-switch dependency - Added Row 5 to dashboard (grid-cols-2) - FiltersConfigCard takes left half of new row **Usage:** User can toggle any filter ON/OFF from dashboard → API updates filter_config.json → Bot reloads config next loop (live update, no restart) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Generated
+3
-1
@@ -9,6 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -3515,6 +3516,7 @@
|
||||
"version": "19.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz",
|
||||
"integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -3524,7 +3526,7 @@
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
EntryFilterCard,
|
||||
PerformanceCard,
|
||||
ModelCard,
|
||||
FiltersConfigCard,
|
||||
} from "@/components/dashboard";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
@@ -235,6 +236,16 @@ export default function Dashboard() {
|
||||
<LogCard logs={data.logs} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 5: Filter Controls */}
|
||||
<div className="flex-[1.4] min-h-0 grid grid-cols-2 gap-1.5">
|
||||
<div className={cn("stagger-enter h-full", visible[15] && "visible")}>
|
||||
<FiltersConfigCard />
|
||||
</div>
|
||||
<div className={cn("stagger-enter h-full", visible[16] && "visible")}>
|
||||
{/* Placeholder for future card */}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useFilterConfig } from "@/hooks/use-filter-config";
|
||||
import { AlertCircle, CheckCircle2, RefreshCcw } from "lucide-react";
|
||||
|
||||
export function FiltersConfigCard() {
|
||||
const { config, loading, error, updating, toggleFilter, refresh } = useFilterConfig();
|
||||
|
||||
if (loading && !config) {
|
||||
return (
|
||||
<Card className="glass">
|
||||
<CardHeader>
|
||||
<CardTitle>Entry Filters</CardTitle>
|
||||
<CardDescription>Loading...</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<Skeleton className="h-4 w-[200px]" />
|
||||
<Skeleton className="h-5 w-9 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="glass border-red-500/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-red-400">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
Entry Filters
|
||||
</CardTitle>
|
||||
<CardDescription className="text-red-400/70">{error}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
Retry
|
||||
</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
const filters = Object.entries(config.filters);
|
||||
const enabledCount = filters.filter(([, f]) => f.enabled).length;
|
||||
|
||||
// Sort by name for consistent display
|
||||
const sortedFilters = filters.sort((a, b) => a[1].name.localeCompare(b[1].name));
|
||||
|
||||
return (
|
||||
<Card className="glass">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
Entry Filters
|
||||
<Badge variant="outline" className="font-normal">
|
||||
{enabledCount}/{filters.length}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Toggle filters on/off — updates live without bot restart
|
||||
</CardDescription>
|
||||
</div>
|
||||
<button
|
||||
onClick={refresh}
|
||||
disabled={updating}
|
||||
className="p-2 hover:bg-white/5 rounded-lg transition-colors disabled:opacity-50"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCcw className={`h-4 w-4 ${updating ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{sortedFilters.map(([key, filter]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-start justify-between gap-4 py-2 border-b border-white/5 last:border-0"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{filter.name}</span>
|
||||
{filter.enabled ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-400 shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="h-3.5 w-3.5 text-orange-400 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{filter.description}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={filter.enabled}
|
||||
onCheckedChange={() => toggleFilter(key)}
|
||||
disabled={updating}
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{config.metadata?.updated_at && (
|
||||
<div className="text-xs text-muted-foreground pt-2 border-t border-white/5">
|
||||
Last updated: {new Date(config.metadata.updated_at).toLocaleString('id-ID', {
|
||||
timeZone: 'Asia/Jakarta',
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short'
|
||||
})} WIB
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -16,3 +16,4 @@ export { EntryFilterCard } from "./entry-filter-card";
|
||||
export { PerformanceCard } from "./performance-card";
|
||||
export { ModelCard } from "./model-card";
|
||||
export { ModelDialog } from "./model-dialog";
|
||||
export { FiltersConfigCard } from "./filters-config-card";
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { FilterConfigData, FilterUpdate } from '@/types/filters';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8001';
|
||||
|
||||
export function useFilterConfig() {
|
||||
const [config, setConfig] = useState<FilterConfigData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`${API_URL}/api/filters/config`);
|
||||
if (!response.ok) throw new Error('Failed to fetch filter config');
|
||||
const data = await response.json();
|
||||
setConfig(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateFilters = async (updates: FilterUpdate) => {
|
||||
try {
|
||||
setUpdating(true);
|
||||
const response = await fetch(`${API_URL}/api/filters/config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update filters');
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
// Refresh config after successful update
|
||||
await fetchConfig();
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Update failed');
|
||||
throw err;
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFilter = async (filterKey: string) => {
|
||||
if (!config) return;
|
||||
|
||||
const currentState = config.filters[filterKey]?.enabled ?? true;
|
||||
await updateFilters({ [filterKey]: !currentState });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
// Refresh every 30 seconds
|
||||
const interval = setInterval(fetchConfig, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
config,
|
||||
loading,
|
||||
error,
|
||||
updating,
|
||||
toggleFilter,
|
||||
updateFilters,
|
||||
refresh: fetchConfig,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface FilterConfig {
|
||||
enabled: boolean;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface FilterConfigData {
|
||||
filters: Record<string, FilterConfig>;
|
||||
metadata: {
|
||||
updated_at: string;
|
||||
version: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FilterUpdate {
|
||||
[key: string]: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user