Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -401,10 +401,15 @@ Auth (when `OPS_API_TOKEN` is set):
curl -H "Authorization: Bearer $OPS_API_TOKEN" http://localhost:3000/health
```

## Architecture Snapshot

![OpenPolyTrader architecture and decision flow](docs/assets/architecture/openpolytrader-architecture-flow.svg)

## Documentation Map

- API reference: `docs/API.md`
- Architecture (with end-to-end event flow diagrams): `docs/ARCHITECTURE.md`
- Operator strategy report: `docs/Operations/operator-strategy-report.md`
- Local setup spec and quickstart details: `docs/Development/setup.md`
- Full command reference (start/help/stop/kill + diagnostics): `docs/Development/commands.md`
- Environment variable descriptions and defaults: `docs/Operations/environment-reference.md`
Expand Down
2 changes: 2 additions & 0 deletions dashboard/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
VITE_OPS_BASE_URL=http://localhost:3000
VITE_PORTFOLIO_REFRESH_MS=5000
VITE_SLO_REFRESH_MS=30000
VITE_STREAM_OFFLINE_DEBOUNCE_MS=3000
VITE_STREAM_WATCHDOG_MS=45000
VITE_INCIDENTS_LIMIT=100
VITE_INCIDENTS_PREVIEW_LIMIT=6
VITE_PUBLIC_REPO_URL=https://github.com/freshtechbro/openpolytrader
15 changes: 11 additions & 4 deletions dashboard/src/components/TopNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import { StatusPill } from './StatusPill';
import { GitHubRepoLink } from './GitHubRepoLink';

type TradingMode = 'off' | 'shadow' | 'paper' | 'live';
export type TopNavStreamState = 'connecting' | 'live' | 'offline';

interface TopNavProps {
title: string;
subtitle: string;
status: 'healthy' | 'degraded';
streamConnected: boolean;
streamState: TopNavStreamState;
tradingMode: TradingMode | null;
tradingEnabled: boolean | null;
onModeChange?: (mode: TradingMode) => void;
Expand All @@ -31,7 +32,13 @@ function getTradingModeVariant(mode: TradingMode | null, enabled: boolean | null
return mode;
}

export function TopNav({ title, subtitle, status, streamConnected, tradingMode, tradingEnabled, onModeChange, onEnabledChange }: TopNavProps) {
function getStreamLabel(streamState: TopNavStreamState): string {
if (streamState === 'live') return 'Stream live';
if (streamState === 'offline') return 'Stream offline';
return 'Stream connecting';
}

export function TopNav({ title, subtitle, status, streamState, tradingMode, tradingEnabled, onModeChange, onEnabledChange }: TopNavProps) {
const modeVariant = getTradingModeVariant(tradingMode, tradingEnabled);
const modeLabel = getTradingModeLabel(tradingMode);
const enabledLabel = tradingEnabled === null ? '' : tradingEnabled ? 'Enabled' : 'Disabled';
Expand Down Expand Up @@ -95,8 +102,8 @@ export function TopNav({ title, subtitle, status, streamConnected, tradingMode,
)}
</span>
<StatusPill status={status} />
<span className={`stream ${streamConnected ? 'stream--on' : 'stream--off'}`}>
{streamConnected ? 'Stream live' : 'Stream offline'}
<span className={`stream ${streamState === 'live' ? 'stream--on' : streamState === 'offline' ? 'stream--off' : 'stream--connecting'}`}>
{getStreamLabel(streamState)}
</span>
</div>
</header>
Expand Down
21 changes: 17 additions & 4 deletions dashboard/src/hooks/useEventStream.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { STREAM_WATCHDOG_MS } from '../lib/dashboardConfig';

export interface StreamEvent {
type: string;
Expand All @@ -18,12 +19,15 @@ export function useEventStream(url: string | null, onEvent?: (event: StreamEvent
}

const source = new EventSource(url, { withCredentials: true });
let lastActivityAt = Date.now();

source.onopen = () => setConnected(true);
source.onopen = () => {
lastActivityAt = Date.now();
setConnected(true);
};
source.onerror = () => {
if (source.readyState === EventSource.CLOSED) {
setConnected(false);
}
// Treat any stream error as a disconnected state; UI debounce handles reconnect jitter.
setConnected(false);
};

source.addEventListener('health', handle);
Expand All @@ -44,6 +48,13 @@ export function useEventStream(url: string | null, onEvent?: (event: StreamEvent
source.addEventListener('allowlist_updated', handle);
source.addEventListener('trading_mode_changed', handle);
source.addEventListener('trading_enabled_changed', handle);
source.addEventListener('stream_ping', handle);

const watchdog = window.setInterval(() => {
if (Date.now() - lastActivityAt > STREAM_WATCHDOG_MS) {
setConnected(false);
}
}, 1000);

function handle(event: Event) {
const data = (event as MessageEvent).data;
Expand All @@ -55,12 +66,14 @@ export function useEventStream(url: string | null, onEvent?: (event: StreamEvent
} catch {
return;
}
lastActivityAt = Date.now();
setConnected(true);
setLastEvent(parsed);
onEvent?.(parsed);
}

return () => {
window.clearInterval(watchdog);
source.close();
};
}, [url, onEvent]);
Expand Down
10 changes: 10 additions & 0 deletions dashboard/src/lib/dashboardConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ const DEFAULTS = {
opsBaseUrl: '',
portfolioRefreshMs: 5000,
sloRefreshMs: 30000,
streamOfflineDebounceMs: 3000,
streamWatchdogMs: 45000,
incidentsLimit: 100,
incidentsPreviewLimit: 6,
publicRepoUrl: 'https' + '://github.com/freshtechbro/openpolytrader'
Expand Down Expand Up @@ -38,6 +40,14 @@ export const SLO_REFRESH_MS = parsePositiveInt(
import.meta.env.VITE_SLO_REFRESH_MS,
DEFAULTS.sloRefreshMs
);
export const STREAM_OFFLINE_DEBOUNCE_MS = parsePositiveInt(
import.meta.env.VITE_STREAM_OFFLINE_DEBOUNCE_MS,
DEFAULTS.streamOfflineDebounceMs
);
export const STREAM_WATCHDOG_MS = parsePositiveInt(
import.meta.env.VITE_STREAM_WATCHDOG_MS,
DEFAULTS.streamWatchdogMs
);
export const INCIDENTS_LIMIT = parsePositiveInt(
import.meta.env.VITE_INCIDENTS_LIMIT,
DEFAULTS.incidentsLimit
Expand Down
7 changes: 6 additions & 1 deletion dashboard/src/lib/opsClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function buildOpsUrl(path: string): string {
export function getOpsStreamUrl(): string {
const base = OPS_STREAM_URL;
if (!opsAuthToken) return base;
if (!shouldAppendStreamToken(base)) return base;
if (!shouldAppendStreamToken(base) && hasOpsSessionCookie()) return base;
const separator = base.includes('?') ? '&' : '?';
return `${base}${separator}token=${encodeURIComponent(opsAuthToken)}`;
}
Expand Down Expand Up @@ -132,3 +132,8 @@ function shouldAppendStreamToken(streamUrl: string): boolean {
return false;
}
}

function hasOpsSessionCookie(): boolean {
if (typeof document === 'undefined') return false;
return document.cookie.split(';').some((part) => part.trim().startsWith('ops_session='));
}
4 changes: 2 additions & 2 deletions dashboard/src/pages/Decisions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { MetricsTable } from '../components/MetricsTable';
import { Panel } from '../components/Panel';
import { Section } from '../components/Section';
import { useEventStream, StreamEvent } from '../hooks/useEventStream';
import { opsFetchJson, OPS_STREAM_URL } from '../lib/opsClient';
import { getOpsStreamUrl, opsFetchJson } from '../lib/opsClient';

interface StoredDecision {
id: string;
Expand Down Expand Up @@ -163,7 +163,7 @@ export function Decisions() {
}, 3000);
}, [liveEnabled, agent, subjectId, limit]);

const [{ connected }] = useEventStream(OPS_STREAM_URL, handleLiveDecision);
const [{ connected }] = useEventStream(getOpsStreamUrl(), handleLiveDecision);

const query = useMemo(() => {
const params = new URLSearchParams();
Expand Down
78 changes: 65 additions & 13 deletions dashboard/src/pages/Markets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { Panel } from '../components/Panel';
import { Section } from '../components/Section';
import { MetricsTable, type TableRow } from '../components/MetricsTable';
import { useEventStream, StreamEvent } from '../hooks/useEventStream';
import { opsFetchJson, OPS_STREAM_URL } from '../lib/opsClient';
import { getOpsStreamUrl, opsFetchJson } from '../lib/opsClient';
import { STREAM_OFFLINE_DEBOUNCE_MS } from '../lib/dashboardConfig';

import type { AllowlistEntry } from './Overview';

Expand All @@ -15,6 +16,32 @@ interface EnrichedMarketEntry extends AllowlistEntry {

const MARKETS_PREVIEW_LIMIT = 30;

type MarketsStreamBadgeState = 'connecting' | 'live' | 'offline';

const STREAM_BADGE_META: Record<
MarketsStreamBadgeState,
{ label: string; background: string; color: string; dot: string }
> = {
connecting: {
label: 'Connecting',
background: 'rgba(148, 163, 184, 0.2)',
color: '#64748b',
dot: '#64748b'
},
live: {
label: 'Live',
background: 'rgba(34, 197, 94, 0.2)',
color: '#22c55e',
dot: '#22c55e'
},
offline: {
label: 'Offline',
background: 'rgba(239, 68, 68, 0.2)',
color: '#ef4444',
dot: '#ef4444'
}
};

export function Markets({ allowlist: initialAllowlist }: { allowlist: AllowlistEntry[] }) {
const [markets, setMarkets] = useState<EnrichedMarketEntry[]>(
initialAllowlist.map((e) => ({
Expand All @@ -26,10 +53,8 @@ export function Markets({ allowlist: initialAllowlist }: { allowlist: AllowlistE
const [loading, setLoading] = useState(false);
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
const [showAllMarkets, setShowAllMarkets] = useState(false);

useEffect(() => {
fetchMarkets();
}, []);
const [hasInitialFetchSettled, setHasInitialFetchSettled] = useState(false);
const [streamOfflineDebounced, setStreamOfflineDebounced] = useState(false);

const fetchMarkets = useCallback(async () => {
setLoading(true);
Expand All @@ -38,19 +63,45 @@ export function Markets({ allowlist: initialAllowlist }: { allowlist: AllowlistE
setMarkets(data);
setLastUpdate(new Date());
} catch {
// Keep existing market rows when refresh fails.
} finally {
setLoading(false);
return;
setHasInitialFetchSettled(true);
}
setLoading(false);
}, []);

useEffect(() => {
void fetchMarkets();
}, [fetchMarkets]);

const handleStreamEvent = useCallback((event: StreamEvent) => {
if (event.type === 'allowlist_updated') {
fetchMarkets();
void fetchMarkets();
}
}, [fetchMarkets]);

const [{ connected }] = useEventStream(OPS_STREAM_URL, handleStreamEvent);
const [{ connected }] = useEventStream(getOpsStreamUrl(), handleStreamEvent);

useEffect(() => {
if (connected) {
setStreamOfflineDebounced(false);
return;
}

const timer = window.setTimeout(() => {
setStreamOfflineDebounced(true);
}, STREAM_OFFLINE_DEBOUNCE_MS);

return () => window.clearTimeout(timer);
}, [connected]);

const streamBadgeState = useMemo<MarketsStreamBadgeState>(() => {
if (!hasInitialFetchSettled) return 'connecting';
if (connected) return 'live';
return streamOfflineDebounced ? 'offline' : 'connecting';
}, [hasInitialFetchSettled, connected, streamOfflineDebounced]);
const streamBadgeMeta = STREAM_BADGE_META[streamBadgeState];

const visibleMarkets = useMemo(
() => (showAllMarkets ? markets : markets.slice(0, MARKETS_PREVIEW_LIMIT)),
[markets, showAllMarkets]
Expand Down Expand Up @@ -101,19 +152,20 @@ export function Markets({ allowlist: initialAllowlist }: { allowlist: AllowlistE
borderRadius: 10,
fontSize: 11,
fontWeight: 600,
background: connected ? 'rgba(34, 197, 94, 0.2)' : 'rgba(239, 68, 68, 0.2)',
color: connected ? '#22c55e' : '#ef4444'
background: streamBadgeMeta.background,
color: streamBadgeMeta.color
}}
aria-live="polite"
>
<span
style={{
width: 6,
height: 6,
borderRadius: '50%',
background: connected ? '#22c55e' : '#ef4444'
background: streamBadgeMeta.dot
}}
/>
{connected ? 'Live' : 'Offline'}
{streamBadgeMeta.label}
</span>
{lastUpdate && (
<span style={{ fontSize: 11, opacity: 0.6 }} aria-live="polite">
Expand Down
11 changes: 8 additions & 3 deletions dashboard/src/pages/RiskGates.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';

import { Panel } from '../components/Panel';
import { Section } from '../components/Section';
Expand Down Expand Up @@ -114,6 +114,7 @@ interface InfraConfigSnapshot {
}

export function RiskGates() {
const profileDraftDirtyRef = useRef(false);
const [schema, setSchema] = useState<ConfigSchema | null>(null);
const [config, setConfig] = useState<ConfigSnapshot | null>(null);
const [draft, setDraft] = useState<ConfigSnapshot | null>(null);
Expand Down Expand Up @@ -168,7 +169,7 @@ export function RiskGates() {
setDraft(configResponse as ConfigSnapshot);
setRiskProfiles(profileSnapshot);
const profile = profileSnapshot.activeProfile ?? (configResponse as ConfigSnapshot).riskProfile;
if (profile) {
if (profile && !profileDraftDirtyRef.current) {
setProfileDraft(profile);
}
setInfra(infraResponse as InfraConfigSnapshot);
Expand Down Expand Up @@ -304,6 +305,7 @@ export function RiskGates() {
setConfig(nextConfig);
setDraft(nextConfig);
if (response.profile?.id) {
profileDraftDirtyRef.current = false;
setProfileDraft(response.profile.id);
}
setRiskProfiles((prev) => {
Expand Down Expand Up @@ -350,7 +352,10 @@ export function RiskGates() {
<select
id="risk-profile-select"
value={profileDraft}
onChange={(event) => setProfileDraft(event.target.value as RiskProfileId)}
onChange={(event) => {
profileDraftDirtyRef.current = true;
setProfileDraft(event.target.value as RiskProfileId);
}}
>
{availableProfiles.map((profileId) => (
<option key={profileId} value={profileId}>
Expand Down
Loading