diff --git a/data/filter_config.json b/data/filter_config.json
index b529c74..d0a2a00 100644
--- a/data/filter_config.json
+++ b/data/filter_config.json
@@ -57,7 +57,7 @@
}
},
"metadata": {
- "updated_at": "2026-02-09T08:05:00+07:00",
+ "updated_at": "2026-02-09T08:28:17.078826+07:00",
"version": "1.0"
}
-}
+}
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
index 5dfec04..c098257 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -39,8 +39,8 @@ services:
ports:
- "${API_PORT:-8000}:8000"
volumes:
- # Mount data/ so API can read bot_status.json written by the bot on host
- - ./data:/app/data:ro
+ # Mount data/ for reading bot_status.json and writing filter_config.json
+ - ./data:/app/data
depends_on:
postgres:
condition: service_healthy
diff --git a/src/smart_risk_manager.py b/src/smart_risk_manager.py
index 1f808a1..0b6bb6e 100644
--- a/src/smart_risk_manager.py
+++ b/src/smart_risk_manager.py
@@ -686,13 +686,22 @@ class SmartRiskManager:
current_hour = now.hour
# Early cut: If loss > 30% of max and momentum negative, cut early
+ # GRACE PERIOD: Wait at least 1 M15 candle (15 min) before early cut
+ # Intra-candle moves are noise — let the trade develop on its timeframe
+ trade_age_seconds = (now - guard.entry_time).total_seconds()
+ trade_age_minutes = trade_age_seconds / 60
+
if current_profit < 0:
loss_percent_of_max = abs(current_profit) / self.max_loss_per_trade * 100
# Cut early if momentum is against us AND loss is significant
+ # BUT only after grace period (15 min = 1 M15 candle)
if momentum < -50 and loss_percent_of_max >= 30: # #24B: relaxed from -30 (backtest +$125)
- logger.info(f"[EARLY CUT] Loss ${abs(current_profit):.2f} ({loss_percent_of_max:.0f}%) + weak momentum ({momentum:.0f}) - CUTTING EARLY")
- return True, ExitReason.TREND_REVERSAL, f"[EARLY CUT] Loss ${abs(current_profit):.2f} + momentum {momentum:.0f} - cutting to preserve daily limit"
+ if trade_age_minutes < 15:
+ logger.info(f"[GRACE] Loss ${abs(current_profit):.2f} ({loss_percent_of_max:.0f}%) + momentum ({momentum:.0f}) — holding {trade_age_minutes:.1f}m/{15}m grace period")
+ else:
+ logger.info(f"[EARLY CUT] Loss ${abs(current_profit):.2f} ({loss_percent_of_max:.0f}%) + weak momentum ({momentum:.0f}) - CUTTING EARLY (age: {trade_age_minutes:.0f}m)")
+ return True, ExitReason.TREND_REVERSAL, f"[EARLY CUT] Loss ${abs(current_profit):.2f} + momentum {momentum:.0f} - cutting to preserve daily limit"
# NOTE: Smart Hold REMOVED - no more holding losers hoping for golden time
# If SL is hit, close the trade immediately
diff --git a/web-dashboard/src/app/page.tsx b/web-dashboard/src/app/page.tsx
index 985d63a..22ee563 100644
--- a/web-dashboard/src/app/page.tsx
+++ b/web-dashboard/src/app/page.tsx
@@ -13,12 +13,11 @@ import {
PositionsCard,
LogCard,
PriceChart,
- EquityChart,
BotStatusCard,
EntryFilterCard,
PerformanceCard,
ModelCard,
- FiltersConfigCard,
+ AssistantCard,
} from "@/components/dashboard";
import { Skeleton } from "@/components/ui/skeleton";
import { TooltipProvider } from "@/components/ui/tooltip";
@@ -69,7 +68,7 @@ function ErrorDisplay({ message }: { message: string }) {
export default function Dashboard() {
const { data, loading, error, dataAge } = useTradingData();
- const visible = useStaggerEntry(17, 40);
+ const visible = useStaggerEntry(15, 40);
const now = new Date();
const wibTime = now.toLocaleTimeString("en-US", {
@@ -204,10 +203,7 @@ export default function Dashboard() {
@@ -237,15 +233,6 @@ export default function Dashboard() {
- {/* Row 5: Filter Controls */}
-
-
-
-
-
- {/* Placeholder for future card */}
-
-
diff --git a/web-dashboard/src/components/dashboard/assistant-card.tsx b/web-dashboard/src/components/dashboard/assistant-card.tsx
new file mode 100644
index 0000000..9177ded
--- /dev/null
+++ b/web-dashboard/src/components/dashboard/assistant-card.tsx
@@ -0,0 +1,404 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ Bot, TrendingUp, TrendingDown, ShieldAlert, Clock, Zap,
+ AlertTriangle, Coffee, CheckCircle2, XCircle, Minus,
+ Activity, Target, BarChart3, Eye,
+} from "lucide-react";
+import { cn } from "@/lib/utils";
+import type { TradingStatus } from "@/types/trading";
+
+type InsightType = "info" | "success" | "warning" | "danger";
+
+interface Insight {
+ icon: React.ReactNode;
+ text: string;
+ type: InsightType;
+}
+
+const typeStyles: Record = {
+ info: "text-blue-400/90",
+ success: "text-apple-green",
+ warning: "text-orange-400",
+ danger: "text-apple-red",
+};
+
+const sessionNames: Record = {
+ sydney: "Sydney", tokyo: "Tokyo", london: "London",
+ new_york: "New York", off_hours: "Off Hours",
+};
+
+function generateInsights(data: TradingStatus): Insight[] {
+ const insights: Insight[] = [];
+ const hasPositions = data.positions && data.positions.length > 0;
+ const sessionName = sessionNames[data.session?.toLowerCase()] || data.session;
+ const regimeName = data.regime?.name || "unknown";
+ const regimeConf = data.regime?.confidence ? (data.regime.confidence * 100).toFixed(0) : "?";
+ const smcSignal = data.smc?.signal || "NONE";
+ const smcConf = data.smc?.confidence ? (data.smc.confidence * 100).toFixed(0) : "0";
+ const mlSignal = data.ml?.signal || "HOLD";
+ const mlConf = data.ml?.confidence ? (data.ml.confidence * 100).toFixed(0) : "0";
+ const h1Bias = data.h1Bias || "N/A";
+ const dynThreshold = data.dynamicThreshold ? (data.dynamicThreshold * 100).toFixed(0) : "60";
+ const spread = data.spread?.toFixed(1) || "?";
+
+ // ═══════════════════════════════════════════
+ // 1. STATUS MARKET
+ // ═══════════════════════════════════════════
+ if (data.marketClose && !data.marketClose.marketOpen) {
+ insights.push({
+ icon: ,
+ text: "Market sedang tutup. Bot dalam mode standby — tidak ada analisis atau eksekusi. Menunggu market buka kembali.",
+ type: "info",
+ });
+ return insights;
+ }
+
+ if (data.marketClose?.nearWeekend) {
+ const hrs = data.marketClose.hoursToWeekendClose;
+ insights.push({
+ icon: ,
+ text: `Peringatan: Weekend close dalam ${hrs.toFixed(1)} jam (Sabtu 05:00 WIB). Bot akan menolak entry baru dan memastikan semua posisi ditutup sebelum market close.`,
+ type: "warning",
+ });
+ }
+
+ // ═══════════════════════════════════════════
+ // 2. RANGKUMAN SITUASI (top-level summary)
+ // ═══════════════════════════════════════════
+ if (hasPositions) {
+ const totalProfit = data.positions.reduce((sum, p) => sum + p.profit, 0);
+ const profitStr = totalProfit >= 0 ? `+$${totalProfit.toFixed(2)}` : `-$${Math.abs(totalProfit).toFixed(2)}`;
+ insights.push({
+ icon: ,
+ text: `Sedang dalam posisi (${data.positions.length} trade aktif, total P/L: ${profitStr}). Bot memantau exit conditions setiap 5 detik.`,
+ type: totalProfit >= 0 ? "success" : "warning",
+ });
+ } else {
+ insights.push({
+ icon: ,
+ text: `Tidak ada posisi terbuka. Bot menganalisis market setiap candle M15 untuk mencari peluang entry.`,
+ type: "info",
+ });
+ }
+
+ // ═══════════════════════════════════════════
+ // 3. SESI & WAKTU
+ // ═══════════════════════════════════════════
+ if (data.isGoldenTime) {
+ insights.push({
+ icon: ,
+ text: `Sesi ${sessionName} — GOLDEN TIME! Overlap London-NY menghasilkan volatilitas tertinggi. Lot size dinaikkan ${data.sessionMultiplier || 1}x. Peluang terbaik untuk entry.`,
+ type: "success",
+ });
+ } else if (!data.canTrade) {
+ const nextSession = data.session?.toLowerCase() === "off_hours" ? "Sydney (04:00 WIB)"
+ : data.session?.toLowerCase() === "sydney" ? "London (14:00 WIB)"
+ : "sesi berikutnya";
+ insights.push({
+ icon: ,
+ text: `Sesi ${sessionName} — di luar jam trading aktif. Volume rendah, spread bisa melebar. Menunggu ${nextSession}.`,
+ type: "info",
+ });
+ } else {
+ const multiplierText = data.sessionMultiplier && data.sessionMultiplier > 1
+ ? ` Lot multiplier: ${data.sessionMultiplier}x.`
+ : data.sessionMultiplier && data.sessionMultiplier < 1
+ ? ` SAFE MODE: lot dikurangi ${data.sessionMultiplier}x.`
+ : "";
+ insights.push({
+ icon: ,
+ text: `Sesi ${sessionName} aktif — market terbuka untuk trading.${multiplierText}`,
+ type: "info",
+ });
+ }
+
+ if (data.timeFilter?.isBlocked) {
+ insights.push({
+ icon: ,
+ text: `Jam ${data.timeFilter.wibHour}:00 WIB diblokir oleh time filter (jam-jam dengan win rate rendah secara historis). Entry ditunda.`,
+ type: "warning",
+ });
+ }
+
+ // ═══════════════════════════════════════════
+ // 4. KONDISI MARKET (regime + spread + H1)
+ // ═══════════════════════════════════════════
+ const regimeDetail = regimeName.includes("trending")
+ ? `Market trending (HMM confidence ${regimeConf}%) — kondisi ideal. Harga bergerak terarah, sinyal lebih reliable.`
+ : regimeName.includes("high_volatility") || regimeName.includes("volatile")
+ ? `Volatilitas tinggi (HMM ${regimeConf}%) — HATI-HATI! Pergerakan harga liar, SL bisa cepat tersentuh. Bot menaikkan spread tolerance.`
+ : regimeName.includes("low_volatility") || regimeName.includes("ranging")
+ ? `Volatilitas rendah / ranging (HMM ${regimeConf}%) — market diam, peluang kecil. Bot menunggu breakout atau perubahan regime.`
+ : `Regime: ${regimeName} (HMM ${regimeConf}%).`;
+
+ insights.push({
+ icon: regimeName.includes("trending") ?
+ : regimeName.includes("volatile") || regimeName.includes("high") ?
+ : ,
+ text: regimeDetail,
+ type: regimeName.includes("trending") ? "success"
+ : regimeName.includes("volatile") || regimeName.includes("high") ? "warning"
+ : "info",
+ });
+
+ // H1 Bias detail
+ if (h1Bias && h1Bias !== "N/A") {
+ const h1Text = h1Bias === "BULLISH"
+ ? `H1 Bias: BULLISH — harga di atas EMA20 H1, tren naik jangka menengah. Entry BUY diizinkan.`
+ : h1Bias === "BEARISH"
+ ? `H1 Bias: BEARISH — harga di bawah EMA20 H1, tren turun jangka menengah. Entry SELL diizinkan.`
+ : `H1 Bias: NEUTRAL — harga dekat EMA20 H1, tidak ada tren jelas. Entry di-hold sampai arah terbentuk.`;
+ insights.push({
+ icon: h1Bias === "BULLISH" ?
+ : h1Bias === "BEARISH" ?
+ : ,
+ text: h1Text,
+ type: h1Bias === "NEUTRAL" ? "warning" : "info",
+ });
+ }
+
+ // Spread
+ insights.push({
+ icon: ,
+ text: `Spread saat ini: ${spread} pips. ${parseFloat(spread) > 40 ? "Cukup lebar — entry mungkin ditunda." : parseFloat(spread) > 25 ? "Normal." : "Ketat — kondisi bagus."}`,
+ type: parseFloat(spread) > 40 ? "warning" : "info",
+ });
+
+ // ═══════════════════════════════════════════
+ // 5. ANALISIS SINYAL
+ // ═══════════════════════════════════════════
+ if (smcSignal !== "NONE" && smcSignal !== "HOLD") {
+ const smcReason = data.smc?.reason || "";
+ const mlAgrees = mlSignal === smcSignal;
+ const mlAboveThreshold = data.ml?.confidence && data.ml.confidence * 100 >= parseFloat(dynThreshold);
+
+ if (mlAgrees && mlAboveThreshold) {
+ insights.push({
+ icon: ,
+ text: `SINYAL KUAT: SMC ${smcSignal} (${smcConf}%) + ML ${mlSignal} (${mlConf}%) — keduanya sepakat dan di atas threshold ${dynThreshold}%.${smcReason ? ` SMC: ${smcReason}.` : ""} Tinggal filter lain terpenuhi untuk entry.`,
+ type: "success",
+ });
+ } else if (mlAgrees && !mlAboveThreshold) {
+ insights.push({
+ icon: ,
+ text: `SMC ${smcSignal} (${smcConf}%) & ML setuju ${mlSignal}, tapi confidence ML (${mlConf}%) masih di bawah threshold (${dynThreshold}%). Perlu lebih yakin.`,
+ type: "warning",
+ });
+ } else {
+ insights.push({
+ icon: ,
+ text: `Sinyal konflik — SMC: ${smcSignal} (${smcConf}%) vs ML: ${mlSignal} (${mlConf}%). Bot menunggu kedua model sinkron sebelum entry.${smcReason ? ` SMC: ${smcReason}.` : ""}`,
+ type: "info",
+ });
+ }
+ } else {
+ insights.push({
+ icon: ,
+ text: `Belum ada sinyal — SMC: ${smcSignal}, ML: ${mlSignal} (${mlConf}%). Menunggu setup terbentuk di candle M15 berikutnya.`,
+ type: "info",
+ });
+ }
+
+ // ═══════════════════════════════════════════
+ // 6. POSISI TERBUKA (detail)
+ // ═══════════════════════════════════════════
+ if (hasPositions) {
+ for (const pos of data.positions) {
+ const detail = data.positionDetails?.find((d) => d.ticket === pos.ticket);
+ const profitStr = pos.profit >= 0 ? `+$${pos.profit.toFixed(2)}` : `-$${Math.abs(pos.profit).toFixed(2)}`;
+ const dir = pos.type;
+
+ const ageMinutes = detail?.tradeHours ? detail.tradeHours * 60 : 0;
+ const ageText = ageMinutes > 0
+ ? ageMinutes < 60 ? `${ageMinutes.toFixed(0)}m` : `${(ageMinutes / 60).toFixed(1)}j`
+ : "";
+ const momentumVal = detail?.momentum ?? 0;
+ const tpProb = detail?.tpProbability ?? 0;
+ const peakProfit = detail?.peakProfit ?? 0;
+ const drawdown = detail?.drawdownFromPeak ?? 0;
+ const reversalWarns = detail?.reversalWarnings ?? 0;
+
+ let analysis = "";
+ if (pos.profit >= 20) {
+ analysis = `Profit sangat baik! Trailing SL aktif mengunci keuntungan. Peak: $${peakProfit.toFixed(2)}, drawdown dari peak: $${drawdown.toFixed(2)}. TP probability: ${tpProb.toFixed(0)}%.`;
+ } else if (pos.profit >= 10) {
+ analysis = `Profit bagus — momentum ${momentumVal > 0 ? "positif" : "melemah"} (${momentumVal.toFixed(0)}). Peak profit: $${peakProfit.toFixed(2)}. ${tpProb > 50 ? "Peluang capai TP masih tinggi." : "Mulai pantau untuk ambil profit."}`;
+ } else if (pos.profit >= 0) {
+ analysis = `Masih floating ${profitStr} (${ageText}). Momentum: ${momentumVal.toFixed(0)}. ${ageMinutes < 15 ? "Masih dalam grace period 15 menit — biarkan trade berkembang." : "Memantau arah selanjutnya."}`;
+ } else if (ageMinutes < 15) {
+ analysis = `Loss ${profitStr} tapi masih GRACE PERIOD (${ageText}/15m). Early cut TIDAK aktif — memberi waktu 1 candle M15 untuk develop. Hard SL tetap jadi safety net.`;
+ } else {
+ analysis = `Loss ${profitStr} (${ageText}), momentum: ${momentumVal.toFixed(0)}. ${momentumVal < -50 ? "Momentum lemah — early cut bisa trigger kapan saja!" : "Momentum belum terlalu buruk, masih ada harapan recovery."}${reversalWarns > 0 ? ` Reversal warning: ${reversalWarns}x.` : ""}`;
+ }
+
+ insights.push({
+ icon: pos.profit >= 5 ?
+ : pos.profit >= 0 ?
+ : pos.profit > -15 ?
+ : ,
+ text: `📊 #${pos.ticket} ${dir} @ ${pos.priceOpen.toFixed(2)} — ${analysis}`,
+ type: pos.profit >= 5 ? "success" : pos.profit >= 0 ? "info" : pos.profit > -15 ? "warning" : "danger",
+ });
+ }
+ }
+
+ // ═══════════════════════════════════════════
+ // 7. KENAPA TIDAK ENTRY (detail per filter)
+ // ═══════════════════════════════════════════
+ if (!hasPositions) {
+ const filters = data.entryFilters || [];
+ const blockers = filters.filter((f) => !f.passed && !f.detail?.includes("[DISABLED]"));
+ const disabledFilters = filters.filter((f) => f.detail?.includes("[DISABLED]"));
+ const passedCount = filters.filter((f) => f.passed).length;
+
+ if (blockers.length > 0) {
+ insights.push({
+ icon: ,
+ text: `Entry diblokir oleh ${blockers.length} filter (${passedCount}/${filters.length} lolos). Filter pertama yang gagal: "${blockers[0].name}" — ${blockers[0].detail || "tidak memenuhi syarat"}. Bot tidak akan entry sampai SEMUA filter hijau.`,
+ type: "warning",
+ });
+ } else if (filters.length > 0 && blockers.length === 0) {
+ insights.push({
+ icon: ,
+ text: `Semua ${filters.length} filter terpenuhi! Bot siap entry begitu ada sinyal valid dari SMC + ML.`,
+ type: "success",
+ });
+ }
+
+ if (disabledFilters.length > 0) {
+ const names = disabledFilters.map((f) => f.name).join(", ");
+ insights.push({
+ icon: ,
+ text: `${disabledFilters.length} filter dinonaktifkan manual: ${names}. Filter ini di-bypass (auto-pass). Aktifkan kembali di panel Filters jika diperlukan.`,
+ type: "warning",
+ });
+ }
+ }
+
+ // ═══════════════════════════════════════════
+ // 8. RISK & MODAL
+ // ═══════════════════════════════════════════
+ const netPnl = (data.dailyProfit || 0) - (data.dailyLoss || 0);
+ if (data.dailyLoss > 0 || data.dailyProfit > 0) {
+ const pnlStr = netPnl >= 0 ? `+$${netPnl.toFixed(2)}` : `-$${Math.abs(netPnl).toFixed(2)}`;
+ const remaining = data.riskMode?.remainingDailyRisk;
+ const riskDetail = remaining !== undefined ? ` Sisa risk harian: $${remaining.toFixed(0)}.` : "";
+
+ if (netPnl < 0 && remaining !== undefined && remaining < 50) {
+ insights.push({
+ icon: ,
+ text: `⚠️ P/L hari ini: ${pnlStr} (loss $${data.dailyLoss.toFixed(2)}, profit $${data.dailyProfit.toFixed(2)}).${riskDetail} Mendekati batas — bot sangat konservatif.`,
+ type: "danger",
+ });
+ } else {
+ insights.push({
+ icon: netPnl >= 0 ? : ,
+ text: `P/L hari ini: ${pnlStr} (loss $${data.dailyLoss.toFixed(2)}, profit $${data.dailyProfit.toFixed(2)}).${riskDetail}`,
+ type: netPnl >= 0 ? "success" : "warning",
+ });
+ }
+ }
+
+ if (data.riskMode) {
+ const mode = data.riskMode.mode?.toUpperCase() || "NORMAL";
+ if (mode !== "NORMAL") {
+ insights.push({
+ icon: ,
+ text: `Risk mode: ${mode} — ${data.riskMode.reason}. Lot: ${data.riskMode.recommendedLot} (max ${data.riskMode.maxAllowedLot}).`,
+ type: mode === "RECOVERY" || mode === "PROTECTIVE" ? "danger" : "warning",
+ });
+ }
+ }
+
+ // ═══════════════════════════════════════════
+ // 9. PERFORMA BOT
+ // ═══════════════════════════════════════════
+ if (data.performance) {
+ const p = data.performance;
+ const uptimeText = p.uptimeHours < 1
+ ? `${(p.uptimeHours * 60).toFixed(0)} menit`
+ : `${p.uptimeHours.toFixed(1)} jam`;
+ insights.push({
+ icon: ,
+ text: `Bot aktif ${uptimeText}, loop ke-${p.loopCount}. Avg execution: ${p.avgExecutionMs.toFixed(0)}ms. Session trades: ${p.totalSessionTrades} (P/L: ${p.totalSessionProfit >= 0 ? "+" : ""}$${p.totalSessionProfit.toFixed(2)}).`,
+ type: "info",
+ });
+ }
+
+ return insights;
+}
+
+interface AssistantCardProps {
+ data: TradingStatus;
+}
+
+export function AssistantCard({ data }: AssistantCardProps) {
+ const insights = generateInsights(data);
+ const [wibTime, setWibTime] = useState("");
+
+ useEffect(() => {
+ const update = () => {
+ setWibTime(
+ new Date().toLocaleString("id-ID", {
+ timeZone: "Asia/Jakarta",
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ hour12: false,
+ })
+ );
+ };
+ update();
+ const interval = setInterval(update, 1000);
+ return () => clearInterval(interval);
+ }, []);
+
+ return (
+
+
+
+
+ Asisten Bot
+
+ {wibTime} WIB
+
+
+
+
+
+ {insights.map((insight, idx) => (
+
+
+ {insight.icon}
+
+ {insight.text}
+
+ ))}
+
+
+ {/* Last analysis timestamp */}
+
+
+ Analisis terakhir: {data.timestamp
+ ? new Date(data.timestamp).toLocaleString("id-ID", {
+ timeZone: "Asia/Jakarta",
+ day: "2-digit",
+ month: "short",
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ hour12: false,
+ })
+ : wibTime
+ } WIB
+
+
+
+ );
+}
diff --git a/web-dashboard/src/components/dashboard/entry-filter-card.tsx b/web-dashboard/src/components/dashboard/entry-filter-card.tsx
index 8dc5ed9..fade75c 100644
--- a/web-dashboard/src/components/dashboard/entry-filter-card.tsx
+++ b/web-dashboard/src/components/dashboard/entry-filter-card.tsx
@@ -2,16 +2,35 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
+import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Filter, Check, X, Minus } from "lucide-react";
import { cn } from "@/lib/utils";
+import { useFilterConfig } from "@/hooks/use-filter-config";
import type { EntryFilter } from "@/types/trading";
+/* Map bot filter display name → filter_config.json key */
+const NAME_TO_KEY: Record = {
+ "Flash Crash Guard": "flash_crash_guard",
+ "Regime Filter": "regime_filter",
+ "Risk Check": "risk_check",
+ "Session Filter": "session_filter",
+ "Spread Check": "spread_check",
+ "H1 Bias (#31B)": "h1_bias",
+ "ML Confidence": "ml_confidence",
+ "Signal Combination": "signal_combination",
+ "Time Filter (#34A)": "time_filter",
+ "Cooldown": "cooldown",
+ "Existing Position": "existing_position",
+};
+
interface EntryFilterCardProps {
filters: EntryFilter[];
}
export function EntryFilterCard({ filters }: EntryFilterCardProps) {
+ const { config, updating, toggleFilter } = useFilterConfig();
+
const passedCount = filters.filter((f) => f.passed).length;
const totalCount = filters.length;
const hasBlocker = filters.some((f) => !f.passed);
@@ -44,33 +63,66 @@ export function EntryFilterCard({ filters }: EntryFilterCardProps) {
{filters.map((filter, idx) => {
const isNotEvaluated = firstBlockerIdx >= 0 && idx > firstBlockerIdx;
const isBlocker = !filter.passed && idx === firstBlockerIdx;
+ const isDisabled = filter.detail?.includes("[DISABLED]");
+
+ /* Resolve filter config key for toggle */
+ const configKey = NAME_TO_KEY[filter.name];
+ const filterEnabled = configKey && config
+ ? config.filters[configKey]?.enabled ?? true
+ : true;
return (
-
-
-
- {isNotEvaluated ? (
-
- ) : filter.passed ? (
-
- ) : (
-
- )}
-
+
+ {isDisabled ? (
+
+ ) : isNotEvaluated ? (
+
+ ) : filter.passed ? (
+
+ ) : (
+
+ )}
+
+
+
+
{filter.name}
-
-
-
- {filter.detail || (filter.passed ? "Passed" : "Blocked")}
-
-
+
+
+
+ {filter.detail || (filter.passed ? "Passed" : "Blocked")}
+ {configKey && !filterEnabled && " — Filter disabled, auto-pass"}
+
+
+
+
+ {/* Toggle switch — outside tooltip so clicks work */}
+ {configKey && config && (
+ toggleFilter(configKey)}
+ disabled={updating}
+ className="shrink-0 scale-75 origin-right"
+ />
+ )}
+
);
})}
diff --git a/web-dashboard/src/components/dashboard/filters-config-card.tsx b/web-dashboard/src/components/dashboard/filters-config-card.tsx
deleted file mode 100644
index 16c3c10..0000000
--- a/web-dashboard/src/components/dashboard/filters-config-card.tsx
+++ /dev/null
@@ -1,128 +0,0 @@
-"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 (
-
-
- Entry Filters
- Loading...
-
-
- {[1, 2, 3, 4, 5].map((i) => (
-
-
-
-
- ))}
-
-
- );
- }
-
- if (error) {
- return (
-
-
-
-
- Entry Filters
-
- {error}
-
-
-
-
-
- );
- }
-
- 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 (
-
-
-
-
-
- Entry Filters
-
- {enabledCount}/{filters.length}
-
-
-
- Toggle filters on/off — updates live without bot restart
-
-
-
-
-
-
- {sortedFilters.map(([key, filter]) => (
-
-
-
-
{filter.name}
- {filter.enabled ? (
-
- ) : (
-
- )}
-
-
- {filter.description}
-
-
-
toggleFilter(key)}
- disabled={updating}
- className="shrink-0"
- />
-
- ))}
-
- {config.metadata?.updated_at && (
-
- Last updated: {new Date(config.metadata.updated_at).toLocaleString('id-ID', {
- timeZone: 'Asia/Jakarta',
- dateStyle: 'short',
- timeStyle: 'short'
- })} WIB
-
- )}
-
-
- );
-}
diff --git a/web-dashboard/src/components/dashboard/index.ts b/web-dashboard/src/components/dashboard/index.ts
index 3b815a7..0aea214 100644
--- a/web-dashboard/src/components/dashboard/index.ts
+++ b/web-dashboard/src/components/dashboard/index.ts
@@ -16,4 +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";
+export { AssistantCard } from "./assistant-card";
diff --git a/web-dashboard/src/hooks/use-filter-config.ts b/web-dashboard/src/hooks/use-filter-config.ts
index d21adc0..c3dda00 100644
--- a/web-dashboard/src/hooks/use-filter-config.ts
+++ b/web-dashboard/src/hooks/use-filter-config.ts
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import type { FilterConfigData, FilterUpdate } from '@/types/filters';
-const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8001';
+const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
export function useFilterConfig() {
const [config, setConfig] = useState(null);