From 7110c15daf9c91523409266c38a1b91e73554053 Mon Sep 17 00:00:00 2001 From: Gojv-byte Date: Mon, 28 Sep 2026 22:08:47 +0100 Subject: [PATCH] feat: add operator AI kill switch --- api/routes/aiControls.js | 77 +++ api/routes/behavior.js | 13 + api/server.js | 4 + api/services/aiKillSwitch.js | 161 +++++ docker-compose.yml | 6 +- docs/features/feature-flag-lifecycle.md | 31 +- nginx.conf | 12 + src/App.tsx | 3 + .../dashboard/AIRouteRecommendations.tsx | 393 ++++++----- src/components/dashboard/AuditLog.tsx | 612 +++++++++++------- src/components/dashboard/FeatureFlags.tsx | 179 ++++- .../LiquidityPredictionDashboard.tsx | 151 +++-- .../layout/widgets/DataInsightsWidget.tsx | 8 +- src/context/AIKillSwitchContext.tsx | 56 ++ src/lib/anomalyDetectionPipeline.ts | 2 +- src/lib/feePredictor.ts | 2 +- src/lib/isolationForest.ts | 69 ++ src/routes/DashboardLayout.tsx | 48 +- tests/api/aiKillSwitch.test.js | 135 ++++ vite.config.js | 47 +- 20 files changed, 1518 insertions(+), 491 deletions(-) create mode 100644 api/routes/aiControls.js create mode 100644 api/services/aiKillSwitch.js create mode 100644 src/context/AIKillSwitchContext.tsx create mode 100644 src/lib/isolationForest.ts create mode 100644 tests/api/aiKillSwitch.test.js diff --git a/api/routes/aiControls.js b/api/routes/aiControls.js new file mode 100644 index 00000000..8d8b3e33 --- /dev/null +++ b/api/routes/aiControls.js @@ -0,0 +1,77 @@ +import express from 'express'; +import { getRuntimeEnvironment } from '../middleware/auth.js'; +import { + getAIKillSwitch, + getAIKillSwitchHistory, + isOperatorTokenValid, + updateAIKillSwitch, + validateKillSwitchUpdate, +} from '../services/aiKillSwitch.js'; + +export const router = express.Router(); + +function sendUnavailable(res, error) { + console.error( + '[ai-controls] Runtime control unavailable:', + error instanceof Error ? error.message : error + ); + return res + .status(503) + .json({ error: 'ai_control_unavailable', message: 'AI runtime controls are unavailable.' }); +} + +router.get('/', async (_req, res) => { + try { + getRuntimeEnvironment(); + const state = await getAIKillSwitch(); + res.set('Cache-Control', 'no-store'); + return res.json({ success: true, data: state }); + } catch (error) { + return sendUnavailable(res, error); + } +}); + +router.get('/audit', async (_req, res) => { + try { + getRuntimeEnvironment(); + const history = await getAIKillSwitchHistory(); + res.set('Cache-Control', 'no-store'); + return res.json({ success: true, data: history }); + } catch (error) { + return sendUnavailable(res, error); + } +}); + +router.put('/', async (req, res) => { + const authorization = req.headers.authorization; + const token = + typeof authorization === 'string' && authorization.startsWith('Bearer ') + ? authorization.slice('Bearer '.length) + : ''; + if (!isOperatorTokenValid(token)) { + return res + .status(403) + .json({ error: 'forbidden', message: 'A configured operator token is required.' }); + } + + let update; + try { + update = validateKillSwitchUpdate(req.body); + getRuntimeEnvironment(); + } catch (error) { + if (error?.status === 400) + return res.status(400).json({ error: 'invalid_payload', message: error.message }); + return res.status(503).json({ + error: 'unsupported_environment', + message: 'AI runtime control is unsupported in this environment.', + }); + } + + try { + const state = await updateAIKillSwitch(update.enabled, 'configured-operator', update.reason); + res.set('Cache-Control', 'no-store'); + return res.json({ success: true, data: state }); + } catch (error) { + return sendUnavailable(res, error); + } +}); diff --git a/api/routes/behavior.js b/api/routes/behavior.js index ae506b75..5489013f 100644 --- a/api/routes/behavior.js +++ b/api/routes/behavior.js @@ -24,9 +24,22 @@ import { clearPersonalizationData, } from '../../src/lib/personalizationEngine.js'; import { requireSelfOrAdmin } from '../middleware/auth.js'; +import { getAIKillSwitch } from '../services/aiKillSwitch.js'; export const router = express.Router(); +router.use(async (_req, res, next) => { + try { + const { enabled } = await getAIKillSwitch(); + if (!enabled) { + return res.status(503).json({ error: 'ai_disabled', message: 'AI-assisted behavior features are temporarily disabled.' }); + } + return next(); + } catch { + return res.status(503).json({ error: 'ai_control_unavailable', message: 'AI features are unavailable until runtime controls can be verified.' }); + } +}); + router.use((req, res, next) => { const userId = req.headers['x-user-id'] || req.query.userId || req.user?.id; if (!userId && req.path !== '/settings') { diff --git a/api/server.js b/api/server.js index ced544b5..4813c2ac 100644 --- a/api/server.js +++ b/api/server.js @@ -14,6 +14,7 @@ import { router as notificationSummariesRouter } from './routes/notificationSumm import { router as gasPredictionRouter } from './routes/gasPrediction.js'; import { router as analyticsRouter } from './routes/analytics.js'; import { router as migrationRouter } from './routes/migration.js'; +import { router as aiControlsRouter } from './routes/aiControls.js'; export const app = express(); export const server = createServer(app); @@ -25,6 +26,8 @@ app.use('/api/health', healthRouter); app.use(apiVersioningMiddleware); app.use(idempotencyMiddleware); app.use(rateLimiter); +// Public read-only status; mutation authorization is enforced by the route itself. +app.use('/api/v1/ai-controls', aiControlsRouter); getRuntimeEnvironment(); @@ -56,6 +59,7 @@ app.get('/api/docs', (req, res) => { '/api/v1/transactions': 'GET - Query transactions (query params: accountId, limit)', '/api/v1/liquidity': 'GET - Liquidity predictions and metrics', '/api/v1/behavior': 'Behavior prediction, suggestions, personalization', + '/api/v1/ai-controls': 'GET/PUT - Global AI panel kill switch (operator token required for writes)', '/api/v1/behavior/predict/intent': 'POST - Predict user intent', '/api/v1/behavior/predict/next-action': 'POST - Predict next user action', '/api/v1/behavior/profile': 'GET - Get behavior profile', diff --git a/api/services/aiKillSwitch.js b/api/services/aiKillSwitch.js new file mode 100644 index 00000000..cb4c5a9b --- /dev/null +++ b/api/services/aiKillSwitch.js @@ -0,0 +1,161 @@ +import { timingSafeEqual } from 'node:crypto'; + +const STATE_KEY = 'stellar-dashboard:ai-kill-switch'; +const AUDIT_LIMIT = 100; + +class MemoryKillSwitchStore { + constructor() { + this.state = { enabled: true, updatedAt: null, updatedBy: null, reason: null }; + this.audit = []; + } + + async read() { + return { ...this.state }; + } + + async update(enabled, actor, reason) { + const now = new Date().toISOString(); + const entry = { enabled, previousEnabled: this.state.enabled, updatedAt: now, actor, reason }; + this.state = { enabled, updatedAt: now, updatedBy: actor, reason }; + this.audit.unshift(entry); + this.audit.length = Math.min(this.audit.length, AUDIT_LIMIT); + return { ...this.state }; + } + + async history() { + return this.audit.map((entry) => ({ ...entry })); + } +} + +class RedisKillSwitchStore { + constructor(redis) { + this.redis = redis; + } + + async read() { + const raw = await this.redis.get(STATE_KEY); + return raw + ? JSON.parse(raw) + : { enabled: true, updatedAt: null, updatedBy: null, reason: null }; + } + + async update(enabled, actor, reason) { + const script = ` + local old = redis.call('GET', KEYS[1]) + local previous = true + if old then previous = cjson.decode(old).enabled end + local state = cjson.encode({enabled=ARGV[1] == 'true', updatedAt=ARGV[2], updatedBy=ARGV[3], reason=ARGV[4]}) + local audit = cjson.encode({enabled=ARGV[1] == 'true', previousEnabled=previous, updatedAt=ARGV[2], actor=ARGV[3], reason=ARGV[4]}) + redis.call('SET', KEYS[1], state) + redis.call('LPUSH', KEYS[2], audit) + redis.call('LTRIM', KEYS[2], 0, ${AUDIT_LIMIT - 1}) + return state + `; + const raw = await this.redis.eval( + script, + 2, + STATE_KEY, + `${STATE_KEY}:audit`, + String(enabled), + new Date().toISOString(), + actor, + reason || '' + ); + return JSON.parse(raw); + } + + async history() { + const rows = await this.redis.lrange(`${STATE_KEY}:audit`, 0, AUDIT_LIMIT - 1); + return rows.map((row) => JSON.parse(row)); + } +} + +let storePromise; +let injectedStore; + +async function getStore() { + if (injectedStore) return injectedStore; + if (storePromise) return storePromise; + storePromise = (async () => { + const mode = (process.env.AI_KILL_SWITCH_STORE || '').toLowerCase(); + const redisUrl = process.env.REDIS_URL; + if (mode && mode !== 'memory' && mode !== 'redis') { + throw new Error('AI_KILL_SWITCH_STORE must be either redis or memory'); + } + if (mode === 'memory' && process.env.NODE_ENV === 'production') { + throw new Error('Process-local AI kill switch storage is unsupported in production'); + } + if (mode === 'memory' || (!redisUrl && process.env.NODE_ENV !== 'production')) { + return new MemoryKillSwitchStore(); + } + if (!redisUrl) { + throw new Error('AI kill switch requires REDIS_URL in production'); + } + const { default: Redis } = await import('ioredis'); + const redis = new Redis(redisUrl, { maxRetriesPerRequest: 1, lazyConnect: true }); + await redis.connect(); + await redis.ping(); + return new RedisKillSwitchStore(redis); + })().catch((error) => { + storePromise = undefined; + throw error; + }); + return storePromise; +} + +export function validateKillSwitchUpdate(body) { + if ( + !body || + typeof body !== 'object' || + Array.isArray(body) || + typeof body.enabled !== 'boolean' + ) { + const error = new Error('enabled must be a boolean'); + error.status = 400; + throw error; + } + const reason = body.reason === undefined ? '' : body.reason; + const containsControlCharacter = + typeof reason === 'string' && + Array.from(reason).some( + (character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127 + ); + if (typeof reason !== 'string' || reason.length > 240 || containsControlCharacter) { + const error = new Error('reason must be a string of at most 240 printable characters'); + error.status = 400; + throw error; + } + return { enabled: body.enabled, reason: reason.trim() }; +} + +export function isOperatorTokenValid(token, expected = process.env.AI_KILL_SWITCH_OPERATOR_TOKEN) { + if (typeof token !== 'string' || typeof expected !== 'string' || expected.length < 32) + return false; + const supplied = Buffer.from(token); + const configured = Buffer.from(expected); + return supplied.length === configured.length && timingSafeEqual(supplied, configured); +} + +export async function getAIKillSwitch() { + return (await getStore()).read(); +} + +export async function updateAIKillSwitch(enabled, actor, reason = '') { + return (await getStore()).update(enabled, actor, reason); +} + +export async function getAIKillSwitchHistory() { + return (await getStore()).history(); +} + +export function setAIKillSwitchStoreForTests(store) { + injectedStore = store; + storePromise = undefined; +} + +export function resetAIKillSwitchStoreForTests() { + injectedStore = undefined; + storePromise = undefined; +} + +export { MemoryKillSwitchStore }; diff --git a/docker-compose.yml b/docker-compose.yml index cac6aef9..761f5f78 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,7 +43,7 @@ services: command: npm run dev -- --host environment: - NODE_ENV=development - - VITE_API_URL=${VITE_API_URL:-http://localhost:4000} + - VITE_API_URL=${VITE_API_URL:-http://api:4000} depends_on: api: condition: service_healthy @@ -59,6 +59,8 @@ services: - NODE_ENV=${NODE_ENV:-development} - PORT=4000 - REDIS_URL=${REDIS_URL:-redis://redis:6379} + - AI_KILL_SWITCH_STORE=${AI_KILL_SWITCH_STORE:-redis} + - AI_KILL_SWITCH_OPERATOR_TOKEN=${AI_KILL_SWITCH_OPERATOR_TOKEN:-} depends_on: redis: condition: service_healthy @@ -82,6 +84,8 @@ services: - CANARY=true - PORT=4000 - REDIS_URL=${REDIS_URL:-redis://redis:6379} + - AI_KILL_SWITCH_STORE=${AI_KILL_SWITCH_STORE:-redis} + - AI_KILL_SWITCH_OPERATOR_TOKEN=${AI_KILL_SWITCH_OPERATOR_TOKEN:-} depends_on: redis: condition: service_healthy diff --git a/docs/features/feature-flag-lifecycle.md b/docs/features/feature-flag-lifecycle.md index f03669ee..85cd2da1 100644 --- a/docs/features/feature-flag-lifecycle.md +++ b/docs/features/feature-flag-lifecycle.md @@ -1,17 +1,19 @@ # Feature Flag Lifecycle Management +The lifecycle API below manages **session-local experimental flags**. It is not the operator kill switch and must not be used as a production-wide safety control. + Dashboard modules can gate experimental UI through a shared lifecycle service: `src/lib/featureFlagLifecycle.ts` + `src/components/dashboard/FeatureFlags.tsx` ## Lifecycle -| Status | Meaning | -| --- | --- | -| `draft` | Created but not yet serving traffic | -| `active` | Eligible for evaluation in configured environments | +| Status | Meaning | +| --------- | --------------------------------------------------------------- | +| `draft` | Created but not yet serving traffic | +| `active` | Eligible for evaluation in configured environments | | `expired` | Past `expiresAt` or manually expired; evaluate returns disabled | -| `retired` | Terminal state; cannot be re-activated | +| `retired` | Terminal state; cannot be re-activated | Flows: **create → activate → evaluate → expire → retire**, with append-only **audit history**. @@ -37,6 +39,25 @@ Flows: **create → activate → evaluate → expire → retire**, with append-o 4. Prefer `expire()` for temporary shutoff and `retire()` for permanent removal. 5. Existing stub `FeatureFlags` tab now manages flags via this service; no storage migration is required (in-memory for the session). +## Global AI panel kill switch (runtime control) + +The Feature Flags screen also exposes a separate server-authoritative switch at `/api/v1/ai-controls`. `GET` reads current status; `PUT` accepts `{ "enabled": false, "reason": "incident response" }`. A configured operator bearer token is required to write. The UI holds the token in memory only, clears it after a successful change, and never persists it. The endpoint also exposes a read-only bounded audit list at `/api/v1/ai-controls/audit`. + +### Compatibility and deployment + +- Current API environments are `development`, `test`, and `production`. Other values (including `staging`) return an unsupported-environment error; define and validate an environment mapping before using a new deployment tier. +- Development/test may use process-local memory when Redis is not configured. That mode is neither durable nor shared across API instances and is unsuitable for production. +- Production requires shared Redis configured with `REDIS_URL` and `AI_KILL_SWITCH_STORE=redis`. The control state and bounded audit history are updated atomically in Redis. If Redis is absent or unavailable, reads and writes return `503`; the browser fails closed and suppresses AI panels. There is intentionally no process-local fallback. +- Set `AI_KILL_SWITCH_OPERATOR_TOKEN` on the API service to a randomly generated secret of at least 32 characters. Configure it through your secret manager/container environment, not source control, browser storage, or Vite's `VITE_*` variables. No operator token means no authorized mutation. +- Compose's API and web containers use the API service proxy so status and operator requests are same-origin. For standalone deployments, route `/api/` to the API server and preserve `Authorization`; deploy a shared Redis service before enabling the production control. + +### Behavior and security + +- AI panel state is polled every 10 seconds and refreshed when a browser tab becomes visible. Initial state and fetch/storage failures are fail-closed; the UI does not treat missing status as enabled. +- Dashboard AI routes/widgets and AI behavior endpoints are gated. Hiding a panel is not an authorization substitute; other AI backend integrations should also check the authoritative state before being considered covered by the global switch. +- The write credential is compared in constant time, never returned by the API, and not written to local storage. Audit records include transition, timestamp, fixed operator label, and optional reason (max 240 printable characters); do not put secrets or personal data in the reason. +- Existing local feature flags and their in-memory audit log are unchanged. No data migration is needed; the runtime control persists independently in Redis. Roll back by setting `enabled` to `true` with the operator credential. + ## Example ```ts diff --git a/nginx.conf b/nginx.conf index 57be7819..844166b7 100644 --- a/nginx.conf +++ b/nginx.conf @@ -20,6 +20,18 @@ server { add_header Content-Type text/plain; } + # Keep the runtime kill switch same-origin and available without rebuilding the UI. + location /api/ { + proxy_pass http://api:4000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 2s; + proxy_read_timeout 10s; + } + # Cache static assets location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { expires 1y; diff --git a/src/App.tsx b/src/App.tsx index 40d7f5cd..e683e028 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import OnboardingFlow from './components/onboarding/OnboardingFlow'; import { TipProvider } from './components/ai/TipProvider'; import AnalyticsConsentPrompt from './components/AnalyticsConsentPrompt'; import { needsAnalyticsConsentReview } from './utils/analyticsConsent'; +import { AIKillSwitchProvider } from './context/AIKillSwitchContext'; const DashboardLayout = lazy(() => import('./routes/DashboardLayout')); @@ -66,6 +67,7 @@ export default function App() { + {showOnboarding && setShowOnboarding(false)} />} {showConsentPrompt && setShowConsentPrompt(false)} />} @@ -79,6 +81,7 @@ export default function App() { + diff --git a/src/components/dashboard/AIRouteRecommendations.tsx b/src/components/dashboard/AIRouteRecommendations.tsx index a5e889c8..7d1e17fc 100644 --- a/src/components/dashboard/AIRouteRecommendations.tsx +++ b/src/components/dashboard/AIRouteRecommendations.tsx @@ -1,58 +1,59 @@ -import React, { useState } from 'react' -import SlippageWarning from './SlippageWarning' +import React, { useState } from 'react'; +import SlippageWarning from './SlippageWarning'; +import { useAIKillSwitch } from '../../context/AIKillSwitchContext'; interface Route { - path?: string[] - source_amount?: string - destination_amount?: string - source_asset_code?: string - destination_asset_code?: string - source_asset_type?: string - destination_asset_type?: string - destination_asset_issuer?: string + path?: string[]; + source_amount?: string; + destination_amount?: string; + source_asset_code?: string; + destination_asset_code?: string; + source_asset_type?: string; + destination_asset_type?: string; + destination_asset_issuer?: string; } interface RankedRoute { - route: Route - overallScore: number - rank: number + route: Route; + overallScore: number; + rank: number; scores: { - cost: number - speed: number - reliability: number - slippage: number - } + cost: number; + speed: number; + reliability: number; + slippage: number; + }; } interface SlippagePrediction { - predictedSlippage: number - confidence: number - riskLevel: 'low' | 'medium' | 'high' | 'critical' + predictedSlippage: number; + confidence: number; + riskLevel: 'low' | 'medium' | 'high' | 'critical'; breakdown: { - hopContribution: number - liquidityContribution: number - amountContribution: number - volatilityContribution: number - timeContribution: number - } - recommendedTolerance: number + hopContribution: number; + liquidityContribution: number; + amountContribution: number; + volatilityContribution: number; + timeContribution: number; + }; + recommendedTolerance: number; } interface RouteExplanation { - summary: string - factors: Array<{ type: string; label: string; detail: string }> - recommendation: string - warnings: string[] + summary: string; + factors: Array<{ type: string; label: string; detail: string }>; + recommendation: string; + warnings: string[]; } interface AIRouteRecommendationsProps { - rankedRoutes: RankedRoute[] - slippagePredictions: SlippagePrediction[] - routeExplanations: RouteExplanation[] - onSelectRoute: (route: Route) => void - selectedRoute?: Route | null - isLoading?: boolean - showExplanations?: boolean + rankedRoutes: RankedRoute[]; + slippagePredictions: SlippagePrediction[]; + routeExplanations: RouteExplanation[]; + onSelectRoute: (_route: Route) => void; + selectedRoute?: Route | null; + isLoading?: boolean; + showExplanations?: boolean; } export default function AIRouteRecommendations({ @@ -64,70 +65,83 @@ export default function AIRouteRecommendations({ isLoading = false, showExplanations = true, }: AIRouteRecommendationsProps) { - const [expandedRoute, setExpandedRoute] = useState(null) - const [showSlippageDetails, setShowSlippageDetails] = useState(false) + const { enabled, ready } = useAIKillSwitch(); + const [expandedRoute, setExpandedRoute] = useState(null); + const [showSlippageDetails, setShowSlippageDetails] = useState(false); + + if (!enabled || !ready) return null; if (isLoading) { return ( -
+
🔄
Analyzing routes with AI...
- ) + ); } if (!rankedRoutes || rankedRoutes.length === 0) { - return null + return null; } return ( -
-
+
+
🤖 - + AI Route Recommendations
-
+
{rankedRoutes.length} routes analyzed
{rankedRoutes.map((ranked, index) => { - const prediction = slippagePredictions[index] - const explanation = routeExplanations[index] - const isExpanded = expandedRoute === index - const isSelected = selectedRoute === ranked.route - const isRecommended = ranked.rank === 1 + const prediction = slippagePredictions[index]; + const explanation = routeExplanations[index]; + const isExpanded = expandedRoute === index; + const isSelected = selectedRoute === ranked.route; + const isRecommended = ranked.rank === 1; return (
@@ -150,55 +168,64 @@ export default function AIRouteRecommendations({ onClick={() => setExpandedRoute(isExpanded ? null : index)} >
-
+
{ranked.rank}
-
+
{isRecommended ? '⭐ Recommended' : `Route #${ranked.rank}`}
-
- {(ranked.overallScore * 100).toFixed(1)}% score • {ranked.route.path?.length || 0} hops +
+ {(ranked.overallScore * 100).toFixed(1)}% score •{' '} + {ranked.route.path?.length || 0} hops
{prediction && ( -
+
{(prediction.predictedSlippage * 100).toFixed(2)}%
)}
{isExpanded && explanation && ( -
-
+
+
{explanation.summary}
{showExplanations && explanation.factors.length > 0 && (
-
+
KEY FACTORS
{explanation.factors.map((factor, fi) => ( -
- - {factor.type === 'positive' ? '✓' : factor.type === 'warning' ? '⚠' : '✗'} +
+ + {factor.type === 'positive' + ? '✓' + : factor.type === 'warning' + ? '⚠' + : '✗'} {factor.label}: - - {factor.detail} - + {factor.detail}
))}
@@ -271,8 +315,8 @@ export default function AIRouteRecommendations({
- ) + ); } function getRiskColor(riskLevel: string, isBackground: boolean): string { @@ -346,7 +397,7 @@ function getRiskColor(riskLevel: string, isBackground: boolean): string { medium: { bg: 'var(--amber-glow-sm)', text: 'var(--amber)' }, high: { bg: 'var(--red-glow-sm)', text: 'var(--red)' }, critical: { bg: 'var(--red-glow-sm)', text: 'var(--red)' }, - } - const color = colors[riskLevel] || colors.medium - return isBackground ? color.bg : color.text + }; + const color = colors[riskLevel] || colors.medium; + return isBackground ? color.bg : color.text; } diff --git a/src/components/dashboard/AuditLog.tsx b/src/components/dashboard/AuditLog.tsx index af9f0dd9..beb6d024 100644 --- a/src/components/dashboard/AuditLog.tsx +++ b/src/components/dashboard/AuditLog.tsx @@ -8,23 +8,23 @@ import { useTablePresets } from '../../hooks/useTablePresets'; import type { AuditEntry } from '../../types/audit'; interface LogRowProps { - entry: AuditEntry - isSelected: boolean - onSelect: (id: string) => void + entry: AuditEntry; + isSelected: boolean; + onSelect: (id: string) => void; } interface FilterBarProps { - onFilterChange: (filters: FilterState) => void + onFilterChange: (filters: FilterState) => void; } interface FilterState { - severity: string[] - category: string[] - search: string + severity: string[]; + category: string[]; + search: string; } interface SeverityBadgeProps { - severity: string + severity: string; } const SEVERITY_COLORS: Record = { @@ -48,18 +48,20 @@ const AUDIT_LOG_COLUMNS = [ const SeverityBadge = ({ severity }: SeverityBadgeProps) => { const style = SEVERITY_COLORS[severity] || SEVERITY_COLORS.info; return ( - + {severity} ); @@ -81,37 +83,45 @@ const LogRow = ({ entry, isSelected, onSelect }: LogRowProps) => ( fontSize: '12px', }} > -
+
-
+
{entry.category}
-
- {entry.message} +
+ {entry.action}
-
+
{formatDistanceToNow(new Date(entry.timestamp), { addSuffix: true })}
@@ -125,7 +135,7 @@ const FilterBar = ({ onFilterChange }: FilterBarProps) => { const toggleSeverity = (sev: string) => { const next = selectedSeverities.includes(sev) - ? selectedSeverities.filter(s => s !== sev) + ? selectedSeverities.filter((s) => s !== sev) : [...selectedSeverities, sev]; setSelectedSeverities(next); onFilterChange({ severity: next, category: selectedCategories, search }); @@ -133,7 +143,7 @@ const FilterBar = ({ onFilterChange }: FilterBarProps) => { const toggleCategory = (cat: string) => { const next = selectedCategories.includes(cat) - ? selectedCategories.filter(c => c !== cat) + ? selectedCategories.filter((c) => c !== cat) : [...selectedCategories, cat]; setSelectedCategories(next); onFilterChange({ severity: selectedSeverities, category: next, search }); @@ -166,14 +176,16 @@ const FilterBar = ({ onFilterChange }: FilterBarProps) => { boxSizing: 'border-box', }} /> - + 🔍
@@ -198,18 +210,20 @@ const FilterBar = ({ onFilterChange }: FilterBarProps) => { ⚙ Filters {(selectedSeverities.length > 0 || selectedCategories.length > 0) && ( - + {selectedSeverities.length + selectedCategories.length} )} @@ -217,21 +231,32 @@ const FilterBar = ({ onFilterChange }: FilterBarProps) => {
{isFilterOpen && ( -
+
-
+
Severity
- {SEVERITIES.map(sev => ( + {SEVERITIES.map((sev) => (
-
+
Category
- {CATEGORIES.map(cat => ( + {CATEGORIES.map((cat) => ( + + +
+ {aiControlMessage && ( +
+ {aiControlMessage} +
+ )} + {remoteAudit.length > 0 && ( +
+ Recent global switch changes + {remoteAudit.slice(0, 10).map((entry, index) => ( +
+ {new Date(entry.updatedAt).toLocaleString()} ·{' '} + {entry.previousEnabled ? 'enabled' : 'disabled'} →{' '} + {entry.enabled ? 'enabled' : 'disabled'} · {entry.actor} + {entry.reason ? ` · ${entry.reason}` : ''} +
+ ))} +
+ )} +
+ + +
+ {message && (
diff --git a/src/components/dashboard/LiquidityPredictionDashboard.tsx b/src/components/dashboard/LiquidityPredictionDashboard.tsx index 02f5efbb..bdfa2602 100644 --- a/src/components/dashboard/LiquidityPredictionDashboard.tsx +++ b/src/components/dashboard/LiquidityPredictionDashboard.tsx @@ -1,21 +1,13 @@ -import React, { useEffect, useState, useMemo } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Brain, TrendingUp, - AlertTriangle, Zap, RefreshCw, Sliders, CheckCircle2, ShieldAlert, - ArrowRightLeft, - Clock, - Layers, Activity, - Maximize2, - ArrowUpRight, - ArrowDownRight, - Info, } from 'lucide-react'; import { ResponsiveContainer, @@ -26,9 +18,9 @@ import { Tooltip, CartesianGrid, Legend, - ReferenceLine, } from 'recharts'; import { useStore } from '../../lib/store'; +import { useAIKillSwitch } from '../../context/AIKillSwitchContext'; import { predictLiquidityFlow, PredictionResult, @@ -36,6 +28,7 @@ import { } from '../../ml/liquidityPredictionModel'; export default function LiquidityPredictionDashboard() { + const { enabled, ready } = useAIKillSwitch(); const { network } = useStore(); const [sellingAsset, setSellingAsset] = useState('native'); const [buyingAsset, setBuyingAsset] = useState( @@ -49,42 +42,44 @@ export default function LiquidityPredictionDashboard() { const [selectedStrategy, setSelectedStrategy] = useState(null); const [appliedNotice, setAppliedNotice] = useState(null); - async function fetchPrediction() { + const fetchPrediction = useCallback(async () => { + if (!enabled || !ready) return; setLoading(true); try { const result = await predictLiquidityFlow(sellingAsset, buyingAsset, { horizonHours, tradeAmount, - network, + network: network.networkId, }); setData(result); - if (result.recommendations.length > 0 && !selectedStrategy) { - setSelectedStrategy(result.recommendations[0]); + const firstRecommendation = result.recommendations[0]; + if (firstRecommendation) { + setSelectedStrategy((current) => current ?? firstRecommendation); } } catch (err) { console.error('Failed to load liquidity prediction:', err); } finally { setLoading(false); } - } + }, [enabled, ready, sellingAsset, buyingAsset, horizonHours, tradeAmount, network.networkId]); useEffect(() => { - fetchPrediction(); - }, [sellingAsset, buyingAsset, horizonHours, tradeAmount, network]); + if (enabled && ready) fetchPrediction(); + }, [enabled, ready, fetchPrediction]); useEffect(() => { - if (!autoRefresh) return; + if (!autoRefresh || !enabled || !ready) return; const interval = setInterval(() => { fetchPrediction(); }, 15000); return () => clearInterval(interval); - }, [autoRefresh, sellingAsset, buyingAsset, horizonHours, tradeAmount, network]); + }, [autoRefresh, enabled, ready, fetchPrediction]); + + if (!enabled || !ready) return null; const applyStrategy = (strategy: TradingRecommendation) => { setSelectedStrategy(strategy); - setAppliedNotice( - `Applied Strategy: ${strategy.title}. Parameters updated for DEX execution.` - ); + setAppliedNotice(`Applied Strategy: ${strategy.title}. Parameters updated for DEX execution.`); setTimeout(() => setAppliedNotice(null), 4000); }; @@ -221,7 +216,9 @@ export default function LiquidityPredictionDashboard() { }} >