mirror of
https://github.com/tugcantopaloglu/openclaw-dashboard.git
synced 2026-08-14 08:52:08 +00:00
5699 lines
233 KiB
HTML
5699 lines
233 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Agent Dashboard</title>
|
||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cdefs%3E%3ClinearGradient id='grad' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' style='stop-color:%236366f1;stop-opacity:1' /%3E%3Cstop offset='100%25' style='stop-color:%238b5cf6;stop-opacity:1' /%3E%3C/linearGradient%3E%3C/defs%3E%3Ccircle cx='50' cy='50' r='45' fill='url(%23grad)'/%3E%3Ccircle cx='35' cy='40' r='8' fill='%23fff'/%3E%3Ccircle cx='65' cy='40' r='8' fill='%23fff'/%3E%3Cpath d='M 30 65 Q 50 75 70 65' stroke='%23fff' stroke-width='4' fill='none' stroke-linecap='round'/%3E%3C/svg%3E">
|
||
<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=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||
<style>
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
:root {
|
||
--bg-primary: #0a0a0f;
|
||
--bg-secondary: #13131a;
|
||
--bg-tertiary: #1a1a24;
|
||
--bg-card: #1f1f2e;
|
||
--border: #2a2a3a;
|
||
--text-primary: #e4e4e7;
|
||
--text-secondary: #a1a1aa;
|
||
--text-muted: #71717a;
|
||
--accent: #6366f1;
|
||
--accent-glow: rgba(99, 102, 241, 0.3);
|
||
--green: #10b981;
|
||
--green-glow: rgba(16, 185, 129, 0.2);
|
||
--red: #ef4444;
|
||
--yellow: #f59e0b;
|
||
--purple: #a855f7;
|
||
--blue: #3b82f6;
|
||
--cyan: #06b6d4;
|
||
}
|
||
|
||
body {
|
||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||
background: var(--bg-primary);
|
||
color: var(--text-primary);
|
||
overflow-x: hidden;
|
||
padding-bottom: 40px;
|
||
}
|
||
|
||
.app {
|
||
display: flex;
|
||
min-height: calc(100vh - 40px);
|
||
}
|
||
|
||
.sidebar {
|
||
width: 72px;
|
||
background: var(--bg-secondary);
|
||
border-right: 1px solid var(--border);
|
||
display: flex;
|
||
flex-direction: column;
|
||
position: fixed;
|
||
height: calc(100vh - 40px);
|
||
left: 0;
|
||
top: 0;
|
||
z-index: 100;
|
||
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
|
||
.sidebar:hover {
|
||
width: 240px;
|
||
}
|
||
|
||
.sidebar-header {
|
||
padding: 20px;
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.logo {
|
||
font-size: 28px;
|
||
filter: drop-shadow(0 0 8px rgba(99, 102, 241, 0.4));
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.brand {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
letter-spacing: -0.02em;
|
||
background: linear-gradient(135deg, var(--text-primary), var(--accent));
|
||
-webkit-background-clip: text;
|
||
-webkit-text-fill-color: transparent;
|
||
background-clip: text;
|
||
opacity: 0;
|
||
transition: opacity 0.3s;
|
||
}
|
||
|
||
.sidebar:hover .brand {
|
||
opacity: 1;
|
||
}
|
||
|
||
.nav {
|
||
flex: 1;
|
||
padding: 16px 0;
|
||
overflow-y: auto;
|
||
}
|
||
.sidebar:hover .nav {
|
||
padding: 16px 8px;
|
||
}
|
||
|
||
.nav-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 14px 0;
|
||
justify-content: center;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||
color: var(--text-secondary);
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
margin-bottom: 4px;
|
||
position: relative;
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.nav-item:hover {
|
||
background: var(--bg-tertiary);
|
||
color: var(--text-primary);
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||
}
|
||
.sidebar:hover .nav-item {
|
||
justify-content: flex-start;
|
||
padding: 14px 16px;
|
||
}
|
||
|
||
.nav-item.active {
|
||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.15), rgba(139, 92, 246, 0.15));
|
||
color: var(--accent);
|
||
box-shadow: 0 0 0 1px var(--accent), 0 4px 12px rgba(99, 102, 241, 0.2);
|
||
}
|
||
|
||
.nav-item .icon {
|
||
font-size: 20px;
|
||
width: 24px;
|
||
text-align: center;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.nav-text {
|
||
display: none;
|
||
}
|
||
|
||
.sidebar:hover .nav-text {
|
||
display: inline;
|
||
}
|
||
|
||
.main {
|
||
flex: 1;
|
||
margin-left: 72px;
|
||
padding: 32px;
|
||
max-width: 100%;
|
||
overflow-x: hidden;
|
||
}
|
||
|
||
.page {
|
||
display: none;
|
||
animation: fadeIn 0.3s ease;
|
||
}
|
||
|
||
.page.active {
|
||
display: block;
|
||
}
|
||
|
||
@keyframes fadeIn {
|
||
from { opacity: 0; transform: translateY(8px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
|
||
.page-header {
|
||
margin-bottom: 32px;
|
||
}
|
||
|
||
.page-title {
|
||
font-size: 32px;
|
||
font-weight: 700;
|
||
letter-spacing: -0.02em;
|
||
margin-bottom: 8px;
|
||
background: linear-gradient(135deg, var(--text-primary), var(--accent));
|
||
-webkit-background-clip: text;
|
||
-webkit-text-fill-color: transparent;
|
||
background-clip: text;
|
||
}
|
||
|
||
.page-subtitle {
|
||
color: var(--text-secondary);
|
||
font-size: 15px;
|
||
}
|
||
|
||
.card {
|
||
background: linear-gradient(135deg, rgba(31, 31, 46, 0.8), rgba(31, 31, 46, 0.95));
|
||
border: 1px solid var(--border);
|
||
border-radius: 16px;
|
||
padding: 24px;
|
||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.card:hover {
|
||
border-color: rgba(99, 102, 241, 0.5);
|
||
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.15), 0 0 32px rgba(99, 102, 241, 0.1);
|
||
transform: translateY(-2px);
|
||
}
|
||
|
||
.card-title {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--text-secondary);
|
||
margin-bottom: 20px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.1em;
|
||
}
|
||
|
||
.grid {
|
||
display: grid;
|
||
gap: 24px;
|
||
}
|
||
|
||
.grid-4 {
|
||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||
}
|
||
|
||
.grid-3 {
|
||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||
}
|
||
|
||
.grid-2 {
|
||
grid-template-columns: repeat(2, 1fr);
|
||
align-items: stretch;
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.grid-2 { grid-template-columns: 1fr; }
|
||
}
|
||
|
||
.metric {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.metric-value {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 36px;
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.metric-label {
|
||
font-size: 13px;
|
||
color: var(--text-secondary);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.metric-subtitle {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.metric.green .metric-value {
|
||
color: var(--green);
|
||
text-shadow: 0 0 20px var(--green-glow);
|
||
}
|
||
|
||
.metric.accent .metric-value {
|
||
color: var(--accent);
|
||
text-shadow: 0 0 20px var(--accent-glow);
|
||
}
|
||
|
||
.radial-gauge {
|
||
width: 120px;
|
||
height: 120px;
|
||
position: relative;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.radial-gauge svg {
|
||
transform: rotate(-90deg);
|
||
}
|
||
|
||
.radial-gauge-bg {
|
||
fill: none;
|
||
stroke: var(--bg-tertiary);
|
||
stroke-width: 8;
|
||
}
|
||
|
||
.radial-gauge-fill {
|
||
fill: none;
|
||
stroke-width: 8;
|
||
stroke-linecap: round;
|
||
transition: stroke-dashoffset 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
|
||
.radial-gauge-label {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
text-align: center;
|
||
}
|
||
|
||
.radial-gauge-value {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 24px;
|
||
font-weight: 700;
|
||
display: block;
|
||
}
|
||
|
||
.radial-gauge-text {
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
}
|
||
|
||
.system-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(6, 1fr);
|
||
gap: 20px;
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.system-grid { grid-template-columns: repeat(3, 1fr); }
|
||
}
|
||
|
||
.system-metric {
|
||
text-align: center;
|
||
}
|
||
|
||
.activity-feed {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
max-height: 480px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.activity-item {
|
||
display: flex;
|
||
gap: 8px;
|
||
padding: 8px 10px;
|
||
background: linear-gradient(135deg, rgba(26, 26, 36, 0.6), rgba(31, 31, 46, 0.4));
|
||
border-radius: 8px;
|
||
border-left: 3px solid var(--border);
|
||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.activity-item:hover {
|
||
background: linear-gradient(135deg, rgba(31, 31, 46, 0.8), rgba(26, 26, 36, 0.6));
|
||
border-left-color: var(--accent);
|
||
transform: translateX(4px);
|
||
}
|
||
|
||
.activity-item.type-main { border-left-color: var(--accent); }
|
||
.activity-item.type-sub { border-left-color: var(--cyan); }
|
||
.activity-item.type-cron { border-left-color: var(--yellow); }
|
||
.activity-item.type-group { border-left-color: var(--blue); }
|
||
|
||
.activity-item.running {
|
||
border-left-width: 4px;
|
||
box-shadow: 0 0 16px rgba(16, 185, 129, 0.15);
|
||
}
|
||
|
||
.activity-dot {
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
margin-top: 5px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.activity-dot.running {
|
||
background: var(--green);
|
||
box-shadow: 0 0 8px var(--green-glow);
|
||
animation: pulse 2s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0%, 100% { opacity: 1; transform: scale(1); }
|
||
50% { opacity: 0.6; transform: scale(0.95); }
|
||
}
|
||
|
||
.activity-content {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.activity-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.activity-name {
|
||
font-weight: 600;
|
||
font-size: 13px;
|
||
color: var(--text-primary);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.activity-snippet {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
margin-bottom: 4px;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
}
|
||
|
||
.activity-meta {
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.badge {
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.03em;
|
||
display: inline-block;
|
||
}
|
||
|
||
.badge.main { background: #6366f1; color: #fff; }
|
||
.badge.sub { background: rgba(6, 182, 212, 0.2); color: var(--cyan); }
|
||
.badge.cron { background: rgba(245, 158, 11, 0.2); color: var(--yellow); }
|
||
.badge.group { background: rgba(59, 130, 246, 0.2); color: var(--blue); }
|
||
|
||
.model-tag {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 10px;
|
||
background: var(--bg-primary);
|
||
padding: 2px 5px;
|
||
border-radius: 4px;
|
||
border: 1px solid var(--border);
|
||
}
|
||
|
||
.bar-chart {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
gap: 4px;
|
||
padding: 8px 0 0 0;
|
||
}
|
||
|
||
.bar-wrapper {
|
||
flex: 1;
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
}
|
||
|
||
.bar-item {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
position: relative;
|
||
}
|
||
|
||
.bar {
|
||
width: 60%;
|
||
max-width: 48px;
|
||
background: linear-gradient(180deg, var(--accent), var(--purple));
|
||
border-radius: 6px 6px 0 0;
|
||
min-height: 4px;
|
||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||
position: relative;
|
||
cursor: pointer;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.bar:hover {
|
||
filter: brightness(1.3);
|
||
box-shadow: 0 0 16px rgba(99, 102, 241, 0.4);
|
||
}
|
||
|
||
.bar-value {
|
||
font-size: 10px;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
color: var(--text-secondary);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.bar-label {
|
||
font-size: 10px;
|
||
color: var(--text-muted);
|
||
font-family: 'JetBrains Mono', monospace;
|
||
}
|
||
|
||
.bar-tooltip {
|
||
position: absolute;
|
||
bottom: 100%;
|
||
left: 50%;
|
||
transform: translateX(-50%) translateY(-8px);
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--border);
|
||
padding: 6px 10px;
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
white-space: nowrap;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
transition: opacity 0.2s;
|
||
z-index: 10;
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||
}
|
||
|
||
.bar:hover .bar-tooltip {
|
||
opacity: 1;
|
||
}
|
||
|
||
.view-all-link {
|
||
display: block;
|
||
text-align: center;
|
||
padding: 12px;
|
||
color: var(--accent);
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
text-decoration: none;
|
||
border-radius: 8px;
|
||
transition: all 0.2s;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.view-all-link:hover {
|
||
background: rgba(99, 102, 241, 0.1);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.toolbar {
|
||
display: flex;
|
||
gap: 16px;
|
||
margin-bottom: 24px;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
}
|
||
|
||
.filter-chips {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.chip {
|
||
padding: 8px 16px;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
background: var(--bg-tertiary);
|
||
border: 1px solid var(--border);
|
||
color: var(--text-secondary);
|
||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
|
||
.chip:hover {
|
||
background: var(--bg-card);
|
||
color: var(--text-primary);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.chip.active {
|
||
background: linear-gradient(135deg, var(--accent), var(--purple));
|
||
color: white;
|
||
border-color: transparent;
|
||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
|
||
}
|
||
|
||
.search-box {
|
||
flex: 1;
|
||
min-width: 200px;
|
||
max-width: 400px;
|
||
padding: 10px 16px;
|
||
background: var(--bg-tertiary);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
color: var(--text-primary);
|
||
font-size: 14px;
|
||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
|
||
.search-box:focus {
|
||
outline: none;
|
||
border-color: var(--accent);
|
||
box-shadow: 0 0 0 3px var(--accent-glow);
|
||
background: var(--bg-card);
|
||
}
|
||
|
||
.sessions-table {
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border);
|
||
border-radius: 16px;
|
||
overflow: hidden;
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||
}
|
||
|
||
.table-header {
|
||
display: grid;
|
||
grid-template-columns: 24px 32px 180px 70px 140px 90px 80px 1fr 90px;
|
||
gap: 10px;
|
||
padding: 14px 16px;
|
||
background: var(--bg-secondary);
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: var(--text-secondary);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
border-bottom: 1px solid var(--border);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.table-header .sortable:hover {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.table-row {
|
||
display: grid;
|
||
grid-template-columns: 24px 32px 180px 70px 140px 90px 80px 1fr 90px;
|
||
gap: 10px;
|
||
padding: 14px 16px;
|
||
align-items: center;
|
||
border-bottom: 1px solid var(--border);
|
||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.table-row:hover {
|
||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.05), rgba(139, 92, 246, 0.05));
|
||
}
|
||
|
||
.table-row.running {
|
||
background: linear-gradient(90deg, rgba(16, 185, 129, 0.08), transparent);
|
||
border-left: 3px solid var(--green);
|
||
}
|
||
|
||
.table-row.aborted {
|
||
border-left: 3px solid var(--red);
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.table-cell {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
font-size: 13px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.active-indicator {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 3px 8px;
|
||
background: rgba(16, 185, 129, 0.15);
|
||
color: var(--green);
|
||
border-radius: 6px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.03em;
|
||
animation: pulse 2s ease-in-out infinite;
|
||
}
|
||
|
||
.active-dot {
|
||
width: 6px;
|
||
height: 6px;
|
||
background: var(--green);
|
||
border-radius: 50%;
|
||
box-shadow: 0 0 8px var(--green-glow);
|
||
}
|
||
|
||
.session-snippet {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
font-family: 'JetBrains Mono', monospace;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.mono {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.feed-controls {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 20px;
|
||
padding: 16px 20px;
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.pause-btn {
|
||
padding: 10px 20px;
|
||
background: var(--bg-tertiary);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
color: var(--text-primary);
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
|
||
.pause-btn:hover {
|
||
background: var(--bg-card);
|
||
border-color: var(--accent);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.pause-btn.paused {
|
||
background: linear-gradient(135deg, var(--accent), var(--purple));
|
||
color: white;
|
||
border-color: transparent;
|
||
}
|
||
|
||
.feed-stream {
|
||
max-height: 700px;
|
||
overflow-y: auto;
|
||
display: flex;
|
||
flex-direction: column-reverse;
|
||
gap: 8px;
|
||
padding: 16px;
|
||
background: var(--bg-tertiary);
|
||
border-radius: 12px;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
}
|
||
|
||
.feed-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
padding: 12px;
|
||
background: var(--bg-card);
|
||
border-radius: 8px;
|
||
border-left: 3px solid var(--border);
|
||
animation: slideIn 0.3s ease;
|
||
font-size: 13px;
|
||
}
|
||
|
||
@keyframes slideIn {
|
||
from { opacity: 0; transform: translateY(10px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
|
||
.feed-item.role-user { border-left-color: var(--blue); }
|
||
.feed-item.role-assistant { border-left-color: var(--green); }
|
||
.feed-item.role-tool { border-left-color: var(--yellow); }
|
||
|
||
.feed-header-line {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.feed-session {
|
||
font-weight: 600;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.feed-role {
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.feed-role.user { background: rgba(59, 130, 246, 0.2); color: var(--blue); }
|
||
.feed-role.assistant { background: rgba(16, 185, 129, 0.2); color: var(--green); }
|
||
.feed-role.tool { background: rgba(245, 158, 11, 0.2); color: var(--yellow); }
|
||
|
||
.feed-content {
|
||
color: var(--text-primary);
|
||
line-height: 1.5;
|
||
word-wrap: break-word;
|
||
}
|
||
|
||
.feed-time {
|
||
color: var(--text-muted);
|
||
font-size: 11px;
|
||
}
|
||
|
||
.status-bar {
|
||
position: fixed;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: 40px;
|
||
background: var(--bg-secondary);
|
||
border-top: 1px solid var(--border);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0 24px;
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
z-index: 200;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
}
|
||
|
||
.status-bar-section {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 20px;
|
||
}
|
||
|
||
.status-bar-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.status-indicator {
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
background: var(--green);
|
||
box-shadow: 0 0 6px var(--green-glow);
|
||
animation: pulse 2s ease-in-out infinite;
|
||
}
|
||
|
||
.empty-state {
|
||
padding: 60px 20px;
|
||
text-align: center;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.empty-state-icon {
|
||
font-size: 48px;
|
||
margin-bottom: 16px;
|
||
opacity: 0.5;
|
||
}
|
||
|
||
.empty-state-text {
|
||
font-size: 16px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
::-webkit-scrollbar {
|
||
width: 8px;
|
||
height: 8px;
|
||
}
|
||
|
||
::-webkit-scrollbar-track {
|
||
background: var(--bg-tertiary);
|
||
}
|
||
|
||
::-webkit-scrollbar-thumb {
|
||
background: var(--border);
|
||
border-radius: 4px;
|
||
}
|
||
|
||
::-webkit-scrollbar-thumb:hover {
|
||
background: var(--text-muted);
|
||
}
|
||
|
||
.mb-24 { margin-bottom: 24px; }
|
||
|
||
/* Mobile Responsive */
|
||
@media (max-width: 768px) {
|
||
.sidebar {
|
||
width: 100vw;
|
||
height: auto;
|
||
bottom: 0;
|
||
top: auto;
|
||
left: 0;
|
||
right: 0;
|
||
border-right: none;
|
||
border-top: 1px solid var(--border);
|
||
flex-direction: row;
|
||
padding: 0;
|
||
z-index: 150;
|
||
}
|
||
.sidebar:hover {
|
||
width: 100vw;
|
||
}
|
||
.sidebar-header {
|
||
display: none;
|
||
}
|
||
.nav {
|
||
flex-direction: row;
|
||
padding: 0;
|
||
overflow-x: auto;
|
||
width: 100%;
|
||
justify-content: space-around;
|
||
}
|
||
.nav-item {
|
||
flex-direction: column;
|
||
padding: 8px 12px;
|
||
margin: 0;
|
||
gap: 4px;
|
||
min-width: 60px;
|
||
}
|
||
.nav-item .icon {
|
||
font-size: 20px;
|
||
}
|
||
.nav-text {
|
||
opacity: 1;
|
||
font-size: 10px;
|
||
}
|
||
.main {
|
||
margin-left: 0;
|
||
margin-bottom: 80px;
|
||
padding: 16px;
|
||
}
|
||
.page-title {
|
||
font-size: 24px;
|
||
}
|
||
.grid-4, .grid-3, .grid-2 {
|
||
grid-template-columns: 1fr !important;
|
||
}
|
||
.system-grid {
|
||
grid-template-columns: repeat(2, 1fr) !important;
|
||
}
|
||
.table-header, .table-row {
|
||
grid-template-columns: 24px 1fr 60px 80px !important;
|
||
font-size: 11px;
|
||
}
|
||
.table-cell:nth-child(n+5) {
|
||
display: none;
|
||
}
|
||
.sessions-table {
|
||
overflow-x: auto;
|
||
}
|
||
.status-bar {
|
||
font-size: 10px;
|
||
padding: 0 12px;
|
||
height: 32px;
|
||
bottom: 52px;
|
||
}
|
||
.status-bar-section {
|
||
gap: 8px;
|
||
}
|
||
.feed-controls {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
.toolbar {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
.search-box {
|
||
max-width: 100%;
|
||
}
|
||
}
|
||
|
||
.qa-btn {
|
||
padding:10px 8px;background:var(--bg-tertiary);color:var(--text-primary);border:1px solid var(--border);border-radius:8px;font-weight:600;font-size:12px;cursor:pointer;transition:all 0.2s;
|
||
}
|
||
.qa-btn:hover { transform:translateY(-2px);border-color:var(--accent); }
|
||
.qa-btn.qa-danger { background:linear-gradient(135deg,var(--accent),var(--purple));color:white;border:none; }
|
||
|
||
/* Notification bell */
|
||
.notification-bell {
|
||
position: fixed;
|
||
top: 16px;
|
||
right: 16px;
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 50%;
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
font-size: 18px;
|
||
z-index: 200;
|
||
transition: all 0.2s;
|
||
}
|
||
.notification-bell:hover {
|
||
background: var(--bg-tertiary);
|
||
transform: scale(1.05);
|
||
}
|
||
.notification-bell.granted {
|
||
color: var(--green);
|
||
}
|
||
.notification-bell.denied {
|
||
color: var(--red);
|
||
}
|
||
|
||
.notification-badge {
|
||
position: absolute;
|
||
top: -2px;
|
||
right: -2px;
|
||
min-width: 16px;
|
||
height: 16px;
|
||
background: var(--red);
|
||
border-radius: 8px;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
color: #fff;
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 0 4px;
|
||
}
|
||
.notif-panel {
|
||
position: fixed;
|
||
top: 60px;
|
||
right: 16px;
|
||
width: 380px;
|
||
max-height: 500px;
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
|
||
z-index: 300;
|
||
display: none;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
.notif-panel-header {
|
||
padding: 12px 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
font-weight: 600;
|
||
font-size: 14px;
|
||
}
|
||
.notif-panel-body {
|
||
overflow-y: auto;
|
||
max-height: 440px;
|
||
padding: 0;
|
||
}
|
||
.notif-item {
|
||
padding: 10px 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
font-size: 12px;
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: flex-start;
|
||
}
|
||
.notif-item:last-child { border-bottom: none; }
|
||
.notif-item:hover { background: var(--bg-secondary); }
|
||
.notif-icon { font-size: 16px; flex-shrink: 0; }
|
||
.notif-content { flex: 1; }
|
||
.notif-time { font-size: 10px; color: var(--text-muted); margin-top: 2px; }
|
||
.notif-event { color: var(--text-primary); }
|
||
body.light-theme .notif-panel { background: #ffffff; border-color: #d1d1d6; box-shadow: 0 8px 24px rgba(0,0,0,0.1); }
|
||
/* Keyboard shortcuts overlay */
|
||
.shortcuts-overlay {
|
||
display: none;
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.8);
|
||
z-index: 1000;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.shortcuts-overlay.active {
|
||
display: flex;
|
||
}
|
||
.shortcuts-panel {
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border);
|
||
border-radius: 16px;
|
||
padding: 32px;
|
||
max-width: 500px;
|
||
width: 90%;
|
||
}
|
||
.shortcut-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
padding: 12px 0;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.shortcut-key {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
background: var(--bg-tertiary);
|
||
padding: 4px 12px;
|
||
border-radius: 6px;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* Search highlight */
|
||
.search-highlight {
|
||
background: var(--yellow);
|
||
color: var(--bg-primary);
|
||
padding: 1px 3px;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
/* Session comparison */
|
||
.session-checkbox {
|
||
width: 18px;
|
||
height: 18px;
|
||
cursor: pointer;
|
||
}
|
||
.compare-btn {
|
||
position: fixed;
|
||
bottom: 100px;
|
||
right: 32px;
|
||
padding: 12px 24px;
|
||
background: linear-gradient(135deg, var(--accent), var(--purple));
|
||
color: white;
|
||
border: none;
|
||
border-radius: 12px;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.4);
|
||
z-index: 100;
|
||
transition: all 0.2s;
|
||
}
|
||
.compare-btn:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 12px 32px rgba(99, 102, 241, 0.5);
|
||
}
|
||
|
||
/* Toast notifications */
|
||
.toast-container {
|
||
position: fixed;
|
||
bottom: 60px;
|
||
right: 24px;
|
||
z-index: 9999;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
max-width: 400px;
|
||
}
|
||
|
||
.toast {
|
||
padding: 16px 20px;
|
||
border-radius: 12px;
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border);
|
||
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
animation: slideInRight 0.3s ease;
|
||
min-width: 280px;
|
||
}
|
||
|
||
.toast.success {
|
||
border-left: 4px solid var(--green);
|
||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.1), var(--bg-card));
|
||
}
|
||
|
||
.toast.warning {
|
||
border-left: 4px solid var(--yellow);
|
||
background: linear-gradient(135deg, rgba(245, 158, 11, 0.1), var(--bg-card));
|
||
}
|
||
|
||
.toast.info {
|
||
border-left: 4px solid var(--blue);
|
||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.1), var(--bg-card));
|
||
}
|
||
|
||
.toast-icon {
|
||
font-size: 24px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.toast-content {
|
||
flex: 1;
|
||
}
|
||
|
||
.toast-message {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: var(--text-primary);
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.toast-submessage {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.toast.fade-out {
|
||
animation: fadeOut 0.3s ease forwards;
|
||
}
|
||
|
||
@keyframes slideInRight {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateX(100px);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateX(0);
|
||
}
|
||
}
|
||
|
||
@keyframes fadeOut {
|
||
to {
|
||
opacity: 0;
|
||
transform: translateX(50px);
|
||
}
|
||
}
|
||
body.light-theme {
|
||
--bg-primary: #f5f5f7;
|
||
--bg-secondary: #e8e8ed;
|
||
--bg-tertiary: #dcdce2;
|
||
--bg-card: #ffffff;
|
||
--border: #d1d1d6;
|
||
--text-primary: #1a1a1a;
|
||
--text-secondary: #4a4a4f;
|
||
--text-muted: #8e8e93;
|
||
--accent: #6366f1;
|
||
--accent-glow: rgba(99, 102, 241, 0.15);
|
||
--green: #10b981;
|
||
--green-glow: rgba(16, 185, 129, 0.1);
|
||
--red: #ef4444;
|
||
--yellow: #f59e0b;
|
||
--purple: #a855f7;
|
||
--blue: #3b82f6;
|
||
--cyan: #06b6d4;
|
||
}
|
||
body.light-theme .sidebar { background: #eaeaef; border-right-color: #d1d1d6; }
|
||
body.light-theme .sidebar:hover { background: #e0e0e5; }
|
||
body.light-theme .status-bar { background: #eaeaef; border-top-color: #d1d1d6; }
|
||
body.light-theme .card { background: #ffffff !important; border-color: #d1d1d6; box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
|
||
body.light-theme .card:hover { box-shadow: 0 4px 12px rgba(99,102,241,0.12); border-color: rgba(99,102,241,0.4); }
|
||
body.light-theme .nav-item:hover { background: rgba(0,0,0,0.06); }
|
||
body.light-theme .nav-item.active { background: rgba(99,102,241,0.12); }
|
||
body.light-theme input, body.light-theme textarea, body.light-theme select { background: #f0f0f5 !important; color: #1a1a1a !important; border-color: #d1d1d6 !important; }
|
||
body.light-theme .session-modal-overlay { background: rgba(0,0,0,0.4); }
|
||
body.light-theme .mono { color: #4a4a4f; }
|
||
body.light-theme pre { background: #f0f0f5 !important; color: #1a1a1a; }
|
||
body.light-theme code { background: #e8e8ed; color: #1a1a1a; }
|
||
body.light-theme .notification-bell { background: #ffffff; border-color: #d1d1d6; }
|
||
body.light-theme #themeToggle { background: #ffffff; border-color: #d1d1d6; }
|
||
body.light-theme button { color: var(--text-primary); }
|
||
body.light-theme .toast { background: #ffffff; border-color: #d1d1d6; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
|
||
body.light-theme .health-ring-container { background: #ffffff !important; }
|
||
body.light-theme [style*="linear-gradient(135deg, rgba(26"] { background: #f5f5f7 !important; }
|
||
body.light-theme [style*="linear-gradient(135deg, rgba(31"] { background: #ffffff !important; }
|
||
body.light-theme .page-header { color: #1a1a1a; }
|
||
body.light-theme .brand { background: linear-gradient(135deg, #1a1a1a, var(--accent)); -webkit-background-clip: text; background-clip: text; }
|
||
body.light-theme svg text { fill: #4a4a4f; }
|
||
body.light-theme .streak-day { border-color: #d1d1d6; }
|
||
body.light-theme [style*="background:var(--bg-tertiary)"] { background: #dcdce2 !important; }
|
||
body.light-theme .activity-item { background: #f0f0f5 !important; }
|
||
body.light-theme .activity-item:hover { background: #e8e8ed !important; }
|
||
body.light-theme .system-grid .card { background: #ffffff !important; }
|
||
body.light-theme .radial-gauge-bg { stroke: #d1d1d6; }
|
||
body.light-theme .session-row:hover { background: rgba(99,102,241,0.06); }
|
||
body.light-theme .log-line:hover { background: rgba(0,0,0,0.03); }
|
||
body.light-theme ::selection { background: rgba(99,102,241,0.2); }
|
||
#themeToggle {
|
||
position: fixed;
|
||
top: 16px;
|
||
right: 64px;
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 50%;
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
font-size: 18px;
|
||
z-index: 200;
|
||
transition: all 0.2s;
|
||
}
|
||
#themeToggle:hover { background: var(--bg-tertiary); transform: scale(1.05); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="themeToggle" onclick="toggleTheme()">🌙</div>
|
||
|
||
<div id="loginPage" style="display:none;position:fixed;inset:0;background:var(--bg-primary);z-index:9999;align-items:center;justify-content:center;">
|
||
<div style="max-width:400px;width:90%;background:var(--bg-card);border:1px solid var(--border);border-radius:16px;padding:40px;box-shadow:0 8px 32px rgba(0,0,0,0.3);">
|
||
<div style="text-align:center;margin-bottom:32px;">
|
||
<div style="font-size:48px;margin-bottom:16px;">🔐</div>
|
||
<h1 style="font-size:24px;font-weight:700;margin-bottom:8px;background:linear-gradient(135deg, var(--text-primary), var(--accent));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;" id="authTitle">Dashboard Login</h1>
|
||
<p style="color:var(--text-secondary);font-size:14px;" id="authSubtitle">Enter your credentials</p>
|
||
</div>
|
||
|
||
<form id="registerForm" style="display:none;" onsubmit="return handleRegister(event)">
|
||
<div style="margin-bottom:16px;">
|
||
<input type="text" id="regUsername" placeholder="Username" style="width:100%;padding:14px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;color:var(--text-primary);font-size:14px;" autocomplete="username" required>
|
||
</div>
|
||
<div style="margin-bottom:16px;">
|
||
<input type="password" id="regPassword" placeholder="Password" style="width:100%;padding:14px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;color:var(--text-primary);font-size:14px;" autocomplete="new-password" required minlength="8">
|
||
</div>
|
||
<div style="margin-bottom:16px;">
|
||
<input type="password" id="regPasswordConfirm" placeholder="Confirm Password" style="width:100%;padding:14px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;color:var(--text-primary);font-size:14px;" autocomplete="new-password" required>
|
||
</div>
|
||
<div style="margin-bottom:20px;">
|
||
<div style="height:6px;background:var(--bg-primary);border-radius:3px;overflow:hidden;">
|
||
<div id="passwordStrengthBar" style="height:100%;width:0%;transition:all 0.3s;border-radius:3px;"></div>
|
||
</div>
|
||
<div id="passwordStrengthText" style="font-size:11px;color:var(--text-muted);margin-top:4px;">Password must be 8+ chars with at least 1 letter and 1 number</div>
|
||
</div>
|
||
<div id="registerError" style="display:none;color:var(--red);font-size:13px;margin-bottom:16px;padding:12px;background:rgba(239,68,68,0.1);border-radius:6px;border:1px solid rgba(239,68,68,0.3);"></div>
|
||
<button type="submit" id="registerBtn" style="width:100%;padding:14px;background:linear-gradient(135deg, var(--accent), var(--purple));color:#fff;border:none;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer;transition:all 0.2s;">Create Account</button>
|
||
</form>
|
||
|
||
<form id="loginForm" style="display:none;" onsubmit="return handleLogin(event)">
|
||
<div style="margin-bottom:16px;" id="usernameInputContainer">
|
||
<input type="text" id="username" placeholder="Username" style="width:100%;padding:14px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;color:var(--text-primary);font-size:14px;" autocomplete="username" required>
|
||
</div>
|
||
<div style="margin-bottom:16px;" id="passwordInputContainer">
|
||
<input type="password" id="password" placeholder="Password" style="width:100%;padding:14px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;color:var(--text-primary);font-size:14px;" autocomplete="current-password" required>
|
||
</div>
|
||
<div style="margin-bottom:16px;display:none;" id="totpInputContainer">
|
||
<input type="text" id="totpInput" placeholder="6-digit code" maxlength="6" pattern="[0-9]{6}" style="width:100%;padding:14px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;color:var(--text-primary);font-size:14px;font-family:'JetBrains Mono',monospace;text-align:center;letter-spacing:0.5em;" autocomplete="off" inputmode="numeric">
|
||
</div>
|
||
<div style="margin-bottom:16px;display:flex;align-items:center;gap:8px;">
|
||
<input type="checkbox" id="rememberMeCheckbox" style="width:16px;height:16px;cursor:pointer;">
|
||
<label for="rememberMeCheckbox" style="color:var(--text-secondary);font-size:14px;cursor:pointer;">Remember me (3 hours)</label>
|
||
</div>
|
||
<div id="loginError" style="display:none;color:var(--red);font-size:13px;margin-bottom:16px;padding:12px;background:rgba(239,68,68,0.1);border-radius:6px;border:1px solid rgba(239,68,68,0.3);"></div>
|
||
<button type="submit" id="loginBtn" style="width:100%;padding:14px;background:linear-gradient(135deg, var(--accent), var(--purple));color:#fff;border:none;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer;transition:all 0.2s;">Login</button>
|
||
<div style="text-align:center;margin-top:16px;">
|
||
<a href="#" onclick="showRecoveryForm();return false;" style="color:var(--accent);font-size:13px;text-decoration:none;">Forgot password?</a>
|
||
</div>
|
||
</form>
|
||
|
||
<form id="recoveryForm" style="display:none;" onsubmit="return handleRecovery(event)">
|
||
<div style="margin-bottom:16px;">
|
||
<input type="text" id="recoveryToken" placeholder="Recovery Token" style="width:100%;padding:14px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;color:var(--text-primary);font-size:14px;font-family:'JetBrains Mono',monospace;" required>
|
||
<div style="font-size:11px;color:var(--text-muted);margin-top:4px;">Check your server console or environment variables</div>
|
||
</div>
|
||
<div style="margin-bottom:16px;">
|
||
<input type="password" id="recoveryNewPassword" placeholder="New Password" style="width:100%;padding:14px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;color:var(--text-primary);font-size:14px;" required minlength="8">
|
||
</div>
|
||
<div style="margin-bottom:20px;">
|
||
<input type="password" id="recoveryNewPasswordConfirm" placeholder="Confirm New Password" style="width:100%;padding:14px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;color:var(--text-primary);font-size:14px;" required>
|
||
</div>
|
||
<div id="recoveryError" style="display:none;color:var(--red);font-size:13px;margin-bottom:16px;padding:12px;background:rgba(239,68,68,0.1);border-radius:6px;border:1px solid rgba(239,68,68,0.3);"></div>
|
||
<button type="submit" id="recoveryBtn" style="width:100%;padding:14px;background:linear-gradient(135deg, var(--accent), var(--purple));color:#fff;border:none;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer;transition:all 0.2s;margin-bottom:12px;">Reset Password</button>
|
||
<button type="button" onclick="showLoginForm()" style="width:100%;padding:12px;background:var(--bg-secondary);color:var(--text-primary);border:1px solid var(--border);border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;">← Back to Login</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="app" id="mainApp" style="display:none;">
|
||
<aside class="sidebar">
|
||
<div class="sidebar-header">
|
||
<div class="logo">✨</div>
|
||
<div class="brand">Agent</div>
|
||
</div>
|
||
<nav class="nav">
|
||
<div class="nav-item active" data-page="overview">
|
||
<span class="icon">📊</span>
|
||
<span class="nav-text">Overview</span>
|
||
</div>
|
||
<div class="nav-item" data-page="sessions">
|
||
<span class="icon">🤖</span>
|
||
<span class="nav-text">Sessions</span>
|
||
</div>
|
||
<div class="nav-item" data-page="costs">
|
||
<span class="icon">💰</span>
|
||
<span class="nav-text">Costs</span>
|
||
</div>
|
||
<div class="nav-item" data-page="limits">
|
||
<span class="icon">📈</span>
|
||
<span class="nav-text">Limits</span>
|
||
</div>
|
||
<div class="nav-item" data-page="memory">
|
||
<span class="icon">🧠</span>
|
||
<span class="nav-text">Memory</span>
|
||
</div>
|
||
<div class="nav-item" data-page="files">
|
||
<span class="icon">📁</span>
|
||
<span class="nav-text">Files</span>
|
||
</div>
|
||
<div class="nav-item" data-page="logs">
|
||
<span class="icon">📋</span>
|
||
<span class="nav-text">Logs</span>
|
||
</div>
|
||
<div class="nav-item" data-page="feed">
|
||
<span class="icon">⚡</span>
|
||
<span class="nav-text">Live Feed</span>
|
||
</div>
|
||
<div class="nav-item" data-page="security">
|
||
<span class="icon">🔒</span>
|
||
<span class="nav-text">Security</span>
|
||
</div>
|
||
<div class="nav-item" data-page="sys-security">
|
||
<span class="icon">🛡️</span>
|
||
<span class="nav-text">Sys Security</span>
|
||
</div>
|
||
<div class="nav-item" data-page="config-editor">
|
||
<span class="icon">⚙️</span>
|
||
<span class="nav-text">Config</span>
|
||
</div>
|
||
<div class="nav-item" data-page="docker">
|
||
<span class="icon">🐳</span>
|
||
<span class="nav-text">Docker</span>
|
||
</div>
|
||
</nav>
|
||
<div style="padding:16px;border-top:1px solid var(--border);margin-top:auto;">
|
||
<div id="mfaStatusIndicator" style="padding:4px 8px;margin-bottom:8px;font-size:10px;text-align:center;border-radius:6px;display:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"></div>
|
||
<div class="nav-item" onclick="handleLogout()" style="color:var(--red);margin-bottom:0;">
|
||
<span class="icon">🚪</span>
|
||
<span class="nav-text">Logout</span>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<main class="main">
|
||
|
||
<div class="page active" id="overview">
|
||
<div class="page-header">
|
||
<h1 class="page-title">Overview</h1>
|
||
<p class="page-subtitle">Agent activity and system performance</p>
|
||
</div>
|
||
|
||
<div class="grid mb-24" style="grid-template-columns: repeat(3, 1fr);">
|
||
<div class="card">
|
||
<div class="metric green">
|
||
<div class="metric-value"><span id="runningAgents">0</span></div>
|
||
<div class="metric-label">Running Agents</div>
|
||
<div class="metric-subtitle"><span id="totalSessions">0</span> total · <span id="activeSessions">0</span> active</div>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="metric accent">
|
||
<div class="metric-value" id="todaySpend">$0.00</div>
|
||
<div class="metric-label">Today's Spend</div>
|
||
<div class="metric-subtitle" id="todayTokensOut" style="font-family:'JetBrains Mono',monospace"></div>
|
||
</div>
|
||
</div>
|
||
<div class="card" id="overviewUsageCard">
|
||
<div class="metric">
|
||
<div style="display:flex;align-items:center;gap:8px;">
|
||
<div class="metric-value" id="ovSessionPct">--%</div>
|
||
<span id="overviewUsageWarn" style="font-size:20px;display:none;">⚠️</span>
|
||
<button onclick="scrapeCurrentProvider()" id="overviewScrapeBtn" title="Refresh now" style="padding:2px 8px;border-radius:4px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-secondary);cursor:pointer;font-size:11px;line-height:1;">⟳</button>
|
||
<div style="display:flex;align-items:center;gap:6px;">
|
||
<label style="position:relative;display:inline-block;width:36px;height:20px;cursor:pointer;border:1px solid rgba(255,255,255,0.3);border-radius:11px;padding:1px;" title="Auto-refresh usage every 2 min">
|
||
<input type="checkbox" id="usageAutoRefresh" onchange="toggleUsageAutoRefresh(this.checked)" style="opacity:0;width:0;height:0;">
|
||
<span style="position:absolute;inset:0;background:var(--bg-tertiary);border-radius:10px;transition:0.3s;" id="usageToggleTrack"></span>
|
||
<span style="position:absolute;top:2px;left:2px;width:16px;height:16px;background:#fff;border-radius:50%;transition:0.3s;" id="usageToggleThumb"></span>
|
||
</label>
|
||
<span style="font-size:10px;color:var(--text-muted);" id="usageAutoLabel">Auto</span>
|
||
</div>
|
||
<div style="display:flex;background:var(--bg-primary);border-radius:6px;overflow:hidden;border:1px solid var(--border);margin-left:4px;" id="providerSwitch">
|
||
<button onclick="switchProvider('claude')" id="provBtnClaude" style="padding:2px 10px;font-size:10px;font-weight:600;border:none;cursor:pointer;transition:0.2s;background:var(--accent);color:#fff;">Claude</button>
|
||
<button onclick="switchProvider('gemini')" id="provBtnGemini" style="padding:2px 10px;font-size:10px;font-weight:600;border:none;cursor:pointer;transition:0.2s;background:transparent;color:var(--text-muted);">Gemini</button>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:8px;margin-top:6px;">
|
||
<select id="modelSelect" onchange="switchModel(this.value)" style="background:var(--bg-primary);color:var(--text-primary);border:1px solid var(--border);border-radius:6px;padding:2px 8px;font-size:11px;font-family:'JetBrains Mono',monospace;cursor:pointer;outline:none;"></select>
|
||
<span class="metric-label" id="overviewUsageLabel" style="font-size:11px;"></span>
|
||
</div>
|
||
<div style="height:8px;background:var(--bg-primary);border-radius:4px;overflow:hidden;margin-top:8px;">
|
||
<div id="overviewSessionBar" style="height:100%;border-radius:4px;width:0%;background:var(--green);transition:width 0.6s cubic-bezier(0.4,0,0.2,1);"></div>
|
||
</div>
|
||
<div class="metric-subtitle" style="margin-top:6px;">Avg response: <span id="avgResponseTime">--</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card mb-24">
|
||
<h3 class="card-title">System Health</h3>
|
||
<div class="system-grid">
|
||
<div class="system-metric">
|
||
<div class="radial-gauge">
|
||
<svg viewBox="0 0 120 120">
|
||
<circle class="radial-gauge-bg" cx="60" cy="60" r="52"/>
|
||
<circle class="radial-gauge-fill" cx="60" cy="60" r="52" id="cpuCircle" style="stroke: var(--green); stroke-dasharray: 326.73; stroke-dashoffset: 326.73"/>
|
||
</svg>
|
||
<div class="radial-gauge-label">
|
||
<span class="radial-gauge-value" id="systemCpu">0%</span>
|
||
<span class="radial-gauge-text">CPU</span>
|
||
</div>
|
||
</div>
|
||
<div id="cpuSparkline" style="margin-top:8px;"></div>
|
||
</div>
|
||
<div class="system-metric">
|
||
<div class="radial-gauge">
|
||
<svg viewBox="0 0 120 120">
|
||
<circle class="radial-gauge-bg" cx="60" cy="60" r="52"/>
|
||
<circle class="radial-gauge-fill" cx="60" cy="60" r="52" id="ramCircle" style="stroke: var(--blue); stroke-dasharray: 326.73; stroke-dashoffset: 326.73"/>
|
||
</svg>
|
||
<div class="radial-gauge-label">
|
||
<span class="radial-gauge-value" id="systemRam">0%</span>
|
||
<span class="radial-gauge-text">RAM</span>
|
||
<span class="radial-gauge-text" id="systemRamDetail" style="font-size:9px;"></span>
|
||
</div>
|
||
</div>
|
||
<div id="ramSparkline" style="margin-top:8px;"></div>
|
||
</div>
|
||
<div class="system-metric" title="CPU temperature. On Mac, install osx-cpu-temp (brew install osx-cpu-temp) for a reading.">
|
||
<div class="radial-gauge">
|
||
<svg viewBox="0 0 120 120">
|
||
<circle class="radial-gauge-bg" cx="60" cy="60" r="52"/>
|
||
<circle class="radial-gauge-fill" cx="60" cy="60" r="52" id="tempCircle" style="stroke: var(--yellow); stroke-dasharray: 326.73; stroke-dashoffset: 326.73"/>
|
||
</svg>
|
||
<div class="radial-gauge-label">
|
||
<span class="radial-gauge-value" id="systemTemp">N/A</span>
|
||
<span class="radial-gauge-text">TEMP</span>
|
||
</div>
|
||
</div>
|
||
<div id="tempSparkline" style="margin-top:8px;"></div>
|
||
</div>
|
||
<div class="system-metric">
|
||
<div class="radial-gauge">
|
||
<svg viewBox="0 0 120 120">
|
||
<circle class="radial-gauge-bg" cx="60" cy="60" r="52"/>
|
||
<circle class="radial-gauge-fill" cx="60" cy="60" r="52" id="diskCircle" style="stroke: var(--purple); stroke-dasharray: 326.73; stroke-dashoffset: 326.73"/>
|
||
</svg>
|
||
<div class="radial-gauge-label">
|
||
<span class="radial-gauge-value" id="systemDisk">0%</span>
|
||
<span class="radial-gauge-text">DISK</span>
|
||
<span class="radial-gauge-text" id="systemDiskDetail" style="font-size:9px;"></span>
|
||
</div>
|
||
</div>
|
||
<div id="diskSparkline" style="margin-top:8px;"></div>
|
||
</div>
|
||
<div class="system-metric">
|
||
<div style="text-align: center; padding-top: 20px;">
|
||
<div style="font-size: 28px; font-weight: 700; font-family: 'JetBrains Mono', monospace; margin-bottom: 8px;" id="systemUptime">--</div>
|
||
<div style="font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 8px;">UPTIME</div>
|
||
<div style="font-size: 10px; color: var(--text-muted); font-family: 'JetBrains Mono', monospace;" id="systemLoad">Load: --</div>
|
||
</div>
|
||
</div>
|
||
<div class="system-metric">
|
||
<div style="text-align: center; padding-top: 20px;">
|
||
<div style="font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px;">💥 Crashes</div>
|
||
<div style="display:flex;align-items:center;justify-content:center;gap:6px;margin-bottom:4px;">
|
||
<div style="font-size: 28px; font-weight: 700; font-family: 'JetBrains Mono', monospace;" id="systemCrashesToday">0</div>
|
||
<div style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">today</div>
|
||
</div>
|
||
<div style="display:flex;align-items:center;justify-content:center;gap:6px;">
|
||
<div style="font-size: 18px; font-weight: 600; font-family: 'JetBrains Mono', monospace;" id="systemCrashes">0</div>
|
||
<div style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">7-day</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-2 mb-24">
|
||
<div class="card">
|
||
<h3 class="card-title">Services Status</h3>
|
||
<div id="servicesStatus"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3 class="card-title">Cron Jobs</h3>
|
||
<div id="cronJobs" style="max-height:320px;overflow-y:auto;"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-2 mb-24">
|
||
<div class="card">
|
||
<h3 class="card-title">Quick Actions</h3>
|
||
<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:10px;">
|
||
<button onclick="quickAction('restart-openclaw', event)" class="qa-btn qa-danger">🔄 Restart OpenClaw</button>
|
||
<button onclick="quickAction('restart-dashboard', event)" class="qa-btn">🔄 Restart Dashboard</button>
|
||
<button onclick="quickAction('clear-cache', event)" class="qa-btn">🗑️ Clear Cache</button>
|
||
<button onclick="scrapeClaudeUsage()" class="qa-btn">📊 Usage Scrape</button>
|
||
<button onclick="quickAction('restart-tailscale', event)" class="qa-btn">🌐 Restart Tailscale</button>
|
||
<button onclick="quickAction('update-openclaw', event)" class="qa-btn qa-danger">⬆️ Update OpenClaw</button>
|
||
<button onclick="quickAction('kill-tmux', event)" class="qa-btn">🧹 Kill Tmux Sessions</button>
|
||
<button onclick="quickAction('gc', event)" class="qa-btn">♻️ Git GC All</button>
|
||
<button onclick="quickAction('check-update', event)" class="qa-btn">🔍 Check Updates</button>
|
||
<button onclick="quickAction('sys-update', event)" class="qa-btn qa-danger">📦 Apt Update</button>
|
||
<button onclick="quickAction('disk-cleanup', event)" class="qa-btn">💾 Disk Cleanup</button>
|
||
<button onclick="quickAction('restart-claude', event)" class="qa-btn">🤖 Restart Claude Tmux</button>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<h3 class="card-title">Tailscale Status</h3>
|
||
<div id="tailscaleStatus" style="display:flex;flex-direction:column;gap:12px;">
|
||
<div style="font-size:13px;color:var(--text-muted);">Loading...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-2 mb-24">
|
||
<div class="card">
|
||
<h3 class="card-title">Lifetime Stats</h3>
|
||
<div id="lifetimeStats" style="display:grid;grid-template-columns:repeat(2,1fr);gap:16px;">
|
||
<div><div style="font-size:11px;color:var(--text-muted);margin-bottom:4px;text-transform:uppercase;">Total Tokens</div><div class="mono" style="font-size:20px;font-weight:700;" id="ltTokens">--</div></div>
|
||
<div><div style="font-size:11px;color:var(--text-muted);margin-bottom:4px;text-transform:uppercase;">Total Messages</div><div class="mono" style="font-size:20px;font-weight:700;" id="ltMessages">--</div></div>
|
||
<div><div style="font-size:11px;color:var(--text-muted);margin-bottom:4px;text-transform:uppercase;">Total Cost</div><div class="mono" style="font-size:20px;font-weight:700;color:var(--accent);" id="ltCost">--</div></div>
|
||
<div><div style="font-size:11px;color:var(--text-muted);margin-bottom:4px;text-transform:uppercase;">Total Sessions</div><div class="mono" style="font-size:20px;font-weight:700;" id="ltSessions">--</div></div>
|
||
<div><div style="font-size:11px;color:var(--text-muted);margin-bottom:4px;text-transform:uppercase;">First Session</div><div style="font-size:13px;font-weight:600;" id="ltFirstDate">--</div></div>
|
||
<div><div style="font-size:11px;color:var(--text-muted);margin-bottom:4px;text-transform:uppercase;">Days Active</div><div style="font-size:20px;font-weight:700;color:var(--green);" id="ltDaysActive">--</div></div>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<h3 class="card-title">Activity Streak</h3>
|
||
<div style="display:flex;align-items:center;gap:24px;">
|
||
<div style="text-align:center;">
|
||
<div style="font-size:48px;margin-bottom:8px;">🔥</div>
|
||
<div style="font-size:36px;font-weight:700;font-family:'JetBrains Mono',monospace;color:var(--accent);" id="currentStreak">0</div>
|
||
<div style="font-size:12px;color:var(--text-muted);text-transform:uppercase;">Current Streak</div>
|
||
</div>
|
||
<div style="flex:1;">
|
||
<div style="margin-bottom:12px;">
|
||
<div style="font-size:11px;color:var(--text-muted);margin-bottom:4px;">Longest Streak</div>
|
||
<div style="font-size:24px;font-weight:700;font-family:'JetBrains Mono',monospace;" id="longestStreak">0</div>
|
||
</div>
|
||
<div style="font-size:10px;color:var(--text-muted);margin-bottom:8px;">Last 30 Days</div>
|
||
<div id="streakCalendar" style="display:grid;grid-template-columns:repeat(10,1fr);gap:3px;"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-2 mb-24" style="align-items:start;">
|
||
<div class="card">
|
||
<h3 class="card-title">Recent Activity</h3>
|
||
<div class="activity-feed" id="recentActivity" style="max-height:400px;overflow-y:auto;"></div>
|
||
<a href="#" class="view-all-link" data-page="sessions">View All Sessions →</a>
|
||
</div>
|
||
<div>
|
||
<div class="card mb-24">
|
||
<h3 class="card-title">Daily Spend (Last 7 Days)</h3>
|
||
<div class="bar-chart" id="dailySpendChart"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3 class="card-title">Memory Files</h3>
|
||
<div id="memoryFiles"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card mb-24">
|
||
<h3 class="card-title">Git Activity (7 Days)</h3>
|
||
<div id="gitActivity" style="max-height:320px;overflow-y:auto;"></div>
|
||
</div>
|
||
|
||
<div class="grid grid-2 mb-24">
|
||
<div class="card">
|
||
<h3 class="card-title">Today's Token Breakdown by Model</h3>
|
||
<div id="tokenBreakdown"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3 class="card-title">Session Models</h3>
|
||
<div id="sessionModels"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="page" id="sessions">
|
||
<div class="page-header">
|
||
<h1 class="page-title">Sessions</h1>
|
||
<p class="page-subtitle">All agent sessions and their activity</p>
|
||
</div>
|
||
|
||
<div class="card mb-24" id="sessionsStatsBar" style="padding:16px 24px;">
|
||
<div style="display:flex;gap:32px;flex-wrap:wrap;align-items:center;">
|
||
<div style="display:flex;align-items:center;gap:8px;">
|
||
<span style="font-size:12px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.05em;">Sessions</span>
|
||
<span class="mono" style="font-size:20px;font-weight:700;" id="statsSessionCount">0</span>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:8px;">
|
||
<span style="font-size:12px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.05em;">Tokens</span>
|
||
<span class="mono" style="font-size:20px;font-weight:700;" id="statsTotalTokens">0</span>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:8px;">
|
||
<span style="font-size:12px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.05em;">Cost</span>
|
||
<span class="mono" style="font-size:20px;font-weight:700;color:var(--accent);" id="statsTotalCost">$0.00</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card mb-24" style="padding:16px 24px;">
|
||
<h3 class="card-title" style="margin-bottom:12px;">Session Timeline</h3>
|
||
<div id="timelineCanvas" style="min-height:60px;"></div>
|
||
</div>
|
||
|
||
|
||
<div style="display:flex;flex-wrap:wrap;gap:12px;align-items:center;margin-bottom:20px;padding:16px 20px;background:var(--bg-card);border:1px solid var(--border);border-radius:12px;">
|
||
<div class="filter-chips" id="statusFilters">
|
||
<div class="chip active" data-filter="all">All</div>
|
||
<div class="chip" data-filter="running">Active</div>
|
||
<div class="chip" data-filter="complete">Idle</div>
|
||
<div class="chip" data-filter="subagent">Subs</div>
|
||
<div class="chip" data-filter="cron">Cron</div>
|
||
<div class="chip" data-filter="group">Groups</div>
|
||
</div>
|
||
<div style="width:1px;height:24px;background:var(--border);"></div>
|
||
<div class="filter-chips" id="modelFilters">
|
||
<div class="chip active" data-model="all">All Models</div>
|
||
<div class="chip" data-model="opus-4-6">Opus</div>
|
||
<div class="chip" data-model="sonnet">Sonnet</div>
|
||
<div class="chip" data-model="gemini">Gemini</div>
|
||
</div>
|
||
<div style="width:1px;height:24px;background:var(--border);"></div>
|
||
<div class="filter-chips" id="dateFilters">
|
||
<div class="chip" data-range="today">Today</div>
|
||
<div class="chip active" data-range="7d">7d</div>
|
||
<div class="chip" data-range="30d">30d</div>
|
||
<div class="chip" data-range="all">All</div>
|
||
</div>
|
||
<input type="text" class="search-box" placeholder="🔍 Search..." id="sessionSearch" style="min-width:150px;max-width:250px;">
|
||
</div>
|
||
|
||
<div class="sessions-table">
|
||
<div class="table-header" style="grid-template-columns: 24px 32px 180px 70px 140px 90px 80px 1fr 90px;">
|
||
<div></div>
|
||
<div></div>
|
||
<div class="sortable" data-sort="label">Name</div>
|
||
<div class="sortable" data-sort="type">Type</div>
|
||
<div class="sortable" data-sort="model">Model</div>
|
||
<div class="sortable" data-sort="tokens">Tokens</div>
|
||
<div class="sortable" data-sort="cost">Cost</div>
|
||
<div>Last Message</div>
|
||
<div class="sortable" data-sort="updated">Updated</div>
|
||
</div>
|
||
<div id="sessionsTableBody"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="page" id="limits">
|
||
<div class="page-header">
|
||
<h1 class="page-title">Rate Limits</h1>
|
||
<p class="page-subtitle">Claude Max 5x · 5-hour rolling usage windows</p>
|
||
</div>
|
||
|
||
<div class="card mb-24">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
||
<h3 class="card-title" style="margin:0;">Claude Code Usage (Anthropic)</h3>
|
||
<div style="display:flex;align-items:center;gap:12px;">
|
||
<span style="font-size:11px;color:var(--text-muted);" id="claudeUsageScrapedAt">Not scraped yet</span>
|
||
<button onclick="scrapeClaudeUsage()" style="padding:4px 12px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);cursor:pointer;font-size:12px;" id="scrapeBtn">⟳ Refresh</button>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:20px;" id="claudeUsageBars">
|
||
<div>
|
||
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px;">
|
||
<span style="font-weight:600;font-size:14px;">Session</span>
|
||
<span class="mono" style="font-size:12px;color:var(--text-secondary);" id="cuSessionLabel">--</span>
|
||
</div>
|
||
<div style="height:24px;background:var(--bg-primary);border-radius:12px;overflow:hidden;position:relative;">
|
||
<div id="cuSessionBar" style="height:100%;border-radius:12px;transition:width 0.6s;width:0%;background:linear-gradient(90deg,#10b981,#10b981);"></div>
|
||
<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-family:'JetBrains Mono',monospace;font-size:11px;font-weight:700;color:#fff;text-shadow:0 1px 3px rgba(0,0,0,0.5);" id="cuSessionPct">--</div>
|
||
</div>
|
||
<div style="font-size:11px;color:var(--text-muted);margin-top:4px;" id="cuSessionReset"></div>
|
||
</div>
|
||
<div>
|
||
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px;">
|
||
<span style="font-weight:600;font-size:14px;">Weekly (All Models)</span>
|
||
<span class="mono" style="font-size:12px;color:var(--text-secondary);" id="cuWeeklyLabel">--</span>
|
||
</div>
|
||
<div style="height:24px;background:var(--bg-primary);border-radius:12px;overflow:hidden;position:relative;">
|
||
<div id="cuWeeklyBar" style="height:100%;border-radius:12px;transition:width 0.6s;width:0%;background:linear-gradient(90deg,#10b981,#10b981);"></div>
|
||
<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-family:'JetBrains Mono',monospace;font-size:11px;font-weight:700;color:#fff;text-shadow:0 1px 3px rgba(0,0,0,0.5);" id="cuWeeklyPct">--</div>
|
||
</div>
|
||
<div style="font-size:11px;color:var(--text-muted);margin-top:4px;" id="cuWeeklyReset"></div>
|
||
</div>
|
||
<div>
|
||
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px;">
|
||
<span style="font-weight:600;font-size:14px;">Weekly (Sonnet)</span>
|
||
<span class="mono" style="font-size:12px;color:var(--text-secondary);" id="cuSonnetLabel">--</span>
|
||
</div>
|
||
<div style="height:24px;background:var(--bg-primary);border-radius:12px;overflow:hidden;position:relative;">
|
||
<div id="cuSonnetBar" style="height:100%;border-radius:12px;transition:width 0.6s;width:0%;background:linear-gradient(90deg,#10b981,#10b981);"></div>
|
||
<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-family:'JetBrains Mono',monospace;font-size:11px;font-weight:700;color:#fff;text-shadow:0 1px 3px rgba(0,0,0,0.5);" id="cuSonnetPct">--</div>
|
||
</div>
|
||
<div style="font-size:11px;color:var(--text-muted);margin-top:4px;" id="cuSonnetReset"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card mb-24">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
||
<h3 class="card-title" style="margin:0;">Gemini Usage (Google)</h3>
|
||
<div style="display:flex;align-items:center;gap:12px;">
|
||
<span style="font-size:11px;color:var(--text-muted);" id="geminiUsageScrapedAt">Not scraped yet</span>
|
||
<button onclick="scrapeGeminiUsage()" style="padding:4px 12px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);cursor:pointer;font-size:12px;" id="geminiScrapeBtn">⟳ Refresh</button>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:20px;" id="geminiUsageBars"></div>
|
||
</div>
|
||
|
||
<div class="grid grid-4 mb-24">
|
||
<div class="card">
|
||
<div class="metric accent">
|
||
<div class="metric-value" id="opusUsageLimits">0%</div>
|
||
<div class="metric-label">Opus 5h Window</div>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="metric">
|
||
<div class="metric-value" id="totalCalls">0</div>
|
||
<div class="metric-label">API Calls (5h)</div>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="metric">
|
||
<div class="metric-value" id="windowCost">$0.00</div>
|
||
<div class="metric-label">Window Cost</div>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="metric">
|
||
<div class="metric-value" id="burnRateDisplay">--</div>
|
||
<div class="metric-label">Burn Rate (tok/min)</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card mb-24">
|
||
<h3 class="card-title">Output Token Usage</h3>
|
||
<div style="display:flex;flex-direction:column;gap:28px;margin-top:8px;">
|
||
<div>
|
||
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:8px;">
|
||
<span style="font-weight:600;font-size:15px;">Opus</span>
|
||
<span class="mono" style="font-size:13px;color:var(--text-secondary);" id="opusTokenLabel">0 / 88k</span>
|
||
</div>
|
||
<div style="height:28px;background:var(--bg-primary);border-radius:14px;overflow:hidden;position:relative;">
|
||
<div id="opusProgressBar" style="height:100%;border-radius:14px;transition:width 0.6s cubic-bezier(0.4,0,0.2,1);width:0%;background:linear-gradient(90deg,#10b981,#10b981);"></div>
|
||
<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-family:'JetBrains Mono',monospace;font-size:12px;font-weight:700;color:#fff;text-shadow:0 1px 3px rgba(0,0,0,0.5);" id="opusProgressPct">0%</div>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:8px;">
|
||
<span style="font-weight:600;font-size:15px;">Sonnet</span>
|
||
<span class="mono" style="font-size:13px;color:var(--text-secondary);" id="sonnetTokenLabel">0 / 220k</span>
|
||
</div>
|
||
<div style="height:28px;background:var(--bg-primary);border-radius:14px;overflow:hidden;position:relative;">
|
||
<div id="sonnetProgressBar" style="height:100%;border-radius:14px;transition:width 0.6s cubic-bezier(0.4,0,0.2,1);width:0%;background:linear-gradient(90deg,#10b981,#10b981);"></div>
|
||
<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-family:'JetBrains Mono',monospace;font-size:12px;font-weight:700;color:#fff;text-shadow:0 1px 3px rgba(0,0,0,0.5);" id="sonnetProgressPct">0%</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-3 mb-24">
|
||
<div class="card" id="timeToLimitCard">
|
||
<h3 class="card-title">Est. Time to Limit</h3>
|
||
<div style="font-family:'JetBrains Mono',monospace;font-size:28px;font-weight:700;" id="timeToLimitValue">Safe</div>
|
||
<div style="font-size:12px;color:var(--text-muted);margin-top:8px;" id="timeToLimitSub">Low usage</div>
|
||
</div>
|
||
<div class="card">
|
||
<h3 class="card-title">Window Resets In</h3>
|
||
<div style="font-family:'JetBrains Mono',monospace;font-size:28px;font-weight:700;" id="windowResetValue">--</div>
|
||
<div style="font-size:12px;color:var(--text-muted);margin-top:8px;" id="windowResetSub"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3 class="card-title">Cost Per Minute</h3>
|
||
<div style="font-family:'JetBrains Mono',monospace;font-size:28px;font-weight:700;color:var(--accent);" id="costPerMinValue">$0.00</div>
|
||
<div style="font-size:12px;color:var(--text-muted);margin-top:8px;" id="costPerMinSub">Last 30 minutes</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-2 mb-24">
|
||
<div class="card">
|
||
<h3 class="card-title">Model Usage Breakdown</h3>
|
||
<div id="modelUsageDonut" style="display:flex;align-items:center;justify-content:center;min-height:300px;"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3 class="card-title">Window Cost Breakdown</h3>
|
||
<div id="windowBreakdown"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card mb-24">
|
||
<h3 class="card-title">Recent API Calls</h3>
|
||
<div id="recentCalls" style="max-height:360px;overflow-y:auto;"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="page" id="costs">
|
||
<div class="page-header">
|
||
<h1 class="page-title">Costs</h1>
|
||
<p class="page-subtitle">Spending breakdown and trends</p>
|
||
</div>
|
||
|
||
<div class="grid grid-4 mb-24">
|
||
<div class="card">
|
||
<div class="metric accent">
|
||
<div class="metric-value" id="costToday">$0.00</div>
|
||
<div class="metric-label">Today</div>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="metric accent">
|
||
<div class="metric-value" id="costWeek">$0.00</div>
|
||
<div class="metric-label">This Week</div>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="metric accent">
|
||
<div class="metric-value" id="costTotal">$0.00</div>
|
||
<div class="metric-label">All Time</div>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="metric">
|
||
<div class="metric-value" id="costAvg">$0.00</div>
|
||
<div class="metric-label">Avg/Day</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card mb-24">
|
||
<h3 class="card-title">Daily Spend Trend (Last 14 Days)</h3>
|
||
<div id="costTrendChart" style="min-height:240px;"></div>
|
||
</div>
|
||
|
||
<div class="grid grid-2">
|
||
<div class="card">
|
||
<h3 class="card-title">Cost by Model</h3>
|
||
<div id="costByModel"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3 class="card-title">Top Sessions</h3>
|
||
<div id="topSessions"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="page" id="feed">
|
||
<div class="page-header">
|
||
<h1 class="page-title">Live Feed</h1>
|
||
<p class="page-subtitle">Real-time agent activity stream — all sessions</p>
|
||
</div>
|
||
|
||
<div class="feed-controls" style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
|
||
<div style="font-size: 13px; color: var(--text-secondary);">
|
||
<span class="status-indicator"></span> Live
|
||
</div>
|
||
<select id="feedSessionFilter" onchange="applyFeedFilter()" style="background:var(--bg-secondary);color:var(--text-primary);border:1px solid var(--border);border-radius:6px;padding:4px 8px;font-size:12px;">
|
||
<option value="all">All Sessions</option>
|
||
</select>
|
||
<select id="feedRoleFilter" onchange="applyFeedFilter()" style="background:var(--bg-secondary);color:var(--text-primary);border:1px solid var(--border);border-radius:6px;padding:4px 8px;font-size:12px;">
|
||
<option value="all">All Roles</option>
|
||
<option value="user">User</option>
|
||
<option value="assistant">Assistant</option>
|
||
</select>
|
||
<div style="position:relative;flex:1;min-width:200px;max-width:300px;">
|
||
<input type="text" id="feedSearchInput" placeholder="🔍 Search feed..." style="width:100%;padding:4px 28px 4px 8px;background:var(--bg-secondary);color:var(--text-primary);border:1px solid var(--border);border-radius:6px;font-size:12px;">
|
||
<button onclick="clearFeedSearch()" style="position:absolute;right:4px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:14px;padding:2px 6px;">✕</button>
|
||
</div>
|
||
<button class="pause-btn" id="pauseBtn">▶ Start</button>
|
||
<button onclick="document.getElementById('feedStream').innerHTML=''" style="background:var(--bg-secondary);color:var(--text-secondary);border:1px solid var(--border);border-radius:6px;padding:4px 10px;font-size:12px;cursor:pointer;">Clear</button>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="feed-stream" id="feedStream"><div style="text-align:center;padding:40px;color:var(--text-muted);font-size:14px;">Click <b>▶ Start</b> to begin streaming</div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="page" id="memory">
|
||
<div class="page-header">
|
||
<h1 class="page-title">Memory</h1>
|
||
<p class="page-subtitle">Agent memory files and daily notes</p>
|
||
</div>
|
||
|
||
<div class="grid grid-2 mb-24">
|
||
<div class="card">
|
||
<h3 class="card-title">Memory Files</h3>
|
||
<div id="memoryFilesList" style="max-height:600px;overflow-y:auto;"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3 class="card-title" id="memoryFileTitle">Select a file</h3>
|
||
<div id="memoryFileContent" style="max-height:600px;overflow-y:auto;white-space:pre-wrap;font-family:'JetBrains Mono',monospace;font-size:12px;line-height:1.6;color:var(--text-primary);"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="page" id="files">
|
||
<div class="page-header">
|
||
<h1 class="page-title">Files</h1>
|
||
<p class="page-subtitle">Key workspace and config files</p>
|
||
</div>
|
||
|
||
<div class="grid grid-2 mb-24">
|
||
<div class="card">
|
||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px;">
|
||
<h3 class="card-title" style="margin-bottom:0;flex:1;">Key Files</h3>
|
||
<button onclick="window.fetchKeyFiles()" style="padding:4px 10px;background:var(--bg-tertiary);color:var(--text-secondary);border:1px solid var(--border);border-radius:6px;font-size:11px;cursor:pointer;">↻ Refresh</button>
|
||
</div>
|
||
<div id="keyFilesList" style="max-height:572px;overflow-y:auto;"></div>
|
||
</div>
|
||
<div class="card">
|
||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;">
|
||
<h3 class="card-title" id="keyFileTitle" style="margin-bottom:0;flex:1;">Select a file</h3>
|
||
<button id="keyFileEditBtn" onclick="window.editKeyFile()" style="display:none;padding:6px 14px;background:var(--accent);color:white;border:none;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;">Edit</button>
|
||
<button id="keyFileSaveBtn" onclick="window.saveKeyFile()" style="display:none;padding:6px 14px;background:var(--green);color:white;border:none;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;">Save</button>
|
||
<button id="keyFileCancelBtn" onclick="window.cancelEditKeyFile()" style="display:none;padding:6px 14px;background:var(--bg-tertiary);color:var(--text-secondary);border:1px solid var(--border);border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;">Cancel</button>
|
||
</div>
|
||
<div id="keyFileContent" style="max-height:560px;overflow-y:auto;white-space:pre-wrap;font-family:'JetBrains Mono',monospace;font-size:12px;line-height:1.6;color:var(--text-primary);"></div>
|
||
<textarea id="keyFileEditor" style="display:none;width:100%;height:560px;background:var(--bg-primary);color:var(--text-primary);border:1px solid var(--border);border-radius:8px;padding:12px;font-family:'JetBrains Mono',monospace;font-size:12px;line-height:1.6;resize:vertical;"></textarea>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="page" id="logs">
|
||
<div class="page-header">
|
||
<h1 class="page-title">Logs</h1>
|
||
<p class="page-subtitle">System and service logs viewer</p>
|
||
</div>
|
||
|
||
<div class="card mb-24" style="padding:16px 24px;">
|
||
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;">
|
||
<select id="logService" style="padding:8px 12px;background:var(--bg-tertiary);color:var(--text-primary);border:1px solid var(--border);border-radius:8px;font-size:14px;font-weight:500;">
|
||
<option value="openclaw">OpenClaw</option>
|
||
<option value="agent-dashboard">Agent Dashboard</option>
|
||
<option value="tailscaled">Tailscaled</option>
|
||
</select>
|
||
<input type="number" id="logLines" value="100" min="10" max="1000" step="10" style="width:100px;padding:8px 12px;background:var(--bg-tertiary);color:var(--text-primary);border:1px solid var(--border);border-radius:8px;font-size:14px;">
|
||
<span style="font-size:13px;color:var(--text-muted);">lines</span>
|
||
<button onclick="fetchLogs()" style="padding:8px 16px;background:linear-gradient(135deg,var(--accent),var(--purple));color:white;border:none;border-radius:8px;font-weight:600;cursor:pointer;">🔄 Refresh</button>
|
||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;margin-left:auto;">
|
||
<input type="checkbox" id="logAutoRefresh" onchange="toggleLogAutoRefresh(this.checked)" style="width:18px;height:18px;cursor:pointer;">
|
||
<span style="font-size:14px;font-weight:500;">Auto-refresh (5s)</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div id="logViewer" style="background:var(--bg-primary);border-radius:8px;padding:16px;font-family:'JetBrains Mono',monospace;font-size:12px;line-height:1.6;color:var(--text-primary);max-height:700px;overflow-y:auto;white-space:pre-wrap;word-wrap:break-word;">
|
||
<div style="color:var(--text-muted);">Click Refresh to load logs...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="page" id="security">
|
||
<div class="page-header">
|
||
<h1 class="page-title">Security</h1>
|
||
<p class="page-subtitle">Authentication and security settings</p>
|
||
</div>
|
||
|
||
<div class="card mb-24">
|
||
<div class="card-title">🔑 Change Password</div>
|
||
|
||
<form onsubmit="return handleChangePassword(event)" style="max-width:500px;">
|
||
<div style="margin-bottom:16px;">
|
||
<input type="password" id="currentPassword" placeholder="Current Password" style="width:100%;padding:14px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;color:var(--text-primary);font-size:14px;" autocomplete="current-password" required>
|
||
</div>
|
||
<div style="margin-bottom:16px;">
|
||
<input type="password" id="newPassword" placeholder="New Password" style="width:100%;padding:14px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;color:var(--text-primary);font-size:14px;" autocomplete="new-password" required minlength="8">
|
||
</div>
|
||
<div style="margin-bottom:20px;">
|
||
<input type="password" id="newPasswordConfirm" placeholder="Confirm New Password" style="width:100%;padding:14px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;color:var(--text-primary);font-size:14px;" autocomplete="new-password" required>
|
||
</div>
|
||
<div id="changePasswordError" style="display:none;color:var(--red);font-size:13px;margin-bottom:16px;padding:12px;background:rgba(239,68,68,0.1);border-radius:6px;border:1px solid rgba(239,68,68,0.3);"></div>
|
||
<button type="submit" id="changePasswordBtn" style="padding:12px 24px;background:linear-gradient(135deg,var(--accent),var(--purple));color:white;border:none;border-radius:8px;font-weight:600;cursor:pointer;font-size:14px;">
|
||
Change Password
|
||
</button>
|
||
</form>
|
||
|
||
<div style="margin-top:16px;padding:12px;background:rgba(59,130,246,0.1);border:1px solid rgba(59,130,246,0.3);border-radius:8px;">
|
||
<div style="font-size:12px;color:var(--text-secondary);">
|
||
<strong>Forgot your password?</strong> Use the recovery token from your server console or environment variables to reset it via the login screen.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-title">🔐 Two-Factor Authentication (TOTP)</div>
|
||
|
||
<div id="mfaDisabledView" style="display:none;">
|
||
<p style="color:var(--text-secondary);margin-bottom:24px;line-height:1.6;">
|
||
Add an extra layer of security by enabling TOTP-based two-factor authentication.
|
||
You'll need an authenticator app like Google Authenticator or Authy.
|
||
</p>
|
||
<button onclick="setupMFA()" id="enableMfaBtn" style="padding:12px 24px;background:linear-gradient(135deg,var(--green),#059669);color:white;border:none;border-radius:8px;font-weight:600;cursor:pointer;font-size:14px;">
|
||
✅ Enable MFA
|
||
</button>
|
||
</div>
|
||
|
||
<div id="mfaEnabledView" style="display:none;">
|
||
<div style="padding:16px;background:rgba(16,185,129,0.1);border:1px solid rgba(16,185,129,0.3);border-radius:8px;margin-bottom:24px;">
|
||
<div style="display:flex;align-items:center;gap:12px;">
|
||
<span style="font-size:24px;">✅</span>
|
||
<div>
|
||
<div style="font-weight:600;color:var(--green);margin-bottom:4px;">MFA is enabled</div>
|
||
<div style="font-size:13px;color:var(--text-secondary);">Your account is protected with two-factor authentication</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button onclick="disableMFA()" id="disableMfaBtn" style="padding:12px 24px;background:linear-gradient(135deg,var(--red),#dc2626);color:white;border:none;border-radius:8px;font-weight:600;cursor:pointer;font-size:14px;">
|
||
⚠️ Disable MFA
|
||
</button>
|
||
</div>
|
||
|
||
<div id="mfaSetupView" style="display:none;">
|
||
<p style="color:var(--text-secondary);margin-bottom:16px;line-height:1.6;">
|
||
Scan this QR code with your authenticator app:
|
||
</p>
|
||
<div id="qrCodeContainer" style="display:flex;justify-content:center;margin:24px 0;padding:24px;background:white;border-radius:12px;"></div>
|
||
<div style="margin-bottom:24px;">
|
||
<div style="font-size:13px;font-weight:600;color:var(--text-secondary);margin-bottom:8px;">Or enter this secret manually:</div>
|
||
<div style="padding:12px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;font-family:'JetBrains Mono',monospace;font-size:14px;word-break:break-all;" id="mfaSecretDisplay"></div>
|
||
</div>
|
||
<div style="margin-bottom:16px;">
|
||
<div style="font-size:13px;font-weight:600;color:var(--text-secondary);margin-bottom:8px;">Enter the 6-digit code from your authenticator app to verify:</div>
|
||
<input type="text" id="mfaVerifyCode" placeholder="000000" maxlength="6" pattern="[0-9]{6}" inputmode="numeric" style="width:200px;padding:12px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;color:var(--text-primary);font-size:18px;font-family:'JetBrains Mono',monospace;text-align:center;letter-spacing:0.5em;">
|
||
<div id="mfaVerifyError" style="display:none;color:var(--red);font-size:12px;margin-top:8px;"></div>
|
||
</div>
|
||
<button onclick="confirmMFASetup()" id="confirmMfaBtn" style="padding:12px 24px;background:linear-gradient(135deg,var(--accent),var(--purple));color:white;border:none;border-radius:8px;font-weight:600;cursor:pointer;font-size:14px;">
|
||
✅ Verify & Enable
|
||
</button>
|
||
<button onclick="cancelMFASetup()" style="padding:12px 24px;background:var(--bg-tertiary);color:var(--text-secondary);border:1px solid var(--border);border-radius:8px;font-weight:600;cursor:pointer;font-size:14px;margin-left:12px;">
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="page" id="sys-security">
|
||
<div class="page-header">
|
||
<h1 class="page-title">System Security</h1>
|
||
<p class="page-subtitle">Firewall, ports, fail2ban and SSH monitoring</p>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px;">
|
||
<div class="card">
|
||
<div class="card-title">🔥 Firewall (UFW)</div>
|
||
<pre id="secUfw" style="font-size:12px;max-height:300px;overflow-y:auto;white-space:pre-wrap;">Loading...</pre>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-title">🚪 Open Ports</div>
|
||
<pre id="secPorts" style="font-size:12px;max-height:300px;overflow-y:auto;white-space:pre-wrap;">Loading...</pre>
|
||
</div>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
|
||
<div class="card">
|
||
<div class="card-title">🛡️ Fail2ban Status</div>
|
||
<pre id="secF2b" style="font-size:12px;max-height:300px;overflow-y:auto;white-space:pre-wrap;">Loading...</pre>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-title">🔑 Failed SSH Logins (last 50)</div>
|
||
<pre id="secSsh" style="font-size:12px;max-height:300px;overflow-y:auto;white-space:pre-wrap;">Loading...</pre>
|
||
</div>
|
||
</div>
|
||
<div class="card" style="margin-top:16px;">
|
||
<div class="card-title">🔍 OpenClaw Security Audit</div>
|
||
<pre id="secAudit" style="font-size:12px;max-height:400px;overflow-y:auto;white-space:pre-wrap;">Loading...</pre>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="page" id="config-editor">
|
||
<div class="page-header">
|
||
<h1 class="page-title">Config Editor</h1>
|
||
<p class="page-subtitle">Edit OpenClaw configuration</p>
|
||
</div>
|
||
<div class="card">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
|
||
<div class="card-title" style="margin:0;">📝 openclaw.json</div>
|
||
<div style="display:flex;gap:8px;">
|
||
<button onclick="loadConfig()" style="padding:8px 16px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;color:var(--text-primary);cursor:pointer;font-size:13px;">↻ Reload</button>
|
||
<button onclick="saveConfig()" id="configSaveBtn" style="padding:8px 16px;background:linear-gradient(135deg,var(--accent),var(--purple));color:#fff;border:none;border-radius:8px;font-weight:600;cursor:pointer;font-size:13px;">💾 Save & Restart</button>
|
||
</div>
|
||
</div>
|
||
<div id="configError" style="display:none;color:var(--red);font-size:12px;margin-bottom:12px;padding:10px;background:rgba(239,68,68,0.1);border-radius:6px;border:1px solid rgba(239,68,68,0.3);"></div>
|
||
<div id="configSuccess" style="display:none;color:var(--green);font-size:12px;margin-bottom:12px;padding:10px;background:rgba(16,185,129,0.1);border-radius:6px;border:1px solid rgba(16,185,129,0.3);"></div>
|
||
<textarea id="configTextarea" spellcheck="false" style="width:100%;min-height:500px;padding:16px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;color:var(--text-primary);font-family:'JetBrains Mono',monospace;font-size:13px;line-height:1.5;resize:vertical;tab-size:2;" placeholder="Loading..."></textarea>
|
||
<div style="margin-top:8px;font-size:11px;color:var(--text-muted);">⚠️ Saving will create a backup and restart the OpenClaw gateway. Be careful with changes.</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="page" id="docker">
|
||
<div class="page-header">
|
||
<h1 class="page-title">Docker</h1>
|
||
<p class="page-subtitle">Containers, images and system info</p>
|
||
</div>
|
||
<div class="card" style="margin-bottom:16px;">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
|
||
<div class="card-title" style="margin:0;">📦 Containers</div>
|
||
<div style="display:flex;gap:8px;">
|
||
<button onclick="dockerAction('prune-containers')" style="padding:6px 14px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;color:var(--red);cursor:pointer;font-size:12px;">🗑 Prune Stopped</button>
|
||
<button onclick="loadDocker()" style="padding:6px 14px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;color:var(--text-primary);cursor:pointer;font-size:12px;">↻ Refresh</button>
|
||
</div>
|
||
</div>
|
||
<div id="dockerContainers" style="overflow-x:auto;"><div style="color:var(--text-muted);font-size:13px;">Loading...</div></div>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
|
||
<div class="card">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
|
||
<div class="card-title" style="margin:0;">💿 Images</div>
|
||
<button onclick="dockerAction('prune-images')" style="padding:6px 14px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;color:var(--red);cursor:pointer;font-size:12px;">🗑 Prune Unused</button>
|
||
</div>
|
||
<div id="dockerImages" style="overflow-x:auto;"><div style="color:var(--text-muted);font-size:13px;">Loading...</div></div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-title">📊 System</div>
|
||
<pre id="dockerSystem" style="font-size:12px;max-height:300px;overflow-y:auto;white-space:pre-wrap;">Loading...</pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</main>
|
||
</div>
|
||
|
||
<div class="toast-container" id="toastContainer"></div>
|
||
|
||
<div class="notification-bell" id="notificationBell" onclick="toggleNotifPanel()" title="Notifications">🔔<span class="notification-badge" id="notifBadge">0</span></div>
|
||
<div class="notif-panel" id="notifPanel">
|
||
<div class="notif-panel-header"><span>Notifications</span><button onclick="toggleNotifPanel()" style="background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:16px;">✕</button></div>
|
||
<div class="notif-panel-body" id="notifPanelBody"><div style="padding:20px;text-align:center;color:var(--text-muted);">Loading...</div></div>
|
||
</div>
|
||
|
||
<div class="shortcuts-overlay" id="shortcutsOverlay" onclick="if(event.target===this)toggleShortcuts()">
|
||
<div class="shortcuts-panel">
|
||
<h2 style="font-size:24px;font-weight:700;margin-bottom:24px;">Keyboard Shortcuts</h2>
|
||
<div class="shortcut-item"><span>Switch to Overview</span><span class="shortcut-key">1</span></div>
|
||
<div class="shortcut-item"><span>Switch to Sessions</span><span class="shortcut-key">2</span></div>
|
||
<div class="shortcut-item"><span>Switch to Costs</span><span class="shortcut-key">3</span></div>
|
||
<div class="shortcut-item"><span>Switch to Rate Limits</span><span class="shortcut-key">4</span></div>
|
||
<div class="shortcut-item"><span>Switch to Live Feed</span><span class="shortcut-key">5</span></div>
|
||
<div class="shortcut-item"><span>Pause/Resume Feed</span><span class="shortcut-key">Space</span></div>
|
||
<div class="shortcut-item"><span>Focus Search</span><span class="shortcut-key">/</span></div>
|
||
<div class="shortcut-item"><span>Close Modals</span><span class="shortcut-key">Esc</span></div>
|
||
<div class="shortcut-item" style="border:none;"><span>Show This Help</span><span class="shortcut-key">?</span></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="session-modal-overlay" id="sessionModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:500;display:none;align-items:center;justify-content:center;" onclick="if(event.target===this)closeSessionModal()">
|
||
<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:16px;width:90%;max-width:700px;max-height:80vh;overflow:hidden;display:flex;flex-direction:column;">
|
||
<div style="padding:20px 24px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;">
|
||
<div>
|
||
<h2 style="font-size:18px;font-weight:700;" id="modalTitle">Session</h2>
|
||
<div style="font-size:12px;color:var(--text-muted);margin-top:4px;font-family:'JetBrains Mono',monospace;" id="modalKey"></div>
|
||
</div>
|
||
<button onclick="closeSessionModal()" style="background:none;border:none;color:var(--text-secondary);font-size:24px;cursor:pointer;padding:4px 8px;">✕</button>
|
||
</div>
|
||
<div style="padding:20px 24px;display:grid;grid-template-columns:1fr 1fr;gap:16px;" id="modalStats"></div>
|
||
<div style="padding:0 24px 12px;"><h3 style="font-size:13px;font-weight:600;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.1em;">Recent Messages</h3></div>
|
||
<div style="padding:0 24px 20px;overflow-y:auto;flex:1;max-height:400px;" id="modalMessages"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="status-bar">
|
||
<div class="status-bar-section">
|
||
<div class="status-bar-item">
|
||
<span class="status-indicator"></span>
|
||
<span>Connected</span>
|
||
</div>
|
||
<div class="status-bar-item">
|
||
<span>Uptime: <span id="statusUptime">--</span></span>
|
||
</div>
|
||
<div class="status-bar-item">
|
||
<span>Model: <span id="statusModel">--</span></span>
|
||
</div>
|
||
</div>
|
||
<div class="status-bar-section">
|
||
<div class="status-bar-item">
|
||
<span>Last activity: <span id="statusLastActivity">--</span></span>
|
||
</div>
|
||
<div class="status-bar-item">
|
||
<span id="statusVersion">OpenClaw</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
function getApiBasePath() {
|
||
var pathname = window.location.pathname;
|
||
if (!pathname || pathname === '/') return '';
|
||
var cleaned = pathname.replace(/\/+$/, '');
|
||
if (cleaned.endsWith('/index.html')) {
|
||
return cleaned.slice(0, -'/index.html'.length);
|
||
}
|
||
return cleaned;
|
||
}
|
||
|
||
const API_BASE = getApiBasePath();
|
||
let sysSecAuthed = false;
|
||
const TOKEN_KEY = 'dashboardToken';
|
||
const TOKEN_EXPIRY_KEY = 'dashboardTokenExpiry';
|
||
const TOKEN_LIFETIME = 24 * 60 * 60 * 1000;
|
||
const REMEMBER_ME_LIFETIME = 3 * 60 * 60 * 1000;
|
||
|
||
function getStoredToken() {
|
||
let token = sessionStorage.getItem(TOKEN_KEY);
|
||
let expiry = sessionStorage.getItem(TOKEN_EXPIRY_KEY);
|
||
|
||
if (!token || !expiry) {
|
||
token = localStorage.getItem(TOKEN_KEY);
|
||
expiry = localStorage.getItem(TOKEN_EXPIRY_KEY);
|
||
}
|
||
|
||
if (token && expiry) {
|
||
if (Date.now() < parseInt(expiry)) {
|
||
return token;
|
||
}
|
||
clearStoredToken();
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function setStoredToken(token, rememberMe = false) {
|
||
if (rememberMe) {
|
||
const expiry = Date.now() + REMEMBER_ME_LIFETIME;
|
||
localStorage.setItem(TOKEN_KEY, token);
|
||
localStorage.setItem(TOKEN_EXPIRY_KEY, expiry.toString());
|
||
sessionStorage.removeItem(TOKEN_KEY);
|
||
sessionStorage.removeItem(TOKEN_EXPIRY_KEY);
|
||
} else {
|
||
sessionStorage.setItem(TOKEN_KEY, token);
|
||
sessionStorage.setItem(TOKEN_EXPIRY_KEY, (Date.now() + TOKEN_LIFETIME).toString());
|
||
localStorage.removeItem(TOKEN_KEY);
|
||
localStorage.removeItem(TOKEN_EXPIRY_KEY);
|
||
}
|
||
}
|
||
|
||
function clearStoredToken() {
|
||
localStorage.removeItem(TOKEN_KEY);
|
||
localStorage.removeItem(TOKEN_EXPIRY_KEY);
|
||
sessionStorage.removeItem(TOKEN_KEY);
|
||
sessionStorage.removeItem(TOKEN_EXPIRY_KEY);
|
||
}
|
||
|
||
function showRegistrationForm() {
|
||
document.getElementById('authTitle').textContent = 'Create Account';
|
||
document.getElementById('authSubtitle').textContent = 'Set up your dashboard credentials';
|
||
document.getElementById('registerForm').style.display = 'block';
|
||
document.getElementById('loginForm').style.display = 'none';
|
||
document.getElementById('recoveryForm').style.display = 'none';
|
||
setTimeout(() => {
|
||
const el = document.getElementById('regUsername');
|
||
if (el) el.focus();
|
||
}, 100);
|
||
}
|
||
|
||
function showLoginForm() {
|
||
document.getElementById('authTitle').textContent = 'Dashboard Login';
|
||
document.getElementById('authSubtitle').textContent = 'Enter your credentials';
|
||
document.getElementById('registerForm').style.display = 'none';
|
||
document.getElementById('loginForm').style.display = 'block';
|
||
document.getElementById('recoveryForm').style.display = 'none';
|
||
document.getElementById('usernameInputContainer').style.display = 'block';
|
||
document.getElementById('passwordInputContainer').style.display = 'block';
|
||
document.getElementById('totpInputContainer').style.display = 'none';
|
||
setTimeout(() => {
|
||
const el = document.getElementById('username');
|
||
if (el) el.focus();
|
||
}, 100);
|
||
}
|
||
|
||
function showRecoveryForm() {
|
||
document.getElementById('authTitle').textContent = 'Reset Password';
|
||
document.getElementById('authSubtitle').textContent = 'Enter recovery token and new password';
|
||
document.getElementById('registerForm').style.display = 'none';
|
||
document.getElementById('loginForm').style.display = 'none';
|
||
document.getElementById('recoveryForm').style.display = 'block';
|
||
setTimeout(() => {
|
||
const el = document.getElementById('recoveryToken');
|
||
if (el) el.focus();
|
||
}, 100);
|
||
}
|
||
|
||
function calculatePasswordStrength(password) {
|
||
let strength = 0;
|
||
if (password.length >= 8) strength += 25;
|
||
if (password.length >= 12) strength += 15;
|
||
if (/[a-z]/.test(password)) strength += 15;
|
||
if (/[A-Z]/.test(password)) strength += 15;
|
||
if (/[0-9]/.test(password)) strength += 15;
|
||
if (/[^a-zA-Z0-9]/.test(password)) strength += 15;
|
||
return Math.min(strength, 100);
|
||
}
|
||
|
||
if (typeof document !== 'undefined') {
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
const regPassword = document.getElementById('regPassword');
|
||
const strengthBar = document.getElementById('passwordStrengthBar');
|
||
const strengthText = document.getElementById('passwordStrengthText');
|
||
|
||
if (regPassword && strengthBar && strengthText) {
|
||
regPassword.addEventListener('input', (e) => {
|
||
const password = e.target.value;
|
||
const strength = calculatePasswordStrength(password);
|
||
strengthBar.style.width = strength + '%';
|
||
|
||
if (strength < 40) {
|
||
strengthBar.style.background = 'var(--red)';
|
||
strengthText.textContent = 'Weak password';
|
||
strengthText.style.color = 'var(--red)';
|
||
} else if (strength < 70) {
|
||
strengthBar.style.background = 'var(--yellow)';
|
||
strengthText.textContent = 'Medium strength';
|
||
strengthText.style.color = 'var(--yellow)';
|
||
} else {
|
||
strengthBar.style.background = 'var(--green)';
|
||
strengthText.textContent = 'Strong password';
|
||
strengthText.style.color = 'var(--green)';
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
async function handleRegister(event) {
|
||
event.preventDefault();
|
||
const username = document.getElementById('regUsername').value.trim();
|
||
const password = document.getElementById('regPassword').value;
|
||
const confirmPassword = document.getElementById('regPasswordConfirm').value;
|
||
const registerBtn = document.getElementById('registerBtn');
|
||
const registerError = document.getElementById('registerError');
|
||
|
||
registerError.style.display = 'none';
|
||
|
||
if (password !== confirmPassword) {
|
||
registerError.textContent = 'Passwords do not match';
|
||
registerError.style.display = 'block';
|
||
return false;
|
||
}
|
||
|
||
if (password.length < 8) {
|
||
registerError.textContent = 'Password must be at least 8 characters';
|
||
registerError.style.display = 'block';
|
||
return false;
|
||
}
|
||
|
||
if (!/[a-zA-Z]/.test(password) || !/[0-9]/.test(password)) {
|
||
registerError.textContent = 'Password must contain at least 1 letter and 1 number';
|
||
registerError.style.display = 'block';
|
||
return false;
|
||
}
|
||
|
||
registerBtn.disabled = true;
|
||
registerBtn.textContent = 'Creating account...';
|
||
|
||
try {
|
||
const res = await fetch(API_BASE + '/api/auth/register', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ username, password })
|
||
});
|
||
|
||
var data = {};
|
||
try { data = await res.json(); } catch (e) { data = {}; }
|
||
|
||
if (res.ok && data.success) {
|
||
setStoredToken(data.sessionToken, false);
|
||
showApp();
|
||
} else {
|
||
registerError.textContent = data.error || ('Registration failed (' + res.status + ')');
|
||
registerError.style.display = 'block';
|
||
registerBtn.disabled = false;
|
||
registerBtn.textContent = 'Create Account';
|
||
}
|
||
} catch (err) {
|
||
registerError.textContent = 'Network error. Please try again.';
|
||
registerError.style.display = 'block';
|
||
registerBtn.disabled = false;
|
||
registerBtn.textContent = 'Create Account';
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
async function handleLogin(event) {
|
||
event.preventDefault();
|
||
const username = document.getElementById('username').value.trim();
|
||
const password = document.getElementById('password').value;
|
||
const totpInput = document.getElementById('totpInput');
|
||
const totpCode = totpInput.value.trim();
|
||
const loginBtn = document.getElementById('loginBtn');
|
||
const loginError = document.getElementById('loginError');
|
||
const rememberMe = document.getElementById('rememberMeCheckbox').checked;
|
||
|
||
loginBtn.disabled = true;
|
||
loginBtn.textContent = 'Logging in...';
|
||
loginError.style.display = 'none';
|
||
|
||
try {
|
||
const body = { username, password };
|
||
if (totpCode) {
|
||
body.totpCode = totpCode;
|
||
}
|
||
if (rememberMe) {
|
||
body.rememberMe = true;
|
||
}
|
||
|
||
const res = await fetch(API_BASE + '/api/auth/login', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body)
|
||
});
|
||
|
||
var data = {};
|
||
try { data = await res.json(); } catch (e) { data = {}; }
|
||
|
||
if (data.requiresMfa) {
|
||
document.getElementById('authSubtitle').textContent = 'Enter your 6-digit TOTP code';
|
||
document.getElementById('usernameInputContainer').style.display = 'none';
|
||
document.getElementById('passwordInputContainer').style.display = 'none';
|
||
document.getElementById('totpInputContainer').style.display = 'block';
|
||
totpInput.focus();
|
||
loginBtn.disabled = false;
|
||
loginBtn.textContent = 'Verify';
|
||
return false;
|
||
}
|
||
|
||
if (res.ok && data.success) {
|
||
setStoredToken(data.sessionToken, rememberMe);
|
||
showApp();
|
||
} else {
|
||
if (data.lockoutRemaining) {
|
||
loginError.textContent = `Too many failed attempts. Try again in ${data.lockoutRemaining} seconds.`;
|
||
} else {
|
||
loginError.textContent = data.error || 'Invalid credentials';
|
||
}
|
||
loginError.style.display = 'block';
|
||
loginBtn.disabled = false;
|
||
loginBtn.textContent = totpCode ? 'Verify' : 'Login';
|
||
|
||
if (totpCode) {
|
||
totpInput.value = '';
|
||
totpInput.focus();
|
||
}
|
||
}
|
||
} catch (err) {
|
||
loginError.textContent = 'Network error. Please try again.';
|
||
loginError.style.display = 'block';
|
||
loginBtn.disabled = false;
|
||
loginBtn.textContent = totpCode ? 'Verify' : 'Login';
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
async function handleRecovery(event) {
|
||
event.preventDefault();
|
||
const recoveryToken = document.getElementById('recoveryToken').value.trim();
|
||
const newPassword = document.getElementById('recoveryNewPassword').value;
|
||
const confirmPassword = document.getElementById('recoveryNewPasswordConfirm').value;
|
||
const recoveryBtn = document.getElementById('recoveryBtn');
|
||
const recoveryError = document.getElementById('recoveryError');
|
||
|
||
recoveryError.style.display = 'none';
|
||
|
||
if (newPassword !== confirmPassword) {
|
||
recoveryError.textContent = 'Passwords do not match';
|
||
recoveryError.style.display = 'block';
|
||
return false;
|
||
}
|
||
|
||
if (newPassword.length < 8) {
|
||
recoveryError.textContent = 'Password must be at least 8 characters';
|
||
recoveryError.style.display = 'block';
|
||
return false;
|
||
}
|
||
|
||
recoveryBtn.disabled = true;
|
||
recoveryBtn.textContent = 'Resetting password...';
|
||
|
||
try {
|
||
const res = await fetch(API_BASE + '/api/auth/reset-password', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ recoveryToken, newPassword })
|
||
});
|
||
|
||
var data = {};
|
||
try { data = await res.json(); } catch (e) { data = {}; }
|
||
|
||
if (res.ok && data.success) {
|
||
showToast('Password reset successfully! Please login.', 'success');
|
||
showLoginForm();
|
||
} else {
|
||
recoveryError.textContent = data.error || ('Password reset failed (' + res.status + ')');
|
||
recoveryError.style.display = 'block';
|
||
recoveryBtn.disabled = false;
|
||
recoveryBtn.textContent = 'Reset Password';
|
||
}
|
||
} catch (err) {
|
||
recoveryError.textContent = 'Network error. Please try again.';
|
||
recoveryError.style.display = 'block';
|
||
recoveryBtn.disabled = false;
|
||
recoveryBtn.textContent = 'Reset Password';
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
async function handleChangePassword(event) {
|
||
event.preventDefault();
|
||
const currentPassword = document.getElementById('currentPassword').value;
|
||
const newPassword = document.getElementById('newPassword').value;
|
||
const confirmPassword = document.getElementById('newPasswordConfirm').value;
|
||
const changePasswordBtn = document.getElementById('changePasswordBtn');
|
||
const changePasswordError = document.getElementById('changePasswordError');
|
||
|
||
changePasswordError.style.display = 'none';
|
||
|
||
if (newPassword !== confirmPassword) {
|
||
changePasswordError.textContent = 'New passwords do not match';
|
||
changePasswordError.style.display = 'block';
|
||
return false;
|
||
}
|
||
|
||
if (newPassword.length < 8) {
|
||
changePasswordError.textContent = 'New password must be at least 8 characters';
|
||
changePasswordError.style.display = 'block';
|
||
return false;
|
||
}
|
||
|
||
changePasswordBtn.disabled = true;
|
||
changePasswordBtn.textContent = 'Changing password...';
|
||
|
||
try {
|
||
const res = await authFetch(API_BASE + '/api/auth/change-password', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ currentPassword, newPassword })
|
||
});
|
||
|
||
const data = await res.json();
|
||
|
||
if (res.ok && data.success) {
|
||
showToast('Password changed successfully! Other sessions have been invalidated.', 'success');
|
||
document.getElementById('currentPassword').value = '';
|
||
document.getElementById('newPassword').value = '';
|
||
document.getElementById('newPasswordConfirm').value = '';
|
||
} else {
|
||
changePasswordError.textContent = data.error || 'Password change failed';
|
||
changePasswordError.style.display = 'block';
|
||
}
|
||
} catch (err) {
|
||
changePasswordError.textContent = err.message || 'Network error. Please try again.';
|
||
changePasswordError.style.display = 'block';
|
||
} finally {
|
||
changePasswordBtn.disabled = false;
|
||
changePasswordBtn.textContent = 'Change Password';
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
async function handleLogout() {
|
||
if (!confirm('Are you sure you want to logout?')) return;
|
||
|
||
try {
|
||
await authFetch(API_BASE + '/api/auth/logout', { method: 'POST' });
|
||
} catch (e) {
|
||
}
|
||
|
||
clearStoredToken();
|
||
location.reload();
|
||
}
|
||
|
||
function showApp() {
|
||
document.getElementById('loginPage').style.display = 'none';
|
||
document.getElementById('mainApp').style.display = 'flex';
|
||
fetchData();
|
||
fetchNewData();
|
||
fetchHealthHistory();
|
||
fetchMemoryFiles();
|
||
fetchKeyFiles();
|
||
checkMFAStatus();
|
||
|
||
if (localStorage.getItem('usageAutoRefresh') === '1') {
|
||
const cb = document.getElementById('usageAutoRefresh');
|
||
if (cb) { cb.checked = true; toggleUsageAutoRefresh(true, true); }
|
||
}
|
||
setInterval(fetchData, 5000);
|
||
setInterval(fetchNewData, 15000);
|
||
setInterval(fetchHealthHistory, 60000);
|
||
setInterval(fetchMemoryFiles, 30000);
|
||
setInterval(fetchKeyFiles, 30000);
|
||
}
|
||
|
||
function showLogin() {
|
||
document.getElementById('loginPage').style.display = 'flex';
|
||
document.getElementById('mainApp').style.display = 'none';
|
||
}
|
||
|
||
async function checkAuth() {
|
||
try {
|
||
const statusRes = await fetch(API_BASE + '/api/auth/status');
|
||
const statusData = await statusRes.json();
|
||
|
||
const token = getStoredToken();
|
||
|
||
if (token) {
|
||
const verifyRes = await fetch(API_BASE + '/api/sessions', {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
});
|
||
if (verifyRes.ok) {
|
||
showApp();
|
||
return;
|
||
} else {
|
||
clearStoredToken();
|
||
}
|
||
}
|
||
|
||
if (statusData.registered === false) {
|
||
showRegistrationForm();
|
||
showLogin();
|
||
} else {
|
||
showLoginForm();
|
||
showLogin();
|
||
}
|
||
} catch (err) {
|
||
showLoginForm();
|
||
showLogin();
|
||
}
|
||
}
|
||
|
||
function authFetch(url, options = {}) {
|
||
const token = getStoredToken();
|
||
if (!token) {
|
||
showLogin();
|
||
throw new Error('Not authenticated');
|
||
}
|
||
|
||
options.headers = options.headers || {};
|
||
options.headers['Authorization'] = `Bearer ${token}`;
|
||
if (typeof options.cache === 'undefined') {
|
||
options.cache = 'no-store';
|
||
}
|
||
|
||
return fetch(url, options).then(res => {
|
||
if (res.status === 401) {
|
||
clearStoredToken();
|
||
showLogin();
|
||
throw new Error('Session expired');
|
||
}
|
||
return res;
|
||
});
|
||
}
|
||
|
||
const qrcodegen = (function() {
|
||
'use strict';
|
||
|
||
class QrCode {
|
||
constructor(version, errorCorrectionLevel, dataCodewords, msk) {
|
||
this.version = version;
|
||
this.errorCorrectionLevel = errorCorrectionLevel;
|
||
this.size = version * 4 + 17;
|
||
this.mask = msk;
|
||
|
||
const qr = [];
|
||
for (let i = 0; i < this.size; i++)
|
||
qr.push(new Array(this.size).fill(false));
|
||
this.modules = qr;
|
||
this.isFunction = qr.map(row => row.slice());
|
||
|
||
this.drawFunctionPatterns();
|
||
const allCodewords = this.addEccAndInterleave(dataCodewords);
|
||
this.drawCodewords(allCodewords);
|
||
this.applyMask(msk);
|
||
this.drawFormatBits(msk);
|
||
this.isFunction = null;
|
||
}
|
||
|
||
static encodeText(text, ecl) {
|
||
const segs = QrSegment.makeSegments(text);
|
||
return QrCode.encodeSegments(segs, ecl);
|
||
}
|
||
|
||
static encodeSegments(segs, ecl, minVersion = 1, maxVersion = 40, mask = -1, boostEcl = true) {
|
||
const version = QrCode.MIN_VERSION;
|
||
for (let v = minVersion; ; v++) {
|
||
const dataCapacityBits = QrCode.getNumDataCodewords(v, ecl) * 8;
|
||
const dataUsedBits = QrSegment.getTotalBits(segs, v);
|
||
if (dataUsedBits <= dataCapacityBits) {
|
||
const bb = [];
|
||
for (const seg of segs) {
|
||
bb.push(...seg.getData());
|
||
}
|
||
while (bb.length < dataCapacityBits)
|
||
bb.push(0);
|
||
const dataCodewords = [];
|
||
for (let i = 0; i < bb.length; i += 8) {
|
||
let byte = 0;
|
||
for (let j = 0; j < 8; j++)
|
||
byte = (byte << 1) | (bb[i + j] || 0);
|
||
dataCodewords.push(byte);
|
||
}
|
||
return new QrCode(v, ecl, dataCodewords, mask === -1 ? 0 : mask);
|
||
}
|
||
if (v >= maxVersion)
|
||
throw new RangeError('Data too long');
|
||
}
|
||
}
|
||
|
||
getModule(x, y) {
|
||
return this.modules[y][x];
|
||
}
|
||
|
||
drawFunctionPatterns() {
|
||
for (let i = 0; i < this.size; i++) {
|
||
this.setFunctionModule(6, i, i % 2 === 0);
|
||
this.setFunctionModule(i, 6, i % 2 === 0);
|
||
}
|
||
this.drawFinderPattern(3, 3);
|
||
this.drawFinderPattern(this.size - 4, 3);
|
||
this.drawFinderPattern(3, this.size - 4);
|
||
}
|
||
|
||
drawFinderPattern(x, y) {
|
||
for (let dy = -4; dy <= 4; dy++) {
|
||
for (let dx = -4; dx <= 4; dx++) {
|
||
const dist = Math.max(Math.abs(dx), Math.abs(dy));
|
||
const xx = x + dx;
|
||
const yy = y + dy;
|
||
if (0 <= xx && xx < this.size && 0 <= yy && yy < this.size)
|
||
this.setFunctionModule(xx, yy, dist !== 2 && dist !== 4);
|
||
}
|
||
}
|
||
}
|
||
|
||
drawFormatBits(mask) {
|
||
const data = this.errorCorrectionLevel.formatBits << 3 | mask;
|
||
let rem = data;
|
||
for (let i = 0; i < 10; i++)
|
||
rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
|
||
const bits = (data << 10 | rem) ^ 0x5412;
|
||
for (let i = 0; i <= 5; i++)
|
||
this.setFunctionModule(8, i, getBit(bits, i));
|
||
this.setFunctionModule(8, 7, getBit(bits, 6));
|
||
this.setFunctionModule(8, 8, getBit(bits, 7));
|
||
this.setFunctionModule(7, 8, getBit(bits, 8));
|
||
for (let i = 9; i < 15; i++)
|
||
this.setFunctionModule(14 - i, 8, getBit(bits, i));
|
||
for (let i = 0; i < 8; i++)
|
||
this.setFunctionModule(this.size - 1 - i, 8, getBit(bits, i));
|
||
for (let i = 8; i < 15; i++)
|
||
this.setFunctionModule(8, this.size - 15 + i, getBit(bits, i));
|
||
this.setFunctionModule(8, this.size - 8, true);
|
||
}
|
||
|
||
drawCodewords(data) {
|
||
let i = 0;
|
||
for (let right = this.size - 1; right >= 1; right -= 2) {
|
||
if (right === 6) right = 5;
|
||
for (let vert = 0; vert < this.size; vert++) {
|
||
for (let j = 0; j < 2; j++) {
|
||
const x = right - j;
|
||
const upward = ((right + 1) & 2) === 0;
|
||
const y = upward ? this.size - 1 - vert : vert;
|
||
if (!this.isFunction[y][x] && i < data.length * 8) {
|
||
this.modules[y][x] = getBit(data[i >>> 3], 7 - (i & 7));
|
||
i++;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
applyMask(mask) {
|
||
for (let y = 0; y < this.size; y++) {
|
||
for (let x = 0; x < this.size; x++) {
|
||
if (!this.isFunction[y][x]) {
|
||
let invert = false;
|
||
if (mask === 0) invert = (x + y) % 2 === 0;
|
||
if (invert) this.modules[y][x] = !this.modules[y][x];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
setFunctionModule(x, y, isDark) {
|
||
this.modules[y][x] = isDark;
|
||
this.isFunction[y][x] = true;
|
||
}
|
||
|
||
addEccAndInterleave(data) {
|
||
const ver = this.version;
|
||
const ecl = this.errorCorrectionLevel;
|
||
const numBlocks = QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver];
|
||
const blockEccLen = QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver];
|
||
const rawCodewords = Math.floor(QrCode.getNumRawDataModules(ver) / 8);
|
||
const numShortBlocks = numBlocks - rawCodewords % numBlocks;
|
||
const shortBlockLen = Math.floor(rawCodewords / numBlocks);
|
||
|
||
const blocks = [];
|
||
const rsDiv = QrCode.reedSolomonComputeDivisor(blockEccLen);
|
||
let k = 0;
|
||
for (let i = 0; i < numBlocks; i++) {
|
||
const dat = data.slice(k, k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1));
|
||
k += dat.length;
|
||
const ecc = QrCode.reedSolomonComputeRemainder(dat, rsDiv);
|
||
if (i < numShortBlocks)
|
||
dat.push(0);
|
||
blocks.push(dat.concat(ecc));
|
||
}
|
||
|
||
const result = [];
|
||
for (let i = 0; i < blocks[0].length; i++) {
|
||
blocks.forEach((block, j) => {
|
||
if (i !== shortBlockLen - blockEccLen || j >= numShortBlocks)
|
||
result.push(block[i]);
|
||
});
|
||
}
|
||
return result;
|
||
}
|
||
|
||
static getNumRawDataModules(ver) {
|
||
let result = (16 * ver + 128) * ver + 64;
|
||
if (ver >= 2) {
|
||
const numAlign = Math.floor(ver / 7) + 2;
|
||
result -= (25 * numAlign - 10) * numAlign - 55;
|
||
if (ver >= 7) result -= 36;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
static getNumDataCodewords(ver, ecl) {
|
||
return Math.floor(QrCode.getNumRawDataModules(ver) / 8) -
|
||
QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver] *
|
||
QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver];
|
||
}
|
||
|
||
static reedSolomonComputeDivisor(degree) {
|
||
const result = [];
|
||
for (let i = 0; i < degree - 1; i++)
|
||
result.push(0);
|
||
result.push(1);
|
||
let root = 1;
|
||
for (let i = 0; i < degree; i++) {
|
||
for (let j = 0; j < result.length; j++) {
|
||
result[j] = QrCode.reedSolomonMultiply(result[j], root);
|
||
if (j + 1 < result.length)
|
||
result[j] ^= result[j + 1];
|
||
}
|
||
root = QrCode.reedSolomonMultiply(root, 0x02);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
static reedSolomonComputeRemainder(data, divisor) {
|
||
const result = divisor.map(() => 0);
|
||
for (const b of data) {
|
||
const factor = b ^ result.shift();
|
||
result.push(0);
|
||
divisor.forEach((coef, i) => result[i] ^= QrCode.reedSolomonMultiply(coef, factor));
|
||
}
|
||
return result;
|
||
}
|
||
|
||
static reedSolomonMultiply(x, y) {
|
||
if (x >>> 8 !== 0 || y >>> 8 !== 0) throw new RangeError('Byte out of range');
|
||
let z = 0;
|
||
for (let i = 7; i >= 0; i--) {
|
||
z = (z << 1) ^ ((z >>> 7) * 0x11D);
|
||
z ^= ((y >>> i) & 1) * x;
|
||
}
|
||
return z;
|
||
}
|
||
}
|
||
|
||
QrCode.MIN_VERSION = 1;
|
||
QrCode.MAX_VERSION = 40;
|
||
QrCode.Ecc = {
|
||
LOW: {ordinal: 0, formatBits: 1},
|
||
MEDIUM: {ordinal: 1, formatBits: 0},
|
||
QUARTILE: {ordinal: 2, formatBits: 3},
|
||
HIGH: {ordinal: 3, formatBits: 2}
|
||
};
|
||
QrCode.ECC_CODEWORDS_PER_BLOCK = [
|
||
[-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
|
||
[-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28],
|
||
[-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
|
||
[-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30]
|
||
];
|
||
QrCode.NUM_ERROR_CORRECTION_BLOCKS = [
|
||
[-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25],
|
||
[-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49],
|
||
[-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68],
|
||
[-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81]
|
||
];
|
||
|
||
class QrSegment {
|
||
constructor(mode, numChars, bitData) {
|
||
this.mode = mode;
|
||
this.numChars = numChars;
|
||
this.bitData = bitData;
|
||
}
|
||
|
||
static makeSegments(text) {
|
||
return [QrSegment.makeBytes(text)];
|
||
}
|
||
|
||
static makeBytes(data) {
|
||
const bb = [];
|
||
for (let i = 0; i < data.length; i++) {
|
||
const c = data.charCodeAt(i);
|
||
for (let j = 7; j >= 0; j--)
|
||
bb.push((c >>> j) & 1);
|
||
}
|
||
return new QrSegment({modeBits: 0x4, numBitsCharCount: [8, 16, 16]}, data.length, bb);
|
||
}
|
||
|
||
getData() {
|
||
const result = [];
|
||
for (let i = 0; i < 4; i++)
|
||
result.push((this.mode.modeBits >>> (3 - i)) & 1);
|
||
const ccBits = this.mode.numBitsCharCount[0];
|
||
for (let i = ccBits - 1; i >= 0; i--)
|
||
result.push((this.numChars >>> i) & 1);
|
||
result.push(...this.bitData);
|
||
return result;
|
||
}
|
||
|
||
static getTotalBits(segs, version) {
|
||
let result = 0;
|
||
for (const seg of segs) {
|
||
const ccbits = seg.mode.numBitsCharCount[0];
|
||
result += 4 + ccbits + seg.bitData.length;
|
||
}
|
||
return result;
|
||
}
|
||
}
|
||
|
||
function getBit(x, i) {
|
||
return ((x >>> i) & 1) !== 0;
|
||
}
|
||
|
||
return {QrCode, QrSegment};
|
||
})();
|
||
|
||
async function checkMFAStatus() {
|
||
try {
|
||
const res = await authFetch(API_BASE + '/api/auth/mfa-status');
|
||
const data = await res.json();
|
||
const enabled = data.enabled;
|
||
|
||
const indicator = document.getElementById('mfaStatusIndicator');
|
||
if (enabled) {
|
||
indicator.textContent = '🔒 MFA Enabled';
|
||
indicator.style.background = 'rgba(16,185,129,0.15)';
|
||
indicator.style.color = 'var(--green)';
|
||
indicator.style.border = '1px solid rgba(16,185,129,0.3)';
|
||
document.getElementById('mfaEnabledView').style.display = 'block';
|
||
document.getElementById('mfaDisabledView').style.display = 'none';
|
||
} else {
|
||
indicator.textContent = '⚠️ MFA Disabled';
|
||
indicator.style.background = 'rgba(245,158,11,0.15)';
|
||
indicator.style.color = 'var(--yellow)';
|
||
indicator.style.border = '1px solid rgba(245,158,11,0.3)';
|
||
document.getElementById('mfaEnabledView').style.display = 'none';
|
||
document.getElementById('mfaDisabledView').style.display = 'block';
|
||
}
|
||
indicator.style.display = 'block';
|
||
document.getElementById('mfaSetupView').style.display = 'none';
|
||
} catch (err) {
|
||
console.error('Failed to check MFA status:', err);
|
||
}
|
||
}
|
||
|
||
function generateQRCode(text) {
|
||
const qr = qrcodegen.QrCode.encodeText(text, qrcodegen.QrCode.Ecc.MEDIUM);
|
||
const border = 4;
|
||
const s = qr.size + border * 2;
|
||
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${s} ${s}" width="256" height="256" shape-rendering="crispEdges">`;
|
||
svg += `<rect width="${s}" height="${s}" fill="#ffffff"/>`;
|
||
let path = '';
|
||
for (let y = 0; y < qr.size; y++) {
|
||
for (let x = 0; x < qr.size; x++) {
|
||
if (qr.getModule(x, y)) {
|
||
path += `M${x + border},${y + border}h1v1h-1z`;
|
||
}
|
||
}
|
||
}
|
||
svg += `<path d="${path}" fill="#000000"/>`;
|
||
svg += '</svg>';
|
||
const b64 = btoa(svg);
|
||
return `<img src="data:image/svg+xml;base64,${b64}" width="256" height="256" style="image-rendering:pixelated;">`;
|
||
}
|
||
|
||
async function setupMFA() {
|
||
const btn = document.getElementById('enableMfaBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Setting up...';
|
||
|
||
try {
|
||
const res = await authFetch(API_BASE + '/api/auth/setup-mfa', { method: 'POST' });
|
||
const data = await res.json();
|
||
|
||
if (res.ok && data.secret) {
|
||
document.getElementById('mfaSecretDisplay').textContent = data.secret;
|
||
document.getElementById('qrCodeContainer').innerHTML = generateQRCode(data.otpauth_uri);
|
||
document.getElementById('mfaDisabledView').style.display = 'none';
|
||
document.getElementById('mfaSetupView').style.display = 'block';
|
||
} else {
|
||
alert('Failed to setup MFA: ' + (data.error || 'Unknown error'));
|
||
btn.disabled = false;
|
||
btn.textContent = '✅ Enable MFA';
|
||
}
|
||
} catch (err) {
|
||
alert('Failed to setup MFA: ' + err.message);
|
||
btn.disabled = false;
|
||
btn.textContent = '✅ Enable MFA';
|
||
}
|
||
}
|
||
|
||
async function confirmMFASetup() {
|
||
const code = document.getElementById('mfaVerifyCode').value.trim();
|
||
const errorEl = document.getElementById('mfaVerifyError');
|
||
const btn = document.getElementById('confirmMfaBtn');
|
||
errorEl.style.display = 'none';
|
||
|
||
if (!code || code.length !== 6) {
|
||
errorEl.textContent = 'Enter the 6-digit code from your authenticator app.';
|
||
errorEl.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
btn.disabled = true;
|
||
btn.textContent = 'Verifying...';
|
||
|
||
try {
|
||
const res = await authFetch(API_BASE + '/api/auth/confirm-mfa', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ totpCode: code })
|
||
});
|
||
const data = await res.json();
|
||
|
||
if (res.ok && data.success) {
|
||
showToast('MFA enabled successfully!', 'success');
|
||
document.getElementById('mfaSetupView').style.display = 'none';
|
||
document.getElementById('mfaVerifyCode').value = '';
|
||
checkMFAStatus();
|
||
} else {
|
||
errorEl.textContent = data.error || 'Invalid code. Try again.';
|
||
errorEl.style.display = 'block';
|
||
document.getElementById('mfaVerifyCode').value = '';
|
||
document.getElementById('mfaVerifyCode').focus();
|
||
}
|
||
} catch (err) {
|
||
errorEl.textContent = 'Verification failed. Try again.';
|
||
errorEl.style.display = 'block';
|
||
}
|
||
|
||
btn.disabled = false;
|
||
btn.textContent = '✅ Verify & Enable';
|
||
}
|
||
|
||
function cancelMFASetup() {
|
||
document.getElementById('mfaSetupView').style.display = 'none';
|
||
document.getElementById('mfaVerifyCode').value = '';
|
||
checkMFAStatus();
|
||
}
|
||
|
||
async function disableMFA() {
|
||
const code = prompt('Enter your current 6-digit TOTP code to disable MFA:');
|
||
if (!code) return;
|
||
|
||
if (!/^\d{6}$/.test(code)) {
|
||
alert('Invalid code format. Must be 6 digits.');
|
||
return;
|
||
}
|
||
|
||
const btn = document.getElementById('disableMfaBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Disabling...';
|
||
|
||
try {
|
||
const res = await authFetch(API_BASE + '/api/auth/disable-mfa', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ totpCode: code })
|
||
});
|
||
|
||
const data = await res.json();
|
||
|
||
if (res.ok && data.success) {
|
||
alert('MFA has been disabled successfully.');
|
||
checkMFAStatus();
|
||
} else {
|
||
alert('Failed to disable MFA: ' + (data.error || 'Invalid code'));
|
||
btn.disabled = false;
|
||
btn.textContent = '⚠️ Disable MFA';
|
||
}
|
||
} catch (err) {
|
||
alert('Failed to disable MFA: ' + err.message);
|
||
btn.disabled = false;
|
||
btn.textContent = '⚠️ Disable MFA';
|
||
}
|
||
}
|
||
|
||
checkAuth();
|
||
setInterval(() => {
|
||
const token = getStoredToken();
|
||
if (!token) showLogin();
|
||
}, 60000);
|
||
|
||
let sessions = [];
|
||
let costs = {};
|
||
let usage = {};
|
||
let systemStats = {};
|
||
let feedPaused = true;
|
||
let liveEventSource = null;
|
||
let sortBy = 'updated';
|
||
let sortDir = 'desc';
|
||
let selectedSessions = new Set();
|
||
let notificationsEnabled = false;
|
||
|
||
document.querySelectorAll('.nav-item').forEach(item => {
|
||
item.addEventListener('click', () => {
|
||
const page = item.dataset.page;
|
||
if ((page === 'sys-security' || page === 'security' || page === 'config-editor') && !sysSecAuthed) { showReauthModal(page); return; }
|
||
document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('active'));
|
||
item.classList.add('active');
|
||
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||
document.getElementById(page).classList.add('active');
|
||
|
||
if (page === 'feed') {
|
||
// Don't auto-connect; user clicks Start
|
||
} else if (liveEventSource) {
|
||
liveEventSource.close();
|
||
liveEventSource = null;
|
||
}
|
||
|
||
if (page === 'memory') {
|
||
fetchMemoryFiles();
|
||
}
|
||
if (page === 'files') {
|
||
fetchKeyFiles();
|
||
}
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll('.view-all-link').forEach(link => {
|
||
link.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
const page = link.dataset.page;
|
||
document.querySelector(`[data-page="${page}"]`).click();
|
||
});
|
||
});
|
||
|
||
async function fetchData() {
|
||
try {
|
||
// Fast path: load sessions, costs, system first
|
||
const [sessRes, costsRes, sysRes] = await Promise.all([
|
||
authFetch(API_BASE + '/api/sessions'),
|
||
authFetch(API_BASE + '/api/costs'),
|
||
authFetch(API_BASE + '/api/system')
|
||
]);
|
||
|
||
sessions = await sessRes.json();
|
||
costs = await costsRes.json();
|
||
systemStats = await sysRes.json();
|
||
|
||
updateDashboard();
|
||
|
||
// Then load usage async (slower endpoint)
|
||
authFetch(API_BASE + '/api/usage').then(r => r.json()).then(u => {
|
||
usage = u;
|
||
try { updateDashboard(); } catch {}
|
||
}).catch(() => {});
|
||
} catch (err) {
|
||
console.error('Fetch error:', err);
|
||
}
|
||
}
|
||
|
||
function animateValue(elem, end) {
|
||
const start = parseFloat(elem.textContent.replace(/[^0-9.-]/g, '')) || 0;
|
||
const duration = 600;
|
||
const range = end - start;
|
||
const startTime = performance.now();
|
||
|
||
function update(currentTime) {
|
||
const elapsed = currentTime - startTime;
|
||
const progress = Math.min(elapsed / duration, 1);
|
||
const value = start + range * progress;
|
||
|
||
if (elem.dataset.format === 'currency') {
|
||
elem.textContent = '$' + value.toFixed(2);
|
||
} else if (elem.dataset.format === 'percent') {
|
||
elem.textContent = Math.round(value) + '%';
|
||
} else {
|
||
elem.textContent = Math.round(value).toLocaleString();
|
||
}
|
||
|
||
if (progress < 1) {
|
||
requestAnimationFrame(update);
|
||
}
|
||
}
|
||
|
||
requestAnimationFrame(update);
|
||
}
|
||
|
||
function updateRadialGauge(circleId, percent) {
|
||
const circle = document.getElementById(circleId);
|
||
const circumference = 326.73;
|
||
const offset = circumference - (percent / 100) * circumference;
|
||
circle.style.strokeDashoffset = offset;
|
||
|
||
if (percent < 50) {
|
||
circle.style.stroke = 'var(--green)';
|
||
} else if (percent < 80) {
|
||
circle.style.stroke = 'var(--yellow)';
|
||
} else {
|
||
circle.style.stroke = 'var(--red)';
|
||
}
|
||
}
|
||
|
||
function updateDashboard() {
|
||
try { updateOverview(); } catch(e) { console.error('Overview error:', e); }
|
||
try { updateSessions(); } catch(e) { console.error('Sessions error:', e); }
|
||
try { updateLimits(); } catch(e) { console.error('Limits error:', e); }
|
||
try { updateCosts(); } catch(e) { console.error('Costs error:', e); }
|
||
try { updateStatusBar(); } catch(e) { console.error('StatusBar error:', e); }
|
||
}
|
||
|
||
function updateOverview() {
|
||
const running = sessions.filter(s => {
|
||
const age = Date.now() - s.updatedAt;
|
||
return age < 300000 && !s.aborted;
|
||
}).length;
|
||
|
||
animateValue(document.getElementById('runningAgents'), running);
|
||
document.getElementById('totalSessions').textContent = sessions.length;
|
||
document.getElementById('activeSessions').textContent = running;
|
||
|
||
const todayEl = document.getElementById('todaySpend');
|
||
todayEl.dataset.format = 'currency';
|
||
animateValue(todayEl, costs.today || 0);
|
||
|
||
const opusLimitsKey = Object.keys((usage.fiveHour && usage.fiveHour.perModel) || {}).find(k => k.includes('opus')) || '';
|
||
// Overview usage card: delegate to provider-specific updater
|
||
try {
|
||
if (_currentProvider === 'claude') updateOverviewClaude();
|
||
else updateOverviewGemini();
|
||
const cu = _cachedClaudeUsage;
|
||
const sPct = (cu && cu.session) ? cu.session.percent : 0;
|
||
if (sPct >= 80 && !window._usageNotified) {
|
||
sendNotification('High Usage Warning', `Claude session usage at ${sPct}%`);
|
||
window._usageNotified = true;
|
||
} else if (sPct < 70) {
|
||
window._usageNotified = false;
|
||
}
|
||
} catch {}
|
||
|
||
if (systemStats.cpu) {
|
||
const cpuPct = systemStats.cpu.usage || 0;
|
||
document.getElementById('systemCpu').textContent = cpuPct + '%';
|
||
updateRadialGauge('cpuCircle', cpuPct);
|
||
|
||
const ramPct = (systemStats.memory && systemStats.memory.percent) || 0;
|
||
document.getElementById('systemRam').textContent = ramPct + '%';
|
||
updateRadialGauge('ramCircle', ramPct);
|
||
const ramDetail = document.getElementById('systemRamDetail');
|
||
if (ramDetail && systemStats.memory) {
|
||
ramDetail.textContent = `${systemStats.memory.usedGB} / ${systemStats.memory.totalGB} GB`;
|
||
}
|
||
|
||
const temp = systemStats.cpu.temp;
|
||
if (temp !== null && temp !== undefined) {
|
||
const tempPct = Math.min((temp / 90) * 100, 100);
|
||
document.getElementById('systemTemp').textContent = temp.toFixed(0) + '°';
|
||
updateRadialGauge('tempCircle', tempPct);
|
||
} else {
|
||
document.getElementById('systemTemp').textContent = 'N/A';
|
||
updateRadialGauge('tempCircle', 0);
|
||
}
|
||
|
||
const uptime = systemStats.uptime || 0;
|
||
const days = Math.floor(uptime / 86400);
|
||
const hours = Math.floor((uptime % 86400) / 3600);
|
||
const uptimeStr = days > 0 ? `${days}d ${hours}h` : hours > 0 ? `${hours}h` : `${Math.floor(uptime / 60)}m`;
|
||
document.getElementById('systemUptime').textContent = uptimeStr;
|
||
|
||
if (systemStats.loadAvg) {
|
||
document.getElementById('systemLoad').textContent =
|
||
`Load: ${systemStats.loadAvg['1m']} ${systemStats.loadAvg['5m']} ${systemStats.loadAvg['15m']}`;
|
||
}
|
||
|
||
if (systemStats.disk) {
|
||
const diskPct = systemStats.disk.percent || 0;
|
||
document.getElementById('systemDisk').textContent = diskPct + '%';
|
||
updateRadialGauge('diskCircle', diskPct);
|
||
const diskDetail = document.getElementById('systemDiskDetail');
|
||
if (diskDetail) diskDetail.textContent = `${systemStats.disk.used} / ${systemStats.disk.total}`;
|
||
}
|
||
if (systemStats.diskHistory) renderDiskSparkline(systemStats.diskHistory);
|
||
|
||
const crashes = systemStats.crashCount || 0;
|
||
const crashesToday = systemStats.crashesToday || 0;
|
||
document.getElementById('systemCrashes').textContent = crashes;
|
||
document.getElementById('systemCrashes').style.color = crashes > 0 ? 'var(--red)' : 'var(--green)';
|
||
document.getElementById('systemCrashesToday').textContent = crashesToday;
|
||
document.getElementById('systemCrashesToday').style.color = crashesToday > 0 ? 'var(--red)' : 'var(--green)';
|
||
}
|
||
|
||
const seen = new Set();
|
||
const recent = sessions
|
||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||
.filter(s => {
|
||
const dedup = s.label;
|
||
if (seen.has(dedup)) return false;
|
||
seen.add(dedup);
|
||
return true;
|
||
})
|
||
.slice(0, 8);
|
||
|
||
const activityHtml = recent.map(s => {
|
||
const age = Date.now() - s.updatedAt;
|
||
const ago = age < 60000 ? 'just now' :
|
||
age < 3600000 ? Math.round(age / 60000) + 'm ago' :
|
||
age < 86400000 ? Math.round(age / 3600000) + 'h ago' :
|
||
Math.round(age / 86400000) + 'd ago';
|
||
|
||
const isActive = age < 300000 && !s.aborted;
|
||
const typeClass = s.key.includes('subagent') ? 'sub' :
|
||
s.key.includes('cron') ? 'cron' :
|
||
s.kind === 'group' ? 'group' : 'main';
|
||
|
||
const badgeText = s.key.includes('subagent') ? 'sub' :
|
||
s.key.includes('cron') ? 'cron' :
|
||
s.kind === 'group' ? 'group' : 'main';
|
||
|
||
const tokens = s.totalTokens || 0;
|
||
const tokStr = tokens >= 1000 ? (tokens / 1000).toFixed(0) + 'k' : tokens;
|
||
const costStr = s.cost > 0 ? '$' + s.cost.toFixed(2) : '';
|
||
const snippet = s.lastMessage ? s.lastMessage : '';
|
||
|
||
const dur = s.createdAt ? Date.now() - s.createdAt : 0;
|
||
const durStr = dur > 86400000 ? Math.floor(dur / 86400000) + 'd' :
|
||
dur > 3600000 ? Math.floor(dur / 3600000) + 'h' :
|
||
dur > 60000 ? Math.floor(dur / 60000) + 'm' : '';
|
||
|
||
return `
|
||
<div class="activity-item type-${typeClass} ${isActive ? 'running' : ''}" onclick="openSessionDetail('${s.key}')">
|
||
<div class="activity-dot ${isActive ? 'running' : ''}"></div>
|
||
<div class="activity-content">
|
||
<div class="activity-header">
|
||
<div style="display:flex;align-items:center;gap:6px"><span class="activity-name">${s.label}</span><span class="badge ${badgeText}">${badgeText}</span></div>
|
||
<span style="font-size:11px;color:var(--text-muted);flex-shrink:0">${ago}</span>
|
||
</div>
|
||
${snippet ? `<div class="activity-snippet">${snippet}</div>` : ''}
|
||
<div class="activity-meta">
|
||
<span>${s.model.split('/').pop()}</span>
|
||
<span>${tokStr} tok</span>
|
||
${costStr ? `<span>${costStr}</span>` : ''}
|
||
${durStr ? `<span>⏱ ${durStr}</span>` : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
document.getElementById('recentActivity').innerHTML = activityHtml ||
|
||
'<div class="empty-state"><div class="empty-state-icon">📭</div><div class="empty-state-text">No recent activity</div></div>';
|
||
|
||
const perDay = costs.perDay || {};
|
||
const days = Object.keys(perDay).sort().slice(-7);
|
||
const maxSpend = Math.max(...days.map(d => perDay[d] || 0), 0.01);
|
||
|
||
const chartHeight = 120;
|
||
const chartHtml = days.map(day => {
|
||
const amount = perDay[day] || 0;
|
||
const h = Math.max(4, (amount / maxSpend) * chartHeight);
|
||
const date = new Date(day);
|
||
const label = date.toLocaleDateString('en', { month: 'short', day: 'numeric' });
|
||
|
||
return `
|
||
<div class="bar-item">
|
||
<div class="bar-value">$${amount.toFixed(2)}</div>
|
||
<div class="bar" style="height: ${h}px"></div>
|
||
<div class="bar-label">${label}</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
document.getElementById('dailySpendChart').innerHTML = chartHtml ||
|
||
'<div class="empty-state-text">No data</div>';
|
||
}
|
||
|
||
let sessionFilter = 'all';
|
||
let sessionSearch = '';
|
||
let modelFilter = 'all';
|
||
let dateRange = '7d';
|
||
let expandedSessionKey = null;
|
||
let _msgPollTimer = null;
|
||
|
||
function refreshExpandedMessages(key) {
|
||
const s = sessions.find(x => x.key === key);
|
||
if (!s) return;
|
||
const el = document.getElementById('expanded-msgs-' + CSS.escape(key));
|
||
if (!el) { expandedSessionKey = null; return; }
|
||
authFetch(API_BASE + '/api/session-messages?id=' + encodeURIComponent(s.sessionId || s.key))
|
||
.then(r => r.json())
|
||
.then(msgs => {
|
||
if (!document.getElementById('expanded-msgs-' + CSS.escape(key))) return;
|
||
const last10 = msgs.slice(-10);
|
||
el.innerHTML = last10.map(m => {
|
||
const roleColor = m.role === 'user' ? 'var(--blue)' : m.role === 'assistant' ? 'var(--green)' : 'var(--yellow)';
|
||
const time = m.timestamp ? new Date(m.timestamp).toLocaleTimeString('en',{hour:'2-digit',minute:'2-digit'}) : '';
|
||
return `<div style="padding:6px 0;border-bottom:1px solid var(--border);">
|
||
<span style="font-weight:600;color:${roleColor};text-transform:uppercase;font-size:10px;margin-right:8px;">${m.role}</span>
|
||
<span style="color:var(--text-muted);font-size:10px;">${time}</span>
|
||
<div style="color:var(--text-primary);line-height:1.4;word-break:break-word;font-family:'JetBrains Mono',monospace;font-size:11px;margin-top:2px;">${m.content.replace(/</g,'<')}</div>
|
||
</div>`;
|
||
}).join('') || '<div style="color:var(--text-muted);">No messages</div>';
|
||
}).catch(() => {});
|
||
}
|
||
|
||
function startMsgPoll(key) {
|
||
stopMsgPoll();
|
||
_msgPollTimer = setInterval(() => refreshExpandedMessages(key), 5000);
|
||
}
|
||
function stopMsgPoll() {
|
||
if (_msgPollTimer) { clearInterval(_msgPollTimer); _msgPollTimer = null; }
|
||
}
|
||
|
||
document.querySelectorAll('#statusFilters .chip').forEach(chip => {
|
||
chip.addEventListener('click', () => {
|
||
document.querySelectorAll('#statusFilters .chip').forEach(c => c.classList.remove('active'));
|
||
chip.classList.add('active');
|
||
sessionFilter = chip.dataset.filter;
|
||
updateSessions();
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll('#modelFilters .chip').forEach(chip => {
|
||
chip.addEventListener('click', () => {
|
||
document.querySelectorAll('#modelFilters .chip').forEach(c => c.classList.remove('active'));
|
||
chip.classList.add('active');
|
||
modelFilter = chip.dataset.model;
|
||
updateSessions();
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll('#dateFilters .chip').forEach(chip => {
|
||
chip.addEventListener('click', () => {
|
||
document.querySelectorAll('#dateFilters .chip').forEach(c => c.classList.remove('active'));
|
||
chip.classList.add('active');
|
||
dateRange = chip.dataset.range;
|
||
updateSessions();
|
||
});
|
||
});
|
||
|
||
document.getElementById('sessionSearch').addEventListener('input', (e) => {
|
||
sessionSearch = e.target.value.toLowerCase();
|
||
updateSessions();
|
||
});
|
||
|
||
document.querySelectorAll('.table-header .sortable').forEach(header => {
|
||
header.addEventListener('click', () => {
|
||
const newSort = header.dataset.sort;
|
||
if (sortBy === newSort) {
|
||
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
|
||
} else {
|
||
sortBy = newSort;
|
||
sortDir = 'desc';
|
||
}
|
||
updateSessions();
|
||
});
|
||
});
|
||
|
||
function getFilteredSessions() {
|
||
let filtered = [...sessions];
|
||
const now = Date.now();
|
||
|
||
if (dateRange === 'today') {
|
||
const todayStart = new Date(); todayStart.setHours(0,0,0,0);
|
||
filtered = filtered.filter(s => s.updatedAt >= todayStart.getTime());
|
||
} else if (dateRange === '7d') {
|
||
filtered = filtered.filter(s => now - s.updatedAt < 7 * 86400000);
|
||
} else if (dateRange === '30d') {
|
||
filtered = filtered.filter(s => now - s.updatedAt < 30 * 86400000);
|
||
}
|
||
|
||
if (sessionFilter === 'running') {
|
||
filtered = filtered.filter(s => now - s.updatedAt < 300000 && !s.aborted);
|
||
} else if (sessionFilter === 'complete') {
|
||
filtered = filtered.filter(s => now - s.updatedAt >= 300000 && !s.aborted);
|
||
} else if (sessionFilter === 'aborted') {
|
||
filtered = filtered.filter(s => s.aborted);
|
||
} else if (sessionFilter === 'subagent') {
|
||
filtered = filtered.filter(s => s.key.includes('subagent'));
|
||
} else if (sessionFilter === 'cron') {
|
||
filtered = filtered.filter(s => s.key.includes('cron'));
|
||
} else if (sessionFilter === 'group') {
|
||
filtered = filtered.filter(s => s.kind === 'group' || s.key.includes('group'));
|
||
}
|
||
|
||
if (modelFilter !== 'all') {
|
||
filtered = filtered.filter(s => {
|
||
const m = s.model.toLowerCase();
|
||
if (modelFilter === 'opus-4-6') return m.includes('opus');
|
||
if (modelFilter === 'sonnet') return m.includes('sonnet');
|
||
if (modelFilter === 'gemini') return m.includes('gemini');
|
||
return true;
|
||
});
|
||
}
|
||
|
||
if (sessionSearch) {
|
||
filtered = filtered.filter(s =>
|
||
s.label.toLowerCase().includes(sessionSearch) ||
|
||
s.key.toLowerCase().includes(sessionSearch) ||
|
||
s.model.toLowerCase().includes(sessionSearch)
|
||
);
|
||
}
|
||
|
||
return filtered;
|
||
}
|
||
|
||
function getModelColor(model) {
|
||
const m = model.toLowerCase();
|
||
if (m.includes('opus-4-6') || m.includes('opus-4')) return 'var(--accent)';
|
||
if (m.includes('opus-4-5')) return 'var(--purple)';
|
||
if (m.includes('sonnet')) return 'var(--cyan)';
|
||
if (m.includes('gemini')) return 'var(--yellow)';
|
||
return 'var(--text-muted)';
|
||
}
|
||
|
||
function updateSessionsStats(filtered) {
|
||
const totalTokens = filtered.reduce((sum, s) => sum + (s.totalTokens || 0), 0);
|
||
const totalCost = filtered.reduce((sum, s) => sum + (s.cost || 0), 0);
|
||
document.getElementById('statsSessionCount').textContent = filtered.length;
|
||
document.getElementById('statsTotalTokens').textContent = totalTokens >= 1000000 ? (totalTokens / 1000000).toFixed(1) + 'M' : totalTokens >= 1000 ? (totalTokens / 1000).toFixed(0) + 'k' : totalTokens;
|
||
document.getElementById('statsTotalCost').textContent = '$' + totalCost.toFixed(2);
|
||
}
|
||
|
||
function renderTimeline(filtered) {
|
||
const now = Date.now();
|
||
const rangeMs = dateRange === 'today' ? 86400000 : dateRange === '7d' ? 7 * 86400000 : dateRange === '30d' ? 30 * 86400000 : 30 * 86400000;
|
||
const start = now - rangeMs;
|
||
const seen = new Set();
|
||
const items = filtered.filter(s => s.updatedAt > start).sort((a, b) => b.updatedAt - a.updatedAt).filter(s => { if (seen.has(s.label)) return false; seen.add(s.label); return true; }).slice(0, 12);
|
||
if (!items.length) { document.getElementById('timelineCanvas').innerHTML = '<div style="color:var(--text-muted);font-size:12px;">No sessions in range</div>'; return; }
|
||
|
||
const colors = { main: 'var(--accent)', sub: 'var(--cyan)', cron: 'var(--yellow)', group: 'var(--blue)' };
|
||
const ticks = [];
|
||
const tickCount = dateRange === 'today' ? 6 : 7;
|
||
for (let i = 0; i <= tickCount; i++) {
|
||
const t = start + (rangeMs / tickCount) * i;
|
||
const d = new Date(t);
|
||
const label = dateRange === 'today' ? d.toLocaleTimeString('en', {hour:'2-digit',minute:'2-digit'}) : d.toLocaleDateString('en', {month:'short',day:'numeric'});
|
||
ticks.push({pct: (i / tickCount) * 100, label});
|
||
}
|
||
|
||
const ticksHtml = ticks.map(t => `<div style="position:absolute;left:${t.pct}%;bottom:0;transform:translateX(-50%);font-size:9px;color:var(--text-muted);font-family:'JetBrains Mono',monospace;">${t.label}</div>`).join('');
|
||
|
||
const rows = items.map(s => {
|
||
const typeClass = s.key.includes('subagent') ? 'sub' : s.key.includes('cron') ? 'cron' : s.kind === 'group' ? 'group' : 'main';
|
||
const color = colors[typeClass] || 'var(--accent)';
|
||
const created = Math.max(s.createdAt || s.updatedAt, start);
|
||
const leftPct = Math.max(((created - start) / rangeMs) * 100, 0);
|
||
const rightPct = Math.min(((s.updatedAt - start) / rangeMs) * 100, 100);
|
||
const widthPct = Math.max(rightPct - leftPct, 1);
|
||
return `<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
|
||
<div style="width:100px;flex-shrink:0;font-size:11px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:right;color:var(--text-secondary);">${s.label}</div>
|
||
<div style="flex:1;height:14px;background:var(--bg-primary);border-radius:4px;position:relative;overflow:hidden;">
|
||
<div style="position:absolute;left:${leftPct}%;width:${widthPct}%;height:100%;background:${color};border-radius:4px;opacity:0.8;"></div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
document.getElementById('timelineCanvas').innerHTML = rows + `<div style="position:relative;height:18px;margin-left:108px;">${ticksHtml}</div>`;
|
||
}
|
||
|
||
function toggleSessionExpand(key, e) {
|
||
if (e) e.stopPropagation();
|
||
const existing = document.getElementById('expanded-' + CSS.escape(key));
|
||
if (existing) {
|
||
existing.remove();
|
||
expandedSessionKey = null;
|
||
stopMsgPoll();
|
||
return;
|
||
}
|
||
const prev = document.querySelector('.session-expanded');
|
||
if (prev) prev.remove();
|
||
expandedSessionKey = key;
|
||
|
||
const s = sessions.find(x => x.key === key);
|
||
if (!s) return;
|
||
const row = document.querySelector(`[data-session-key="${CSS.escape(key)}"]`);
|
||
if (!row) return;
|
||
|
||
const age = Date.now() - s.updatedAt;
|
||
const ago = age < 60000 ? 'just now' : age < 3600000 ? Math.round(age/60000)+'m ago' : age < 86400000 ? Math.round(age/3600000)+'h ago' : Math.round(age/86400000)+'d ago';
|
||
const createdAgo = s.createdAt ? new Date(s.createdAt).toLocaleString() : '--';
|
||
const modelColor = getModelColor(s.model);
|
||
|
||
const detail = document.createElement('div');
|
||
detail.id = 'expanded-' + key;
|
||
detail.className = 'session-expanded';
|
||
detail.style.cssText = 'padding:16px 20px;background:var(--bg-tertiary);border-bottom:1px solid var(--border);animation:fadeIn 0.2s ease;';
|
||
detail.innerHTML = `
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-bottom:16px;">
|
||
<div><div style="font-size:10px;color:var(--text-muted);text-transform:uppercase;">Session Key</div><div class="mono" style="font-size:11px;word-break:break-all;">${s.key}</div></div>
|
||
<div><div style="font-size:10px;color:var(--text-muted);text-transform:uppercase;">Model</div><div class="mono" style="font-size:12px;color:${modelColor};">${s.model.split('/').pop()}</div></div>
|
||
<div><div style="font-size:10px;color:var(--text-muted);text-transform:uppercase;">Tokens</div><div class="mono" style="font-size:12px;">${(s.totalTokens||0).toLocaleString()}</div></div>
|
||
<div><div style="font-size:10px;color:var(--text-muted);text-transform:uppercase;">Cost</div><div class="mono" style="font-size:12px;">$${(s.cost||0).toFixed(2)}</div></div>
|
||
<div><div style="font-size:10px;color:var(--text-muted);text-transform:uppercase;">Channel</div><div style="font-size:12px;">${s.channel||'--'}</div></div>
|
||
<div><div style="font-size:10px;color:var(--text-muted);text-transform:uppercase;">Created</div><div style="font-size:12px;">${createdAgo}</div></div>
|
||
<div><div style="font-size:10px;color:var(--text-muted);text-transform:uppercase;">Last Active</div><div style="font-size:12px;">${ago}</div></div>
|
||
</div>
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
|
||
<span style="font-size:11px;font-weight:600;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.05em;">Recent Messages</span>
|
||
<button onclick="openSessionDetail('${s.key}')" style="background:var(--accent);color:white;border:none;padding:4px 12px;border-radius:6px;font-size:11px;font-weight:600;cursor:pointer;">Full View</button>
|
||
</div>
|
||
<div id="expanded-msgs-${CSS.escape(key)}" style="font-size:12px;color:var(--text-muted);">Loading...</div>`;
|
||
row.after(detail);
|
||
|
||
refreshExpandedMessages(key);
|
||
startMsgPoll(key);
|
||
}
|
||
|
||
function updateSessions() {
|
||
const filtered = getFilteredSessions();
|
||
updateSessionsStats(filtered);
|
||
try { renderTimeline(filtered); } catch(e) { console.error('Timeline error:', e); }
|
||
// Refresh expanded session messages async
|
||
if (expandedSessionKey) {
|
||
refreshExpandedMessages(expandedSessionKey);
|
||
return;
|
||
}
|
||
|
||
const sorted = [...filtered];
|
||
sorted.sort((a, b) => {
|
||
let aVal, bVal;
|
||
if (sortBy === 'label') { aVal = a.label; bVal = b.label; }
|
||
else if (sortBy === 'tokens') { aVal = a.totalTokens || 0; bVal = b.totalTokens || 0; }
|
||
else if (sortBy === 'cost') { aVal = a.cost || 0; bVal = b.cost || 0; }
|
||
else if (sortBy === 'updated') { aVal = a.updatedAt; bVal = b.updatedAt; }
|
||
else { aVal = a.model; bVal = b.model; }
|
||
return sortDir === 'asc' ? (aVal > bVal ? 1 : -1) : (aVal < bVal ? 1 : -1);
|
||
});
|
||
|
||
const rowsHtml = sorted.map(s => {
|
||
const age = Date.now() - s.updatedAt;
|
||
const ago = age < 60000 ? 'just now' : age < 3600000 ? Math.round(age/60000)+'m ago' : age < 86400000 ? Math.round(age/3600000)+'h ago' : Math.round(age/86400000)+'d ago';
|
||
const isActive = age < 300000 && !s.aborted;
|
||
const statusClass = s.aborted ? 'aborted' : isActive ? 'running' : '';
|
||
const statusDot = s.aborted ? '🔴' : isActive ? '🟢' : '⚪';
|
||
const typeClass = s.key.includes('subagent') ? 'sub' : s.key.includes('cron') ? 'cron' : s.kind === 'group' ? 'group' : 'main';
|
||
const shortModel = s.model.split('/').pop();
|
||
const modelColor = getModelColor(s.model);
|
||
const activeIndicator = isActive ? ' <span style="display:inline-flex;align-items:center;gap:3px;padding:1px 5px;background:rgba(16,185,129,0.15);color:var(--green);border-radius:4px;font-size:9px;font-weight:600;vertical-align:middle;">● LIVE</span>' : '';
|
||
const costStr = s.cost > 0 ? '$' + s.cost.toFixed(2) : '-';
|
||
const lastMsg = s.lastMessage ? s.lastMessage.substring(0, 60) + (s.lastMessage.length > 60 ? '…' : '') : '';
|
||
const escapedKey = s.key.replace(/'/g, "\\'");
|
||
const checked = selectedSessions.has(s.key) ? 'checked' : '';
|
||
|
||
return `
|
||
<div class="table-row ${statusClass}" data-session-key="${s.key}">
|
||
<div class="table-cell"><input type="checkbox" class="session-checkbox" ${checked} onchange="toggleSessionCompare('${escapedKey}', this.checked)" onclick="event.stopPropagation()"></div>
|
||
<div class="table-cell" onclick="toggleSessionExpand('${escapedKey}', event)">${statusDot}</div>
|
||
<div class="table-cell" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" onclick="toggleSessionExpand('${escapedKey}', event)">
|
||
<strong>${s.label}</strong>${activeIndicator}
|
||
</div>
|
||
<div class="table-cell" onclick="toggleSessionExpand('${escapedKey}', event)"><span class="badge ${typeClass}">${typeClass}</span></div>
|
||
<div class="table-cell mono" style="color:${modelColor};" onclick="toggleSessionExpand('${escapedKey}', event)">${shortModel}</div>
|
||
<div class="table-cell mono" onclick="toggleSessionExpand('${escapedKey}', event)">${(s.totalTokens||0).toLocaleString()}</div>
|
||
<div class="table-cell mono" onclick="toggleSessionExpand('${escapedKey}', event)">${costStr}</div>
|
||
<div class="table-cell" style="font-size:11px;color:var(--text-muted);font-family:'JetBrains Mono',monospace;" onclick="toggleSessionExpand('${escapedKey}', event)">${lastMsg}</div>
|
||
<div class="table-cell" onclick="toggleSessionExpand('${escapedKey}', event)">${ago}</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
document.getElementById('sessionsTableBody').innerHTML = rowsHtml ||
|
||
'<div class="empty-state"><div class="empty-state-icon">🔍</div><div class="empty-state-text">No sessions found</div></div>';
|
||
}
|
||
|
||
let _cachedClaudeUsage = null;
|
||
async function fetchClaudeUsage() {
|
||
try {
|
||
const r = await authFetch(API_BASE + '/api/claude-usage');
|
||
const d = await r.json();
|
||
if (d.error) return;
|
||
_cachedClaudeUsage = d;
|
||
function setBar(prefix, data) {
|
||
if (!data) return;
|
||
const bar = document.getElementById(prefix + 'Bar');
|
||
const pct = document.getElementById(prefix + 'Pct');
|
||
const label = document.getElementById(prefix + 'Label');
|
||
const reset = document.getElementById(prefix + 'Reset');
|
||
if (bar) { bar.style.width = data.percent + '%'; bar.style.background = getProgressColor(data.percent); }
|
||
if (pct) pct.textContent = data.percent + '%';
|
||
if (label) label.textContent = data.percent + '% used';
|
||
if (reset) reset.textContent = data.resets ? 'Resets ' + data.resets : '';
|
||
}
|
||
setBar('cuSession', d.session);
|
||
setBar('cuWeekly', d.weekly_all);
|
||
setBar('cuSonnet', d.weekly_sonnet);
|
||
const ts = document.getElementById('claudeUsageScrapedAt');
|
||
if (ts && d.scraped_at) {
|
||
const ago = Math.round((Date.now() - new Date(d.scraped_at).getTime()) / 60000);
|
||
ts.textContent = ago < 1 ? 'Just now' : ago + 'm ago';
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
let _usageAutoInterval = null;
|
||
|
||
function toggleUsageAutoRefresh(on, init) {
|
||
if (!init) localStorage.setItem('usageAutoRefresh', on ? '1' : '0');
|
||
const track = document.getElementById('usageToggleTrack');
|
||
const thumb = document.getElementById('usageToggleThumb');
|
||
const label = document.getElementById('usageAutoLabel');
|
||
if (on) {
|
||
track.style.background = 'var(--green)';
|
||
thumb.style.left = '18px';
|
||
label.textContent = 'Auto ✓';
|
||
label.style.color = 'var(--green)';
|
||
// Scrape immediately, then every 30 min
|
||
scrapeCurrentProvider();
|
||
_usageAutoInterval = setInterval(scrapeCurrentProvider, 120000);
|
||
} else {
|
||
track.style.background = 'var(--bg-tertiary)';
|
||
thumb.style.left = '2px';
|
||
label.textContent = 'Auto';
|
||
label.style.color = 'var(--text-muted)';
|
||
if (_usageAutoInterval) { clearInterval(_usageAutoInterval); _usageAutoInterval = null; }
|
||
}
|
||
}
|
||
|
||
async function scrapeClaudeUsage() {
|
||
const btn = document.getElementById('scrapeBtn');
|
||
const label = document.getElementById('usageAutoLabel');
|
||
const btn2 = document.getElementById('overviewScrapeBtn');
|
||
if (btn) { btn.textContent = '⏳ Refreshing...'; btn.disabled = true; }
|
||
if (btn2) { btn2.textContent = '⏳'; btn2.disabled = true; }
|
||
if (label && _usageAutoInterval) label.textContent = '⏳...';
|
||
try {
|
||
await authFetch(API_BASE + '/api/claude-usage-scrape', { method: 'POST' });
|
||
// Poll until data changes or timeout
|
||
const oldTs = (_cachedClaudeUsage && _cachedClaudeUsage.scraped_at) || '';
|
||
for (let i = 0; i < 12; i++) {
|
||
await new Promise(r => setTimeout(r, 2000));
|
||
await fetchClaudeUsage();
|
||
if (_cachedClaudeUsage && _cachedClaudeUsage.scraped_at && _cachedClaudeUsage.scraped_at !== oldTs) break;
|
||
}
|
||
showToast('Usage data refreshed', 'success');
|
||
if (typeof updateDashboard === 'function') updateDashboard();
|
||
} catch {} finally {
|
||
if (btn) { btn.textContent = '⟳ Refresh'; btn.disabled = false; }
|
||
if (btn2) { btn2.textContent = '⟳'; btn2.disabled = false; }
|
||
if (label && _usageAutoInterval) label.textContent = 'Auto ✓';
|
||
}
|
||
}
|
||
|
||
fetchClaudeUsage();
|
||
setInterval(fetchClaudeUsage, 60000);
|
||
|
||
let _cachedGeminiUsage = null;
|
||
let _currentProvider = localStorage.getItem('usageProvider') || 'claude';
|
||
|
||
async function fetchGeminiUsage() {
|
||
try {
|
||
const r = await authFetch(API_BASE + '/api/gemini-usage');
|
||
const d = await r.json();
|
||
if (d.error) return;
|
||
_cachedGeminiUsage = d;
|
||
const container = document.getElementById('geminiUsageBars');
|
||
if (container && d.models) {
|
||
container.innerHTML = '';
|
||
for (const [name, info] of Object.entries(d.models)) {
|
||
const used = parseFloat(info.used_percent) || 0;
|
||
const resets = String(info.resets_in || '').replace(/[<>&"']/g, '');
|
||
const safeName = String(name).replace(/[<>&"']/g, '');
|
||
const color = getProgressColor(used);
|
||
container.innerHTML += '<div>' +
|
||
'<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px;">' +
|
||
'<span style="font-weight:600;font-size:14px;">' + safeName + '</span>' +
|
||
'<span class="mono" style="font-size:12px;color:var(--text-secondary);">' + used + '% used</span>' +
|
||
'</div>' +
|
||
'<div style="height:24px;background:var(--bg-primary);border-radius:12px;overflow:hidden;position:relative;">' +
|
||
'<div style="height:100%;border-radius:12px;transition:width 0.6s;width:' + used + '%;background:' + color + ';"></div>' +
|
||
'<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-family:\'JetBrains Mono\',monospace;font-size:11px;font-weight:700;color:#fff;text-shadow:0 1px 3px rgba(0,0,0,0.5);">' + used + '%</div>' +
|
||
'</div>' +
|
||
'<div style="font-size:11px;color:var(--text-muted);margin-top:4px;">Resets in ' + resets + '</div>' +
|
||
'</div>';
|
||
}
|
||
}
|
||
const ts = document.getElementById('geminiUsageScrapedAt');
|
||
if (ts && d.scraped_at) {
|
||
const ago = Math.round((Date.now() - new Date(d.scraped_at).getTime()) / 60000);
|
||
ts.textContent = ago < 1 ? 'Just now' : ago + 'm ago';
|
||
}
|
||
if (_currentProvider === 'gemini') updateOverviewGemini();
|
||
} catch {}
|
||
}
|
||
|
||
async function scrapeGeminiUsage() {
|
||
const btn = document.getElementById('geminiScrapeBtn');
|
||
const btn2 = document.getElementById('overviewScrapeBtn');
|
||
const label = document.getElementById('usageAutoLabel');
|
||
if (btn) { btn.textContent = '⏳ Refreshing...'; btn.disabled = true; }
|
||
if (btn2) { btn2.textContent = '⏳'; btn2.disabled = true; }
|
||
if (label && _usageAutoInterval) label.textContent = '⏳...';
|
||
try {
|
||
await authFetch(API_BASE + '/api/gemini-usage-scrape', { method: 'POST' });
|
||
const oldTs = (_cachedGeminiUsage && _cachedGeminiUsage.scraped_at) || '';
|
||
for (let i = 0; i < 12; i++) {
|
||
await new Promise(r => setTimeout(r, 2000));
|
||
await fetchGeminiUsage();
|
||
if (_cachedGeminiUsage && _cachedGeminiUsage.scraped_at && _cachedGeminiUsage.scraped_at !== oldTs) break;
|
||
}
|
||
showToast('Gemini usage refreshed', 'success');
|
||
} catch {} finally {
|
||
if (btn) { btn.textContent = '⟳ Refresh'; btn.disabled = false; }
|
||
if (btn2) { btn2.textContent = '⟳'; btn2.disabled = false; }
|
||
if (label && _usageAutoInterval) label.textContent = 'Auto ✓';
|
||
}
|
||
}
|
||
|
||
let _currentModel = localStorage.getItem('usageModel') || 'session';
|
||
|
||
const _claudeModels = [
|
||
{ value: 'session', label: '5h Session' },
|
||
{ value: 'weekly_all', label: 'Weekly (All)' },
|
||
{ value: 'weekly_sonnet', label: 'Weekly (Sonnet)' }
|
||
];
|
||
|
||
function _getGeminiModelOptions() {
|
||
if (!_cachedGeminiUsage || !_cachedGeminiUsage.models) return [];
|
||
return Object.keys(_cachedGeminiUsage.models)
|
||
.filter(k => /^[\w.\-]+$/.test(k))
|
||
.map(k => ({ value: k, label: k }));
|
||
}
|
||
|
||
function _populateModelSelect() {
|
||
const sel = document.getElementById('modelSelect');
|
||
if (!sel) return;
|
||
const opts = _currentProvider === 'claude' ? _claudeModels : _getGeminiModelOptions();
|
||
sel.innerHTML = '';
|
||
opts.forEach(o => {
|
||
const opt = document.createElement('option');
|
||
opt.value = o.value;
|
||
opt.textContent = o.label;
|
||
sel.appendChild(opt);
|
||
});
|
||
if (opts.some(o => o.value === _currentModel)) {
|
||
sel.value = _currentModel;
|
||
} else if (opts.length) {
|
||
_currentModel = opts[0].value;
|
||
sel.value = _currentModel;
|
||
}
|
||
}
|
||
|
||
function _setOverviewBar(pct, label) {
|
||
pct = parseFloat(pct) || 0;
|
||
const pctEl = document.getElementById('ovSessionPct');
|
||
const barEl = document.getElementById('overviewSessionBar');
|
||
const labelEl = document.getElementById('overviewUsageLabel');
|
||
const warnEl = document.getElementById('overviewUsageWarn');
|
||
if (pctEl) pctEl.textContent = pct + '%';
|
||
if (barEl) { barEl.style.width = Math.min(pct, 100) + '%'; barEl.style.background = pct < 50 ? 'var(--green)' : pct < 80 ? '#f59e0b' : '#ef4444'; }
|
||
if (labelEl) labelEl.textContent = String(label || '').replace(/[<>&]/g, '');
|
||
if (warnEl) warnEl.style.display = pct >= 80 ? '' : 'none';
|
||
}
|
||
|
||
function updateOverviewGemini() {
|
||
_populateModelSelect();
|
||
if (!_cachedGeminiUsage || !_cachedGeminiUsage.models) return;
|
||
const model = _cachedGeminiUsage.models[_currentModel];
|
||
if (!model) {
|
||
const first = Object.keys(_cachedGeminiUsage.models)[0];
|
||
if (first) {
|
||
_currentModel = first;
|
||
const sel = document.getElementById('modelSelect');
|
||
if (sel) sel.value = first;
|
||
_setOverviewBar(_cachedGeminiUsage.models[first].used_percent, 'Resets in ' + _cachedGeminiUsage.models[first].resets_in);
|
||
}
|
||
return;
|
||
}
|
||
_setOverviewBar(model.used_percent, 'Resets in ' + model.resets_in);
|
||
}
|
||
|
||
function updateOverviewClaude() {
|
||
_populateModelSelect();
|
||
if (!_cachedClaudeUsage) return;
|
||
const keyMap = { session: 'session', weekly_all: 'weekly_all', weekly_sonnet: 'weekly_sonnet' };
|
||
const d = _cachedClaudeUsage[keyMap[_currentModel] || 'session'];
|
||
if (!d) return;
|
||
_setOverviewBar(d.percent, d.resets ? 'Resets ' + d.resets : '');
|
||
}
|
||
|
||
function switchModel(val) {
|
||
_currentModel = val;
|
||
localStorage.setItem(_currentProvider === 'claude' ? 'claudeModel' : 'geminiModel', val);
|
||
if (_currentProvider === 'claude') updateOverviewClaude();
|
||
else updateOverviewGemini();
|
||
}
|
||
|
||
function switchProvider(prov) {
|
||
_currentProvider = prov;
|
||
localStorage.setItem('usageProvider', prov);
|
||
const cBtn = document.getElementById('provBtnClaude');
|
||
const gBtn = document.getElementById('provBtnGemini');
|
||
if (prov === 'claude') {
|
||
if (cBtn) { cBtn.style.background = 'var(--accent)'; cBtn.style.color = '#fff'; }
|
||
if (gBtn) { gBtn.style.background = 'transparent'; gBtn.style.color = 'var(--text-muted)'; }
|
||
_currentModel = localStorage.getItem('claudeModel') || 'session';
|
||
updateOverviewClaude();
|
||
} else {
|
||
if (gBtn) { gBtn.style.background = '#4285f4'; gBtn.style.color = '#fff'; }
|
||
if (cBtn) { cBtn.style.background = 'transparent'; cBtn.style.color = 'var(--text-muted)'; }
|
||
_currentModel = localStorage.getItem('geminiModel') || 'gemini-3-pro-preview';
|
||
updateOverviewGemini();
|
||
}
|
||
if (_usageAutoInterval) {
|
||
clearInterval(_usageAutoInterval);
|
||
_usageAutoInterval = setInterval(scrapeCurrentProvider, 120000);
|
||
}
|
||
}
|
||
|
||
function scrapeCurrentProvider() {
|
||
if (_currentProvider === 'gemini') scrapeGeminiUsage();
|
||
else scrapeClaudeUsage();
|
||
}
|
||
|
||
fetchGeminiUsage();
|
||
setInterval(fetchGeminiUsage, 60000);
|
||
switchProvider(_currentProvider);
|
||
|
||
function getProgressColor(pct) {
|
||
if (pct < 50) return 'linear-gradient(90deg, #10b981, #34d399)';
|
||
if (pct < 80) return 'linear-gradient(90deg, #f59e0b, #fbbf24)';
|
||
return 'linear-gradient(90deg, #ef4444, #f87171)';
|
||
}
|
||
|
||
function formatMs(ms) {
|
||
if (!ms || ms <= 0) return '--';
|
||
const h = Math.floor(ms / 3600000);
|
||
const m = Math.floor((ms % 3600000) / 60000);
|
||
if (h > 0) return h + 'h ' + m + 'm';
|
||
return m + 'm';
|
||
}
|
||
|
||
function updateLimits() {
|
||
const limits = usage.estimatedLimits || { opus: 88000, sonnet: 220000 };
|
||
const models = (usage.fiveHour && usage.fiveHour.perModel) || {};
|
||
|
||
const opusKey = Object.keys(models).find(k => k.includes('opus')) || '';
|
||
const sonnetKey = Object.keys(models).find(k => k.includes('sonnet')) || '';
|
||
const opusData = opusKey ? models[opusKey] : { output: 0, input: 0, cost: 0, calls: 0 };
|
||
const sonnetData = sonnetKey ? models[sonnetKey] : { output: 0, input: 0, cost: 0, calls: 0 };
|
||
|
||
const opusPct = Math.min(Math.round((opusData.output / limits.opus) * 100), 100);
|
||
const sonnetPct = Math.min(Math.round((sonnetData.output / limits.sonnet) * 100), 100);
|
||
|
||
const opusEl = document.getElementById('opusUsageLimits');
|
||
opusEl.dataset.format = 'percent';
|
||
animateValue(opusEl, opusPct);
|
||
|
||
document.getElementById('opusProgressBar').style.width = opusPct + '%';
|
||
document.getElementById('opusProgressBar').style.background = getProgressColor(opusPct);
|
||
document.getElementById('opusProgressPct').textContent = opusPct + '%';
|
||
document.getElementById('opusTokenLabel').textContent = (opusData.output / 1000).toFixed(1) + 'k / ' + (limits.opus / 1000) + 'k';
|
||
|
||
if (opusPct >= 80) {
|
||
document.getElementById('opusProgressBar').style.animation = 'pulse 2s ease-in-out infinite';
|
||
} else {
|
||
document.getElementById('opusProgressBar').style.animation = 'none';
|
||
}
|
||
|
||
document.getElementById('sonnetProgressBar').style.width = sonnetPct + '%';
|
||
document.getElementById('sonnetProgressBar').style.background = getProgressColor(sonnetPct);
|
||
document.getElementById('sonnetProgressPct').textContent = sonnetPct + '%';
|
||
document.getElementById('sonnetTokenLabel').textContent = (sonnetData.output / 1000).toFixed(1) + 'k / ' + (limits.sonnet / 1000) + 'k';
|
||
|
||
const totalCalls = Object.values(models).reduce((sum, m) => sum + (m.calls || 0), 0);
|
||
animateValue(document.getElementById('totalCalls'), totalCalls);
|
||
|
||
const windowCost = Object.values(models).reduce((sum, m) => sum + (m.cost || 0), 0);
|
||
const costEl = document.getElementById('windowCost');
|
||
costEl.dataset.format = 'currency';
|
||
animateValue(costEl, windowCost);
|
||
|
||
const burnRate = usage.burnRate || { tokensPerMinute: 0, costPerMinute: 0 };
|
||
document.getElementById('burnRateDisplay').textContent = burnRate.tokensPerMinute > 0 ? burnRate.tokensPerMinute.toFixed(0) : '--';
|
||
document.getElementById('costPerMinValue').textContent = '$' + burnRate.costPerMinute.toFixed(4);
|
||
|
||
const predictions = usage.predictions || { timeToLimit: null, safe: true };
|
||
const ttlEl = document.getElementById('timeToLimitValue');
|
||
const ttlSub = document.getElementById('timeToLimitSub');
|
||
if (predictions.safe || !predictions.timeToLimit) {
|
||
ttlEl.textContent = '✅ Safe';
|
||
ttlEl.style.color = 'var(--green)';
|
||
ttlSub.textContent = 'Low usage rate';
|
||
} else {
|
||
ttlEl.textContent = formatMs(predictions.timeToLimit);
|
||
ttlEl.style.color = predictions.timeToLimit < 1800000 ? 'var(--red)' : 'var(--yellow)';
|
||
ttlSub.textContent = 'At current burn rate';
|
||
if (predictions.timeToLimit < 1800000) {
|
||
document.getElementById('timeToLimitCard').style.boxShadow = '0 0 20px rgba(239,68,68,0.3)';
|
||
}
|
||
}
|
||
|
||
const resetIn = (usage.fiveHour && usage.fiveHour.windowResetIn) || 0;
|
||
document.getElementById('windowResetValue').textContent = resetIn > 0 ? formatMs(resetIn) : 'No window';
|
||
document.getElementById('windowResetSub').textContent = (usage.fiveHour && usage.fiveHour.windowStart) ? 'Since ' + new Date(usage.fiveHour.windowStart).toLocaleTimeString('en', {hour:'2-digit',minute:'2-digit'}) : '';
|
||
|
||
var windowHtml = Object.entries(models)
|
||
.sort(function(a, b) { return b[1].output - a[1].output; })
|
||
.map(function(entry) {
|
||
var model = entry[0], data = entry[1];
|
||
var shortModel = model.split('/').pop();
|
||
var calc = (usage.fiveHour && usage.fiveHour.perModelCost && usage.fiveHour.perModelCost[model]) || {};
|
||
var inputCost = calc.inputCost || 0;
|
||
var outputCost = calc.outputCost || 0;
|
||
var cacheReadCost = calc.cacheReadCost || 0;
|
||
var cacheWriteCost = calc.cacheWriteCost || 0;
|
||
var totalModelCost = calc.totalCost || (inputCost + outputCost + cacheReadCost + cacheWriteCost);
|
||
var cacheCost = cacheReadCost + cacheWriteCost;
|
||
return '<div style="margin-bottom:20px;padding-bottom:16px;border-bottom:1px solid var(--border);">' +
|
||
'<div style="display:flex;justify-content:space-between;margin-bottom:6px;">' +
|
||
'<span class="mono" style="font-size:14px;font-weight:600;">' + shortModel + '</span>' +
|
||
'<span class="mono" style="font-size:14px;font-weight:600;color:var(--accent);">$' + totalModelCost.toFixed(4) + '</span>' +
|
||
'</div>' +
|
||
'<div style="display:flex;gap:16px;font-size:11px;color:var(--text-muted);font-family:\'JetBrains Mono\',monospace;">' +
|
||
'<span>' + data.calls + ' calls</span>' +
|
||
'<span>' + (data.input/1000).toFixed(0) + 'k in ($' + inputCost.toFixed(4) + ')</span>' +
|
||
'<span>' + (data.output/1000).toFixed(0) + 'k out ($' + outputCost.toFixed(4) + ')</span>' +
|
||
(cacheCost > 0 ? '<span>cache ($' + cacheCost.toFixed(4) + ')</span>' : '') +
|
||
'</div>' +
|
||
'</div>';
|
||
}).join('');
|
||
|
||
document.getElementById('windowBreakdown').innerHTML = windowHtml || '<div class="empty-state-text">No data</div>';
|
||
|
||
const recentCalls = (usage.fiveHour && usage.fiveHour.recentCalls) || [];
|
||
const callsHtml = recentCalls.slice(0, 15).map(call => {
|
||
const shortModel = call.model.split('/').pop();
|
||
return `
|
||
<div style="display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid var(--border);font-size:12px;">
|
||
<div>
|
||
<div class="mono" style="font-weight:600;">${shortModel}</div>
|
||
<div style="color:var(--text-muted);font-size:11px;">${call.ago}</div>
|
||
</div>
|
||
<div style="text-align:right;">
|
||
<div class="mono">${call.output.toLocaleString()} out</div>
|
||
<div style="color:var(--text-muted);font-size:11px;">$${call.cost.toFixed(4)}</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
document.getElementById('recentCalls').innerHTML = callsHtml || '<div class="empty-state-text">No recent calls</div>';
|
||
|
||
// Model usage donut chart
|
||
try {
|
||
const donutEl = document.getElementById('modelUsageDonut');
|
||
if (donutEl && costs.perModel) {
|
||
const modelData = Object.entries(costs.perModel).map(([m, cost]) => {
|
||
const shortModel = m.split('/').pop();
|
||
let color = 'var(--text-muted)';
|
||
if (shortModel.includes('opus')) color = '#a855f7';
|
||
else if (shortModel.includes('sonnet')) color = '#3b82f6';
|
||
else if (shortModel.includes('gemini')) color = '#10b981';
|
||
return { model: shortModel, cost, color };
|
||
}).filter(d => d.cost > 0).sort((a, b) => b.cost - a.cost);
|
||
|
||
if (modelData.length === 0) {
|
||
donutEl.innerHTML = '<div style="color:var(--text-muted);">No data</div>';
|
||
} else {
|
||
const total = modelData.reduce((s, d) => s + d.cost, 0);
|
||
const size = 200, r = 70, strokeW = 40;
|
||
let currentAngle = 0;
|
||
|
||
const arcs = modelData.map(d => {
|
||
const pct = d.cost / total;
|
||
const angle = pct * 360;
|
||
const largeArc = angle > 180 ? 1 : 0;
|
||
const startX = size / 2 + r * Math.cos((currentAngle - 90) * Math.PI / 180);
|
||
const startY = size / 2 + r * Math.sin((currentAngle - 90) * Math.PI / 180);
|
||
const endX = size / 2 + r * Math.cos((currentAngle + angle - 90) * Math.PI / 180);
|
||
const endY = size / 2 + r * Math.sin((currentAngle + angle - 90) * Math.PI / 180);
|
||
currentAngle += angle;
|
||
|
||
return `<path d="M ${size/2},${size/2} L ${startX},${startY} A ${r},${r} 0 ${largeArc},1 ${endX},${endY} Z" fill="${d.color}" opacity="0.8" style="cursor:pointer;"><title>${d.model}: ${(pct*100).toFixed(1)}%</title></path>`;
|
||
}).join('');
|
||
|
||
const legend = modelData.map((d, i) => {
|
||
const pct = ((d.cost / total) * 100).toFixed(1);
|
||
return `<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||
<div style="width:16px;height:16px;background:${d.color};border-radius:3px;opacity:0.8;"></div>
|
||
<span style="font-size:13px;flex:1;">${d.model}</span>
|
||
<span style="font-size:13px;font-weight:600;font-family:'JetBrains Mono',monospace;">${pct}%</span>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
donutEl.innerHTML = `<div style="display:flex;gap:32px;align-items:center;">
|
||
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
|
||
<circle cx="${size/2}" cy="${size/2}" r="${r}" fill="var(--bg-primary)"/>
|
||
${arcs}
|
||
<circle cx="${size/2}" cy="${size/2}" r="${r - strokeW / 2}" fill="var(--bg-card)"/>
|
||
</svg>
|
||
<div style="flex:1;">${legend}</div>
|
||
</div>`;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('Donut chart error:', e);
|
||
}
|
||
}
|
||
|
||
function updateCosts() {
|
||
const todayEl = document.getElementById('costToday');
|
||
todayEl.dataset.format = 'currency';
|
||
animateValue(todayEl, costs.today || 0);
|
||
|
||
const weekEl = document.getElementById('costWeek');
|
||
weekEl.dataset.format = 'currency';
|
||
animateValue(weekEl, costs.week || 0);
|
||
|
||
const totalEl = document.getElementById('costTotal');
|
||
totalEl.dataset.format = 'currency';
|
||
animateValue(totalEl, costs.total || 0);
|
||
|
||
const perDay = costs.perDay || {};
|
||
const dayCount = Object.keys(perDay).length || 1;
|
||
const avgEl = document.getElementById('costAvg');
|
||
avgEl.dataset.format = 'currency';
|
||
animateValue(avgEl, (costs.total || 0) / dayCount);
|
||
|
||
const days = Object.keys(perDay).sort().slice(-14);
|
||
const maxSpend = Math.max(...days.map(d => perDay[d] || 0), 0.01);
|
||
|
||
// Render SVG line chart
|
||
try {
|
||
const chartEl = document.getElementById('costTrendChart');
|
||
if (chartEl && days.length > 0) {
|
||
const w = 800, h = 200, pad = 40;
|
||
const vals = days.map(d => perDay[d] || 0);
|
||
const minVal = 0;
|
||
const maxVal = Math.max(...vals, 0.01);
|
||
const range = maxVal - minVal || 1;
|
||
|
||
const points = vals.map((v, i) => {
|
||
const x = pad + (i / (vals.length - 1)) * (w - pad * 2);
|
||
const y = h - pad - ((v - minVal) / range) * (h - pad * 2);
|
||
return `${x},${y}`;
|
||
}).join(' ');
|
||
|
||
const dots = vals.map((v, i) => {
|
||
const x = pad + (i / (vals.length - 1)) * (w - pad * 2);
|
||
const y = h - pad - ((v - minVal) / range) * (h - pad * 2);
|
||
const date = new Date(days[i]);
|
||
const label = date.toLocaleDateString('en', { month: 'short', day: 'numeric' });
|
||
return `<circle cx="${x}" cy="${y}" r="4" fill="var(--accent)" stroke="var(--bg-card)" stroke-width="2" style="cursor:pointer;"><title>${label}: $${v.toFixed(2)}</title></circle>`;
|
||
}).join('');
|
||
|
||
const xLabels = days.map((d, i) => {
|
||
if (i % Math.ceil(days.length / 7) !== 0 && i !== days.length - 1) return '';
|
||
const x = pad + (i / (vals.length - 1)) * (w - pad * 2);
|
||
const date = new Date(d);
|
||
const label = date.toLocaleDateString('en', { month: 'short', day: 'numeric' });
|
||
return `<text x="${x}" y="${h - 10}" fill="var(--text-muted)" font-size="11" text-anchor="middle" font-family="JetBrains Mono, monospace">${label}</text>`;
|
||
}).join('');
|
||
|
||
const yTicks = [0, maxVal / 2, maxVal].map((val, i) => {
|
||
const y = h - pad - (i / 2) * (h - pad * 2);
|
||
return `<text x="${pad - 10}" y="${y + 4}" fill="var(--text-muted)" font-size="11" text-anchor="end" font-family="JetBrains Mono, monospace">$${val.toFixed(2)}</text>
|
||
<line x1="${pad - 5}" y1="${y}" x2="${w - pad}" y2="${y}" stroke="var(--border)" stroke-width="1" stroke-dasharray="4,4"/>`;
|
||
}).join('');
|
||
|
||
chartEl.innerHTML = `<svg width="100%" height="${h}" viewBox="0 0 ${w} ${h}" style="max-width:100%;height:auto;">
|
||
${yTicks}
|
||
<polyline points="${points}" fill="none" stroke="var(--accent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||
${dots}
|
||
${xLabels}
|
||
</svg>`;
|
||
}
|
||
} catch (e) {
|
||
console.error('Cost chart error:', e);
|
||
}
|
||
|
||
const modelHtml = Object.entries(costs.perModel || {})
|
||
.sort((a, b) => b[1] - a[1])
|
||
.map(([model, cost]) => {
|
||
const shortModel = model.split('/').pop();
|
||
return `
|
||
<div style="display:flex; justify-content:space-between; padding:10px 0; border-bottom:1px solid var(--border);">
|
||
<span class="mono">${shortModel}</span>
|
||
<span class="mono" style="font-weight:700;">$${cost.toFixed(2)}</span>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
document.getElementById('costByModel').innerHTML = modelHtml ||
|
||
'<div class="empty-state-text">No data</div>';
|
||
|
||
const topHtml = Object.entries(costs.perSession || {})
|
||
.map(([sid, v]) => [sid, typeof v === 'object' ? v : { cost: v, label: sid.substring(0, 12) }])
|
||
.sort((a, b) => b[1].cost - a[1].cost)
|
||
.slice(0, 10)
|
||
.map(([sid, v]) => `
|
||
<div style="display:flex; justify-content:space-between; padding:10px 0; border-bottom:1px solid var(--border);">
|
||
<span>${v.label}</span>
|
||
<span class="mono" style="font-weight:700;">$${v.cost.toFixed(2)}</span>
|
||
</div>
|
||
`).join('');
|
||
|
||
document.getElementById('topSessions').innerHTML = topHtml ||
|
||
'<div class="empty-state-text">No data</div>';
|
||
}
|
||
|
||
function updateStatusBar() {
|
||
if (systemStats.uptime) {
|
||
const uptime = systemStats.uptime;
|
||
const days = Math.floor(uptime / 86400);
|
||
const hours = Math.floor((uptime % 86400) / 3600);
|
||
const uptimeStr = days > 0 ? `${days}d ${hours}h` : hours > 0 ? `${hours}h` : `${Math.floor(uptime / 60)}m`;
|
||
document.getElementById('statusUptime').textContent = uptimeStr;
|
||
}
|
||
|
||
const mainSession = sessions.find(s => s.key.includes('main:main'));
|
||
if (mainSession) {
|
||
document.getElementById('statusModel').textContent = mainSession.model.split('/').pop();
|
||
}
|
||
|
||
if (sessions.length > 0) {
|
||
const latest = sessions.reduce((a, b) => a.updatedAt > b.updatedAt ? a : b);
|
||
const age = Date.now() - latest.updatedAt;
|
||
const ago = age < 60000 ? 'just now' :
|
||
age < 3600000 ? Math.round(age / 60000) + 'm ago' :
|
||
age < 86400000 ? Math.round(age / 3600000) + 'h ago' :
|
||
Math.round(age / 86400000) + 'd ago';
|
||
document.getElementById('statusLastActivity').textContent = ago;
|
||
}
|
||
}
|
||
|
||
const _feedSessions = new Set();
|
||
|
||
function getSessionColor(name) {
|
||
const colors = ['#6366f1','#ec4899','#10b981','#f59e0b','#8b5cf6','#06b6d4','#ef4444','#84cc16','#f97316','#14b8a6'];
|
||
let hash = 0;
|
||
for (let i = 0; i < name.length; i++) hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0;
|
||
return colors[Math.abs(hash) % colors.length];
|
||
}
|
||
|
||
function applyFeedFilter() {
|
||
const sf = document.getElementById('feedSessionFilter').value;
|
||
const rf = document.getElementById('feedRoleFilter').value;
|
||
document.querySelectorAll('.feed-item').forEach(el => {
|
||
const matchSession = sf === 'all' || el.dataset.session === sf;
|
||
const matchRole = rf === 'all' || el.dataset.role === rf;
|
||
let matchSearch = true;
|
||
if (feedSearchTerm) {
|
||
const content = el.querySelector('.feed-content');
|
||
if (content) {
|
||
const text = content.getAttribute('data-original') || content.textContent;
|
||
if (!content.getAttribute('data-original')) {
|
||
content.setAttribute('data-original', text);
|
||
}
|
||
matchSearch = text.toLowerCase().includes(feedSearchTerm);
|
||
if (matchSearch && feedSearchTerm) {
|
||
content.innerHTML = highlightText(text, feedSearchTerm);
|
||
} else {
|
||
content.textContent = text;
|
||
}
|
||
}
|
||
}
|
||
el.style.display = (matchSession && matchRole && matchSearch) ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
function connectLiveFeed() {
|
||
if (liveEventSource) return;
|
||
|
||
// Populate session filter from known sessions
|
||
const sel = document.getElementById('feedSessionFilter');
|
||
if (sel && sessions.length) {
|
||
const current = sel.value;
|
||
const opts = ['<option value="all">All Sessions</option>'];
|
||
const seen = new Set();
|
||
sessions.forEach(s => {
|
||
const label = s.label || s.key.split(':').slice(2).join(':') || s.key;
|
||
if (!seen.has(label)) { seen.add(label); opts.push(`<option value="${label}">${label}</option>`); }
|
||
});
|
||
sel.innerHTML = opts.join('');
|
||
sel.value = current || 'all';
|
||
}
|
||
|
||
const token = getStoredToken();
|
||
liveEventSource = new EventSource(API_BASE + '/api/live?token=' + encodeURIComponent(token));
|
||
|
||
liveEventSource.onmessage = (event) => {
|
||
if (feedPaused) return;
|
||
|
||
try {
|
||
const data = JSON.parse(event.data);
|
||
if (data.status === 'connected') {
|
||
const feed = document.getElementById('feedStream');
|
||
if (feed.querySelector('[data-placeholder]')) feed.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
const feed = document.getElementById('feedStream');
|
||
const ph = feed.querySelector('[data-placeholder]');
|
||
if (ph) ph.remove();
|
||
const roleClass = data.role || 'assistant';
|
||
const roleLabel = (data.role || 'assistant').toUpperCase();
|
||
const sessionName = data.session || 'unknown';
|
||
const sessionColor = getSessionColor(sessionName);
|
||
|
||
// Add to session filter if new
|
||
if (!_feedSessions.has(sessionName)) {
|
||
_feedSessions.add(sessionName);
|
||
const sel = document.getElementById('feedSessionFilter');
|
||
if (sel && ![...sel.options].find(o => o.value === sessionName)) {
|
||
sel.add(new Option(sessionName, sessionName));
|
||
}
|
||
}
|
||
|
||
const sfEl = document.getElementById('feedSessionFilter');
|
||
const rfEl = document.getElementById('feedRoleFilter');
|
||
const sf = sfEl ? sfEl.value : 'all';
|
||
const rf = rfEl ? rfEl.value : 'all';
|
||
const visible = (sf === 'all' || sf === sessionName) && (rf === 'all' || rf === roleClass);
|
||
|
||
const time = new Date(data.timestamp).toLocaleTimeString('en', {
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit'
|
||
});
|
||
|
||
const item = document.createElement('div');
|
||
item.className = `feed-item role-${roleClass}`;
|
||
item.dataset.session = sessionName;
|
||
item.dataset.role = roleClass;
|
||
if (!visible) item.style.display = 'none';
|
||
item.innerHTML = `
|
||
<div class="feed-header-line">
|
||
<div style="display:flex;align-items:center;gap:8px;">
|
||
<span style="background:${sessionColor};color:#fff;padding:1px 8px;border-radius:10px;font-size:10px;font-weight:600;letter-spacing:0.02em;">${sessionName}</span>
|
||
<span class="feed-role ${roleClass}">${roleLabel}</span>
|
||
</div>
|
||
<span class="feed-time">${time}</span>
|
||
</div>
|
||
<div class="feed-content">${(data.content || '').replace(/</g,'<')}</div>
|
||
`;
|
||
|
||
feed.insertBefore(item, feed.firstChild);
|
||
|
||
const items = feed.querySelectorAll('.feed-item');
|
||
if (items.length > 200) {
|
||
items[items.length - 1].remove();
|
||
}
|
||
|
||
// Check for sub-agent completion notification
|
||
if (data.session && data.session.toLowerCase().includes('sub') && data.role === 'assistant' && data.content && data.content.toLowerCase().includes('complet')) {
|
||
sendNotification('Sub-agent Complete', `${data.session} finished a task`);
|
||
}
|
||
} catch (e) {
|
||
console.error('Feed parse error:', e);
|
||
}
|
||
};
|
||
|
||
liveEventSource.onerror = () => {
|
||
console.error('Live feed disconnected');
|
||
liveEventSource.close();
|
||
liveEventSource = null;
|
||
setTimeout(connectLiveFeed, 5000);
|
||
};
|
||
}
|
||
|
||
document.getElementById('pauseBtn').addEventListener('click', function() {
|
||
if (!liveEventSource) {
|
||
this.textContent = '⏳ Loading...';
|
||
this.disabled = true;
|
||
const feed = document.getElementById('feedStream');
|
||
feed.innerHTML = '<div data-placeholder style="text-align:center;padding:24px;color:var(--text-muted);">Connecting to live stream...</div>';
|
||
feedPaused = false;
|
||
connectLiveFeed();
|
||
setTimeout(() => { this.textContent = '⏸ Pause'; this.disabled = false; }, 1500);
|
||
} else {
|
||
feedPaused = !feedPaused;
|
||
if (feedPaused) {
|
||
liveEventSource.close();
|
||
liveEventSource = null;
|
||
this.textContent = '▶ Start';
|
||
} else {
|
||
this.textContent = '⏳ Loading...';
|
||
this.disabled = true;
|
||
connectLiveFeed();
|
||
setTimeout(() => { this.textContent = '⏸ Pause'; this.disabled = false; }, 1500);
|
||
}
|
||
}
|
||
});
|
||
|
||
let healthHistory = [];
|
||
|
||
async function fetchHealthHistory() {
|
||
try {
|
||
const res = await authFetch(API_BASE + '/api/health-history');
|
||
healthHistory = await res.json();
|
||
renderHealthSparklines();
|
||
} catch {}
|
||
}
|
||
|
||
function renderHealthSparklines() {
|
||
if (!healthHistory || healthHistory.length < 2) return;
|
||
|
||
const renderSparkline = (containerId, dataKey, color) => {
|
||
const el = document.getElementById(containerId);
|
||
if (!el) return;
|
||
const w = 120, h = 30;
|
||
const vals = healthHistory.map(p => p[dataKey] || 0);
|
||
const min = Math.max(Math.min(...vals) - 5, 0);
|
||
const max = Math.min(Math.max(...vals) + 5, 100);
|
||
const range = max - min || 1;
|
||
const points = vals.map((v, i) => {
|
||
const x = (i / (vals.length - 1)) * w;
|
||
const y = h - ((v - min) / range) * h;
|
||
return `${x},${y}`;
|
||
}).join(' ');
|
||
el.innerHTML = `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
|
||
<polyline points="${points}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||
</svg>`;
|
||
};
|
||
|
||
renderSparkline('cpuSparkline', 'cpu', 'var(--green)');
|
||
renderSparkline('ramSparkline', 'ram', 'var(--blue)');
|
||
renderSparkline('tempSparkline', 'temp', 'var(--yellow)');
|
||
renderSparkline('diskSparkline', 'disk', 'var(--purple)');
|
||
}
|
||
|
||
async function fetchNewData() {
|
||
try {
|
||
const [cronsRes, gitRes, svcRes, memRes, tokRes, rtRes] = await Promise.all([
|
||
authFetch(API_BASE + '/api/crons'),
|
||
authFetch(API_BASE + '/api/git'),
|
||
authFetch(API_BASE + '/api/services'),
|
||
authFetch(API_BASE + '/api/memory'),
|
||
authFetch(API_BASE + '/api/tokens-today'),
|
||
authFetch(API_BASE + '/api/response-time')
|
||
]);
|
||
const crons = await cronsRes.json();
|
||
const git = await gitRes.json();
|
||
const services = await svcRes.json();
|
||
const memFiles = await memRes.json();
|
||
const tokens = await tokRes.json();
|
||
const rt = await rtRes.json();
|
||
|
||
document.getElementById('servicesStatus').innerHTML = services.map((s, i) => {
|
||
const isLast = i === services.length - 1;
|
||
const border = isLast ? 'none' : '1px solid var(--border)';
|
||
const status = s.active === null ? 'N/A' : (s.active ? 'Running' : 'Stopped');
|
||
const dotColor = s.active === null ? 'var(--text-muted)' : (s.active ? 'var(--green)' : 'var(--red)');
|
||
const textColor = s.active === null ? 'var(--text-muted)' : (s.active ? 'var(--green)' : 'var(--red)');
|
||
return `<div style="display:flex;align-items:center;gap:10px;padding:12px 0;border-bottom:${border};">
|
||
<span style="width:10px;height:10px;border-radius:50%;background:${dotColor};flex-shrink:0;"></span>
|
||
<span style="font-weight:600;font-size:14px;">${s.name}</span>
|
||
<span style="margin-left:auto;font-size:12px;color:${textColor};">${status}</span>
|
||
</div>`;
|
||
}).join('') || '<div class="empty-state-text">No services</div>';
|
||
|
||
const now = Date.now();
|
||
document.getElementById('cronJobs').innerHTML = crons.map(c => {
|
||
const statusIcon = c.lastStatus === 'ok' ? '✅' : c.lastStatus === 'unknown' ? '⚪' : '❌';
|
||
const nextAgo = c.nextRunAt > now ? formatTimeAgo(c.nextRunAt - now, true) : '--';
|
||
const toggleColor = c.enabled ? 'var(--green)' : 'var(--text-muted)';
|
||
const toggleBg = c.enabled ? 'rgba(16,185,129,0.2)' : 'var(--bg-tertiary)';
|
||
return `<div style="padding:10px 0;border-bottom:1px solid var(--border);">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;">
|
||
<span style="font-weight:600;font-size:13px;flex:1;">${statusIcon} ${c.name}</span>
|
||
<button onclick="window.toggleCronJob('${c.id}')" style="padding:2px 8px;background:${toggleBg};color:${toggleColor};border:1px solid var(--border);border-radius:4px;font-size:10px;font-weight:600;cursor:pointer;margin-right:4px;">${c.enabled ? 'ON' : 'OFF'}</button>
|
||
<button onclick="window.runCronJob('${c.id}')" style="padding:2px 8px;background:var(--bg-tertiary);color:var(--text-primary);border:1px solid var(--border);border-radius:4px;font-size:10px;cursor:pointer;">▶</button>
|
||
</div>
|
||
<span class="mono" style="font-size:11px;color:var(--text-muted);">${c.schedule}</span>
|
||
<div style="font-size:11px;color:var(--text-muted);margin-top:4px;">Next: ${c.enabled ? nextAgo : 'disabled'} · Last: ${c.lastDuration ? (c.lastDuration / 1000).toFixed(0) + 's' : '--'}</div>
|
||
</div>`;
|
||
}).join('') || '<div class="empty-state-text">No cron jobs</div>';
|
||
|
||
// Cron management
|
||
window.toggleCronJob = async function(id) {
|
||
try {
|
||
await authFetch(`/api/cron/${id}/toggle`, { method: 'POST' });
|
||
await fetchNewData();
|
||
} catch {}
|
||
};
|
||
|
||
window.runCronJob = async function(id) {
|
||
try {
|
||
await authFetch(`/api/cron/${id}/run`, { method: 'POST' });
|
||
sendNotification('Cron Job Started', `Running cron job ${id.substring(0, 8)}...`);
|
||
} catch {}
|
||
};
|
||
|
||
document.getElementById('gitActivity').innerHTML = git.map(c => {
|
||
const age = now - c.timestamp;
|
||
const ago = age < 3600000 ? Math.round(age/60000)+'m ago' : age < 86400000 ? Math.round(age/3600000)+'h ago' : Math.round(age/86400000)+'d ago';
|
||
return `<div style="display:flex;gap:10px;padding:8px 0;border-bottom:1px solid var(--border);font-size:12px;">
|
||
<span class="mono" style="color:var(--accent);flex-shrink:0;">${c.hash}</span>
|
||
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${c.message}</span>
|
||
<span style="flex-shrink:0;color:var(--text-muted);">${c.repo}</span>
|
||
<span style="flex-shrink:0;color:var(--text-muted);">${ago}</span>
|
||
</div>`;
|
||
}).join('') || '<div class="empty-state-text">No recent commits</div>';
|
||
|
||
const memLimit = 5;
|
||
const memHtml = memFiles.map((f, idx) => {
|
||
const age = now - f.modified;
|
||
const ago = age < 60000 ? 'just now' : age < 3600000 ? Math.round(age/60000)+'m ago' : age < 86400000 ? Math.round(age/3600000)+'h ago' : Math.round(age/86400000)+'d ago';
|
||
return `<div class="mem-file-item" style="display:flex;justify-content:space-between;padding:12px 0;border-bottom:1px solid var(--border);${idx >= memLimit ? 'display:none;' : ''}">
|
||
<span class="mono" style="font-size:13px;">📄 ${f.name}</span>
|
||
<span style="font-size:12px;color:var(--text-muted);">${ago}</span>
|
||
</div>`;
|
||
}).join('') || '<div class="empty-state-text">No files</div>';
|
||
const showMoreBtn = memFiles.length > memLimit ? `<div id="memShowMore" style="text-align:center;padding:10px 0;cursor:pointer;color:var(--accent);font-size:13px;font-weight:500;" onclick="document.querySelectorAll('.mem-file-item').forEach(e=>e.style.display='flex');this.style.display='none';">Show all (${memFiles.length} files) ↓</div>` : '';
|
||
document.getElementById('memoryFiles').innerHTML = memHtml + showMoreBtn;
|
||
|
||
const inK = (tokens.totalInput / 1000).toFixed(0) + 'k';
|
||
const outK = (tokens.totalOutput / 1000).toFixed(0) + 'k';
|
||
document.getElementById('todayTokensOut').textContent = inK + ' in / ' + outK + ' out';
|
||
|
||
document.getElementById('tokenBreakdown').innerHTML = Object.entries(tokens.perModel || {})
|
||
.sort((a, b) => (b[1].input + b[1].output) - (a[1].input + a[1].output))
|
||
.map(([model, d]) => {
|
||
const total = d.input + d.output;
|
||
const maxTok = Math.max(...Object.values(tokens.perModel).map(m => m.input + m.output), 1);
|
||
const pct = (total / maxTok) * 100;
|
||
return `<div style="margin-bottom:12px;">
|
||
<div style="display:flex;justify-content:space-between;font-size:12px;margin-bottom:4px;">
|
||
<span class="mono">${model}</span>
|
||
<span class="mono" style="color:var(--text-muted);">${(d.input/1000).toFixed(0)}k in / ${(d.output/1000).toFixed(0)}k out</span>
|
||
</div>
|
||
<div style="height:6px;background:var(--bg-tertiary);border-radius:3px;overflow:hidden;">
|
||
<div style="height:100%;width:${pct}%;background:linear-gradient(90deg,var(--accent),var(--purple));border-radius:3px;"></div>
|
||
</div>
|
||
</div>`;
|
||
}).join('') || '<div class="empty-state-text">No token data</div>';
|
||
|
||
const modelColors = {'claude-opus-4-6':'var(--accent)','claude-opus-4-5':'var(--purple)','claude-sonnet-4-5':'var(--cyan)','gemini-3-pro-preview':'var(--yellow)','gemini-2.5-flash':'var(--green)'};
|
||
const uniqueSessions = [];
|
||
const seenLabels = new Set();
|
||
sessions.sort((a,b) => b.updatedAt - a.updatedAt).forEach(s => {
|
||
if (!seenLabels.has(s.label)) { seenLabels.add(s.label); uniqueSessions.push(s); }
|
||
});
|
||
document.getElementById('sessionModels').innerHTML = uniqueSessions.map(s => {
|
||
const shortModel = s.model.split('/').pop();
|
||
const color = modelColors[shortModel] || 'var(--text-muted)';
|
||
return `<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid var(--border);">
|
||
<span style="font-weight:500;font-size:13px;">${s.label}</span>
|
||
<span class="mono" style="font-size:11px;color:${color};background:${color}18;padding:2px 8px;border-radius:4px;">${shortModel}</span>
|
||
</div>`;
|
||
}).join('') || '<div class="empty-state-text">No sessions</div>';
|
||
|
||
const rtVal = rt.avgSeconds;
|
||
document.getElementById('avgResponseTime').textContent = rtVal > 0 ? rtVal + 's' : '--';
|
||
} catch (e) { console.error('New data fetch error:', e); }
|
||
}
|
||
|
||
// Memory page
|
||
let memoryFiles = [];
|
||
async function fetchMemoryFiles() {
|
||
try {
|
||
const res = await authFetch(API_BASE + '/api/memory-files');
|
||
memoryFiles = await res.json();
|
||
renderMemoryFilesList();
|
||
} catch {}
|
||
}
|
||
|
||
function renderMemoryFilesList() {
|
||
const el = document.getElementById('memoryFilesList');
|
||
if (!el) return;
|
||
const now = Date.now();
|
||
el.innerHTML = memoryFiles.map(f => {
|
||
const age = now - f.modified;
|
||
const ago = age < 60000 ? 'just now' : age < 3600000 ? Math.round(age/60000)+'m ago' : age < 86400000 ? Math.round(age/3600000)+'h ago' : Math.round(age/86400000)+'d ago';
|
||
const sizeKb = (f.size / 1024).toFixed(1);
|
||
const icon = f.name.includes('MEMORY') ? '🧠' : f.name.includes('HEARTBEAT') ? '💓' : '📄';
|
||
return `<div style="padding:12px;border-bottom:1px solid var(--border);cursor:pointer;transition:all 0.2s;" onclick="window.loadMemoryFile('${f.name}')" onmouseover="this.style.background='var(--bg-tertiary)'" onmouseout="this.style.background='transparent'">
|
||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
|
||
<span style="font-size:18px;">${icon}</span>
|
||
<span style="font-weight:600;font-size:13px;flex:1;">${f.name}</span>
|
||
</div>
|
||
<div style="font-size:11px;color:var(--text-muted);">${sizeKb} KB · ${ago}</div>
|
||
</div>`;
|
||
}).join('') || '<div class="empty-state-text">No memory files</div>';
|
||
}
|
||
|
||
window.loadMemoryFile = async function(name) {
|
||
try {
|
||
const titleEl = document.getElementById('memoryFileTitle');
|
||
const contentEl = document.getElementById('memoryFileContent');
|
||
titleEl.textContent = name;
|
||
contentEl.innerHTML = '<div style="color:var(--text-muted);">Loading...</div>';
|
||
const res = await authFetch(API_BASE + '/api/memory-file?path=' + encodeURIComponent(name));
|
||
const content = await res.text();
|
||
let html = content
|
||
.replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/^### (.+)$/gm, '<div style="font-size:16px;font-weight:700;color:var(--accent);margin:16px 0 8px;">$1</div>')
|
||
.replace(/^## (.+)$/gm, '<div style="font-size:18px;font-weight:700;color:var(--accent);margin:20px 0 12px;">$1</div>')
|
||
.replace(/^# (.+)$/gm, '<div style="font-size:20px;font-weight:700;color:var(--accent);margin:24px 0 16px;">$1</div>')
|
||
.replace(/\*\*(.+?)\*\*/g, '<strong style="color:var(--text-primary);font-weight:700;">$1</strong>')
|
||
.replace(/`([^`]+)`/g, '<code style="background:var(--bg-tertiary);padding:2px 6px;border-radius:4px;font-size:11px;">$1</code>');
|
||
contentEl.innerHTML = html;
|
||
} catch (e) {
|
||
document.getElementById('memoryFileContent').innerHTML = '<div style="color:var(--red);">Failed to load file</div>';
|
||
}
|
||
};
|
||
|
||
// Files page
|
||
let keyFiles = [];
|
||
let currentKeyFile = null;
|
||
let keyFileEditing = false;
|
||
let _currentKeyFileRaw = '';
|
||
|
||
window.fetchKeyFiles = async function fetchKeyFiles() {
|
||
try {
|
||
const res = await authFetch(API_BASE + '/api/key-files');
|
||
keyFiles = await res.json();
|
||
renderKeyFilesList();
|
||
} catch {}
|
||
}
|
||
|
||
function renderKeyFilesList() {
|
||
const el = document.getElementById('keyFilesList');
|
||
if (!el) return;
|
||
const now = Date.now();
|
||
el.innerHTML = keyFiles.map(f => {
|
||
const age = now - f.modified;
|
||
const ago = age < 60000 ? 'just now' : age < 3600000 ? Math.round(age/60000)+'m ago' : age < 86400000 ? Math.round(age/3600000)+'h ago' : Math.round(age/86400000)+'d ago';
|
||
const sizeKb = (f.size / 1024).toFixed(1);
|
||
const icon = f.name.startsWith('skills/') ? '🎯' : f.name.endsWith('.service') ? '⚙️' : f.name.endsWith('.json') ? '🔧' : '📄';
|
||
const isSelected = f.name === currentKeyFile;
|
||
return `<div style="padding:12px;border-bottom:1px solid var(--border);cursor:pointer;transition:all 0.2s;${isSelected ? 'background:var(--bg-tertiary);' : ''}" onclick="window.loadKeyFile('${f.name.replace(/'/g, "\\'")}')" onmouseover="this.style.background='var(--bg-tertiary)'" onmouseout="this.style.background='${isSelected ? 'var(--bg-tertiary)' : 'transparent'}'">
|
||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
|
||
<span style="font-size:18px;">${icon}</span>
|
||
<span style="font-weight:600;font-size:13px;flex:1;">${f.name}</span>
|
||
</div>
|
||
<div style="font-size:11px;color:var(--text-muted);">${sizeKb} KB · ${ago}</div>
|
||
</div>`;
|
||
}).join('') || '<div class="empty-state-text">No files found</div>';
|
||
}
|
||
|
||
window.loadKeyFile = async function(name) {
|
||
currentKeyFile = name;
|
||
keyFileEditing = false;
|
||
const titleEl = document.getElementById('keyFileTitle');
|
||
const contentEl = document.getElementById('keyFileContent');
|
||
const editorEl = document.getElementById('keyFileEditor');
|
||
const editBtn = document.getElementById('keyFileEditBtn');
|
||
const saveBtn = document.getElementById('keyFileSaveBtn');
|
||
const cancelBtn = document.getElementById('keyFileCancelBtn');
|
||
|
||
titleEl.textContent = name;
|
||
contentEl.innerHTML = '<div style="color:var(--text-muted);">Loading...</div>';
|
||
contentEl.style.display = 'block';
|
||
editorEl.style.display = 'none';
|
||
editBtn.style.display = 'inline-block';
|
||
saveBtn.style.display = 'none';
|
||
cancelBtn.style.display = 'none';
|
||
|
||
renderKeyFilesList();
|
||
|
||
try {
|
||
const res = await authFetch(API_BASE + '/api/key-file?path=' + encodeURIComponent(name));
|
||
if (!res.ok) { contentEl.innerHTML = '<div style="color:var(--red);">Failed to load: ' + res.status + '</div>'; return; }
|
||
const content = await res.text();
|
||
_currentKeyFileRaw = content;
|
||
|
||
if (name.endsWith('.md')) {
|
||
let html = content
|
||
.replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/^### (.+)$/gm, '<div style="font-size:16px;font-weight:700;color:var(--accent);margin:16px 0 8px;">$1</div>')
|
||
.replace(/^## (.+)$/gm, '<div style="font-size:18px;font-weight:700;color:var(--accent);margin:20px 0 12px;">$1</div>')
|
||
.replace(/^# (.+)$/gm, '<div style="font-size:20px;font-weight:700;color:var(--accent);margin:24px 0 16px;">$1</div>')
|
||
.replace(/\*\*(.+?)\*\*/g, '<strong style="color:var(--text-primary);font-weight:700;">$1</strong>')
|
||
.replace(/`([^`]+)`/g, '<code style="background:var(--bg-tertiary);padding:2px 6px;border-radius:4px;font-size:11px;">$1</code>');
|
||
contentEl.innerHTML = html;
|
||
} else {
|
||
contentEl.innerHTML = '<pre style="white-space:pre-wrap;word-wrap:break-word;">' + content.replace(/</g, '<').replace(/>/g, '>') + '</pre>';
|
||
}
|
||
} catch (e) {
|
||
contentEl.innerHTML = '<div style="color:var(--red);">Failed to load file</div>';
|
||
}
|
||
};
|
||
|
||
window.editKeyFile = function() {
|
||
if (!currentKeyFile) return;
|
||
keyFileEditing = true;
|
||
const contentEl = document.getElementById('keyFileContent');
|
||
const editorEl = document.getElementById('keyFileEditor');
|
||
const editBtn = document.getElementById('keyFileEditBtn');
|
||
const saveBtn = document.getElementById('keyFileSaveBtn');
|
||
const cancelBtn = document.getElementById('keyFileCancelBtn');
|
||
|
||
editorEl.value = _currentKeyFileRaw;
|
||
contentEl.style.display = 'none';
|
||
editorEl.style.display = 'block';
|
||
editBtn.style.display = 'none';
|
||
saveBtn.style.display = 'inline-block';
|
||
cancelBtn.style.display = 'inline-block';
|
||
editorEl.focus();
|
||
};
|
||
|
||
window.cancelEditKeyFile = function() {
|
||
keyFileEditing = false;
|
||
const contentEl = document.getElementById('keyFileContent');
|
||
const editorEl = document.getElementById('keyFileEditor');
|
||
const editBtn = document.getElementById('keyFileEditBtn');
|
||
const saveBtn = document.getElementById('keyFileSaveBtn');
|
||
const cancelBtn = document.getElementById('keyFileCancelBtn');
|
||
|
||
contentEl.style.display = 'block';
|
||
editorEl.style.display = 'none';
|
||
editBtn.style.display = 'inline-block';
|
||
saveBtn.style.display = 'none';
|
||
cancelBtn.style.display = 'none';
|
||
};
|
||
|
||
window.saveKeyFile = async function() {
|
||
if (!currentKeyFile) return;
|
||
const editorEl = document.getElementById('keyFileEditor');
|
||
const saveBtn = document.getElementById('keyFileSaveBtn');
|
||
const content = editorEl.value;
|
||
|
||
saveBtn.textContent = 'Saving…';
|
||
saveBtn.disabled = true;
|
||
|
||
try {
|
||
const res = await authFetch(API_BASE + '/api/key-file', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path: currentKeyFile, content })
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok || !data.success) throw new Error(data.error || 'Save failed');
|
||
|
||
saveBtn.textContent = 'Saved!';
|
||
setTimeout(() => {
|
||
saveBtn.textContent = 'Save';
|
||
saveBtn.disabled = false;
|
||
}, 1200);
|
||
window.cancelEditKeyFile();
|
||
await window.loadKeyFile(currentKeyFile);
|
||
} catch (e) {
|
||
saveBtn.textContent = 'Save';
|
||
saveBtn.disabled = false;
|
||
const contentEl = document.getElementById('keyFileContent');
|
||
const errDiv = document.createElement('div');
|
||
errDiv.style.cssText = 'color:var(--red);font-size:12px;margin-top:8px;';
|
||
errDiv.textContent = 'Error: ' + e.message;
|
||
contentEl.parentNode.appendChild(errDiv);
|
||
setTimeout(() => errDiv.remove(), 4000);
|
||
}
|
||
};
|
||
|
||
function formatTimeAgo(ms, future) {
|
||
if (ms < 60000) return (future ? 'in ' : '') + 'less than a minute';
|
||
if (ms < 3600000) return (future ? 'in ' : '') + Math.round(ms/60000) + 'm';
|
||
if (ms < 86400000) return (future ? 'in ' : '') + Math.round(ms/3600000) + 'h';
|
||
return (future ? 'in ' : '') + Math.round(ms/86400000) + 'd';
|
||
}
|
||
|
||
// Keyboard shortcuts
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
|
||
|
||
const pages = ['overview', 'sessions', 'costs', 'limits', 'feed'];
|
||
if (e.key >= '1' && e.key <= '5') {
|
||
const idx = parseInt(e.key) - 1;
|
||
if (pages[idx]) {
|
||
document.querySelector(`[data-page="${pages[idx]}"]`).click();
|
||
}
|
||
} else if (e.key === ' ' && document.querySelector('.page.active').id === 'feed') {
|
||
e.preventDefault();
|
||
document.getElementById('pauseBtn').click();
|
||
} else if (e.key === 'Escape') {
|
||
closeSessionModal();
|
||
toggleShortcuts(false);
|
||
} else if (e.key === '/') {
|
||
e.preventDefault();
|
||
const activePage = document.querySelector('.page.active').id;
|
||
if (activePage === 'sessions') {
|
||
document.getElementById('sessionSearch').focus();
|
||
} else if (activePage === 'feed') {
|
||
document.getElementById('feedSearchInput').focus();
|
||
}
|
||
} else if (e.key === '?') {
|
||
e.preventDefault();
|
||
toggleShortcuts();
|
||
}
|
||
});
|
||
|
||
function toggleShortcuts(force) {
|
||
const overlay = document.getElementById('shortcutsOverlay');
|
||
if (force === false) {
|
||
overlay.classList.remove('active');
|
||
} else {
|
||
overlay.classList.toggle('active');
|
||
}
|
||
}
|
||
|
||
// Browser notifications
|
||
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
|
||
notificationsEnabled = true;
|
||
}
|
||
|
||
function sendNotification(title, body) {
|
||
if (notificationsEnabled && Notification.permission === 'granted') {
|
||
try {
|
||
new Notification(title, { body, icon: '✨' });
|
||
} catch {}
|
||
}
|
||
}
|
||
|
||
// Feed search
|
||
let feedSearchTerm = '';
|
||
{
|
||
const feedSearchInputEl = document.getElementById('feedSearchInput');
|
||
if (feedSearchInputEl) {
|
||
feedSearchInputEl.addEventListener('input', (e) => {
|
||
feedSearchTerm = e.target.value.toLowerCase();
|
||
applyFeedFilter();
|
||
});
|
||
}
|
||
}
|
||
|
||
function clearFeedSearch() {
|
||
document.getElementById('feedSearchInput').value = '';
|
||
feedSearchTerm = '';
|
||
applyFeedFilter();
|
||
}
|
||
|
||
function highlightText(text, term) {
|
||
if (!term) return text;
|
||
const idx = text.toLowerCase().indexOf(term);
|
||
if (idx === -1) return text;
|
||
return text.substring(0, idx) + '<span class="search-highlight">' + text.substring(idx, idx + term.length) + '</span>' + text.substring(idx + term.length);
|
||
}
|
||
|
||
// Session comparison
|
||
let compareMode = false;
|
||
function toggleSessionCompare(key, checked) {
|
||
if (checked) {
|
||
selectedSessions.add(key);
|
||
} else {
|
||
selectedSessions.delete(key);
|
||
}
|
||
|
||
const btn = document.getElementById('compareBtn');
|
||
if (selectedSessions.size === 2) {
|
||
if (!btn) {
|
||
const b = document.createElement('button');
|
||
b.id = 'compareBtn';
|
||
b.className = 'compare-btn';
|
||
b.textContent = 'Compare Sessions';
|
||
b.onclick = showComparison;
|
||
document.body.appendChild(b);
|
||
}
|
||
} else if (btn) {
|
||
btn.remove();
|
||
}
|
||
}
|
||
|
||
function showComparison() {
|
||
const keys = Array.from(selectedSessions);
|
||
const s1 = sessions.find(s => s.key === keys[0]);
|
||
const s2 = sessions.find(s => s.key === keys[1]);
|
||
if (!s1 || !s2) return;
|
||
|
||
const maxTokens = Math.max(s1.totalTokens || 0, s2.totalTokens || 0);
|
||
const maxCost = Math.max(s1.cost || 0, s2.cost || 0);
|
||
|
||
const modal = document.getElementById('sessionModal');
|
||
modal.querySelector('#modalTitle').textContent = 'Session Comparison';
|
||
modal.querySelector('#modalKey').textContent = '';
|
||
modal.querySelector('#modalStats').innerHTML = `
|
||
<div style="grid-column:1/-1;display:grid;grid-template-columns:1fr 1fr;gap:24px;">
|
||
<div>
|
||
<h3 style="font-size:16px;font-weight:700;margin-bottom:16px;color:var(--accent);">${s1.label}</h3>
|
||
<div style="display:flex;flex-direction:column;gap:12px;">
|
||
<div><span style="color:var(--text-muted);font-size:12px;">Model:</span> <span class="mono">${s1.model.split('/').pop()}</span></div>
|
||
<div><span style="color:var(--text-muted);font-size:12px;">Tokens:</span> <span class="mono">${(s1.totalTokens||0).toLocaleString()}</span></div>
|
||
<div><span style="color:var(--text-muted);font-size:12px;">Cost:</span> <span class="mono">$${(s1.cost||0).toFixed(2)}</span></div>
|
||
<div style="margin-top:12px;">
|
||
<div style="font-size:11px;color:var(--text-muted);margin-bottom:4px;">Tokens</div>
|
||
<div style="height:24px;background:var(--bg-primary);border-radius:6px;overflow:hidden;">
|
||
<div style="height:100%;width:${Math.round(((s1.totalTokens||0)/maxTokens)*100)}%;background:var(--accent);border-radius:6px;"></div>
|
||
</div>
|
||
<div style="font-size:11px;color:var(--text-muted);margin-top:8px;margin-bottom:4px;">Cost</div>
|
||
<div style="height:24px;background:var(--bg-primary);border-radius:6px;overflow:hidden;">
|
||
<div style="height:100%;width:${Math.round(((s1.cost||0)/maxCost)*100)}%;background:var(--green);border-radius:6px;"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<h3 style="font-size:16px;font-weight:700;margin-bottom:16px;color:var(--purple);">${s2.label}</h3>
|
||
<div style="display:flex;flex-direction:column;gap:12px;">
|
||
<div><span style="color:var(--text-muted);font-size:12px;">Model:</span> <span class="mono">${s2.model.split('/').pop()}</span></div>
|
||
<div><span style="color:var(--text-muted);font-size:12px;">Tokens:</span> <span class="mono">${(s2.totalTokens||0).toLocaleString()}</span></div>
|
||
<div><span style="color:var(--text-muted);font-size:12px;">Cost:</span> <span class="mono">$${(s2.cost||0).toFixed(2)}</span></div>
|
||
<div style="margin-top:12px;">
|
||
<div style="font-size:11px;color:var(--text-muted);margin-bottom:4px;">Tokens</div>
|
||
<div style="height:24px;background:var(--bg-primary);border-radius:6px;overflow:hidden;">
|
||
<div style="height:100%;width:${Math.round(((s2.totalTokens||0)/maxTokens)*100)}%;background:var(--purple);border-radius:6px;"></div>
|
||
</div>
|
||
<div style="font-size:11px;color:var(--text-muted);margin-top:8px;margin-bottom:4px;">Cost</div>
|
||
<div style="height:24px;background:var(--bg-primary);border-radius:6px;overflow:hidden;">
|
||
<div style="height:100%;width:${Math.round(((s2.cost||0)/maxCost)*100)}%;background:var(--cyan);border-radius:6px;"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
modal.querySelector('#modalMessages').innerHTML = '<div style="padding:20px;text-align:center;color:var(--text-muted);">Comparison complete</div>';
|
||
modal.style.display = 'flex';
|
||
}
|
||
|
||
function renderDiskSparkline(history) {
|
||
if (!history || history.length < 2) return;
|
||
const el = document.getElementById('diskSparkline');
|
||
if (!el) return;
|
||
const w = 120, h = 40;
|
||
const vals = history.map(p => p.v);
|
||
const min = Math.max(Math.min(...vals) - 2, 0);
|
||
const max = Math.min(Math.max(...vals) + 2, 100);
|
||
const range = max - min || 1;
|
||
const points = vals.map((v, i) => {
|
||
const x = (i / (vals.length - 1)) * w;
|
||
const y = h - ((v - min) / range) * h;
|
||
return `${x},${y}`;
|
||
}).join(' ');
|
||
el.innerHTML = `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><polyline points="${points}" fill="none" stroke="var(--purple)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
||
}
|
||
|
||
const origUpdateOverview = updateOverview;
|
||
const _origUpdateOverview = updateOverview;
|
||
|
||
function openSessionDetail(key) {
|
||
const s = sessions.find(x => x.key === key);
|
||
if (!s) return;
|
||
document.getElementById('modalTitle').textContent = s.label;
|
||
document.getElementById('modalKey').textContent = s.key;
|
||
const age = Date.now() - s.updatedAt;
|
||
const ago = age < 60000 ? 'just now' : age < 3600000 ? Math.round(age/60000)+'m ago' : age < 86400000 ? Math.round(age/3600000)+'h ago' : Math.round(age/86400000)+'d ago';
|
||
const isActive = age < 300000 && !s.aborted;
|
||
document.getElementById('modalStats').innerHTML = `
|
||
<div><div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;margin-bottom:4px;">Status</div><div style="font-weight:600;color:${isActive ? 'var(--green)' : 'var(--text-primary)'}">${isActive ? '🟢 Active' : s.aborted ? '🔴 Aborted' : '⚪ Idle'}</div></div>
|
||
<div><div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;margin-bottom:4px;">Model</div><div class="mono" style="font-size:13px;">${s.model.split('/').pop()}</div></div>
|
||
<div><div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;margin-bottom:4px;">Tokens</div><div class="mono" style="font-size:13px;">${(s.totalTokens||0).toLocaleString()}</div></div>
|
||
<div><div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;margin-bottom:4px;">Cost</div><div class="mono" style="font-size:13px;">$${(s.cost||0).toFixed(2)}</div></div>
|
||
<div><div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;margin-bottom:4px;">Last Active</div><div style="font-size:13px;">${ago}</div></div>
|
||
<div><div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;margin-bottom:4px;">Channel</div><div style="font-size:13px;">${s.channel || '--'}</div></div>`;
|
||
document.getElementById('modalMessages').innerHTML = '<div style="color:var(--text-muted);font-size:13px;">Loading...</div>';
|
||
document.getElementById('sessionModal').style.display = 'flex';
|
||
authFetch(API_BASE + '/api/session-messages?id=' + encodeURIComponent(s.sessionId || s.key))
|
||
.then(r => r.json())
|
||
.then(msgs => {
|
||
document.getElementById('modalMessages').innerHTML = msgs.map(m => {
|
||
const roleColor = m.role === 'user' ? 'var(--blue)' : m.role === 'assistant' ? 'var(--green)' : 'var(--yellow)';
|
||
const time = m.timestamp ? new Date(m.timestamp).toLocaleTimeString('en', {hour:'2-digit',minute:'2-digit'}) : '';
|
||
return `<div style="padding:8px 0;border-bottom:1px solid var(--border);font-size:12px;">
|
||
<div style="display:flex;justify-content:space-between;margin-bottom:4px;">
|
||
<span style="font-weight:600;color:${roleColor};text-transform:uppercase;font-size:10px;">${m.role}</span>
|
||
<span style="color:var(--text-muted);font-size:10px;">${time}</span>
|
||
</div>
|
||
<div style="color:var(--text-primary);line-height:1.4;word-break:break-word;font-family:'JetBrains Mono',monospace;font-size:11px;">${m.content.replace(/</g,'<')}</div>
|
||
</div>`;
|
||
}).join('') || '<div style="color:var(--text-muted);font-size:13px;">No messages found</div>';
|
||
}).catch(() => {
|
||
document.getElementById('modalMessages').innerHTML = '<div style="color:var(--text-muted);">Failed to load</div>';
|
||
});
|
||
}
|
||
function closeSessionModal() { document.getElementById('sessionModal').style.display = 'none'; }
|
||
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeSessionModal(); });
|
||
|
||
// Toast notifications
|
||
function showToast(message, type = 'info', duration = 4000) {
|
||
const container = document.getElementById('toastContainer');
|
||
if (!container) return;
|
||
|
||
const icons = { success: '✅', warning: '⚠️', info: 'ℹ️' };
|
||
const toast = document.createElement('div');
|
||
toast.className = `toast ${type}`;
|
||
toast.innerHTML = `
|
||
<div class="toast-icon">${icons[type] || icons.info}</div>
|
||
<div class="toast-content">
|
||
<div class="toast-message">${message}</div>
|
||
</div>
|
||
`;
|
||
|
||
container.appendChild(toast);
|
||
|
||
setTimeout(() => {
|
||
toast.classList.add('fade-out');
|
||
setTimeout(() => toast.remove(), 300);
|
||
}, duration);
|
||
}
|
||
|
||
// Quick Actions
|
||
async function quickAction(action, evt) {
|
||
const confirmMsg = {
|
||
'restart-openclaw': 'Restart OpenClaw? This will interrupt running tasks.',
|
||
'restart-dashboard': 'Restart Dashboard? Page will reload.',
|
||
'restart-tailscale': 'Restart Tailscale? Network may drop briefly.',
|
||
'update-openclaw': 'Update OpenClaw? This may take a minute.',
|
||
'kill-tmux': 'Kill all tmux sessions? (including Claude persistent session)',
|
||
'sys-update': 'Run apt update + upgrade? This may take several minutes.',
|
||
'disk-cleanup': null,
|
||
'restart-claude': null,
|
||
'check-update': null,
|
||
'clear-cache': null,
|
||
'gc': null,
|
||
'scrape-usage': null
|
||
};
|
||
|
||
if (confirmMsg[action] && !confirm(confirmMsg[action])) return;
|
||
|
||
const loadingLabels = {
|
||
'restart-openclaw': '🔄 Restarting...',
|
||
'restart-dashboard': '🔄 Restarting...',
|
||
'clear-cache': '🗑️ Clearing...',
|
||
'restart-tailscale': '🌐 Restarting...',
|
||
'update-openclaw': '⬆️ Updating...',
|
||
'kill-tmux': '🧹 Killing...',
|
||
'gc': '♻️ Running GC...',
|
||
'check-update': '🔍 Checking...',
|
||
'sys-update': '📦 Updating...',
|
||
'disk-cleanup': '💾 Cleaning...',
|
||
'restart-claude': '🤖 Restarting...'
|
||
};
|
||
|
||
const evtTarget = evt && evt.target ? evt.target : null;
|
||
const triggerBtn = (evtTarget && typeof evtTarget.closest === 'function' ? evtTarget.closest('.qa-btn') : null) || evtTarget;
|
||
const origText = triggerBtn && triggerBtn.textContent ? triggerBtn.textContent : '';
|
||
if (triggerBtn) { triggerBtn.textContent = loadingLabels[action] || '⏳ Working...'; triggerBtn.disabled = true; triggerBtn.style.opacity = '0.6'; triggerBtn.style.pointerEvents = 'none'; }
|
||
|
||
try {
|
||
const res = await authFetch(`/api/action/${action}`, { method: 'POST' });
|
||
const data = await res.json();
|
||
|
||
if (data.success) {
|
||
showToast(data.output || data.message || 'Action completed successfully', 'success');
|
||
|
||
if (action === 'restart-dashboard') {
|
||
setTimeout(() => location.reload(), 3000);
|
||
} else if (action === 'clear-cache') {
|
||
setTimeout(() => fetchData(), 500);
|
||
}
|
||
} else {
|
||
showToast('Action failed: ' + (data.error || 'Unknown error'), 'warning');
|
||
}
|
||
} catch (e) {
|
||
showToast('Action failed: ' + e.message, 'warning');
|
||
} finally {
|
||
if (triggerBtn) { triggerBtn.textContent = origText; triggerBtn.disabled = false; triggerBtn.style.opacity = '1'; triggerBtn.style.pointerEvents = ''; }
|
||
}
|
||
}
|
||
|
||
// Tailscale Status
|
||
async function fetchTailscaleStatus() {
|
||
try {
|
||
const res = await authFetch(API_BASE + '/api/tailscale');
|
||
const data = await res.json();
|
||
|
||
const statusEl = document.getElementById('tailscaleStatus');
|
||
if (!statusEl) return;
|
||
|
||
if (data.error) {
|
||
statusEl.innerHTML = `<div style="color:var(--text-muted);">${data.error}</div>`;
|
||
return;
|
||
}
|
||
|
||
const onlineColor = data.online ? 'var(--green)' : 'var(--red)';
|
||
const onlineText = data.online ? 'Online' : 'Offline';
|
||
|
||
let html = `
|
||
<div style="display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid var(--border);">
|
||
<span style="color:var(--text-muted);">Status</span>
|
||
<span style="color:${onlineColor};font-weight:600;">${onlineText}</span>
|
||
</div>
|
||
<div style="display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid var(--border);">
|
||
<span style="color:var(--text-muted);">Device</span>
|
||
<span class="mono" style="font-size:13px;">${data.hostname}</span>
|
||
</div>
|
||
<div style="display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid var(--border);">
|
||
<span style="color:var(--text-muted);">Tailnet IP</span>
|
||
<span class="mono" style="font-size:13px;">${data.ip}</span>
|
||
</div>
|
||
<div style="display:flex;justify-content:space-between;padding:8px 0;">
|
||
<span style="color:var(--text-muted);">Connected Peers</span>
|
||
<span style="font-weight:600;">${data.peers}</span>
|
||
</div>
|
||
`;
|
||
|
||
if (data.routes && data.routes.length > 0) {
|
||
html += `
|
||
<div style="margin-top:12px;padding-top:12px;border-top:1px solid var(--border);">
|
||
<div style="font-size:11px;color:var(--text-muted);margin-bottom:6px;text-transform:uppercase;">Active Routes</div>
|
||
${data.routes.map(r => `<div style="font-size:11px;font-family:'JetBrains Mono',monospace;color:var(--text-secondary);margin-bottom:4px;">${r}</div>`).join('')}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
statusEl.innerHTML = html;
|
||
} catch (e) {
|
||
const statusEl = document.getElementById('tailscaleStatus');
|
||
if (statusEl) statusEl.innerHTML = '<div style="color:var(--text-muted);">Failed to load</div>';
|
||
}
|
||
}
|
||
|
||
// Lifetime Stats
|
||
async function fetchLifetimeStats() {
|
||
try {
|
||
const res = await authFetch(API_BASE + '/api/lifetime-stats');
|
||
const data = await res.json();
|
||
|
||
document.getElementById('ltTokens').textContent = data.totalTokens >= 1000000 ? (data.totalTokens / 1000000).toFixed(1) + 'M' : data.totalTokens >= 1000 ? (data.totalTokens / 1000).toFixed(0) + 'k' : data.totalTokens;
|
||
document.getElementById('ltMessages').textContent = data.totalMessages.toLocaleString();
|
||
document.getElementById('ltCost').textContent = '$' + data.totalCost.toFixed(2);
|
||
document.getElementById('ltSessions').textContent = data.totalSessions;
|
||
document.getElementById('ltDaysActive').textContent = data.daysActive;
|
||
|
||
if (data.firstSessionDate) {
|
||
const date = new Date(data.firstSessionDate);
|
||
document.getElementById('ltFirstDate').textContent = date.toLocaleDateString('en', { month: 'short', day: 'numeric', year: 'numeric' });
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
// Activity Streak
|
||
function calculateStreak() {
|
||
try {
|
||
const perDay = costs.perDay || {};
|
||
const days = Object.keys(perDay).sort();
|
||
|
||
// Calculate current streak
|
||
let currentStreak = 0;
|
||
const today = new Date().toISOString().substring(0, 10);
|
||
let checkDate = new Date();
|
||
|
||
while (true) {
|
||
const dateStr = checkDate.toISOString().substring(0, 10);
|
||
if (perDay[dateStr] && perDay[dateStr] > 0) {
|
||
currentStreak++;
|
||
} else if (dateStr !== today) {
|
||
break;
|
||
}
|
||
checkDate.setDate(checkDate.getDate() - 1);
|
||
if (currentStreak > 365) break;
|
||
}
|
||
|
||
// Calculate longest streak
|
||
let longestStreak = 0;
|
||
let tempStreak = 0;
|
||
let prevDate = null;
|
||
|
||
for (const day of days) {
|
||
if (perDay[day] > 0) {
|
||
if (!prevDate) {
|
||
tempStreak = 1;
|
||
} else {
|
||
const diff = (new Date(day) - new Date(prevDate)) / 86400000;
|
||
if (diff === 1) {
|
||
tempStreak++;
|
||
} else {
|
||
longestStreak = Math.max(longestStreak, tempStreak);
|
||
tempStreak = 1;
|
||
}
|
||
}
|
||
prevDate = day;
|
||
}
|
||
}
|
||
longestStreak = Math.max(longestStreak, tempStreak);
|
||
|
||
document.getElementById('currentStreak').textContent = currentStreak;
|
||
document.getElementById('longestStreak').textContent = longestStreak;
|
||
|
||
// Render 30-day calendar
|
||
const calendarEl = document.getElementById('streakCalendar');
|
||
if (calendarEl) {
|
||
const last30Days = [];
|
||
for (let i = 29; i >= 0; i--) {
|
||
const d = new Date();
|
||
d.setDate(d.getDate() - i);
|
||
const dateStr = d.toISOString().substring(0, 10);
|
||
const active = perDay[dateStr] && perDay[dateStr] > 0;
|
||
last30Days.push({ date: dateStr, active });
|
||
}
|
||
|
||
calendarEl.innerHTML = last30Days.map(d => {
|
||
const color = d.active ? 'var(--green)' : 'var(--bg-tertiary)';
|
||
const opacity = d.active ? '1' : '0.3';
|
||
return `<div style="width:100%;aspect-ratio:1;background:${color};opacity:${opacity};border-radius:3px;" title="${d.date}"></div>`;
|
||
}).join('');
|
||
}
|
||
} catch (e) {
|
||
console.error('Streak calculation error:', e);
|
||
}
|
||
}
|
||
|
||
// Logs Viewer
|
||
let logAutoRefreshInterval = null;
|
||
|
||
async function fetchLogs() {
|
||
const service = document.getElementById('logService').value;
|
||
const lines = document.getElementById('logLines').value;
|
||
const viewer = document.getElementById('logViewer');
|
||
|
||
if (!viewer) return;
|
||
viewer.innerHTML = '<div style="color:var(--text-muted);">Loading logs...</div>';
|
||
|
||
try {
|
||
const token = getStoredToken();
|
||
const res = await fetch(`/api/logs?service=${service}&lines=${lines}`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
});
|
||
|
||
if (!res.ok) {
|
||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||
}
|
||
|
||
const logs = await res.text();
|
||
viewer.textContent = logs;
|
||
viewer.scrollTop = viewer.scrollHeight;
|
||
} catch (e) {
|
||
viewer.innerHTML = '<div style="color:var(--red);">Failed to load logs: ' + e.message + '</div>';
|
||
}
|
||
}
|
||
|
||
function toggleLogAutoRefresh(enabled) {
|
||
if (logAutoRefreshInterval) {
|
||
clearInterval(logAutoRefreshInterval);
|
||
logAutoRefreshInterval = null;
|
||
}
|
||
|
||
if (enabled) {
|
||
fetchLogs();
|
||
logAutoRefreshInterval = setInterval(fetchLogs, 5000);
|
||
}
|
||
}
|
||
|
||
// Update scrapeClaudeUsage to show toast
|
||
const origScrapeClaudeUsage = scrapeClaudeUsage;
|
||
scrapeClaudeUsage = async function() {
|
||
await origScrapeClaudeUsage();
|
||
showToast('Usage data refreshed successfully', 'success');
|
||
};
|
||
|
||
// Update document title with usage percentage
|
||
function updatePageTitle() {
|
||
try {
|
||
const cu = _cachedClaudeUsage;
|
||
if (cu && cu.session) {
|
||
document.title = cu.session.percent + '% | Agent Dashboard';
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
// Add session duration to stats
|
||
const origUpdateSessionsStats = updateSessionsStats;
|
||
updateSessionsStats = function(filtered) {
|
||
origUpdateSessionsStats(filtered);
|
||
|
||
// Calculate average session duration
|
||
try {
|
||
const durations = filtered
|
||
.filter(s => s.createdAt && s.updatedAt)
|
||
.map(s => s.updatedAt - s.createdAt)
|
||
.filter(d => d > 0 && d < 86400000 * 30);
|
||
|
||
if (durations.length > 0) {
|
||
const avgDur = durations.reduce((a, b) => a + b, 0) / durations.length;
|
||
const hours = Math.floor(avgDur / 3600000);
|
||
const mins = Math.floor((avgDur % 3600000) / 60000);
|
||
|
||
const statsBar = document.getElementById('sessionsStatsBar');
|
||
if (statsBar) {
|
||
const avgDurEl = document.getElementById('statsAvgDuration');
|
||
if (!avgDurEl) {
|
||
const container = statsBar.querySelector('div[style*="display:flex"]');
|
||
if (container) {
|
||
const durDiv = document.createElement('div');
|
||
durDiv.style.cssText = 'display:flex;align-items:center;gap:8px;';
|
||
durDiv.innerHTML = `<span style="font-size:12px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.05em;">Avg Duration</span><span class="mono" style="font-size:20px;font-weight:700;" id="statsAvgDuration">${hours > 0 ? hours + 'h ' : ''}${mins}m</span>`;
|
||
container.appendChild(durDiv);
|
||
}
|
||
} else {
|
||
avgDurEl.textContent = `${hours > 0 ? hours + 'h ' : ''}${mins}m`;
|
||
}
|
||
}
|
||
}
|
||
} catch {}
|
||
};
|
||
|
||
// Add duration to expanded session view
|
||
const origToggleSessionExpand = toggleSessionExpand;
|
||
toggleSessionExpand = function(key, e) {
|
||
origToggleSessionExpand(key, e);
|
||
|
||
try {
|
||
const s = sessions.find(x => x.key === key);
|
||
if (!s || !s.createdAt) return;
|
||
|
||
const detail = document.getElementById('expanded-' + CSS.escape(key));
|
||
if (!detail) return;
|
||
|
||
const now = s.updatedAt || Date.now();
|
||
const duration = now - s.createdAt;
|
||
|
||
const days = Math.floor(duration / 86400000);
|
||
const hours = Math.floor((duration % 86400000) / 3600000);
|
||
const mins = Math.floor((duration % 3600000) / 60000);
|
||
|
||
let durStr = '';
|
||
if (days > 0) durStr = `${days}d ${hours}h`;
|
||
else if (hours > 0) durStr = `${hours}h ${mins}m`;
|
||
else durStr = `${mins}m`;
|
||
|
||
const grid = detail.querySelector('div[style*="display:grid"]');
|
||
if (grid && !document.getElementById('dur-' + CSS.escape(key))) {
|
||
const durDiv = document.createElement('div');
|
||
durDiv.id = 'dur-' + CSS.escape(key);
|
||
durDiv.innerHTML = `<div style="font-size:10px;color:var(--text-muted);text-transform:uppercase;">Duration</div><div style="font-size:12px;">${durStr}</div>`;
|
||
grid.appendChild(durDiv);
|
||
}
|
||
} catch {}
|
||
};
|
||
|
||
// Fetch new data on load
|
||
fetchTailscaleStatus();
|
||
fetchLifetimeStats();
|
||
|
||
// Periodic updates
|
||
setInterval(fetchTailscaleStatus, 30000);
|
||
setInterval(fetchLifetimeStats, 60000);
|
||
setInterval(updatePageTitle, 5000);
|
||
setInterval(() => { if (costs.perDay) calculateStreak(); }, 10000);
|
||
|
||
// Initial calls
|
||
setTimeout(() => {
|
||
if (costs.perDay) calculateStreak();
|
||
updatePageTitle();
|
||
}, 2000);
|
||
|
||
function toggleTheme() {
|
||
const btn = document.getElementById('themeToggle');
|
||
document.body.classList.toggle('light-theme');
|
||
const isLight = document.body.classList.contains('light-theme');
|
||
localStorage.setItem('theme', isLight ? 'light' : 'dark');
|
||
btn.textContent = isLight ? '☀️' : '🌙';
|
||
}
|
||
(function() {
|
||
if (localStorage.getItem('theme') === 'light') {
|
||
document.body.classList.add('light-theme');
|
||
document.getElementById('themeToggle').textContent = '☀️';
|
||
}
|
||
})();
|
||
|
||
async function fetchSysSecurity() {
|
||
try {
|
||
const res = await authFetch(API_BASE + '/api/sys-security');
|
||
const data = await res.json();
|
||
document.getElementById('secUfw').innerHTML = data.ufw || 'N/A';
|
||
document.getElementById('secPorts').innerHTML = data.ports || 'N/A';
|
||
document.getElementById('secF2b').innerHTML = data.fail2ban || 'N/A';
|
||
document.getElementById('secSsh').innerHTML = data.ssh || 'N/A';
|
||
document.getElementById('secAudit').innerHTML = data.audit || 'N/A';
|
||
} catch(e) { showToast('Security fetch error: ' + e.message, 'warning'); }
|
||
}
|
||
|
||
let reauthTargetPage = 'sys-security';
|
||
function showReauthModal(targetPage) {
|
||
reauthTargetPage = targetPage || 'sys-security';
|
||
let overlay = document.getElementById('reauthOverlay');
|
||
if (!overlay) {
|
||
overlay = document.createElement('div');
|
||
overlay.id = 'reauthOverlay';
|
||
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:600;display:flex;align-items:center;justify-content:center;';
|
||
overlay.innerHTML = '<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:16px;padding:32px;max-width:380px;width:90%;">' +
|
||
'<h3 style="margin-bottom:8px;">🔒 Re-authentication Required</h3>' +
|
||
'<p style="font-size:13px;color:var(--text-secondary);margin-bottom:20px;">Enter your credentials to access System Security.</p>' +
|
||
'<input type="password" id="reauthPass" placeholder="Password" style="width:100%;padding:12px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;color:var(--text-primary);font-size:14px;margin-bottom:12px;">' +
|
||
'<input type="text" id="reauthTotp" placeholder="Authenticator Code (if enabled)" style="width:100%;padding:12px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;color:var(--text-primary);font-size:14px;margin-bottom:12px;font-family:JetBrains Mono,monospace;" maxlength="6" autocomplete="one-time-code">' +
|
||
'<div id="reauthError" style="display:none;color:var(--red);font-size:12px;margin-bottom:12px;padding:8px;background:rgba(239,68,68,0.1);border-radius:6px;"></div>' +
|
||
'<div style="display:flex;gap:8px;">' +
|
||
'<button onclick="submitReauth()" style="flex:1;padding:12px;background:linear-gradient(135deg,var(--accent),var(--purple));color:#fff;border:none;border-radius:8px;font-weight:600;cursor:pointer;">Verify</button>' +
|
||
'<button onclick="cancelReauth()" style="flex:1;padding:12px;background:var(--bg-secondary);color:var(--text-primary);border:1px solid var(--border);border-radius:8px;font-weight:500;cursor:pointer;">Cancel</button>' +
|
||
'</div></div>';
|
||
document.body.appendChild(overlay);
|
||
document.getElementById('reauthPass').addEventListener('keydown', e => { if(e.key === 'Enter') document.getElementById('reauthTotp').focus(); });
|
||
document.getElementById('reauthTotp').addEventListener('keydown', e => { if(e.key === 'Enter') submitReauth(); });
|
||
}
|
||
overlay.style.display = 'flex';
|
||
document.getElementById('reauthPass').value = '';
|
||
document.getElementById('reauthTotp').value = '';
|
||
document.getElementById('reauthError').style.display = 'none';
|
||
setTimeout(() => document.getElementById('reauthPass').focus(), 100);
|
||
}
|
||
|
||
async function submitReauth() {
|
||
const pass = document.getElementById('reauthPass').value;
|
||
const totp = document.getElementById('reauthTotp').value;
|
||
const errEl = document.getElementById('reauthError');
|
||
if (!pass) { errEl.textContent = 'Password required'; errEl.style.display = 'block'; return; }
|
||
try {
|
||
const token = getStoredToken();
|
||
const res = await fetch(API_BASE + '/api/reauth', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
|
||
body: JSON.stringify({ password: pass, totp: totp || undefined })
|
||
});
|
||
const data = await res.json();
|
||
if (data.needsTotp && !totp) { errEl.textContent = 'Authenticator code required'; errEl.style.display = 'block'; document.getElementById('reauthTotp').focus(); return; }
|
||
if (data.error) { errEl.textContent = data.error; errEl.style.display = 'block'; return; }
|
||
sysSecAuthed = true;
|
||
document.getElementById('reauthOverlay').style.display = 'none';
|
||
document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('active'));
|
||
document.querySelector('[data-page="' + reauthTargetPage + '"]').classList.add('active');
|
||
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||
document.getElementById(reauthTargetPage).classList.add('active');
|
||
if (reauthTargetPage === 'sys-security') fetchSysSecurity();
|
||
if (reauthTargetPage === 'config-editor') loadConfig();
|
||
|
||
} catch(e) { errEl.textContent = 'Error: ' + e.message; errEl.style.display = 'block'; }
|
||
}
|
||
|
||
async function loadConfig() {
|
||
const ta = document.getElementById('configTextarea');
|
||
const errEl = document.getElementById('configError');
|
||
const sucEl = document.getElementById('configSuccess');
|
||
errEl.style.display = 'none';
|
||
sucEl.style.display = 'none';
|
||
ta.value = 'Loading...';
|
||
try {
|
||
const res = await authFetch(API_BASE + '/api/openclaw-config');
|
||
const data = await res.json();
|
||
if (data.error) { errEl.textContent = data.error; errEl.style.display = 'block'; return; }
|
||
ta.value = JSON.stringify(JSON.parse(data.config), null, 2);
|
||
} catch(e) { errEl.textContent = 'Failed to load: ' + e.message; errEl.style.display = 'block'; }
|
||
}
|
||
|
||
async function saveConfig() {
|
||
const ta = document.getElementById('configTextarea');
|
||
const errEl = document.getElementById('configError');
|
||
const sucEl = document.getElementById('configSuccess');
|
||
errEl.style.display = 'none';
|
||
sucEl.style.display = 'none';
|
||
try {
|
||
JSON.parse(ta.value);
|
||
} catch(e) { errEl.textContent = 'Invalid JSON: ' + e.message; errEl.style.display = 'block'; return; }
|
||
if (!confirm('Save config and restart OpenClaw gateway?')) return;
|
||
try {
|
||
const res = await authFetch(API_BASE + '/api/openclaw-config', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ config: ta.value })
|
||
});
|
||
const data = await res.json();
|
||
if (data.error) { errEl.textContent = data.error; errEl.style.display = 'block'; return; }
|
||
sucEl.textContent = '✅ Config saved. Backup: ' + data.backup + '. Gateway restarting...';
|
||
sucEl.style.display = 'block';
|
||
showToast('Config saved, gateway restarting...', 'success');
|
||
} catch(e) { errEl.textContent = 'Save failed: ' + e.message; errEl.style.display = 'block'; }
|
||
}
|
||
|
||
function cancelReauth() {
|
||
document.getElementById('reauthOverlay').style.display = 'none';
|
||
document.querySelector('[data-page="overview"]').click();
|
||
}
|
||
|
||
document.querySelectorAll('.nav-item').forEach(item => {
|
||
item.addEventListener('click', () => {
|
||
if (item.dataset.page === 'sys-security' && sysSecAuthed) fetchSysSecurity();
|
||
if (item.dataset.page === 'config-editor' && sysSecAuthed) loadConfig();
|
||
if (item.dataset.page === 'docker') loadDocker();
|
||
});
|
||
});
|
||
|
||
async function loadDocker() {
|
||
try {
|
||
const res = await authFetch(API_BASE + '/api/docker');
|
||
const data = await res.json();
|
||
const ce = document.getElementById('dockerContainers');
|
||
if (data.containers && data.containers.length) {
|
||
ce.innerHTML = '<table style="width:100%;font-size:12px;border-collapse:collapse;">' +
|
||
'<tr style="border-bottom:1px solid var(--border);"><th style="text-align:left;padding:6px;">Name</th><th style="text-align:left;padding:6px;">Image</th><th style="text-align:left;padding:6px;">Status</th><th style="text-align:left;padding:6px;">Ports</th><th style="padding:6px;">Actions</th></tr>' +
|
||
data.containers.map(c => {
|
||
const running = c.State === 'running';
|
||
const dot = running ? '🟢' : '🔴';
|
||
return '<tr style="border-bottom:1px solid var(--border);">' +
|
||
'<td style="padding:6px;">' + dot + ' ' + (c.Names || '') + '</td>' +
|
||
'<td style="padding:6px;color:var(--text-secondary);max-width:200px;overflow:hidden;text-overflow:ellipsis;">' + (c.Image || '') + '</td>' +
|
||
'<td style="padding:6px;color:var(--text-secondary);">' + (c.Status || '') + '</td>' +
|
||
'<td style="padding:6px;color:var(--text-muted);font-size:11px;">' + (c.Ports || '-') + '</td>' +
|
||
'<td style="padding:6px;text-align:center;">' +
|
||
(running
|
||
? '<button onclick="dockerAction(\'stop\',\'' + (c.Names||'') + '\')" style="padding:3px 8px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:4px;color:var(--red);cursor:pointer;font-size:11px;margin-right:4px;">Stop</button>' +
|
||
'<button onclick="dockerAction(\'restart\',\'' + (c.Names||'') + '\')" style="padding:3px 8px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:4px;color:var(--yellow);cursor:pointer;font-size:11px;">Restart</button>'
|
||
: '<button onclick="dockerAction(\'start\',\'' + (c.Names||'') + '\')" style="padding:3px 8px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:4px;color:var(--green);cursor:pointer;font-size:11px;">Start</button>') +
|
||
'</td></tr>';
|
||
}).join('') + '</table>';
|
||
} else { ce.innerHTML = '<div style="color:var(--text-muted);font-size:13px;">No containers found</div>'; }
|
||
|
||
const ie = document.getElementById('dockerImages');
|
||
if (data.images && data.images.length) {
|
||
ie.innerHTML = '<table style="width:100%;font-size:12px;border-collapse:collapse;">' +
|
||
'<tr style="border-bottom:1px solid var(--border);"><th style="text-align:left;padding:6px;">Repository</th><th style="text-align:left;padding:6px;">Tag</th><th style="text-align:left;padding:6px;">Size</th></tr>' +
|
||
data.images.map(i => '<tr style="border-bottom:1px solid var(--border);">' +
|
||
'<td style="padding:6px;">' + (i.Repository || '') + '</td>' +
|
||
'<td style="padding:6px;color:var(--text-secondary);">' + (i.Tag || '') + '</td>' +
|
||
'<td style="padding:6px;color:var(--text-muted);">' + (i.Size || '') + '</td></tr>'
|
||
).join('') + '</table>';
|
||
} else { ie.innerHTML = '<div style="color:var(--text-muted);font-size:13px;">No images found</div>'; }
|
||
|
||
document.getElementById('dockerSystem').textContent = data.system || 'N/A';
|
||
} catch(e) { showToast('Docker fetch error: ' + e.message, 'warning'); }
|
||
}
|
||
|
||
const notifIcons = {
|
||
login_success: '✅', login_failed: '❌', login_locked: '🔒', logout: '👋',
|
||
register: '📝', reauth_success: '🔓', reauth_failed: '🚫',
|
||
config_saved: '⚙️', config_read: '📖', docker_view: '🐳', docker_action: '🐳',
|
||
sys_security_view: '🛡️', auth_failed: '⚠️', login_mfa_failed: '🔑',
|
||
password_changed: '🔐', mfa_enabled: '📱', mfa_disabled: '📱'
|
||
};
|
||
let notifLastSeen = localStorage.getItem('notifLastSeen') || '';
|
||
|
||
function toggleNotifPanel() {
|
||
const panel = document.getElementById('notifPanel');
|
||
if (panel.style.display === 'flex') { panel.style.display = 'none'; return; }
|
||
panel.style.display = 'flex';
|
||
fetchNotifications();
|
||
}
|
||
|
||
async function fetchNotifications() {
|
||
try {
|
||
const res = await authFetch(API_BASE + '/api/notifications?limit=50');
|
||
const data = await res.json();
|
||
const body = document.getElementById('notifPanelBody');
|
||
if (!data.events || !data.events.length) { body.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text-muted);">No events yet</div>'; return; }
|
||
body.innerHTML = data.events.map(e => {
|
||
const icon = notifIcons[e.event] || '📋';
|
||
const time = e.timestamp ? new Date(e.timestamp).toLocaleString() : '';
|
||
const detail = e.username ? ' (' + e.username + ')' : '';
|
||
const ip = e.ip ? ' from ' + e.ip : '';
|
||
return '<div class="notif-item"><div class="notif-icon">' + icon + '</div><div class="notif-content"><div class="notif-event">' + (e.event||'').replace(/_/g, ' ') + detail + ip + '</div><div class="notif-time">' + time + '</div></div></div>';
|
||
}).join('');
|
||
if (data.events.length) {
|
||
notifLastSeen = data.events[0].timestamp;
|
||
localStorage.setItem('notifLastSeen', notifLastSeen);
|
||
document.getElementById('notifBadge').style.display = 'none';
|
||
}
|
||
} catch(e) { showToast('Notification fetch error: ' + e.message, 'warning'); }
|
||
}
|
||
|
||
async function checkNewNotifications() {
|
||
try {
|
||
const res = await authFetch(API_BASE + '/api/notifications?limit=5');
|
||
const data = await res.json();
|
||
if (data.events && data.events.length && notifLastSeen) {
|
||
const newCount = data.events.filter(e => e.timestamp > notifLastSeen).length;
|
||
const badge = document.getElementById('notifBadge');
|
||
if (newCount > 0) { badge.textContent = newCount; badge.style.display = 'flex'; }
|
||
}
|
||
} catch {}
|
||
}
|
||
setInterval(checkNewNotifications, 30000);
|
||
setTimeout(checkNewNotifications, 3000);
|
||
document.addEventListener('click', (e) => {
|
||
const panel = document.getElementById('notifPanel');
|
||
const bell = document.getElementById('notificationBell');
|
||
if (panel.style.display === 'flex' && !panel.contains(e.target) && !bell.contains(e.target)) panel.style.display = 'none';
|
||
});
|
||
|
||
async function dockerAction(action, id) {
|
||
const label = action === 'prune-containers' ? 'prune stopped containers' : action === 'prune-images' ? 'prune unused images' : action + ' ' + id;
|
||
if (!confirm('Run: ' + label + '?')) return;
|
||
try {
|
||
const res = await authFetch(API_BASE + '/api/docker/action', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ action, id })
|
||
});
|
||
const data = await res.json();
|
||
if (data.error) { showToast('Error: ' + data.error, 'warning'); return; }
|
||
showToast(label + ' done', 'success');
|
||
loadDocker();
|
||
} catch(e) { showToast('Error: ' + e.message, 'warning'); }
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|