From 5d4dfc43bd189039f67d737b8587bd1c9f1ef481 Mon Sep 17 00:00:00 2001 From: Ame Date: Thu, 19 Mar 2026 01:45:41 +0800 Subject: [PATCH 1/6] feat: UTA health tracking + auto-recovery on broker failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UTA now tracks consecutive broker call failures and derives health state (healthy → degraded → offline). When offline, broker calls fail fast and a background recovery loop attempts broker.init() with exponential backoff. initAccount always registers UTAs (marking offline if init fails) so they appear in the UI with health indicators instead of silently disappearing. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../trading/UnifiedTradingAccount.spec.ts | 150 +++++++++++++++++- src/domain/trading/UnifiedTradingAccount.ts | 141 ++++++++++++++-- src/domain/trading/account-manager.ts | 29 ++-- src/domain/trading/brokers/mock/MockBroker.ts | 17 +- src/domain/trading/brokers/types.ts | 13 ++ src/main.ts | 19 ++- ui/src/pages/PortfolioPage.tsx | 37 +++-- 7 files changed, 353 insertions(+), 53 deletions(-) diff --git a/src/domain/trading/UnifiedTradingAccount.spec.ts b/src/domain/trading/UnifiedTradingAccount.spec.ts index 26f13237..43604c4e 100644 --- a/src/domain/trading/UnifiedTradingAccount.spec.ts +++ b/src/domain/trading/UnifiedTradingAccount.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import Decimal from 'decimal.js' import { Order, OrderState, UNSET_DOUBLE, UNSET_DECIMAL } from '@traderalice/ibkr' import { UnifiedTradingAccount } from './UnifiedTradingAccount.js' @@ -518,3 +518,151 @@ describe('UTA — constructor', () => { expect(restored.log()[0].message).toBe('initial buy') }) }) + +// ==================== health tracking ==================== + +describe('UTA — health tracking', () => { + beforeEach(() => { vi.useFakeTimers() }) + afterEach(() => { vi.useRealTimers() }) + + it('starts healthy', () => { + const { uta } = createUTA() + expect(uta.health).toBe('healthy') + expect(uta.getHealthInfo().consecutiveFailures).toBe(0) + }) + + it('transitions healthy → degraded after 3 consecutive failures', async () => { + const broker = new MockBroker() + const { uta } = createUTA(broker) + broker.setFailMode(3) + + for (let i = 0; i < 3; i++) { + await expect(uta.getAccount()).rejects.toThrow() + } + expect(uta.health).toBe('degraded') + }) + + it('transitions degraded → offline after 6 consecutive failures', async () => { + const broker = new MockBroker() + const { uta } = createUTA(broker) + broker.setFailMode(6) + + for (let i = 0; i < 6; i++) { + await expect(uta.getAccount()).rejects.toThrow() + } + expect(uta.health).toBe('offline') + }) + + it('resets to healthy on any successful call', async () => { + const broker = new MockBroker() + const { uta } = createUTA(broker) + broker.setFailMode(4) + + for (let i = 0; i < 4; i++) { + await expect(uta.getAccount()).rejects.toThrow() + } + expect(uta.health).toBe('degraded') + + // Next call succeeds (failMode exhausted) + await uta.getAccount() + expect(uta.health).toBe('healthy') + expect(uta.getHealthInfo().consecutiveFailures).toBe(0) + }) + + it('fails fast when offline and recovering', async () => { + const broker = new MockBroker() + const { uta } = createUTA(broker) + broker.setFailMode(100) + + for (let i = 0; i < 6; i++) { + await expect(uta.getAccount()).rejects.toThrow() + } + expect(uta.health).toBe('offline') + expect(uta.getHealthInfo().recovering).toBe(true) + + // Subsequent calls fail fast with offline message + await expect(uta.getAccount()).rejects.toThrow(/offline and reconnecting/) + await uta.close() + }) + + it('push() throws when offline', async () => { + const broker = new MockBroker() + const { uta } = createUTA(broker) + broker.setFailMode(100) + + for (let i = 0; i < 6; i++) { + await expect(uta.getAccount()).rejects.toThrow() + } + + uta.stagePlaceOrder({ aliceId: 'a-AAPL', side: 'buy', type: 'market', qty: 10 }) + uta.commit('buy AAPL') + await expect(uta.push()).rejects.toThrow(/offline/) + await uta.close() + }) + + it('markOffline sets offline state and starts recovery', () => { + const { uta } = createUTA() + uta.markOffline('connection lost') + + expect(uta.health).toBe('offline') + expect(uta.getHealthInfo().recovering).toBe(true) + expect(uta.getHealthInfo().lastError).toBe('connection lost') + uta.close() + }) + + it('auto-recovery restores healthy after broker comes back', async () => { + const broker = new MockBroker() + const { uta } = createUTA(broker) + + // Go offline + broker.setFailMode(6) + for (let i = 0; i < 6; i++) { + await expect(uta.getAccount()).rejects.toThrow() + } + expect(uta.health).toBe('offline') + expect(uta.getHealthInfo().recovering).toBe(true) + + // Broker is back (failMode exhausted) — advance timer to trigger recovery + await vi.advanceTimersByTimeAsync(5_000) + + expect(uta.health).toBe('healthy') + expect(uta.getHealthInfo().recovering).toBe(false) + }) + + it('close() cancels recovery timer', async () => { + const broker = new MockBroker() + const { uta } = createUTA(broker) + uta.markOffline('test') + + expect(uta.getHealthInfo().recovering).toBe(true) + await uta.close() + expect(uta.getHealthInfo().recovering).toBe(false) + }) + + it('getHealthInfo returns full snapshot', async () => { + const broker = new MockBroker() + const { uta } = createUTA(broker) + + // Successful call + await uta.getAccount() + const info = uta.getHealthInfo() + expect(info.status).toBe('healthy') + expect(info.consecutiveFailures).toBe(0) + expect(info.lastSuccessAt).toBeInstanceOf(Date) + expect(info.recovering).toBe(false) + }) + + it('tracks health across different broker methods', async () => { + const broker = new MockBroker() + const { uta } = createUTA(broker) + broker.setFailMode(2) + + await expect(uta.getAccount()).rejects.toThrow() + await expect(uta.getPositions()).rejects.toThrow() + expect(uta.getHealthInfo().consecutiveFailures).toBe(2) + + // Success on a different method resets + await uta.getMarketClock() + expect(uta.health).toBe('healthy') + }) +}) diff --git a/src/domain/trading/UnifiedTradingAccount.ts b/src/domain/trading/UnifiedTradingAccount.ts index 315bc92b..1838632a 100644 --- a/src/domain/trading/UnifiedTradingAccount.ts +++ b/src/domain/trading/UnifiedTradingAccount.ts @@ -9,7 +9,7 @@ import Decimal from 'decimal.js' import { Contract, Order, ContractDescription, ContractDetails } from '@traderalice/ibkr' -import type { IBroker, AccountInfo, Position, OpenOrder, PlaceOrderResult, Quote, MarketClock, AccountCapabilities } from './brokers/types.js' +import type { IBroker, AccountInfo, Position, OpenOrder, PlaceOrderResult, Quote, MarketClock, AccountCapabilities, BrokerHealth, BrokerHealthInfo } from './brokers/types.js' import { TradingGit } from './git/TradingGit.js' import type { Operation, @@ -109,6 +109,19 @@ export class UnifiedTradingAccount { private readonly _getState: () => Promise + // ---- Health tracking ---- + private static readonly DEGRADED_THRESHOLD = 3 + private static readonly OFFLINE_THRESHOLD = 6 + private static readonly RECOVERY_BASE_MS = 5_000 + private static readonly RECOVERY_MAX_MS = 60_000 + + private _consecutiveFailures = 0 + private _lastError?: string + private _lastSuccessAt?: Date + private _lastFailureAt?: Date + private _recoveryTimer?: ReturnType + private _recovering = false + constructor(broker: IBroker, options: UnifiedTradingAccountOptions = {}) { this.broker = broker this.id = broker.id @@ -118,11 +131,13 @@ export class UnifiedTradingAccount { // Wire internals this._getState = async (): Promise => { const pendingIds = this.git.getPendingOrderIds().map(p => p.orderId) - const [accountInfo, positions, orders] = await Promise.all([ - broker.getAccount(), - broker.getPositions(), - broker.getOrders(pendingIds), - ]) + const [accountInfo, positions, orders] = await this._callBroker(() => + Promise.all([ + broker.getAccount(), + broker.getPositions(), + broker.getOrders(pendingIds), + ]), + ) // Stamp aliceId on all contracts returned by broker for (const p of positions) this.stampAliceId(p.contract) for (const o of orders) this.stampAliceId(o.contract) @@ -164,6 +179,92 @@ export class UnifiedTradingAccount { : new TradingGit(gitConfig) } + // ==================== Health ==================== + + get health(): BrokerHealth { + if (this._consecutiveFailures >= UnifiedTradingAccount.OFFLINE_THRESHOLD) return 'offline' + if (this._consecutiveFailures >= UnifiedTradingAccount.DEGRADED_THRESHOLD) return 'degraded' + return 'healthy' + } + + getHealthInfo(): BrokerHealthInfo { + return { + status: this.health, + consecutiveFailures: this._consecutiveFailures, + lastError: this._lastError, + lastSuccessAt: this._lastSuccessAt, + lastFailureAt: this._lastFailureAt, + recovering: this._recovering, + } + } + + /** Force offline state and start recovery. Used when broker.init() fails at startup. */ + markOffline(reason: string): void { + this._consecutiveFailures = UnifiedTradingAccount.OFFLINE_THRESHOLD + this._lastError = reason + this._lastFailureAt = new Date() + this._startRecovery() + } + + private async _callBroker(fn: () => Promise): Promise { + if (this.health === 'offline' && this._recovering) { + throw new Error(`Account "${this.label}" is offline and reconnecting. Try again shortly.`) + } + try { + const result = await fn() + this._onSuccess() + return result + } catch (err) { + this._onFailure(err) + throw err + } + } + + private _onSuccess(): void { + this._consecutiveFailures = 0 + this._lastSuccessAt = new Date() + if (this._recoveryTimer) { + clearTimeout(this._recoveryTimer) + this._recoveryTimer = undefined + this._recovering = false + } + } + + private _onFailure(err: unknown): void { + this._consecutiveFailures++ + this._lastError = err instanceof Error ? err.message : String(err) + this._lastFailureAt = new Date() + if (this.health === 'offline' && !this._recovering) { + this._startRecovery() + } + } + + private _startRecovery(): void { + if (this._recovering) return + this._recovering = true + console.log(`UTA[${this.id}]: offline, starting auto-recovery...`) + this._scheduleRecoveryAttempt(0) + } + + private _scheduleRecoveryAttempt(attempt: number): void { + const delay = Math.min( + UnifiedTradingAccount.RECOVERY_BASE_MS * 2 ** attempt, + UnifiedTradingAccount.RECOVERY_MAX_MS, + ) + this._recoveryTimer = setTimeout(async () => { + try { + await this.broker.init() + await this.broker.getAccount() + this._onSuccess() + console.log(`UTA[${this.id}]: auto-recovery succeeded`) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + console.warn(`UTA[${this.id}]: recovery attempt ${attempt + 1} failed: ${msg}`) + this._scheduleRecoveryAttempt(attempt + 1) + } + }, delay) + } + // ==================== aliceId management ==================== /** Construct aliceId: "{utaId}|{nativeKey}" */ @@ -252,7 +353,10 @@ export class UnifiedTradingAccount { return this.git.commit(message) } - push(): Promise { + async push(): Promise { + if (this.health === 'offline') { + throw new Error(`Account "${this.label}" is offline. Cannot execute trades.`) + } return this.git.push() } @@ -286,7 +390,7 @@ export class UnifiedTradingAccount { const updates: OrderStatusUpdate[] = [] for (const { orderId, symbol } of pendingOrders) { - const brokerOrder = await this.broker.getOrder(orderId) + const brokerOrder = await this._callBroker(() => this.broker.getOrder(orderId)) if (!brokerOrder) continue const status = brokerOrder.orderState.status @@ -323,39 +427,39 @@ export class UnifiedTradingAccount { // ==================== Broker queries (delegation) ==================== getAccount(): Promise { - return this.broker.getAccount() + return this._callBroker(() => this.broker.getAccount()) } async getPositions(): Promise { - const positions = await this.broker.getPositions() + const positions = await this._callBroker(() => this.broker.getPositions()) for (const p of positions) this.stampAliceId(p.contract) return positions } async getOrders(orderIds: string[]): Promise { - const orders = await this.broker.getOrders(orderIds) + const orders = await this._callBroker(() => this.broker.getOrders(orderIds)) for (const o of orders) this.stampAliceId(o.contract) return orders } async getQuote(contract: Contract): Promise { - const quote = await this.broker.getQuote(contract) + const quote = await this._callBroker(() => this.broker.getQuote(contract)) this.stampAliceId(quote.contract) return quote } getMarketClock(): Promise { - return this.broker.getMarketClock() + return this._callBroker(() => this.broker.getMarketClock()) } async searchContracts(pattern: string): Promise { - const results = await this.broker.searchContracts(pattern) + const results = await this._callBroker(() => this.broker.searchContracts(pattern)) for (const desc of results) this.stampAliceId(desc.contract) return results } async getContractDetails(query: Contract): Promise { - const details = await this.broker.getContractDetails(query) + const details = await this._callBroker(() => this.broker.getContractDetails(query)) if (details) this.stampAliceId(details.contract) return details } @@ -380,7 +484,12 @@ export class UnifiedTradingAccount { return this.broker.init() } - close(): Promise { + async close(): Promise { + if (this._recoveryTimer) { + clearTimeout(this._recoveryTimer) + this._recoveryTimer = undefined + this._recovering = false + } return this.broker.close() } } diff --git a/src/domain/trading/account-manager.ts b/src/domain/trading/account-manager.ts index 227deced..9104f96f 100644 --- a/src/domain/trading/account-manager.ts +++ b/src/domain/trading/account-manager.ts @@ -6,7 +6,7 @@ */ import type { Contract, ContractDescription, ContractDetails } from '@traderalice/ibkr' -import type { AccountCapabilities } from './brokers/types.js' +import type { AccountCapabilities, BrokerHealth, BrokerHealthInfo } from './brokers/types.js' import type { UnifiedTradingAccount } from './UnifiedTradingAccount.js' import './contract-ext.js' @@ -17,6 +17,7 @@ export interface AccountSummary { label: string platformId?: string capabilities: AccountCapabilities + health: BrokerHealthInfo } // ==================== Aggregated equity ==================== @@ -32,6 +33,7 @@ export interface AggregatedEquity { equity: number cash: number unrealizedPnL: number + health: BrokerHealth }> } @@ -72,6 +74,7 @@ export class AccountManager { label: uta.label, platformId: uta.platformId, capabilities: uta.getCapabilities(), + health: uta.getHealthInfo(), })) } @@ -126,7 +129,7 @@ export class AccountManager { Array.from(this.entries.values()).map(async (uta) => { try { const info = await uta.getAccount() - return { id: uta.id, label: uta.label, info } + return { id: uta.id, label: uta.label, health: uta.health, info } } catch (err) { const now = Date.now() const lastWarned = this.equityWarnedAt.get(uta.id) ?? 0 @@ -134,7 +137,7 @@ export class AccountManager { console.warn(`getAggregatedEquity: ${uta.id} failed, skipping:`, err) this.equityWarnedAt.set(uta.id, now) } - return { id: uta.id, label: uta.label, info: null } + return { id: uta.id, label: uta.label, health: uta.health, info: null } } }), ) @@ -145,18 +148,20 @@ export class AccountManager { let totalRealizedPnL = 0 const accounts: AggregatedEquity['accounts'] = [] - for (const { id, label, info } of results) { - if (!info) continue - totalEquity += info.netLiquidation - totalCash += info.totalCashValue - totalUnrealizedPnL += info.unrealizedPnL - totalRealizedPnL += info.realizedPnL + for (const { id, label, health, info } of results) { + if (info) { + totalEquity += info.netLiquidation + totalCash += info.totalCashValue + totalUnrealizedPnL += info.unrealizedPnL + totalRealizedPnL += info.realizedPnL + } accounts.push({ id, label, - equity: info.netLiquidation, - cash: info.totalCashValue, - unrealizedPnL: info.unrealizedPnL, + equity: info?.netLiquidation ?? 0, + cash: info?.totalCashValue ?? 0, + unrealizedPnL: info?.unrealizedPnL ?? 0, + health, }) } diff --git a/src/domain/trading/brokers/mock/MockBroker.ts b/src/domain/trading/brokers/mock/MockBroker.ts index 6e56a8cd..ded86929 100644 --- a/src/domain/trading/brokers/mock/MockBroker.ts +++ b/src/domain/trading/brokers/mock/MockBroker.ts @@ -134,6 +134,7 @@ export class MockBroker implements IBroker { private _nextOrderId = 1 private _accountOverride: AccountInfo | null = null private _callLog: CallRecord[] = [] + private _failRemaining = 0 constructor(options: MockBrokerOptions = {}) { this.id = options.id ?? 'mock-paper' @@ -153,6 +154,13 @@ export class MockBroker implements IBroker { this._callLog.push({ method, args, timestamp: Date.now() }) } + private _checkFail(method: string): void { + if (this._failRemaining > 0) { + this._failRemaining-- + throw new Error(`MockBroker[${this.id}]: simulated ${method} failure`) + } + } + /** Get all calls, optionally filtered by method name. */ calls(method?: string): CallRecord[] { return method ? this._callLog.filter(c => c.method === method) : [...this._callLog] @@ -176,7 +184,7 @@ export class MockBroker implements IBroker { // ---- Lifecycle ---- - async init(): Promise { this._record('init', []) } + async init(): Promise { this._record('init', []); this._checkFail('init') } async close(): Promise { this._record('close', []) } // ---- Contract search (stub) ---- @@ -293,6 +301,7 @@ export class MockBroker implements IBroker { async getAccount(): Promise { this._record('getAccount', []) + this._checkFail('getAccount') if (this._accountOverride) return this._accountOverride let unrealizedPnL = 0 @@ -315,6 +324,7 @@ export class MockBroker implements IBroker { async getPositions(): Promise { this._record('getPositions', []) + this._checkFail('getPositions') const result: Position[] = [] for (const pos of this._positions.values()) { const price = this._quotes.get(pos.contract.symbol ?? '') ?? pos.avgCost.toNumber() @@ -425,6 +435,11 @@ export class MockBroker implements IBroker { } } + /** Make the next N broker calls throw. Used to test health transitions. */ + setFailMode(count: number): void { + this._failRemaining = count + } + /** Override account info directly. Bypasses computed values from positions. */ setAccountInfo(info: Partial): void { this._accountOverride = { diff --git a/src/domain/trading/brokers/types.ts b/src/domain/trading/brokers/types.ts index 59c0f0d6..d7487340 100644 --- a/src/domain/trading/brokers/types.ts +++ b/src/domain/trading/brokers/types.ts @@ -102,6 +102,19 @@ export interface MarketClock { timestamp?: Date } +// ==================== Broker health ==================== + +export type BrokerHealth = 'healthy' | 'degraded' | 'offline' + +export interface BrokerHealthInfo { + status: BrokerHealth + consecutiveFailures: number + lastError?: string + lastSuccessAt?: Date + lastFailureAt?: Date + recovering: boolean +} + // ==================== Account capabilities ==================== export interface AccountCapabilities { diff --git a/src/main.ts b/src/main.ts index b726451e..43f3576d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -119,18 +119,12 @@ async function main() { } validatePlatformRefs([...platformRegistry.values()], tradingConfig.accounts) - /** Initialize and register a single account. Returns true if successful. */ + /** Initialize and register a single account. Always registers (offline if init fails). */ async function initAccount( accountCfg: { id: string; platformId: string; guards: Array<{ type: string; options: Record }> }, platform: IPlatform, ): Promise { const broker = createBrokerFromConfig(platform, accountCfg) - try { - await broker.init() - } catch (err) { - console.warn(`trading: ${accountCfg.id} init failed (non-fatal):`, err) - return false - } const savedState = await loadGitState(accountCfg.id) const filePath = gitFilePath(accountCfg.id) const uta = new UnifiedTradingAccount(broker, { @@ -139,8 +133,17 @@ async function main() { onCommit: createGitPersister(filePath), platformId: accountCfg.platformId, }) + + try { + await broker.init() + console.log(`trading: ${uta.label} initialized`) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + console.warn(`trading: ${accountCfg.id} init failed, registered as offline: ${msg}`) + uta.markOffline(msg) + } + accountManager.add(uta) - console.log(`trading: ${uta.label} initialized`) return true } diff --git a/ui/src/pages/PortfolioPage.tsx b/ui/src/pages/PortfolioPage.tsx index 91e29068..3fcf54d8 100644 --- a/ui/src/pages/PortfolioPage.tsx +++ b/ui/src/pages/PortfolioPage.tsx @@ -10,7 +10,7 @@ interface AggregatedEquity { totalCash: number totalUnrealizedPnL: number totalRealizedPnL: number - accounts: Array<{ id: string; label: string; equity: number; cash: number }> + accounts: Array<{ id: string; label: string; equity: number; cash: number; health?: string }> } interface AccountData { @@ -63,7 +63,7 @@ export function PortfolioPage() { const accountSources = (data.equity?.accounts ?? []).map(eq => { const acct = data.accounts.find(a => a.id === eq.id) const unrealizedPnL = acct?.positions.reduce((sum, p) => sum + p.unrealizedPnL, 0) ?? 0 - return { ...eq, provider: acct?.provider ?? '', unrealizedPnL, error: acct?.error } + return { ...eq, provider: acct?.provider ?? '', unrealizedPnL, error: acct?.error, health: eq.health } }) return ( @@ -179,27 +179,34 @@ function HeroItem({ label, value, pnl }: { label: string; value: string; pnl?: n // ==================== Account Strip ==================== -const PROVIDER_COLORS: Record = { - ccxt: 'bg-accent', - alpaca: 'bg-green', +const HEALTH_DOT: Record = { + healthy: 'bg-green', + degraded: 'bg-yellow-400', + offline: 'bg-red', } -function AccountStrip({ sources }: { sources: Array<{ id: string; label: string; provider: string; equity: number; unrealizedPnL: number; error?: string }> }) { +function AccountStrip({ sources }: { sources: Array<{ id: string; label: string; provider: string; equity: number; unrealizedPnL: number; error?: string; health?: string }> }) { return (
{sources.map(s => { - const dotColor = PROVIDER_COLORS[s.provider] || 'bg-text-muted' + const dotColor = HEALTH_DOT[s.health ?? 'healthy'] ?? 'bg-text-muted' + const isOffline = s.health === 'offline' return ( -
+
{s.label} - {fmt(s.equity)} - {s.unrealizedPnL !== 0 && ( - = 0 ? 'text-green' : 'text-red'}> - {fmtPnl(s.unrealizedPnL)} - - )} - {s.error && {s.error}} + {isOffline + ? Reconnecting… + : <> + {fmt(s.equity)} + {s.unrealizedPnL !== 0 && ( + = 0 ? 'text-green' : 'text-red'}> + {fmtPnl(s.unrealizedPnL)} + + )} + + } + {s.error && !isOffline && {s.error}}
) })} From 259684ae77019eb50fd0db103842c7b011965bc9 Mon Sep 17 00:00:00 2001 From: Ame Date: Thu, 19 Mar 2026 01:55:22 +0800 Subject: [PATCH 2/6] refactor: UTA self-connects on construction, remove external init/markOffline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UTA is a business entity that always starts successfully. Broker connection is an internal concern — _connect() fires from the constructor, goes offline if it fails, and auto-recovers. initAccount in main.ts no longer calls broker.init() or distinguishes Alpaca/CCXT — all accounts register synchronously and connect asynchronously. Adds dedicated uta-health.spec.ts with TDD coverage for initial connect failure, exponential backoff recovery, runtime disconnect, and offline behavior (staging works, push blocked). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../trading/UnifiedTradingAccount.spec.ts | 68 +++-- src/domain/trading/UnifiedTradingAccount.ts | 31 ++- .../trading/__test__/uta-health.spec.ts | 258 ++++++++++++++++++ src/domain/trading/account-manager.ts | 2 +- src/main.ts | 61 +---- 5 files changed, 344 insertions(+), 76 deletions(-) create mode 100644 src/domain/trading/__test__/uta-health.spec.ts diff --git a/src/domain/trading/UnifiedTradingAccount.spec.ts b/src/domain/trading/UnifiedTradingAccount.spec.ts index 43604c4e..80fdc309 100644 --- a/src/domain/trading/UnifiedTradingAccount.spec.ts +++ b/src/domain/trading/UnifiedTradingAccount.spec.ts @@ -525,15 +525,49 @@ describe('UTA — health tracking', () => { beforeEach(() => { vi.useFakeTimers() }) afterEach(() => { vi.useRealTimers() }) - it('starts healthy', () => { - const { uta } = createUTA() + /** Let _connect() (fire-and-forget from constructor) complete via microtask flush. */ + async function flush() { await vi.advanceTimersByTimeAsync(0) } + + it('connects automatically on construction and becomes healthy', async () => { + const broker = new MockBroker() + const { uta } = createUTA(broker) + await flush() + expect(uta.health).toBe('healthy') - expect(uta.getHealthInfo().consecutiveFailures).toBe(0) + expect(uta.getHealthInfo().lastSuccessAt).toBeInstanceOf(Date) + }) + + it('goes offline and starts recovery when initial connect fails', async () => { + const broker = new MockBroker() + broker.setFailMode(100) // init + getAccount will fail + const { uta } = createUTA(broker) + await flush() + + expect(uta.health).toBe('offline') + expect(uta.getHealthInfo().recovering).toBe(true) + await uta.close() + }) + + it('auto-recovers after initial connect failure when broker comes back', async () => { + const broker = new MockBroker() + // _connect calls init() which fails (consumes 1). Recovery at 5s: init() + getAccount() succeed. + broker.setFailMode(1) + const { uta } = createUTA(broker) + await flush() + + expect(uta.health).toBe('offline') + + // Advance to trigger first recovery attempt — broker is back (failMode exhausted) + await vi.advanceTimersByTimeAsync(5_000) + + expect(uta.health).toBe('healthy') + expect(uta.getHealthInfo().recovering).toBe(false) }) it('transitions healthy → degraded after 3 consecutive failures', async () => { const broker = new MockBroker() const { uta } = createUTA(broker) + await flush() broker.setFailMode(3) for (let i = 0; i < 3; i++) { @@ -545,17 +579,20 @@ describe('UTA — health tracking', () => { it('transitions degraded → offline after 6 consecutive failures', async () => { const broker = new MockBroker() const { uta } = createUTA(broker) + await flush() broker.setFailMode(6) for (let i = 0; i < 6; i++) { await expect(uta.getAccount()).rejects.toThrow() } expect(uta.health).toBe('offline') + await uta.close() }) it('resets to healthy on any successful call', async () => { const broker = new MockBroker() const { uta } = createUTA(broker) + await flush() broker.setFailMode(4) for (let i = 0; i < 4; i++) { @@ -572,6 +609,7 @@ describe('UTA — health tracking', () => { it('fails fast when offline and recovering', async () => { const broker = new MockBroker() const { uta } = createUTA(broker) + await flush() broker.setFailMode(100) for (let i = 0; i < 6; i++) { @@ -588,6 +626,7 @@ describe('UTA — health tracking', () => { it('push() throws when offline', async () => { const broker = new MockBroker() const { uta } = createUTA(broker) + await flush() broker.setFailMode(100) for (let i = 0; i < 6; i++) { @@ -600,21 +639,13 @@ describe('UTA — health tracking', () => { await uta.close() }) - it('markOffline sets offline state and starts recovery', () => { - const { uta } = createUTA() - uta.markOffline('connection lost') - - expect(uta.health).toBe('offline') - expect(uta.getHealthInfo().recovering).toBe(true) - expect(uta.getHealthInfo().lastError).toBe('connection lost') - uta.close() - }) - - it('auto-recovery restores healthy after broker comes back', async () => { + it('auto-recovery restores healthy after runtime disconnect', async () => { const broker = new MockBroker() const { uta } = createUTA(broker) + await flush() + expect(uta.health).toBe('healthy') - // Go offline + // Go offline via runtime failures broker.setFailMode(6) for (let i = 0; i < 6; i++) { await expect(uta.getAccount()).rejects.toThrow() @@ -631,8 +662,9 @@ describe('UTA — health tracking', () => { it('close() cancels recovery timer', async () => { const broker = new MockBroker() + broker.setFailMode(100) const { uta } = createUTA(broker) - uta.markOffline('test') + await flush() expect(uta.getHealthInfo().recovering).toBe(true) await uta.close() @@ -642,9 +674,8 @@ describe('UTA — health tracking', () => { it('getHealthInfo returns full snapshot', async () => { const broker = new MockBroker() const { uta } = createUTA(broker) + await flush() - // Successful call - await uta.getAccount() const info = uta.getHealthInfo() expect(info.status).toBe('healthy') expect(info.consecutiveFailures).toBe(0) @@ -655,6 +686,7 @@ describe('UTA — health tracking', () => { it('tracks health across different broker methods', async () => { const broker = new MockBroker() const { uta } = createUTA(broker) + await flush() broker.setFailMode(2) await expect(uta.getAccount()).rejects.toThrow() diff --git a/src/domain/trading/UnifiedTradingAccount.ts b/src/domain/trading/UnifiedTradingAccount.ts index 1838632a..8dc07d69 100644 --- a/src/domain/trading/UnifiedTradingAccount.ts +++ b/src/domain/trading/UnifiedTradingAccount.ts @@ -145,7 +145,7 @@ export class UnifiedTradingAccount { netLiquidation: accountInfo.netLiquidation, totalCashValue: accountInfo.totalCashValue, unrealizedPnL: accountInfo.unrealizedPnL, - realizedPnL: accountInfo.realizedPnL, + realizedPnL: accountInfo.realizedPnL ?? 0, positions, pendingOrders: orders.filter(o => o.orderState.status === 'Submitted' || o.orderState.status === 'PreSubmitted'), } @@ -177,6 +177,10 @@ export class UnifiedTradingAccount { this.git = options.savedState ? TradingGit.restore(options.savedState, gitConfig) : new TradingGit(gitConfig) + + // Kick off broker connection asynchronously — UTA is usable immediately, + // broker queries will fail (tracked by health) until init succeeds. + this._connect() } // ==================== Health ==================== @@ -198,12 +202,21 @@ export class UnifiedTradingAccount { } } - /** Force offline state and start recovery. Used when broker.init() fails at startup. */ - markOffline(reason: string): void { - this._consecutiveFailures = UnifiedTradingAccount.OFFLINE_THRESHOLD - this._lastError = reason - this._lastFailureAt = new Date() - this._startRecovery() + /** Initial broker connection — fire-and-forget from constructor. */ + private async _connect(): Promise { + try { + await this.broker.init() + await this.broker.getAccount() + this._onSuccess() + console.log(`UTA[${this.id}]: connected`) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + console.warn(`UTA[${this.id}]: initial connect failed: ${msg}`) + this._consecutiveFailures = UnifiedTradingAccount.OFFLINE_THRESHOLD + this._lastError = msg + this._lastFailureAt = new Date() + this._startRecovery() + } } private async _callBroker(fn: () => Promise): Promise { @@ -480,10 +493,6 @@ export class UnifiedTradingAccount { // ==================== Lifecycle ==================== - init(): Promise { - return this.broker.init() - } - async close(): Promise { if (this._recoveryTimer) { clearTimeout(this._recoveryTimer) diff --git a/src/domain/trading/__test__/uta-health.spec.ts b/src/domain/trading/__test__/uta-health.spec.ts new file mode 100644 index 00000000..77a891e2 --- /dev/null +++ b/src/domain/trading/__test__/uta-health.spec.ts @@ -0,0 +1,258 @@ +/** + * UTA health tracking + auto-recovery — TDD tests. + * + * Covers the full lifecycle: initial connect, runtime disconnect, + * auto-recovery, and health state transitions. + * + * All tests use fake timers to control recovery scheduling. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { UnifiedTradingAccount } from '../UnifiedTradingAccount.js' +import { MockBroker } from '../brokers/mock/index.js' +import '../contract-ext.js' + +/** Let _connect() (fire-and-forget from constructor) complete via microtask flush. */ +async function flush() { await vi.advanceTimersByTimeAsync(0) } + +function createUTA(broker?: MockBroker) { + const b = broker ?? new MockBroker() + const uta = new UnifiedTradingAccount(b) + return { uta, broker: b } +} + +describe('UTA health — initial connect', () => { + beforeEach(() => { vi.useFakeTimers() }) + afterEach(() => { vi.useRealTimers() }) + + it('connects automatically and becomes healthy', async () => { + const { uta } = createUTA() + await flush() + + expect(uta.health).toBe('healthy') + expect(uta.getHealthInfo().lastSuccessAt).toBeInstanceOf(Date) + expect(uta.getHealthInfo().recovering).toBe(false) + }) + + it('goes offline when broker.init() fails at startup', async () => { + const broker = new MockBroker() + broker.setFailMode(1) // init() throws + const { uta } = createUTA(broker) + await flush() + + expect(uta.health).toBe('offline') + expect(uta.getHealthInfo().recovering).toBe(true) + expect(uta.getHealthInfo().lastError).toContain('simulated init failure') + await uta.close() + }) +}) + +describe('UTA health — auto-recovery from initial connect failure', () => { + beforeEach(() => { vi.useFakeTimers() }) + afterEach(() => { vi.useRealTimers() }) + + it('recovers when broker comes back after first attempt', async () => { + const broker = new MockBroker() + broker.setFailMode(1) // _connect → init() fails + const { uta } = createUTA(broker) + await flush() + + expect(uta.health).toBe('offline') + + // failMode exhausted — recovery at 5s should succeed + await vi.advanceTimersByTimeAsync(5_000) + + expect(uta.health).toBe('healthy') + expect(uta.getHealthInfo().recovering).toBe(false) + }) + + it('retries with exponential backoff when broker stays down', async () => { + const broker = new MockBroker() + broker.setFailMode(100) // stays broken + const { uta } = createUTA(broker) + await flush() + + expect(uta.health).toBe('offline') + + // 1st recovery at 5s — still fails + await vi.advanceTimersByTimeAsync(5_000) + expect(uta.health).toBe('offline') + + // 2nd recovery at 10s — still fails + await vi.advanceTimersByTimeAsync(10_000) + expect(uta.health).toBe('offline') + + // 3rd recovery at 20s — still fails + await vi.advanceTimersByTimeAsync(20_000) + expect(uta.health).toBe('offline') + expect(uta.getHealthInfo().recovering).toBe(true) + + await uta.close() + }) + + it('recovers on later attempt after earlier recoveries also fail', async () => { + const broker = new MockBroker() + // Each attempt calls broker.init() which consumes 1 fail if failing. + // _connect: init fails (1), recovery #1 at 5s: init fails (2), recovery #2 at 10s+5s: init succeeds + broker.setFailMode(2) + const { uta } = createUTA(broker) + await flush() + expect(uta.health).toBe('offline') + + // 1st recovery at 5s — init still fails (consumes fail #2) + await vi.advanceTimersByTimeAsync(5_000) + expect(uta.health).toBe('offline') + + // 2nd recovery at 5s + 10s = 15s — failMode exhausted, init + getAccount succeed + await vi.advanceTimersByTimeAsync(10_000) + expect(uta.health).toBe('healthy') + }) +}) + +describe('UTA health — runtime disconnect and recovery', () => { + beforeEach(() => { vi.useFakeTimers() }) + afterEach(() => { vi.useRealTimers() }) + + it('healthy → degraded after 3 consecutive failures', async () => { + const { uta, broker } = createUTA() + await flush() + expect(uta.health).toBe('healthy') + + broker.setFailMode(3) + for (let i = 0; i < 3; i++) { + await expect(uta.getAccount()).rejects.toThrow() + } + + expect(uta.health).toBe('degraded') + }) + + it('healthy → offline after 6 consecutive failures, triggers recovery', async () => { + const { uta, broker } = createUTA() + await flush() + + broker.setFailMode(100) + for (let i = 0; i < 6; i++) { + await expect(uta.getAccount()).rejects.toThrow() + } + + expect(uta.health).toBe('offline') + expect(uta.getHealthInfo().recovering).toBe(true) + await uta.close() + }) + + it('recovers from runtime disconnect when broker comes back', async () => { + const { uta, broker } = createUTA() + await flush() + + // Go offline + broker.setFailMode(6) + for (let i = 0; i < 6; i++) { + await expect(uta.getAccount()).rejects.toThrow() + } + expect(uta.health).toBe('offline') + + // failMode exhausted — recovery should succeed + await vi.advanceTimersByTimeAsync(5_000) + + expect(uta.health).toBe('healthy') + expect(uta.getHealthInfo().recovering).toBe(false) + }) + + it('any successful call resets to healthy', async () => { + const { uta, broker } = createUTA() + await flush() + + broker.setFailMode(4) + for (let i = 0; i < 4; i++) { + await expect(uta.getAccount()).rejects.toThrow() + } + expect(uta.health).toBe('degraded') + + // failMode exhausted — next call succeeds + await uta.getAccount() + expect(uta.health).toBe('healthy') + expect(uta.getHealthInfo().consecutiveFailures).toBe(0) + }) + + it('tracks failures across different broker methods', async () => { + const { uta, broker } = createUTA() + await flush() + + broker.setFailMode(2) + await expect(uta.getAccount()).rejects.toThrow() + await expect(uta.getPositions()).rejects.toThrow() + expect(uta.getHealthInfo().consecutiveFailures).toBe(2) + + await uta.getMarketClock() + expect(uta.health).toBe('healthy') + }) +}) + +describe('UTA health — offline behavior', () => { + beforeEach(() => { vi.useFakeTimers() }) + afterEach(() => { vi.useRealTimers() }) + + it('broker calls fail fast when offline + recovering', async () => { + const { uta, broker } = createUTA() + await flush() + + broker.setFailMode(100) + for (let i = 0; i < 6; i++) { + await expect(uta.getAccount()).rejects.toThrow() + } + + // Now offline — should fail fast without hitting broker + await expect(uta.getAccount()).rejects.toThrow(/offline and reconnecting/) + await uta.close() + }) + + it('push() throws when offline', async () => { + const { uta, broker } = createUTA() + await flush() + + broker.setFailMode(100) + for (let i = 0; i < 6; i++) { + await expect(uta.getAccount()).rejects.toThrow() + } + + uta.stagePlaceOrder({ aliceId: 'a-AAPL', side: 'buy', type: 'market', qty: 10 }) + uta.commit('buy AAPL') + await expect(uta.push()).rejects.toThrow(/offline/) + await uta.close() + }) + + it('staging and commit still work when offline (pure in-memory)', async () => { + const { uta, broker } = createUTA() + await flush() + + broker.setFailMode(100) + for (let i = 0; i < 6; i++) { + await expect(uta.getAccount()).rejects.toThrow() + } + + // Staging is a local operation — should work even when offline + const result = uta.stagePlaceOrder({ aliceId: 'a-AAPL', side: 'buy', type: 'market', qty: 10 }) + expect(result.staged).toBe(true) + + const commit = uta.commit('buy while offline') + expect(commit.prepared).toBe(true) + + await uta.close() + }) +}) + +describe('UTA health — close() cleanup', () => { + beforeEach(() => { vi.useFakeTimers() }) + afterEach(() => { vi.useRealTimers() }) + + it('cancels recovery timer on close', async () => { + const broker = new MockBroker() + broker.setFailMode(100) + const { uta } = createUTA(broker) + await flush() + + expect(uta.getHealthInfo().recovering).toBe(true) + await uta.close() + expect(uta.getHealthInfo().recovering).toBe(false) + }) +}) diff --git a/src/domain/trading/account-manager.ts b/src/domain/trading/account-manager.ts index 9104f96f..753899c2 100644 --- a/src/domain/trading/account-manager.ts +++ b/src/domain/trading/account-manager.ts @@ -153,7 +153,7 @@ export class AccountManager { totalEquity += info.netLiquidation totalCash += info.totalCashValue totalUnrealizedPnL += info.unrealizedPnL - totalRealizedPnL += info.realizedPnL + totalRealizedPnL += info.realizedPnL ?? 0 } accounts.push({ id, diff --git a/src/main.ts b/src/main.ts index 43f3576d..bd632c6d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -119,11 +119,11 @@ async function main() { } validatePlatformRefs([...platformRegistry.values()], tradingConfig.accounts) - /** Initialize and register a single account. Always registers (offline if init fails). */ + /** Create and register a UTA. Broker connection happens asynchronously inside UTA. */ async function initAccount( accountCfg: { id: string; platformId: string; guards: Array<{ type: string; options: Record }> }, platform: IPlatform, - ): Promise { + ): Promise { const broker = createBrokerFromConfig(platform, accountCfg) const savedState = await loadGitState(accountCfg.id) const filePath = gitFilePath(accountCfg.id) @@ -133,42 +133,15 @@ async function main() { onCommit: createGitPersister(filePath), platformId: accountCfg.platformId, }) - - try { - await broker.init() - console.log(`trading: ${uta.label} initialized`) - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - console.warn(`trading: ${accountCfg.id} init failed, registered as offline: ${msg}`) - uta.markOffline(msg) - } - accountManager.add(uta) - return true + return uta } - // Alpaca accounts — sync init (fast, blocks startup) - // CCXT accounts — async background init (loadMarkets is slow) - const ccxtAccountConfigs: Array<{ cfg: typeof tradingConfig.accounts[number]; platform: IPlatform }> = [] - for (const accCfg of tradingConfig.accounts) { const platform = platformRegistry.get(accCfg.platformId)! - if (platform.providerType === 'alpaca') { - await initAccount(accCfg, platform) - } else { - ccxtAccountConfigs.push({ cfg: accCfg, platform }) - } + await initAccount(accCfg, platform) } - // CCXT init in background — register tools when ready - const ccxtInitPromise = ccxtAccountConfigs.length > 0 - ? (async () => { - for (const { cfg, platform } of ccxtAccountConfigs) { - await initAccount(cfg, platform) - } - })() - : Promise.resolve() - // ==================== Brain ==================== const [brainExport, persona] = await Promise.all([ @@ -522,22 +495,18 @@ async function main() { console.log('engine: started') - // ==================== CCXT Background Injection ==================== - // CCXT accounts init in background (loadMarkets is slow). When done, register - // CCXT-specific tools so the next agent call picks them up automatically. - ccxtInitPromise.then(() => { - // Check if any CCXT accounts were successfully registered + // ==================== CCXT Tools ==================== + // All UTAs are registered synchronously — check if any are CCXT and register provider tools. + { const hasCcxt = accountManager.resolve().some((uta) => uta.broker instanceof CcxtBroker) - if (!hasCcxt) return - - toolCenter.register( - createCcxtProviderTools(accountManager), - 'trading-ccxt', - ) - console.log('ccxt: provider tools registered') - }).catch((err) => { - console.error('ccxt: background init failed:', err instanceof Error ? err.message : String(err)) - }) + if (hasCcxt) { + toolCenter.register( + createCcxtProviderTools(accountManager), + 'trading-ccxt', + ) + console.log('ccxt: provider tools registered') + } + } // ==================== Shutdown ==================== From 5352408e0321c403b4c1b44b098e9120a3156445 Mon Sep 17 00:00:00 2001 From: Ame Date: Thu, 19 Mar 2026 11:57:10 +0800 Subject: [PATCH 3/6] refactor: split DataSourcesPage into MarketDataPage + NewsPage Separate structured data (market data) from unstructured data (news) into independent pages with cleaner UI using Section/Field components. Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/App.tsx | 12 +- ui/src/components/Sidebar.tsx | 16 +- ui/src/pages/DataSourcesPage.tsx | 663 ------------------------------- ui/src/pages/MarketDataPage.tsx | 407 +++++++++++++++++++ ui/src/pages/NewsPage.tsx | 159 ++++++++ 5 files changed, 588 insertions(+), 669 deletions(-) delete mode 100644 ui/src/pages/DataSourcesPage.tsx create mode 100644 ui/src/pages/MarketDataPage.tsx create mode 100644 ui/src/pages/NewsPage.tsx diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 745f8ca6..b0819cf2 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -6,7 +6,8 @@ import { PortfolioPage } from './pages/PortfolioPage' import { EventsPage } from './pages/EventsPage' import { SettingsPage } from './pages/SettingsPage' import { AIProviderPage } from './pages/AIProviderPage' -import { DataSourcesPage } from './pages/DataSourcesPage' +import { MarketDataPage } from './pages/MarketDataPage' +import { NewsPage } from './pages/NewsPage' import { TradingPage } from './pages/TradingPage' import { ConnectorsPage } from './pages/ConnectorsPage' import { DevPage } from './pages/DevPage' @@ -15,7 +16,7 @@ import { ToolsPage } from './pages/ToolsPage' import { AgentStatusPage } from './pages/AgentStatusPage' export type Page = - | 'chat' | 'portfolio' | 'events' | 'agent-status' | 'heartbeat' | 'data-sources' | 'connectors' + | 'chat' | 'portfolio' | 'events' | 'agent-status' | 'heartbeat' | 'market-data' | 'news' | 'connectors' | 'trading' | 'ai-provider' | 'settings' | 'tools' | 'dev' @@ -26,7 +27,8 @@ export const ROUTES: Record = { 'events': '/events', 'agent-status': '/agent-status', 'heartbeat': '/heartbeat', - 'data-sources': '/data-sources', + 'market-data': '/market-data', + 'news': '/news', 'connectors': '/connectors', 'tools': '/tools', 'trading': '/trading', @@ -68,7 +70,9 @@ export function App() { } /> } /> } /> - } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index a2426ef4..a8241628 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -88,8 +88,8 @@ const NAV_ITEMS: NavItem[] = [ ), }, { - page: 'data-sources', - label: 'Data Sources', + page: 'market-data', + label: 'Market Data', icon: (active) => ( @@ -98,6 +98,18 @@ const NAV_ITEMS: NavItem[] = [ ), }, + { + page: 'news', + label: 'News', + icon: (active) => ( + + + + + + + ), + }, { page: 'connectors', label: 'Connectors', diff --git a/ui/src/pages/DataSourcesPage.tsx b/ui/src/pages/DataSourcesPage.tsx deleted file mode 100644 index e8abb258..00000000 --- a/ui/src/pages/DataSourcesPage.tsx +++ /dev/null @@ -1,663 +0,0 @@ -import { useState } from 'react' -import { api, type AppConfig, type NewsCollectorConfig, type NewsCollectorFeed } from '../api' -import { SaveIndicator } from '../components/SaveIndicator' -import { Field, inputClass } from '../components/form' -import { Toggle } from '../components/Toggle' -import { useConfigPage } from '../hooks/useConfigPage' -import { PageHeader } from '../components/PageHeader' -import type { SaveStatus } from '../hooks/useAutoSave' - -type OpenbbConfig = Record - -function combineStatus(a: SaveStatus, b: SaveStatus): SaveStatus { - if (a === 'error' || b === 'error') return 'error' - if (a === 'saving' || b === 'saving') return 'saving' - if (a === 'saved' || b === 'saved') return 'saved' - return 'idle' -} - -// ==================== Constants ==================== - -const PROVIDER_OPTIONS: Record = { - equity: ['yfinance', 'fmp', 'intrinio', 'tiingo', 'alpha_vantage'], - crypto: ['yfinance', 'fmp', 'tiingo'], - currency: ['yfinance', 'fmp', 'tiingo'], -} - -const ASSET_LABELS: Record = { - equity: 'Equity', - crypto: 'Crypto', - currency: 'Currency', -} - -/** Maps provider name → providerKeys key. null means free, no key required. */ -const PROVIDER_KEY_MAP: Record = { - yfinance: null, - fmp: 'fmp', - intrinio: 'intrinio', - tiingo: 'tiingo', - alpha_vantage: 'alpha_vantage', - benzinga: 'benzinga', - biztoc: 'biztoc', -} - -/** Macro/utility providers used by dedicated endpoints (not per-asset-class selectable) */ -const UTILITY_PROVIDERS = [ - { key: 'fred', name: 'FRED', desc: 'Federal Reserve Economic Data — CPI, GDP, interest rates, macro indicators.', hint: 'Free — fredaccount.stlouisfed.org/apikeys' }, - { key: 'bls', name: 'BLS', desc: 'Bureau of Labor Statistics — employment, payrolls, wages, CPI.', hint: 'Free — registrationapps.bls.gov/bls_registration' }, - { key: 'eia', name: 'EIA', desc: 'Energy Information Administration — petroleum status, energy reports.', hint: 'Free — eia.gov/opendata' }, - { key: 'econdb', name: 'EconDB', desc: 'Global macro indicators, country profiles, shipping data.', hint: 'Optional — works without key (limited). econdb.com' }, - { key: 'nasdaq', name: 'Nasdaq', desc: 'Nasdaq Data Link — dividend/earnings calendars, short interest.', hint: 'Freemium — data.nasdaq.com' }, - { key: 'tradingeconomics', name: 'Trading Economics', desc: '20M+ indicators across 196 countries, economic calendar.', hint: 'Paid — tradingeconomics.com' }, -] as const - -// ==================== Zone ==================== - -interface ZoneProps { - title: string - subtitle: string - badge?: string - enabled: boolean - onToggle: () => void - children: React.ReactNode -} - -function Zone({ title, subtitle, badge, enabled, onToggle, children }: ZoneProps) { - return ( -
-
-
-
-

{title}

- {badge && ( - - {badge} - - )} -
-

{subtitle}

-
- -
-
- {children} -
-
- ) -} - -// ==================== Market Data Engine ==================== - -interface AssetProviderGridProps { - providers: Record - providerKeys: Record - onProviderChange: (asset: string, provider: string) => void - onKeyChange: (keyName: string, value: string) => void -} - -function AssetProviderGrid({ providers, providerKeys, onProviderChange, onKeyChange }: AssetProviderGridProps) { - const [localKeys, setLocalKeys] = useState>(() => ({ ...providerKeys })) - const [testStatus, setTestStatus] = useState>({}) - - const handleKeyChange = (keyName: string, value: string) => { - setLocalKeys((prev) => ({ ...prev, [keyName]: value })) - setTestStatus((prev) => ({ ...prev, [keyName]: 'idle' })) - onKeyChange(keyName, value) - } - - const testProvider = async (provider: string, keyName: string) => { - const key = localKeys[keyName] - if (!key) return - setTestStatus((prev) => ({ ...prev, [keyName]: 'testing' })) - try { - const result = await api.marketData.testProvider(provider, key) - setTestStatus((prev) => ({ ...prev, [keyName]: result.ok ? 'ok' : 'error' })) - } catch { - setTestStatus((prev) => ({ ...prev, [keyName]: 'error' })) - } - } - - return ( -
-

Asset Providers

- {Object.entries(PROVIDER_OPTIONS).map(([asset, options]) => { - const selectedProvider = providers[asset] || options[0] - const keyName = PROVIDER_KEY_MAP[selectedProvider] ?? null - const status = keyName ? (testStatus[keyName] || 'idle') : 'idle' - - return ( -
- {ASSET_LABELS[asset]} - - {keyName ? ( - <> - handleKeyChange(keyName, e.target.value)} - placeholder="API key" - /> - - - ) : ( - Free - )} -
- ) - })} -
- ) -} - -interface UtilityProvidersSectionProps { - providerKeys: Record - onKeyChange: (keyName: string, value: string) => void -} - -function UtilityProvidersSection({ providerKeys, onKeyChange }: UtilityProvidersSectionProps) { - const [expanded, setExpanded] = useState(false) - const [localKeys, setLocalKeys] = useState>(() => { - const init: Record = {} - for (const p of UTILITY_PROVIDERS) init[p.key] = providerKeys[p.key] || '' - return init - }) - const [testStatus, setTestStatus] = useState>({}) - - const configuredCount = Object.values(localKeys).filter(Boolean).length - - const handleKeyChange = (keyName: string, value: string) => { - setLocalKeys((prev) => ({ ...prev, [keyName]: value })) - setTestStatus((prev) => ({ ...prev, [keyName]: 'idle' })) - onKeyChange(keyName, value) - } - - const testProvider = async (keyName: string) => { - const key = localKeys[keyName] - if (!key) return - setTestStatus((prev) => ({ ...prev, [keyName]: 'testing' })) - try { - const result = await api.marketData.testProvider(keyName, key) - setTestStatus((prev) => ({ ...prev, [keyName]: result.ok ? 'ok' : 'error' })) - } catch { - setTestStatus((prev) => ({ ...prev, [keyName]: 'error' })) - } - } - - return ( -
- - {expanded && ( -
-

- Used by dedicated macro endpoints (FRED for CPI/GDP, BLS for employment, EIA for energy). Not per-asset-class selectable. -

-
- {UTILITY_PROVIDERS.map(({ key, name, desc, hint }) => { - const status = testStatus[key] || 'idle' - return ( - -

{desc}

-

{hint}

-
- handleKeyChange(key, e.target.value)} - placeholder="Not configured" - /> - -
-
- ) - })} -
-
- )} -
- ) -} - -interface MarketDataZoneProps { - openbb: OpenbbConfig - enabled: boolean - onToggle: () => void - onChange: (patch: Partial) => void - onChangeImmediate: (patch: Partial) => void -} - -function MarketDataZone({ openbb, enabled, onToggle, onChange, onChangeImmediate }: MarketDataZoneProps) { - const [testing, setTesting] = useState(false) - const [testStatus, setTestStatus] = useState<'idle' | 'ok' | 'error'>('idle') - - const dataBackend = (openbb.backend as string) || 'typebb-sdk' - const apiUrl = (openbb.apiUrl as string) || 'http://localhost:6900' - const apiServer = (openbb.apiServer as { enabled: boolean; port: number } | undefined) ?? { enabled: false, port: 6901 } - const providers = (openbb.providers ?? { - equity: 'yfinance', crypto: 'yfinance', currency: 'yfinance', - }) as Record - const providerKeys = (openbb.providerKeys ?? {}) as Record - - const testConnection = async () => { - setTesting(true) - setTestStatus('idle') - try { - const res = await fetch(`${apiUrl}/api/v1/equity/search?query=AAPL&provider=sec`, { signal: AbortSignal.timeout(5000) }) - setTestStatus(res.ok ? 'ok' : 'error') - } catch { - setTestStatus('error') - } finally { - setTesting(false) - } - } - - const handleProviderChange = (asset: string, provider: string) => { - onChangeImmediate({ providers: { ...providers, [asset]: provider } }) - } - - const handleKeyChange = (keyName: string, value: string) => { - const all = (openbb.providerKeys ?? {}) as Record - const updated = { ...all, [keyName]: value } - const cleaned: Record = {} - for (const [k, v] of Object.entries(updated)) { - if (v) cleaned[k] = v - } - onChange({ providerKeys: cleaned }) - } - - return ( - - {/* Backend selector */} -
-

Data Backend

-
- {(['typebb-sdk', 'openbb-api'] as const).map((backend, i) => ( - - ))} -
-

- {dataBackend === 'typebb-sdk' - ? 'Uses the built-in TypeBB engine. No external process required.' - : 'Connects to an external OpenBB-compatible HTTP endpoint.'} -

-
- - {/* Connection — only shown for external OpenBB backend */} - {dataBackend === 'openbb-api' && ( -
-

Connection

-
- { onChange({ apiUrl: e.target.value }); setTestStatus('idle') }} - placeholder="http://localhost:6900" - /> - - {testStatus !== 'idle' && ( -
- )} -
-
- )} - - {/* Asset providers + inline keys */} - - - {/* Embedded API server */} -
-

Embedded API Server

-
-
-

Expose OpenBB HTTP API

-

- Start an OpenBB-compatible HTTP server at Alice startup. Other services can connect to{' '} - http://localhost:{apiServer.port}. -

- {apiServer.enabled && ( -
- - onChange({ apiServer: { ...apiServer, port: Number(e.target.value) || 6901 } })} - /> -
- )} -
- onChangeImmediate({ apiServer: { ...apiServer, enabled: v } })} - /> -
-
- - {/* Utility/macro providers */} - - - ) -} - -// ==================== Open Intelligence ==================== - -function FeedsSection({ - feeds, - onChange, -}: { - feeds: NewsCollectorFeed[] - onChange: (feeds: NewsCollectorFeed[]) => void -}) { - const [newName, setNewName] = useState('') - const [newUrl, setNewUrl] = useState('') - const [newSource, setNewSource] = useState('') - - const removeFeed = (index: number) => onChange(feeds.filter((_, i) => i !== index)) - - const addFeed = () => { - if (!newName.trim() || !newUrl.trim() || !newSource.trim()) return - onChange([...feeds, { name: newName.trim(), url: newUrl.trim(), source: newSource.trim() }]) - setNewName('') - setNewUrl('') - setNewSource('') - } - - return ( -
-

