diff --git a/src/domain/trading/UnifiedTradingAccount.spec.ts b/src/domain/trading/UnifiedTradingAccount.spec.ts index 26f13237..80fdc309 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,183 @@ describe('UTA — constructor', () => { expect(restored.log()[0].message).toBe('initial buy') }) }) + +// ==================== health tracking ==================== + +describe('UTA — health tracking', () => { + beforeEach(() => { vi.useFakeTimers() }) + afterEach(() => { vi.useRealTimers() }) + + /** 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().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++) { + 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) + 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++) { + 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) + 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) + + // 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) + 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('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 via runtime failures + 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() + broker.setFailMode(100) + const { uta } = createUTA(broker) + await flush() + + 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) + await flush() + + 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) + await flush() + 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..8dc07d69 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) @@ -130,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'), } @@ -162,6 +177,105 @@ 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 ==================== + + 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, + } + } + + /** 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 { + 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 ==================== @@ -252,7 +366,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 +403,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 +440,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 } @@ -376,11 +493,12 @@ export class UnifiedTradingAccount { // ==================== Lifecycle ==================== - init(): Promise { - 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/__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 227deced..753899c2 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 ?? 0 + } 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..bd632c6d 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. */ + /** 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) - 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, { @@ -140,32 +134,14 @@ async function main() { platformId: accountCfg.platformId, }) accountManager.add(uta) - console.log(`trading: ${uta.label} initialized`) - 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([ @@ -519,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 ==================== 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/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/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/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/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 9d33118b..0cecef1c 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' && ( -
+ -
+ )}
@@ -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/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/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/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/MarketDataPage.tsx b/ui/src/pages/MarketDataPage.tsx new file mode 100644 index 00000000..49e3d1e7 --- /dev/null +++ b/ui/src/pages/MarketDataPage.tsx @@ -0,0 +1,425 @@ +import { useState } from 'react' +import { api, type AppConfig } from '../api' +import { SaveIndicator } from '../components/SaveIndicator' +import { ConfigSection, 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 ( + + ) +} + +// ==================== 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" + /> + +
+
+
+ )} +
+ ) +} + +// ==================== 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 new file mode 100644 index 00000000..f10094c6 --- /dev/null +++ b/ui/src/pages/NewsPage.tsx @@ -0,0 +1,166 @@ +import { useState } from 'react' +import { type AppConfig, type NewsCollectorConfig, type NewsCollectorFeed } from '../api' +import { SaveIndicator } from '../components/SaveIndicator' +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({ + 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, and readNews tools.` + : 'No feeds configured yet. Add feeds 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" /> + + +
+
+ ) +} 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}}
) })} 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 */} -
+ -
+
) : ( 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