<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#0a0a0f">
<title>Vincent โ Dashboard</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0a0a0f;
--surface: #12121a;
--surface2: #1a1a26;
--border: #2a2a3a;
--text: #c8c8d8;
--text-dim: #78788a;
--accent: #6c63ff;
--accent-dim: #4a42cc;
--green: #4ade80;
--red: #f87171;
--yellow: #fbbf24;
--mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
--sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
}
/* โโ Reset & base โโ */
html {
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 15px;
line-height: 1.5;
min-height: 100vh;
padding-bottom: calc(16px + var(--safe-bottom));
-webkit-font-smoothing: antialiased;
}
/* โโ Header โโ */
.header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 10;
gap: 12px;
padding-top: calc(12px + var(--safe-top));
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.avatar {
flex-shrink: 0;
width: 36px;
height: 36px;
border-radius: 8px;
background: var(--accent);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 16px;
color: #fff;
}
.identity {
min-width: 0;
}
.identity h1 {
font-size: 16px;
font-weight: 600;
color: #e8e8f0;
white-space: nowrap;
}
.identity .subtitle {
font-size: 11px;
color: var(--text-dim);
font-family: var(--mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.status-pill {
font-size: 11px;
font-family: var(--mono);
padding: 4px 8px;
border-radius: 12px;
background: rgba(74, 222, 128, 0.1);
color: var(--green);
border: 1px solid rgba(74, 222, 128, 0.2);
white-space: nowrap;
}
.timestamp {
font-size: 11px;
font-family: var(--mono);
color: var(--text-dim);
white-space: nowrap;
}
.refresh-btn {
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text);
padding: 6px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
font-family: var(--mono);
transition: all 0.15s;
min-height: 44px;
min-width: 44px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
white-space: nowrap;
-webkit-tap-highlight-color: transparent;
}
.refresh-btn:hover {
background: var(--accent-dim);
border-color: var(--accent);
}
.refresh-btn-label {
display: none;
}
/* โโ Grid โโ */
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
padding: 12px 16px;
max-width: 1600px;
margin: 0 auto;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
gap: 8px;
}
.card-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
}
.card-badge {
font-size: 10px;
font-family: var(--mono);
padding: 2px 8px;
border-radius: 8px;
background: var(--surface2);
color: var(--text-dim);
white-space: nowrap;
flex-shrink: 0;
}
/* โโ Stats row โโ */
.stats-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.stats-row.stats-3col {
grid-template-columns: repeat(2, 1fr);
}
.stat {
text-align: center;
padding: 6px;
}
.stat-value {
font-size: 22px;
font-weight: 700;
font-family: var(--mono);
color: #e8e8f0;
}
.stat-value.accent { color: var(--accent); }
.stat-value.green { color: var(--green); }
.stat-value.yellow { color: var(--yellow); }
.stat-label {
font-size: 10px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-top: 2px;
}
/* โโ Table โโ */
.data-table-wrapper {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
margin: 0 -4px;
padding: 0 4px;
}
.data-table {
width: 100%;
min-width: 0;
border-collapse: collapse;
table-layout: fixed;
}
.data-table th {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
text-align: left;
padding: 8px 6px;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.data-table td {
font-size: 12px;
font-family: var(--mono);
padding: 8px 6px;
border-bottom: 1px solid rgba(42, 42, 58, 0.5);
color: var(--text);
white-space: nowrap;
}
.data-table tr:last-child td {
border-bottom: none;
}
.data-table .dim {
color: var(--text-dim);
}
/* โโ Service list โโ */
.service-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.service-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-radius: 8px;
background: var(--surface2);
min-height: 44px;
-webkit-tap-highlight-color: transparent;
}
.service-name {
font-size: 13px;
font-family: var(--mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.service-port {
color: var(--text-dim);
font-size: 11px;
}
.service-status {
font-size: 11px;
font-family: var(--mono);
padding: 3px 10px;
border-radius: 10px;
flex-shrink: 0;
margin-left: 8px;
}
.service-status.up {
background: rgba(74, 222, 128, 0.1);
color: var(--green);
}
.service-status.down {
background: rgba(248, 113, 113, 0.1);
color: var(--red);
}
/* โโ Progress bar โโ */
.progress-bar {
height: 6px;
background: var(--surface2);
border-radius: 3px;
overflow: hidden;
margin-top: 8px;
}
.progress-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease;
}
.progress-fill.green { background: var(--green); }
.progress-fill.accent { background: var(--accent); }
.progress-fill.yellow { background: var(--yellow); }
.progress-fill.red { background: var(--red); }
/* โโ System health rows โโ */
.health-row {
margin-bottom: 12px;
}
.health-row:last-child {
margin-bottom: 0;
}
.health-label {
display: flex;
justify-content: space-between;
font-size: 12px;
font-family: var(--mono);
margin-bottom: 4px;
}
.health-label-name {
color: var(--text-dim);
}
.health-label-value {
color: var(--text);
}
/* โโ Tags โโ */
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag {
font-size: 11px;
font-family: var(--mono);
padding: 3px 8px;
border-radius: 6px;
background: var(--surface2);
color: var(--text-dim);
}
.tag.active {
background: rgba(108, 99, 255, 0.15);
color: var(--accent);
}
/* โโ Cron list โโ */
.cron-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 300px;
overflow-y: auto;
}
.cron-item {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: var(--surface2);
border-radius: 8px;
min-height: 44px;
gap: 8px;
-webkit-tap-highlight-color: transparent;
}
.cron-name {
font-size: 13px;
font-family: var(--mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cron-schedule {
font-size: 11px;
color: var(--text-dim);
font-family: var(--mono);
flex-shrink: 0;
}
.cron-status {
display: flex;
align-items: center;
gap: 4px;
font-size: 10px;
font-family: var(--mono);
color: var(--text-dim);
flex-shrink: 0;
}
.cron-status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.cron-status-dot.success { background: var(--green); }
.cron-status-dot.error { background: var(--red); }
.cron-status-dot.pending { background: var(--yellow); }
.cron-status-dot.unknown { background: var(--text-dim); }
.cron-time {
font-size: 10px;
color: var(--text-dim);
font-family: var(--mono);
}
/* โโ Session info โโ */
.session-focus {
font-size: 13px;
color: #e8e8f0;
margin-bottom: 6px;
}
.session-plan {
font-size: 11px;
color: var(--text-dim);
font-family: var(--mono);
}
.session-platforms {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
/* โโ Loading โโ */
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
color: var(--text-dim);
font-family: var(--mono);
font-size: 12px;
grid-column: 1 / -1;
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-right: 10px;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* โโ Scroll hint for tables โโ */
.scroll-hint {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 4px;
font-size: 10px;
color: var(--text-dim);
font-family: var(--mono);
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Tablet: โฅ768px โ 2 columns
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
@media (min-width: 768px) {
.grid {
grid-template-columns: repeat(2, 1fr);
gap: 16px;
padding: 16px 20px;
}
.card.full-width {
grid-column: span 2;
}
.card.two-col {
grid-column: span 2;
}
.stats-row.stats-3col {
grid-template-columns: repeat(3, 1fr);
}
.refresh-btn-label {
display: inline;
}
.card {
padding: 16px;
}
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Desktop: โฅ1100px โ 3 columns
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
@media (min-width: 1100px) {
body { font-size: 14px; }
.header {
padding: 16px 24px;
padding-top: calc(16px + var(--safe-top));
}
.grid {
grid-template-columns: repeat(3, 1fr);
gap: 16px;
padding: 20px 24px;
}
.card.full-width { grid-column: span 3; }
.card.two-col { grid-column: span 2; }
.stats-row {
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
}
.stats-row.stats-3col {
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
}
.stat-value { font-size: 24px; }
.card {
padding: 18px;
}
.timestamp {
display: inline;
}
.avatar {
width: 40px;
height: 40px;
font-size: 18px;
}
.identity h1 { font-size: 18px; }
}
/* Hide timestamp on mobile */
@media (max-width: 767px) {
.timestamp { display: none; }
/* Prevent horizontal page scroll */
body {
overflow-x: hidden;
}
/* Hide less important table columns on mobile */
.data-table th:nth-child(1),
.data-table td:nth-child(1),
.data-table th:nth-child(6),
.data-table td:nth-child(6),
.data-table th:nth-child(7),
.data-table td:nth-child(7),
.data-table th:nth-child(8),
.data-table td:nth-child(8) {
display: none;
}
/* Allow table cells to wrap */
.data-table td {
white-space: normal;
}
}
/* โโ Tab navigation โโ */
.tabs {
display: flex;
gap: 0;
padding: 0 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.tab {
padding: 10px 16px;
font-size: 13px;
font-family: var(--mono);
color: var(--text-dim);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.15s;
min-height: 44px;
display: flex;
align-items: center;
gap: 6px;
-webkit-tap-highlight-color: transparent;
}
.tab:hover {
color: var(--text);
}
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.tab-icon {
font-size: 14px;
}
/* โโ File explorer โโ */
.explorer-view {
display: none;
}
.explorer-view.active {
display: block;
}
.dashboard-view {
display: block;
}
.dashboard-view.hidden {
display: none;
}
.breadcrumb-bar {
display: flex;
align-items: center;
gap: 4px;
padding: 12px 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
white-space: nowrap;
}
.breadcrumb-segment {
font-size: 12px;
font-family: var(--mono);
color: var(--accent);
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
transition: background 0.15s;
-webkit-tap-highlight-color: transparent;
}
.breadcrumb-segment:hover {
background: var(--surface2);
}
.breadcrumb-segment:last-child {
color: var(--text);
cursor: default;
font-weight: 600;
}
.breadcrumb-segment:last-child:hover {
background: transparent;
}
.breadcrumb-sep {
font-size: 10px;
color: var(--text-dim);
flex-shrink: 0;
}
.explorer-stats {
display: flex;
gap: 12px;
padding: 10px 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.explorer-stat {
font-size: 11px;
font-family: var(--mono);
color: var(--text-dim);
}
.explorer-stat strong {
color: var(--text);
}
.file-list {
padding: 0;
}
.file-row {
display: grid;
grid-template-columns: 24px 1fr auto auto;
align-items: center;
gap: 10px;
padding: 10px 16px;
border-bottom: 1px solid rgba(42, 42, 58, 0.3);
cursor: pointer;
transition: background 0.1s;
min-height: 44px;
-webkit-tap-highlight-color: transparent;
}
.file-row:hover {
background: var(--surface2);
}
.file-row.file-item {
cursor: default;
}
.file-row.file-item:hover {
background: transparent;
}
.file-icon {
font-size: 16px;
flex-shrink: 0;
text-align: center;
}
.file-icon.dir {
color: var(--yellow);
}
.file-icon.file {
color: var(--text-dim);
}
.file-name {
font-size: 13px;
font-family: var(--mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
}
.file-row.has-error .file-name {
color: var(--red);
font-style: italic;
}
.file-size {
font-size: 11px;
font-family: var(--mono);
color: var(--text-dim);
text-align: right;
flex-shrink: 0;
}
.file-modified {
font-size: 11px;
font-family: var(--mono);
color: var(--text-dim);
flex-shrink: 0;
display: none;
}
/* Show modified column on wider screens */
@media (min-width: 768px) {
.file-list-header {
grid-template-columns: 24px 1fr 80px 140px;
}
.file-row {
grid-template-columns: 24px 1fr 80px 140px;
}
.file-modified {
display: block;
}
}
.file-list-header {
display: grid;
grid-template-columns: 24px 1fr auto;
gap: 10px;
padding: 8px 16px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.file-list-header span {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
font-family: var(--mono);
}
.file-list-header .file-size {
text-align: right;
}
.fs-empty {
padding: 40px 16px;
text-align: center;
color: var(--text-dim);
font-family: var(--mono);
font-size: 12px;
}
.fs-error {
padding: 20px 16px;
text-align: center;
color: var(--red);
font-family: var(--mono);
font-size: 12px;
background: rgba(248, 113, 113, 0.05);
border: 1px solid rgba(248, 113, 113, 0.1);
border-radius: 8px;
margin: 16px;
}
</style>
</head>
<body>
<div class="header">
<div class="header-left">
<div class="avatar">V</div>
<div class="identity">
<h1>Vincent</h1>
<div class="subtitle" id="model-badge">loading...</div>
</div>
</div>
<div class="header-right">
<span class="status-pill" id="status-pill">โ ONLINE</span>
<span class="timestamp" id="last-update">โ</span>
<button class="refresh-btn" onclick="loadData()">โป <span class="refresh-btn-label">Refresh</span></button>
</div>
</div>
<!-- Tab navigation -->
<div class="tabs">
<div class="tab active" data-tab="dashboard" onclick="switchTab('dashboard')">
<span class="tab-icon">โ</span> Dashboard
</div>
<div class="tab" data-tab="explorer" onclick="switchTab('explorer')">
<span class="tab-icon">โ</span> Explorer
</div>
</div>
<!-- Dashboard view -->
<div class="dashboard-view" id="dashboard-view">
<div class="grid" id="dashboard">
<div class="loading"><div class="spinner"></div>Loading dashboard...</div>
</div>
</div>
<!-- Explorer view -->
<div class="explorer-view" id="explorer-view">
<div class="breadcrumb-bar" id="breadcrumb"></div>
<div class="explorer-stats" id="explorer-stats"></div>
<div class="file-list-header">
<span></span>
<span>Name</span>
<span class="file-size">Size</span>
<span class="file-modified">Modified</span>
</div>
<div class="file-list" id="file-list">
<div class="loading"><div class="spinner"></div>Loading file system...</div>
</div>
</div>
<script>
const API = '/api/state';
const FS_API = '/api/fs';
let currentPath = '/';
function fmt(n) {
if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B';
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
return n.toString();
}
function pctBar(pct, color) {
return `<div class="progress-bar"><div class="progress-fill ${color}" style="width:${Math.min(pct, 100)}%"></div></div>`;
}
/* โโ Tab switching โโ */
function switchTab(tab) {
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
const dashboardView = document.getElementById('dashboard-view');
const explorerView = document.getElementById('explorer-view');
if (tab === 'explorer') {
dashboardView.classList.add('hidden');
explorerView.classList.add('active');
loadExplorer(currentPath);
} else {
dashboardView.classList.remove('hidden');
explorerView.classList.remove('active');
}
}
/* โโ File explorer โโ */
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function renderBreadcrumb(path) {
const bc = document.getElementById('breadcrumb');
const parts = path.split('/').filter(Boolean);
let html = `<span class="breadcrumb-segment" onclick="navigateTo('/')">/</span>`;
let accumulated = '';
for (let i = 0; i < parts.length; i++) {
accumulated += '/' + parts[i];
const isLast = i === parts.length - 1;
const display = escapeHtml(parts[i]);
html += `<span class="breadcrumb-sep">โบ</span>`;
if (isLast) {
html += `<span class="breadcrumb-segment">${display}</span>`;
} else {
html += `<span class="breadcrumb-segment" onclick="navigateTo('${accumulated}')">${display}</span>`;
}
}
bc.innerHTML = html;
}
function navigateTo(path) {
currentPath = path;
loadExplorer(path);
}
async function loadExplorer(path) {
const fileList = document.getElementById('file-list');
fileList.innerHTML = '<div class="loading"><div class="spinner"></div>Loading...</div>';
try {
const res = await fetch(`${FS_API}?path=${encodeURIComponent(path)}`);
const data = await res.json();
if (data.error) {
fileList.innerHTML = `<div class="fs-error">${escapeHtml(data.error)}</div>`;
document.getElementById('explorer-stats').innerHTML = '';
return;
}
currentPath = data.path;
renderBreadcrumb(data.display_path);
// Stats bar
document.getElementById('explorer-stats').innerHTML = `
<span class="explorer-stat"><strong>${data.dirs}</strong> dirs</span>
<span class="explorer-stat"><strong>${data.files}</strong> files</span>
<span class="explorer-stat"><strong>${data.entries.length}</strong> total</span>
`;
// File list
if (data.entries.length === 0) {
fileList.innerHTML = '<div class="fs-empty">Empty directory</div>';
return;
}
let html = '';
for (const entry of data.entries) {
const isDir = entry.is_dir;
const icon = isDir ? '๐' : '๐';
const iconClass = isDir ? 'dir' : 'file';
const itemClass = isDir ? '' : 'file-item';
const errorClass = entry.error ? 'has-error' : '';
const name = entry.error ? `${escapeHtml(entry.name)} (${escapeHtml(entry.error)})` : escapeHtml(entry.name);
const clickHandler = isDir ? `onclick="navigateTo('${entry.path}')"` : '';
html += `
<div class="file-row ${itemClass} ${errorClass}" ${clickHandler}>
<span class="file-icon ${iconClass}">${icon}</span>
<span class="file-name" title="${entry.path}">${name}</span>
<span class="file-size">${entry.size_str || 'โ'}</span>
<span class="file-modified">${entry.modified || 'โ'}</span>
</div>`;
}
fileList.innerHTML = html;
} catch (e) {
fileList.innerHTML = `<div class="fs-error">Error loading directory: ${escapeHtml(e.message)}</div>`;
}
}
function render(data) {
const grid = document.getElementById('dashboard');
// Header
document.getElementById('model-badge').textContent =
(data.identity.model || '').replace(/-UD-Q4_K_XL\.gguf$/, '').replace(/\.gguf$/, '') || 'Qwen3.6-27B' +
' ยท ' + (data.identity.provider || 'custom');
let html = '';
// --- IDENTITY CARD ---
html += `
<div class="card">
<div class="card-header">
<span class="card-title">Identity</span>
<span class="card-badge">${data.identity.role || ''}</span>
</div>
<div class="stats-row">
<div class="stat">
<div class="stat-value accent">${data.skills.total || 'โ'}</div>
<div class="stat-label">Skills</div>
</div>
<div class="stat">
<div class="stat-value">${data.cron_jobs.total || 'โ'}</div>
<div class="stat-label">Cron Jobs</div>
</div>
<div class="stat">
<div class="stat-value">${data.grounding.total || 'โ'}</div>
<div class="stat-label">Grounding</div>
</div>
<div class="stat">
<div class="stat-value">${data.identity.connected_platforms?.length || 0}</div>
<div class="stat-label">Platforms</div>
</div>
</div>
</div>`;
// --- CURRENT SESSION ---
const sess = data.session || {};
html += `
<div class="card">
<div class="card-header">
<span class="card-title">Current Session</span>
<span class="card-badge">${sess.context || 'Active'}</span>
</div>
<div class="session-focus">${sess.focus || 'No active focus'}</div>
<div class="session-plan">${sess.next || ''}</div>
<div class="session-platforms">
${(data.identity.connected_platforms || []).map(p => `<span class="tag active">${p}</span>`).join('')}
</div>
</div>`;
// --- MEMORY ---
const mem = data.memory || {};
const memStats = mem.memory || {};
const userStats = mem.user || {};
html += `
<div class="card">
<div class="card-header">
<span class="card-title">Memory</span>
</div>
<div class="stats-row">
<div class="stat">
<div class="stat-value">${memStats.entries || 0}</div>
<div class="stat-label">System Notes</div>
</div>
<div class="stat">
<div class="stat-value">${userStats.entries || 0}</div>
<div class="stat-label">User Profile</div>
</div>
<div class="stat">
<div class="stat-value accent">${((memStats.char_count || 0) + (userStats.char_count || 0)).toLocaleString()}</div>
<div class="stat-label">Total Chars</div>
</div>
</div>
</div>`;
// --- SESSION STATS ---
const ss = data.sessions || {};
html += `
<div class="card two-col">
<div class="card-header">
<span class="card-title">Sessions</span>
<span class="card-badge">${ss.total || 0} total ยท ${ss.today || 0} today</span>
</div>
<div class="stats-row stats-3col">
<div class="stat">
<div class="stat-value accent">${fmt(ss.tokens?.input || 0)}</div>
<div class="stat-label">Input Tokens</div>
</div>
<div class="stat">
<div class="stat-value green">${fmt(ss.tokens?.output || 0)}</div>
<div class="stat-label">Output Tokens</div>
</div>
<div class="stat">
<div class="stat-value">${fmt(ss.tokens?.cache_read || 0)}</div>
<div class="stat-label">Cache Read</div>
</div>
<div class="stat">
<div class="stat-value yellow">${fmt(ss.tokens?.reasoning || 0)}</div>
<div class="stat-label">Reasoning</div>
</div>
<div class="stat">
<div class="stat-value">$${(ss.cost_usd || 0).toFixed(2)}</div>
<div class="stat-label">Est. Cost</div>
</div>
</div>
</div>`;
// --- CALIBRATION ---
const cal = data.calibration || {};
html += `
<div class="card">
<div class="card-header">
<span class="card-title">Calibration</span>
<span class="card-badge">${cal.accuracy ? cal.accuracy.toFixed(1) : 'โ'}% accuracy</span>
</div>
<div class="stats-row">
<div class="stat">
<div class="stat-value green">${cal.correct || 0}</div>
<div class="stat-label">Correct</div>
</div>
<div class="stat">
<div class="stat-value" style="color:var(--red)">${cal.incorrect || 0}</div>
<div class="stat-label">Incorrect</div>
</div>
<div class="stat">
<div class="stat-value yellow">${cal.pending || 0}</div>
<div class="stat-label">Pending</div>
</div>
</div>
${pctBar(cal.accuracy || 0, cal.accuracy >= 80 ? 'green' : cal.accuracy >= 50 ? 'yellow' : 'red')}
</div>`;
// --- GROUNDING ---
html += `
<div class="card">
<div class="card-header">
<span class="card-title">Grounding System</span>
<span class="card-badge">${data.grounding.total} components</span>
</div>
<div class="tag-list">
${(data.grounding.components || []).map(c =>
`<span class="tag ${c.status === 'active' ? 'active' : ''}">${c.name}</span>`
).join('')}
</div>
</div>`;
// --- CRON JOBS ---
const statusDot = (s) => {
if (!s) return '<span class="cron-status-dot unknown"></span> unknown';
const l = s.toLowerCase();
if (l === 'success' || l === 'completed') return `<span class="cron-status-dot success"></span> ok`;
if (l === 'error' || l === 'failed') return `<span class="cron-status-dot error"></span> fail`;
if (l === 'running' || l === 'pending') return `<span class="cron-status-dot pending"></span> ${l}`;
return `<span class="cron-status-dot unknown"></span> ${s}`;
};
html += `
<div class="card">
<div class="card-header">
<span class="card-title">Cron Jobs</span>
<span class="card-badge">${data.cron_jobs.total} active</span>
</div>
<div class="cron-list">
${(data.cron_jobs.jobs || []).map(j => `
<div class="cron-item">
<div>
<div class="cron-name">${j.name}</div>
<div class="cron-time">${j.schedule}</div>
</div>
<div class="cron-status">${statusDot(j.last_status)}</div>
</div>
`).join('')}
</div>
</div>`;
// --- SYSTEM HEALTH ---
const sys = data.system || {};
const memPct = sys.memory?.percent || 0;
const cpuPct = sys.cpu_usage_pct || 0;
const formatDiskSize = (gb) => { const v = Math.round(gb); return v >= 1000 ? `${(v/1000).toFixed(1)}T` : `${v}G`; };
const diskRows = (sys.disk || []).map(d => {
const c = d.percent < 70 ? 'green' : d.percent < 90 ? 'yellow' : 'red';
return `<div class="health-row">
<div class="health-label">
<span class="health-label-name">Disk (${d.label})</span>
<span class="health-label-value">${formatDiskSize(d.used_gb)} / ${formatDiskSize(d.total_gb)} (${d.percent}%)</span>
</div>
${pctBar(d.percent, c)}
</div>`;
}).join('');
html += `
<div class="card">
<div class="card-header">
<span class="card-title">System Health</span>
<span class="card-badge">${sys.cpu_cores || '?'} cores</span>
</div>
<div class="health-row">
<div class="health-label">
<span class="health-label-name">CPU Load</span>
<span class="health-label-value">${(sys.load?.['1m'] || 0).toFixed(2)} / ${(sys.load?.['5m'] || 0).toFixed(2)} / ${(sys.load?.['15m'] || 0).toFixed(2)} (${cpuPct}%)</span>
</div>
${pctBar(cpuPct, cpuPct < 60 ? 'green' : cpuPct < 85 ? 'yellow' : 'red')}
</div>
<div class="health-row">
<div class="health-label">
<span class="health-label-name">Memory</span>
<span class="health-label-value">${sys.memory?.used_gb || 0} / ${sys.memory?.total_gb || 0} GB (${memPct}%)</span>
</div>
${pctBar(memPct, memPct < 70 ? 'green' : memPct < 90 ? 'yellow' : 'red')}
</div>
${diskRows}
</div>`;
// --- GPU ---
const gpu = data.gpu || {};
const gpuTempColor = gpu.temp_c > 80 ? 'red' : gpu.temp_c > 70 ? 'yellow' : 'green';
const gpuUtilColor = gpu.util_pct > 90 ? 'yellow' : 'accent';
html += `
<div class="card">
<div class="card-header">
<span class="card-title">GPU</span>
<span class="card-badge">${gpu.name || 'N/A'}</span>
</div>
<div class="health-row">
<div class="health-label">
<span class="health-label-name">Temperature</span>
<span class="health-label-value" style="color:var(--${gpuTempColor})">${gpu.temp_c || 0}ยฐC</span>
</div>
${pctBar((gpu.temp_c || 0) / 100, gpuTempColor)}
</div>
<div class="health-row">
<div class="health-label">
<span class="health-label-name">GPU Util</span>
<span class="health-label-value">${gpu.util_pct || 0}%</span>
</div>
${pctBar(gpu.util_pct || 0, gpuUtilColor)}
</div>
<div class="health-row">
<div class="health-label">
<span class="health-label-name">VRAM</span>
<span class="health-label-value">${gpu.mem_used_mb || 0} / ${gpu.mem_total_mb || 0} MB (${gpu.mem_used_pct || 0}%)</span>
</div>
${pctBar(gpu.mem_used_pct || 0, gpu.mem_used_pct > 90 ? 'yellow' : gpu.mem_used_pct > 70 ? 'accent' : 'green')}
</div>
</div>`;
// --- SERVICES ---
const services = data.services || {};
const running = Object.values(services).filter(s => s.running).length;
const total = Object.keys(services).length;
html += `
<div class="card">
<div class="card-header">
<span class="card-title">Services</span>
<span class="card-badge">${running}/${total} up</span>
</div>
<div class="service-list">
${Object.entries(services).map(([name, svc]) => `
<div class="service-row">
<span class="service-name">${name} <span class="service-port">:${svc.port}</span></span>
<span class="service-status ${svc.running ? 'up' : 'down'}">${svc.running ? 'โ UP' : 'โ DOWN'}</span>
</div>
`).join('')}
</div>
</div>`;
// --- RECENT SESSIONS TABLE ---
const recent = ss.recent || [];
if (recent.length > 0) {
html += `
<div class="card full-width">
<div class="card-header">
<span class="card-title">Recent Sessions</span>
<span class="card-badge">Last 10</span>
</div>
<div class="data-table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Session</th>
<th>Title</th>
<th>Source</th>
<th>Time</th>
<th>Messages</th>
<th>In</th>
<th>Out</th>
<th>Cost</th>
</tr>
</thead>
<tbody>
${recent.map(r => `
<tr>
<td class="dim">${r.id}</td>
<td>${r.title}</td>
<td><span class="tag">${r.source}</span></td>
<td class="dim">${r.started}</td>
<td>${r.messages}</td>
<td>${fmt(r.input_tokens)}</td>
<td>${fmt(r.output_tokens)}</td>
<td class="${r.cost > 0 ? 'accent' : 'dim'}">$${(r.cost || 0).toFixed(4)}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>`;
}
grid.innerHTML = html;
document.getElementById('last-update').textContent = 'updated ' + new Date().toLocaleTimeString();
}
async function loadData() {
try {
const res = await fetch(API);
const data = await res.json();
render(data);
} catch (e) {
document.getElementById('dashboard').innerHTML =
`<div class="card full-width" style="color:var(--red); font-family:var(--mono); font-size:12px;">
Error: ${e.message}
</div>`;
}
}
// Initial load + auto-refresh
loadData();
setInterval(loadData, 30000);
</script>
</body>
</html>