RSS Feeds

-

- Collected articles are searchable via globNews / grepNews / readNews. Changes take effect on the next fetch cycle. -

- - {/* Existing feeds */} - {feeds.length > 0 && ( -
- {feeds.map((feed, i) => ( -
-
-

{feed.name}

-

{feed.url}

-

source: {feed.source}

-
- -
- ))} -
- )} - {feeds.length === 0 && ( -

No feeds configured.

- )} - - {/* Add feed form */} -
-

Add Feed

-
-
- - setNewName(e.target.value)} placeholder="e.g. CoinDesk" /> -
-
- - setNewSource(e.target.value)} placeholder="e.g. coindesk" /> -
-
-
- - setNewUrl(e.target.value)} placeholder="https://example.com/rss.xml" /> -
- -
-
- ) -} - -interface CompactNewsSettingsProps { - config: NewsCollectorConfig - onChange: (patch: Partial) => void - onChangeImmediate: (patch: Partial) => void -} - -function CompactNewsSettings({ config, onChange }: CompactNewsSettingsProps) { - return ( -
-

Settings

-
-
- - onChange({ intervalMinutes: Number(e.target.value) || 10 })} - /> -
-
- - onChange({ retentionDays: Number(e.target.value) || 7 })} - /> -
-
-
- ) -} - -interface OpenIntelligenceZoneProps { - config: NewsCollectorConfig - enabled: boolean - onToggle: () => void - onChange: (patch: Partial) => void - onChangeImmediate: (patch: Partial) => void -} - -function OpenIntelligenceZone({ config, enabled, onToggle, onChange, onChangeImmediate }: OpenIntelligenceZoneProps) { - const badge = config.feeds.length > 0 ? `${config.feeds.length} feeds` : undefined - - return ( - - onChangeImmediate({ feeds })} - /> - - - ) -} - -// ==================== Page ==================== - -const DEFAULT_NEWS_CONFIG: NewsCollectorConfig = { - enabled: true, - intervalMinutes: 10, - maxInMemory: 2000, - retentionDays: 7, - feeds: [], -} - -export function DataSourcesPage() { - const openbb = useConfigPage({ - section: 'marketData', - extract: (full: AppConfig) => (full as Record).marketData as OpenbbConfig, - }) - - const news = useConfigPage({ - section: 'news', - extract: (full: AppConfig) => (full as Record).news as NewsCollectorConfig, - }) - - const status = combineStatus(openbb.status, news.status) - const loadError = openbb.loadError || news.loadError - const retry = () => { openbb.retry(); news.retry() } - - const openbbEnabled = !openbb.config || (openbb.config as Record).enabled !== false - const newsEnabled = !news.config || news.config.enabled !== false - - return ( -
- } - /> - - {/* Content */} -
-
- {/* Market Data Engine zone */} - {openbb.config ? ( - openbb.updateConfigImmediate({ enabled: !openbbEnabled } as Partial)} - onChange={openbb.updateConfig} - onChangeImmediate={openbb.updateConfigImmediate} - /> - ) : ( - {}} - > -

