Simplify portfolio strategy UI and reconnect handling

This commit is contained in:
Thigazhezhilan J 2026-03-24 21:59:17 +05:30
parent 469f204452
commit aa93225ef7
4 changed files with 283 additions and 232 deletions

View File

@ -18,6 +18,19 @@ type StrategyRun = {
events: StrategyEvent[]; events: StrategyEvent[];
}; };
type StrategySummary = {
run_id?: string | null;
status?: string | null;
tone?: "neutral" | "warning" | "error" | "success";
message?: string | null;
event?: string | null;
ts?: string | null;
};
type StrategyTimelineProps = {
compact?: boolean;
};
function normalizeLog(log: unknown): StrategyEvent { function normalizeLog(log: unknown): StrategyEvent {
if (typeof log === "string") { if (typeof log === "string") {
try { try {
@ -45,6 +58,16 @@ function formatTimestamp(value?: string) {
return parsed.toLocaleString(); return parsed.toLocaleString();
} }
function formatCompactTimestamp(value?: string | null) {
if (!value) return null;
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return null;
return parsed.toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
});
}
function getRunId(entry: StrategyEvent) { function getRunId(entry: StrategyEvent) {
return entry.run_id ?? "unknown"; return entry.run_id ?? "unknown";
} }
@ -104,7 +127,62 @@ function getStopReason(events: StrategyEvent[]) {
return typeof reason === "string" ? reason : null; return typeof reason === "string" ? reason : null;
} }
export default function StrategyTimeline() { function CompactStrategySummary() {
const [summary, setSummary] = useState<StrategySummary | null>(null);
useEffect(() => {
let cancelled = false;
const fetchSummary = async () => {
try {
const res = await apiRequest("GET", "/strategy/summary");
const data = (await res.json()) as StrategySummary;
if (!cancelled) {
setSummary(data);
}
} catch {
if (!cancelled) {
setSummary({
tone: "error",
message: "Could not load strategy status.",
});
}
}
};
fetchSummary();
const id = window.setInterval(fetchSummary, 5000);
return () => {
cancelled = true;
window.clearInterval(id);
};
}, []);
const tone = summary?.tone ?? "neutral";
const message = summary?.message || "No active strategy.";
const updatedAt = formatCompactTimestamp(summary?.ts);
const className =
tone === "error"
? "border-red-500/40 bg-red-500/10 text-red-300"
: tone === "warning"
? "border-amber-400/40 bg-amber-400/10 text-amber-200"
: tone === "success"
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-300"
: "border-border/60 bg-background/40 text-muted-foreground";
return (
<div className={`rounded-lg border px-4 py-3 text-sm ${className}`}>
<div className="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<span>{message}</span>
{updatedAt ? (
<span className="text-xs opacity-80">Updated {updatedAt}</span>
) : null}
</div>
</div>
);
}
function VerboseStrategyTimeline() {
const [logs, setLogs] = useState<StrategyEvent[]>([]); const [logs, setLogs] = useState<StrategyEvent[]>([]);
const latestSeqRef = useRef(0); const latestSeqRef = useRef(0);
@ -118,10 +196,10 @@ export default function StrategyTimeline() {
setLogs((prev) => { setLogs((prev) => {
const seenSeq = new Set( const seenSeq = new Set(
prev.map((entry) => entry.seq).filter((seq) => typeof seq === "number") prev.map((entry) => entry.seq).filter((seq) => typeof seq === "number"),
); );
const next = normalized.filter( const next = normalized.filter(
(entry) => typeof entry.seq !== "number" || !seenSeq.has(entry.seq) (entry) => typeof entry.seq !== "number" || !seenSeq.has(entry.seq),
); );
return [...prev, ...next]; return [...prev, ...next];
}); });
@ -131,7 +209,7 @@ export default function StrategyTimeline() {
} else { } else {
const lastSeq = normalized.reduce( const lastSeq = normalized.reduce(
(max, entry) => Math.max(max, entry.seq ?? 0), (max, entry) => Math.max(max, entry.seq ?? 0),
latestSeqRef.current latestSeqRef.current,
); );
latestSeqRef.current = lastSeq; latestSeqRef.current = lastSeq;
} }
@ -190,7 +268,7 @@ export default function StrategyTimeline() {
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div> <div>
<div className="text-sm font-semibold text-white/90"> <div className="text-sm font-semibold text-white/90">
Run started at {formatTimestamp(startTime)} Run started at {formatTimestamp(startTime)}
</div> </div>
<div className="text-xs text-green-300/70">Run ID: {run.runId}</div> <div className="text-xs text-green-300/70">Run ID: {run.runId}</div>
</div> </div>
@ -264,3 +342,10 @@ export default function StrategyTimeline() {
</div> </div>
); );
} }
export default function StrategyTimeline({ compact = false }: StrategyTimelineProps) {
if (compact) {
return <CompactStrategySummary />;
}
return <VerboseStrategyTimeline />;
}

View File

@ -32,6 +32,7 @@ type BrokerStatusResponse = {
connected_at?: string; connected_at?: string;
userName?: string; userName?: string;
brokerUserId?: string; brokerUserId?: string;
authState?: string;
}; };
const CALLBACK_STORAGE_KEY = "zerodha:callback"; const CALLBACK_STORAGE_KEY = "zerodha:callback";
@ -61,9 +62,11 @@ export default function BrokerConnectDialog({
const [apiSecret, setApiSecret] = useState(""); const [apiSecret, setApiSecret] = useState("");
const [holdings, setHoldings] = useState<any[]>([]); const [holdings, setHoldings] = useState<any[]>([]);
const { data: sessionUser } = useQuery<SessionUser | null>({ const { data: sessionUser, refetch: refetchSessionUser } = useQuery<SessionUser | null>({
queryKey: ["/me"], queryKey: ["/me"],
queryFn: getQueryFn<SessionUser | null>({ on401: "returnNull" }), queryFn: getQueryFn<SessionUser | null>({ on401: "returnNull" }),
staleTime: 0,
refetchOnMount: "always",
}); });
const { data: brokerStatus, refetch: refetchStatus } = useQuery<BrokerStatusResponse | null>({ const { data: brokerStatus, refetch: refetchStatus } = useQuery<BrokerStatusResponse | null>({
@ -100,6 +103,34 @@ export default function BrokerConnectDialog({
toast({ title: "Could not start Zerodha login", description: err?.message || "Try again." }), toast({ title: "Could not start Zerodha login", description: err?.message || "Try again." }),
}); });
const reconnectSavedMutation = useMutation({
mutationFn: async () => {
const res = await apiRequest("GET", "/broker/login-url");
return res.json() as Promise<{ loginUrl: string }>;
},
onSuccess: ({ loginUrl }) => {
window.open(loginUrl, "_blank", "noopener,noreferrer");
toast({
title: "Continue in Zerodha",
description: "Log in and return here. We will reconnect your broker automatically.",
});
},
onError: (err: any) => {
const message = String(err?.message || "");
if (message.includes("400:") && message.includes("Broker credentials not configured")) {
toast({
title: "Enter Zerodha API credentials",
description: "Saved credentials are missing. Enter the API key and secret once to reconnect.",
});
return;
}
toast({
title: "Could not reconnect Zerodha",
description: err?.message || "Try again.",
});
},
});
const holdingsMutation = useMutation({ const holdingsMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
const res = await apiRequest("GET", "/zerodha/holdings"); const res = await apiRequest("GET", "/zerodha/holdings");
@ -133,12 +164,15 @@ export default function BrokerConnectDialog({
}); });
const connected = !!brokerStatus?.connected; const connected = !!brokerStatus?.connected;
const canReconnectWithSavedZerodha =
connected && (brokerStatus?.broker || "").trim().toUpperCase() === "ZERODHA";
const connectedAt = brokerStatus?.connected_at const connectedAt = brokerStatus?.connected_at
? new Date(brokerStatus.connected_at) ? new Date(brokerStatus.connected_at)
: null; : null;
const handleConnectClick = () => { const handleConnectClick = async () => {
if (!sessionUser) { const latest = await refetchSessionUser();
if (!latest.data) {
setLoginPromptOpen(true); setLoginPromptOpen(true);
return; return;
} }
@ -297,6 +331,16 @@ export default function BrokerConnectDialog({
<ArrowUpRight className="h-4 w-4" /> <ArrowUpRight className="h-4 w-4" />
{loginUrlMutation.isPending ? "Opening Zerodha..." : "Open Zerodha login"} {loginUrlMutation.isPending ? "Opening Zerodha..." : "Open Zerodha login"}
</Button> </Button>
{canReconnectWithSavedZerodha ? (
<Button
variant="secondary"
onClick={() => reconnectSavedMutation.mutate()}
disabled={reconnectSavedMutation.isPending}
>
<RefreshCcw className="h-4 w-4" />
{reconnectSavedMutation.isPending ? "Opening Zerodha..." : "Reconnect saved Zerodha"}
</Button>
) : null}
{connected ? ( {connected ? (
<Button <Button
variant="secondary" variant="secondary"
@ -322,6 +366,10 @@ export default function BrokerConnectDialog({
After you log in with your Zerodha account, we will connect automatically. Keep this tab open After you log in with your Zerodha account, we will connect automatically. Keep this tab open
until the login completes. until the login completes.
</p> </p>
<p className="text-xs text-muted-foreground">
Zerodha access tokens expire daily. After the first setup, reconnect can use your saved API key
and secret without re-entering them.
</p>
</motion.div> </motion.div>
<div className="space-y-2 rounded-lg border border-dashed border-border/50 p-4 text-sm text-muted-foreground"> <div className="space-y-2 rounded-lg border border-dashed border-border/50 p-4 text-sm text-muted-foreground">

View File

@ -5,7 +5,7 @@ import { motion } from "framer-motion";
import { Wallet, BarChart3, AlertCircle, RefreshCcw, PlugZap } from "lucide-react"; import { Wallet, BarChart3, AlertCircle, RefreshCcw, PlugZap } from "lucide-react";
import BrokerConnectDialog from "./BrokerConnectDialog"; import BrokerConnectDialog from "./BrokerConnectDialog";
import LoginRequiredDialog from "./LoginRequiredDialog"; import LoginRequiredDialog from "./LoginRequiredDialog";
import { getQueryFn, apiRequest } from "@/lib/queryClient"; import { getQueryFn, apiRequest, queryClient } from "@/lib/queryClient";
import { getStrategyStatus, startStrategy, stopStrategy } from "@/api/strategy"; import { getStrategyStatus, startStrategy, stopStrategy } from "@/api/strategy";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -33,38 +33,7 @@ type BrokerStatusResponse = {
connected_at?: string; connected_at?: string;
userName?: string; userName?: string;
brokerUserId?: string; brokerUserId?: string;
}; authState?: string;
type SystemArmResponse = {
armed_runs: Array<{ run_id: string; status: string; next_run?: string; already_running?: boolean }>;
failed_runs: Array<{ run_id: string; status: string; reason: string }>;
next_execution?: string | null;
broker_state?: {
connected?: boolean;
auth_state?: string | null;
broker?: string | null;
user_name?: string | null;
};
};
type SystemStatusRun = {
run_id: string;
status: string;
strategy?: string | null;
mode?: string | null;
broker?: string | null;
next_run?: string | null;
lifecycle?: string | null;
};
type SystemStatusResponse = {
runs: SystemStatusRun[];
broker_state?: {
connected?: boolean;
auth_state?: string | null;
broker?: string | null;
user_name?: string | null;
};
}; };
type HoldingsResponse = { type HoldingsResponse = {
@ -177,7 +146,6 @@ export default function PortfolioSection() {
const [cachedHoldings, setCachedHoldings] = useState<any[]>([]); const [cachedHoldings, setCachedHoldings] = useState<any[]>([]);
const [cachedFunds, setCachedFunds] = useState<FundsResponse["funds"] | null>(null); const [cachedFunds, setCachedFunds] = useState<FundsResponse["funds"] | null>(null);
const [cachedEquityCurve, setCachedEquityCurve] = useState<EquityCurveResponse | null>(null); const [cachedEquityCurve, setCachedEquityCurve] = useState<EquityCurveResponse | null>(null);
const [armSummary, setArmSummary] = useState<SystemArmResponse | null>(null);
const { const {
data: brokerStatus, data: brokerStatus,
isFetching: brokerStatusLoading, isFetching: brokerStatusLoading,
@ -188,55 +156,67 @@ export default function PortfolioSection() {
staleTime: 0, staleTime: 0,
refetchOnMount: "always", refetchOnMount: "always",
}); });
const { data: sessionUser } = useQuery<SessionUser | null>({ const { data: sessionUser, refetch: refetchSessionUser } = useQuery<SessionUser | null>({
queryKey: ["/me"], queryKey: ["/me"],
queryFn: getQueryFn<SessionUser | null>({ on401: "returnNull" }), queryFn: getQueryFn<SessionUser | null>({ on401: "returnNull" }),
staleTime: 0,
refetchOnMount: "always",
}); });
const systemStatusQuery = useQuery<SystemStatusResponse>({
queryKey: ["/system/status"], async function startSavedBrokerReconnect() {
queryFn: getQueryFn<SystemStatusResponse>({ on401: "throw" }),
refetchInterval: 15000,
});
const armMutation = useMutation({
mutationFn: async () => {
const res = await fetch("/system/arm", {
method: "POST",
credentials: "include",
});
if (res.status === 401) {
let payload: any = {};
try { try {
payload = await res.json(); const res = await apiRequest("GET", "/broker/login-url");
} catch {} const data = (await res.json()) as { loginUrl?: string };
const redirect = if (!data?.loginUrl) {
payload?.detail?.redirect_url || throw new Error("Reconnect link unavailable.");
payload?.redirect_url ||
"/broker/login";
window.location.assign(redirect);
return null;
} }
if (!res.ok) { window.open(data.loginUrl, "_blank", "noopener,noreferrer");
const text = await res.text();
throw new Error(text || res.statusText);
}
return (await res.json()) as SystemArmResponse;
},
onSuccess: (data) => {
if (!data) return;
setArmSummary(data);
toast({ toast({
title: "System armed", title: "Continue in Zerodha",
description: data.next_execution description: "Log in and return here. We will reconnect your broker automatically.",
? `Next execution at ${new Date(data.next_execution).toLocaleString()}` });
: "All strategies are armed.", } catch (error: any) {
const message = String(error?.message || "");
if (message.includes("400:") && message.includes("Broker credentials not configured")) {
setBrokerDialogOpen(true);
toast({
title: "Enter Zerodha API credentials",
description: "Saved broker credentials are missing. Enter the API key and secret once to reconnect.",
});
return;
}
if (message.includes("401:")) {
setLoginPromptOpen(true);
return;
}
throw error;
}
}
const disconnectBrokerMutation = useMutation({
mutationFn: async () => {
const res = await apiRequest("POST", "/broker/disconnect");
return res.json();
},
onSuccess: async () => {
setSessionExpired(false);
setReconnectAttempted(false);
setCachedHoldings([]);
setCachedFunds(null);
setCachedEquityCurve(null);
queryClient.removeQueries({ queryKey: ["/zerodha/holdings"] });
queryClient.removeQueries({ queryKey: ["/zerodha/funds"] });
queryClient.removeQueries({ queryKey: ["/zerodha/equity-curve"] });
await refetchBrokerStatus();
toast({
title: "Broker disconnected",
description: "Your broker account has been disconnected.",
}); });
systemStatusQuery.refetch();
refetchBrokerStatus();
}, },
onError: (err: any) => onError: (err: any) =>
toast({ toast({
title: "Arm failed", title: "Disconnect failed",
description: err?.message || "Unable to arm system.", description: err?.message || "Unable to disconnect broker.",
}), }),
}); });
@ -325,7 +305,7 @@ export default function PortfolioSection() {
useEffect(() => { useEffect(() => {
const fetchStatus = async () => { const fetchStatus = async () => {
try { try {
const res = await fetch("/engine/status"); const res = await apiRequest("GET", "/engine/status");
const data = await res.json(); const data = await res.json();
setEngineStatus(data); setEngineStatus(data);
} catch { } catch {
@ -341,7 +321,7 @@ export default function PortfolioSection() {
useEffect(() => { useEffect(() => {
const fetchMarketStatus = async () => { const fetchMarketStatus = async () => {
try { try {
const res = await fetch("/market/status"); const res = await apiRequest("GET", "/market/status");
const data = await res.json(); const data = await res.json();
setMarketStatus(data); setMarketStatus(data);
} catch { } catch {
@ -431,18 +411,6 @@ export default function PortfolioSection() {
const holdings = holdingsQuery.data ? holdingsQuery.data.holdings : cachedHoldings; const holdings = holdingsQuery.data ? holdingsQuery.data.holdings : cachedHoldings;
const fundsSnapshot = fundsQuery.data?.funds ?? cachedFunds; const fundsSnapshot = fundsQuery.data?.funds ?? cachedFunds;
const noHoldings = holdings.length === 0; const noHoldings = holdings.length === 0;
const systemRuns = systemStatusQuery.data?.runs ?? [];
const armedCount = systemRuns.filter(
(run) => (run.status || "").toUpperCase() === "RUNNING",
).length;
const nextExecution = useMemo(() => {
const nextDates = systemRuns
.map((run) => (run.next_run ? new Date(run.next_run) : null))
.filter((value): value is Date => !!value && !Number.isNaN(value.getTime()));
if (!nextDates.length) return null;
nextDates.sort((a, b) => a.getTime() - b.getTime());
return nextDates[0];
}, [systemRuns]);
useEffect(() => { useEffect(() => {
if (!isConnected) { if (!isConnected) {
setSessionExpired(false); setSessionExpired(false);
@ -549,7 +517,7 @@ export default function PortfolioSection() {
const isEligible = eligibleSeconds <= 0; const isEligible = eligibleSeconds <= 0;
const relativeEligible = formatRelativeSeconds(eligibleSeconds); const relativeEligible = formatRelativeSeconds(eligibleSeconds);
let nextEligibleLine = ""; let nextEligibleLine = "-";
let eligibilityStatus: string | null = "First execution pending"; let eligibilityStatus: string | null = "First execution pending";
let eligibilityClass = "text-muted-foreground"; let eligibilityClass = "text-muted-foreground";
@ -557,11 +525,11 @@ export default function PortfolioSection() {
eligibilityStatus = null; eligibilityStatus = null;
if (isEligible && marketState === "OPEN") { if (isEligible && marketState === "OPEN") {
nextEligibleLine = "Now"; nextEligibleLine = "Now";
eligibilityStatus = "Eligible — execution imminent"; eligibilityStatus = "Eligible. Execution imminent.";
eligibilityClass = "text-emerald-400"; eligibilityClass = "text-emerald-400";
} else if (isEligible && marketState === "CLOSED") { } else if (isEligible && marketState === "CLOSED") {
nextEligibleLine = `${formatMinuteTimestamp(nextEligibleTs)} (eligible)`; nextEligibleLine = `${formatMinuteTimestamp(nextEligibleTs)} (eligible)`;
eligibilityStatus = "Eligible — waiting for market open"; eligibilityStatus = "Eligible. Waiting for market open.";
eligibilityClass = "text-amber-300"; eligibilityClass = "text-amber-300";
} else { } else {
nextEligibleLine = `${formatMinuteTimestamp(nextEligibleTs)} (${relativeEligible})`; nextEligibleLine = `${formatMinuteTimestamp(nextEligibleTs)} (${relativeEligible})`;
@ -570,46 +538,97 @@ export default function PortfolioSection() {
} }
} }
const requireLogin = useCallback(() => { const requireLogin = useCallback(async () => {
if (!sessionUser) { const latest = await refetchSessionUser();
if (!latest.data) {
setLoginPromptOpen(true); setLoginPromptOpen(true);
return false; return false;
} }
return true; return true;
}, [sessionUser]); }, [refetchSessionUser]);
const handleReconnectClick = useCallback(() => { const handleReconnectClick = useCallback(() => {
if (!requireLogin()) { requireLogin()
.then((isLoggedIn) => {
if (!isLoggedIn) {
return; return;
} }
if (brokerStatus?.connected && (brokerStatus?.broker || "").trim().toUpperCase() === "ZERODHA") {
return startSavedBrokerReconnect().catch((error: any) =>
toast({
title: "Reconnect failed",
description: error?.message || "Unable to start Zerodha reconnect.",
}),
);
}
setBrokerDialogOpen(true); setBrokerDialogOpen(true);
}, [requireLogin]); })
.catch(() => {
setLoginPromptOpen(true);
});
}, [brokerStatus, requireLogin]);
const handleRefreshBrokerData = useCallback(() => {
holdingsQuery.refetch();
fundsQuery.refetch();
equityCurveQuery.refetch();
refetchBrokerStatus();
}, [equityCurveQuery, fundsQuery, holdingsQuery, refetchBrokerStatus]);
const handleDisconnectBroker = useCallback(() => {
disconnectBrokerMutation.mutate();
}, [disconnectBrokerMutation]);
const handleStart = async () => { const handleStart = async () => {
if (!requireLogin()) { if (!(await requireLogin())) {
return; return;
} }
if (!isConnected) { if (!isConnected) {
setConnectPromptOpen(true); setConnectPromptOpen(true);
return; return;
} }
if (showSessionExpired || (brokerStatus?.authState || "").toUpperCase() === "EXPIRED") {
toast({
title: "Reconnect broker",
description: "Your Zerodha session has expired. Reconnect before starting the live strategy.",
});
handleReconnectClick();
return;
}
setIsStarting(true); setIsStarting(true);
try { try {
const result = await startStrategy({ const result = await startStrategy({
strategy_name: "Golden Nifty", strategy_name: "Golden Nifty",
initial_cash: availableFunds,
sip_amount: sipAmount, sip_amount: sipAmount,
sip_frequency: { sip_frequency: {
value: frequencyDays, value: frequencyDays,
unit: "days", unit: "days",
}, },
mode: "PAPER", mode: "LIVE",
}); });
if (result?.status === "broker_auth_required") {
toast({
title: "Reconnect broker",
description: "Your Zerodha session has expired. Reconnect and try again.",
});
await startSavedBrokerReconnect();
return;
}
if (result?.status === "already_running") { if (result?.status === "already_running") {
toast({ toast({
title: "Strategy already running", title: "Strategy already running",
description: "The engine is already active.", description: "The engine is already active.",
}); });
} else if (result?.status === "started" || result?.status === "restarted") {
toast({
title: "Live strategy started",
description: "Golden Nifty is now armed for live Zerodha execution.",
});
} else {
toast({
title: "Start failed",
description: result?.message || result?.status || "Unable to start strategy.",
});
} }
} finally { } finally {
setIsStarting(false); setIsStarting(false);
@ -762,12 +781,7 @@ export default function PortfolioSection() {
{isConnected ? ( {isConnected ? (
<Button <Button
variant="outline" variant="outline"
onClick={() => { onClick={handleRefreshBrokerData}
holdingsQuery.refetch();
fundsQuery.refetch();
equityCurveQuery.refetch();
refetchBrokerStatus();
}}
disabled={holdingsQuery.isFetching || equityCurveQuery.isFetching} disabled={holdingsQuery.isFetching || equityCurveQuery.isFetching}
> >
<RefreshCcw className="h-4 w-4" /> <RefreshCcw className="h-4 w-4" />
@ -776,6 +790,24 @@ export default function PortfolioSection() {
: "Refresh data"} : "Refresh data"}
</Button> </Button>
) : null} ) : null}
{isConnected ? (
<Button
variant="outline"
onClick={handleReconnectClick}
>
<PlugZap className="h-4 w-4" />
Reconnect broker
</Button>
) : null}
{isConnected ? (
<Button
variant="outline"
onClick={handleDisconnectBroker}
disabled={disconnectBrokerMutation.isPending}
>
{disconnectBrokerMutation.isPending ? "Disconnecting..." : "Disconnect broker"}
</Button>
) : null}
</div> </div>
</div> </div>
@ -887,109 +919,6 @@ export default function PortfolioSection() {
)} )}
</div> </div>
<div
className={`rounded-2xl border border-border/60 bg-card/70 shadow-xl overflow-hidden ${hoverLift} ${revealTransition} ${cardRevealClass}`}
style={prefersReducedMotion ? undefined : { transitionDelay: "560ms" }}
>
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 px-6 py-4 border-b border-border/50">
<div className="space-y-1">
<p className="text-sm font-semibold">System arm</p>
<p className="text-xs text-muted-foreground">
Re-arm all active strategies after broker login.
</p>
</div>
<Button
className="shimmer"
onClick={() => armMutation.mutate()}
disabled={armMutation.isPending || !isConnected}
>
<PlugZap className="h-4 w-4" />
{armMutation.isPending ? "Arming..." : "Arm All Strategies"}
</Button>
</div>
<div className="grid gap-4 md:grid-cols-5 px-6 py-4 text-sm">
<div className="space-y-1">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Broker</p>
<p className="font-semibold">
{isConnected ? "Connected" : "Not connected"}
</p>
</div>
<div className="space-y-1">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Armed</p>
<p className="font-semibold">{armedCount}</p>
</div>
<div className="space-y-1">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Market</p>
<p className="font-semibold">{marketState}</p>
</div>
<div className="space-y-1">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Next execution</p>
<p className="font-semibold">
{nextExecution ? formatMinuteTimestamp(nextExecution) : "Unknown"}
</p>
</div>
<div className="space-y-1">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Engine</p>
<p className="font-semibold">{liveness}</p>
</div>
</div>
{armSummary ? (
<div className="px-6 pb-4 text-xs text-muted-foreground">
System armed. {armSummary.failed_runs?.length ? "Some runs failed to arm." : "All runs armed."}
</div>
) : null}
</div>
<div
className={`rounded-2xl border border-border/60 bg-card/70 shadow-xl overflow-hidden ${hoverLift} ${revealTransition} ${cardRevealClass}`}
style={prefersReducedMotion ? undefined : { transitionDelay: "580ms" }}
>
<div className="flex items-center justify-between px-6 py-4 border-b border-border/50">
<div className="space-y-1">
<p className="text-sm font-semibold">Strategy status</p>
<p className="text-xs text-muted-foreground">
Live status for every configured strategy run.
</p>
</div>
<Badge variant="outline">{systemRuns.length} total</Badge>
</div>
{systemStatusQuery.isLoading ? (
<ZeroState message="Loading strategy status..." />
) : systemRuns.length === 0 ? (
<ZeroState message="No strategies configured yet." />
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-muted/40 text-muted-foreground">
<tr>
<th className="px-6 py-3 text-left font-medium">Strategy</th>
<th className="px-6 py-3 text-left font-medium">Mode</th>
<th className="px-6 py-3 text-left font-medium">Status</th>
<th className="px-6 py-3 text-left font-medium">Next run</th>
<th className="px-6 py-3 text-left font-medium">Broker</th>
<th className="px-6 py-3 text-left font-medium">Lifecycle</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{systemRuns.map((run) => (
<tr key={run.run_id}>
<td className="px-6 py-3">
{run.strategy || "Strategy"}
</td>
<td className="px-6 py-3">{run.mode || "-"}</td>
<td className="px-6 py-3">{run.status}</td>
<td className="px-6 py-3">
{run.next_run ? new Date(run.next_run).toLocaleString() : "-"}
</td>
<td className="px-6 py-3">{run.broker || "-"}</td>
<td className="px-6 py-3">{run.lifecycle || run.status}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div <div
className={`rounded-2xl border border-border/60 bg-card/70 shadow-xl overflow-hidden ${hoverLift} ${revealTransition} ${cardRevealClass}`} className={`rounded-2xl border border-border/60 bg-card/70 shadow-xl overflow-hidden ${hoverLift} ${revealTransition} ${cardRevealClass}`}
@ -1071,21 +1000,10 @@ export default function PortfolioSection() {
{isStopping ? "Stopping..." : "Stop Strategy"} {isStopping ? "Stopping..." : "Stop Strategy"}
</Button> </Button>
</div> </div>
{marketState === "CLOSED" ? ( <StrategyTimeline compact />
<p className="text-xs text-muted-foreground">
Market closed execution will resume at next session
</p>
) : null}
{isStrategyRunning ? (
<p className="text-xs text-muted-foreground">
Strategy running next SIP will execute when eligible
</p>
) : null}
</div> </div>
</div> </div>
<StrategyTimeline />
<div <div
className={`rounded-2xl border border-border/60 bg-card/70 shadow-xl p-6 space-y-4 ${hoverLift} ${revealTransition} ${cardRevealClass}`} className={`rounded-2xl border border-border/60 bg-card/70 shadow-xl p-6 space-y-4 ${hoverLift} ${revealTransition} ${cardRevealClass}`}
style={prefersReducedMotion ? undefined : { transitionDelay: "700ms" }} style={prefersReducedMotion ? undefined : { transitionDelay: "700ms" }}

View File

@ -12,7 +12,7 @@ const NORMALIZED_API_BASE_URL = API_BASE_URL
: ""; : "";
const REQUEST_TIMEOUT_MS = 12000; const REQUEST_TIMEOUT_MS = 12000;
function resolveApiUrl(url: string) { export function resolveApiUrl(url: string) {
if (url.startsWith("http://") || url.startsWith("https://")) { if (url.startsWith("http://") || url.startsWith("https://")) {
return url; return url;
} }