4236 lines
160 KiB
HTML
4236 lines
160 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>系统总控台</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
|
<style>
|
|
:root {
|
|
--bg: #08111a;
|
|
--panel: rgba(11, 21, 32, 0.86);
|
|
--panel-strong: rgba(8, 18, 28, 0.96);
|
|
--panel-soft: rgba(16, 28, 41, 0.72);
|
|
--line: rgba(128, 169, 202, 0.18);
|
|
--line-strong: rgba(128, 169, 202, 0.32);
|
|
--text: #edf6ff;
|
|
--muted: #8ea6bc;
|
|
--cold: #7ec8ff;
|
|
--accent: #9cff57;
|
|
--warn: #ffb84d;
|
|
--danger: #ff6f61;
|
|
--good: #30d158;
|
|
--shadow: 0 20px 80px rgba(0, 0, 0, 0.38);
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
html, body {
|
|
margin: 0;
|
|
min-height: 100%;
|
|
background:
|
|
radial-gradient(circle at top left, rgba(64, 127, 255, 0.16), transparent 28%),
|
|
radial-gradient(circle at top right, rgba(156, 255, 87, 0.12), transparent 22%),
|
|
linear-gradient(180deg, #071018 0%, #08111a 48%, #050b11 100%);
|
|
color: var(--text);
|
|
font-family: "IBM Plex Sans", sans-serif;
|
|
}
|
|
|
|
body::before {
|
|
content: "";
|
|
position: fixed;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
background-image:
|
|
linear-gradient(rgba(255, 255, 255, 0.018) 1px, transparent 1px),
|
|
linear-gradient(90deg, rgba(255, 255, 255, 0.018) 1px, transparent 1px);
|
|
background-size: 28px 28px;
|
|
mask-image: linear-gradient(180deg, rgba(0,0,0,0.45), rgba(0,0,0,0.9));
|
|
}
|
|
|
|
.auth-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 1000;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 24px;
|
|
background: rgba(3, 8, 14, 0.82);
|
|
backdrop-filter: blur(18px);
|
|
}
|
|
|
|
.auth-overlay.hidden {
|
|
display: none;
|
|
}
|
|
|
|
.auth-card {
|
|
width: min(420px, 100%);
|
|
padding: 28px;
|
|
border-radius: 24px;
|
|
background: rgba(9, 18, 28, 0.96);
|
|
border: 1px solid rgba(128, 169, 202, 0.24);
|
|
box-shadow: var(--shadow);
|
|
}
|
|
|
|
.auth-card h2 {
|
|
margin: 0 0 10px;
|
|
font-size: 28px;
|
|
}
|
|
|
|
.auth-card p {
|
|
margin: 0 0 18px;
|
|
color: var(--muted);
|
|
line-height: 1.7;
|
|
}
|
|
|
|
.auth-field {
|
|
width: 100%;
|
|
padding: 14px 16px;
|
|
border-radius: 14px;
|
|
border: 1px solid rgba(255,255,255,0.08);
|
|
background: rgba(255,255,255,0.04);
|
|
color: var(--text);
|
|
font-size: 15px;
|
|
outline: none;
|
|
}
|
|
|
|
.auth-field:focus {
|
|
border-color: rgba(126, 200, 255, 0.36);
|
|
}
|
|
|
|
.auth-actions {
|
|
display: flex;
|
|
gap: 12px;
|
|
margin-top: 14px;
|
|
}
|
|
|
|
.auth-error {
|
|
min-height: 20px;
|
|
margin-top: 12px;
|
|
color: var(--danger);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.console-shell {
|
|
width: min(1600px, calc(100vw - 32px));
|
|
margin: 0 auto;
|
|
padding: 24px 0 40px;
|
|
}
|
|
|
|
.console-hub {
|
|
display: grid;
|
|
grid-template-columns: 240px minmax(0, 1fr);
|
|
gap: 18px;
|
|
align-items: start;
|
|
}
|
|
|
|
.console-sidebar {
|
|
position: sticky;
|
|
top: 18px;
|
|
padding: 16px;
|
|
border-radius: 22px;
|
|
background: var(--panel);
|
|
border: 1px solid var(--line);
|
|
box-shadow: var(--shadow);
|
|
backdrop-filter: blur(18px);
|
|
}
|
|
|
|
.console-sidebar::after {
|
|
content: "";
|
|
position: absolute;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
border-radius: 22px;
|
|
background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent 24%, transparent 76%, rgba(126, 200, 255, 0.04));
|
|
}
|
|
|
|
.sidebar-label {
|
|
color: var(--muted);
|
|
font-size: 11px;
|
|
margin-bottom: 12px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
}
|
|
|
|
.sidebar-nav {
|
|
display: grid;
|
|
gap: 10px;
|
|
}
|
|
|
|
.sidebar-link {
|
|
appearance: none;
|
|
width: 100%;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
padding: 14px 16px;
|
|
border-radius: 16px;
|
|
border: 1px solid rgba(255,255,255,0.06);
|
|
background: rgba(255,255,255,0.03);
|
|
color: var(--text);
|
|
transition: border-color 0.2s ease, background 0.2s ease, transform 0.2s ease;
|
|
}
|
|
|
|
.sidebar-link:hover {
|
|
transform: translateY(-1px);
|
|
border-color: rgba(126, 200, 255, 0.18);
|
|
}
|
|
|
|
.sidebar-link.active {
|
|
border-color: rgba(126, 200, 255, 0.28);
|
|
background: rgba(126, 200, 255, 0.10);
|
|
}
|
|
|
|
.sidebar-link-title {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.sidebar-link-sub {
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
line-height: 1.55;
|
|
}
|
|
|
|
.sidebar-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-width: 24px;
|
|
height: 20px;
|
|
padding: 0 8px;
|
|
border-radius: 999px;
|
|
background: rgba(255,255,255,0.08);
|
|
color: var(--cold);
|
|
font-size: 10px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
}
|
|
|
|
.hub-content {
|
|
display: grid;
|
|
gap: 18px;
|
|
}
|
|
|
|
.hub-pane {
|
|
display: none;
|
|
gap: 18px;
|
|
}
|
|
|
|
.hub-pane.active {
|
|
display: grid;
|
|
}
|
|
|
|
.pane-grid {
|
|
display: grid;
|
|
gap: 18px;
|
|
}
|
|
|
|
.pane-grid.two-col {
|
|
grid-template-columns: minmax(0, 1.08fr) minmax(320px, 0.92fr);
|
|
}
|
|
|
|
.pane-grid.equal {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
|
|
.compact-panel-body {
|
|
display: grid;
|
|
gap: 14px;
|
|
}
|
|
|
|
.monitor-strip {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 12px;
|
|
}
|
|
|
|
.monitor-card {
|
|
padding: 14px 16px;
|
|
border-radius: 16px;
|
|
background: rgba(255,255,255,0.03);
|
|
border: 1px solid rgba(255,255,255,0.06);
|
|
}
|
|
|
|
.monitor-card .title {
|
|
color: var(--muted);
|
|
font-size: 11px;
|
|
margin-bottom: 8px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
}
|
|
|
|
.monitor-card .main {
|
|
font-family: "IBM Plex Mono", monospace;
|
|
font-size: 16px;
|
|
color: var(--text);
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.monitor-card .meta {
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.hero {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1.4fr) minmax(360px, 0.9fr);
|
|
gap: 18px;
|
|
margin-bottom: 18px;
|
|
}
|
|
|
|
.hero-card,
|
|
.panel,
|
|
.platform-card,
|
|
.stream-card,
|
|
.signal-card {
|
|
position: relative;
|
|
overflow: hidden;
|
|
background: var(--panel);
|
|
border: 1px solid var(--line);
|
|
border-radius: 22px;
|
|
box-shadow: var(--shadow);
|
|
backdrop-filter: blur(18px);
|
|
}
|
|
|
|
.hero-card::after,
|
|
.panel::after,
|
|
.platform-card::after,
|
|
.stream-card::after,
|
|
.signal-card::after {
|
|
content: "";
|
|
position: absolute;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
background: linear-gradient(135deg, rgba(255,255,255,0.04), transparent 24%, transparent 70%, rgba(158, 214, 255, 0.05));
|
|
}
|
|
|
|
.hero-main {
|
|
padding: 26px 28px 22px;
|
|
}
|
|
|
|
.eyebrow {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
margin-bottom: 14px;
|
|
color: var(--cold);
|
|
font-family: "IBM Plex Mono", monospace;
|
|
font-size: 12px;
|
|
letter-spacing: 0.14em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.eyebrow::before {
|
|
content: "";
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 999px;
|
|
background: var(--accent);
|
|
box-shadow: 0 0 18px rgba(156, 255, 87, 0.72);
|
|
}
|
|
|
|
.hero-title {
|
|
margin: 0;
|
|
font-size: clamp(34px, 4.8vw, 56px);
|
|
line-height: 1;
|
|
font-weight: 700;
|
|
letter-spacing: -0.04em;
|
|
}
|
|
|
|
.hero-title strong {
|
|
color: var(--cold);
|
|
font-weight: 700;
|
|
}
|
|
|
|
.hero-subtitle {
|
|
margin: 14px 0 24px;
|
|
max-width: 860px;
|
|
color: var(--muted);
|
|
font-size: 15px;
|
|
line-height: 1.7;
|
|
}
|
|
|
|
.hero-metrics {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
gap: 14px;
|
|
}
|
|
|
|
.hero-metric {
|
|
padding: 16px;
|
|
border-radius: 18px;
|
|
background: var(--panel-soft);
|
|
border: 1px solid rgba(126, 200, 255, 0.08);
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.hero-metric::after {
|
|
content: "";
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
bottom: 0;
|
|
width: 3px;
|
|
background: linear-gradient(180deg, var(--cold), transparent);
|
|
opacity: 0.9;
|
|
}
|
|
|
|
.metric-label {
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.metric-value {
|
|
font-family: "IBM Plex Mono", monospace;
|
|
font-size: clamp(22px, 3vw, 30px);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.metric-value.good {
|
|
color: var(--good);
|
|
}
|
|
|
|
.metric-value.danger {
|
|
color: var(--danger);
|
|
}
|
|
|
|
.metric-note {
|
|
margin-top: 6px;
|
|
color: var(--muted);
|
|
font-size: 11px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
}
|
|
|
|
.hero-side {
|
|
padding: 22px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
background: var(--panel-strong);
|
|
}
|
|
|
|
.status-stack {
|
|
display: grid;
|
|
gap: 12px;
|
|
}
|
|
|
|
.status-pill {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 14px 16px;
|
|
border-radius: 16px;
|
|
background: rgba(255,255,255,0.025);
|
|
border: 1px solid rgba(255,255,255,0.06);
|
|
}
|
|
|
|
.status-pill .name {
|
|
font-size: 14px;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.status-pill .value {
|
|
font-family: "IBM Plex Mono", monospace;
|
|
font-size: 13px;
|
|
color: var(--text);
|
|
}
|
|
|
|
.status-pill.halted .value {
|
|
color: var(--danger);
|
|
}
|
|
|
|
.hero-actions {
|
|
display: flex;
|
|
gap: 12px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.action-btn {
|
|
appearance: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
border-radius: 14px;
|
|
padding: 12px 16px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
font-size: 12px;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
transition: transform 0.2s ease, opacity 0.2s ease, background 0.2s ease;
|
|
}
|
|
|
|
.action-btn:hover {
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.action-primary {
|
|
background: linear-gradient(135deg, #9cff57, #63e6be);
|
|
color: #071018;
|
|
}
|
|
|
|
.action-secondary {
|
|
background: rgba(126, 200, 255, 0.12);
|
|
color: var(--cold);
|
|
border: 1px solid rgba(126, 200, 255, 0.22);
|
|
}
|
|
|
|
.layout {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1.35fr) minmax(360px, 0.78fr);
|
|
gap: 18px;
|
|
}
|
|
|
|
.layout > :only-child {
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.left-stack,
|
|
.right-stack {
|
|
display: grid;
|
|
gap: 18px;
|
|
}
|
|
|
|
.section-label {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 6px 12px;
|
|
border-radius: 999px;
|
|
background: rgba(126, 200, 255, 0.08);
|
|
border: 1px solid rgba(126, 200, 255, 0.16);
|
|
color: var(--cold);
|
|
font-size: 11px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
}
|
|
|
|
.section-label::before {
|
|
content: "";
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 999px;
|
|
background: currentColor;
|
|
box-shadow: 0 0 10px currentColor;
|
|
}
|
|
|
|
.panel {
|
|
padding: 22px;
|
|
}
|
|
|
|
.panel-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 12px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.panel-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.panel-title {
|
|
margin: 0;
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
letter-spacing: -0.02em;
|
|
}
|
|
|
|
.panel-sub {
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
}
|
|
|
|
.priority-layout {
|
|
display: grid;
|
|
grid-template-columns: minmax(320px, 0.9fr) minmax(0, 1.1fr);
|
|
gap: 18px;
|
|
margin-top: 18px;
|
|
}
|
|
|
|
.priority-main,
|
|
.priority-side {
|
|
display: grid;
|
|
gap: 18px;
|
|
}
|
|
|
|
.focus-summary {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
gap: 12px;
|
|
margin-top: 14px;
|
|
}
|
|
|
|
.focus-summary-card {
|
|
padding: 14px 16px;
|
|
border-radius: 16px;
|
|
background: rgba(255,255,255,0.03);
|
|
border: 1px solid rgba(255,255,255,0.06);
|
|
}
|
|
|
|
.focus-summary-card .kicker {
|
|
color: var(--muted);
|
|
font-size: 11px;
|
|
margin-bottom: 8px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
}
|
|
|
|
.focus-summary-card .headline {
|
|
font-family: "IBM Plex Mono", monospace;
|
|
font-size: 18px;
|
|
color: var(--text);
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.focus-summary-card .headline.good {
|
|
color: var(--good);
|
|
}
|
|
|
|
.focus-summary-card .headline.warn {
|
|
color: var(--warn);
|
|
}
|
|
|
|
.focus-summary-card .headline.danger {
|
|
color: var(--danger);
|
|
}
|
|
|
|
.focus-summary-card .detail {
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
line-height: 1.55;
|
|
}
|
|
|
|
.alert-strip {
|
|
display: grid;
|
|
gap: 10px;
|
|
margin-top: 14px;
|
|
}
|
|
|
|
.alert-strip:empty {
|
|
display: none;
|
|
}
|
|
|
|
.alert-banner {
|
|
display: grid;
|
|
grid-template-columns: 140px 1fr auto;
|
|
gap: 12px;
|
|
align-items: center;
|
|
padding: 12px 14px;
|
|
border-radius: 16px;
|
|
border: 1px solid rgba(255,255,255,0.08);
|
|
background: rgba(255,255,255,0.03);
|
|
}
|
|
|
|
.alert-banner.danger {
|
|
border-color: rgba(255, 111, 97, 0.26);
|
|
background: rgba(255, 111, 97, 0.10);
|
|
}
|
|
|
|
.alert-banner.warn {
|
|
border-color: rgba(255, 184, 77, 0.24);
|
|
background: rgba(255, 184, 77, 0.10);
|
|
}
|
|
|
|
.alert-banner.good {
|
|
border-color: rgba(48, 209, 88, 0.22);
|
|
background: rgba(48, 209, 88, 0.08);
|
|
}
|
|
|
|
.alert-kicker {
|
|
color: var(--text);
|
|
font-size: 11px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
}
|
|
|
|
.alert-detail {
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
line-height: 1.55;
|
|
}
|
|
|
|
.alert-count {
|
|
font-family: "IBM Plex Mono", monospace;
|
|
font-size: 18px;
|
|
color: var(--text);
|
|
}
|
|
|
|
.focus-grid {
|
|
display: grid;
|
|
grid-template-columns: minmax(320px, 0.82fr) minmax(0, 1.18fr);
|
|
gap: 18px;
|
|
margin-top: 18px;
|
|
}
|
|
|
|
.focus-panel {
|
|
min-height: 100%;
|
|
}
|
|
|
|
.overview-grid {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1.18fr) minmax(320px, 0.82fr);
|
|
gap: 14px;
|
|
}
|
|
|
|
.overview-main,
|
|
.overview-side {
|
|
display: grid;
|
|
gap: 14px;
|
|
}
|
|
|
|
.ops-panel-body {
|
|
min-height: 320px;
|
|
}
|
|
|
|
.ops-pane {
|
|
display: grid;
|
|
gap: 14px;
|
|
}
|
|
|
|
.ops-summary {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
gap: 12px;
|
|
margin-bottom: 14px;
|
|
}
|
|
|
|
.ops-summary-card {
|
|
appearance: none;
|
|
width: 100%;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
padding: 14px 16px;
|
|
border-radius: 16px;
|
|
background: rgba(255,255,255,0.03);
|
|
border: 1px solid rgba(255,255,255,0.06);
|
|
color: var(--text);
|
|
transition: border-color 0.2s ease, background 0.2s ease, transform 0.2s ease;
|
|
}
|
|
|
|
.ops-summary-card:hover {
|
|
transform: translateY(-1px);
|
|
border-color: rgba(126, 200, 255, 0.16);
|
|
}
|
|
|
|
.ops-summary-card.active {
|
|
border-color: rgba(126, 200, 255, 0.26);
|
|
background: rgba(126, 200, 255, 0.10);
|
|
}
|
|
|
|
.ops-summary-card.danger {
|
|
border-color: rgba(255, 111, 97, 0.22);
|
|
background: rgba(255, 111, 97, 0.08);
|
|
}
|
|
|
|
.ops-summary-card.warn {
|
|
border-color: rgba(255, 184, 77, 0.20);
|
|
background: rgba(255, 184, 77, 0.08);
|
|
}
|
|
|
|
.ops-summary-kicker {
|
|
color: var(--muted);
|
|
font-size: 11px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.ops-summary-headline {
|
|
font-family: "IBM Plex Mono", monospace;
|
|
font-size: 18px;
|
|
color: var(--text);
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.ops-summary-headline.good {
|
|
color: var(--good);
|
|
}
|
|
|
|
.ops-summary-headline.warn {
|
|
color: var(--warn);
|
|
}
|
|
|
|
.ops-summary-headline.danger {
|
|
color: var(--danger);
|
|
}
|
|
|
|
.ops-summary-detail {
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
line-height: 1.55;
|
|
}
|
|
|
|
.ops-pane .analysis-log-list,
|
|
.ops-pane .halt-list {
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.health-ribbon {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
gap: 12px;
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.health-card {
|
|
padding: 14px 16px;
|
|
border-radius: 16px;
|
|
background: rgba(255,255,255,0.03);
|
|
border: 1px solid rgba(255,255,255,0.05);
|
|
}
|
|
|
|
.health-card .kicker {
|
|
color: var(--muted);
|
|
font-size: 11px;
|
|
margin-bottom: 6px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
}
|
|
|
|
.health-card .headline {
|
|
font-family: "IBM Plex Mono", monospace;
|
|
font-size: 14px;
|
|
color: var(--text);
|
|
}
|
|
|
|
.health-card .headline.good {
|
|
color: var(--good);
|
|
}
|
|
|
|
.health-card .headline.warn {
|
|
color: var(--warn);
|
|
}
|
|
|
|
.health-card .headline.danger {
|
|
color: var(--danger);
|
|
}
|
|
|
|
.health-card .detail {
|
|
margin-top: 6px;
|
|
color: var(--muted);
|
|
font-size: 11px;
|
|
line-height: 1.55;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
}
|
|
|
|
.signal-grid,
|
|
.platform-grid {
|
|
display: grid;
|
|
gap: 14px;
|
|
}
|
|
|
|
.coord-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 14px;
|
|
}
|
|
|
|
.coord-block {
|
|
padding: 14px;
|
|
border-radius: 18px;
|
|
background: rgba(255,255,255,0.025);
|
|
border: 1px solid rgba(255,255,255,0.06);
|
|
}
|
|
|
|
.coord-block .signal-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.block-head {
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.block-title {
|
|
margin: 10px 0 4px;
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
letter-spacing: -0.02em;
|
|
}
|
|
|
|
.block-sub {
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
}
|
|
|
|
.signal-grid {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
|
|
.signal-card {
|
|
padding: 18px;
|
|
}
|
|
|
|
.signal-head {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
gap: 12px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.signal-symbol {
|
|
font-size: 20px;
|
|
font-weight: 700;
|
|
letter-spacing: -0.03em;
|
|
}
|
|
|
|
.signal-meta {
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
}
|
|
|
|
.badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-width: 74px;
|
|
padding: 6px 10px;
|
|
border-radius: 999px;
|
|
font-size: 11px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
border: 1px solid transparent;
|
|
}
|
|
|
|
.badge.buy {
|
|
background: rgba(48, 209, 88, 0.12);
|
|
color: var(--good);
|
|
border-color: rgba(48, 209, 88, 0.2);
|
|
}
|
|
|
|
.badge.sell {
|
|
background: rgba(255, 111, 97, 0.12);
|
|
color: var(--danger);
|
|
border-color: rgba(255, 111, 97, 0.22);
|
|
}
|
|
|
|
.badge.wait {
|
|
background: rgba(255, 184, 77, 0.12);
|
|
color: var(--warn);
|
|
border-color: rgba(255, 184, 77, 0.22);
|
|
}
|
|
|
|
.signal-stats {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
gap: 10px;
|
|
}
|
|
|
|
.stat-chip {
|
|
padding: 10px;
|
|
border-radius: 12px;
|
|
background: rgba(255, 255, 255, 0.03);
|
|
}
|
|
|
|
.decision-chip {
|
|
border: 1px solid rgba(255,255,255,0.06);
|
|
transition: border-color 0.2s ease, background 0.2s ease;
|
|
}
|
|
|
|
.decision-chip.success {
|
|
border-color: rgba(48, 209, 88, 0.18);
|
|
background: rgba(48, 209, 88, 0.08);
|
|
}
|
|
|
|
.decision-chip.warning {
|
|
border-color: rgba(255, 184, 77, 0.18);
|
|
background: rgba(255, 184, 77, 0.08);
|
|
}
|
|
|
|
.decision-chip.error {
|
|
border-color: rgba(255, 111, 97, 0.18);
|
|
background: rgba(255, 111, 97, 0.08);
|
|
}
|
|
|
|
.decision-chip.hold {
|
|
border-color: rgba(255,255,255,0.08);
|
|
background: rgba(255,255,255,0.04);
|
|
}
|
|
|
|
.stat-chip .label {
|
|
display: block;
|
|
color: var(--muted);
|
|
font-size: 11px;
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.stat-chip .value {
|
|
font-family: "IBM Plex Mono", monospace;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.platform-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.platform-grid.compact {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.platform-card {
|
|
padding: 18px;
|
|
}
|
|
|
|
.platform-card.danger {
|
|
border-color: rgba(255, 111, 97, 0.22);
|
|
background: rgba(255, 111, 97, 0.06);
|
|
}
|
|
|
|
.platform-card.warn {
|
|
border-color: rgba(255, 184, 77, 0.18);
|
|
background: rgba(255, 184, 77, 0.05);
|
|
}
|
|
|
|
.platform-top {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
align-items: flex-start;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.platform-title {
|
|
margin: 0;
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.platform-state {
|
|
font-family: "IBM Plex Mono", monospace;
|
|
font-size: 11px;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.platform-overview {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
gap: 10px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.platform-pill-stat {
|
|
padding: 10px 12px;
|
|
border-radius: 12px;
|
|
background: rgba(255,255,255,0.035);
|
|
border: 1px solid rgba(255,255,255,0.05);
|
|
}
|
|
|
|
.platform-pill-stat .label {
|
|
display: block;
|
|
color: var(--muted);
|
|
font-size: 10px;
|
|
margin-bottom: 4px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
}
|
|
|
|
.platform-pill-stat .value {
|
|
font-family: "IBM Plex Mono", monospace;
|
|
font-size: 14px;
|
|
color: var(--text);
|
|
}
|
|
|
|
.risk-band {
|
|
margin: 8px 0 12px;
|
|
height: 8px;
|
|
border-radius: 999px;
|
|
background: rgba(255,255,255,0.06);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.risk-fill {
|
|
height: 100%;
|
|
border-radius: 999px;
|
|
background: linear-gradient(90deg, var(--good), var(--warn), var(--danger));
|
|
}
|
|
|
|
.platform-stats {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
gap: 10px;
|
|
}
|
|
|
|
.platform-stat {
|
|
padding: 10px 12px;
|
|
border-radius: 12px;
|
|
background: rgba(255,255,255,0.03);
|
|
}
|
|
|
|
.platform-stat .label {
|
|
display: block;
|
|
color: var(--muted);
|
|
font-size: 11px;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.platform-stat .value {
|
|
font-family: "IBM Plex Mono", monospace;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.platform-detail-toggle {
|
|
margin-top: 12px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 10px;
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.platform-detail-toggle summary {
|
|
cursor: pointer;
|
|
list-style: none;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: var(--cold);
|
|
}
|
|
|
|
.platform-detail-toggle summary::-webkit-details-marker {
|
|
display: none;
|
|
}
|
|
|
|
.stream-card {
|
|
padding: 18px;
|
|
}
|
|
|
|
.dense-panel .panel-header {
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.workspace-panel {
|
|
margin-top: 18px;
|
|
}
|
|
|
|
.workspace-head {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 16px;
|
|
margin-bottom: 14px;
|
|
}
|
|
|
|
.workspace-tabs {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
}
|
|
|
|
.workspace-tab {
|
|
appearance: none;
|
|
border: 1px solid rgba(255,255,255,0.08);
|
|
background: rgba(255,255,255,0.03);
|
|
color: var(--muted);
|
|
cursor: pointer;
|
|
border-radius: 999px;
|
|
padding: 9px 12px;
|
|
font-size: 11px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease;
|
|
}
|
|
|
|
.workspace-tab.active {
|
|
color: var(--cold);
|
|
border-color: rgba(126, 200, 255, 0.28);
|
|
background: rgba(126, 200, 255, 0.12);
|
|
}
|
|
|
|
.workspace-tab.muted {
|
|
color: rgba(142, 166, 188, 0.72);
|
|
border-color: rgba(255,255,255,0.05);
|
|
}
|
|
|
|
.tab-count {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-width: 18px;
|
|
height: 18px;
|
|
margin-left: 6px;
|
|
padding: 0 6px;
|
|
border-radius: 999px;
|
|
background: rgba(255,255,255,0.08);
|
|
color: inherit;
|
|
font-size: 10px;
|
|
line-height: 1;
|
|
}
|
|
|
|
.tab-pane {
|
|
display: none;
|
|
}
|
|
|
|
.tab-pane.active {
|
|
display: block;
|
|
}
|
|
|
|
.stream-list {
|
|
display: grid;
|
|
gap: 10px;
|
|
}
|
|
|
|
.signal-feed-grid {
|
|
display: grid;
|
|
gap: 12px;
|
|
}
|
|
|
|
.top-priority-grid {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1.08fr) minmax(280px, 0.92fr);
|
|
gap: 14px;
|
|
}
|
|
|
|
.top-priority-card {
|
|
padding: 18px;
|
|
border-radius: 18px;
|
|
border: 1px solid rgba(255,255,255,0.08);
|
|
background:
|
|
linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02)),
|
|
rgba(255,255,255,0.02);
|
|
}
|
|
|
|
.top-priority-card.danger {
|
|
border-color: rgba(255, 111, 97, 0.24);
|
|
background:
|
|
linear-gradient(180deg, rgba(255, 111, 97, 0.16), rgba(255, 111, 97, 0.06)),
|
|
rgba(255, 111, 97, 0.05);
|
|
}
|
|
|
|
.top-priority-card.warn {
|
|
border-color: rgba(255, 184, 77, 0.24);
|
|
background:
|
|
linear-gradient(180deg, rgba(255, 184, 77, 0.14), rgba(255, 184, 77, 0.05)),
|
|
rgba(255, 184, 77, 0.04);
|
|
}
|
|
|
|
.top-priority-card.good {
|
|
border-color: rgba(48, 209, 88, 0.22);
|
|
background:
|
|
linear-gradient(180deg, rgba(48, 209, 88, 0.12), rgba(48, 209, 88, 0.04)),
|
|
rgba(48, 209, 88, 0.04);
|
|
}
|
|
|
|
.top-priority-kicker {
|
|
color: var(--muted);
|
|
font-size: 11px;
|
|
margin-bottom: 8px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
}
|
|
|
|
.top-priority-title {
|
|
font-size: 20px;
|
|
font-weight: 700;
|
|
letter-spacing: -0.03em;
|
|
color: var(--text);
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.top-priority-detail,
|
|
.top-priority-action {
|
|
font-size: 13px;
|
|
line-height: 1.65;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.top-priority-action {
|
|
margin-top: 12px;
|
|
padding-top: 12px;
|
|
border-top: 1px solid rgba(255,255,255,0.08);
|
|
}
|
|
|
|
.top-priority-meta {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
margin-top: 14px;
|
|
}
|
|
|
|
.priority-mini-list {
|
|
display: grid;
|
|
gap: 10px;
|
|
}
|
|
|
|
.priority-mini-item {
|
|
padding: 14px;
|
|
border-radius: 14px;
|
|
background: rgba(255,255,255,0.03);
|
|
border: 1px solid rgba(255,255,255,0.06);
|
|
}
|
|
|
|
.priority-mini-item.danger {
|
|
border-color: rgba(255, 111, 97, 0.18);
|
|
background: rgba(255, 111, 97, 0.08);
|
|
}
|
|
|
|
.priority-mini-item.warn {
|
|
border-color: rgba(255, 184, 77, 0.18);
|
|
background: rgba(255, 184, 77, 0.08);
|
|
}
|
|
|
|
.priority-mini-item.good {
|
|
border-color: rgba(48, 209, 88, 0.16);
|
|
background: rgba(48, 209, 88, 0.07);
|
|
}
|
|
|
|
.priority-mini-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.priority-mini-title {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
}
|
|
|
|
.priority-mini-detail {
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
line-height: 1.55;
|
|
}
|
|
|
|
.ops-kpi-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
gap: 12px;
|
|
}
|
|
|
|
.ops-kpi-card {
|
|
padding: 14px 16px;
|
|
border-radius: 16px;
|
|
background: rgba(255,255,255,0.03);
|
|
border: 1px solid rgba(255,255,255,0.06);
|
|
}
|
|
|
|
.ops-kpi-card.warn {
|
|
border-color: rgba(255, 184, 77, 0.2);
|
|
background: rgba(255, 184, 77, 0.08);
|
|
}
|
|
|
|
.ops-kpi-card.danger {
|
|
border-color: rgba(255, 111, 97, 0.2);
|
|
background: rgba(255, 111, 97, 0.08);
|
|
}
|
|
|
|
.ops-kpi-label {
|
|
color: var(--muted);
|
|
font-size: 11px;
|
|
margin-bottom: 8px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
}
|
|
|
|
.ops-kpi-value {
|
|
font-family: "IBM Plex Mono", monospace;
|
|
font-size: 20px;
|
|
color: var(--text);
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.ops-kpi-detail {
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
line-height: 1.55;
|
|
}
|
|
|
|
.lifecycle-list {
|
|
display: grid;
|
|
gap: 12px;
|
|
}
|
|
|
|
.lifecycle-card {
|
|
padding: 16px;
|
|
border-radius: 16px;
|
|
background: rgba(255,255,255,0.03);
|
|
border: 1px solid rgba(255,255,255,0.06);
|
|
}
|
|
|
|
.lifecycle-card.buy {
|
|
border-color: rgba(48, 209, 88, 0.18);
|
|
}
|
|
|
|
.lifecycle-card.sell {
|
|
border-color: rgba(255, 111, 97, 0.18);
|
|
}
|
|
|
|
.lifecycle-head {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
align-items: flex-start;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.lifecycle-title {
|
|
font-size: 16px;
|
|
font-weight: 700;
|
|
color: var(--text);
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.lifecycle-meta {
|
|
color: var(--muted);
|
|
font-size: 11px;
|
|
line-height: 1.55;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
}
|
|
|
|
.lifecycle-summary {
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
line-height: 1.6;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.lifecycle-lanes {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 10px;
|
|
}
|
|
|
|
.lifecycle-lane {
|
|
padding: 12px 14px;
|
|
border-radius: 14px;
|
|
background: rgba(255,255,255,0.03);
|
|
border: 1px solid rgba(255,255,255,0.06);
|
|
}
|
|
|
|
.lifecycle-lane.good {
|
|
border-color: rgba(48, 209, 88, 0.18);
|
|
background: rgba(48, 209, 88, 0.08);
|
|
}
|
|
|
|
.lifecycle-lane.warn {
|
|
border-color: rgba(255, 184, 77, 0.18);
|
|
background: rgba(255, 184, 77, 0.08);
|
|
}
|
|
|
|
.lifecycle-lane.danger {
|
|
border-color: rgba(255, 111, 97, 0.18);
|
|
background: rgba(255, 111, 97, 0.08);
|
|
}
|
|
|
|
.lifecycle-lane-head {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
margin-bottom: 8px;
|
|
align-items: center;
|
|
}
|
|
|
|
.lifecycle-lane-title {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
}
|
|
|
|
.lifecycle-lane-state {
|
|
font-size: 11px;
|
|
color: var(--muted);
|
|
font-family: "IBM Plex Mono", monospace;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.lifecycle-lane-detail {
|
|
display: grid;
|
|
gap: 6px;
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
line-height: 1.55;
|
|
}
|
|
|
|
.signal-feed-card {
|
|
padding: 14px 16px;
|
|
border-radius: 16px;
|
|
background: rgba(255,255,255,0.03);
|
|
border: 1px solid rgba(255,255,255,0.06);
|
|
}
|
|
|
|
.signal-feed-card.buy {
|
|
border-color: rgba(48, 209, 88, 0.18);
|
|
background: rgba(48, 209, 88, 0.07);
|
|
}
|
|
|
|
.signal-feed-card.sell {
|
|
border-color: rgba(255, 111, 97, 0.18);
|
|
background: rgba(255, 111, 97, 0.07);
|
|
}
|
|
|
|
.signal-feed-head {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
align-items: flex-start;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.signal-feed-symbol {
|
|
font-size: 16px;
|
|
font-weight: 700;
|
|
color: var(--text);
|
|
}
|
|
|
|
.signal-feed-meta {
|
|
color: var(--muted);
|
|
font-size: 11px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.signal-feed-tags {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.signal-feed-stats {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 10px;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.signal-feed-stat {
|
|
padding: 10px 12px;
|
|
border-radius: 12px;
|
|
background: rgba(255,255,255,0.03);
|
|
}
|
|
|
|
.signal-feed-stat .label {
|
|
display: block;
|
|
color: var(--muted);
|
|
font-size: 10px;
|
|
margin-bottom: 4px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
}
|
|
|
|
.signal-feed-stat .value {
|
|
font-family: "IBM Plex Mono", monospace;
|
|
font-size: 13px;
|
|
color: var(--text);
|
|
}
|
|
|
|
.signal-feed-reason {
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
line-height: 1.65;
|
|
}
|
|
|
|
.workspace-stream {
|
|
min-height: 0;
|
|
}
|
|
|
|
.scroll-panel {
|
|
max-height: 560px;
|
|
overflow: auto;
|
|
padding-right: 4px;
|
|
}
|
|
|
|
.log-shell {
|
|
display: grid;
|
|
gap: 12px;
|
|
}
|
|
|
|
.log-head {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.event-list {
|
|
display: grid;
|
|
gap: 10px;
|
|
}
|
|
|
|
.filter-row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.filter-chip {
|
|
appearance: none;
|
|
border: 1px solid rgba(255,255,255,0.08);
|
|
background: rgba(255,255,255,0.03);
|
|
color: var(--muted);
|
|
cursor: pointer;
|
|
border-radius: 999px;
|
|
padding: 8px 10px;
|
|
font-size: 11px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
|
|
.filter-chip.active {
|
|
border-color: rgba(126, 200, 255, 0.28);
|
|
color: var(--cold);
|
|
background: rgba(126, 200, 255, 0.10);
|
|
}
|
|
|
|
.ghost-btn {
|
|
appearance: none;
|
|
border: 1px solid rgba(255,255,255,0.08);
|
|
background: rgba(255,255,255,0.03);
|
|
color: var(--muted);
|
|
cursor: pointer;
|
|
border-radius: 999px;
|
|
padding: 8px 12px;
|
|
font-size: 11px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease;
|
|
}
|
|
|
|
.ghost-btn.active {
|
|
color: var(--cold);
|
|
border-color: rgba(126, 200, 255, 0.24);
|
|
background: rgba(126, 200, 255, 0.1);
|
|
}
|
|
|
|
.event-item {
|
|
display: grid;
|
|
grid-template-columns: 120px 110px 1fr;
|
|
gap: 12px;
|
|
align-items: start;
|
|
padding: 12px 14px;
|
|
border-radius: 14px;
|
|
background: rgba(255,255,255,0.03);
|
|
border: 1px solid rgba(255,255,255,0.05);
|
|
}
|
|
|
|
.event-time {
|
|
color: var(--muted);
|
|
font-size: 11px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
}
|
|
|
|
.event-tag {
|
|
display: inline-flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
min-height: 28px;
|
|
padding: 6px 8px;
|
|
border-radius: 10px;
|
|
font-size: 11px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
background: rgba(126, 200, 255, 0.12);
|
|
color: var(--cold);
|
|
}
|
|
|
|
.event-tag.success {
|
|
background: rgba(48, 209, 88, 0.12);
|
|
color: var(--good);
|
|
}
|
|
|
|
.event-tag.error {
|
|
background: rgba(255, 111, 97, 0.12);
|
|
color: var(--danger);
|
|
}
|
|
|
|
.event-tag.warning {
|
|
background: rgba(255, 184, 77, 0.12);
|
|
color: var(--warn);
|
|
}
|
|
|
|
.event-tag.hold {
|
|
background: rgba(255,255,255,0.08);
|
|
color: var(--muted);
|
|
}
|
|
|
|
.event-body {
|
|
font-size: 13px;
|
|
line-height: 1.55;
|
|
}
|
|
|
|
.event-body strong {
|
|
color: var(--text);
|
|
}
|
|
|
|
.event-summary {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.event-inline-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 2px 8px;
|
|
border-radius: 999px;
|
|
background: rgba(255,255,255,0.06);
|
|
color: var(--muted);
|
|
font-size: 10px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.event-details {
|
|
margin-top: 8px;
|
|
border-top: 1px dashed rgba(255,255,255,0.08);
|
|
padding-top: 8px;
|
|
}
|
|
|
|
.event-details summary {
|
|
cursor: pointer;
|
|
list-style: none;
|
|
color: var(--cold);
|
|
font-size: 11px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.event-details summary::-webkit-details-marker {
|
|
display: none;
|
|
}
|
|
|
|
.event-details pre {
|
|
margin: 10px 0 0;
|
|
padding: 10px 12px;
|
|
border-radius: 10px;
|
|
background: rgba(0,0,0,0.2);
|
|
border: 1px solid rgba(255,255,255,0.06);
|
|
overflow: auto;
|
|
color: #cfe1f2;
|
|
font-size: 11px;
|
|
line-height: 1.55;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
}
|
|
|
|
.stream-item {
|
|
display: grid;
|
|
grid-template-columns: 124px 1fr auto;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 12px 14px;
|
|
border-radius: 14px;
|
|
background: rgba(255,255,255,0.03);
|
|
border: 1px solid rgba(255,255,255,0.05);
|
|
}
|
|
|
|
.stream-item .time {
|
|
color: var(--muted);
|
|
font-family: "IBM Plex Mono", monospace;
|
|
font-size: 11px;
|
|
}
|
|
|
|
.stream-item .headline {
|
|
font-size: 14px;
|
|
line-height: 1.45;
|
|
}
|
|
|
|
.stream-item .grade {
|
|
font-family: "IBM Plex Mono", monospace;
|
|
font-size: 11px;
|
|
color: var(--cold);
|
|
}
|
|
|
|
.halt-list {
|
|
display: grid;
|
|
gap: 10px;
|
|
}
|
|
|
|
.halt-item {
|
|
padding: 14px;
|
|
border-radius: 14px;
|
|
border: 1px solid rgba(255,255,255,0.05);
|
|
background: rgba(255,255,255,0.025);
|
|
}
|
|
|
|
.halt-item.active {
|
|
border-color: rgba(255, 111, 97, 0.2);
|
|
background: rgba(255, 111, 97, 0.08);
|
|
}
|
|
|
|
.halt-item.disabled {
|
|
border-color: rgba(255, 184, 77, 0.2);
|
|
background: rgba(255, 184, 77, 0.08);
|
|
}
|
|
|
|
.halt-top {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.halt-name {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.halt-state {
|
|
font-family: "IBM Plex Mono", monospace;
|
|
font-size: 11px;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.halt-reason {
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
line-height: 1.55;
|
|
}
|
|
|
|
.halt-actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
margin-top: 10px;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.mini-btn {
|
|
appearance: none;
|
|
border: 1px solid rgba(126, 200, 255, 0.22);
|
|
background: rgba(126, 200, 255, 0.12);
|
|
color: var(--cold);
|
|
cursor: pointer;
|
|
border-radius: 10px;
|
|
padding: 8px 10px;
|
|
font-size: 11px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.mini-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.mini-btn.warn {
|
|
border-color: rgba(255, 184, 77, 0.24);
|
|
background: rgba(255, 184, 77, 0.12);
|
|
color: var(--warn);
|
|
}
|
|
|
|
.mini-btn.good {
|
|
border-color: rgba(48, 209, 88, 0.22);
|
|
background: rgba(48, 209, 88, 0.12);
|
|
color: var(--good);
|
|
}
|
|
|
|
.footer-note {
|
|
margin-top: 16px;
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
}
|
|
|
|
.heartbeat-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
gap: 10px;
|
|
margin-bottom: 14px;
|
|
}
|
|
|
|
.heartbeat-card {
|
|
padding: 12px;
|
|
border-radius: 12px;
|
|
background: rgba(255,255,255,0.03);
|
|
border: 1px solid rgba(255,255,255,0.05);
|
|
}
|
|
|
|
.heartbeat-card .label {
|
|
display: block;
|
|
color: var(--muted);
|
|
font-size: 11px;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.heartbeat-card .value {
|
|
font-size: 13px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
color: var(--text);
|
|
}
|
|
|
|
.analysis-log-list {
|
|
display: grid;
|
|
gap: 10px;
|
|
}
|
|
|
|
.runtime-summary-grid {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
|
|
gap: 12px;
|
|
margin-bottom: 14px;
|
|
}
|
|
|
|
.runtime-summary-card {
|
|
padding: 14px;
|
|
border-radius: 14px;
|
|
background: rgba(255,255,255,0.03);
|
|
border: 1px solid rgba(255,255,255,0.05);
|
|
}
|
|
|
|
.runtime-summary-title {
|
|
color: var(--muted);
|
|
font-size: 11px;
|
|
margin-bottom: 10px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
}
|
|
|
|
.runtime-summary-main {
|
|
font-family: "IBM Plex Mono", monospace;
|
|
font-size: 16px;
|
|
color: var(--text);
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.runtime-summary-meta {
|
|
display: grid;
|
|
gap: 8px;
|
|
}
|
|
|
|
.runtime-summary-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.runtime-summary-row strong {
|
|
color: var(--text);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.blocked-list {
|
|
display: grid;
|
|
gap: 10px;
|
|
}
|
|
|
|
.blocked-item {
|
|
padding: 12px 14px;
|
|
border-radius: 14px;
|
|
background: rgba(255, 184, 77, 0.08);
|
|
border: 1px solid rgba(255, 184, 77, 0.16);
|
|
}
|
|
|
|
.blocked-item-head {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
margin-bottom: 8px;
|
|
align-items: center;
|
|
}
|
|
|
|
.blocked-item-title {
|
|
font-size: 13px;
|
|
color: var(--text);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.blocked-item-meta {
|
|
color: var(--muted);
|
|
font-size: 11px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
}
|
|
|
|
.blocked-platforms {
|
|
display: grid;
|
|
gap: 6px;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.blocked-platform {
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: flex-start;
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.blocked-platform strong {
|
|
color: var(--text);
|
|
min-width: 92px;
|
|
}
|
|
|
|
.target-grid,
|
|
.decision-account-grid {
|
|
display: grid;
|
|
gap: 10px;
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.target-grid {
|
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
}
|
|
|
|
.decision-account-grid {
|
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
}
|
|
|
|
.target-mini-card,
|
|
.decision-account-card {
|
|
padding: 12px 14px;
|
|
border-radius: 14px;
|
|
background: rgba(255,255,255,0.03);
|
|
border: 1px solid rgba(255,255,255,0.06);
|
|
}
|
|
|
|
.target-mini-card.halted,
|
|
.decision-account-card.error {
|
|
border-color: rgba(255, 111, 97, 0.22);
|
|
background: rgba(255, 111, 97, 0.08);
|
|
}
|
|
|
|
.target-mini-card.disabled {
|
|
border-color: rgba(255, 184, 77, 0.22);
|
|
background: rgba(255, 184, 77, 0.08);
|
|
}
|
|
|
|
.decision-account-card.warning {
|
|
border-color: rgba(255, 184, 77, 0.22);
|
|
background: rgba(255, 184, 77, 0.08);
|
|
}
|
|
|
|
.decision-account-card.success {
|
|
border-color: rgba(48, 209, 88, 0.20);
|
|
background: rgba(48, 209, 88, 0.08);
|
|
}
|
|
|
|
.target-mini-head,
|
|
.decision-account-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.target-mini-title,
|
|
.decision-account-title {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
}
|
|
|
|
.target-mini-meta,
|
|
.decision-account-meta {
|
|
color: var(--muted);
|
|
font-size: 11px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
}
|
|
|
|
.target-mini-stats,
|
|
.decision-account-detail {
|
|
display: grid;
|
|
gap: 6px;
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
line-height: 1.55;
|
|
}
|
|
|
|
.target-mini-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
}
|
|
|
|
.target-mini-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.lane-state-list {
|
|
display: grid;
|
|
gap: 8px;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.lane-state-item {
|
|
display: grid;
|
|
grid-template-columns: 76px 1fr;
|
|
gap: 10px;
|
|
padding-top: 8px;
|
|
border-top: 1px solid rgba(255,255,255,0.06);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.lane-state-symbol {
|
|
font-family: "IBM Plex Mono", monospace;
|
|
color: var(--text);
|
|
}
|
|
|
|
.lane-state-detail {
|
|
color: var(--muted);
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.analysis-log-item {
|
|
padding: 12px 14px;
|
|
border-radius: 14px;
|
|
background: rgba(255,255,255,0.03);
|
|
border: 1px solid rgba(255,255,255,0.05);
|
|
}
|
|
|
|
.analysis-log-item.error {
|
|
border-color: rgba(255, 111, 97, 0.2);
|
|
background: rgba(255, 111, 97, 0.08);
|
|
}
|
|
|
|
.analysis-log-item.warning,
|
|
.analysis-log-item.hold {
|
|
border-color: rgba(255, 184, 77, 0.2);
|
|
background: rgba(255, 184, 77, 0.08);
|
|
}
|
|
|
|
.analysis-log-item.success {
|
|
border-color: rgba(48, 209, 88, 0.18);
|
|
background: rgba(48, 209, 88, 0.08);
|
|
}
|
|
|
|
.analysis-log-head {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
align-items: center;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.analysis-log-head strong {
|
|
font-size: 13px;
|
|
}
|
|
|
|
.analysis-log-meta {
|
|
color: var(--muted);
|
|
font-size: 11px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
}
|
|
|
|
.analysis-log-detail {
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.ops-grid {
|
|
display: grid;
|
|
gap: 18px;
|
|
grid-template-columns: 1fr;
|
|
margin-top: 18px;
|
|
}
|
|
|
|
.attention-list,
|
|
.unified-list {
|
|
display: grid;
|
|
gap: 10px;
|
|
}
|
|
|
|
.attention-item,
|
|
.unified-item {
|
|
padding: 14px 16px;
|
|
border-radius: 14px;
|
|
background: rgba(255,255,255,0.03);
|
|
border: 1px solid rgba(255,255,255,0.05);
|
|
}
|
|
|
|
.attention-item.danger {
|
|
border-color: rgba(255, 111, 97, 0.2);
|
|
background: rgba(255, 111, 97, 0.08);
|
|
}
|
|
|
|
.attention-item.warning {
|
|
border-color: rgba(255, 184, 77, 0.2);
|
|
background: rgba(255, 184, 77, 0.08);
|
|
}
|
|
|
|
.attention-item.info {
|
|
border-color: rgba(126, 200, 255, 0.2);
|
|
background: rgba(126, 200, 255, 0.08);
|
|
}
|
|
|
|
.attention-title,
|
|
.unified-title {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
align-items: center;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.attention-title strong,
|
|
.unified-title strong {
|
|
font-size: 14px;
|
|
}
|
|
|
|
.attention-time,
|
|
.unified-time {
|
|
color: var(--muted);
|
|
font-size: 11px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
}
|
|
|
|
.attention-detail,
|
|
.unified-detail {
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.table-toolbar {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.toolbar-chip {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 6px 10px;
|
|
border-radius: 999px;
|
|
background: rgba(126, 200, 255, 0.08);
|
|
color: var(--cold);
|
|
border: 1px solid rgba(126, 200, 255, 0.16);
|
|
font-size: 11px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.unified-section {
|
|
margin-top: 18px;
|
|
}
|
|
|
|
.unified-table {
|
|
overflow-x: auto;
|
|
border-radius: 16px;
|
|
border: 1px solid rgba(255,255,255,0.05);
|
|
background: rgba(255,255,255,0.02);
|
|
}
|
|
|
|
.position-card-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 14px;
|
|
}
|
|
|
|
.position-card {
|
|
padding: 16px;
|
|
border-radius: 18px;
|
|
background: rgba(255,255,255,0.03);
|
|
border: 1px solid rgba(255,255,255,0.06);
|
|
}
|
|
|
|
.position-card.long {
|
|
border-color: rgba(48, 209, 88, 0.18);
|
|
}
|
|
|
|
.position-card.short {
|
|
border-color: rgba(255, 111, 97, 0.18);
|
|
}
|
|
|
|
.position-card-head {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
align-items: flex-start;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.position-card-symbol {
|
|
font-size: 16px;
|
|
font-weight: 700;
|
|
color: var(--text);
|
|
}
|
|
|
|
.position-card-meta {
|
|
color: var(--muted);
|
|
font-size: 11px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.position-card-pnl {
|
|
text-align: right;
|
|
}
|
|
|
|
.position-card-pnl .amount {
|
|
font-family: "IBM Plex Mono", monospace;
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.position-card-pnl .ratio {
|
|
margin-top: 4px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.position-card-gridline {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 10px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.position-card-block {
|
|
padding: 10px 12px;
|
|
border-radius: 12px;
|
|
background: rgba(255,255,255,0.03);
|
|
}
|
|
|
|
.position-card-block .label {
|
|
display: block;
|
|
color: var(--muted);
|
|
font-size: 10px;
|
|
margin-bottom: 4px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
}
|
|
|
|
.position-card-block .value {
|
|
font-family: "IBM Plex Mono", monospace;
|
|
font-size: 13px;
|
|
color: var(--text);
|
|
line-height: 1.55;
|
|
}
|
|
|
|
.position-card-tags {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.unified-table table {
|
|
min-width: 980px;
|
|
}
|
|
|
|
.platform-pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-width: 92px;
|
|
padding: 5px 10px;
|
|
border-radius: 999px;
|
|
font-size: 10px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
color: var(--cold);
|
|
background: rgba(126, 200, 255, 0.1);
|
|
border: 1px solid rgba(126, 200, 255, 0.2);
|
|
}
|
|
|
|
.side-pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-width: 60px;
|
|
padding: 4px 10px;
|
|
border-radius: 999px;
|
|
font-size: 10px;
|
|
font-family: "IBM Plex Mono", monospace;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
}
|
|
|
|
.side-pill.long {
|
|
color: var(--good);
|
|
background: rgba(48, 209, 88, 0.12);
|
|
border: 1px solid rgba(48, 209, 88, 0.2);
|
|
}
|
|
|
|
.side-pill.short {
|
|
color: var(--danger);
|
|
background: rgba(255, 111, 97, 0.12);
|
|
border: 1px solid rgba(255, 111, 97, 0.2);
|
|
}
|
|
|
|
.inline-mono {
|
|
font-family: "IBM Plex Mono", monospace;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.loading,
|
|
.error-box,
|
|
.empty-box {
|
|
padding: 24px;
|
|
border-radius: 18px;
|
|
text-align: center;
|
|
color: var(--muted);
|
|
background: rgba(255,255,255,0.03);
|
|
border: 1px solid rgba(255,255,255,0.06);
|
|
}
|
|
|
|
.error-box {
|
|
color: #ffd0cb;
|
|
background: rgba(255, 111, 97, 0.08);
|
|
border-color: rgba(255, 111, 97, 0.2);
|
|
}
|
|
|
|
.empty-box.compact {
|
|
padding: 14px 16px;
|
|
text-align: left;
|
|
border-radius: 14px;
|
|
}
|
|
|
|
.empty-box.compact strong {
|
|
display: block;
|
|
color: var(--text);
|
|
font-size: 13px;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.empty-detail {
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
line-height: 1.55;
|
|
}
|
|
|
|
@media (max-width: 1240px) {
|
|
.console-hub,
|
|
.hero,
|
|
.layout,
|
|
.priority-layout,
|
|
.focus-grid,
|
|
.overview-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.console-sidebar {
|
|
position: static;
|
|
}
|
|
|
|
.sidebar-nav {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
|
|
.platform-grid,
|
|
.signal-grid,
|
|
.ops-grid,
|
|
.coord-grid,
|
|
.position-card-grid,
|
|
.pane-grid.two-col,
|
|
.pane-grid.equal,
|
|
.monitor-strip,
|
|
.runtime-summary-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.platform-overview,
|
|
.platform-stats,
|
|
.signal-feed-stats,
|
|
.position-card-gridline,
|
|
.ops-kpi-grid,
|
|
.lifecycle-lanes {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
|
|
.hero-metrics {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
|
|
.focus-summary {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
}
|
|
|
|
@media (max-width: 720px) {
|
|
.console-shell {
|
|
width: min(100vw - 20px, 100%);
|
|
padding-top: 12px;
|
|
}
|
|
|
|
.sidebar-nav {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.hero-main,
|
|
.hero-side,
|
|
.panel,
|
|
.platform-card,
|
|
.signal-card,
|
|
.stream-card {
|
|
padding: 18px;
|
|
}
|
|
|
|
.hero-metrics,
|
|
.platform-stats,
|
|
.platform-overview,
|
|
.signal-feed-stats,
|
|
.position-card-gridline,
|
|
.signal-stats,
|
|
.heartbeat-grid,
|
|
.health-ribbon,
|
|
.ops-summary,
|
|
.focus-summary,
|
|
.top-priority-grid,
|
|
.ops-kpi-grid,
|
|
.lifecycle-lanes {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.alert-banner {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.stream-item {
|
|
grid-template-columns: 1fr;
|
|
align-items: flex-start;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="auth-overlay" id="consoleAuthOverlay">
|
|
<div class="auth-card">
|
|
<div class="eyebrow">Console Access</div>
|
|
<h2>输入访问密码</h2>
|
|
<p>总控台包含账户权益、持仓、挂单和执行控制,需要先完成访问验证。</p>
|
|
<input id="consolePasswordInput" class="auth-field" type="password" placeholder="请输入总控台访问密码">
|
|
<div class="auth-actions">
|
|
<button class="action-btn action-primary" id="consoleLoginBtn">进入总控台</button>
|
|
</div>
|
|
<div class="auth-error" id="consoleAuthError"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="console-shell">
|
|
<section class="hero">
|
|
<div class="hero-card hero-main">
|
|
<div class="eyebrow">System Console</div>
|
|
<h1 class="hero-title">交易系统 <strong>总控台</strong></h1>
|
|
<p class="hero-subtitle">
|
|
统一观察信号流、执行层、多账户风险和平台熔断状态。
|
|
这个页面的目标不是展示“历史”,而是让你在一屏内判断系统现在是不是健康、哪里堵住了、哪里需要人工接管。
|
|
</p>
|
|
<div class="hero-metrics" id="heroMetrics">
|
|
<div class="hero-metric"><div class="metric-label">运行 Agent</div><div class="metric-value">-</div></div>
|
|
<div class="hero-metric"><div class="metric-label">30 分钟信号</div><div class="metric-value">-</div></div>
|
|
<div class="hero-metric"><div class="metric-label">活跃持仓</div><div class="metric-value">-</div></div>
|
|
<div class="hero-metric"><div class="metric-label">停机平台</div><div class="metric-value">-</div></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="hero-card hero-side">
|
|
<div>
|
|
<div class="panel-header" style="margin-bottom: 14px;">
|
|
<h2 class="panel-title">运行态快照</h2>
|
|
<div class="panel-sub" id="lastUpdated">等待刷新</div>
|
|
</div>
|
|
<div class="status-stack" id="runSnapshot">
|
|
<div class="status-pill"><span class="name">系统启动</span><span class="value">-</span></div>
|
|
<div class="status-pill"><span class="name">Crypto Agent</span><span class="value">-</span></div>
|
|
<div class="status-pill"><span class="name">轮询模式</span><span class="value">-</span></div>
|
|
<div class="status-pill"><span class="name">监控标的</span><span class="value">-</span></div>
|
|
</div>
|
|
</div>
|
|
<div class="hero-actions">
|
|
<button class="action-btn action-primary" id="refreshBtn">立即刷新</button>
|
|
<button class="action-btn action-secondary" id="toggleAutoRefreshBtn">自动刷新: 开</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<div id="feedback"></div>
|
|
|
|
<section class="console-hub">
|
|
<aside class="console-sidebar">
|
|
<div class="sidebar-label">Navigation</div>
|
|
<div class="sidebar-nav" id="sidebarNav">
|
|
<button class="sidebar-link active" data-hub-target="hubOverview">
|
|
<div class="sidebar-link-title">总览 <span class="sidebar-badge" id="sidebarBadgeOverview">-</span></div>
|
|
<div class="sidebar-link-sub">系统是否正常、账户总览、常用操作</div>
|
|
</button>
|
|
<button class="sidebar-link" data-hub-target="hubExecution">
|
|
<div class="sidebar-link-title">账户执行 <span class="sidebar-badge" id="sidebarBadgeExecution">-</span></div>
|
|
<div class="sidebar-link-sub">模拟盘与 Bitget 的持仓、挂单、盈亏</div>
|
|
</button>
|
|
<button class="sidebar-link" data-hub-target="hubSignals">
|
|
<div class="sidebar-link-title">最近信号 <span class="sidebar-badge" id="sidebarBadgeSignals">-</span></div>
|
|
<div class="sidebar-link-sub">最新信号、执行跟踪、关键信息</div>
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
|
|
<div class="hub-content">
|
|
<section class="panel">
|
|
<div class="panel-header">
|
|
<div>
|
|
<div class="section-label">Health Layer</div>
|
|
<h2 class="panel-title" style="margin-top: 12px;">交易运营总览</h2>
|
|
<div class="panel-sub">先看系统有没有正常工作,再看账户、仓位、订单和最近信号</div>
|
|
</div>
|
|
<div class="panel-actions">
|
|
<button class="ghost-btn" id="toggleSensitiveBtn">敏感数据: 隐藏</button>
|
|
</div>
|
|
</div>
|
|
<div class="health-ribbon" id="healthRibbon">
|
|
<div class="health-card"><div class="kicker">分析状态</div><div class="headline">-</div></div>
|
|
<div class="health-card"><div class="kicker">最近轮次</div><div class="headline">-</div></div>
|
|
<div class="health-card"><div class="kicker">人工处理</div><div class="headline">-</div></div>
|
|
</div>
|
|
<div class="focus-summary" id="focusSummary">
|
|
<div class="focus-summary-card"><div class="kicker">运行心跳</div><div class="headline">-</div></div>
|
|
<div class="focus-summary-card"><div class="kicker">待执行机会</div><div class="headline">-</div></div>
|
|
<div class="focus-summary-card"><div class="kicker">风险暴露</div><div class="headline">-</div></div>
|
|
<div class="focus-summary-card"><div class="kicker">阻塞状态</div><div class="headline">-</div></div>
|
|
</div>
|
|
<div class="alert-strip" id="alertStrip"></div>
|
|
</section>
|
|
|
|
<section class="hub-pane active" id="hubOverview">
|
|
<div class="pane-grid">
|
|
<div class="pane-grid two-col">
|
|
<section class="panel workspace-panel">
|
|
<div class="workspace-head">
|
|
<div>
|
|
<div class="section-label">Runtime Layer</div>
|
|
<h2 class="panel-title" style="margin-top: 12px;">系统运行状态</h2>
|
|
<div class="panel-sub">判断系统是否在持续分析,以及价格与执行链路是否正常</div>
|
|
</div>
|
|
</div>
|
|
<div class="monitor-strip" id="executionMonitorStrip">
|
|
<div class="monitor-card">
|
|
<div class="title">价格链路</div>
|
|
<div class="main">等待刷新</div>
|
|
<div class="meta">状态加载中</div>
|
|
</div>
|
|
<div class="monitor-card">
|
|
<div class="title">执行链路</div>
|
|
<div class="main">等待刷新</div>
|
|
<div class="meta">状态加载中</div>
|
|
</div>
|
|
</div>
|
|
<div class="heartbeat-grid" id="analysisHeartbeat" style="margin-top: 14px;">
|
|
<div class="heartbeat-card"><span class="label">最近心跳</span><span class="value">-</span></div>
|
|
<div class="heartbeat-card"><span class="label">最近分析</span><span class="value">-</span></div>
|
|
<div class="heartbeat-card"><span class="label">下一次运行</span><span class="value">-</span></div>
|
|
<div class="heartbeat-card"><span class="label">最近信号</span><span class="value">-</span></div>
|
|
</div>
|
|
<div class="runtime-summary-card" id="runtimeSummaryCard" style="margin-top: 14px;">
|
|
<div class="runtime-summary-title">运行摘要</div>
|
|
<div class="runtime-summary-main">正在整理运行状态...</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel workspace-panel">
|
|
<div class="workspace-head">
|
|
<div>
|
|
<div class="section-label">Action Layer</div>
|
|
<h2 class="panel-title" style="margin-top: 12px;">常用操作与异常</h2>
|
|
<div class="panel-sub">自动交易开关、恢复执行,以及需要你马上处理的异常</div>
|
|
</div>
|
|
</div>
|
|
<div class="halt-list" id="haltList">
|
|
<div class="loading">正在读取平台状态...</div>
|
|
</div>
|
|
<div class="attention-list" id="attentionList" style="margin-top: 14px;">
|
|
<div class="loading">正在汇总需要处理的事项...</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<section class="panel workspace-panel">
|
|
<div class="workspace-head">
|
|
<div>
|
|
<div class="section-label">Account Layer</div>
|
|
<h2 class="panel-title" style="margin-top: 12px;">账户总览</h2>
|
|
<div class="panel-sub">只保留你日常管理需要看的权益、仓位、订单和自动交易状态</div>
|
|
</div>
|
|
</div>
|
|
<div class="platform-grid" id="platformGrid">
|
|
<div class="loading">正在加载平台状态...</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="hub-pane" id="hubExecution">
|
|
<div class="pane-grid">
|
|
<section class="panel workspace-panel unified-section">
|
|
<div class="workspace-head">
|
|
<div>
|
|
<div class="section-label">Position Layer</div>
|
|
<h2 class="panel-title" style="margin-top: 12px;">当前持仓</h2>
|
|
<div class="panel-sub">重点看模拟盘与 Bitget 的持仓、未实现盈亏和保护单情况</div>
|
|
</div>
|
|
</div>
|
|
<div class="table-toolbar" id="positionsToolbar"></div>
|
|
<div class="unified-table" id="positionsTable">
|
|
<div class="loading">正在整理跨平台持仓...</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel workspace-panel unified-section">
|
|
<div class="workspace-head">
|
|
<div>
|
|
<div class="section-label">Order Layer</div>
|
|
<h2 class="panel-title" style="margin-top: 12px;">当前挂单</h2>
|
|
<div class="panel-sub">集中看入场单和保护单,不再用密集表格堆信息</div>
|
|
</div>
|
|
</div>
|
|
<div class="table-toolbar" id="ordersToolbar"></div>
|
|
<div class="unified-table" id="ordersTable">
|
|
<div class="loading">正在整理跨平台挂单...</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="hub-pane" id="hubSignals">
|
|
<div class="pane-grid">
|
|
<div class="pane-grid two-col">
|
|
<section class="panel workspace-panel">
|
|
<div class="workspace-head">
|
|
<div>
|
|
<div class="section-label">Signal Layer</div>
|
|
<h2 class="panel-title" style="margin-top: 12px;">最近信号流</h2>
|
|
<div class="panel-sub">保留最近 5 条信号的关键内容,便于快速判断系统是否有正常产出</div>
|
|
</div>
|
|
</div>
|
|
<div class="stream-list workspace-stream scroll-panel" id="signalStream">
|
|
<div class="loading">正在读取信号...</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel workspace-panel">
|
|
<div class="workspace-head">
|
|
<div>
|
|
<div class="section-label">Lifecycle Layer</div>
|
|
<h2 class="panel-title" style="margin-top: 12px;">信号执行跟踪</h2>
|
|
<div class="panel-sub">看最近信号是否已经转成挂单或持仓,足够就好,不展示过多内部决策细节</div>
|
|
</div>
|
|
</div>
|
|
<div class="lifecycle-list" id="signalLifecycle">
|
|
<div class="loading">正在整理信号生命周期...</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<script>
|
|
let autoRefresh = true;
|
|
let timer = null;
|
|
let currentEventFilter = 'all';
|
|
let cachedExecutionEvents = [];
|
|
let cachedConsoleData = null;
|
|
let revealSensitiveData = false;
|
|
let consoleAuthenticated = false;
|
|
const SENSITIVE_VISIBILITY_KEY = 'console_sensitive_visible_v2';
|
|
const HUB_PREFERENCE_KEY = 'console_hub_active_v1';
|
|
|
|
function formatNumber(value, digits = 2) {
|
|
const num = Number(value || 0);
|
|
return Number.isFinite(num) ? num.toLocaleString('zh-CN', {
|
|
minimumFractionDigits: digits,
|
|
maximumFractionDigits: digits,
|
|
}) : '-';
|
|
}
|
|
|
|
function formatPercent(value, digits = 1) {
|
|
const num = Number(value || 0);
|
|
return Number.isFinite(num) ? `${num.toFixed(digits)}%` : '-';
|
|
}
|
|
|
|
function formatMoney(value) {
|
|
const num = Number(value || 0);
|
|
return Number.isFinite(num) ? `$${formatNumber(num, 2)}` : '-';
|
|
}
|
|
|
|
function parseConsoleDate(value) {
|
|
if (!value) return null;
|
|
if (value instanceof Date) return value;
|
|
const text = String(value).trim();
|
|
if (!text) return null;
|
|
|
|
const normalized = /([zZ]|[+-]\d{2}:\d{2})$/.test(text) ? text : `${text}Z`;
|
|
const date = new Date(normalized);
|
|
if (Number.isNaN(date.getTime())) return null;
|
|
return date;
|
|
}
|
|
|
|
function formatTime(value) {
|
|
if (!value) return '-';
|
|
try {
|
|
const date = parseConsoleDate(value);
|
|
if (!date) return String(value);
|
|
return date.toLocaleString('zh-CN', { hour12: false, timeZone: 'Asia/Shanghai' });
|
|
} catch {
|
|
return String(value);
|
|
}
|
|
}
|
|
|
|
function relativeTime(value) {
|
|
if (!value) return '-';
|
|
const target = parseConsoleDate(value);
|
|
if (!target) return String(value);
|
|
if (Number.isNaN(target.getTime())) return String(value);
|
|
const diff = Date.now() - target.getTime();
|
|
const sec = Math.max(0, Math.floor(diff / 1000));
|
|
if (sec < 60) return `${sec}s 前`;
|
|
if (sec < 3600) return `${Math.floor(sec / 60)}m 前`;
|
|
if (sec < 86400) return `${Math.floor(sec / 3600)}h 前`;
|
|
return `${Math.floor(sec / 86400)}d 前`;
|
|
}
|
|
|
|
function statusClassFromAction(action) {
|
|
if (action === 'buy') return 'buy';
|
|
if (action === 'sell') return 'sell';
|
|
return 'wait';
|
|
}
|
|
|
|
function riskFillStyle(current, max) {
|
|
if (!max || max <= 0) return 0;
|
|
return Math.max(0, Math.min(100, (current / max) * 100));
|
|
}
|
|
|
|
function sumPlatformPositions(platforms) {
|
|
return ['paper', 'bitget']
|
|
.map((key) => platforms?.[key]?.positions?.count || 0)
|
|
.reduce((sum, count) => sum + count, 0);
|
|
}
|
|
|
|
function countHalted(platformHalts) {
|
|
return Object.values(platformHalts || {}).filter((item) => item && item.halted).length;
|
|
}
|
|
|
|
function setFeedback(message, isError = false) {
|
|
const el = document.getElementById('feedback');
|
|
if (!message) {
|
|
el.innerHTML = '';
|
|
return;
|
|
}
|
|
el.innerHTML = `<div class="${isError ? 'error-box' : 'empty-box'}" style="margin-bottom: 18px;">${message}</div>`;
|
|
}
|
|
|
|
function compactEmpty(title, detail = '') {
|
|
return `
|
|
<div class="empty-box compact">
|
|
<strong>${title}</strong>
|
|
${detail ? `<div class="empty-detail">${detail}</div>` : ''}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function showConsoleAuthOverlay(message = '') {
|
|
document.getElementById('consoleAuthOverlay').classList.remove('hidden');
|
|
document.getElementById('consoleAuthError').textContent = message;
|
|
}
|
|
|
|
function hideConsoleAuthOverlay() {
|
|
document.getElementById('consoleAuthOverlay').classList.add('hidden');
|
|
document.getElementById('consoleAuthError').textContent = '';
|
|
}
|
|
|
|
async function ensureConsoleSession() {
|
|
const response = await fetch('/api/auth/console-session');
|
|
if (!response.ok) {
|
|
consoleAuthenticated = false;
|
|
showConsoleAuthOverlay(response.status === 401 ? '请输入访问密码后继续。' : '总控台认证失败');
|
|
return false;
|
|
}
|
|
|
|
consoleAuthenticated = true;
|
|
hideConsoleAuthOverlay();
|
|
return true;
|
|
}
|
|
|
|
async function loginConsole() {
|
|
const input = document.getElementById('consolePasswordInput');
|
|
const password = input.value.trim();
|
|
if (!password) {
|
|
showConsoleAuthOverlay('请输入访问密码');
|
|
return;
|
|
}
|
|
|
|
const button = document.getElementById('consoleLoginBtn');
|
|
button.disabled = true;
|
|
button.textContent = '验证中...';
|
|
|
|
try {
|
|
const response = await fetch('/api/auth/console-login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ password })
|
|
});
|
|
const result = await response.json();
|
|
if (!response.ok || !result.success) {
|
|
throw new Error(result.detail || result.message || '访问密码错误');
|
|
}
|
|
|
|
input.value = '';
|
|
consoleAuthenticated = true;
|
|
hideConsoleAuthOverlay();
|
|
loadConsole();
|
|
} catch (error) {
|
|
showConsoleAuthOverlay(error.message || '登录失败');
|
|
} finally {
|
|
button.disabled = false;
|
|
button.textContent = '进入总控台';
|
|
}
|
|
}
|
|
|
|
function normalizeSeverity(severity) {
|
|
const value = String(severity || '').toLowerCase();
|
|
if (['danger', 'error', 'critical'].includes(value)) return 'danger';
|
|
if (['warning', 'warn', 'hold'].includes(value)) return 'warn';
|
|
return 'good';
|
|
}
|
|
|
|
function countRecentEvents(events, hours = 24, matcher = null) {
|
|
const cutoff = Date.now() - hours * 3600 * 1000;
|
|
return (events || []).filter((event) => {
|
|
const rawTime = event?.timestamp || event?.created_at || event?.opened_at;
|
|
const parsed = parseConsoleDate(rawTime);
|
|
const time = parsed ? parsed.getTime() : 0;
|
|
if (!time || Number.isNaN(time) || time < cutoff) return false;
|
|
return typeof matcher === 'function' ? matcher(event) : true;
|
|
}).length;
|
|
}
|
|
|
|
function inferOpsSnapshot(data) {
|
|
const signals = data.signals?.latest || [];
|
|
const events = data.execution_events || [];
|
|
const positions = data.management?.positions || [];
|
|
const orders = data.management?.orders || [];
|
|
const blocked24h = countRecentEvents(events, 24, (event) => event.event_type === 'execution_blocked_summary');
|
|
const success24h = countRecentEvents(events, 24, (event) => event.status === 'success');
|
|
const warn24h = countRecentEvents(events, 24, (event) => ['warning', 'error'].includes(event.status));
|
|
const recentSignal24h = countRecentEvents(signals, 24, () => true);
|
|
const entryOrders = orders.filter((order) => order.category === 'entry');
|
|
const protectionOrders = orders.filter((order) => order.category === 'tp_sl');
|
|
const completeProtectionCount = positions.filter((position) => position.take_profit && position.stop_loss).length;
|
|
const protectionCoverage = positions.length > 0 ? (completeProtectionCount / positions.length) * 100 : 100;
|
|
const conversionRate = recentSignal24h > 0 ? (success24h / recentSignal24h) * 100 : 0;
|
|
const staleEntryOrders = entryOrders.filter((order) => {
|
|
const created = order.created_at ? (parseConsoleDate(order.created_at)?.getTime() || 0) : 0;
|
|
return created && !Number.isNaN(created) && (Date.now() - created) > (30 * 60 * 1000);
|
|
}).length;
|
|
|
|
return {
|
|
recentSignal24h,
|
|
success24h,
|
|
warn24h,
|
|
blocked24h,
|
|
entryOrders,
|
|
protectionOrders,
|
|
protectionCoverage,
|
|
conversionRate,
|
|
staleEntryOrders,
|
|
positions,
|
|
};
|
|
}
|
|
|
|
function deriveTopPriorities(data) {
|
|
const cryptoAgent = data.crypto_agent || {};
|
|
const monitor = cryptoAgent.analysis_monitor || {};
|
|
const attentionItems = data.management?.attention_items || [];
|
|
const events = data.execution_events || [];
|
|
const positions = data.management?.positions || [];
|
|
const executionControls = cryptoAgent.target_execution_controls || {};
|
|
const haltedEntries = Object.entries(cryptoAgent.platform_halts || {}).filter(([, item]) => item?.halted);
|
|
const disabledTargets = Object.entries(executionControls).filter(([, item]) => item?.enabled === false);
|
|
const top = [];
|
|
|
|
if (!cryptoAgent.running || !monitor.last_heartbeat_at) {
|
|
top.push({
|
|
tone: 'danger',
|
|
title: !cryptoAgent.running ? 'Crypto Agent 已停止' : '分析心跳缺失',
|
|
detail: !cryptoAgent.running
|
|
? '信号层当前未运行,后续不会继续产出分析、信号或执行决策。'
|
|
: '当前没有最近分析心跳,说明调度、循环或主任务可能卡住。',
|
|
action: '优先核对进程状态、定时调度和主循环日志,确认系统是否仍在正常扫盘。',
|
|
badges: [
|
|
!cryptoAgent.running ? 'STOPPED' : 'NO HEARTBEAT',
|
|
monitor.last_heartbeat_at ? `上次 ${relativeTime(monitor.last_heartbeat_at)}` : '无时间戳',
|
|
],
|
|
});
|
|
}
|
|
|
|
if (haltedEntries.length > 0) {
|
|
const [targetKey, halt] = haltedEntries[0];
|
|
top.push({
|
|
tone: 'danger',
|
|
title: `${targetKey} 已停机`,
|
|
detail: halt?.reason || '平台已触发回撤熔断或风险停机。',
|
|
action: '检查触发原因、核对持仓是否已处理,然后决定是恢复自动执行还是保持停机。',
|
|
badges: [
|
|
`${haltedEntries.length} 个停机目标`,
|
|
halt?.halted_at ? formatTime(halt.halted_at) : '等待处理',
|
|
],
|
|
});
|
|
}
|
|
|
|
const latestBlocked = events.find((event) => event.event_type === 'execution_blocked_summary');
|
|
if (latestBlocked) {
|
|
top.push({
|
|
tone: latestBlocked.status === 'error' ? 'danger' : 'warn',
|
|
title: `${latestBlocked.symbol || '-'} 执行被阻塞`,
|
|
detail: latestBlocked.reason || '最近有信号未能落到执行层。',
|
|
action: '切到风险标签核对平台级归因,确认是价格未到、风控拦截、余额问题还是执行关闭。',
|
|
badges: [
|
|
latestBlocked.platform || 'execution',
|
|
latestBlocked.signal_timeframe_text || latestBlocked.setup_type || 'blocked',
|
|
],
|
|
});
|
|
}
|
|
|
|
const unprotected = positions.filter((item) => !item.take_profit || !item.stop_loss);
|
|
if (unprotected.length > 0) {
|
|
const sample = unprotected[0];
|
|
top.push({
|
|
tone: 'warn',
|
|
title: `${sample.symbol || '-'} 风控保护不完整`,
|
|
detail: `当前有 ${unprotected.length} 个持仓缺少止盈或止损,保护单链路可能存在异常。`,
|
|
action: '优先检查执行监管器和交易所保护单状态,确认 TP/SL 是否漏挂、失败或已失效。',
|
|
badges: [
|
|
`${unprotected.length} 个异常持仓`,
|
|
`${sample.platform || '-'} / ${sample.account_id || '-'}`,
|
|
],
|
|
});
|
|
}
|
|
|
|
if (disabledTargets.length > 0) {
|
|
const [targetKey] = disabledTargets[0];
|
|
top.push({
|
|
tone: 'warn',
|
|
title: `${targetKey} 自动交易已关闭`,
|
|
detail: `当前共有 ${disabledTargets.length} 个执行目标处于手动关闭状态。`,
|
|
action: '确认这是有意停用还是遗留配置。如果应继续执行,需要在控制台重新开启。',
|
|
badges: [`${disabledTargets.length} OFF`, 'manual control'],
|
|
});
|
|
}
|
|
|
|
attentionItems.slice(0, 2).forEach((item) => {
|
|
top.push({
|
|
tone: normalizeSeverity(item.severity),
|
|
title: item.title || '待处理事项',
|
|
detail: item.detail || '请检查对应模块状态。',
|
|
action: '根据事项详情进入风险、运行或平台区域继续下钻。',
|
|
badges: [item.timestamp ? relativeTime(item.timestamp) : 'now'],
|
|
});
|
|
});
|
|
|
|
if (!top.length) {
|
|
top.push({
|
|
tone: 'good',
|
|
title: '当前没有紧急人工接管项',
|
|
detail: '系统没有明显的停机、熔断、执行阻塞或保护单异常。',
|
|
action: '重点关注新的信号生命周期和运营指标,确认系统仍有稳定分析与执行产出。',
|
|
badges: ['CLEAR'],
|
|
});
|
|
}
|
|
|
|
return top.slice(0, 4);
|
|
}
|
|
|
|
function eventPlatformMatches(event, platform) {
|
|
const eventPlatform = String(event?.platform || '').toLowerCase();
|
|
if (platform === 'paper') {
|
|
return eventPlatform.includes('paper');
|
|
}
|
|
if (platform === 'bitget') {
|
|
return eventPlatform.includes('bitget');
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function findMatchingEvent(signal, laneKeyword, events, platform) {
|
|
const signalTime = signal?.created_at ? (parseConsoleDate(signal.created_at)?.getTime() || 0) : 0;
|
|
const symbol = signal?.symbol;
|
|
const candidates = (events || []).filter((event) => {
|
|
if (event.symbol !== symbol) return false;
|
|
if (!eventPlatformMatches(event, platform)) return false;
|
|
if (laneKeyword && event.signal_timeframe_text && !String(event.signal_timeframe_text).includes(laneKeyword)) return false;
|
|
const eventTime = event?.timestamp ? (parseConsoleDate(event.timestamp)?.getTime() || 0) : 0;
|
|
if (!signalTime || !eventTime || Number.isNaN(signalTime) || Number.isNaN(eventTime)) return true;
|
|
return Math.abs(eventTime - signalTime) <= 12 * 3600 * 1000;
|
|
});
|
|
return candidates.sort((a, b) => {
|
|
const bt = parseConsoleDate(b.timestamp)?.getTime() || 0;
|
|
const at = parseConsoleDate(a.timestamp)?.getTime() || 0;
|
|
return bt - at;
|
|
})[0] || null;
|
|
}
|
|
|
|
function findMatchingPosition(signal, platform, positions) {
|
|
return (positions || []).find((position) => {
|
|
if (position.symbol !== signal.symbol) return false;
|
|
return String(position.platform || '').toLowerCase() === String(platform || '').toLowerCase();
|
|
}) || null;
|
|
}
|
|
|
|
function findMatchingOrder(signal, platform, orders) {
|
|
return (orders || []).find((order) => {
|
|
if (order.symbol !== signal.symbol) return false;
|
|
if (String(order.platform || '').toLowerCase() !== String(platform || '').toLowerCase()) return false;
|
|
return order.category === 'entry';
|
|
}) || null;
|
|
}
|
|
|
|
function buildLifecycleLane(signal, laneName, platform, previewDecision, events, positions, orders) {
|
|
const position = findMatchingPosition(signal, platform, positions);
|
|
const order = findMatchingOrder(signal, platform, orders);
|
|
const event = findMatchingEvent(signal, signal.timeframe || signal.type || '', events, platform);
|
|
let tone = 'warn';
|
|
let state = 'pending';
|
|
const detailRows = [];
|
|
|
|
if (previewDecision?.decision) {
|
|
detailRows.push(`决策: ${previewDecision.decision}`);
|
|
}
|
|
|
|
if (position) {
|
|
tone = (!position.take_profit || !position.stop_loss) ? 'warn' : 'good';
|
|
state = 'position';
|
|
detailRows.push(`已持仓 ${position.side || '-'} / ${formatNumber(position.size || 0, 4)} / ${formatNumber(position.leverage || 0, 1)}x`);
|
|
detailRows.push(`入场 ${formatMoney(position.entry_price)} / 现价 ${formatMoney(position.mark_price)}`);
|
|
detailRows.push(`TP ${position.take_profit ? formatMoney(position.take_profit) : '-'} / SL ${position.stop_loss ? formatMoney(position.stop_loss) : '-'}`);
|
|
} else if (order) {
|
|
tone = 'warn';
|
|
state = 'entry order';
|
|
detailRows.push(`挂单 ${order.order_type || '-'} / ${formatMoney(order.price)} / ${formatNumber(order.size || 0, 4)}`);
|
|
detailRows.push(`创建 ${order.created_at ? `${relativeTime(order.created_at)} / ${formatTime(order.created_at)}` : '-'}`);
|
|
} else if (event) {
|
|
tone = event.status === 'success' ? 'good' : (event.status === 'error' ? 'danger' : 'warn');
|
|
state = event.status || event.event_type || 'event';
|
|
detailRows.push(event.reason || '已有执行事件,但未找到持仓或挂单。');
|
|
if (event.action || event.event_type) {
|
|
detailRows.push(`动作 ${event.action || '-'} / 事件 ${event.event_type || '-'}`);
|
|
}
|
|
} else if (previewDecision?.decision) {
|
|
tone = ['OPEN', 'ADD', 'CLOSE', 'CANCEL_PENDING'].includes(previewDecision.decision) ? 'warn' : 'good';
|
|
state = previewDecision.decision.toLowerCase();
|
|
detailRows.push(previewDecision.reason || previewDecision.reasoning || '已有决策预览,但尚未找到执行落地迹象。');
|
|
} else {
|
|
tone = 'good';
|
|
state = 'idle';
|
|
detailRows.push('当前没有匹配到执行结果、挂单或持仓。');
|
|
}
|
|
|
|
return {
|
|
laneName,
|
|
tone,
|
|
state,
|
|
detailRows,
|
|
};
|
|
}
|
|
|
|
function loadSensitivePreference() {
|
|
try {
|
|
revealSensitiveData = window.localStorage.getItem(SENSITIVE_VISIBILITY_KEY) === '1';
|
|
} catch {
|
|
revealSensitiveData = false;
|
|
}
|
|
renderSensitiveToggle();
|
|
}
|
|
|
|
function renderSensitiveToggle() {
|
|
const button = document.getElementById('toggleSensitiveBtn');
|
|
if (!button) return;
|
|
button.textContent = `敏感数据: ${revealSensitiveData ? '显示' : '隐藏'}`;
|
|
button.classList.toggle('active', revealSensitiveData);
|
|
}
|
|
|
|
function toggleSensitiveData() {
|
|
revealSensitiveData = !revealSensitiveData;
|
|
try {
|
|
window.localStorage.setItem(SENSITIVE_VISIBILITY_KEY, revealSensitiveData ? '1' : '0');
|
|
} catch {
|
|
// ignore storage errors
|
|
}
|
|
renderSensitiveToggle();
|
|
if (cachedConsoleData) {
|
|
renderPlatforms(
|
|
cachedConsoleData.platforms,
|
|
cachedConsoleData.crypto_agent?.platform_halts,
|
|
cachedConsoleData.crypto_agent?.target_execution_controls || {}
|
|
);
|
|
renderFocusSummary(cachedConsoleData);
|
|
renderUnifiedPositions(cachedConsoleData.management?.positions || []);
|
|
renderUnifiedOrders(cachedConsoleData.management?.orders || []);
|
|
renderSignalLifecycle(cachedConsoleData);
|
|
}
|
|
}
|
|
|
|
function formatSensitiveMoney(value) {
|
|
return revealSensitiveData ? formatMoney(value) : '$••••';
|
|
}
|
|
|
|
function initHubNavigation() {
|
|
document.querySelectorAll('[data-hub-target]').forEach((button) => {
|
|
button.addEventListener('click', () => {
|
|
setActiveHub(button.getAttribute('data-hub-target'));
|
|
});
|
|
});
|
|
|
|
let preferredHub = 'hubOverview';
|
|
try {
|
|
preferredHub = window.localStorage.getItem(HUB_PREFERENCE_KEY) || 'hubOverview';
|
|
} catch {
|
|
preferredHub = 'hubOverview';
|
|
}
|
|
setActiveHub(preferredHub);
|
|
}
|
|
|
|
function setActiveHub(target) {
|
|
document.querySelectorAll('[data-hub-target]').forEach((button) => {
|
|
button.classList.toggle('active', button.getAttribute('data-hub-target') === target);
|
|
});
|
|
document.querySelectorAll('.hub-pane').forEach((pane) => {
|
|
pane.classList.toggle('active', pane.id === target);
|
|
});
|
|
try {
|
|
window.localStorage.setItem(HUB_PREFERENCE_KEY, target);
|
|
} catch {
|
|
// ignore storage errors
|
|
}
|
|
}
|
|
|
|
function updateSidebarBadge(id, value) {
|
|
const el = document.getElementById(id);
|
|
if (!el) return;
|
|
el.textContent = value;
|
|
}
|
|
|
|
function initTabs() {
|
|
document.querySelectorAll('[data-tab-group]').forEach((groupEl) => {
|
|
const group = groupEl.getAttribute('data-tab-group');
|
|
const buttons = groupEl.querySelectorAll('[data-tab]');
|
|
buttons.forEach((button) => {
|
|
button.addEventListener('click', () => {
|
|
const target = button.getAttribute('data-target');
|
|
setActiveTab(group, target);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function setActiveTab(group, target) {
|
|
document.querySelectorAll(`[data-tab="${group}"]`).forEach((button) => {
|
|
button.classList.toggle('active', button.getAttribute('data-target') === target);
|
|
});
|
|
document.querySelectorAll(`[data-tab-pane="${group}"]`).forEach((pane) => {
|
|
pane.classList.toggle('active', pane.id === target);
|
|
});
|
|
}
|
|
|
|
function getActiveTabTarget(group) {
|
|
return document.querySelector(`[data-tab="${group}"].active`)?.getAttribute('data-target') || null;
|
|
}
|
|
|
|
function updateTabButton(group, target, label, count, hasData) {
|
|
const button = document.querySelector(`[data-tab="${group}"][data-target="${target}"]`);
|
|
if (!button) return;
|
|
button.innerHTML = `${label}<span class="tab-count">${count}</span>`;
|
|
button.classList.toggle('muted', !hasData);
|
|
}
|
|
|
|
function renderExecutionMonitor(data) {
|
|
const container = document.getElementById('executionMonitorStrip');
|
|
if (!container) return;
|
|
|
|
const executionLoop = data.monitoring?.execution_loop || {};
|
|
const priceMonitor = data.monitoring?.price_monitor || {};
|
|
const latestPriceCount = Object.keys(priceMonitor.latest_prices || {}).length;
|
|
|
|
container.innerHTML = `
|
|
<div class="monitor-card">
|
|
<div class="title">价格链路</div>
|
|
<div class="main">${priceMonitor.running ? 'RUNNING' : 'STOPPED'} / ${(priceMonitor.mode || 'unknown').toUpperCase()}</div>
|
|
<div class="meta">
|
|
订阅标的 ${(priceMonitor.subscribed_symbols || []).length || 0} / 缓存价格 ${latestPriceCount}<br>
|
|
最近刷新 ${priceMonitor.checked_at ? formatTime(priceMonitor.checked_at) : '-'}
|
|
</div>
|
|
</div>
|
|
<div class="monitor-card">
|
|
<div class="title">模拟盘执行链路</div>
|
|
<div class="main">${executionLoop.running ? 'RUNNING' : 'STOPPED'}</div>
|
|
<div class="meta">
|
|
心跳 ${executionLoop.last_heartbeat_at ? `${relativeTime(executionLoop.last_heartbeat_at)} / ${formatTime(executionLoop.last_heartbeat_at)}` : '-'}<br>
|
|
活跃订单 ${executionLoop.active_orders ?? 0} / 标的 ${Array.isArray(executionLoop.last_symbols) && executionLoop.last_symbols.length ? executionLoop.last_symbols.join(', ') : '-'}<br>
|
|
错误 ${executionLoop.last_error || '无'}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderFocusSummary(data) {
|
|
const container = document.getElementById('focusSummary');
|
|
if (!container) return;
|
|
|
|
const cryptoAgent = data.crypto_agent || {};
|
|
const monitor = cryptoAgent.analysis_monitor || {};
|
|
const management = data.management || {};
|
|
const positions = management.positions || [];
|
|
const orders = management.orders || [];
|
|
const attentionItems = management.attention_items || [];
|
|
const haltedCount = countHalted(cryptoAgent.platform_halts || {});
|
|
const latestSignals = data.signals?.latest || [];
|
|
const funnel = cryptoAgent.analysis_funnel_stats || {};
|
|
const heartbeatHeadline = monitor.last_heartbeat_at ? relativeTime(monitor.last_heartbeat_at) : '无心跳';
|
|
const heartbeatTone = toneClassForHealth(cryptoAgent.running ? (monitor.last_cycle_status || monitor.last_analysis_status) : 'stopped');
|
|
const exposureNotional = positions.reduce((sum, item) => {
|
|
const margin = Number(item.margin || 0);
|
|
const leverage = Math.max(Number(item.leverage || 0), 1);
|
|
return sum + margin * leverage;
|
|
}, 0);
|
|
const riskTone = haltedCount > 0 ? 'danger' : (positions.length > 0 ? 'warn' : 'good');
|
|
const signalTone = latestSignals.length > 0 ? 'good' : 'warn';
|
|
const orderTone = orders.length > 0 ? 'warn' : 'good';
|
|
|
|
container.innerHTML = `
|
|
<div class="focus-summary-card">
|
|
<div class="kicker">运行心跳</div>
|
|
<div class="headline ${heartbeatTone}">${heartbeatHeadline}</div>
|
|
<div class="detail">状态 ${String(monitor.last_cycle_status || monitor.last_analysis_status || 'idle').toUpperCase()} / 下次 ${monitor.next_scheduled_run_at ? formatTime(monitor.next_scheduled_run_at) : '-'}</div>
|
|
</div>
|
|
<div class="focus-summary-card">
|
|
<div class="kicker">最近信号</div>
|
|
<div class="headline ${signalTone}">${latestSignals.length}</div>
|
|
<div class="detail">有效轮次 ${funnel.valid_signal_runs || 0} / 无信号 ${funnel.no_trade_signal_runs || 0}</div>
|
|
</div>
|
|
<div class="focus-summary-card">
|
|
<div class="kicker">持仓暴露</div>
|
|
<div class="headline ${riskTone}">${positions.length} 仓</div>
|
|
<div class="detail">名义暴露 ${formatSensitiveMoney(exposureNotional)} / 停机 ${haltedCount}</div>
|
|
</div>
|
|
<div class="focus-summary-card">
|
|
<div class="kicker">挂单与异常</div>
|
|
<div class="headline ${orderTone}">${orders.length}</div>
|
|
<div class="detail">挂单 ${orders.length} / 待处理 ${attentionItems.length}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderAlertStrip(data) {
|
|
const container = document.getElementById('alertStrip');
|
|
if (!container) return;
|
|
|
|
const cryptoAgent = data.crypto_agent || {};
|
|
const monitor = cryptoAgent.analysis_monitor || {};
|
|
const attentionItems = data.management?.attention_items || [];
|
|
const haltedCount = countHalted(cryptoAgent.platform_halts || {});
|
|
const disabledCount = Object.values(cryptoAgent.target_execution_controls || {}).filter((item) => item && item.enabled === false).length;
|
|
const blockedEvents = (data.execution_events || []).filter((event) => event.event_type === 'execution_blocked_summary');
|
|
const banners = [];
|
|
|
|
if (!cryptoAgent.running || !monitor.last_heartbeat_at) {
|
|
banners.push({
|
|
tone: 'danger',
|
|
kicker: 'System Halt',
|
|
detail: !cryptoAgent.running ? 'Crypto Agent 当前未运行。' : '尚未收到最近心跳,请优先确认调度与任务循环。',
|
|
count: !cryptoAgent.running ? 'STOP' : 'NO HB',
|
|
});
|
|
}
|
|
|
|
if (haltedCount > 0) {
|
|
banners.push({
|
|
tone: 'danger',
|
|
kicker: 'Platform Halt',
|
|
detail: `当前有 ${haltedCount} 个执行目标处于停机/熔断状态,需要优先恢复或核查风控触发原因。`,
|
|
count: `${haltedCount} HALT`,
|
|
});
|
|
} else if (disabledCount > 0) {
|
|
banners.push({
|
|
tone: 'warn',
|
|
kicker: 'Manual Off',
|
|
detail: `当前有 ${disabledCount} 个执行目标被人工关闭自动交易。`,
|
|
count: `${disabledCount} OFF`,
|
|
});
|
|
}
|
|
|
|
if (blockedEvents.length > 0) {
|
|
banners.push({
|
|
tone: 'warn',
|
|
kicker: 'Execution Blocked',
|
|
detail: `最近存在 ${blockedEvents.length} 条执行阻塞汇总,建议切到“风险”标签查看平台级归因。`,
|
|
count: `${blockedEvents.length} BLOCK`,
|
|
});
|
|
}
|
|
|
|
if (!banners.length && attentionItems.length === 0) {
|
|
banners.push({
|
|
tone: 'good',
|
|
kicker: 'System Clear',
|
|
detail: '当前没有停机、阻塞或明显告警,系统处于可管理状态。',
|
|
count: 'CLEAR',
|
|
});
|
|
}
|
|
|
|
container.innerHTML = banners.slice(0, 3).map((banner) => `
|
|
<div class="alert-banner ${banner.tone}">
|
|
<div class="alert-kicker">${banner.kicker}</div>
|
|
<div class="alert-detail">${banner.detail}</div>
|
|
<div class="alert-count">${banner.count}</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function syncTabState(data) {
|
|
const recentSignals = data.signals?.latest || [];
|
|
const positions = data.management?.positions || [];
|
|
const orders = data.management?.orders || [];
|
|
|
|
updateSidebarBadge('sidebarBadgeOverview', String((data.management?.attention_items || []).length));
|
|
updateSidebarBadge('sidebarBadgeExecution', `${positions.length}/${orders.length}`);
|
|
updateSidebarBadge('sidebarBadgeSignals', String(recentSignals.length));
|
|
}
|
|
|
|
function toneClassForHealth(status) {
|
|
if (['error', 'failed', 'stopped'].includes(String(status || '').toLowerCase())) return 'danger';
|
|
if (['warning', 'halted', 'idle'].includes(String(status || '').toLowerCase())) return 'warn';
|
|
return 'good';
|
|
}
|
|
|
|
function formatBlockedReason(reasonKey) {
|
|
const key = String(reasonKey || '');
|
|
if (key === 'tradability_avoid') return '市场状态禁止交易';
|
|
if (key.startsWith('lane_blocked:')) {
|
|
const lane = key.split(':')[1] || '';
|
|
return lane === 'short_term' ? '日内 lane 被禁止' : lane === 'medium_term' ? '趋势 lane 被禁止' : `lane 被禁止: ${lane}`;
|
|
}
|
|
if (key.startsWith('setup_blocked:')) {
|
|
const setup = key.split(':')[1] || '';
|
|
const setupMap = {
|
|
range_reversal: '区间反转 setup 被禁止',
|
|
trend_continuation_pullback: '趋势回调延续 setup 被禁止',
|
|
deep_pullback_continuation: '深回踩延续 setup 被禁止',
|
|
trend_reversal: '趋势反转 setup 被禁止',
|
|
breakout_confirmation: '突破确认 setup 被禁止',
|
|
breakout_pullback: '突破回踩 setup 被禁止',
|
|
unknown: '未识别 setup 被禁止',
|
|
};
|
|
return setupMap[setup] || `setup 被禁止: ${setup}`;
|
|
}
|
|
return key || '-';
|
|
}
|
|
|
|
function renderHealthRibbon(data) {
|
|
const container = document.getElementById('healthRibbon');
|
|
const cryptoAgent = data.crypto_agent || {};
|
|
const monitor = cryptoAgent.analysis_monitor || {};
|
|
const attentionItems = data.management?.attention_items || [];
|
|
const dangerCount = attentionItems.filter((item) => item.severity === 'danger').length;
|
|
const warningCount = attentionItems.filter((item) => item.severity === 'warning').length;
|
|
const haltedCount = countHalted(cryptoAgent.platform_halts || {});
|
|
const cycleStatus = cryptoAgent.running
|
|
? String(monitor.last_cycle_status || 'waiting').toUpperCase()
|
|
: 'STOPPED';
|
|
const cycleTone = toneClassForHealth(cryptoAgent.running ? monitor.last_cycle_status : 'stopped');
|
|
const attentionTone = attentionItems.length === 0
|
|
? 'good'
|
|
: dangerCount > 0 || haltedCount > 0
|
|
? 'danger'
|
|
: 'warn';
|
|
const analysisTone = toneClassForHealth(monitor.last_analysis_status || 'idle');
|
|
const lastCycleText = monitor.last_cycle_completed_at
|
|
? `${relativeTime(monitor.last_cycle_completed_at)}`
|
|
: '尚无完成轮次';
|
|
const nextRunText = monitor.next_scheduled_run_at
|
|
? formatTime(monitor.next_scheduled_run_at)
|
|
: (monitor.current_cycle_total ? '轮次进行中' : '-');
|
|
|
|
container.innerHTML = `
|
|
<div class="health-card">
|
|
<div class="kicker">分析状态</div>
|
|
<div class="headline ${cycleTone}">${cycleStatus}</div>
|
|
<div class="detail">最近分析: ${(monitor.last_analysis_symbol || '-')} / ${(monitor.last_analysis_status || '-')}</div>
|
|
</div>
|
|
<div class="health-card">
|
|
<div class="kicker">最近轮次</div>
|
|
<div class="headline ${analysisTone}">${lastCycleText}</div>
|
|
<div class="detail">下一次运行: ${nextRunText}</div>
|
|
</div>
|
|
<div class="health-card">
|
|
<div class="kicker">人工处理</div>
|
|
<div class="headline ${attentionTone}">${attentionItems.length} 项</div>
|
|
<div class="detail">danger ${dangerCount} / warning ${warningCount} / halted ${haltedCount}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderHero(data) {
|
|
const heroMetrics = document.getElementById('heroMetrics');
|
|
const system = data.system || {};
|
|
const signals = data.signals || {};
|
|
const platforms = data.platforms || {};
|
|
const platformHalts = data.crypto_agent?.platform_halts || {};
|
|
const executionControls = data.crypto_agent?.target_execution_controls || {};
|
|
const haltedCount = countHalted(platformHalts);
|
|
const disabledCount = Object.values(executionControls).filter((item) => item && item.enabled === false).length;
|
|
|
|
heroMetrics.innerHTML = `
|
|
<div class="hero-metric">
|
|
<div class="metric-label">运行 Agent</div>
|
|
<div class="metric-value ${system.error_agents > 0 ? 'danger' : 'good'}">${system.running_agents || 0}/${system.total_agents || 0}</div>
|
|
<div class="metric-note">异常 ${system.error_agents || 0} 个</div>
|
|
</div>
|
|
<div class="hero-metric">
|
|
<div class="metric-label">30 分钟信号</div>
|
|
<div class="metric-value">${signals.recent_30m_count || 0}</div>
|
|
<div class="metric-note">最新分析窗口</div>
|
|
</div>
|
|
<div class="hero-metric">
|
|
<div class="metric-label">活跃持仓</div>
|
|
<div class="metric-value">${sumPlatformPositions(platforms)}</div>
|
|
<div class="metric-note">模拟盘 + Bitget 合计</div>
|
|
</div>
|
|
<div class="hero-metric">
|
|
<div class="metric-label">停机 / 关闭</div>
|
|
<div class="metric-value ${haltedCount > 0 ? 'danger' : 'good'}">${haltedCount}</div>
|
|
<div class="metric-note">停机 ${haltedCount} / 关闭 ${disabledCount}</div>
|
|
</div>
|
|
`;
|
|
|
|
const snapshot = document.getElementById('runSnapshot');
|
|
const cryptoAgent = data.crypto_agent || {};
|
|
snapshot.innerHTML = `
|
|
<div class="status-pill">
|
|
<span class="name">系统启动</span>
|
|
<span class="value">${formatTime(system.system_start_time)}</span>
|
|
</div>
|
|
<div class="status-pill ${cryptoAgent.running ? '' : 'halted'}">
|
|
<span class="name">Crypto Agent</span>
|
|
<span class="value">${cryptoAgent.running ? 'RUNNING' : 'STOPPED'}</span>
|
|
</div>
|
|
<div class="status-pill">
|
|
<span class="name">轮询模式</span>
|
|
<span class="value">${cryptoAgent.mode || '-'}</span>
|
|
</div>
|
|
<div class="status-pill">
|
|
<span class="name">监控标的</span>
|
|
<span class="value">${(cryptoAgent.symbols || []).length} symbols</span>
|
|
</div>
|
|
`;
|
|
|
|
document.getElementById('lastUpdated').textContent = `刷新时间 ${formatTime(data.generated_at)}`;
|
|
}
|
|
|
|
function renderPlatforms(platforms, platformHalts, executionControls) {
|
|
const container = document.getElementById('platformGrid');
|
|
const entries = [
|
|
{ key: 'paper', title: '模拟盘', subtitle: '执行基准 / 策略验证' },
|
|
{ key: 'bitget', title: 'Bitget', subtitle: 'U 本位实盘' },
|
|
];
|
|
|
|
container.innerHTML = entries.map(({ key, title, subtitle }) => {
|
|
const item = platforms?.[key] || { enabled: false };
|
|
const haltKey = key === 'paper'
|
|
? 'PaperTrading'
|
|
: (Object.keys(platformHalts || {}).find((targetKey) => targetKey.startsWith('Bitget:')) || 'Bitget');
|
|
const bitgetHaltActive = key === 'bitget'
|
|
? Object.entries(platformHalts || {}).some(([targetKey, haltItem]) => targetKey.startsWith('Bitget:') && haltItem?.halted)
|
|
: false;
|
|
const halt = platformHalts?.[haltKey] || {};
|
|
const platformExecutionControl = key === 'paper'
|
|
? (executionControls?.[haltKey] || {})
|
|
: null;
|
|
const enabled = item.enabled !== false;
|
|
const risk = item.risk || {};
|
|
const account = item.account || {};
|
|
const orders = item.orders || {};
|
|
const positions = item.positions || {};
|
|
const currentLeverage = risk.current_leverage || account.current_total_leverage || 0;
|
|
const maxLeverage = risk.max_leverage || account.max_total_leverage || 0;
|
|
const fill = riskFillStyle(currentLeverage, maxLeverage);
|
|
const accounts = Array.isArray(item.accounts) ? item.accounts : [];
|
|
const disabledBitgetCount = key === 'bitget'
|
|
? accounts.filter((accountItem) => executionControls?.[`Bitget:${accountItem.account_id}`]?.enabled === false).length
|
|
: 0;
|
|
const drawdownValue = risk.drawdown_percent || risk.drawdown || 0;
|
|
const thresholdValue = risk.circuit_breaker_threshold || 25;
|
|
const platformTone = (!enabled || halt.halted || bitgetHaltActive || drawdownValue >= thresholdValue)
|
|
? 'danger'
|
|
: ((key === 'paper' && platformExecutionControl?.enabled === false) || disabledBitgetCount > 0 || currentLeverage >= maxLeverage * 0.75)
|
|
? 'warn'
|
|
: '';
|
|
const accountRows = key === 'bitget' && accounts.length
|
|
? `
|
|
<details class="platform-detail-toggle">
|
|
<summary>查看账号明细</summary>
|
|
<div class="target-grid">
|
|
${accounts.slice(0, 4).map((accountItem) => {
|
|
const accountTargetKey = `Bitget:${accountItem.account_id}`;
|
|
const accountHalt = platformHalts?.[accountTargetKey] || {};
|
|
const accountControl = executionControls?.[accountTargetKey] || {};
|
|
const accountLeverage = accountItem.risk?.current_leverage || 0;
|
|
return `
|
|
<div class="target-mini-card ${accountHalt.halted ? 'halted' : (accountControl.enabled === false ? 'disabled' : '')}">
|
|
<div class="target-mini-head">
|
|
<div class="target-mini-title">${accountItem.account_id}</div>
|
|
<div class="target-mini-meta">${accountHalt.halted ? 'HALTED' : (accountControl.enabled === false ? 'DISABLED' : 'ACTIVE')}</div>
|
|
</div>
|
|
<div class="target-mini-stats">
|
|
<div class="target-mini-row"><span>权益</span><strong>${formatSensitiveMoney(accountItem.account?.account_value || 0)}</strong></div>
|
|
<div class="target-mini-row"><span>可用 / 保证金</span><strong>${formatSensitiveMoney(accountItem.account?.available_balance || 0)} / ${formatSensitiveMoney(accountItem.account?.total_margin_used || 0)}</strong></div>
|
|
<div class="target-mini-row"><span>持仓 / 挂单</span><strong>${accountItem.positions?.count || 0} / ${accountItem.orders?.count || 0}</strong></div>
|
|
<div class="target-mini-row"><span>总杠杆 / 回撤</span><strong>${formatNumber(accountLeverage, 2)}x / ${formatPercent(accountItem.risk?.drawdown_percent || 0, 1)}</strong></div>
|
|
<div class="target-mini-row"><span>自动交易</span><strong>${accountControl.enabled === false ? 'OFF' : 'ON'}</strong></div>
|
|
</div>
|
|
<div class="target-mini-actions">
|
|
<button class="mini-btn ${accountControl.enabled === false ? 'good' : 'warn'}" data-toggle-target="${accountTargetKey}" data-enabled="${accountControl.enabled === false ? 'true' : 'false'}">
|
|
${accountControl.enabled === false ? '开启自动交易' : '关闭自动交易'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('')}
|
|
</div>
|
|
</details>
|
|
`
|
|
: '';
|
|
|
|
return `
|
|
<article class="platform-card ${platformTone}">
|
|
<div class="platform-top">
|
|
<div>
|
|
<div class="panel-sub">${subtitle}</div>
|
|
<h3 class="platform-title">${title}</h3>
|
|
</div>
|
|
<div class="platform-state">${!enabled ? 'DISABLED' : (halt.halted || bitgetHaltActive) ? 'HALTED' : ((key === 'paper' && platformExecutionControl?.enabled === false) ? 'MANUAL OFF' : 'ONLINE')}</div>
|
|
</div>
|
|
<div class="platform-overview">
|
|
<div class="platform-pill-stat">
|
|
<span class="label">权益</span>
|
|
<span class="value">${formatSensitiveMoney(account.current_balance || account.account_value)}</span>
|
|
</div>
|
|
<div class="platform-pill-stat">
|
|
<span class="label">可用 / 保证金</span>
|
|
<span class="value">${formatSensitiveMoney(account.available || account.available_balance)} / ${formatSensitiveMoney(account.used_margin || account.total_margin_used)}</span>
|
|
</div>
|
|
<div class="platform-pill-stat">
|
|
<span class="label">持仓 / 挂单</span>
|
|
<span class="value">${positions.count || 0} / ${orders.count || 0}</span>
|
|
</div>
|
|
<div class="platform-pill-stat">
|
|
<span class="label">自动交易</span>
|
|
<span class="value">${key === 'paper' ? (platformExecutionControl?.enabled === false ? 'OFF' : 'ON') : `${accounts.length - disabledBitgetCount}/${accounts.length} ON`}</span>
|
|
</div>
|
|
</div>
|
|
<div class="risk-band"><div class="risk-fill" style="width:${fill}%"></div></div>
|
|
<div class="platform-stats">
|
|
<div class="platform-stat">
|
|
<span class="label">开仓单 / TP-SL</span>
|
|
<span class="value">${orders.entry_orders || orders.pending_count || 0} / ${orders.tp_sl_orders || 0}</span>
|
|
</div>
|
|
<div class="platform-stat">
|
|
<span class="label">总杠杆</span>
|
|
<span class="value">${formatNumber(currentLeverage, 2)}x / ${formatNumber(maxLeverage, 2)}x</span>
|
|
</div>
|
|
<div class="platform-stat">
|
|
<span class="label">回撤</span>
|
|
<span class="value" style="color:${drawdownValue >= thresholdValue ? 'var(--danger)' : 'var(--text)'};">
|
|
${formatPercent(drawdownValue)}
|
|
</span>
|
|
</div>
|
|
<div class="platform-stat">
|
|
<span class="label">风险阈值</span>
|
|
<span class="value">${formatPercent(thresholdValue, 1)}</span>
|
|
</div>
|
|
<div class="platform-stat">
|
|
<span class="label">账号数量</span>
|
|
<span class="value">${key === 'bitget' ? accounts.length : 1}</span>
|
|
</div>
|
|
</div>
|
|
${key === 'paper' ? `
|
|
<div class="halt-actions" style="margin-top: 14px; justify-content: flex-start;">
|
|
<button class="mini-btn ${platformExecutionControl?.enabled === false ? 'good' : 'warn'}" data-toggle-target="${haltKey}" data-enabled="${platformExecutionControl?.enabled === false ? 'true' : 'false'}">
|
|
${platformExecutionControl?.enabled === false ? '开启自动交易' : '关闭自动交易'}
|
|
</button>
|
|
</div>
|
|
` : ''}
|
|
${accountRows}
|
|
</article>
|
|
`;
|
|
}).join('');
|
|
|
|
container.querySelectorAll('[data-toggle-target]').forEach((button) => {
|
|
button.addEventListener('click', async () => {
|
|
const targetKey = button.getAttribute('data-toggle-target');
|
|
const enabled = button.getAttribute('data-enabled') === 'true';
|
|
await updateExecutionControl(targetKey, enabled, button);
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderSignalLifecycle(data) {
|
|
const container = document.getElementById('signalLifecycle');
|
|
if (!container) return;
|
|
|
|
const signals = data.signals?.latest || [];
|
|
const events = data.execution_events || [];
|
|
const positions = data.management?.positions || [];
|
|
const orders = data.management?.orders || [];
|
|
const previewMap = data.crypto_agent?.last_execution_preview || {};
|
|
|
|
if (!signals.length) {
|
|
container.innerHTML = compactEmpty('暂无可追踪的信号生命周期', '当前没有最近信号,先关注运行心跳和分析状态。');
|
|
return;
|
|
}
|
|
|
|
const cards = signals.slice(0, 5).map((signal) => {
|
|
const preview = previewMap?.[signal.symbol] || {};
|
|
const paperLane = buildLifecycleLane(signal, '模拟盘', 'paper', preview.paper, events, positions, orders);
|
|
const bitgetPreview = preview.bitget_accounts?.default || preview.bitget;
|
|
const bitgetLane = buildLifecycleLane(signal, 'Bitget', 'bitget', bitgetPreview, events, positions, orders);
|
|
const tone = statusClassFromAction(signal.action);
|
|
const summary = signal.reasoning || signal.reason || signal.summary || '暂无额外说明';
|
|
|
|
return `
|
|
<article class="lifecycle-card ${tone}">
|
|
<div class="lifecycle-head">
|
|
<div>
|
|
<div class="lifecycle-title">${signal.symbol || '-'}</div>
|
|
<div class="lifecycle-meta">
|
|
${relativeTime(signal.created_at)} / ${formatTime(signal.created_at)}<br>
|
|
${signal.signal_type || '-'} / ${signal.timeframe || signal.type || '-'} / ${signal.grade || '-'} / ${formatPercent(signal.confidence || 0, 1)}
|
|
</div>
|
|
</div>
|
|
<span class="badge ${tone}">${signal.action === 'buy' ? '做多' : signal.action === 'sell' ? '做空' : '观望'}</span>
|
|
</div>
|
|
<div class="lifecycle-summary">
|
|
入场 ${formatMoney(signal.entry_price)} / 现价 ${formatMoney(signal.current_price)} / TP ${formatMoney(signal.take_profit)} / SL ${formatMoney(signal.stop_loss)}<br>
|
|
${summary}
|
|
</div>
|
|
<div class="lifecycle-lanes">
|
|
${[paperLane, bitgetLane].map((lane) => `
|
|
<div class="lifecycle-lane ${lane.tone}">
|
|
<div class="lifecycle-lane-head">
|
|
<div class="lifecycle-lane-title">${lane.laneName}</div>
|
|
<div class="lifecycle-lane-state">${lane.state}</div>
|
|
</div>
|
|
<div class="lifecycle-lane-detail">
|
|
${lane.detailRows.map((row) => `<div>${row}</div>`).join('')}
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</article>
|
|
`;
|
|
});
|
|
|
|
container.innerHTML = cards.join('');
|
|
}
|
|
|
|
function renderSignalStream(signals) {
|
|
const container = document.getElementById('signalStream');
|
|
if (!signals || signals.length === 0) {
|
|
container.innerHTML = compactEmpty('最近没有可展示信号', '等待新的分析结果写入,运行状态可继续看上方心跳。');
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = `
|
|
<div class="signal-feed-grid">
|
|
${signals.slice(0, 5).map((signal) => `
|
|
<article class="signal-feed-card ${signal.action || 'wait'}">
|
|
<div class="signal-feed-head">
|
|
<div>
|
|
<div class="signal-feed-symbol">${signal.symbol || '-'}</div>
|
|
<div class="signal-feed-meta">${relativeTime(signal.created_at)} / ${formatTime(signal.created_at)}</div>
|
|
</div>
|
|
<span class="badge ${statusClassFromAction(signal.action)}">${signal.action === 'buy' ? '做多' : signal.action === 'sell' ? '做空' : '观望'}</span>
|
|
</div>
|
|
<div class="signal-feed-tags">
|
|
<span class="event-inline-badge">${signal.signal_type || '-'}</span>
|
|
<span class="event-inline-badge">${signal.timeframe || signal.type || '-'}</span>
|
|
${signal.grade ? `<span class="event-inline-badge">${signal.grade}</span>` : ''}
|
|
${signal.setup_type ? `<span class="event-inline-badge">${signal.setup_type}</span>` : ''}
|
|
</div>
|
|
<div class="signal-feed-stats">
|
|
<div class="signal-feed-stat">
|
|
<span class="label">信心度</span>
|
|
<span class="value">${formatPercent(signal.confidence || 0, 1)}</span>
|
|
</div>
|
|
<div class="signal-feed-stat">
|
|
<span class="label">价格</span>
|
|
<span class="value">入场 ${formatMoney(signal.entry_price)} / 现价 ${formatMoney(signal.current_price)}</span>
|
|
</div>
|
|
<div class="signal-feed-stat">
|
|
<span class="label">止盈止损</span>
|
|
<span class="value">${formatMoney(signal.take_profit)} / ${formatMoney(signal.stop_loss)}</span>
|
|
</div>
|
|
<div class="signal-feed-stat">
|
|
<span class="label">仓位 / 入场方式</span>
|
|
<span class="value">${signal.position_size || '-'} / ${signal.entry_type || '-'}</span>
|
|
</div>
|
|
</div>
|
|
<div class="signal-feed-reason">${signal.reasoning || signal.reason || signal.summary || '暂无详细分析理由'}</div>
|
|
</article>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderAnalysisHeartbeat(analysisMonitor, analysisEvents) {
|
|
const heartbeat = document.getElementById('analysisHeartbeat');
|
|
const summaryCard = document.getElementById('runtimeSummaryCard');
|
|
const monitor = analysisMonitor || {};
|
|
const notifications = cachedConsoleData?.crypto_agent?.analysis_notifications || {};
|
|
const schedule = cachedConsoleData?.crypto_agent?.llm_schedule || {};
|
|
const funnel = cachedConsoleData?.crypto_agent?.analysis_funnel_stats || {};
|
|
const funnel24h = cachedConsoleData?.crypto_agent?.analysis_funnel_24h || {};
|
|
const cycleStatus = monitor.last_cycle_status || 'idle';
|
|
const lastSignalAt = notifications.last_signal_at;
|
|
const lastSignalSymbol = notifications.last_signal_symbol || '-';
|
|
const topSymbols = Object.entries(funnel24h.symbols || {}).slice(0, 3);
|
|
const topBlockedReasons = Object.entries(funnel24h.blocked_reason_counts || {}).slice(0, 3);
|
|
const laneSignalCounts = funnel24h.lane_signal_counts || {};
|
|
|
|
if (!heartbeat || !summaryCard) return;
|
|
|
|
heartbeat.innerHTML = `
|
|
<div class="heartbeat-card">
|
|
<span class="label">最近心跳</span>
|
|
<span class="value">${monitor.last_heartbeat_at ? `${relativeTime(monitor.last_heartbeat_at)} / ${formatTime(monitor.last_heartbeat_at)}` : '-'}</span>
|
|
</div>
|
|
<div class="heartbeat-card">
|
|
<span class="label">最近分析</span>
|
|
<span class="value">${monitor.last_analysis_symbol || '-'} / ${monitor.last_analysis_status || '-'}</span>
|
|
</div>
|
|
<div class="heartbeat-card">
|
|
<span class="label">下一次运行</span>
|
|
<span class="value">${monitor.next_scheduled_run_at ? formatTime(monitor.next_scheduled_run_at) : '-'}</span>
|
|
</div>
|
|
<div class="heartbeat-card">
|
|
<span class="label">最近信号</span>
|
|
<span class="value">${lastSignalAt ? `${lastSignalSymbol} / ${relativeTime(lastSignalAt)}` : '暂无'}</span>
|
|
</div>
|
|
`;
|
|
|
|
summaryCard.innerHTML = `
|
|
<div class="runtime-summary-title">运行摘要</div>
|
|
<div class="runtime-summary-main">${cycleStatus.toUpperCase()} / ${monitor.last_analysis_symbol || '-'}</div>
|
|
<div class="runtime-summary-meta">
|
|
<div class="runtime-summary-row"><span>最近分析说明</span><strong>${monitor.last_analysis_detail || '-'}</strong></div>
|
|
<div class="runtime-summary-row"><span>最近完成轮次</span><strong>${monitor.last_cycle_completed_at ? `${relativeTime(monitor.last_cycle_completed_at)} / ${formatTime(monitor.last_cycle_completed_at)}` : '暂无'}</strong></div>
|
|
<div class="runtime-summary-row"><span>日内 / 趋势冷却</span><strong>${schedule.intraday_cooldown_minutes ?? '-'}m / ${schedule.trend_cooldown_minutes ?? '-'}m</strong></div>
|
|
<div class="runtime-summary-row"><span>日内 / 趋势阈值</span><strong>${schedule.intraday_signal_threshold || '-'}% / ${schedule.trend_signal_threshold || '-'}%</strong></div>
|
|
<div class="runtime-summary-row"><span>事件分析</span><strong>${schedule.event_analysis_enabled ? '开启' : '关闭'}</strong></div>
|
|
<div class="runtime-summary-row"><span>触发 / LLM</span><strong>${funnel.total_triggers || 0} / ${funnel.llm_analyses || 0}</strong></div>
|
|
<div class="runtime-summary-row"><span>波动率跳过 / 数据异常</span><strong>${funnel.volatility_skips || 0} / ${funnel.data_invalid_skips || 0}</strong></div>
|
|
<div class="runtime-summary-row"><span>硬规则过滤 / 阈值过滤</span><strong>${funnel.regime_filtered_out || 0} / ${funnel.threshold_filtered_runs || 0}</strong></div>
|
|
<div class="runtime-summary-row"><span>24h 触发 / LLM</span><strong>${funnel24h.triggered_symbols || 0} / ${funnel24h.llm_runs || 0}</strong></div>
|
|
<div class="runtime-summary-row"><span>24h 日内 / 趋势</span><strong>${funnel24h.lane_calls?.intraday || 0} / ${funnel24h.lane_calls?.trend || 0}</strong></div>
|
|
<div class="runtime-summary-row"><span>24h 日内前后</span><strong>${laneSignalCounts.short_term_pre || 0} / ${laneSignalCounts.short_term_post || 0}</strong></div>
|
|
<div class="runtime-summary-row"><span>24h 趋势前后</span><strong>${laneSignalCounts.medium_term_pre || 0} / ${laneSignalCounts.medium_term_post || 0}</strong></div>
|
|
</div>
|
|
<div class="lane-state-list">
|
|
${topSymbols.length ? topSymbols.map(([symbol, stats]) => `
|
|
<div class="lane-state-item">
|
|
<div class="lane-state-symbol">${symbol}</div>
|
|
<div class="lane-state-detail">
|
|
24h 触发 ${stats.triggers || 0} / LLM ${stats.llm_runs || 0} / 有效 ${stats.valid_signal_runs || 0}
|
|
<br>日内前后 ${stats.lane_signal_counts?.short_term_pre || 0}/${stats.lane_signal_counts?.short_term_post || 0} / 趋势前后 ${stats.lane_signal_counts?.medium_term_pre || 0}/${stats.lane_signal_counts?.medium_term_post || 0}
|
|
<br>波动率跳过 ${stats.volatility_skips || 0} / 阈值过滤 ${stats.threshold_filtered_runs || 0}
|
|
</div>
|
|
</div>
|
|
`).join('') : '<div class="analysis-log-detail">近 24 小时还没有形成 symbol 级分析统计。</div>'}
|
|
</div>
|
|
<div class="lane-state-list" style="margin-top: 10px;">
|
|
${topBlockedReasons.length ? topBlockedReasons.map(([reason, count]) => `
|
|
<div class="lane-state-item">
|
|
<div class="lane-state-symbol">${count}</div>
|
|
<div class="lane-state-detail">${formatBlockedReason(reason)}</div>
|
|
</div>
|
|
`).join('') : '<div class="analysis-log-detail">近 24 小时没有记录到明确的硬规则过滤原因。</div>'}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderHalts(platformHalts, executionControls) {
|
|
const container = document.getElementById('haltList');
|
|
const bitgetTargets = Object.keys(platformHalts || {})
|
|
.filter((key) => key.startsWith('Bitget:'))
|
|
.sort();
|
|
const entries = [
|
|
['PaperTrading', '模拟盘'],
|
|
...bitgetTargets.map((key) => [key, key]),
|
|
];
|
|
|
|
container.innerHTML = entries.map(([key, label]) => {
|
|
const halt = platformHalts?.[key] || {};
|
|
const active = !!halt.halted;
|
|
const control = executionControls?.[key] || {};
|
|
const disabled = control.enabled === false;
|
|
return `
|
|
<div class="halt-item ${active ? 'active' : (disabled ? 'disabled' : '')}">
|
|
<div class="halt-top">
|
|
<div class="halt-name">${label}</div>
|
|
<div class="halt-state">${active ? 'HALTED' : (disabled ? 'MANUAL OFF' : 'ACTIVE')}</div>
|
|
</div>
|
|
<div class="halt-reason">
|
|
${active
|
|
? `${halt.reason || '已触发平台停机'}<br>回撤: ${formatPercent(halt.drawdown_pct || 0)} | 触发时间: ${formatTime(halt.halted_at)}`
|
|
: disabled
|
|
? `${control.reason || '已被人工关闭自动交易'}<br>更新时间: ${formatTime(control.updated_at)}`
|
|
: '运行正常,未触发平台停机或熔断。'}
|
|
</div>
|
|
<div class="halt-actions">
|
|
<button class="mini-btn ${disabled ? 'good' : 'warn'}" data-toggle-target="${key}" data-enabled="${disabled ? 'true' : 'false'}">
|
|
${disabled ? '开启自动交易' : '关闭自动交易'}
|
|
</button>
|
|
${active ? `<button class="mini-btn" data-platform="${key}" data-target-key="${key}">恢复执行</button>` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
container.querySelectorAll('[data-platform]').forEach((button) => {
|
|
button.addEventListener('click', async () => {
|
|
const platform = button.getAttribute('data-platform');
|
|
const targetKey = button.getAttribute('data-target-key');
|
|
await resumePlatform(platform, button, targetKey);
|
|
});
|
|
});
|
|
|
|
container.querySelectorAll('[data-toggle-target]').forEach((button) => {
|
|
button.addEventListener('click', async () => {
|
|
const targetKey = button.getAttribute('data-toggle-target');
|
|
const enabled = button.getAttribute('data-enabled') === 'true';
|
|
await updateExecutionControl(targetKey, enabled, button);
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderAttentionItems(items) {
|
|
const container = document.getElementById('attentionList');
|
|
if (!items || items.length === 0) {
|
|
container.innerHTML = compactEmpty('当前没有需要人工处理的事项', '系统暂无明显风险、停机或待干预问题。');
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = items.map((item) => `
|
|
<div class="attention-item ${item.severity || 'info'}">
|
|
<div class="attention-title">
|
|
<strong>${item.title || '-'}</strong>
|
|
<span class="attention-time">${item.timestamp ? relativeTime(item.timestamp) : 'now'}</span>
|
|
</div>
|
|
<div class="attention-detail">${item.detail || '-'}</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function renderUnifiedPositions(positions) {
|
|
const toolbar = document.getElementById('positionsToolbar');
|
|
const container = document.getElementById('positionsTable');
|
|
const total = positions || [];
|
|
const platformCounts = ['paper', 'bitget'].map((platform) => {
|
|
const count = total.filter((item) => item.platform === platform).length;
|
|
return `<span class="toolbar-chip">${platform}: ${count}</span>`;
|
|
}).join('');
|
|
toolbar.innerHTML = `${platformCounts}<span class="toolbar-chip">total: ${total.length}</span>`;
|
|
|
|
if (!total.length) {
|
|
container.innerHTML = compactEmpty('当前没有跨平台持仓', '没有活动持仓时,这里会保持紧凑,不再占用大块留白。');
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = `
|
|
<div class="position-card-grid">
|
|
${total.map((item) => `
|
|
<article class="position-card ${item.side === 'long' ? 'long' : 'short'}">
|
|
<div class="position-card-head">
|
|
<div>
|
|
<div class="position-card-symbol">${item.symbol || '-'}</div>
|
|
<div class="position-card-meta">${item.platform || '-'} / ${item.account_id || '-'} / ${item.opened_at ? `${relativeTime(item.opened_at)} / ${formatTime(item.opened_at)}` : '-'}</div>
|
|
</div>
|
|
<div class="position-card-pnl">
|
|
<div class="amount" style="color:${(item.unrealized_pnl || 0) >= 0 ? 'var(--good)' : 'var(--danger)'}">${formatMoney(item.unrealized_pnl)}</div>
|
|
<div class="ratio" style="color:${(item.pnl_percent || 0) >= 0 ? 'var(--good)' : 'var(--danger)'}">${formatPercent(item.pnl_percent, 2)}</div>
|
|
</div>
|
|
</div>
|
|
<div class="position-card-tags">
|
|
<span class="platform-pill">${item.platform}</span>
|
|
<span class="side-pill ${item.side === 'long' ? 'long' : 'short'}">${item.side === 'long' ? 'long' : 'short'}</span>
|
|
${item.setup_type ? `<span class="event-inline-badge">${item.setup_type}</span>` : ''}
|
|
</div>
|
|
<div class="position-card-gridline">
|
|
<div class="position-card-block">
|
|
<span class="label">入场 / 现价</span>
|
|
<span class="value">${formatMoney(item.entry_price)} / ${formatMoney(item.mark_price)}</span>
|
|
</div>
|
|
<div class="position-card-block">
|
|
<span class="label">仓位 / 杠杆</span>
|
|
<span class="value">${formatNumber(item.size, 4)} / ${formatNumber(item.leverage, 1)}x</span>
|
|
</div>
|
|
<div class="position-card-block">
|
|
<span class="label">止盈 / 止损</span>
|
|
<span class="value">${item.take_profit ? formatMoney(item.take_profit) : '-'} / ${item.stop_loss ? formatMoney(item.stop_loss) : '-'}</span>
|
|
</div>
|
|
<div class="position-card-block">
|
|
<span class="label">Setup / Entry</span>
|
|
<span class="value">${item.setup_type || '-'}${item.entry_basis || item.setup_basis ? `<br>${item.entry_basis || item.setup_basis}` : ''}</span>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderUnifiedOrders(orders) {
|
|
const toolbar = document.getElementById('ordersToolbar');
|
|
const container = document.getElementById('ordersTable');
|
|
const total = orders || [];
|
|
const entryCount = total.filter((item) => item.category === 'entry').length;
|
|
const protectionCount = total.filter((item) => item.category === 'tp_sl').length;
|
|
toolbar.innerHTML = `
|
|
<span class="toolbar-chip">entry: ${entryCount}</span>
|
|
<span class="toolbar-chip">tp/sl: ${protectionCount}</span>
|
|
<span class="toolbar-chip">total: ${total.length}</span>
|
|
`;
|
|
|
|
if (!total.length) {
|
|
container.innerHTML = compactEmpty('当前没有跨平台挂单', '没有入场单或保护单时,这里会保持紧凑展示。');
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = `
|
|
<div class="position-card-grid">
|
|
${total.map((item) => `
|
|
<article class="position-card ${item.side === 'long' ? 'long' : 'short'}">
|
|
<div class="position-card-head">
|
|
<div>
|
|
<div class="position-card-symbol">${item.symbol || '-'}</div>
|
|
<div class="position-card-meta">${item.platform || '-'} / ${item.account_id || '-'} / ${item.created_at ? `${relativeTime(item.created_at)} / ${formatTime(item.created_at)}` : '-'}</div>
|
|
</div>
|
|
<div class="position-card-pnl">
|
|
<div class="amount">${formatMoney(item.price)}</div>
|
|
<div class="ratio">${item.category === 'tp_sl' ? 'TP/SL' : 'ENTRY'}</div>
|
|
</div>
|
|
</div>
|
|
<div class="position-card-tags">
|
|
<span class="platform-pill">${item.platform}</span>
|
|
<span class="side-pill ${item.side === 'long' ? 'long' : 'short'}">${item.side === 'long' ? 'long' : 'short'}</span>
|
|
${item.signal_grade ? `<span class="event-inline-badge">${item.signal_grade}</span>` : ''}
|
|
${item.signal_type ? `<span class="event-inline-badge">${item.signal_type}</span>` : ''}
|
|
${item.setup_type ? `<span class="event-inline-badge">${item.setup_type}</span>` : ''}
|
|
</div>
|
|
<div class="position-card-gridline">
|
|
<div class="position-card-block">
|
|
<span class="label">价格 / 数量</span>
|
|
<span class="value">${formatMoney(item.price)} / ${formatNumber(item.size, 4)}</span>
|
|
</div>
|
|
<div class="position-card-block">
|
|
<span class="label">杠杆 / 信心度</span>
|
|
<span class="value">${item.leverage ? `${formatNumber(item.leverage, 1)}x` : '-'} / ${item.confidence ? formatPercent(item.confidence, 1) : '-'}</span>
|
|
</div>
|
|
<div class="position-card-block">
|
|
<span class="label">止盈 / 止损</span>
|
|
<span class="value">${item.take_profit ? formatMoney(item.take_profit) : '-'} / ${item.stop_loss ? formatMoney(item.stop_loss) : '-'}</span>
|
|
</div>
|
|
<div class="position-card-block">
|
|
<span class="label">Setup</span>
|
|
<span class="value">${item.entry_basis || item.setup_basis || '-'}</span>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
async function resumePlatform(platform, button, targetKey = null) {
|
|
const platformMap = {
|
|
PaperTrading: 'PaperTrading',
|
|
Bitget: 'Bitget',
|
|
};
|
|
const normalized = platformMap[platform] || platform;
|
|
|
|
try {
|
|
button.disabled = true;
|
|
button.textContent = '恢复中...';
|
|
const response = await fetch('/api/trading/platform-halts/resume', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ platform: normalized, target_key: targetKey || normalized }),
|
|
});
|
|
const result = await response.json();
|
|
|
|
if (!response.ok || !result.success) {
|
|
if (response.status === 401) {
|
|
consoleAuthenticated = false;
|
|
showConsoleAuthOverlay('访问会话已失效,请重新输入密码');
|
|
}
|
|
throw new Error(result.detail || result.message || '恢复失败');
|
|
}
|
|
|
|
setFeedback(`${normalized} 已恢复执行`);
|
|
await loadConsole();
|
|
} catch (error) {
|
|
setFeedback(`恢复平台失败: ${error.message}`, true);
|
|
button.disabled = false;
|
|
button.textContent = '恢复执行';
|
|
}
|
|
}
|
|
|
|
async function updateExecutionControl(targetKey, enabled, button) {
|
|
try {
|
|
button.disabled = true;
|
|
button.textContent = enabled ? '开启中...' : '关闭中...';
|
|
const response = await fetch('/api/trading/execution-controls', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
target_key: targetKey,
|
|
enabled,
|
|
reason: enabled ? '控制台手动开启自动交易' : '控制台手动关闭自动交易',
|
|
}),
|
|
});
|
|
const result = await response.json();
|
|
|
|
if (!response.ok || !result.success) {
|
|
if (response.status === 401) {
|
|
consoleAuthenticated = false;
|
|
showConsoleAuthOverlay('访问会话已失效,请重新输入密码');
|
|
}
|
|
throw new Error(result.detail || result.message || '更新失败');
|
|
}
|
|
|
|
setFeedback(`${targetKey} 自动交易已${enabled ? '开启' : '关闭'}`);
|
|
await loadConsole();
|
|
} catch (error) {
|
|
setFeedback(`更新自动交易开关失败: ${error.message}`, true);
|
|
button.disabled = false;
|
|
button.textContent = enabled ? '开启自动交易' : '关闭自动交易';
|
|
}
|
|
}
|
|
|
|
async function loadConsole() {
|
|
try {
|
|
if (!consoleAuthenticated) {
|
|
const ok = await ensureConsoleSession();
|
|
if (!ok) return;
|
|
}
|
|
setFeedback('');
|
|
const response = await fetch('/api/system/console');
|
|
if (response.status === 401) {
|
|
consoleAuthenticated = false;
|
|
showConsoleAuthOverlay('访问会话已失效,请重新输入密码');
|
|
return;
|
|
}
|
|
const result = await response.json();
|
|
|
|
if (result.status !== 'success') {
|
|
throw new Error(result.detail || '总控台数据加载失败');
|
|
}
|
|
|
|
const data = result.data || {};
|
|
cachedConsoleData = data;
|
|
syncTabState(data);
|
|
renderHero(data);
|
|
renderHealthRibbon(data);
|
|
renderFocusSummary(data);
|
|
renderAlertStrip(data);
|
|
renderExecutionMonitor(data);
|
|
renderSignalLifecycle(data);
|
|
renderPlatforms(data.platforms, data.crypto_agent?.platform_halts, data.crypto_agent?.target_execution_controls || {});
|
|
renderSignalStream(data.signals?.latest || []);
|
|
renderAnalysisHeartbeat(
|
|
data.crypto_agent?.analysis_monitor || {},
|
|
data.crypto_agent?.recent_analysis_events || []
|
|
);
|
|
renderHalts(data.crypto_agent?.platform_halts || {}, data.crypto_agent?.target_execution_controls || {});
|
|
renderAttentionItems(data.management?.attention_items || []);
|
|
renderUnifiedPositions(data.management?.positions || []);
|
|
renderUnifiedOrders(data.management?.orders || []);
|
|
} catch (error) {
|
|
console.error(error);
|
|
setFeedback(`总控台加载失败: ${error.message}`, true);
|
|
}
|
|
}
|
|
|
|
function applyAutoRefreshState() {
|
|
const btn = document.getElementById('toggleAutoRefreshBtn');
|
|
btn.textContent = `自动刷新: ${autoRefresh ? '开' : '关'}`;
|
|
|
|
if (timer) {
|
|
clearInterval(timer);
|
|
timer = null;
|
|
}
|
|
|
|
if (autoRefresh) {
|
|
timer = setInterval(loadConsole, 15000);
|
|
}
|
|
}
|
|
|
|
document.getElementById('refreshBtn').addEventListener('click', loadConsole);
|
|
document.getElementById('toggleAutoRefreshBtn').addEventListener('click', () => {
|
|
autoRefresh = !autoRefresh;
|
|
applyAutoRefreshState();
|
|
});
|
|
document.getElementById('toggleSensitiveBtn').addEventListener('click', toggleSensitiveData);
|
|
document.getElementById('consoleLoginBtn').addEventListener('click', loginConsole);
|
|
document.getElementById('consolePasswordInput').addEventListener('keydown', (event) => {
|
|
if (event.key === 'Enter') loginConsole();
|
|
});
|
|
|
|
loadSensitivePreference();
|
|
initTabs();
|
|
initHubNavigation();
|
|
applyAutoRefreshState();
|
|
ensureConsoleSession().then((ok) => {
|
|
if (ok) loadConsole();
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|