Loading…

-
- )} - - {/* Open Intelligence zone */} - {news.config ? ( - news.updateConfigImmediate({ enabled: !newsEnabled })} - onChange={news.updateConfig} - onChangeImmediate={news.updateConfigImmediate} - /> - ) : ( - {}} - > -

Loading…

-
- )} -
- {loadError &&

Failed to load configuration.

} -
-
- ) -} diff --git a/ui/src/pages/MarketDataPage.tsx b/ui/src/pages/MarketDataPage.tsx new file mode 100644 index 00000000..4c167dec --- /dev/null +++ b/ui/src/pages/MarketDataPage.tsx @@ -0,0 +1,407 @@ +import { useState } from 'react' +import { api, type AppConfig } from '../api' +import { SaveIndicator } from '../components/SaveIndicator' +import { Section, Field, inputClass } from '../components/form' +import { Toggle } from '../components/Toggle' +import { useConfigPage } from '../hooks/useConfigPage' +import { PageHeader } from '../components/PageHeader' + +type MarketDataConfig = Record + +// ==================== Constants ==================== + +const PROVIDER_OPTIONS: Record = { + equity: ['yfinance', 'fmp', 'intrinio', 'tiingo', 'alpha_vantage'], + crypto: ['yfinance', 'fmp', 'tiingo'], + currency: ['yfinance', 'fmp', 'tiingo'], +} + +const ASSET_LABELS: Record = { + equity: 'Equity', + crypto: 'Crypto', + currency: 'Currency', +} + +/** Maps provider name → providerKeys key. null means free, no key required. */ +const PROVIDER_KEY_MAP: Record = { + yfinance: null, + fmp: 'fmp', + intrinio: 'intrinio', + tiingo: 'tiingo', + alpha_vantage: 'alpha_vantage', + benzinga: 'benzinga', + biztoc: 'biztoc', +} + +const UTILITY_PROVIDERS = [ + { key: 'fred', name: 'FRED', desc: 'Federal Reserve Economic Data — CPI, GDP, interest rates, macro indicators.', hint: 'Free — fredaccount.stlouisfed.org/apikeys' }, + { key: 'bls', name: 'BLS', desc: 'Bureau of Labor Statistics — employment, payrolls, wages, CPI.', hint: 'Free — registrationapps.bls.gov/bls_registration' }, + { key: 'eia', name: 'EIA', desc: 'Energy Information Administration — petroleum status, energy reports.', hint: 'Free — eia.gov/opendata' }, + { key: 'econdb', name: 'EconDB', desc: 'Global macro indicators, country profiles, shipping data.', hint: 'Optional — works without key (limited). econdb.com' }, + { key: 'nasdaq', name: 'Nasdaq', desc: 'Nasdaq Data Link — dividend/earnings calendars, short interest.', hint: 'Freemium — data.nasdaq.com' }, + { key: 'tradingeconomics', name: 'Trading Economics', desc: '20M+ indicators across 196 countries, economic calendar.', hint: 'Paid — tradingeconomics.com' }, +] as const + +// ==================== Test Button ==================== + +function TestButton({ + status, + disabled, + onClick, +}: { + status: 'idle' | 'testing' | 'ok' | 'error' + disabled: boolean + onClick: () => void +}) { + return ( + + ) +} + +// ==================== Asset Providers ==================== + +interface AssetProviderGridProps { + providers: Record + providerKeys: Record + onProviderChange: (asset: string, provider: string) => void + onKeyChange: (keyName: string, value: string) => void +} + +function AssetProviderGrid({ providers, providerKeys, onProviderChange, onKeyChange }: AssetProviderGridProps) { + const [localKeys, setLocalKeys] = useState>(() => ({ ...providerKeys })) + const [testStatus, setTestStatus] = useState>({}) + + const handleKeyChange = (keyName: string, value: string) => { + setLocalKeys((prev) => ({ ...prev, [keyName]: value })) + setTestStatus((prev) => ({ ...prev, [keyName]: 'idle' })) + onKeyChange(keyName, value) + } + + const testProvider = async (provider: string, keyName: string) => { + const key = localKeys[keyName] + if (!key) return + setTestStatus((prev) => ({ ...prev, [keyName]: 'testing' })) + try { + const result = await api.marketData.testProvider(provider, key) + setTestStatus((prev) => ({ ...prev, [keyName]: result.ok ? 'ok' : 'error' })) + } catch { + setTestStatus((prev) => ({ ...prev, [keyName]: 'error' })) + } + } + + return ( +
+
+ {Object.entries(PROVIDER_OPTIONS).map(([asset, options]) => { + const selectedProvider = providers[asset] || options[0] + const keyName = PROVIDER_KEY_MAP[selectedProvider] ?? null + const status = keyName ? (testStatus[keyName] || 'idle') : 'idle' + + return ( +
+ {ASSET_LABELS[asset]} + + {keyName ? ( + <> + handleKeyChange(keyName, e.target.value)} + placeholder="API key" + /> + testProvider(selectedProvider, keyName)} + /> + + ) : ( + Free + )} +
+ ) + })} +
+
+ ) +} + +// ==================== Utility Providers ==================== + +interface UtilityProvidersSectionProps { + providerKeys: Record + onKeyChange: (keyName: string, value: string) => void +} + +function UtilityProvidersSection({ providerKeys, onKeyChange }: UtilityProvidersSectionProps) { + const [localKeys, setLocalKeys] = useState>(() => { + const init: Record = {} + for (const p of UTILITY_PROVIDERS) init[p.key] = providerKeys[p.key] || '' + return init + }) + const [testStatus, setTestStatus] = useState>({}) + + const handleKeyChange = (keyName: string, value: string) => { + setLocalKeys((prev) => ({ ...prev, [keyName]: value })) + setTestStatus((prev) => ({ ...prev, [keyName]: 'idle' })) + onKeyChange(keyName, value) + } + + const testProvider = async (keyName: string) => { + const key = localKeys[keyName] + if (!key) return + setTestStatus((prev) => ({ ...prev, [keyName]: 'testing' })) + try { + const result = await api.marketData.testProvider(keyName, key) + setTestStatus((prev) => ({ ...prev, [keyName]: result.ok ? 'ok' : 'error' })) + } catch { + setTestStatus((prev) => ({ ...prev, [keyName]: 'error' })) + } + } + + return ( +
+
+ {UTILITY_PROVIDERS.map(({ key, name, desc, hint }) => { + const status = testStatus[key] || 'idle' + return ( + +

{desc}

+
+ handleKeyChange(key, e.target.value)} + placeholder="Not configured" + /> + testProvider(key)} + /> +
+
+ ) + })} +
+
+ ) +} + +// ==================== Page ==================== + +export function MarketDataPage() { + const { config, status, loadError, updateConfig, updateConfigImmediate, retry } = useConfigPage({ + section: 'marketData', + extract: (full: AppConfig) => (full as Record).marketData as MarketDataConfig, + }) + + const enabled = !config || (config as Record).enabled !== false + + if (!config) { + return ( +
+ +
+

