mirror of
https://github.com/mauricioabh/arbpulse.git
synced 2026-07-27 15:47:43 +00:00
feat(pwa): installable PWA + responsive mobile dashboard
- Add Web App Manifest + ArbPulse icons (192/512/maskable/apple-touch) under web/public/; no service worker (real-time honesty: never serve stale data). - Add mobile/install metadata to index.html (theme-color, viewport-fit=cover, apple-mobile-web-app-*, manifest + apple-touch-icon links). - Responsive layout: PriceMatrix stacked per-venue cards below sm, StatsBar reflow, ~44px touch targets in Controls/ConfigPanel, safe-area top inset. - scripts/gen-icons.ps1 derives icon sizes from the master (no new deps). - openspec: add-pwa-mobile change (proposal/design/specs/tasks). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-16
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
The dashboard (`web/`, React 18 + Vite 6 + Tailwind v3) is served by Express as
|
||||||
|
static files from `web/dist` (same origin as `/api` + SSE). `App.tsx` already uses a
|
||||||
|
`grid-cols-1 lg:grid-cols-3` layout, so it collapses to a single column on mobile,
|
||||||
|
but two things are missing for a good phone experience: (1) the `PriceMatrix` order
|
||||||
|
book is a 6-column `<table>` that overflows horizontally on ~360–430px screens, and
|
||||||
|
(2) there is no install/mobile metadata (manifest, theme-color, apple-touch-icon).
|
||||||
|
|
||||||
|
The app is real-time: the frontend consumes `/api/state` over SSE and shows a
|
||||||
|
`connected` badge. A domain rule (honesty) forbids ever presenting stale or fake
|
||||||
|
market data. The target runtime is the 24/7 VPS behind HTTPS at
|
||||||
|
`arbpulse.wayool.com` (nginx + certbot, Cloudflare DNS-only).
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Installable to the phone home screen (standalone, branded icon).
|
||||||
|
- Responsive, touch-friendly dashboard on phones without regressing desktop.
|
||||||
|
- Zero risk of showing cached/stale market data.
|
||||||
|
- No new runtime dependencies; no change to build/deploy steps.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Offline support of any kind (no cached app shell, no cached data).
|
||||||
|
- Push notifications / background sync.
|
||||||
|
- A native app or app-store packaging.
|
||||||
|
- Redesigning the dashboard; this is layout + metadata only.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### D1: No service worker (installable via manifest + HTTPS only)
|
||||||
|
Since Chrome 108 (mobile) / 112 (desktop), a service worker is no longer required to
|
||||||
|
install from the browser menu / "Add to Home Screen"; iOS Safari never required one.
|
||||||
|
No-op `fetch` handlers are now actively skipped and warned about by Chrome. Because
|
||||||
|
this app must never serve cached data, we ship **no service worker at all**.
|
||||||
|
- **Chosen:** manifest + icons + HTTPS → installable; SSE always fetches live data.
|
||||||
|
- **Alternatives:** `vite-plugin-pwa`/Workbox with a shell precache (rejected: its
|
||||||
|
value is offline/precaching, which conflicts with the no-stale-data rule and adds
|
||||||
|
a dependency); a minimal passthrough SW just to get the auto-install banner
|
||||||
|
(rejected: Chrome ignores no-op fetch handlers, and it adds a moving part for a
|
||||||
|
banner we don't need — manual install from the menu is sufficient).
|
||||||
|
- **Trade-off:** no automatic install prompt on Android; users install from the
|
||||||
|
browser menu (the only path on iOS anyway). Acceptable.
|
||||||
|
|
||||||
|
### D2: PriceMatrix responsive strategy — stacked cards below `sm`
|
||||||
|
Render per-venue cards (exchange + bid/ask/qty/spread as label–value rows) on small
|
||||||
|
screens and keep the existing `<table>` from `sm` up.
|
||||||
|
- **Chosen:** Tailwind responsive classes to swap layouts (`block sm:table` pattern
|
||||||
|
or two branches gated by breakpoint) — no JS, no `matchMedia`, purely CSS.
|
||||||
|
- **Alternative:** wrap the table in `overflow-x-auto` (rejected as primary: a
|
||||||
|
6-column price table sideways-scrolling on a phone is poor UX for the primary
|
||||||
|
widget; horizontal scroll can remain as a defensive fallback).
|
||||||
|
|
||||||
|
### D3: Icons derived from one master
|
||||||
|
Generate a single ArbPulse master icon (pulse line → Bitcoin "B", blue→green on
|
||||||
|
`#0a0e14`) and derive `icon-192.png`, `icon-512.png`, `icon-maskable-512.png`
|
||||||
|
(with safe padding), and `apple-touch-icon.png` (180). Store under `web/public/` so
|
||||||
|
Vite copies them verbatim to `web/dist`.
|
||||||
|
- **theme_color/background_color:** `#0a0e14` (matches the dashboard background).
|
||||||
|
|
||||||
|
### D4: Manifest + head metadata, same-origin
|
||||||
|
`manifest.webmanifest` uses `start_url: "/"`, `scope: "/"`, `display: "standalone"`.
|
||||||
|
`index.html` adds `theme-color`, `apple-mobile-web-app-capable`,
|
||||||
|
`apple-mobile-web-app-status-bar-style`, `apple-mobile-web-app-title`,
|
||||||
|
`viewport-fit=cover` (already has `width=device-width, initial-scale=1`), and links
|
||||||
|
to the manifest + apple-touch-icon. Everything is same-origin, so no CORS/proxy
|
||||||
|
concerns and the existing Express static + SPA fallback serve it unchanged.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Stale data if a SW is ever added later] → Documented no-SW decision; if a SW is
|
||||||
|
introduced in the future it MUST deny-list `/api` and never cache the SSE stream.
|
||||||
|
- [iOS quirks: no auto-prompt, status-bar/notch rendering] → Provide
|
||||||
|
`apple-touch-icon`, `apple-mobile-web-app-*`, and `viewport-fit=cover`; verify on a
|
||||||
|
real iPhone (Add to Home Screen), not just Android.
|
||||||
|
- [PriceMatrix layout duplication risk] → Keep a single data mapping and switch only
|
||||||
|
presentation via breakpoints to avoid divergent desktop/mobile logic.
|
||||||
|
- [Maskable icon getting cropped] → Keep the glyph within the ~80% safe zone in the
|
||||||
|
maskable variant.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The Arb Pulse dashboard is desktop-first: the 3-column grid collapses acceptably on
|
||||||
|
narrow screens, but the order-book table overflows horizontally and there is no
|
||||||
|
mobile/PWA metadata. We want to demo and monitor the engine from a phone —
|
||||||
|
installable to the home screen, full-window, and touch-friendly — while the app
|
||||||
|
runs 24/7 on the VPS behind HTTPS (`arbpulse.wayool.com`).
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Make the dashboard fully responsive on phones (~360–430px): stack the
|
||||||
|
`PriceMatrix` order book as per-venue cards below `sm`, reflow the `StatsBar`
|
||||||
|
badges, and ensure touch targets are at least ~44px in `Controls`/`ConfigPanel`.
|
||||||
|
- Add a Web App Manifest (`web/public/manifest.webmanifest`) so the dashboard is
|
||||||
|
installable to the home screen (name/short_name, `start_url`, `display: standalone`,
|
||||||
|
theme/background color, icons).
|
||||||
|
- Add ArbPulse app icons (192, 512, a maskable variant, and a 180px
|
||||||
|
`apple-touch-icon`) under `web/public/`.
|
||||||
|
- Add mobile/install metadata to `web/index.html`: `theme-color`,
|
||||||
|
`apple-mobile-web-app-*`, `viewport-fit=cover`, and links to the manifest and
|
||||||
|
apple-touch-icon.
|
||||||
|
- **No service worker and no offline support.** The app is real-time (SSE); it must
|
||||||
|
never serve cached/stale market data. Installability comes from the manifest +
|
||||||
|
HTTPS (menu / "Add to Home Screen"), not from a cached shell. When disconnected
|
||||||
|
the UI shows its existing "reconnecting/offline" state, never fake data.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `pwa`: installability and mobile-web behavior of the dashboard — how the app is
|
||||||
|
installable to the home screen via manifest + HTTPS with no service worker, how
|
||||||
|
it stays responsive on small screens, and the explicit no-offline / no-stale-data
|
||||||
|
guarantee for this real-time app.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
<!-- None: responsiveness and install metadata are new behavior; no existing spec's requirements change. -->
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Frontend: `web/index.html` (head metadata), `web/public/` (new manifest + icons),
|
||||||
|
`web/src/components/PriceMatrix.tsx` and `StatsBar.tsx` (responsive layout),
|
||||||
|
minor touch-target tweaks in `Controls.tsx`/`ConfigPanel.tsx`.
|
||||||
|
- Build/serve: none — `npm run build` copies `web/public/` into `web/dist`, already
|
||||||
|
served by Express static; the Docker image bakes it in. No new deploy steps.
|
||||||
|
- Dependencies: none added (no `vite-plugin-pwa`/Workbox, since there is no SW).
|
||||||
|
- Behavior: no API, wire-format, or engine changes. Desktop layout unchanged.
|
||||||
|
- Testing: verified on a real phone at `https://arbpulse.wayool.com` after the
|
||||||
|
normal `dev → PR → main → deploy.sh` flow.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Installable to the home screen
|
||||||
|
The dashboard SHALL be installable to a device home screen via a Web App Manifest
|
||||||
|
served over HTTPS, without requiring a service worker.
|
||||||
|
|
||||||
|
#### Scenario: Manifest is present and valid
|
||||||
|
- **WHEN** the dashboard is loaded over HTTPS
|
||||||
|
- **THEN** a linked `manifest.webmanifest` is served that includes `name`,
|
||||||
|
`short_name`, `start_url` in scope, `display: standalone`, `theme_color`,
|
||||||
|
`background_color`, and at least 192px and 512px PNG icons
|
||||||
|
|
||||||
|
#### Scenario: User installs from the browser
|
||||||
|
- **WHEN** a user chooses "Install app" / "Add to Home Screen" from the browser menu
|
||||||
|
- **THEN** the app is added with the ArbPulse icon and launches in a standalone,
|
||||||
|
full-window mode at `start_url`
|
||||||
|
|
||||||
|
### Requirement: No offline mode and no stale data
|
||||||
|
The app SHALL NOT register a service worker and SHALL NOT cache market data, so it
|
||||||
|
never presents stale or fake data (real-time honesty rule).
|
||||||
|
|
||||||
|
#### Scenario: No service worker is registered
|
||||||
|
- **WHEN** the dashboard runs in any environment
|
||||||
|
- **THEN** no service worker is registered and no application cache stores `/api`
|
||||||
|
responses or the SSE stream
|
||||||
|
|
||||||
|
#### Scenario: Disconnected device shows a clear non-data state
|
||||||
|
- **WHEN** the device has no working connection to the engine
|
||||||
|
- **THEN** the UI shows its reconnecting/offline indicator and does not display
|
||||||
|
cached market figures as if they were live
|
||||||
|
|
||||||
|
### Requirement: Responsive mobile layout
|
||||||
|
The dashboard SHALL be usable on phone-sized viewports (~360–430px wide) without
|
||||||
|
horizontal overflow of primary widgets, while the desktop layout is unchanged.
|
||||||
|
|
||||||
|
#### Scenario: Order book fits a phone screen
|
||||||
|
- **WHEN** the viewport is narrower than the `sm` breakpoint
|
||||||
|
- **THEN** the order-book (`PriceMatrix`) is presented as per-venue stacked cards
|
||||||
|
showing bid, ask, quantities, and spread without horizontal scrolling
|
||||||
|
|
||||||
|
#### Scenario: Stats and controls remain readable and tappable
|
||||||
|
- **WHEN** the dashboard is viewed on a phone
|
||||||
|
- **THEN** the stats bar reflows without clipping and interactive controls have
|
||||||
|
touch targets of at least ~44px
|
||||||
|
|
||||||
|
#### Scenario: Desktop layout is preserved
|
||||||
|
- **WHEN** the viewport is at the `lg` breakpoint or wider
|
||||||
|
- **THEN** the existing multi-column layout and table order book render as before
|
||||||
|
|
||||||
|
### Requirement: Mobile and install metadata
|
||||||
|
The `index.html` head SHALL include the metadata needed for correct mobile rendering
|
||||||
|
and home-screen installation on Android and iOS.
|
||||||
|
|
||||||
|
#### Scenario: Head includes mobile/install tags
|
||||||
|
- **WHEN** the page is served
|
||||||
|
- **THEN** the head contains a `theme-color`, `viewport-fit=cover` in the viewport
|
||||||
|
meta, a link to the manifest, an `apple-touch-icon`, and the
|
||||||
|
`apple-mobile-web-app-*` tags for standalone iOS launch
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
## 1. Icons
|
||||||
|
|
||||||
|
- [x] 1.1 Finalize the ArbPulse master icon and derive `icon-192.png`,
|
||||||
|
`icon-512.png`, `icon-maskable-512.png` (glyph within ~80% safe zone), and
|
||||||
|
`apple-touch-icon.png` (180px)
|
||||||
|
- [x] 1.2 Place all icons under `web/public/`
|
||||||
|
|
||||||
|
## 2. Manifest
|
||||||
|
|
||||||
|
- [x] 2.1 Create `web/public/manifest.webmanifest` with `name`, `short_name`,
|
||||||
|
`start_url: "/"`, `scope: "/"`, `display: "standalone"`,
|
||||||
|
`theme_color`/`background_color` `#0a0e14`, and icon entries (192, 512, and
|
||||||
|
512 maskable with `"purpose": "maskable"`)
|
||||||
|
|
||||||
|
## 3. HTML head metadata
|
||||||
|
|
||||||
|
- [x] 3.1 In `web/index.html`, add `viewport-fit=cover` to the viewport meta and a
|
||||||
|
`theme-color` meta (`#0a0e14`)
|
||||||
|
- [x] 3.2 Add `<link rel="manifest">`, `<link rel="apple-touch-icon">`, and the
|
||||||
|
`apple-mobile-web-app-capable` / `apple-mobile-web-app-status-bar-style` /
|
||||||
|
`apple-mobile-web-app-title` tags
|
||||||
|
|
||||||
|
## 4. Responsive layout
|
||||||
|
|
||||||
|
- [x] 4.1 `PriceMatrix.tsx`: render per-venue stacked cards (exchange + bid/ask/qty/
|
||||||
|
spread as label–value rows) below `sm`; keep the `<table>` from `sm` up, with
|
||||||
|
no horizontal overflow on phones
|
||||||
|
- [x] 4.2 `StatsBar.tsx`: reflow stats and badges on mobile without clipping
|
||||||
|
- [x] 4.3 Ensure touch targets in `Controls.tsx` and `ConfigPanel.tsx` are ~44px min
|
||||||
|
- [x] 4.4 Sanity-check `App.tsx` spacing/padding on narrow viewports
|
||||||
|
|
||||||
|
## 5. Verify
|
||||||
|
|
||||||
|
- [x] 5.1 `npm run typecheck` and `npm test` pass
|
||||||
|
- [x] 5.2 `npm run build` succeeds and `web/dist` contains the manifest + icons
|
||||||
|
- [x] 5.3 DevTools device emulation: no horizontal overflow at ~375px; Application →
|
||||||
|
Manifest shows no errors; confirm no service worker is registered
|
||||||
|
- [ ] 5.4 After `dev → PR → main → deploy.sh`, install on a real phone from
|
||||||
|
`https://arbpulse.wayool.com` and confirm standalone launch + live SSE data
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 824 KiB |
@@ -0,0 +1,44 @@
|
|||||||
|
# One-off ArbPulse PWA icon generator.
|
||||||
|
# Resizes the AI-generated master icon into the sizes referenced by the
|
||||||
|
# Web App Manifest and index.html. Uses only Windows System.Drawing (no deps).
|
||||||
|
param(
|
||||||
|
[string]$Master = (Join-Path $PSScriptRoot "arbpulse-icon-master.png"),
|
||||||
|
[string]$OutDir = (Join-Path $PSScriptRoot "..\web\public")
|
||||||
|
)
|
||||||
|
|
||||||
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
|
||||||
|
$bg = [System.Drawing.ColorTranslator]::FromHtml("#0a0e14")
|
||||||
|
|
||||||
|
function Save-Resized {
|
||||||
|
param([System.Drawing.Image]$Src, [int]$Size, [string]$Path, [double]$Scale = 1.0)
|
||||||
|
|
||||||
|
$bmp = New-Object System.Drawing.Bitmap($Size, $Size)
|
||||||
|
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
||||||
|
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
|
||||||
|
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality
|
||||||
|
$g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
|
||||||
|
$g.Clear($bg)
|
||||||
|
|
||||||
|
$target = [int]([math]::Round($Size * $Scale))
|
||||||
|
$offset = [int]([math]::Round(($Size - $target) / 2))
|
||||||
|
$rect = New-Object System.Drawing.Rectangle($offset, $offset, $target, $target)
|
||||||
|
$g.DrawImage($Src, $rect)
|
||||||
|
|
||||||
|
$g.Dispose()
|
||||||
|
$bmp.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||||
|
$bmp.Dispose()
|
||||||
|
Write-Output "wrote $Path ($Size x $Size, scale $Scale)"
|
||||||
|
}
|
||||||
|
|
||||||
|
$src = [System.Drawing.Image]::FromFile($Master)
|
||||||
|
|
||||||
|
Save-Resized -Src $src -Size 192 -Path (Join-Path $OutDir "icon-192.png")
|
||||||
|
Save-Resized -Src $src -Size 512 -Path (Join-Path $OutDir "icon-512.png")
|
||||||
|
Save-Resized -Src $src -Size 180 -Path (Join-Path $OutDir "apple-touch-icon.png")
|
||||||
|
# Maskable: full-bleed on the master's own background. The glyph's built-in
|
||||||
|
# padding already keeps it inside the ~80% safe zone, and full-bleed avoids a
|
||||||
|
# visible seam from mismatched flat padding vs. the master's subtle glow.
|
||||||
|
Save-Resized -Src $src -Size 512 -Path (Join-Path $OutDir "icon-maskable-512.png") -Scale 1.0
|
||||||
|
|
||||||
|
$src.Dispose()
|
||||||
+8
-1
@@ -2,9 +2,16 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<title>Arb Pulse — BTC Arbitrage Engine</title>
|
<title>Arb Pulse — BTC Arbitrage Engine</title>
|
||||||
<meta name="description" content="Real-time cross-exchange BTC arbitrage detection & simulation" />
|
<meta name="description" content="Real-time cross-exchange BTC arbitrage detection & simulation" />
|
||||||
|
<meta name="theme-color" content="#0a0e14" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Arb Pulse" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 254 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 254 KiB |
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "Arb Pulse — BTC Arbitrage Engine",
|
||||||
|
"short_name": "Arb Pulse",
|
||||||
|
"description": "Real-time cross-exchange BTC arbitrage detection & simulation (Kraken, Bybit, OKX, Binance).",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "any",
|
||||||
|
"theme_color": "#0a0e14",
|
||||||
|
"background_color": "#0a0e14",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-maskable-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+13
-5
@@ -19,13 +19,16 @@ export function App(): JSX.Element {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[1400px] px-4 py-6 lg:px-8">
|
<div className="mx-auto max-w-[1400px] px-4 pb-6 pt-[max(1.5rem,env(safe-area-inset-top))] lg:px-8">
|
||||||
<header className="mb-6 flex items-end justify-between">
|
<header className="mb-6 flex items-end justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-display text-2xl font-extrabold tracking-tight text-slate-100">
|
<h1 className="font-display text-2xl font-extrabold tracking-tight text-slate-100">
|
||||||
Arb<span className="text-accent">Pulse</span>
|
Arb<span className="text-accent">Pulse</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-slate-500">Real-time cross-exchange BTC arbitrage engine · Kraken · Bybit · OKX · Binance</p>
|
<p className="text-sm text-slate-500">
|
||||||
|
Real-time cross-exchange BTC arbitrage engine · Kraken · Bybit · OKX
|
||||||
|
· Binance
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="hidden text-xs text-slate-600 sm:block">
|
<p className="hidden text-xs text-slate-600 sm:block">
|
||||||
BTC/USDT · WebSocket feeds · inventory model
|
BTC/USDT · WebSocket feeds · inventory model
|
||||||
@@ -33,7 +36,9 @@ export function App(): JSX.Element {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{!snapshot ? (
|
{!snapshot ? (
|
||||||
<div className="flex h-[60vh] items-center justify-center text-slate-500">Connecting to engine…</div>
|
<div className="flex h-[60vh] items-center justify-center text-slate-500">
|
||||||
|
Connecting to engine…
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<StatsBar stats={snapshot.stats} connected={connected} />
|
<StatsBar stats={snapshot.stats} connected={connected} />
|
||||||
@@ -47,7 +52,10 @@ export function App(): JSX.Element {
|
|||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<Controls stats={snapshot.stats} config={snapshot.config} />
|
<Controls stats={snapshot.stats} config={snapshot.config} />
|
||||||
<ConfigPanel config={snapshot.config} />
|
<ConfigPanel config={snapshot.config} />
|
||||||
<Wallets wallets={snapshot.wallets} rebalances={snapshot.rebalances} />
|
<Wallets
|
||||||
|
wallets={snapshot.wallets}
|
||||||
|
rebalances={snapshot.rebalances}
|
||||||
|
/>
|
||||||
<OpportunityFeed opportunities={snapshot.recentOpportunities} />
|
<OpportunityFeed opportunities={snapshot.recentOpportunities} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,12 +37,21 @@ function Toggle({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(!on)}
|
onClick={() => onChange(!on)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex items-center justify-between gap-3 rounded-xl border px-3 py-2 text-left text-sm transition",
|
"flex min-h-[44px] items-center justify-between gap-3 rounded-xl border px-3 py-2 text-left text-sm transition",
|
||||||
modified ? "border-accent/50 bg-accent/5" : "border-ink-600/50 bg-ink-700/30 hover:border-ink-500",
|
modified
|
||||||
|
? "border-accent/50 bg-accent/5"
|
||||||
|
: "border-ink-600/50 bg-ink-700/30 hover:border-ink-500",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className={clsx(modified ? "text-accent" : "text-slate-300")}>{label}</span>
|
<span className={clsx(modified ? "text-accent" : "text-slate-300")}>
|
||||||
<span className={clsx("relative h-5 w-9 rounded-full transition", on ? "bg-accent" : "bg-ink-500")}>
|
{label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"relative h-5 w-9 rounded-full transition",
|
||||||
|
on ? "bg-accent" : "bg-ink-500",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all",
|
"absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all",
|
||||||
@@ -77,11 +86,15 @@ function SliderRow({
|
|||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"rounded-xl border px-3 py-2",
|
"rounded-xl border px-3 py-2",
|
||||||
modified ? "border-accent/50 bg-accent/5" : "border-ink-600/50 bg-ink-700/30",
|
modified
|
||||||
|
? "border-accent/50 bg-accent/5"
|
||||||
|
: "border-ink-600/50 bg-ink-700/30",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="mb-1 flex items-center justify-between text-sm">
|
<div className="mb-1 flex items-center justify-between text-sm">
|
||||||
<span className={clsx(modified ? "text-accent" : "text-slate-300")}>{label}</span>
|
<span className={clsx(modified ? "text-accent" : "text-slate-300")}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
<span className="font-mono text-accent">{valueLabel}</span>
|
<span className="font-mono text-accent">{valueLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@@ -179,7 +192,9 @@ export function ConfigPanel({ config }: Props): JSX.Element {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs font-medium uppercase tracking-wider text-slate-500">Active exchanges</p>
|
<p className="text-xs font-medium uppercase tracking-wider text-slate-500">
|
||||||
|
Active exchanges
|
||||||
|
</p>
|
||||||
{EXCHANGES.map(({ id, label }) => (
|
{EXCHANGES.map(({ id, label }) => (
|
||||||
<Toggle
|
<Toggle
|
||||||
key={id}
|
key={id}
|
||||||
|
|||||||
@@ -9,15 +9,28 @@ interface Props {
|
|||||||
config: PublicConfig;
|
config: PublicConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Toggle({ label, on, onChange }: { label: string; on: boolean; onChange: (v: boolean) => void }): JSX.Element {
|
function Toggle({
|
||||||
|
label,
|
||||||
|
on,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
on: boolean;
|
||||||
|
onChange: (v: boolean) => void;
|
||||||
|
}): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(!on)}
|
onClick={() => onChange(!on)}
|
||||||
className="flex items-center justify-between gap-3 rounded-xl border border-ink-600/50 bg-ink-700/30 px-3 py-2 text-left text-sm transition hover:border-ink-500"
|
className="flex min-h-[44px] items-center justify-between gap-3 rounded-xl border border-ink-600/50 bg-ink-700/30 px-3 py-2 text-left text-sm transition hover:border-ink-500"
|
||||||
>
|
>
|
||||||
<span className="text-slate-300">{label}</span>
|
<span className="text-slate-300">{label}</span>
|
||||||
<span className={clsx("relative h-5 w-9 rounded-full transition", on ? "bg-accent" : "bg-ink-500")}>
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"relative h-5 w-9 rounded-full transition",
|
||||||
|
on ? "bg-accent" : "bg-ink-500",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all",
|
"absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all",
|
||||||
@@ -41,7 +54,7 @@ export function Controls({ stats, config }: Props): JSX.Element {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => control.resume()}
|
onClick={() => control.resume()}
|
||||||
className="rounded-xl border border-profit/40 bg-profit/10 px-3 py-2 text-sm font-semibold text-profit transition hover:bg-profit/20"
|
className="min-h-[44px] rounded-xl border border-profit/40 bg-profit/10 px-3 py-2 text-sm font-semibold text-profit transition hover:bg-profit/20"
|
||||||
>
|
>
|
||||||
Resume
|
Resume
|
||||||
</button>
|
</button>
|
||||||
@@ -49,7 +62,7 @@ export function Controls({ stats, config }: Props): JSX.Element {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => control.pause()}
|
onClick={() => control.pause()}
|
||||||
className="rounded-xl border border-warn/40 bg-warn/10 px-3 py-2 text-sm font-semibold text-warn transition hover:bg-warn/20"
|
className="min-h-[44px] rounded-xl border border-warn/40 bg-warn/10 px-3 py-2 text-sm font-semibold text-warn transition hover:bg-warn/20"
|
||||||
>
|
>
|
||||||
Pause
|
Pause
|
||||||
</button>
|
</button>
|
||||||
@@ -57,13 +70,17 @@ export function Controls({ stats, config }: Props): JSX.Element {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => control.reset()}
|
onClick={() => control.reset()}
|
||||||
className="rounded-xl border border-loss/40 bg-loss/10 px-3 py-2 text-sm font-semibold text-loss transition hover:bg-loss/20"
|
className="min-h-[44px] rounded-xl border border-loss/40 bg-loss/10 px-3 py-2 text-sm font-semibold text-loss transition hover:bg-loss/20"
|
||||||
>
|
>
|
||||||
Reset
|
Reset
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Toggle label="Demo mode (synthetic feed)" on={stats.demoMode} onChange={(v) => control.setDemo(v)} />
|
<Toggle
|
||||||
|
label="Demo mode (synthetic feed)"
|
||||||
|
on={stats.demoMode}
|
||||||
|
onChange={(v) => control.setDemo(v)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="rounded-xl border border-ink-600/50 bg-ink-700/30 px-3 py-2">
|
<div className="rounded-xl border border-ink-600/50 bg-ink-700/30 px-3 py-2">
|
||||||
<div className="mb-1 flex items-center justify-between text-sm">
|
<div className="mb-1 flex items-center justify-between text-sm">
|
||||||
@@ -89,7 +106,10 @@ export function Controls({ stats, config }: Props): JSX.Element {
|
|||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Taker fees</span>
|
<span>Taker fees</span>
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
K {pct(config.takerFees.kraken, 2)} · By {pct(config.takerFees.bybit, 2)} · O {pct(config.takerFees.okx, 2)} · Bn {pct(config.takerFees.binance, 2)}
|
K {pct(config.takerFees.kraken, 2)} · By{" "}
|
||||||
|
{pct(config.takerFees.bybit, 2)} · O{" "}
|
||||||
|
{pct(config.takerFees.okx, 2)} · Bn{" "}
|
||||||
|
{pct(config.takerFees.binance, 2)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex justify-between">
|
<div className="mt-1 flex justify-between">
|
||||||
|
|||||||
@@ -50,7 +50,83 @@ export function PriceMatrix({ quotes }: Props): JSX.Element {
|
|||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="overflow-hidden rounded-xl border border-ink-600/50">
|
{/* Mobile: per-venue stacked cards (no horizontal overflow). */}
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:hidden">
|
||||||
|
{quotes.map((q) => {
|
||||||
|
const spread =
|
||||||
|
q.ask !== null && q.bid !== null ? q.ask - q.bid : null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={q.exchange}
|
||||||
|
className="rounded-xl border border-ink-600/50 bg-ink-700/30 p-3"
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-center gap-2 font-display text-sm">
|
||||||
|
<span
|
||||||
|
className={clsx("h-2 w-2 rounded-full", statusDot(q.status))}
|
||||||
|
/>
|
||||||
|
{LABELS[q.exchange]}
|
||||||
|
</div>
|
||||||
|
<dl className="grid grid-cols-2 gap-x-4 gap-y-1.5 font-mono text-sm tabular-nums">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<dt className="text-xs uppercase tracking-wider text-slate-500">
|
||||||
|
Bid
|
||||||
|
</dt>
|
||||||
|
<dd
|
||||||
|
className={clsx(
|
||||||
|
q.bid !== null && q.bid === maxBid
|
||||||
|
? "font-semibold text-profit"
|
||||||
|
: "text-slate-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{q.bid !== null ? usd(q.bid) : "—"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<dt className="text-xs uppercase tracking-wider text-slate-500">
|
||||||
|
Bid Qty
|
||||||
|
</dt>
|
||||||
|
<dd className="text-slate-500">
|
||||||
|
{q.bidQty?.toFixed(3) ?? "—"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<dt className="text-xs uppercase tracking-wider text-slate-500">
|
||||||
|
Ask
|
||||||
|
</dt>
|
||||||
|
<dd
|
||||||
|
className={clsx(
|
||||||
|
q.ask !== null && q.ask === minAsk
|
||||||
|
? "font-semibold text-accent"
|
||||||
|
: "text-slate-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{q.ask !== null ? usd(q.ask) : "—"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<dt className="text-xs uppercase tracking-wider text-slate-500">
|
||||||
|
Ask Qty
|
||||||
|
</dt>
|
||||||
|
<dd className="text-slate-500">
|
||||||
|
{q.askQty?.toFixed(3) ?? "—"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<dt className="text-xs uppercase tracking-wider text-slate-500">
|
||||||
|
Spread
|
||||||
|
</dt>
|
||||||
|
<dd className="text-slate-400">
|
||||||
|
{spread !== null ? usd(spread) : "—"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop / tablet: full table from the sm breakpoint up. */}
|
||||||
|
<div className="hidden overflow-hidden rounded-xl border border-ink-600/50 sm:block">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-ink-700/50 text-left text-xs uppercase tracking-wider text-slate-400">
|
<tr className="bg-ink-700/50 text-left text-xs uppercase tracking-wider text-slate-400">
|
||||||
@@ -64,34 +140,50 @@ export function PriceMatrix({ quotes }: Props): JSX.Element {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="font-mono tabular-nums">
|
<tbody className="font-mono tabular-nums">
|
||||||
{quotes.map((q) => {
|
{quotes.map((q) => {
|
||||||
const spread = q.ask !== null && q.bid !== null ? q.ask - q.bid : null;
|
const spread =
|
||||||
|
q.ask !== null && q.bid !== null ? q.ask - q.bid : null;
|
||||||
return (
|
return (
|
||||||
<tr key={q.exchange} className="border-t border-ink-600/40">
|
<tr key={q.exchange} className="border-t border-ink-600/40">
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
<span className="flex items-center gap-2 font-display text-sm">
|
<span className="flex items-center gap-2 font-display text-sm">
|
||||||
<span className={clsx("h-2 w-2 rounded-full", statusDot(q.status))} />
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"h-2 w-2 rounded-full",
|
||||||
|
statusDot(q.status),
|
||||||
|
)}
|
||||||
|
/>
|
||||||
{LABELS[q.exchange]}
|
{LABELS[q.exchange]}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"px-4 py-2.5 text-right",
|
"px-4 py-2.5 text-right",
|
||||||
q.bid !== null && q.bid === maxBid ? "font-semibold text-profit" : "text-slate-300",
|
q.bid !== null && q.bid === maxBid
|
||||||
|
? "font-semibold text-profit"
|
||||||
|
: "text-slate-300",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{q.bid !== null ? usd(q.bid) : "—"}
|
{q.bid !== null ? usd(q.bid) : "—"}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 text-right text-slate-500">{q.bidQty?.toFixed(3) ?? "—"}</td>
|
<td className="px-4 py-2.5 text-right text-slate-500">
|
||||||
|
{q.bidQty?.toFixed(3) ?? "—"}
|
||||||
|
</td>
|
||||||
<td
|
<td
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"px-4 py-2.5 text-right",
|
"px-4 py-2.5 text-right",
|
||||||
q.ask !== null && q.ask === minAsk ? "font-semibold text-accent" : "text-slate-300",
|
q.ask !== null && q.ask === minAsk
|
||||||
|
? "font-semibold text-accent"
|
||||||
|
: "text-slate-300",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{q.ask !== null ? usd(q.ask) : "—"}
|
{q.ask !== null ? usd(q.ask) : "—"}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 text-right text-slate-500">{q.askQty?.toFixed(3) ?? "—"}</td>
|
<td className="px-4 py-2.5 text-right text-slate-500">
|
||||||
<td className="px-4 py-2.5 text-right text-slate-400">{spread !== null ? usd(spread) : "—"}</td>
|
{q.askQty?.toFixed(3) ?? "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-right text-slate-400">
|
||||||
|
{spread !== null ? usd(spread) : "—"}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -6,10 +6,20 @@ interface Props {
|
|||||||
connected: boolean;
|
connected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Stat({ label, value, tone }: { label: string; value: string; tone?: "profit" | "loss" | "warn" }): JSX.Element {
|
function Stat({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
tone?: "profit" | "loss" | "warn";
|
||||||
|
}): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-[10px] uppercase tracking-widest text-slate-500">{label}</span>
|
<span className="text-[10px] uppercase tracking-widest text-slate-500">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"font-mono text-lg font-semibold tabular-nums",
|
"font-mono text-lg font-semibold tabular-nums",
|
||||||
@@ -25,14 +35,23 @@ function Stat({ label, value, tone }: { label: string; value: string; tone?: "pr
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function circuitTone(c: EngineStats["circuit"]): { label: string; cls: string } {
|
function circuitTone(c: EngineStats["circuit"]): {
|
||||||
|
label: string;
|
||||||
|
cls: string;
|
||||||
|
} {
|
||||||
switch (c) {
|
switch (c) {
|
||||||
case "running":
|
case "running":
|
||||||
return { label: "RUNNING", cls: "bg-profit/15 text-profit border-profit/40" };
|
return {
|
||||||
|
label: "RUNNING",
|
||||||
|
cls: "bg-profit/15 text-profit border-profit/40",
|
||||||
|
};
|
||||||
case "paused":
|
case "paused":
|
||||||
return { label: "PAUSED", cls: "bg-warn/15 text-warn border-warn/40" };
|
return { label: "PAUSED", cls: "bg-warn/15 text-warn border-warn/40" };
|
||||||
case "tripped":
|
case "tripped":
|
||||||
return { label: "BREAKER TRIPPED", cls: "bg-loss/15 text-loss border-loss/40" };
|
return {
|
||||||
|
label: "BREAKER TRIPPED",
|
||||||
|
cls: "bg-loss/15 text-loss border-loss/40",
|
||||||
|
};
|
||||||
default: {
|
default: {
|
||||||
const _exhaustive: never = c;
|
const _exhaustive: never = c;
|
||||||
return { label: _exhaustive, cls: "" };
|
return { label: _exhaustive, cls: "" };
|
||||||
@@ -45,29 +64,45 @@ export function StatsBar({ stats, connected }: Props): JSX.Element {
|
|||||||
const pnlTone = stats.realizedPnl >= 0 ? "profit" : "loss";
|
const pnlTone = stats.realizedPnl >= 0 ? "profit" : "loss";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-x-8 gap-y-4 rounded-2xl border border-ink-600/60 bg-ink-800/60 px-5 py-4 backdrop-blur-sm">
|
<div className="flex flex-wrap items-center gap-x-6 gap-y-4 rounded-2xl border border-ink-600/60 bg-ink-800/60 px-4 py-4 backdrop-blur-sm sm:gap-x-8 sm:px-5">
|
||||||
<Stat label="Realized P&L" value={`$${usd(stats.realizedPnl)}`} tone={pnlTone} />
|
<Stat
|
||||||
|
label="Realized P&L"
|
||||||
|
value={`$${usd(stats.realizedPnl)}`}
|
||||||
|
tone={pnlTone}
|
||||||
|
/>
|
||||||
<Stat label="Trades" value={String(stats.tradesExecuted)} />
|
<Stat label="Trades" value={String(stats.tradesExecuted)} />
|
||||||
<Stat label="Opportunities" value={String(stats.opportunitiesDetected)} />
|
<Stat label="Opportunities" value={String(stats.opportunitiesDetected)} />
|
||||||
<Stat label="Rejected" value={String(stats.tradesRejected)} tone="warn" />
|
<Stat label="Rejected" value={String(stats.tradesRejected)} tone="warn" />
|
||||||
<Stat label="Ticks" value={stats.ticksProcessed.toLocaleString()} />
|
<Stat label="Ticks" value={stats.ticksProcessed.toLocaleString()} />
|
||||||
<Stat label="Engine /tick" value={`${stats.avgTickMs.toFixed(3)}ms`} />
|
<Stat label="Engine /tick" value={`${stats.avgTickMs.toFixed(3)}ms`} />
|
||||||
<div className="ml-auto flex items-center gap-3">
|
<div className="flex w-full flex-wrap items-center gap-2 sm:ml-auto sm:w-auto sm:gap-3">
|
||||||
{stats.demoMode && (
|
{stats.demoMode && (
|
||||||
<span className="rounded-full border border-accent/40 bg-accent/15 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-accent">
|
<span className="rounded-full border border-accent/40 bg-accent/15 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-accent">
|
||||||
Demo / Simulated Feed
|
Demo / Simulated Feed
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className={clsx("rounded-full border px-3 py-1 text-xs font-semibold tracking-wider", circuit.cls)}>
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"rounded-full border px-3 py-1 text-xs font-semibold tracking-wider",
|
||||||
|
circuit.cls,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{circuit.label}
|
{circuit.label}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold tracking-wider",
|
"flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold tracking-wider",
|
||||||
connected ? "border-profit/40 bg-profit/10 text-profit" : "border-loss/40 bg-loss/10 text-loss",
|
connected
|
||||||
|
? "border-profit/40 bg-profit/10 text-profit"
|
||||||
|
: "border-loss/40 bg-loss/10 text-loss",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className={clsx("h-2 w-2 rounded-full", connected ? "bg-profit" : "bg-loss")} />
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"h-2 w-2 rounded-full",
|
||||||
|
connected ? "bg-profit" : "bg-loss",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
{connected ? "LIVE" : "OFFLINE"}
|
{connected ? "LIVE" : "OFFLINE"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user