Files
openclaw-dashboard/index.html

5699 lines
233 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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,'&lt;')}</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;">●&thinsp;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,'&lt;')}</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, '&lt;').replace(/>/g, '&gt;')
.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, '&lt;').replace(/>/g, '&gt;')
.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, '&lt;').replace(/>/g, '&gt;') + '</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,'&lt;')}</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>