Loading…

+
+
+ ) + } + + const dataBackend = (config.backend as string) || 'typebb-sdk' + const apiUrl = (config.apiUrl as string) || 'http://localhost:6900' + const apiServer = (config.apiServer as { enabled: boolean; port: number } | undefined) ?? { enabled: false, port: 6901 } + const providers = (config.providers ?? { equity: 'yfinance', crypto: 'yfinance', currency: 'yfinance' }) as Record + const providerKeys = (config.providerKeys ?? {}) as Record + + const handleProviderChange = (asset: string, provider: string) => { + updateConfigImmediate({ providers: { ...providers, [asset]: provider } }) + } + + const handleKeyChange = (keyName: string, value: string) => { + const all = (config.providerKeys ?? {}) as Record + const updated = { ...all, [keyName]: value } + const cleaned: Record = {} + for (const [k, v] of Object.entries(updated)) { + if (v) cleaned[k] = v + } + updateConfig({ providerKeys: cleaned }) + } + + return ( +
+ + + updateConfigImmediate({ enabled: v })} /> +
+ } + /> + +
+
+ {/* Data Backend */} + { updateConfigImmediate({ backend }); }} + onApiUrlChange={(url) => updateConfig({ apiUrl: url })} + /> + + {/* Asset Providers */} + + + {/* Embedded API Server */} +
+
+
+

Enable HTTP server

+

+ Serves at http://localhost:{apiServer.port} +

+
+ updateConfigImmediate({ apiServer: { ...apiServer, enabled: v } })} + /> +
+ {apiServer.enabled && ( + + updateConfig({ apiServer: { ...apiServer, port: Number(e.target.value) || 6901 } })} + /> + + )} +
+ + {/* Macro & Utility Providers */} + +
+ {loadError &&

Failed to load configuration.

} +
+
+ ) +} + +// ==================== Data Backend Section ==================== + +function DataBackendSection({ + backend, + apiUrl, + onBackendChange, + onApiUrlChange, +}: { + backend: string + apiUrl: string + onBackendChange: (backend: string) => void + onApiUrlChange: (url: string) => void +}) { + const [testing, setTesting] = useState(false) + const [testStatus, setTestStatus] = useState<'idle' | 'ok' | 'error'>('idle') + + const testConnection = async () => { + setTesting(true) + setTestStatus('idle') + try { + const res = await fetch(`${apiUrl}/api/v1/equity/search?query=AAPL&provider=sec`, { signal: AbortSignal.timeout(5000) }) + setTestStatus(res.ok ? 'ok' : 'error') + } catch { + setTestStatus('error') + } finally { + setTesting(false) + } + } + + return ( +
+
+ {(['typebb-sdk', 'openbb-api'] as const).map((opt, i) => ( + + ))} +
+

+ {backend === 'typebb-sdk' + ? 'Uses the built-in TypeBB engine. No external process required.' + : 'Connects to an external OpenBB-compatible HTTP endpoint.'} +

+ + {backend === 'openbb-api' && ( +
+ +
+ { onApiUrlChange(e.target.value); setTestStatus('idle') }} + placeholder="http://localhost:6900" + /> + +
+
+
+ )} +
+ ) +} diff --git a/ui/src/pages/NewsPage.tsx b/ui/src/pages/NewsPage.tsx new file mode 100644 index 00000000..8768cdea --- /dev/null +++ b/ui/src/pages/NewsPage.tsx @@ -0,0 +1,159 @@ +import { useState } from 'react' +import { type AppConfig, type NewsCollectorConfig, type NewsCollectorFeed } from '../api' +import { SaveIndicator } from '../components/SaveIndicator' +import { Section, Field, inputClass } from '../components/form' +import { Toggle } from '../components/Toggle' +import { useConfigPage } from '../hooks/useConfigPage' +import { PageHeader } from '../components/PageHeader' + +// ==================== Feeds Section ==================== + +function FeedsSection({ + feeds, + onChange, +}: { + feeds: NewsCollectorFeed[] + onChange: (feeds: NewsCollectorFeed[]) => void +}) { + const [newName, setNewName] = useState('') + const [newUrl, setNewUrl] = useState('') + const [newSource, setNewSource] = useState('') + + const removeFeed = (index: number) => onChange(feeds.filter((_, i) => i !== index)) + + const addFeed = () => { + if (!newName.trim() || !newUrl.trim() || !newSource.trim()) return + onChange([...feeds, { name: newName.trim(), url: newUrl.trim(), source: newSource.trim() }]) + setNewName('') + setNewUrl('') + setNewSource('') + } + + return ( +
0 ? `${feeds.length} feed${feeds.length > 1 ? 's' : ''} configured. Articles are searchable via globNews / grepNews / readNews.` : 'No feeds configured. Add feeds below to start collecting articles.'} + > + {/* Existing feeds */} + {feeds.length > 0 && ( +
+ {feeds.map((feed, i) => ( +
+
+

{feed.name}

+

{feed.url}

+

source: {feed.source}

+
+ +
+ ))} +
+ )} + + {/* Add feed form */} +
+

Add Feed

+
+ + setNewName(e.target.value)} placeholder="e.g. CoinDesk" /> + + + setNewSource(e.target.value)} placeholder="e.g. coindesk" /> + +
+ + setNewUrl(e.target.value)} placeholder="https://example.com/rss.xml" /> + + +
+
+ ) +} + +// ==================== Page ==================== + +const DEFAULT_NEWS_CONFIG: NewsCollectorConfig = { + enabled: true, + intervalMinutes: 10, + maxInMemory: 2000, + retentionDays: 7, + feeds: [], +} + +export function NewsPage() { + const { config, status, loadError, updateConfig, updateConfigImmediate, retry } = useConfigPage({ + section: 'news', + extract: (full: AppConfig) => (full as Record).news as NewsCollectorConfig, + }) + + const cfg = config ?? DEFAULT_NEWS_CONFIG + const enabled = cfg.enabled !== false + + return ( +
+ + + updateConfigImmediate({ enabled: v })} /> +
+ } + /> + +
+
+ {/* Collection Settings */} +
+
+ + updateConfig({ intervalMinutes: Number(e.target.value) || 10 })} + /> + + + updateConfig({ retentionDays: Number(e.target.value) || 7 })} + /> + +
+
+ + {/* RSS Feeds */} + updateConfigImmediate({ feeds })} + /> +
+ {loadError &&

Failed to load configuration.

} +
+
+ ) +} From eb85fd48675ea0017da0e17f0a998474c8aec77f Mon Sep 17 00:00:00 2001 From: Ame Date: Thu, 19 Mar 2026 12:04:19 +0800 Subject: [PATCH 4/6] ui: description-aside layout for config pages (MarketData, News) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ConfigSection component — two-column settings layout with title/description on the left and controls on the right. Wider content area (880px, centered) replaces narrow left-aligned 640px. Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/components/form.tsx | 23 +++ ui/src/pages/MarketDataPage.tsx | 312 +++++++++++++++++--------------- ui/src/pages/NewsPage.tsx | 155 ++++++++-------- 3 files changed, 269 insertions(+), 221 deletions(-) diff --git a/ui/src/components/form.tsx b/ui/src/components/form.tsx index aa19b01a..b1e5ff63 100644 --- a/ui/src/components/form.tsx +++ b/ui/src/components/form.tsx @@ -45,6 +45,29 @@ export function Section({ id, title, description, children }: SectionProps) { ) } +// ==================== ConfigSection ==================== + +/** Two-column settings layout: title + description on the left, controls on the right. */ +interface ConfigSectionProps { + title: string + description?: string + children: ReactNode +} + +export function ConfigSection({ title, description, children }: ConfigSectionProps) { + return ( +
+
+

{title}

+ {description && ( +

{description}

+ )} +
+
{children}
+
+ ) +} + // ==================== Field ==================== interface FieldProps { diff --git a/ui/src/pages/MarketDataPage.tsx b/ui/src/pages/MarketDataPage.tsx index 4c167dec..49e3d1e7 100644 --- a/ui/src/pages/MarketDataPage.tsx +++ b/ui/src/pages/MarketDataPage.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { api, type AppConfig } from '../api' import { SaveIndicator } from '../components/SaveIndicator' -import { Section, Field, inputClass } from '../components/form' +import { ConfigSection, Field, inputClass } from '../components/form' import { Toggle } from '../components/Toggle' import { useConfigPage } from '../hooks/useConfigPage' import { PageHeader } from '../components/PageHeader' @@ -70,144 +70,6 @@ function TestButton({ ) } -// ==================== Asset Providers ==================== - -interface AssetProviderGridProps { - providers: Record - providerKeys: Record - onProviderChange: (asset: string, provider: string) => void - onKeyChange: (keyName: string, value: string) => void -} - -function AssetProviderGrid({ providers, providerKeys, onProviderChange, onKeyChange }: AssetProviderGridProps) { - const [localKeys, setLocalKeys] = useState>(() => ({ ...providerKeys })) - const [testStatus, setTestStatus] = useState>({}) - - const handleKeyChange = (keyName: string, value: string) => { - setLocalKeys((prev) => ({ ...prev, [keyName]: value })) - setTestStatus((prev) => ({ ...prev, [keyName]: 'idle' })) - onKeyChange(keyName, value) - } - - const testProvider = async (provider: string, keyName: string) => { - const key = localKeys[keyName] - if (!key) return - setTestStatus((prev) => ({ ...prev, [keyName]: 'testing' })) - try { - const result = await api.marketData.testProvider(provider, key) - setTestStatus((prev) => ({ ...prev, [keyName]: result.ok ? 'ok' : 'error' })) - } catch { - setTestStatus((prev) => ({ ...prev, [keyName]: 'error' })) - } - } - - return ( -
-
- {Object.entries(PROVIDER_OPTIONS).map(([asset, options]) => { - const selectedProvider = providers[asset] || options[0] - const keyName = PROVIDER_KEY_MAP[selectedProvider] ?? null - const status = keyName ? (testStatus[keyName] || 'idle') : 'idle' - - return ( -
- {ASSET_LABELS[asset]} - - {keyName ? ( - <> - handleKeyChange(keyName, e.target.value)} - placeholder="API key" - /> - testProvider(selectedProvider, keyName)} - /> - - ) : ( - Free - )} -
- ) - })} -
-
- ) -} - -// ==================== Utility Providers ==================== - -interface UtilityProvidersSectionProps { - providerKeys: Record - onKeyChange: (keyName: string, value: string) => void -} - -function UtilityProvidersSection({ providerKeys, onKeyChange }: UtilityProvidersSectionProps) { - const [localKeys, setLocalKeys] = useState>(() => { - const init: Record = {} - for (const p of UTILITY_PROVIDERS) init[p.key] = providerKeys[p.key] || '' - return init - }) - const [testStatus, setTestStatus] = useState>({}) - - const handleKeyChange = (keyName: string, value: string) => { - setLocalKeys((prev) => ({ ...prev, [keyName]: value })) - setTestStatus((prev) => ({ ...prev, [keyName]: 'idle' })) - onKeyChange(keyName, value) - } - - const testProvider = async (keyName: string) => { - const key = localKeys[keyName] - if (!key) return - setTestStatus((prev) => ({ ...prev, [keyName]: 'testing' })) - try { - const result = await api.marketData.testProvider(keyName, key) - setTestStatus((prev) => ({ ...prev, [keyName]: result.ok ? 'ok' : 'error' })) - } catch { - setTestStatus((prev) => ({ ...prev, [keyName]: 'error' })) - } - } - - return ( -
-
- {UTILITY_PROVIDERS.map(({ key, name, desc, hint }) => { - const status = testStatus[key] || 'idle' - return ( - -

{desc}

-
- handleKeyChange(key, e.target.value)} - placeholder="Not configured" - /> - testProvider(key)} - /> -
-
- ) - })} -
-
- ) -} - // ==================== Page ==================== export function MarketDataPage() { @@ -262,8 +124,8 @@ export function MarketDataPage() { } /> -
-
+
+
{/* Data Backend */} {/* Asset Providers */} - {/* Embedded API Server */} -
+

Enable HTTP server

@@ -307,7 +172,7 @@ export function MarketDataPage() { /> )} -
+ {/* Macro & Utility Providers */}
- {loadError &&

Failed to load configuration.

} + {loadError &&

Failed to load configuration.

}
) @@ -351,7 +216,10 @@ function DataBackendSection({ } return ( -
+
{(['typebb-sdk', 'openbb-api'] as const).map((opt, i) => (
)} -
+ + ) +} + +// ==================== Asset Providers Section ==================== + +function AssetProvidersSection({ + providers, + providerKeys, + onProviderChange, + onKeyChange, +}: AssetProviderGridProps) { + const [localKeys, setLocalKeys] = useState>(() => ({ ...providerKeys })) + const [testStatus, setTestStatus] = useState>({}) + + const handleKeyChange = (keyName: string, value: string) => { + setLocalKeys((prev) => ({ ...prev, [keyName]: value })) + setTestStatus((prev) => ({ ...prev, [keyName]: 'idle' })) + onKeyChange(keyName, value) + } + + const testProvider = async (provider: string, keyName: string) => { + const key = localKeys[keyName] + if (!key) return + setTestStatus((prev) => ({ ...prev, [keyName]: 'testing' })) + try { + const result = await api.marketData.testProvider(provider, key) + setTestStatus((prev) => ({ ...prev, [keyName]: result.ok ? 'ok' : 'error' })) + } catch { + setTestStatus((prev) => ({ ...prev, [keyName]: 'error' })) + } + } + + return ( + +
+ {Object.entries(PROVIDER_OPTIONS).map(([asset, options]) => { + const selectedProvider = providers[asset] || options[0] + const keyName = PROVIDER_KEY_MAP[selectedProvider] ?? null + const status = keyName ? (testStatus[keyName] || 'idle') : 'idle' + + return ( +
+ {ASSET_LABELS[asset]} + + {keyName ? ( + <> + handleKeyChange(keyName, e.target.value)} + placeholder="API key" + /> + testProvider(selectedProvider, keyName)} + /> + + ) : ( + Free + )} +
+ ) + })} +
+
+ ) +} + +interface AssetProviderGridProps { + providers: Record + providerKeys: Record + onProviderChange: (asset: string, provider: string) => void + onKeyChange: (keyName: string, value: string) => void +} + +// ==================== Utility Providers Section ==================== + +function UtilityProvidersSection({ + providerKeys, + onKeyChange, +}: { + providerKeys: Record + onKeyChange: (keyName: string, value: string) => void +}) { + const [localKeys, setLocalKeys] = useState>(() => { + const init: Record = {} + for (const p of UTILITY_PROVIDERS) init[p.key] = providerKeys[p.key] || '' + return init + }) + const [testStatus, setTestStatus] = useState>({}) + + const handleKeyChange = (keyName: string, value: string) => { + setLocalKeys((prev) => ({ ...prev, [keyName]: value })) + setTestStatus((prev) => ({ ...prev, [keyName]: 'idle' })) + onKeyChange(keyName, value) + } + + const testProvider = async (keyName: string) => { + const key = localKeys[keyName] + if (!key) return + setTestStatus((prev) => ({ ...prev, [keyName]: 'testing' })) + try { + const result = await api.marketData.testProvider(keyName, key) + setTestStatus((prev) => ({ ...prev, [keyName]: result.ok ? 'ok' : 'error' })) + } catch { + setTestStatus((prev) => ({ ...prev, [keyName]: 'error' })) + } + } + + return ( + +
+ {UTILITY_PROVIDERS.map(({ key, name, desc, hint }) => { + const status = testStatus[key] || 'idle' + return ( + +

{desc}

+
+ handleKeyChange(key, e.target.value)} + placeholder="Not configured" + /> + testProvider(key)} + /> +
+
+ ) + })} +
+
) } diff --git a/ui/src/pages/NewsPage.tsx b/ui/src/pages/NewsPage.tsx index 8768cdea..fa6a30fb 100644 --- a/ui/src/pages/NewsPage.tsx +++ b/ui/src/pages/NewsPage.tsx @@ -1,11 +1,84 @@ import { useState } from 'react' import { type AppConfig, type NewsCollectorConfig, type NewsCollectorFeed } from '../api' import { SaveIndicator } from '../components/SaveIndicator' -import { Section, Field, inputClass } from '../components/form' +import { ConfigSection, Field, inputClass } from '../components/form' import { Toggle } from '../components/Toggle' import { useConfigPage } from '../hooks/useConfigPage' import { PageHeader } from '../components/PageHeader' +// ==================== Page ==================== + +const DEFAULT_NEWS_CONFIG: NewsCollectorConfig = { + enabled: true, + intervalMinutes: 10, + maxInMemory: 2000, + retentionDays: 7, + feeds: [], +} + +export function NewsPage() { + const { config, status, loadError, updateConfig, updateConfigImmediate, retry } = useConfigPage({ + section: 'news', + extract: (full: AppConfig) => (full as Record).news as NewsCollectorConfig, + }) + + const cfg = config ?? DEFAULT_NEWS_CONFIG + const enabled = cfg.enabled !== false + + return ( +
+ + + updateConfigImmediate({ enabled: v })} /> +
+ } + /> + +
+
+ {/* Collection Settings */} + +
+ + updateConfig({ intervalMinutes: Number(e.target.value) || 10 })} + /> + + + updateConfig({ retentionDays: Number(e.target.value) || 7 })} + /> + +
+
+ + {/* RSS Feeds */} + updateConfigImmediate({ feeds })} + /> +
+ {loadError &&

Failed to load configuration.

} +
+
+ ) +} + // ==================== Feeds Section ==================== function FeedsSection({ @@ -30,9 +103,13 @@ function FeedsSection({ } return ( -
0 ? `${feeds.length} feed${feeds.length > 1 ? 's' : ''} configured. Articles are searchable via globNews / grepNews / readNews.` : 'No feeds configured. Add feeds below to start collecting articles.'} + description={ + feeds.length > 0 + ? `${feeds.length} feed${feeds.length > 1 ? 's' : ''} configured. Articles are searchable via globNews, grepNews, and readNews tools.` + : 'No feeds configured yet. Add feeds to start collecting articles.' + } > {/* Existing feeds */} {feeds.length > 0 && ( @@ -84,76 +161,6 @@ function FeedsSection({ Add Feed
- - ) -} - -// ==================== Page ==================== - -const DEFAULT_NEWS_CONFIG: NewsCollectorConfig = { - enabled: true, - intervalMinutes: 10, - maxInMemory: 2000, - retentionDays: 7, - feeds: [], -} - -export function NewsPage() { - const { config, status, loadError, updateConfig, updateConfigImmediate, retry } = useConfigPage({ - section: 'news', - extract: (full: AppConfig) => (full as Record).news as NewsCollectorConfig, - }) - - const cfg = config ?? DEFAULT_NEWS_CONFIG - const enabled = cfg.enabled !== false - - return ( -
- - - updateConfigImmediate({ enabled: v })} /> -
- } - /> - -
-
- {/* Collection Settings */} -
-
- - updateConfig({ intervalMinutes: Number(e.target.value) || 10 })} - /> - - - updateConfig({ retentionDays: Number(e.target.value) || 7 })} - /> - -
-
- - {/* RSS Feeds */} - updateConfigImmediate({ feeds })} - /> -
- {loadError &&

Failed to load configuration.

} -
-
+ ) } From e9120569358f0e6fc51ec7d1f6fb2022e8a1eb7c Mon Sep 17 00:00:00 2001 From: Ame Date: Thu, 19 Mar 2026 12:08:30 +0800 Subject: [PATCH 5/6] ui: apply ConfigSection layout to Settings, Connectors, AI Provider Consistent two-column description-aside layout across all config pages. Wider content area (880px centered) replaces 640px left-aligned. Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/pages/AIProviderPage.tsx | 18 +++++++++--------- ui/src/pages/ConnectorsPage.tsx | 26 +++++++++++++------------- ui/src/pages/SettingsPage.tsx | 14 +++++++------- 3 files changed, 29 insertions(+), 29 deletions(-) diff --git a/ui/src/pages/AIProviderPage.tsx b/ui/src/pages/AIProviderPage.tsx index 9d33118b..74d9bef1 100644 --- a/ui/src/pages/AIProviderPage.tsx +++ b/ui/src/pages/AIProviderPage.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react' import { api, type AppConfig, type AIProviderConfig, type LoginMethod } from '../api' import { SaveIndicator } from '../components/SaveIndicator' -import { Section, Field, inputClass } from '../components/form' +import { ConfigSection, Field, inputClass } from '../components/form' import { useAutoSave, type SaveStatus } from '../hooks/useAutoSave' import { PageHeader } from '../components/PageHeader' import { PageLoading } from '../components/StateViews' @@ -98,10 +98,10 @@ export function AIProviderPage() { {config ? ( -
-
+
+
{/* Backend */} -
+
-
+ {/* Auth mode (only for Agent SDK) */} {config.aiProvider.backend === 'agent-sdk' && ( -
+ setConfig((c) => c ? { ...c, aiProvider: { ...c.aiProvider, ...patch } } : c)} /> -
+ )} {/* Model (only for Vercel AI SDK) */} {config.aiProvider.backend === 'vercel-ai-sdk' && ( -
+ -
+ )}
diff --git a/ui/src/pages/ConnectorsPage.tsx b/ui/src/pages/ConnectorsPage.tsx index 13f41598..097245b4 100644 --- a/ui/src/pages/ConnectorsPage.tsx +++ b/ui/src/pages/ConnectorsPage.tsx @@ -1,7 +1,7 @@ import { useConfigPage } from '../hooks/useConfigPage' import { SaveIndicator } from '../components/SaveIndicator' import { SDKSelector, CONNECTOR_OPTIONS } from '../components/SDKSelector' -import { Section, Field, inputClass } from '../components/form' +import { ConfigSection, Field, inputClass } from '../components/form' import { PageHeader } from '../components/PageHeader' import type { AppConfig, ConnectorsConfig } from '../api' @@ -40,11 +40,11 @@ export function ConnectorsPage() { /> {/* Content */} -
+
{config && ( -
+
{/* Connector selector cards */} -
@@ -53,10 +53,10 @@ export function ConnectorsPage() { selected={selected} onToggle={handleToggle} /> -
+ {/* Web UI config — always shown */} -
@@ -68,10 +68,10 @@ export function ConnectorsPage() { onChange={(e) => updateConfig({ web: { port: Number(e.target.value) } })} /> -
+ {/* MCP Server config — always shown */} -
@@ -83,11 +83,11 @@ export function ConnectorsPage() { onChange={(e) => updateConfig({ mcp: { port: Number(e.target.value) } })} /> -
+ {/* MCP Ask config */} {config.mcpAsk.enabled && ( -
@@ -103,12 +103,12 @@ export function ConnectorsPage() { placeholder="e.g. 3003" /> -
+ )} {/* Telegram config */} {config.telegram.enabled && ( -
@@ -157,7 +157,7 @@ export function ConnectorsPage() { placeholder="Comma-separated, e.g. 123456, 789012" /> -
+ )}
)} diff --git a/ui/src/pages/SettingsPage.tsx b/ui/src/pages/SettingsPage.tsx index d18a5f7a..f3f43861 100644 --- a/ui/src/pages/SettingsPage.tsx +++ b/ui/src/pages/SettingsPage.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react' import { api, type AppConfig } from '../api' import { Toggle } from '../components/Toggle' import { SaveIndicator } from '../components/SaveIndicator' -import { Section, Field, inputClass } from '../components/form' +import { ConfigSection, Field, inputClass } from '../components/form' import { useAutoSave } from '../hooks/useAutoSave' import { PageHeader } from '../components/PageHeader' import { PageLoading } from '../components/StateViews' @@ -19,10 +19,10 @@ export function SettingsPage() { {config ? ( -
-
+
+
{/* Agent */} -
+
@@ -46,12 +46,12 @@ export function SettingsPage() { }} />
-
+ {/* Compaction */} -
+ -
+
) : ( From 8debc24d1cc06d873f1ef01109031dce4797516c Mon Sep 17 00:00:00 2001 From: Ame Date: Thu, 19 Mar 2026 12:19:23 +0800 Subject: [PATCH 6/6] ui: unified button classes, HeartbeatPage/ToolsPage layout polish Add btn-primary/secondary/danger CSS utility classes with focus-visible rings. Replace 10+ inline button styles across pages and components. HeartbeatPage uses ConfigSection two-column layout. Both pages centered at 880px. Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/components/ChannelConfigModal.tsx | 2 +- ui/src/components/PushApprovalPanel.tsx | 4 +-- ui/src/index.css | 37 ++++++++++++++++++++++++ ui/src/pages/AIProviderPage.tsx | 4 +-- ui/src/pages/DevPage.tsx | 2 +- ui/src/pages/EventsPage.tsx | 4 +-- ui/src/pages/HeartbeatPage.tsx | 26 ++++++++--------- ui/src/pages/NewsPage.tsx | 2 +- ui/src/pages/ToolsPage.tsx | 4 +-- ui/src/pages/TradingPage.tsx | 8 ++--- 10 files changed, 65 insertions(+), 28 deletions(-) diff --git a/ui/src/components/ChannelConfigModal.tsx b/ui/src/components/ChannelConfigModal.tsx index 02d65753..46de7728 100644 --- a/ui/src/components/ChannelConfigModal.tsx +++ b/ui/src/components/ChannelConfigModal.tsx @@ -321,7 +321,7 @@ export function ChannelConfigModal({ channel, onClose, onSaved }: ChannelConfigM diff --git a/ui/src/components/PushApprovalPanel.tsx b/ui/src/components/PushApprovalPanel.tsx index 922b9352..22fd77c9 100644 --- a/ui/src/components/PushApprovalPanel.tsx +++ b/ui/src/components/PushApprovalPanel.tsx @@ -258,7 +258,7 @@ export function PushApprovalPanel() { @@ -274,7 +274,7 @@ export function PushApprovalPanel() { diff --git a/ui/src/index.css b/ui/src/index.css index c59121e8..e92d403c 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -31,6 +31,43 @@ body, height: 100%; } +/* ==================== Button utility classes ==================== */ + +.btn-primary { + @apply px-4 py-2 text-[13px] font-medium rounded-lg bg-accent text-white + hover:bg-accent/85 transition-colors + disabled:opacity-40 disabled:cursor-default cursor-pointer + focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-bg; +} + +.btn-secondary { + @apply px-4 py-2 text-[13px] font-medium rounded-lg border border-border text-text-muted + hover:bg-bg-tertiary hover:text-text transition-colors + disabled:opacity-40 disabled:cursor-default cursor-pointer + focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-bg; +} + +.btn-danger { + @apply px-4 py-2 text-[13px] font-medium rounded-lg border border-red/30 text-red + hover:bg-red/10 transition-colors + disabled:opacity-40 disabled:cursor-default cursor-pointer + focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red/40 focus-visible:ring-offset-2 focus-visible:ring-offset-bg; +} + +.btn-primary-sm { + @apply px-3 py-1.5 text-xs font-medium rounded-md bg-accent text-white + hover:bg-accent/85 transition-colors + disabled:opacity-40 disabled:cursor-default cursor-pointer + focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-bg; +} + +.btn-secondary-sm { + @apply px-3 py-1.5 text-xs font-medium rounded-md border border-border text-text-muted + hover:bg-bg-tertiary hover:text-text transition-colors + disabled:opacity-40 disabled:cursor-default cursor-pointer + focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-bg; +} + /* Scrollbar styling */ ::-webkit-scrollbar { width: 6px; diff --git a/ui/src/pages/AIProviderPage.tsx b/ui/src/pages/AIProviderPage.tsx index 74d9bef1..0cecef1c 100644 --- a/ui/src/pages/AIProviderPage.tsx +++ b/ui/src/pages/AIProviderPage.tsx @@ -382,7 +382,7 @@ function ModelForm({ aiProvider }: { aiProvider: AIProviderConfig }) { @@ -465,7 +465,7 @@ function AgentSdkAuthForm({ aiProvider, onUpdate }: { aiProvider: AIProviderConf diff --git a/ui/src/pages/DevPage.tsx b/ui/src/pages/DevPage.tsx index 80db2369..682d3aec 100644 --- a/ui/src/pages/DevPage.tsx +++ b/ui/src/pages/DevPage.tsx @@ -184,7 +184,7 @@ function SendSection() { diff --git a/ui/src/pages/EventsPage.tsx b/ui/src/pages/EventsPage.tsx index 50090afd..6957fba4 100644 --- a/ui/src/pages/EventsPage.tsx +++ b/ui/src/pages/EventsPage.tsx @@ -330,7 +330,7 @@ function CronSection() { {jobs.length} jobs @@ -537,7 +537,7 @@ function AddCronJobForm({ onClose, onCreated }: { onClose: () => void; onCreated diff --git a/ui/src/pages/HeartbeatPage.tsx b/ui/src/pages/HeartbeatPage.tsx index 8ef639a8..f4db2d10 100644 --- a/ui/src/pages/HeartbeatPage.tsx +++ b/ui/src/pages/HeartbeatPage.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react' import { api, type AppConfig, type EventLogEntry } from '../api' import { Toggle } from '../components/Toggle' import { SaveIndicator } from '../components/SaveIndicator' -import { Section, Field, inputClass } from '../components/form' +import { ConfigSection, Section, Field, inputClass } from '../components/form' import { useAutoSave } from '../hooks/useAutoSave' import { PageHeader } from '../components/PageHeader' @@ -84,7 +84,7 @@ function StatusBar() { @@ -98,9 +98,9 @@ function StatusBar() { ) } -// ==================== Config Section ==================== +// ==================== Config Form ==================== -function ConfigSection({ config }: { config: AppConfig }) { +function HeartbeatConfigForm({ config }: { config: AppConfig }) { const [every, setEvery] = useState(config.heartbeat?.every || '30m') const [ahEnabled, setAhEnabled] = useState(config.heartbeat?.activeHours != null) const [ahStart, setAhStart] = useState(config.heartbeat?.activeHours?.start || '09:00') @@ -120,7 +120,7 @@ function ConfigSection({ config }: { config: AppConfig }) { const { status, retry } = useAutoSave({ data: configData, save }) return ( -
+
- +
{ahEnabled && ( @@ -180,7 +180,7 @@ function ConfigSection({ config }: { config: AppConfig }) {
- + ) } @@ -222,7 +222,7 @@ function PromptEditor() { } return ( -
+ {loading ? (
Loading...
) : ( @@ -236,7 +236,7 @@ function PromptEditor() { @@ -258,7 +258,7 @@ function PromptEditor() {
)} - + ) } @@ -335,10 +335,10 @@ export function HeartbeatPage() {
-
-
+
+
- {config && } + {config && }
diff --git a/ui/src/pages/NewsPage.tsx b/ui/src/pages/NewsPage.tsx index fa6a30fb..f10094c6 100644 --- a/ui/src/pages/NewsPage.tsx +++ b/ui/src/pages/NewsPage.tsx @@ -156,7 +156,7 @@ function FeedsSection({ diff --git a/ui/src/pages/ToolsPage.tsx b/ui/src/pages/ToolsPage.tsx index b851e11a..1a2f1196 100644 --- a/ui/src/pages/ToolsPage.tsx +++ b/ui/src/pages/ToolsPage.tsx @@ -103,13 +103,13 @@ export function ToolsPage() { right={} /> -
+
{!loaded ? ( ) : groups.length === 0 ? ( ) : ( -
+
{groups.map((g) => ( )} {step > 1 && ( - )} {step === 2 && ( - )} {step === 3 && ( - )} @@ -523,7 +523,7 @@ function EditDialog({ account, platform, onSaveAccount, onSavePlatform, onDelete