4470 lines
224 KiB
HTML
4470 lines
224 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||
<title>OpenClaw Dashboard</title>
|
||
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
||
<link rel="apple-touch-icon" sizes="180x180" href="/icon-180.png">
|
||
<link rel="manifest" href="/manifest.json">
|
||
<meta name="mobile-web-app-capable" content="yes">
|
||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||
<meta name="apple-mobile-web-app-title" content="OpenClaw">
|
||
<meta name="theme-color" content="#06080e">
|
||
<meta name="description" content="Glassmorphic agent management dashboard for OpenClaw with task kanban, document editor, API monitoring & real-time agent tracking.">
|
||
<meta name="author" content="Jony Jing">
|
||
<meta property="og:title" content="OpenClaw Agent Dashboard">
|
||
<meta property="og:description" content="Glassmorphic agent management dashboard for OpenClaw with task kanban, document editor, API monitoring & real-time agent tracking.">
|
||
<meta property="og:type" content="website">
|
||
<meta property="og:url" content="https://github.com/JonathanJing/openclaw-dashboard">
|
||
<meta property="og:image" content="https://raw.githubusercontent.com/JonathanJing/openclaw-dashboard/main/screenshots/tasks-kanban.png">
|
||
<script src="https://cdn.jsdelivr.net/npm/marked@14/marked.min.js"></script>
|
||
<style>
|
||
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=Space+Mono:wght@400;700&display=swap');
|
||
*{margin:0;padding:0;box-sizing:border-box}
|
||
:root{
|
||
--bg:#06080e;--bg2:#0a0d14;--card:#0d1117;--border:#1a1f2e;
|
||
--text:#e6edf3;--text2:#8b949e;--accent:#7c5cfc;--accent2:#a78bfa;
|
||
--green:#2dd4a0;--yellow:#f5a623;--red:#f85149;--blue:#58a6ff;
|
||
--glass:rgba(13,17,23,0.7);--glow:rgba(124,92,252,0.15);
|
||
--font:'Outfit',system-ui,sans-serif;--mono:'Space Mono',monospace;
|
||
}
|
||
html{scroll-behavior:smooth}
|
||
body{background:var(--bg);color:var(--text);font-family:var(--font);min-height:100vh;overflow-x:hidden}
|
||
.bg-mesh{position:fixed;inset:0;z-index:0;pointer-events:none;
|
||
background:radial-gradient(ellipse 80% 60% at 20% 20%,rgba(124,92,252,0.06),transparent),
|
||
radial-gradient(ellipse 60% 50% at 80% 80%,rgba(45,212,160,0.04),transparent),
|
||
radial-gradient(ellipse 50% 40% at 50% 50%,rgba(88,166,255,0.03),transparent)}
|
||
.container{max-width:1280px;margin:0 auto;padding:20px;position:relative;z-index:1}
|
||
|
||
/* ─── Header ─── */
|
||
header{display:flex;align-items:center;justify-content:space-between;padding:16px 0 24px;border-bottom:1px solid var(--border);margin-bottom:24px}
|
||
.logo{font-size:1.5rem;font-weight:700;background:linear-gradient(135deg,var(--accent),var(--accent2),var(--blue));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-size:200% 200%;animation:shimmer 6s ease infinite}
|
||
@keyframes shimmer{0%,100%{background-position:0% 50%}50%{background-position:100% 50%}}
|
||
.logo-sub{font-size:.7rem;letter-spacing:3px;text-transform:uppercase;color:var(--text2);margin-top:2px}
|
||
.status-dot{width:10px;height:10px;border-radius:50%;display:inline-block;animation:pulse 2s infinite;margin-right:8px}
|
||
.status-dot.ok{background:var(--green)}
|
||
.status-dot.err{background:var(--red);animation:pulse-red 2s infinite}
|
||
@keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(45,212,160,0.4)}50%{box-shadow:0 0 0 6px rgba(45,212,160,0)}}
|
||
@keyframes pulse-red{0%,100%{box-shadow:0 0 0 0 rgba(248,81,73,0.4)}50%{box-shadow:0 0 0 6px rgba(248,81,73,0)}}
|
||
.header-right{display:flex;align-items:center;gap:12px;font-size:.85rem;color:var(--text2)}
|
||
.back-link,.refresh-btn{color:var(--accent);text-decoration:none;font-size:.85rem;border:1px solid var(--border);padding:6px 14px;border-radius:999px;transition:all .2s;background:transparent;cursor:pointer;font-family:var(--font)}
|
||
.back-link:hover,.refresh-btn:hover{background:var(--glow);border-color:var(--accent)}
|
||
.refresh-btn svg{vertical-align:middle}
|
||
|
||
/* ─── Tab Bar ─── */
|
||
.tab-bar{display:flex;gap:4px;background:var(--glass);backdrop-filter:blur(16px);border:1px solid var(--border);border-radius:14px;padding:4px;margin-bottom:24px;width:fit-content}
|
||
.tab-btn{padding:10px 24px;border-radius:10px;border:none;background:transparent;color:var(--text2);font-family:var(--font);font-size:.9rem;font-weight:500;cursor:pointer;transition:all .25s;position:relative;white-space:nowrap}
|
||
.tab-btn:hover{color:var(--text)}
|
||
.tab-btn.active{background:var(--accent);color:#fff;box-shadow:0 2px 12px rgba(124,92,252,0.3)}
|
||
.tab-btn .badge-count{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 5px;border-radius:9px;background:rgba(255,255,255,0.15);font-size:.7rem;margin-left:6px;font-weight:600}
|
||
.tab-btn.active .badge-count{background:rgba(255,255,255,0.25)}
|
||
|
||
/* ─── Tab Panels ─── */
|
||
.tab-panel{display:none;animation:fadeIn .3s ease}
|
||
.tab-panel.active{display:block}
|
||
@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
|
||
|
||
/* ─── Live Indicator ─── */
|
||
.live-indicator{display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;font-size:.72rem;font-weight:600;letter-spacing:.5px;text-transform:uppercase;cursor:pointer;transition:all .3s;border:1px solid var(--border);background:transparent;color:var(--text2);font-family:var(--font);user-select:none}
|
||
.live-indicator .live-dot{width:7px;height:7px;border-radius:50%;background:var(--text2);transition:all .3s}
|
||
.live-indicator.active{border-color:rgba(45,212,160,0.35);color:var(--green)}
|
||
.live-indicator.active .live-dot{background:var(--green);box-shadow:0 0 6px rgba(45,212,160,0.5);animation:livePulse 2s infinite}
|
||
.live-indicator.flash{border-color:rgba(124,92,252,0.5);box-shadow:0 0 12px rgba(124,92,252,0.15)}
|
||
@keyframes livePulse{0%,100%{opacity:1;box-shadow:0 0 0 0 rgba(45,212,160,0.4)}50%{opacity:.6;box-shadow:0 0 0 4px rgba(45,212,160,0)}}
|
||
.live-indicator:hover{background:rgba(45,212,160,0.06)}
|
||
|
||
/* ─── Task card status transition ─── */
|
||
.task-card{transition:all .3s ease,border-color .5s ease,box-shadow .5s ease}
|
||
|
||
/* ─── Cards ─── */
|
||
.glass-card{background:var(--glass);backdrop-filter:blur(16px);border:1px solid var(--border);border-radius:16px;padding:20px;position:relative;overflow:hidden;transition:all .3s}
|
||
.glass-card:hover{transform:translateY(-2px);box-shadow:0 8px 32px rgba(124,92,252,0.08)}
|
||
.glass-card::before{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;border-radius:3px 0 0 3px}
|
||
|
||
/* ─── Badges ─── */
|
||
.badge{display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:999px;font-size:.72rem;font-weight:600;letter-spacing:.5px;text-transform:uppercase}
|
||
.badge-new{background:rgba(245,166,35,0.15);color:var(--yellow);border:1px solid rgba(245,166,35,0.25)}
|
||
.badge-in-progress{background:rgba(88,166,255,0.15);color:var(--blue);border:1px solid rgba(88,166,255,0.25)}
|
||
.badge-done{background:rgba(45,212,160,0.15);color:var(--green);border:1px solid rgba(45,212,160,0.25)}
|
||
.badge-failed{background:rgba(248,81,73,0.15);color:var(--red);border:1px solid rgba(248,81,73,0.25)}
|
||
.badge-priority{background:rgba(124,92,252,0.15);color:var(--accent2);border:1px solid rgba(124,92,252,0.25)}
|
||
.badge-priority.high{background:rgba(248,81,73,0.12);color:var(--red);border-color:rgba(248,81,73,0.2)}
|
||
.badge-priority.low{background:rgba(139,148,158,0.12);color:var(--text2);border-color:rgba(139,148,158,0.2)}
|
||
|
||
/* ─── Task Cards ─── */
|
||
.tasks-toolbar{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:20px}
|
||
.filter-group{display:flex;gap:6px;flex-wrap:wrap}
|
||
.filter-btn{padding:6px 14px;border-radius:999px;border:1px solid var(--border);background:transparent;color:var(--text2);font-family:var(--font);font-size:.78rem;cursor:pointer;transition:all .2s}
|
||
.filter-btn:hover{border-color:var(--accent);color:var(--text)}
|
||
.filter-btn.active{background:var(--accent);color:#fff;border-color:var(--accent)}
|
||
.search-input{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:8px 14px;color:var(--text);font-family:var(--font);font-size:.85rem;outline:none;transition:all .25s;width:220px}
|
||
.search-input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(124,92,252,0.15)}
|
||
.search-input::placeholder{color:var(--text2)}
|
||
.create-btn{padding:8px 20px;border-radius:10px;border:none;background:var(--accent);color:#fff;font-family:var(--font);font-size:.85rem;font-weight:600;cursor:pointer;transition:all .25s;display:flex;align-items:center;gap:6px}
|
||
.create-btn:hover{background:#6b4ce6;transform:translateY(-1px);box-shadow:0 4px 16px rgba(124,92,252,0.3)}
|
||
.spacer{flex:1}
|
||
|
||
.task-list{display:flex;flex-direction:column;gap:12px}
|
||
.task-card{cursor:pointer;transition:all .3s}
|
||
.task-card::before{background:linear-gradient(180deg,var(--accent),var(--blue))}
|
||
.task-card.status-done::before{background:linear-gradient(180deg,var(--green),var(--blue))}
|
||
.task-card.status-failed::before{background:linear-gradient(180deg,var(--red),var(--yellow))}
|
||
.task-card.status-in-progress::before{background:linear-gradient(180deg,var(--blue),var(--accent))}
|
||
.task-card.status-new::before{background:linear-gradient(180deg,var(--yellow),var(--accent))}
|
||
.task-header{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||
.task-title{font-weight:600;font-size:1rem;flex:1;min-width:150px}
|
||
.task-meta{display:flex;align-items:center;gap:8px;font-size:.78rem;color:var(--text2);margin-top:8px;font-family:var(--mono)}
|
||
.task-meta svg{width:14px;height:14px;opacity:.5}
|
||
.task-body{max-height:0;overflow:hidden;transition:max-height .4s ease,padding .3s ease;padding:0 0}
|
||
.task-card.expanded .task-body{max-height:2000px;padding:16px 0 0}
|
||
.task-description{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:16px;font-size:.85rem;line-height:1.7;margin-bottom:16px;color:var(--text2)}
|
||
.task-description p{margin-bottom:8px}
|
||
.task-description code{background:rgba(124,92,252,0.1);padding:2px 6px;border-radius:4px;font-family:var(--mono);font-size:.8rem;color:var(--accent2)}
|
||
.task-description pre{background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:12px;overflow-x:auto;margin:8px 0}
|
||
.task-description pre code{background:none;padding:0}
|
||
.task-actions{display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap}
|
||
.action-btn{padding:6px 14px;border-radius:8px;border:1px solid var(--border);background:var(--card);color:var(--text2);font-family:var(--font);font-size:.78rem;cursor:pointer;transition:all .2s;display:flex;align-items:center;gap:4px}
|
||
.action-btn:hover{border-color:var(--accent);color:var(--text);background:var(--glow)}
|
||
.action-btn.primary{background:var(--accent);color:#fff;border-color:var(--accent)}
|
||
.action-btn.primary:hover{background:#6b4ce6}
|
||
.action-btn.danger{border-color:rgba(248,81,73,0.3);color:var(--red)}
|
||
.action-btn.danger:hover{background:rgba(248,81,73,0.1)}
|
||
|
||
/* ─── Kanban Board ─── */
|
||
.view-toggle-tasks{display:flex;background:var(--card);border:1px solid var(--border);border-radius:8px;overflow:hidden;flex-shrink:0}
|
||
.view-toggle-tasks button{padding:6px 14px;border:none;background:transparent;color:var(--text2);font-family:var(--font);font-size:.78rem;cursor:pointer;transition:all .2s;display:flex;align-items:center;gap:5px}
|
||
.view-toggle-tasks button:hover{color:var(--text)}
|
||
.view-toggle-tasks button.active{background:var(--accent);color:#fff}
|
||
.view-toggle-tasks button svg{width:14px;height:14px}
|
||
|
||
.kanban-board{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;min-height:400px}
|
||
.kanban-column{background:var(--glass);backdrop-filter:blur(16px);border:1px solid var(--border);border-radius:16px;display:flex;flex-direction:column;min-height:300px;transition:border-color .2s}
|
||
.kanban-column.drag-over{border-color:var(--accent);box-shadow:0 0 20px rgba(124,92,252,0.15)}
|
||
.kanban-col-header{padding:16px 16px 12px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border);flex-shrink:0}
|
||
.kanban-col-title{font-size:.82rem;font-weight:600;text-transform:uppercase;letter-spacing:1px;display:flex;align-items:center;gap:8px}
|
||
.kanban-col-title .col-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
|
||
.kanban-col-title .col-dot.new{background:var(--yellow)}
|
||
.kanban-col-title .col-dot.in-progress{background:var(--blue)}
|
||
.kanban-col-title .col-dot.done{background:var(--green)}
|
||
.kanban-col-title .col-dot.failed{background:var(--red)}
|
||
.kanban-col-count{font-size:.72rem;font-family:var(--mono);color:var(--text2);background:var(--card);padding:2px 8px;border-radius:999px;border:1px solid var(--border)}
|
||
.kanban-col-body{flex:1;padding:10px;overflow-y:auto;display:flex;flex-direction:column;gap:8px;min-height:60px}
|
||
.kanban-col-body.empty-drop{display:flex;align-items:center;justify-content:center}
|
||
.kanban-col-body .drop-hint{font-size:.78rem;color:var(--text2);opacity:.4;text-align:center;padding:20px 10px}
|
||
|
||
.kanban-card{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:12px;cursor:grab;transition:all .2s;position:relative;overflow:hidden}
|
||
.kanban-card:active{cursor:grabbing}
|
||
.kanban-card:hover{border-color:rgba(124,92,252,0.3);transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,0.2)}
|
||
.kanban-card::before{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;border-radius:3px 0 0 3px}
|
||
.kanban-card.status-new::before{background:linear-gradient(180deg,var(--yellow),var(--accent))}
|
||
.kanban-card.status-in-progress::before{background:linear-gradient(180deg,var(--blue),var(--accent))}
|
||
.kanban-card.status-done::before{background:linear-gradient(180deg,var(--green),var(--blue))}
|
||
.kanban-card.status-failed::before{background:linear-gradient(180deg,var(--red),var(--yellow))}
|
||
.kanban-card.dragging{opacity:.4;transform:scale(.96)}
|
||
.kanban-card-title{font-size:.85rem;font-weight:600;margin-bottom:8px;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||
.kanban-card-footer{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
||
.kanban-card-footer .badge{font-size:.65rem;padding:2px 7px}
|
||
.kanban-card-assignee{font-size:.72rem;color:var(--text2);margin-left:auto;white-space:nowrap}
|
||
|
||
@media(max-width:768px){
|
||
.kanban-board{grid-template-columns:1fr;gap:12px;overflow-x:auto}
|
||
.kanban-board.horizontal-scroll{display:flex;grid-template-columns:unset;overflow-x:auto;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch;padding-bottom:8px}
|
||
.kanban-board.horizontal-scroll .kanban-column{min-width:280px;scroll-snap-align:start;flex-shrink:0}
|
||
.view-toggle-tasks{width:100%}
|
||
.view-toggle-tasks button{flex:1;justify-content:center}
|
||
}
|
||
|
||
/* ─── Notes Timeline ─── */
|
||
.notes-section{margin-top:12px}
|
||
.notes-title{font-size:.8rem;font-weight:600;color:var(--text2);text-transform:uppercase;letter-spacing:1px;margin-bottom:10px}
|
||
.notes-list{display:flex;flex-direction:column;gap:10px;margin-bottom:12px}
|
||
.note-item{display:flex;gap:12px;padding:0 0 0 16px;border-left:2px solid var(--border);font-size:.82rem}
|
||
.note-time{font-family:var(--mono);font-size:.72rem;color:var(--text2);white-space:nowrap;min-width:80px}
|
||
.note-text{color:var(--text);line-height:1.5}
|
||
.add-note-row{display:flex;gap:8px}
|
||
.note-input{flex:1;background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:8px 12px;color:var(--text);font-family:var(--font);font-size:.82rem;outline:none;transition:border-color .2s}
|
||
.note-input:focus{border-color:var(--accent)}
|
||
|
||
/* ─── Empty State ─── */
|
||
.empty-state{text-align:center;padding:60px 20px;color:var(--text2)}
|
||
.empty-state svg{width:80px;height:80px;opacity:.3;margin-bottom:16px}
|
||
.empty-state h3{font-size:1.1rem;font-weight:600;margin-bottom:6px;color:var(--text)}
|
||
.empty-state p{font-size:.85rem;max-width:400px;margin:0 auto 20px}
|
||
|
||
/* ─── Loading / Skeleton ─── */
|
||
.skeleton{background:linear-gradient(90deg,var(--card) 25%,var(--border) 50%,var(--card) 75%);background-size:200% 100%;animation:skeletonShine 1.5s infinite;border-radius:8px}
|
||
@keyframes skeletonShine{0%{background-position:200% 0}100%{background-position:-200% 0}}
|
||
.skeleton-card{height:80px;margin-bottom:12px;border-radius:16px}
|
||
.spinner{display:inline-block;width:20px;height:20px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .6s linear infinite}
|
||
@keyframes spin{to{transform:rotate(360deg)}}
|
||
|
||
/* ─── Modal ─── */
|
||
.modal-overlay{position:fixed;inset:0;z-index:1000;background:rgba(6,8,14,0.8);backdrop-filter:blur(8px);display:none;align-items:center;justify-content:center;animation:fadeIn .2s ease}
|
||
.modal-overlay.show{display:flex}
|
||
.modal{background:var(--bg2);border:1px solid var(--border);border-radius:20px;padding:28px;width:90%;max-width:540px;max-height:90vh;overflow-y:auto;transform:translateY(20px);animation:modalSlideIn .3s ease forwards}
|
||
@keyframes modalSlideIn{to{transform:translateY(0)}}
|
||
.modal h2{font-size:1.2rem;font-weight:700;margin-bottom:20px;display:flex;align-items:center;gap:8px}
|
||
.modal h2::before{content:'';width:4px;height:20px;border-radius:4px;background:linear-gradient(180deg,var(--accent),var(--blue))}
|
||
.form-group{margin-bottom:16px}
|
||
.form-label{display:block;font-size:.78rem;font-weight:500;color:var(--text2);margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px}
|
||
.form-input,.form-textarea,.form-select{width:100%;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:10px 14px;color:var(--text);font-family:var(--font);font-size:.88rem;outline:none;transition:all .2s}
|
||
.form-input:focus,.form-textarea:focus,.form-select:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(124,92,252,0.1)}
|
||
.form-textarea{resize:vertical;min-height:100px;font-family:var(--mono);font-size:.82rem;line-height:1.6}
|
||
.form-select{cursor:pointer;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b949e' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center}
|
||
.form-select option{background:var(--bg2);color:var(--text)}
|
||
.modal-actions{display:flex;gap:10px;justify-content:flex-end;margin-top:20px}
|
||
.modal-close{position:absolute;top:16px;right:16px;background:none;border:none;color:var(--text2);cursor:pointer;font-size:1.4rem;width:36px;height:36px;display:flex;align-items:center;justify-content:center;border-radius:8px;transition:all .2s;z-index:100}
|
||
.modal-close:hover{color:var(--text);background:rgba(255,255,255,0.08)}
|
||
|
||
/* ─── Toast ─── */
|
||
.toast-container{position:fixed;top:20px;right:20px;z-index:2000;display:flex;flex-direction:column;gap:8px}
|
||
.toast{background:var(--bg2);border:1px solid var(--border);border-radius:12px;padding:14px 20px;font-size:.85rem;display:flex;align-items:center;gap:10px;animation:toastIn .3s ease,toastOut .3s ease forwards;animation-delay:0s,3s;min-width:260px;box-shadow:0 8px 32px rgba(0,0,0,0.4)}
|
||
@keyframes toastIn{from{opacity:0;transform:translateX(40px)}to{opacity:1;transform:translateX(0)}}
|
||
@keyframes toastOut{to{opacity:0;transform:translateX(40px)}}
|
||
.toast.success{border-left:3px solid var(--green)}
|
||
.toast.error{border-left:3px solid var(--red)}
|
||
.toast.info{border-left:3px solid var(--blue)}
|
||
.toast-icon{font-size:1rem}
|
||
|
||
/* ─── Documents Tab ─── */
|
||
.docs-layout{display:grid;grid-template-columns:260px 1fr;gap:20px;min-height:500px}
|
||
.file-sidebar{display:flex;flex-direction:column;gap:4px}
|
||
.file-sidebar-header{font-size:.7rem;font-weight:600;letter-spacing:2px;text-transform:uppercase;color:var(--text2);padding:8px 12px;margin-bottom:4px}
|
||
.file-item{display:flex;align-items:center;gap:8px;padding:10px 14px;border-radius:10px;cursor:pointer;transition:all .2s;font-size:.85rem;color:var(--text2);border:1px solid transparent}
|
||
.file-item:hover{background:rgba(124,92,252,0.06);color:var(--text)}
|
||
.file-item.active{background:rgba(124,92,252,0.1);color:var(--accent2);border-color:rgba(124,92,252,0.2)}
|
||
.file-item svg{width:16px;height:16px;opacity:.5;flex-shrink:0}
|
||
.file-item.active svg{opacity:.8}
|
||
.file-item .file-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.file-item.indent{padding-left:28px;font-size:.8rem}
|
||
.file-divider{height:1px;background:var(--border);margin:8px 0}
|
||
.editor-area{display:flex;flex-direction:column;gap:12px}
|
||
.editor-toolbar{display:flex;align-items:center;gap:10px}
|
||
.editor-filename{font-weight:600;font-size:1rem;flex:1}
|
||
.editor-filename span{color:var(--text2);font-weight:400;font-size:.82rem;margin-left:8px}
|
||
.editor-textarea{width:100%;min-height:400px;background:var(--card);border:1px solid var(--border);border-radius:12px;padding:16px;color:var(--text);font-family:var(--mono);font-size:.82rem;line-height:1.7;outline:none;resize:vertical;transition:border-color .2s}
|
||
.editor-textarea:focus{border-color:var(--accent)}
|
||
.save-btn{padding:8px 20px;border-radius:10px;border:none;background:var(--green);color:#000;font-family:var(--font);font-size:.85rem;font-weight:600;cursor:pointer;transition:all .2s}
|
||
.save-btn:hover{background:#25b889;transform:translateY(-1px)}
|
||
.save-btn:disabled{opacity:.5;cursor:not-allowed;transform:none}
|
||
.edit-toggle-btn{padding:8px 18px;border-radius:10px;border:1px solid var(--border);background:var(--card);color:var(--text2);font-family:var(--font);font-size:.82rem;font-weight:500;cursor:pointer;transition:all .25s;display:flex;align-items:center;gap:6px}
|
||
.edit-toggle-btn:hover{border-color:var(--accent);color:var(--text);background:var(--glow)}
|
||
.edit-toggle-btn.editing{background:var(--accent);color:#fff;border-color:var(--accent)}
|
||
.edit-toggle-btn.editing:hover{background:#6b4ce6}
|
||
.md-preview{width:100%;min-height:400px;background:var(--card);border:1px solid var(--border);border-radius:12px;padding:24px 28px;color:var(--text);font-size:.88rem;line-height:1.8;overflow-y:auto;overflow-x:hidden}
|
||
.md-preview h1{font-size:1.6rem;font-weight:700;margin:0 0 16px;padding-bottom:10px;border-bottom:1px solid var(--border);color:var(--text);line-height:1.3}
|
||
.md-preview h2{font-size:1.25rem;font-weight:600;margin:24px 0 12px;padding-bottom:6px;border-bottom:1px solid rgba(26,31,46,0.6);color:var(--text);line-height:1.3}
|
||
.md-preview h3{font-size:1.05rem;font-weight:600;margin:20px 0 8px;color:var(--text);line-height:1.3}
|
||
.md-preview h4{font-size:.92rem;font-weight:600;margin:16px 0 6px;color:var(--accent2)}
|
||
.md-preview p{margin:0 0 12px;color:var(--text2)}
|
||
.md-preview strong{color:var(--text);font-weight:600}
|
||
.md-preview em{color:var(--accent2);font-style:italic}
|
||
.md-preview a{color:var(--blue);text-decoration:none;border-bottom:1px solid rgba(88,166,255,0.3);transition:all .2s}
|
||
.md-preview a:hover{border-bottom-color:var(--blue);color:var(--accent2)}
|
||
.md-preview code{background:rgba(124,92,252,0.1);padding:2px 7px;border-radius:5px;font-family:var(--mono);font-size:.82rem;color:var(--accent2)}
|
||
.md-preview pre{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:16px 18px;overflow-x:auto;margin:12px 0 16px;line-height:1.6}
|
||
.md-preview pre code{background:none;padding:0;color:var(--text);font-size:.8rem}
|
||
.md-preview ul,.md-preview ol{margin:8px 0 16px 8px;padding-left:20px}
|
||
.md-preview li{margin-bottom:6px;color:var(--text2);line-height:1.6}
|
||
.md-preview li::marker{color:var(--accent)}
|
||
.md-preview blockquote{border-left:3px solid var(--accent);margin:12px 0 16px;padding:10px 16px;background:rgba(124,92,252,0.04);border-radius:0 8px 8px 0;color:var(--text2)}
|
||
.md-preview blockquote p{margin-bottom:4px}
|
||
.md-preview hr{border:none;height:1px;background:var(--border);margin:24px 0}
|
||
.md-preview table{width:100%;border-collapse:collapse;margin:12px 0 16px;font-size:.84rem}
|
||
.md-preview th{text-align:left;padding:10px 14px;background:var(--bg);border:1px solid var(--border);font-weight:600;color:var(--text);text-transform:uppercase;font-size:.75rem;letter-spacing:.5px}
|
||
.md-preview td{padding:10px 14px;border:1px solid var(--border);color:var(--text2)}
|
||
.md-preview tr:hover td{background:rgba(124,92,252,0.03)}
|
||
.md-preview img{max-width:100%;border-radius:8px;margin:8px 0}
|
||
.md-empty-hint{text-align:center;padding:60px 20px;color:var(--text2)}
|
||
.md-empty-hint svg{width:60px;height:60px;opacity:.3;margin-bottom:12px}
|
||
.md-empty-hint p{font-size:.88rem;margin-top:8px}
|
||
.view-toggle{display:flex;background:var(--card);border:1px solid var(--border);border-radius:8px;overflow:hidden}
|
||
.view-toggle button{padding:6px 14px;border:none;background:transparent;color:var(--text2);font-family:var(--font);font-size:.78rem;cursor:pointer;transition:all .2s}
|
||
.view-toggle button.active{background:var(--accent);color:#fff}
|
||
|
||
/* Skills Grid */
|
||
.skills-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px;margin-top:12px}
|
||
.skill-card{padding:16px;cursor:default}
|
||
.skill-card::before{background:linear-gradient(180deg,var(--accent2),var(--blue))}
|
||
.skill-card .skill-name{font-weight:600;font-size:.92rem;margin-bottom:6px}
|
||
.skill-card .skill-desc{font-size:.8rem;color:var(--text2);line-height:1.5}
|
||
.skill-card .skill-meta{font-family:var(--mono);font-size:.7rem;color:var(--text2);margin-top:8px;opacity:.7}
|
||
|
||
/* ─── Logs Tab ─── */
|
||
.logs-toolbar{display:flex;align-items:center;gap:12px;margin-bottom:20px;flex-wrap:wrap}
|
||
.toggle-switch{display:flex;align-items:center;gap:8px;font-size:.82rem;color:var(--text2)}
|
||
.toggle-track{width:36px;height:20px;background:var(--border);border-radius:10px;cursor:pointer;position:relative;transition:background .2s}
|
||
.toggle-track.on{background:var(--accent)}
|
||
.toggle-track::after{content:'';position:absolute;width:16px;height:16px;background:#fff;border-radius:50%;top:2px;left:2px;transition:transform .2s}
|
||
.toggle-track.on::after{transform:translateX(16px)}
|
||
.timeline{position:relative;padding-left:28px}
|
||
.timeline::before{content:'';position:absolute;left:8px;top:0;bottom:0;width:2px;background:var(--border)}
|
||
.timeline-item{position:relative;margin-bottom:20px;animation:cardIn .4s ease backwards}
|
||
.timeline-item::before{content:'';position:absolute;left:-24px;top:6px;width:12px;height:12px;border-radius:50%;background:var(--card);border:2px solid var(--border)}
|
||
.timeline-item.type-task::before{border-color:var(--accent);background:rgba(124,92,252,0.2)}
|
||
.timeline-item.type-memory::before{border-color:var(--green);background:rgba(45,212,160,0.2)}
|
||
.timeline-date{font-family:var(--mono);font-size:.72rem;color:var(--text2);margin-bottom:4px}
|
||
.timeline-content{background:var(--glass);border:1px solid var(--border);border-radius:12px;padding:14px;font-size:.84rem;line-height:1.6;color:var(--text2);cursor:pointer;transition:all .2s}
|
||
.timeline-content:hover{border-color:rgba(124,92,252,0.3)}
|
||
.timeline-content.expanded{color:var(--text);white-space:pre-wrap}
|
||
.timeline-content .preview{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
|
||
.timeline-tag{display:inline-block;padding:2px 8px;border-radius:4px;font-size:.7rem;font-weight:600;text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px}
|
||
.timeline-tag.memory{background:rgba(45,212,160,0.1);color:var(--green)}
|
||
.timeline-tag.task{background:rgba(124,92,252,0.1);color:var(--accent2)}
|
||
|
||
@keyframes cardIn{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}}
|
||
|
||
/* ─── Ops (Cron + Vision) ─── */
|
||
.ops-grid{display:grid;grid-template-columns:2fr 1fr;gap:16px}
|
||
.card-header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}
|
||
.card-title{font-weight:700;font-size:1rem}
|
||
.card-sub{font-size:.82rem;color:var(--text2)}
|
||
.pill-row{display:flex;gap:6px;flex-wrap:wrap}
|
||
.pill{font-size:.72rem;padding:4px 8px;border-radius:999px;border:1px solid var(--border);background:var(--card);color:var(--text2)}
|
||
.pill.ok{color:var(--green);border-color:rgba(45,212,160,0.35)}
|
||
.pill.fail{color:var(--red);border-color:rgba(248,81,73,0.35)}
|
||
.pill.run{color:var(--yellow);border-color:rgba(245,166,35,0.35)}
|
||
.timeline-list{display:flex;flex-direction:column;gap:8px}
|
||
.timeline-item.ops{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 12px;border:1px solid var(--border);border-radius:12px;background:var(--card)}
|
||
.timeline-left{display:flex;flex-direction:column;gap:4px;min-width:0}
|
||
.timeline-title{font-weight:600;font-size:.9rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.timeline-meta{font-size:.75rem;color:var(--text2)}
|
||
.timeline-status{display:inline-flex;align-items:center;gap:6px;font-size:.75rem}
|
||
.timeline-dot{width:8px;height:8px;border-radius:50%}
|
||
.timeline-dot.ok{background:var(--green)}
|
||
.timeline-dot.fail{background:var(--red)}
|
||
.timeline-dot.run{background:var(--yellow)}
|
||
.timeline-right{font-size:.78rem;color:var(--text2);text-align:right;white-space:nowrap}
|
||
.vision-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}
|
||
.vision-card{padding:12px;border:1px solid var(--border);border-radius:12px;background:var(--card)}
|
||
.vision-label{font-size:.78rem;color:var(--text2);margin-bottom:6px}
|
||
.vision-count{font-size:1.4rem;font-weight:700}
|
||
.vision-sub{font-size:.72rem;color:var(--text2);margin-top:4px}
|
||
|
||
/* ─── Ops Channel Usage ─── */
|
||
.ops-channel-list{display:flex;flex-direction:column;gap:8px}
|
||
.ops-channel-card{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 14px;border:1px solid var(--border);border-radius:12px;background:var(--card)}
|
||
.ops-ch-left{display:flex;flex-direction:column;gap:4px;min-width:0;flex:1}
|
||
.ops-ch-name{font-weight:600;font-size:.9rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.ops-ch-meta{font-size:.75rem;color:var(--text2);display:flex;gap:8px;flex-wrap:wrap}
|
||
.ops-ch-right{text-align:right;flex-shrink:0}
|
||
.ops-ch-tokens{font-size:1.1rem;font-weight:700}
|
||
.ops-ch-cost{font-size:.78rem;color:var(--text2)}
|
||
.ops-model-bar{margin-top:12px}
|
||
.ops-bar-track{display:flex;height:8px;border-radius:4px;overflow:hidden;background:var(--border)}
|
||
.ops-bar-track>div{height:100%;flex-shrink:0}
|
||
.ops-model-legend{display:flex;gap:12px;flex-wrap:wrap;margin-top:8px;font-size:.75rem;color:var(--text2)}
|
||
.ops-model-legend-item{display:flex;align-items:center;gap:4px}
|
||
.ops-model-dot{width:8px;height:8px;border-radius:50%}
|
||
|
||
/* ─── Ops Management Buttons ─── */
|
||
.ops-mgmt-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:0}
|
||
.ops-mgmt-btn{display:flex;align-items:center;gap:10px;padding:14px 16px;border-radius:12px;border:1px solid var(--border);background:rgba(13,17,23,0.6);color:var(--text);font-family:var(--font);font-size:.9rem;font-weight:500;cursor:pointer;transition:all .25s;width:100%;text-align:left;position:relative;backdrop-filter:blur(12px)}
|
||
.ops-mgmt-btn:hover{border-color:var(--accent);background:rgba(124,92,252,0.08);transform:translateY(-1px);box-shadow:0 4px 16px rgba(124,92,252,0.12)}
|
||
.ops-mgmt-btn:active{transform:translateY(0)}
|
||
.ops-mgmt-btn .btn-icon{font-size:1.2rem;flex-shrink:0}
|
||
.ops-mgmt-btn .btn-label{flex:1}
|
||
.ops-mgmt-btn .btn-badge{font-size:1rem;margin-left:auto;transition:all .2s}
|
||
.ops-mgmt-btn.loading .btn-badge{animation:spin .8s linear infinite}
|
||
@keyframes spin{to{transform:rotate(360deg)}}
|
||
.ops-cost-card{margin-top:0}
|
||
.ops-cost-row{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid var(--border);font-size:.85rem}
|
||
.ops-cost-row:last-child{border-bottom:none}
|
||
.ops-cost-label{color:var(--text2)}
|
||
.ops-cost-value{color:var(--text);font-family:var(--mono);font-size:.8rem}
|
||
|
||
/* ─── Config Viewer ─── */
|
||
.config-file{margin-bottom:12px;border:1px solid var(--border);border-radius:12px;overflow:hidden}
|
||
.config-file-header{padding:10px 14px;background:var(--card);cursor:pointer;display:flex;justify-content:space-between;align-items:center;font-weight:600;font-size:.85rem}
|
||
.config-file-header:hover{background:rgba(124,92,252,.06)}
|
||
.config-file-meta{font-size:.72rem;color:var(--text2);font-weight:400}
|
||
.config-file-body{display:none;padding:12px;background:var(--bg);border-top:1px solid var(--border);max-height:400px;overflow:auto}
|
||
.config-file-body.open{display:block}
|
||
.config-file-body pre{font-family:'SF Mono',monospace;font-size:.75rem;white-space:pre-wrap;word-break:break-all;margin:0;line-height:1.5;color:var(--text1)}
|
||
.config-cat{font-size:.72rem;padding:2px 8px;border-radius:4px;margin-left:8px}
|
||
.config-cat.core{background:rgba(124,92,252,.15);color:#c084fc}
|
||
.config-cat.keys{background:rgba(248,113,113,.15);color:#f87171}
|
||
.config-cat.personality{background:rgba(52,211,153,.15);color:#34d399}
|
||
|
||
/* ─── Enhanced Cron ─── */
|
||
.cron-card{border:1px solid var(--border);border-radius:12px;padding:14px;margin-bottom:10px;background:var(--card)}
|
||
.cron-card.disabled{opacity:.5}
|
||
.cron-header{display:flex;justify-content:space-between;align-items:flex-start;gap:8px}
|
||
.cron-name{font-weight:700;font-size:.9rem}
|
||
.cron-schedule{font-size:.78rem;color:var(--accent);font-weight:500}
|
||
.cron-desc{font-size:.82rem;color:var(--text2);margin:6px 0;line-height:1.4}
|
||
.cron-footer{display:flex;gap:12px;font-size:.72rem;color:var(--text2);flex-wrap:wrap;margin-top:8px}
|
||
.cron-status{display:inline-flex;align-items:center;gap:4px}
|
||
.cron-status .dot{width:6px;height:6px;border-radius:50%}
|
||
.cron-status .dot.ok{background:#34d399}.cron-status .dot.fail{background:#f87171}.cron-status .dot.off{background:#6b7280}
|
||
|
||
/* ─── Sessions Table ─── */
|
||
.sessions-table-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch}
|
||
.sessions-table{width:100%;border-collapse:collapse;font-size:.78rem}
|
||
.sessions-table th{padding:8px 6px;text-align:left;color:var(--text2);font-weight:500;white-space:nowrap;position:sticky;top:0;background:var(--bg);border-bottom:1px solid var(--border)}
|
||
.sess-sort-btn{display:inline-flex;align-items:center;gap:4px;border:none;background:transparent;color:inherit;font:inherit;cursor:pointer;padding:0}
|
||
.sess-sort-btn:hover{color:var(--text)}
|
||
.sess-sort-btn.active{color:var(--accent2)}
|
||
.sess-sort-indicator{font-family:var(--mono);font-size:.65rem;opacity:.85;min-width:12px;text-align:center}
|
||
.sessions-table td{padding:7px 6px;border-bottom:1px solid var(--border);white-space:nowrap;vertical-align:middle}
|
||
.sessions-table tr:hover{background:rgba(124,92,252,.06)}
|
||
.sess-name{font-weight:600;max-width:160px;overflow:hidden;text-overflow:ellipsis}
|
||
.sess-status{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:4px}
|
||
.sess-status.active{background:#34d399}.sess-status.idle{background:#6b7280}.sess-status.error{background:#f87171}.sess-status.stale{background:#fbbf24}
|
||
.sess-model{font-size:.72rem;padding:2px 6px;border-radius:4px;border:1px solid var(--border)}
|
||
.model-select{font-size:.7rem;padding:2px 4px;border-radius:4px;border:1px solid var(--border);background:var(--bg2);color:var(--text);cursor:pointer;outline:none;max-width:80px;transition:all .3s}
|
||
.model-select:focus{border-color:var(--blue)}
|
||
.sess-alert{padding:8px 12px;border-radius:8px;margin-bottom:6px;font-size:.82rem;display:flex;align-items:center;gap:8px}
|
||
.sess-alert.error{background:rgba(248,113,113,.1);border:1px solid rgba(248,113,113,.3)}
|
||
.sess-alert.waste{background:rgba(251,191,36,.1);border:1px solid rgba(251,191,36,.3)}
|
||
.sess-alert.stale{background:rgba(107,114,128,.1);border:1px solid rgba(107,114,128,.3)}
|
||
|
||
/* ─── Cost Heatmap ─── */
|
||
.cost-heatmap{overflow-x:auto;-webkit-overflow-scrolling:touch}
|
||
.cost-heatmap table{width:100%;border-collapse:collapse;font-size:.75rem}
|
||
.cost-heatmap th{padding:6px 4px;text-align:center;color:var(--text2);font-weight:500;white-space:nowrap;position:sticky;top:0;background:var(--bg)}
|
||
.cost-heatmap th:first-child{text-align:left;min-width:90px}
|
||
.cost-heatmap td{padding:5px 4px;text-align:center;border-radius:4px;white-space:nowrap}
|
||
.cost-heatmap td:first-child{text-align:left;font-weight:600;color:var(--text1)}
|
||
.cost-heatmap .heat-cell{display:inline-block;padding:2px 6px;border-radius:4px;min-width:40px;font-variant-numeric:tabular-nums}
|
||
.cost-heatmap .total-row td{border-top:1px solid var(--border);font-weight:700}
|
||
|
||
/* ─── Responsive ─── */
|
||
@media(max-width:768px){
|
||
.ops-mgmt-grid{grid-template-columns:1fr}
|
||
.ops-grid{grid-template-columns:1fr}
|
||
.vision-grid{grid-template-columns:1fr 1fr}
|
||
|
||
body{padding-bottom:calc(64px + env(safe-area-inset-bottom))}
|
||
header{flex-direction:column;gap:8px;text-align:center;padding:calc(12px + env(safe-area-inset-top)) 0 16px}
|
||
.header-right{justify-content:center;flex-wrap:wrap;gap:8px}
|
||
/* Bottom nav bar on mobile */
|
||
.tab-bar{
|
||
position:fixed;bottom:0;left:0;right:0;z-index:900;
|
||
width:100%;margin:0;border-radius:0;border:none;
|
||
border-top:1px solid var(--border);
|
||
background:rgba(6,8,14,0.92);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);
|
||
display:flex;justify-content:space-around;
|
||
padding:8px 8px calc(8px + env(safe-area-inset-bottom));
|
||
gap:0
|
||
}
|
||
.tab-btn{
|
||
flex:1;display:flex;flex-direction:column;align-items:center;
|
||
padding:6px 4px;min-height:44px;font-size:.65rem;gap:2px;
|
||
border-radius:10px;white-space:nowrap
|
||
}
|
||
.tab-btn svg{width:20px;height:20px;margin:0}
|
||
.tab-btn .badge-count{margin-left:0;margin-top:1px}
|
||
.tab-btn .tab-label{display:block}
|
||
.tab-content{display:block;font-size:.65rem}
|
||
.tasks-toolbar{flex-direction:column;align-items:stretch}
|
||
.search-input{width:100%}
|
||
.spacer{display:none}
|
||
.docs-layout{grid-template-columns:1fr;min-height:auto}
|
||
.file-sidebar{max-height:200px;overflow-y:auto}
|
||
.modal{width:95%;padding:20px}
|
||
.task-header{flex-direction:column;align-items:flex-start}
|
||
.logs-toolbar{flex-direction:column;align-items:flex-start}
|
||
.container{padding:0 12px}
|
||
.agent-stat-value{font-size:2rem}
|
||
}
|
||
|
||
/* ─── Task Detail Modal ─── */
|
||
.detail-modal .modal{max-width:720px;padding:0;overflow:hidden}
|
||
.detail-header{padding:24px 28px 16px;padding-right:56px;border-bottom:1px solid var(--border);position:relative}
|
||
.detail-header .detail-status-row{display:flex;align-items:center;gap:8px;margin-bottom:12px;flex-wrap:wrap}
|
||
.detail-title{font-size:1.3rem;font-weight:700;line-height:1.3;margin-bottom:4px}
|
||
.detail-meta{display:flex;align-items:center;gap:12px;font-size:.78rem;color:var(--text2);font-family:var(--mono);flex-wrap:wrap;margin-top:8px}
|
||
.detail-meta span{display:flex;align-items:center;gap:4px}
|
||
.detail-body{padding:0 28px 24px;max-height:60vh;overflow-y:auto}
|
||
.detail-section{margin-top:20px}
|
||
.detail-section-title{font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:1.5px;color:var(--text2);margin-bottom:10px;display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none}
|
||
.detail-section-title svg{width:14px;height:14px;transition:transform .2s}
|
||
.detail-section-title.collapsed svg{transform:rotate(-90deg)}
|
||
.detail-section-content{animation:fadeIn .3s ease}
|
||
.detail-section-title.collapsed+.detail-section-content{display:none}
|
||
.detail-description{background:var(--bg);border:1px solid var(--border);border-radius:12px;padding:16px 18px;font-size:.85rem;line-height:1.8;color:var(--text2)}
|
||
.detail-description p{margin-bottom:8px}
|
||
.detail-description code{background:rgba(124,92,252,0.1);padding:2px 6px;border-radius:4px;font-family:var(--mono);font-size:.8rem;color:var(--accent2)}
|
||
.detail-description pre{background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:14px;overflow-x:auto;margin:10px 0;font-size:.8rem;line-height:1.6}
|
||
.detail-description pre code{background:none;padding:0;color:var(--text)}
|
||
.detail-description h2,.detail-description h3,.detail-description h4{color:var(--text);margin:12px 0 6px;font-weight:600}
|
||
.detail-description h2{font-size:1rem}
|
||
.detail-description h3{font-size:.92rem}
|
||
.detail-description h4{font-size:.86rem}
|
||
.detail-description strong{color:var(--text)}
|
||
.detail-description em{color:var(--accent2)}
|
||
.detail-description li{margin-bottom:4px;list-style-position:inside}
|
||
.detail-description ul,.detail-description ol{margin:6px 0 6px 4px}
|
||
|
||
.detail-output{background:var(--bg);border:1px solid var(--border);border-radius:12px;overflow:hidden}
|
||
.detail-output-header{padding:10px 16px;background:var(--card);border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;font-size:.78rem}
|
||
.detail-output-header span{color:var(--text2);font-weight:600;letter-spacing:.5px;text-transform:uppercase}
|
||
.detail-output-copy{padding:4px 10px;border-radius:6px;border:1px solid var(--border);background:transparent;color:var(--text2);font-family:var(--font);font-size:.72rem;cursor:pointer;transition:all .2s}
|
||
.detail-output-copy:hover{border-color:var(--accent);color:var(--text)}
|
||
.detail-output-body{padding:16px 18px;font-family:var(--mono);font-size:.8rem;line-height:1.7;color:var(--text);white-space:pre-wrap;word-break:break-word;max-height:400px;overflow-y:auto}
|
||
|
||
.detail-notes{display:flex;flex-direction:column;gap:10px}
|
||
.detail-note{display:flex;gap:12px;padding:10px 14px;background:var(--bg);border-radius:10px;border:1px solid var(--border);transition:border-color .2s}
|
||
.detail-note:hover{border-color:rgba(124,92,252,0.2)}
|
||
.detail-note.is-output{border-left:3px solid var(--accent);background:rgba(124,92,252,0.03)}
|
||
.detail-note.is-status{border-left:3px solid var(--blue);opacity:.7}
|
||
.detail-note-time{font-family:var(--mono);font-size:.7rem;color:var(--text2);white-space:nowrap;min-width:90px;padding-top:2px}
|
||
.detail-note-text{font-size:.82rem;color:var(--text);line-height:1.6;flex:1;overflow:hidden}
|
||
.detail-note-text.truncated{max-height:60px;position:relative}
|
||
.detail-note-text.truncated::after{content:'';position:absolute;bottom:0;left:0;right:0;height:24px;background:linear-gradient(transparent,var(--bg))}
|
||
.detail-note-expand{font-size:.72rem;color:var(--accent);cursor:pointer;margin-top:4px;flex-shrink:0;align-self:flex-end}
|
||
.detail-note-expand:hover{text-decoration:underline}
|
||
|
||
.detail-add-note{display:flex;gap:8px;margin-top:12px}
|
||
.detail-note-input{flex:1;background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:10px 14px;color:var(--text);font-family:var(--font);font-size:.84rem;outline:none;transition:border-color .25s}
|
||
.detail-note-input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(124,92,252,0.1)}
|
||
.detail-note-input::placeholder{color:var(--text2)}
|
||
|
||
.detail-actions{display:flex;gap:8px;padding:16px 28px;border-top:1px solid var(--border);background:var(--bg2);flex-wrap:wrap}
|
||
.detail-actions .action-btn{padding:8px 18px;font-size:.82rem}
|
||
|
||
/* ─── Connected APIs Grid ─── */
|
||
.apis-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;margin-top:12px}
|
||
.api-card{padding:20px;cursor:default;transition:all .3s}
|
||
.api-card::before{background:linear-gradient(180deg,var(--accent),var(--green))}
|
||
.api-card:hover{transform:translateY(-3px);box-shadow:0 12px 40px rgba(124,92,252,0.12)}
|
||
.api-card-header{display:flex;align-items:center;gap:14px;margin-bottom:14px}
|
||
.api-icon{width:48px;height:48px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:1.5rem;flex-shrink:0;border:1px solid var(--border);background:var(--bg)}
|
||
.api-icon.telegram{background:linear-gradient(135deg,#2AABEE,#229ED9);border:none;color:#fff}
|
||
.api-icon.google{background:linear-gradient(135deg,#4285F4,#34A853);border:none;color:#fff}
|
||
.api-icon.notion{background:#000;border:none;color:#fff}
|
||
.api-icon.brevo{background:linear-gradient(135deg,#0092FF,#004DFF);border:none;color:#fff}
|
||
.api-icon.email{background:linear-gradient(135deg,#EA4335,#FBBC05);border:none;color:#fff}
|
||
.api-icon.whisper{background:linear-gradient(135deg,#10a37f,#1a7f64);border:none;color:#fff}
|
||
.api-icon.tavily{background:linear-gradient(135deg,#6366F1,#8B5CF6);border:none;color:#fff}
|
||
.api-icon.brave{background:linear-gradient(135deg,#FB542B,#FF7654);border:none;color:#fff}
|
||
.api-icon.imagen{background:linear-gradient(135deg,#4285F4,#DB4437,#F4B400,#0F9D58);border:none;color:#fff}
|
||
.api-icon.meta{background:linear-gradient(135deg,#0081FB,#0064E0,#0082FB);border:none;color:#fff}
|
||
.api-icon.openclaw{background:linear-gradient(135deg,var(--accent),var(--accent2));border:none;color:#fff}
|
||
.api-card-info{flex:1;min-width:0}
|
||
.api-card-name{font-weight:700;font-size:1rem;margin-bottom:2px;display:flex;align-items:center;gap:8px}
|
||
.api-card-name .api-status{width:8px;height:8px;border-radius:50%;flex-shrink:0}
|
||
.api-card-name .api-status.connected{background:var(--green);box-shadow:0 0 6px rgba(45,212,160,0.4)}
|
||
.api-card-name .api-status.disconnected{background:var(--red);box-shadow:0 0 6px rgba(248,81,73,0.4)}
|
||
.api-card-name .api-status.partial{background:var(--yellow);box-shadow:0 0 6px rgba(245,166,35,0.4)}
|
||
.api-card-provider{font-size:.78rem;color:var(--text2)}
|
||
.api-card-desc{font-size:.82rem;color:var(--text2);line-height:1.5;margin-bottom:14px}
|
||
.api-capabilities{display:flex;flex-wrap:wrap;gap:6px}
|
||
.api-cap{display:inline-flex;align-items:center;gap:4px;padding:4px 10px;border-radius:8px;font-size:.7rem;font-weight:500;background:rgba(124,92,252,0.08);color:var(--accent2);border:1px solid rgba(124,92,252,0.12);letter-spacing:.3px}
|
||
.api-cap svg{width:12px;height:12px;opacity:.7}
|
||
.api-cap.messaging{background:rgba(88,166,255,0.08);color:var(--blue);border-color:rgba(88,166,255,0.15)}
|
||
.api-cap.data{background:rgba(45,212,160,0.08);color:var(--green);border-color:rgba(45,212,160,0.15)}
|
||
.api-cap.ai{background:rgba(167,139,250,0.08);color:var(--accent2);border-color:rgba(167,139,250,0.15)}
|
||
.api-cap.search{background:rgba(248,166,35,0.08);color:var(--yellow);border-color:rgba(248,166,35,0.15)}
|
||
.apis-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:20px}
|
||
.api-stat-card{background:var(--glass);backdrop-filter:blur(16px);border:1px solid var(--border);border-radius:14px;padding:16px 20px;text-align:center}
|
||
.api-stat-value{font-size:1.8rem;font-weight:800;background:linear-gradient(135deg,var(--accent),var(--green));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||
.api-stat-label{font-size:.75rem;color:var(--text2);text-transform:uppercase;letter-spacing:1px;margin-top:4px}
|
||
|
||
/* ─── Agent Monitor Bar ─── */
|
||
.agent-monitor{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:24px}
|
||
.agent-stat{background:var(--glass);backdrop-filter:blur(16px);border:1px solid var(--border);border-radius:14px;padding:16px 18px;position:relative;overflow:hidden;transition:all .3s}
|
||
.agent-stat:hover{transform:translateY(-2px);box-shadow:0 6px 24px rgba(124,92,252,0.1)}
|
||
.agent-stat::before{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;border-radius:3px 0 0 3px}
|
||
.agent-stat.stat-main::before{background:linear-gradient(180deg,var(--green),var(--accent))}
|
||
.agent-stat.stat-subagent::before{background:linear-gradient(180deg,var(--accent),var(--blue))}
|
||
.agent-stat.stat-hook::before{background:linear-gradient(180deg,var(--blue),var(--accent2))}
|
||
.agent-stat.stat-cron::before{background:linear-gradient(180deg,var(--yellow),var(--accent))}
|
||
.agent-stat.stat-total::before{background:linear-gradient(180deg,var(--accent2),var(--green))}
|
||
.agent-stat-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
|
||
.agent-stat-icon{font-size:1.2rem;opacity:.8}
|
||
.agent-stat-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:999px;font-size:.65rem;font-weight:600;letter-spacing:.5px}
|
||
.agent-stat-badge.active{background:rgba(45,212,160,0.15);color:var(--green);border:1px solid rgba(45,212,160,0.25)}
|
||
.agent-stat-badge.idle{background:rgba(139,148,158,0.1);color:var(--text2);border:1px solid rgba(139,148,158,0.2)}
|
||
.agent-stat-badge .pulse-dot{width:6px;height:6px;border-radius:50%;background:currentColor;animation:pulse 2s infinite}
|
||
.agent-stat-value{font-size:1.8rem;font-weight:800;line-height:1;margin-bottom:2px}
|
||
.agent-stat-value.val-active{background:linear-gradient(135deg,var(--green),var(--accent));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||
.agent-stat-value.val-accent{background:linear-gradient(135deg,var(--accent),var(--blue));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||
.agent-stat-value.val-blue{background:linear-gradient(135deg,var(--blue),var(--accent2));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||
.agent-stat-value.val-yellow{background:linear-gradient(135deg,var(--yellow),var(--accent));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||
.agent-stat-value.val-purple{background:linear-gradient(135deg,var(--accent2),var(--green));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||
.agent-stat-label{font-size:.72rem;color:var(--text2);text-transform:uppercase;letter-spacing:1px;font-weight:500}
|
||
.agent-stat-detail{font-size:.7rem;color:var(--text2);margin-top:6px;font-family:var(--mono);opacity:.7}
|
||
|
||
.agent-sessions-panel{background:var(--glass);backdrop-filter:blur(16px);border:1px solid var(--border);border-radius:14px;margin-bottom:24px;overflow:hidden;transition:all .3s}
|
||
.agent-sessions-header{padding:12px 18px;display:flex;align-items:center;justify-content:space-between;cursor:pointer;transition:background .2s;user-select:none}
|
||
.agent-sessions-header:hover{background:rgba(124,92,252,0.04)}
|
||
.agent-sessions-title{font-size:.78rem;font-weight:600;text-transform:uppercase;letter-spacing:1px;color:var(--text2);display:flex;align-items:center;gap:8px}
|
||
.agent-sessions-title svg{width:14px;height:14px;transition:transform .2s}
|
||
.agent-sessions-header.collapsed .agent-sessions-title svg{transform:rotate(-90deg)}
|
||
.agent-sessions-count{font-size:.7rem;font-family:var(--mono);color:var(--accent2);background:rgba(124,92,252,0.1);padding:2px 8px;border-radius:999px}
|
||
.agent-sessions-body{max-height:400px;overflow-y:auto;padding:0;transition:max-height .4s ease}
|
||
.agent-sessions-header.collapsed+.agent-sessions-body{max-height:0;overflow:hidden}
|
||
.agent-session-row{display:flex;align-items:center;gap:12px;padding:10px 18px;border-top:1px solid var(--border);font-size:.8rem;transition:background .15s}
|
||
.agent-session-row:hover{background:rgba(124,92,252,0.04)}
|
||
.agent-session-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
|
||
.agent-session-dot.active{background:var(--green);box-shadow:0 0 6px rgba(45,212,160,0.4);animation:pulse 2s infinite}
|
||
.agent-session-dot.idle{background:var(--text2);opacity:.4}
|
||
.agent-session-key{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text);font-family:var(--mono);font-size:.75rem}
|
||
.agent-session-type{padding:2px 8px;border-radius:999px;font-size:.65rem;font-weight:600;letter-spacing:.5px;text-transform:uppercase;flex-shrink:0}
|
||
.agent-session-type.type-main{background:rgba(45,212,160,0.12);color:var(--green)}
|
||
.agent-session-type.type-subagent{background:rgba(124,92,252,0.12);color:var(--accent2)}
|
||
.agent-session-type.type-hook{background:rgba(88,166,255,0.12);color:var(--blue)}
|
||
.agent-session-type.type-cron{background:rgba(245,166,35,0.12);color:var(--yellow)}
|
||
.agent-session-type.type-group{background:rgba(139,148,158,0.1);color:var(--text2)}
|
||
.agent-session-age{font-family:var(--mono);font-size:.7rem;color:var(--text2);white-space:nowrap;flex-shrink:0}
|
||
.agent-session-tokens{font-family:var(--mono);font-size:.68rem;color:var(--text2);white-space:nowrap;flex-shrink:0;opacity:.6}
|
||
.agent-session-label{font-size:.72rem;color:var(--accent2);max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex-shrink:0}
|
||
|
||
@media(max-width:768px){
|
||
.agent-monitor{grid-template-columns:repeat(2,1fr);gap:8px}
|
||
.agent-monitor .agent-stat:last-child{grid-column:1 / -1}
|
||
.agent-stat-value{font-size:1.6rem}
|
||
.agent-stat{padding:12px 14px}
|
||
.agent-session-tokens,.agent-session-label{display:none}
|
||
/* Mobile: hide 任务(2), Tokens(5); keep 频道/模型/消息/花费/$/条/匹配 */
|
||
.sessions-table th:nth-child(2),.sessions-table td:nth-child(2),
|
||
.sessions-table th:nth-child(5),.sessions-table td:nth-child(5){display:none}
|
||
.sessions-table{font-size:.72rem}
|
||
.sess-name{max-width:100px}
|
||
}
|
||
|
||
/* ─── Scrollbar ─── */
|
||
::-webkit-scrollbar{width:6px;height:6px}
|
||
::-webkit-scrollbar-track{background:transparent}
|
||
::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
|
||
::-webkit-scrollbar-thumb:hover{background:var(--text2)}
|
||
|
||
/* ─── Staggered animations ─── */
|
||
.stagger-1{animation-delay:.05s!important}
|
||
.stagger-2{animation-delay:.1s!important}
|
||
.stagger-3{animation-delay:.15s!important}
|
||
.stagger-4{animation-delay:.2s!important}
|
||
.stagger-5{animation-delay:.25s!important}
|
||
.stagger-6{animation-delay:.3s!important}
|
||
.stagger-7{animation-delay:.35s!important}
|
||
.stagger-8{animation-delay:.4s!important}
|
||
|
||
/* ─── Task Markdown Content Field ─── */
|
||
.task-content-preview{background:var(--bg);border:1px solid var(--border);border-radius:12px;padding:16px 18px;margin-top:12px;position:relative;overflow:hidden;max-height:120px;cursor:pointer;transition:all .3s}
|
||
.task-content-preview::after{content:'';position:absolute;bottom:0;left:0;right:0;height:40px;background:linear-gradient(transparent,var(--bg));pointer-events:none}
|
||
.task-content-preview.expanded{max-height:none}
|
||
.task-content-preview.expanded::after{display:none}
|
||
.task-content-label{display:flex;align-items:center;gap:6px;font-size:.72rem;font-weight:600;text-transform:uppercase;letter-spacing:1px;color:var(--accent2);margin-bottom:8px}
|
||
.task-content-label svg{width:14px;height:14px;opacity:.7}
|
||
.task-content-md{font-size:.84rem;line-height:1.7;color:var(--text2)}
|
||
.task-content-md h1,.task-content-md h2,.task-content-md h3,.task-content-md h4{color:var(--text);margin:8px 0 4px;font-weight:600}
|
||
.task-content-md h1{font-size:1.1rem}.task-content-md h2{font-size:1rem}.task-content-md h3{font-size:.92rem}.task-content-md h4{font-size:.86rem}
|
||
.task-content-md p{margin:0 0 8px}
|
||
.task-content-md code{background:rgba(124,92,252,0.1);padding:2px 6px;border-radius:4px;font-family:var(--mono);font-size:.8rem;color:var(--accent2)}
|
||
.task-content-md pre{background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:12px;overflow-x:auto;margin:8px 0}
|
||
.task-content-md pre code{background:none;padding:0;color:var(--text);font-size:.78rem}
|
||
.task-content-md ul,.task-content-md ol{margin:6px 0 10px;padding-left:20px}
|
||
.task-content-md li{margin-bottom:4px;color:var(--text2)}
|
||
.task-content-md li::marker{color:var(--accent)}
|
||
.task-content-md blockquote{border-left:3px solid var(--accent);padding:8px 14px;margin:8px 0;background:rgba(124,92,252,0.04);border-radius:0 8px 8px 0}
|
||
.task-content-md strong{color:var(--text)}
|
||
.task-content-md em{color:var(--accent2)}
|
||
.task-content-md a{color:var(--blue);text-decoration:none;border-bottom:1px solid rgba(88,166,255,0.3)}
|
||
.task-content-md a:hover{border-bottom-color:var(--blue)}
|
||
.task-content-md table{width:100%;border-collapse:collapse;font-size:.82rem;margin:8px 0}
|
||
.task-content-md th{text-align:left;padding:8px 12px;background:var(--bg2);border:1px solid var(--border);font-weight:600;color:var(--text);font-size:.75rem;letter-spacing:.5px;text-transform:uppercase}
|
||
.task-content-md td{padding:8px 12px;border:1px solid var(--border);color:var(--text2)}
|
||
.task-content-md tr:hover td{background:rgba(124,92,252,0.03)}
|
||
.task-content-md hr{border:none;height:1px;background:var(--border);margin:16px 0}
|
||
.task-content-md img{max-width:100%;border-radius:6px}
|
||
|
||
.detail-content-section{margin-top:20px}
|
||
.detail-content-area{background:var(--bg);border:1px solid var(--border);border-radius:12px;overflow:hidden;transition:border-color .2s}
|
||
.detail-content-area:focus-within{border-color:var(--accent)}
|
||
.detail-content-header{padding:10px 16px;background:var(--card);border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
|
||
.detail-content-header .content-label{font-size:.75rem;font-weight:600;letter-spacing:1px;text-transform:uppercase;color:var(--accent2);display:flex;align-items:center;gap:6px}
|
||
.detail-content-header .content-label svg{width:14px;height:14px}
|
||
.detail-content-actions{display:flex;gap:6px}
|
||
.content-edit-btn{padding:4px 12px;border-radius:6px;border:1px solid var(--border);background:transparent;color:var(--text2);font-family:var(--font);font-size:.72rem;cursor:pointer;transition:all .2s}
|
||
.content-edit-btn:hover{border-color:var(--accent);color:var(--text)}
|
||
.content-edit-btn.active{background:var(--accent);color:#fff;border-color:var(--accent)}
|
||
.content-save-btn{padding:4px 12px;border-radius:6px;border:none;background:var(--green);color:#000;font-family:var(--font);font-size:.72rem;font-weight:600;cursor:pointer;transition:all .2s}
|
||
.content-save-btn:hover{background:#25b889}
|
||
.detail-content-md{padding:18px 20px;font-size:.85rem;line-height:1.8;color:var(--text2);min-height:80px}
|
||
.detail-content-md h1{font-size:1.3rem;font-weight:700;margin:0 0 12px;color:var(--text)}
|
||
.detail-content-md h2{font-size:1.1rem;font-weight:600;margin:16px 0 8px;color:var(--text)}
|
||
.detail-content-md h3{font-size:.95rem;font-weight:600;margin:12px 0 6px;color:var(--text)}
|
||
.detail-content-md h4{font-size:.88rem;font-weight:600;margin:10px 0 4px;color:var(--accent2)}
|
||
.detail-content-md p{margin:0 0 10px}
|
||
.detail-content-md code{background:rgba(124,92,252,0.1);padding:2px 6px;border-radius:4px;font-family:var(--mono);font-size:.8rem;color:var(--accent2)}
|
||
.detail-content-md pre{background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:14px;overflow-x:auto;margin:10px 0;line-height:1.6}
|
||
.detail-content-md pre code{background:none;padding:0;color:var(--text);font-size:.78rem}
|
||
.detail-content-md ul,.detail-content-md ol{margin:6px 0 12px;padding-left:20px}
|
||
.detail-content-md li{margin-bottom:4px;color:var(--text2);line-height:1.6}
|
||
.detail-content-md li::marker{color:var(--accent)}
|
||
.detail-content-md blockquote{border-left:3px solid var(--accent);padding:10px 16px;margin:10px 0;background:rgba(124,92,252,0.04);border-radius:0 8px 8px 0}
|
||
.detail-content-md blockquote p{margin-bottom:4px}
|
||
.detail-content-md strong{color:var(--text)}
|
||
.detail-content-md em{color:var(--accent2)}
|
||
.detail-content-md a{color:var(--blue);text-decoration:none;border-bottom:1px solid rgba(88,166,255,0.3)}
|
||
.detail-content-md a:hover{border-bottom-color:var(--blue)}
|
||
.detail-content-md table{width:100%;border-collapse:collapse;font-size:.84rem;margin:10px 0}
|
||
.detail-content-md th{text-align:left;padding:8px 12px;background:var(--bg2);border:1px solid var(--border);font-weight:600;color:var(--text);font-size:.75rem;letter-spacing:.5px;text-transform:uppercase}
|
||
.detail-content-md td{padding:8px 12px;border:1px solid var(--border);color:var(--text2)}
|
||
.detail-content-md tr:hover td{background:rgba(124,92,252,0.03)}
|
||
.detail-content-md hr{border:none;height:1px;background:var(--border);margin:20px 0}
|
||
.detail-content-md img{max-width:100%;border-radius:8px;margin:8px 0}
|
||
.detail-content-textarea{width:100%;min-height:200px;background:var(--bg2);border:none;padding:16px 20px;color:var(--text);font-family:var(--mono);font-size:.82rem;line-height:1.7;outline:none;resize:vertical}
|
||
.detail-content-empty{text-align:center;padding:30px 20px;color:var(--text2);font-size:.84rem}
|
||
.detail-content-empty svg{width:40px;height:40px;opacity:.3;margin-bottom:8px;display:block;margin:0 auto 8px}
|
||
|
||
/* ─── Parallel Execution Panel ─── */
|
||
.parallel-panel{background:var(--glass);backdrop-filter:blur(16px);border:1px solid rgba(124,92,252,0.25);border-radius:16px;margin-bottom:20px;overflow:hidden;animation:fadeIn .3s ease}
|
||
.parallel-header{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;border-bottom:1px solid var(--border)}
|
||
.parallel-title{font-size:.85rem;font-weight:700;display:flex;align-items:center;gap:8px;background:linear-gradient(135deg,var(--accent),var(--blue));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||
.parallel-count{font-size:.72rem;font-family:var(--mono);color:var(--green);background:rgba(45,212,160,0.1);padding:3px 10px;border-radius:999px;border:1px solid rgba(45,212,160,0.2)}
|
||
.parallel-tasks{padding:8px;max-height:300px;overflow-y:auto}
|
||
.parallel-task-row{display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:10px;transition:background .2s;font-size:.82rem}
|
||
.parallel-task-row:hover{background:rgba(124,92,252,0.04)}
|
||
.parallel-spinner{width:16px;height:16px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .6s linear infinite;flex-shrink:0}
|
||
.parallel-done-icon{width:16px;height:16px;display:flex;align-items:center;justify-content:center;border-radius:50%;background:var(--green);color:#000;font-size:.6rem;font-weight:700;flex-shrink:0}
|
||
.parallel-fail-icon{width:16px;height:16px;display:flex;align-items:center;justify-content:center;border-radius:50%;background:var(--red);color:#fff;font-size:.6rem;font-weight:700;flex-shrink:0}
|
||
.parallel-task-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}
|
||
.parallel-task-time{font-family:var(--mono);font-size:.7rem;color:var(--text2);white-space:nowrap}
|
||
.parallel-task-status{padding:2px 8px;border-radius:999px;font-size:.65rem;font-weight:600;text-transform:uppercase;letter-spacing:.5px}
|
||
.parallel-task-status.running{background:rgba(88,166,255,0.15);color:var(--blue);border:1px solid rgba(88,166,255,0.25)}
|
||
.parallel-task-status.done{background:rgba(45,212,160,0.15);color:var(--green);border:1px solid rgba(45,212,160,0.25)}
|
||
.parallel-task-status.failed{background:rgba(248,81,73,0.15);color:var(--red);border:1px solid rgba(248,81,73,0.25)}
|
||
.parallel-actions{display:flex;gap:8px;padding:10px 18px;border-top:1px solid var(--border)}
|
||
.parallel-clear-btn{padding:5px 14px;border-radius:8px;border:1px solid var(--border);background:transparent;color:var(--text2);font-family:var(--font);font-size:.75rem;cursor:pointer;transition:all .2s}
|
||
.parallel-clear-btn:hover{border-color:var(--accent);color:var(--text)}
|
||
|
||
/* ─── Task Selection ─── */
|
||
.task-checkbox{width:18px;height:18px;border-radius:6px;border:2px solid var(--border);background:transparent;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .2s;flex-shrink:0;margin-right:4px}
|
||
.task-checkbox:hover{border-color:var(--accent)}
|
||
.task-checkbox.checked{background:var(--accent);border-color:var(--accent)}
|
||
.task-checkbox.checked::after{content:'✓';color:#fff;font-size:.7rem;font-weight:700}
|
||
.batch-bar{display:flex;align-items:center;gap:12px;padding:10px 16px;background:rgba(124,92,252,0.08);border:1px solid rgba(124,92,252,0.2);border-radius:12px;margin-bottom:16px;animation:fadeIn .2s ease}
|
||
.batch-bar-count{font-size:.85rem;font-weight:600;color:var(--accent2)}
|
||
.batch-run-btn{padding:7px 18px;border-radius:8px;border:none;background:linear-gradient(135deg,var(--accent),var(--blue));color:#fff;font-family:var(--font);font-size:.82rem;font-weight:600;cursor:pointer;transition:all .25s;display:flex;align-items:center;gap:6px}
|
||
.batch-run-btn:hover{transform:translateY(-1px);box-shadow:0 4px 16px rgba(124,92,252,0.3)}
|
||
.batch-deselect-btn{padding:6px 14px;border-radius:8px;border:1px solid var(--border);background:transparent;color:var(--text2);font-family:var(--font);font-size:.78rem;cursor:pointer;transition:all .2s}
|
||
.batch-deselect-btn:hover{border-color:var(--accent);color:var(--text)}
|
||
|
||
/* ─── Spawn button in task actions ─── */
|
||
.spawn-btn{padding:6px 14px;border-radius:8px;border:1px solid rgba(124,92,252,0.3);background:rgba(124,92,252,0.06);color:var(--accent2);font-family:var(--font);font-size:.78rem;cursor:pointer;transition:all .2s;display:flex;align-items:center;gap:4px}
|
||
.spawn-btn:hover{background:rgba(124,92,252,0.15);border-color:var(--accent);transform:translateY(-1px)}
|
||
|
||
/* ─── Attachments Section ─── */
|
||
.attachments-section{margin-top:20px}
|
||
.attachments-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:10px;margin-top:10px}
|
||
.attachment-card{background:var(--bg);border:1px solid var(--border);border-radius:12px;overflow:hidden;transition:all .2s;position:relative;cursor:pointer}
|
||
.attachment-card:hover{border-color:rgba(124,92,252,0.3);transform:translateY(-2px);box-shadow:0 4px 16px rgba(0,0,0,0.2)}
|
||
.attachment-card .att-preview{width:100%;height:110px;display:flex;align-items:center;justify-content:center;background:var(--bg2);overflow:hidden;position:relative}
|
||
.attachment-card .att-preview img{width:100%;height:100%;object-fit:cover;transition:transform .3s}
|
||
.attachment-card:hover .att-preview img{transform:scale(1.05)}
|
||
.attachment-card .att-preview .att-icon{font-size:2.2rem;opacity:.5}
|
||
.attachment-card .att-info{padding:8px 10px}
|
||
.attachment-card .att-name{font-size:.72rem;font-weight:600;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:1.3}
|
||
.attachment-card .att-size{font-size:.65rem;color:var(--text2);font-family:var(--mono);margin-top:2px}
|
||
.attachment-card .att-delete{position:absolute;top:6px;right:6px;width:22px;height:22px;border-radius:50%;background:rgba(248,81,73,0.85);color:#fff;border:none;font-size:.65rem;cursor:pointer;display:none;align-items:center;justify-content:center;transition:all .15s;z-index:2;line-height:1}
|
||
.attachment-card:hover .att-delete{display:flex}
|
||
.attachment-card .att-delete:hover{background:var(--red);transform:scale(1.1)}
|
||
|
||
.att-drop-zone{border:2px dashed var(--border);border-radius:14px;padding:24px;text-align:center;cursor:pointer;transition:all .25s;margin-top:12px;position:relative}
|
||
.att-drop-zone:hover,.att-drop-zone.drag-over{border-color:var(--accent);background:rgba(124,92,252,0.04)}
|
||
.att-drop-zone.drag-over{box-shadow:0 0 16px rgba(124,92,252,0.12)}
|
||
.att-drop-zone-icon{font-size:2rem;opacity:.4;margin-bottom:6px}
|
||
.att-drop-zone-text{font-size:.82rem;color:var(--text2)}
|
||
.att-drop-zone-hint{font-size:.7rem;color:var(--text2);opacity:.5;margin-top:4px}
|
||
.att-drop-zone input[type="file"]{position:absolute;inset:0;opacity:0;cursor:pointer}
|
||
.att-upload-progress{margin-top:8px;display:none}
|
||
.att-upload-bar{height:4px;background:var(--border);border-radius:2px;overflow:hidden}
|
||
.att-upload-bar-fill{height:100%;background:linear-gradient(90deg,var(--accent),var(--green));border-radius:2px;transition:width .3s;width:0%}
|
||
.att-upload-status{font-size:.72rem;color:var(--text2);margin-top:4px;text-align:center}
|
||
/* Create modal attachments */
|
||
.create-att-drop-zone{border:2px dashed var(--border);border-radius:14px;padding:18px;text-align:center;cursor:pointer;transition:all .25s;position:relative}
|
||
.create-att-drop-zone:hover,.create-att-drop-zone.drag-over{border-color:var(--accent);background:rgba(124,92,252,0.04)}
|
||
.create-att-drop-zone.drag-over{box-shadow:0 0 16px rgba(124,92,252,0.12)}
|
||
.create-att-drop-zone input[type="file"]{position:absolute;inset:0;opacity:0;cursor:pointer}
|
||
.create-att-queue{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
|
||
.create-att-item{display:flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:6px 10px;font-size:.75rem;max-width:100%;transition:all .2s}
|
||
.create-att-item:hover{border-color:rgba(124,92,252,0.3)}
|
||
.create-att-item .cai-icon{font-size:1rem;flex-shrink:0}
|
||
.create-att-item .cai-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text);font-weight:500}
|
||
.create-att-item .cai-size{color:var(--text2);font-family:var(--mono);flex-shrink:0;font-size:.68rem}
|
||
.create-att-item .cai-remove{background:none;border:none;color:var(--text2);cursor:pointer;font-size:.9rem;padding:0 2px;border-radius:4px;transition:color .15s;flex-shrink:0;line-height:1}
|
||
.create-att-item .cai-remove:hover{color:var(--red)}
|
||
.create-att-item .cai-thumb{width:32px;height:32px;border-radius:6px;object-fit:cover;flex-shrink:0}
|
||
|
||
/* ─── Image Lightbox ─── */
|
||
.lightbox-overlay{position:fixed;inset:0;z-index:2000;background:rgba(6,8,14,0.92);backdrop-filter:blur(12px);display:none;align-items:center;justify-content:center;cursor:zoom-out;animation:fadeIn .2s ease}
|
||
.lightbox-overlay.show{display:flex}
|
||
.lightbox-img{max-width:90%;max-height:85vh;border-radius:12px;box-shadow:0 16px 64px rgba(0,0,0,0.6);animation:modalSlideIn .3s ease forwards}
|
||
.lightbox-close{position:fixed;top:20px;right:24px;width:40px;height:40px;border-radius:50%;background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.15);color:#fff;font-size:1.2rem;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .2s;z-index:2001}
|
||
.lightbox-close:hover{background:rgba(255,255,255,0.2);transform:scale(1.1)}
|
||
.lightbox-info{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);color:var(--text2);font-size:.82rem;display:flex;align-items:center;gap:12px;background:var(--glass);backdrop-filter:blur(16px);border:1px solid var(--border);border-radius:12px;padding:8px 18px;z-index:2001}
|
||
.lightbox-download{padding:5px 14px;border-radius:8px;border:1px solid var(--border);background:transparent;color:var(--accent2);font-family:var(--font);font-size:.78rem;cursor:pointer;transition:all .2s;text-decoration:none}
|
||
.lightbox-download:hover{border-color:var(--accent);background:var(--glow);color:var(--text)}
|
||
.att-empty{text-align:center;padding:20px;color:var(--text2);font-size:.82rem;opacity:.6}
|
||
.att-empty svg{width:36px;height:36px;opacity:.25;margin-bottom:6px;display:block;margin:0 auto 6px}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="bg-mesh"></div>
|
||
<div class="container">
|
||
<!-- Header -->
|
||
<header>
|
||
<div>
|
||
<div class="logo">🦞 OpenClaw Dashboard</div>
|
||
<div class="logo-sub">24/7 Online</div>
|
||
</div>
|
||
<div class="header-right">
|
||
<span class="status-dot ok" id="statusDot"></span>
|
||
<span id="statusText">Connecting…</span>
|
||
<button class="live-indicator active" id="liveIndicator" onclick="toggleLivePolling()" title="Toggle live updates">
|
||
<span class="live-dot"></span> LIVE
|
||
</button>
|
||
<button class="refresh-btn" onclick="refreshCurrentTab()" title="Refresh">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
||
</button>
|
||
<a href="/" class="back-link">← Control UI</a>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- Agent Monitor Bar -->
|
||
<div class="agent-monitor" id="agentMonitor">
|
||
<div class="agent-stat stat-main">
|
||
<div class="agent-stat-header">
|
||
<span class="agent-stat-icon">💰</span>
|
||
<span class="agent-stat-badge idle" id="mainAgentBadge"><span class="pulse-dot"></span> today</span>
|
||
</div>
|
||
<div class="agent-stat-value val-active" id="mainAgentValue">—</div>
|
||
<div class="agent-stat-label">Today Cost</div>
|
||
<div class="agent-stat-detail" id="mainAgentDetail">Loading…</div>
|
||
</div>
|
||
<div class="agent-stat stat-subagent">
|
||
<div class="agent-stat-header">
|
||
<span class="agent-stat-icon">📊</span>
|
||
<span class="agent-stat-badge idle" id="subagentBadge">—</span>
|
||
</div>
|
||
<div class="agent-stat-value val-accent" id="subagentValue">—</div>
|
||
<div class="agent-stat-label">Today Tokens</div>
|
||
<div class="agent-stat-detail" id="subagentDetail">—</div>
|
||
</div>
|
||
<div class="agent-stat stat-cron">
|
||
<div class="agent-stat-header">
|
||
<span class="agent-stat-icon">⏰</span>
|
||
<span class="agent-stat-badge idle" id="cronBadge">—</span>
|
||
</div>
|
||
<div class="agent-stat-value val-yellow" id="cronValue">0</div>
|
||
<div class="agent-stat-label">Cron Jobs</div>
|
||
<div class="agent-stat-detail" id="cronDetail">—</div>
|
||
</div>
|
||
<div class="agent-stat stat-hook">
|
||
<div class="agent-stat-header">
|
||
<span class="agent-stat-icon">💬</span>
|
||
<span class="agent-stat-badge idle" id="hookBadge">—</span>
|
||
</div>
|
||
<div class="agent-stat-value val-blue" id="hookValue">0</div>
|
||
<div class="agent-stat-label">Sessions</div>
|
||
<div class="agent-stat-detail" id="hookDetail">—</div>
|
||
</div>
|
||
<div class="agent-stat stat-total">
|
||
<div class="agent-stat-header">
|
||
<span class="agent-stat-icon">🧠</span>
|
||
<span class="agent-stat-badge idle" id="totalBadge">—</span>
|
||
</div>
|
||
<div class="agent-stat-value val-purple" id="totalValue">—</div>
|
||
<div class="agent-stat-label">Model Mix</div>
|
||
<div id="modelMixBars" style="margin-top:8px"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- System Info Bar (fixed, always visible) -->
|
||
<div id="systemInfoBar" class="glass-card" style="padding:10px 16px;margin-bottom:12px;font-size:.75rem;display:none">
|
||
<div style="display:flex;flex-wrap:wrap;gap:6px 16px;align-items:center" id="systemInfoContent"></div>
|
||
</div>
|
||
|
||
<div id="watchdogGlobalAlert" class="glass-card" style="padding:10px 14px;margin-bottom:12px;border-color:var(--red);display:none">
|
||
<div style="display:flex;justify-content:space-between;gap:10px;align-items:center">
|
||
<div style="font-size:.8rem;color:#ffb4b4" id="watchdogGlobalText">OpenClaw watchdog alert</div>
|
||
<button class="btn-secondary" style="padding:6px 10px;font-size:.72rem" onclick="focusOperationsTab()">Open Operations</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Today's Cron Runs -->
|
||
<div class="agent-sessions-panel" id="cronRunsPanel">
|
||
<div class="agent-sessions-header" onclick="this.classList.toggle('collapsed');this.nextElementSibling.style.maxHeight=this.classList.contains('collapsed')?'0':'400px'">
|
||
<div class="agent-sessions-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
|
||
今日 Cron 执行记录
|
||
</div>
|
||
<span class="agent-sessions-count" id="cronRunsCount">0</span>
|
||
</div>
|
||
<div class="agent-sessions-body" id="cronRunsBody" style="max-height:400px;transition:max-height .4s"></div>
|
||
</div>
|
||
|
||
<!-- Tab Bar -->
|
||
<div class="tab-bar">
|
||
<button class="tab-btn active" data-tab="sessions">Sessions</button>
|
||
<button class="tab-btn" data-tab="ops">Cost</button>
|
||
<button class="tab-btn" data-tab="operations">Operations</button>
|
||
<button class="tab-btn" data-tab="tasks">Cron<span class="badge-count" id="taskCount">0</span></button>
|
||
<button class="tab-btn" data-tab="quality">Quality</button>
|
||
<button class="tab-btn" data-tab="audit">Audit</button>
|
||
<button class="tab-btn" data-tab="config">Config</button>
|
||
</div>
|
||
|
||
<!-- ═══ Sessions Panel ═══ -->
|
||
<div class="tab-panel active" id="panel-sessions">
|
||
<div id="sessionsAlerts" style="margin-bottom:12px"></div>
|
||
<div id="sessionsTable" class="sessions-table-wrap">
|
||
<div class="skeleton skeleton-card"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══ Cost Panel ═══ -->
|
||
<div class="tab-panel" id="panel-ops">
|
||
<div class="glass-card" style="padding:16px;margin-bottom:16px">
|
||
<div class="card-header">
|
||
<div>
|
||
<div class="card-title">Today's Usage (PST)</div>
|
||
<div class="card-sub" id="opsTotalSub">Loading…</div>
|
||
</div>
|
||
<div class="pill-row" id="opsTotalPills"></div>
|
||
</div>
|
||
<div class="ops-model-bar" id="opsModelBar"></div>
|
||
</div>
|
||
<div class="card-title" style="margin-bottom:12px">Channel Breakdown</div>
|
||
<div id="opsChannelList" class="ops-channel-list">
|
||
<div class="skeleton skeleton-card"></div>
|
||
<div class="skeleton skeleton-card"></div>
|
||
</div>
|
||
|
||
<div class="glass-card" style="padding:16px;margin-top:16px">
|
||
<div class="card-header">
|
||
<div>
|
||
<div class="card-title">All-Time Usage</div>
|
||
<div class="card-sub" id="alltimeSub">Loading…</div>
|
||
</div>
|
||
</div>
|
||
<div id="alltimeModels" class="ops-channel-list" style="margin-bottom:12px"></div>
|
||
<div class="card-title" style="font-size:.85rem;margin-bottom:8px">Daily Tokens (Last 14 Days)</div>
|
||
<canvas id="dailyChart" height="120" style="width:100%;border-radius:8px"></canvas>
|
||
<div class="ops-model-legend chart-legend" style="margin-top:6px"></div>
|
||
<div class="card-title" style="font-size:.85rem;margin:12px 0 8px">Daily Cost by Model</div>
|
||
<canvas id="dailyCostChart" height="120" style="width:100%;border-radius:8px"></canvas>
|
||
<div class="ops-model-legend chart-cost-legend" style="margin-top:6px"></div>
|
||
<div class="card-title" style="font-size:.85rem;margin:12px 0 8px">Cost Heatmap (Model × Day)</div>
|
||
<div id="costHeatmap" class="cost-heatmap"></div>
|
||
</div>
|
||
|
||
<!-- Provider Audit hidden — no useful billing API from Anthropic/Google yet -->
|
||
<div class="glass-card" style="padding:16px;margin-top:16px;display:none" id="auditCard">
|
||
<div class="card-title">🔍 Provider Audit (Official APIs)</div>
|
||
<div class="card-sub" style="margin-bottom:12px">Cross-reference with provider billing data</div>
|
||
<div id="auditContent"></div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- ═══ Operations Panel ═══ -->
|
||
<div class="tab-panel" id="panel-operations">
|
||
<div class="glass-card" style="padding:16px">
|
||
<div class="card-title" style="margin-bottom:4px">⚙️ Operations Management</div>
|
||
<div class="card-sub" style="margin-bottom:16px">Backup/restore, restart and safe OpenClaw update workflow</div>
|
||
<div class="ops-mgmt-grid">
|
||
<button class="ops-mgmt-btn" id="btnBackup" onclick="opsAction('backup')">
|
||
<span class="btn-icon">💾</span>
|
||
<span class="btn-label">Backup + Push Repo</span>
|
||
<span class="btn-badge" id="badgeBackup"></span>
|
||
</button>
|
||
<button class="ops-mgmt-btn" id="btnRestore" onclick="opsAction('restore')">
|
||
<span class="btn-icon">📂</span>
|
||
<span class="btn-label">Load from Backup</span>
|
||
<span class="btn-badge" id="badgeRestore"></span>
|
||
</button>
|
||
<button class="ops-mgmt-btn" id="btnUpdateOpenClaw" onclick="opsAction('updateOpenClaw')">
|
||
<span class="btn-icon">⬆️</span>
|
||
<span class="btn-label">Update OpenClaw</span>
|
||
<span class="btn-badge" id="badgeUpdateOpenClaw"></span>
|
||
</button>
|
||
<button class="ops-mgmt-btn" id="btnRestart" onclick="opsAction('restart')">
|
||
<span class="btn-icon">🔄</span>
|
||
<span class="btn-label">Restart OpenClaw</span>
|
||
<span class="btn-badge" id="badgeRestart"></span>
|
||
</button>
|
||
</div>
|
||
<div id="opsMgmtResult" style="margin-top:14px;display:none">
|
||
<div class="glass-card" style="padding:12px;background:rgba(0,0,0,0.3);border-color:var(--border)" id="opsMgmtResultInner"></div>
|
||
</div>
|
||
</div>
|
||
<div class="glass-card" style="padding:16px;margin-top:16px">
|
||
<div class="card-title" style="margin-bottom:4px">🐶 Watchdog</div>
|
||
<div class="card-sub" id="watchdogStatusText">Loading watchdog status…</div>
|
||
<div class="ops-ch-meta" id="watchdogStatusMeta" style="margin-top:8px"></div>
|
||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:10px">
|
||
<select id="watchdogWindowSelect" class="form-input" style="max-width:180px;padding:6px 10px;font-size:.75rem" onchange="setWatchdogWindow(this.value)">
|
||
<option value="5">Last 5 min</option>
|
||
<option value="10">Last 10 min</option>
|
||
<option value="15" selected>Last 15 min</option>
|
||
</select>
|
||
<label style="display:flex;align-items:center;gap:6px;font-size:.75rem;color:var(--text2);cursor:pointer">
|
||
<input type="checkbox" id="watchdogCriticalOnly" onchange="setWatchdogCriticalOnly(this.checked)">
|
||
Only critical events
|
||
</label>
|
||
</div>
|
||
<div id="watchdogTimeline" style="margin-top:10px"></div>
|
||
<div id="watchdogEventsList" class="ops-channel-list" style="margin-top:10px"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══ Cron Panel ═══ -->
|
||
<!-- ═══ Quality Panel ═══ -->
|
||
<div class="tab-panel" id="panel-quality">
|
||
<div id="qualityContent"><div class="skeleton skeleton-card"></div></div>
|
||
</div>
|
||
|
||
<!-- ═══ Audit Panel ═══ -->
|
||
<div class="tab-panel" id="panel-audit">
|
||
<div id="auditContent2"><div class="skeleton skeleton-card"></div></div>
|
||
</div>
|
||
|
||
<!-- ═══ Config Panel ═══ -->
|
||
<div class="tab-panel" id="panel-config">
|
||
<div id="configContent"><div class="skeleton skeleton-card"></div></div>
|
||
</div>
|
||
|
||
<!-- ═══ Cron Panel ═══ -->
|
||
<div class="tab-panel" id="panel-tasks">
|
||
<div class="ops-grid">
|
||
<div class="glass-card" style="padding:16px">
|
||
<div class="card-header">
|
||
<div>
|
||
<div class="card-title">Vision Ingestion</div>
|
||
<div class="card-sub" id="visionStatus">Notion source</div>
|
||
</div>
|
||
</div>
|
||
<div id="visionGrid" class="vision-grid">
|
||
<div class="skeleton skeleton-card" style="height:80px"></div>
|
||
<div class="skeleton skeleton-card" style="height:80px"></div>
|
||
<div class="skeleton skeleton-card" style="height:80px"></div>
|
||
<div class="skeleton skeleton-card" style="height:80px"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Cron Cost Analysis -->
|
||
<div class="glass-card" style="padding:16px;margin-top:16px">
|
||
<div class="card-title">💰 Cron 成本分析</div>
|
||
<div class="card-sub" id="cronCostSummary">Loading...</div>
|
||
<div id="cronCostContent" style="margin-top:12px"></div>
|
||
</div>
|
||
|
||
<!-- Fixed vs Variable Trend -->
|
||
<div class="glass-card" style="padding:16px;margin-top:12px">
|
||
<div class="card-title">📈 固定成本 vs 浮动成本趋势</div>
|
||
<div class="card-sub">Cron(固定)= 每日自动任务 · 交互(浮动)= 人工触发的对话</div>
|
||
<canvas id="cronTrendChart" height="160" style="width:100%;margin-top:8px"></canvas>
|
||
<div id="cronTrendLegend" style="margin-top:6px;font-size:.72rem;display:flex;gap:16px"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══ Documents Panel ═══ -->
|
||
<div class="tab-panel" id="panel-documents">
|
||
<div class="docs-layout">
|
||
<div class="glass-card" style="padding:12px">
|
||
<div class="file-sidebar-header">Workspace Files</div>
|
||
<div class="file-sidebar" id="fileSidebar"></div>
|
||
<div class="file-divider"></div>
|
||
<div class="file-sidebar-header">Skills</div>
|
||
<div style="padding:0 8px 8px">
|
||
<div class="view-toggle" id="viewToggle">
|
||
<button class="active" data-view="editor" onclick="setDocView('editor')">Editor</button>
|
||
<button data-view="skills" onclick="setDocView('skills')">Skills</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="editor-area" id="editorArea">
|
||
<div class="editor-toolbar">
|
||
<div class="editor-filename" id="editorFilename">Select a file<span>to begin editing</span></div>
|
||
<button class="edit-toggle-btn" id="editToggleBtn" onclick="toggleEditMode()" style="display:none">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||
<span id="editToggleLabel">Edit</span>
|
||
</button>
|
||
<button class="save-btn" id="saveBtn" onclick="saveFile()" disabled style="display:none">Save</button>
|
||
</div>
|
||
<div class="md-preview" id="mdPreview">
|
||
<div class="md-empty-hint">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||
<p>Select a file from the sidebar to view its contents</p>
|
||
</div>
|
||
</div>
|
||
<textarea class="editor-textarea" id="editorTextarea" placeholder="Select a file from the sidebar to view and edit its contents…" disabled style="display:none"></textarea>
|
||
</div>
|
||
<div id="skillsArea" style="display:none">
|
||
<input type="text" class="search-input" id="skillSearch" placeholder="Search skills…" style="width:100%;margin-bottom:12px">
|
||
<div class="skills-grid" id="skillsGrid">
|
||
<div class="skeleton skeleton-card" style="height:120px"></div>
|
||
<div class="skeleton skeleton-card" style="height:120px"></div>
|
||
<div class="skeleton skeleton-card" style="height:120px"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══ Connected APIs Panel ═══ -->
|
||
<div class="tab-panel" id="panel-apis">
|
||
<div class="apis-stats" id="apiStats"></div>
|
||
<input type="text" class="search-input" id="apiSearch" placeholder="Search APIs…" style="width:100%;max-width:360px;margin-bottom:16px">
|
||
<div class="apis-grid" id="apisGrid">
|
||
<div class="skeleton skeleton-card" style="height:180px"></div>
|
||
<div class="skeleton skeleton-card" style="height:180px"></div>
|
||
<div class="skeleton skeleton-card" style="height:180px"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══ Logs Panel ═══ -->
|
||
<div class="tab-panel" id="panel-logs">
|
||
<div class="logs-toolbar">
|
||
<div class="filter-group" id="logDateFilters">
|
||
<button class="filter-btn active" data-days="7">7 Days</button>
|
||
<button class="filter-btn" data-days="30">30 Days</button>
|
||
<button class="filter-btn" data-days="0">All</button>
|
||
</div>
|
||
<span class="spacer"></span>
|
||
<div class="toggle-switch">
|
||
<span>Auto-refresh</span>
|
||
<div class="toggle-track" id="autoRefreshToggle" onclick="toggleAutoRefresh()"></div>
|
||
</div>
|
||
</div>
|
||
<div class="timeline" id="logsTimeline">
|
||
<div class="skeleton skeleton-card"></div>
|
||
<div class="skeleton skeleton-card"></div>
|
||
<div class="skeleton skeleton-card"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Task Detail Modal -->
|
||
<div class="modal-overlay detail-modal" id="detailModal">
|
||
<div class="modal" style="position:relative">
|
||
<button class="modal-close" onclick="closeDetailModal()">✕</button>
|
||
<div class="detail-header">
|
||
<div class="detail-status-row" id="detailStatusRow"></div>
|
||
<div class="detail-title" id="detailTitle"></div>
|
||
<div class="detail-meta" id="detailMeta"></div>
|
||
</div>
|
||
<div class="detail-body" id="detailBody"></div>
|
||
<div class="detail-actions" id="detailActions"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Create Task Modal -->
|
||
<div class="modal-overlay" id="createModal">
|
||
<div class="modal" style="position:relative">
|
||
<button class="modal-close" onclick="closeCreateModal()">✕</button>
|
||
<h2>Create Task</h2>
|
||
<div class="form-group">
|
||
<label class="form-label">Title</label>
|
||
<input type="text" class="form-input" id="newTitle" placeholder="Task title…">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Description</label>
|
||
<textarea class="form-textarea" id="newDesc" placeholder="Short task description…" style="min-height:60px"></textarea>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">📝 Content <span style="font-weight:400;text-transform:none;letter-spacing:0;font-size:.72rem;color:var(--text2)">(Markdown supported — use headers, lists, code blocks, tables…)</span></label>
|
||
<textarea class="form-textarea" id="newContent" placeholder="# Detailed content here… Supports **bold**, *italic*, `code`, lists, tables, and more. ```js const example = 'code block'; ```" style="min-height:160px"></textarea>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||
<div class="form-group">
|
||
<label class="form-label">Priority</label>
|
||
<select class="form-select" id="newPriority">
|
||
<option value="medium">Medium</option>
|
||
<option value="high">High</option>
|
||
<option value="low">Low</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Assignee</label>
|
||
<select class="form-select" id="newAssignee">
|
||
<option value="main">Main Agent</option>
|
||
<option value="sub-agent">Sub-Agent</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Due Date</label>
|
||
<input type="date" class="form-input" id="newDueDate">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">📎 Attachments</label>
|
||
<div class="create-att-drop-zone" id="createAttDropZone">
|
||
<input type="file" id="createAttFileInput" multiple>
|
||
<div class="att-drop-zone-icon">📁</div>
|
||
<div class="att-drop-zone-text">Drop files here or click to upload</div>
|
||
<div class="att-drop-zone-hint">Images, PDFs, documents — up to 20MB each</div>
|
||
</div>
|
||
<div class="create-att-queue" id="createAttQueue" style="display:none"></div>
|
||
</div>
|
||
<div class="modal-actions">
|
||
<button class="action-btn" onclick="closeCreateModal()">Cancel</button>
|
||
<button class="action-btn primary" id="createTaskBtn" onclick="createTask()">Create Task</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Image Lightbox -->
|
||
<div class="lightbox-overlay" id="lightboxOverlay" onclick="closeLightbox()">
|
||
<button class="lightbox-close" onclick="closeLightbox()">✕</button>
|
||
<img class="lightbox-img" id="lightboxImg" onclick="event.stopPropagation()">
|
||
<div class="lightbox-info" onclick="event.stopPropagation()">
|
||
<span id="lightboxName"></span>
|
||
<span id="lightboxSize" style="font-family:var(--mono);font-size:.72rem"></span>
|
||
<a class="lightbox-download" id="lightboxDownload" target="_blank">⬇ Download</a>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Toast Container -->
|
||
<div class="toast-container" id="toastContainer"></div>
|
||
|
||
<script>
|
||
/* ═══════════════════════════════════════════════
|
||
Agent Dashboard — Vanilla JS
|
||
═══════════════════════════════════════════════ */
|
||
|
||
// ─── Auth ───
|
||
function getToken() {
|
||
const p = new URLSearchParams(location.search).get('token');
|
||
if (p) {
|
||
try {
|
||
const clean = `${location.origin}${location.pathname}${location.hash || ''}`;
|
||
history.replaceState(null, '', clean);
|
||
} catch {}
|
||
return p;
|
||
}
|
||
try { const s = JSON.parse(localStorage.getItem('openclaw.control.settings.v1') || '{}'); if (s.token) return s.token; } catch(e) {}
|
||
return localStorage.getItem('openclaw_token') || '';
|
||
}
|
||
const TOKEN = getToken();
|
||
const API = (location.port === '18789' || location.port === '18790')
|
||
? `${location.protocol}//${location.hostname}:18790`
|
||
: '';
|
||
|
||
async function apiFetch(path, opts = {}) {
|
||
const url = `${API}${path}`;
|
||
const authHeaders = TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {};
|
||
const res = await fetch(url, {
|
||
...opts,
|
||
credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/json', ...authHeaders, ...(opts.headers || {}) }
|
||
});
|
||
if (!res.ok) throw new Error(`API ${res.status}: ${res.statusText}`);
|
||
const ct = res.headers.get('content-type') || '';
|
||
if (ct.includes('json')) return res.json();
|
||
return res.text();
|
||
}
|
||
|
||
// ─── Connection Check ───
|
||
async function checkConnection() {
|
||
const dot = document.getElementById('statusDot');
|
||
const txt = document.getElementById('statusText');
|
||
try {
|
||
await apiFetch('/health');
|
||
dot.className = 'status-dot ok';
|
||
txt.textContent = 'Connected';
|
||
} catch(e) {
|
||
dot.className = 'status-dot err';
|
||
txt.textContent = 'Disconnected';
|
||
}
|
||
}
|
||
|
||
// ─── Tabs ───
|
||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
document.getElementById(`panel-${btn.dataset.tab}`).classList.add('active');
|
||
if (btn.dataset.tab === 'sessions') loadSessions();
|
||
if (btn.dataset.tab === 'tasks') { loadCronEnhanced(); loadTasks(true); loadCronCosts(); }
|
||
if (btn.dataset.tab === 'ops') { loadOpsChannels(); loadOpsAlltime(); loadOpsAudit(); }
|
||
if (btn.dataset.tab === 'operations') loadOperationsStatus();
|
||
if (btn.dataset.tab === 'quality') loadQuality();
|
||
if (btn.dataset.tab === 'audit') loadAudit();
|
||
if (btn.dataset.tab === 'config') loadConfig();
|
||
});
|
||
});
|
||
|
||
let _watchdogCache = null;
|
||
let _watchdogWindowMins = 15;
|
||
let _watchdogCriticalOnly = false;
|
||
|
||
function focusOperationsTab() {
|
||
const btn = document.querySelector('.tab-btn[data-tab="operations"]');
|
||
if (btn) btn.click();
|
||
}
|
||
|
||
function setWatchdogWindow(v) {
|
||
const n = Number(v);
|
||
if (Number.isFinite(n)) _watchdogWindowMins = Math.min(Math.max(n, 5), 15);
|
||
pollWatchdogStatus();
|
||
}
|
||
|
||
function setWatchdogCriticalOnly(checked) {
|
||
_watchdogCriticalOnly = !!checked;
|
||
pollWatchdogStatus();
|
||
}
|
||
|
||
async function pollWatchdogStatus() {
|
||
try {
|
||
const data = await apiFetch(`/ops/watchdog?limit=12&windowMinutes=${_watchdogWindowMins}&criticalOnly=${_watchdogCriticalOnly ? '1' : '0'}`);
|
||
_watchdogCache = data;
|
||
renderWatchdogStatus(data);
|
||
} catch (e) {
|
||
renderWatchdogStatus({
|
||
effectiveStatus: 'unknown',
|
||
runtime: { running: false, checkedAt: new Date().toISOString() },
|
||
watchdog: null,
|
||
recentEvents: [],
|
||
timeline: { points: [], windowMinutes: _watchdogWindowMins, stepSeconds: 30 },
|
||
error: e.message,
|
||
});
|
||
}
|
||
}
|
||
|
||
function renderWatchdogTimeline(timeline) {
|
||
const el = document.getElementById('watchdogTimeline');
|
||
if (!el) return;
|
||
const points = Array.isArray(timeline?.points) ? timeline.points : [];
|
||
const windowMinutes = timeline?.windowMinutes || _watchdogWindowMins;
|
||
if (!points.length) {
|
||
el.innerHTML = '<div class="ops-ch-meta">No timeline data.</div>';
|
||
return;
|
||
}
|
||
const bars = points.map(p => {
|
||
const color = p.status === 'down' ? 'var(--red)' : (p.status === 'healthy' ? 'var(--green)' : 'var(--yellow)');
|
||
const ts = new Date(p.ts || Date.now()).toLocaleTimeString();
|
||
return `<div title="${ts} · ${p.status}" style="height:14px;flex:1;background:${color};opacity:.9;border-radius:2px"></div>`;
|
||
}).join('');
|
||
el.innerHTML = `
|
||
<div class="ops-ch-meta" style="margin-bottom:6px">Status timeline (${windowMinutes} min): green=healthy · red=down</div>
|
||
<div style="display:flex;gap:2px;align-items:center">${bars}</div>
|
||
`;
|
||
}
|
||
|
||
function renderWatchdogStatus(data) {
|
||
const statusEl = document.getElementById('watchdogStatusText');
|
||
const metaEl = document.getElementById('watchdogStatusMeta');
|
||
const eventsEl = document.getElementById('watchdogEventsList');
|
||
const globalAlert = document.getElementById('watchdogGlobalAlert');
|
||
const globalText = document.getElementById('watchdogGlobalText');
|
||
if (!statusEl || !metaEl || !eventsEl || !globalAlert || !globalText) return;
|
||
|
||
const effective = data?.effectiveStatus || 'unknown';
|
||
const runtimeRunning = !!data?.runtime?.running;
|
||
const wd = data?.watchdog || {};
|
||
const cfg = data?.configGuard || {};
|
||
const failures = wd?.consecutive_failures ?? '—';
|
||
const reason = wd?.last_reason || 'unknown';
|
||
const updatedAt = wd?.updated_at || data?.runtime?.checkedAt || '';
|
||
const configGuardStatus = cfg?.status || 'unknown';
|
||
const configDriftDetected = !!cfg?.driftDetected;
|
||
const suspectedConfigDrift = !!cfg?.suspectedConfigDrift;
|
||
const recent = Array.isArray(data?.recentEvents) ? data.recentEvents : [];
|
||
const timeline = data?.timeline || { points: [] };
|
||
|
||
if (effective === 'down') {
|
||
const driftMsg = suspectedConfigDrift
|
||
? ' Probable root cause: config drift (`openclaw.json` vs `.good`).'
|
||
: '';
|
||
statusEl.innerHTML = `<span style="color:var(--red)">OpenClaw is DOWN</span> — watchdog detected runtime outage.${driftMsg}`;
|
||
globalAlert.style.display = 'block';
|
||
globalText.textContent = `OpenClaw down: ${reason} · failures=${failures}${suspectedConfigDrift ? ' · config_drift=YES' : ''}`;
|
||
} else if (effective === 'healthy') {
|
||
statusEl.innerHTML = '<span style="color:var(--green)">OpenClaw is healthy</span> — watchdog is monitoring.';
|
||
globalAlert.style.display = 'none';
|
||
} else {
|
||
const driftMsg = configDriftDetected
|
||
? ' Config drift is present; investigate before it escalates.'
|
||
: '';
|
||
statusEl.innerHTML = `<span style="color:var(--yellow)">Watchdog degraded / unknown</span> — check events below.${driftMsg}`;
|
||
globalAlert.style.display = 'block';
|
||
globalText.textContent = `Watchdog warning: ${reason} · failures=${failures}${configDriftDetected ? ' · config_drift=YES' : ''}`;
|
||
}
|
||
|
||
metaEl.textContent = `runtime=${runtimeRunning ? 'running' : 'stopped'} · watchdog=${wd.status || 'unknown'} · failures=${failures} · last_reason=${reason} · config_guard=${configGuardStatus} · config_drift=${configDriftDetected ? 'yes' : 'no'} · updated=${updatedAt ? new Date(updatedAt).toLocaleString() : '—'} · filter=${_watchdogCriticalOnly ? 'critical only' : 'all'}`;
|
||
renderWatchdogTimeline(timeline);
|
||
|
||
if (!recent.length) {
|
||
eventsEl.innerHTML = `<div class="ops-ch-meta">No watchdog events for selected filter in last ${_watchdogWindowMins} minutes.</div>`;
|
||
return;
|
||
}
|
||
eventsEl.innerHTML = recent.map(ev => {
|
||
const sev = ev.severity || 'info';
|
||
const color = sev === 'critical' ? 'var(--red)' : (sev === 'warning' ? 'var(--yellow)' : 'var(--green)');
|
||
const detailsHtml = escHtml(ev.details || '').replace(/\n/g, '<br>');
|
||
return `<div class="ops-channel-card" style="padding:8px 10px">
|
||
<div class="ops-ch-left">
|
||
<div class="ops-ch-name" style="font-size:.8rem;color:${color}">${escHtml(ev.event || 'event')} · ${escHtml(ev.reason || 'n/a')}</div>
|
||
<div class="ops-ch-meta">${detailsHtml}</div>
|
||
</div>
|
||
<div class="ops-ch-right"><div class="ops-ch-cost">${new Date(ev.time || Date.now()).toLocaleTimeString()}</div></div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function loadOperationsStatus() {
|
||
if (_watchdogCache) renderWatchdogStatus(_watchdogCache);
|
||
pollWatchdogStatus();
|
||
}
|
||
|
||
function refreshCurrentTab() {
|
||
const active = document.querySelector('.tab-btn.active');
|
||
if (active) active.click();
|
||
checkConnection();
|
||
loadAgentMonitor();
|
||
}
|
||
|
||
// ─── Toast ───
|
||
function toast(message, type = 'info') {
|
||
const c = document.getElementById('toastContainer');
|
||
const icons = { success: '✓', error: '✕', info: 'ℹ' };
|
||
const t = document.createElement('div');
|
||
t.className = `toast ${type}`;
|
||
t.innerHTML = `<span class="toast-icon">${icons[type] || icons.info}</span><span>${message}</span>`;
|
||
c.appendChild(t);
|
||
setTimeout(() => { if (t.parentNode) t.remove(); }, 3500);
|
||
}
|
||
|
||
// ─── Simple Markdown ───
|
||
function renderMarkdown(text) {
|
||
if (!text) return '';
|
||
let html = text
|
||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>')
|
||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||
.replace(/^### (.+)$/gm, '<h4>$1</h4>')
|
||
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
|
||
.replace(/^# (.+)$/gm, '<h2>$1</h2>')
|
||
.replace(/^- (.+)$/gm, '<li>$1</li>')
|
||
.replace(/\n\n/g, '</p><p>')
|
||
.replace(/\n/g, '<br>');
|
||
return `<p>${html}</p>`;
|
||
}
|
||
|
||
// ─── Full Markdown (using marked.js library) ───
|
||
function renderFullMarkdown(text) {
|
||
if (!text) return '';
|
||
try {
|
||
if (typeof marked !== 'undefined' && marked.parse) {
|
||
return marked.parse(text, { breaks: true, gfm: true });
|
||
}
|
||
} catch(e) { /* fallback */ }
|
||
return renderMarkdown(text);
|
||
}
|
||
|
||
// ═══ TASKS ═══
|
||
let allTasks = [];
|
||
let currentFilter = 'all';
|
||
let expandedTaskId = null;
|
||
let _tasksHash = '';
|
||
let _livePollingId = null;
|
||
const LIVE_POLL_MS = 15000; // was 3000, reduced to avoid excessive polling // poll every 3 seconds
|
||
|
||
function _hashTasks(tasks) {
|
||
// Fast hash: JSON of id+status+updatedAt+notes.length for each task
|
||
return tasks.map(t => `${t.id}|${t.status}|${t.updatedAt}|${(t.notes||[]).length}`).join(';');
|
||
}
|
||
|
||
async function loadTasks(force) {
|
||
const list = document.getElementById('taskList');
|
||
try {
|
||
const data = await apiFetch('/tasks');
|
||
const tasks = Array.isArray(data) ? data : (data.tasks || []);
|
||
const newHash = _hashTasks(tasks);
|
||
// Skip re-render if nothing changed (unless forced)
|
||
if (!force && newHash === _tasksHash) return;
|
||
_tasksHash = newHash;
|
||
allTasks = tasks;
|
||
document.getElementById('taskCount').textContent = allTasks.length;
|
||
if (taskView === 'kanban') { setTaskView('kanban'); } else { setTaskView('list'); }
|
||
// If detail modal is open and user isn't editing content, refresh it
|
||
if (detailTaskId && !isContentEditing) {
|
||
const updated = allTasks.find(t => t.id === detailTaskId);
|
||
if (updated) openDetailModal(detailTaskId);
|
||
}
|
||
// Flash the live indicator on data change
|
||
_flashLiveIndicator();
|
||
} catch(e) {
|
||
list.innerHTML = `<div class="empty-state"><svg viewBox="0 0 80 80"><circle cx="40" cy="40" r="36" fill="none" stroke="currentColor" stroke-width="2"/><path d="M28 28l24 24M52 28L28 52" stroke="currentColor" stroke-width="2"/></svg><h3>Unable to Load Tasks</h3><p>${e.message}</p><button class="action-btn primary" onclick="loadTasks(true)" style="margin:0 auto">Retry</button></div>`;
|
||
}
|
||
}
|
||
|
||
function _flashLiveIndicator() {
|
||
const el = document.getElementById('liveIndicator');
|
||
if (!el) return;
|
||
el.classList.add('flash');
|
||
setTimeout(() => el.classList.remove('flash'), 600);
|
||
}
|
||
|
||
function startLivePolling() {
|
||
if (_livePollingId) return;
|
||
_livePollingId = setInterval(() => loadTasks(false), LIVE_POLL_MS);
|
||
const el = document.getElementById('liveIndicator');
|
||
if (el) el.classList.add('active');
|
||
}
|
||
|
||
function stopLivePolling() {
|
||
if (_livePollingId) { clearInterval(_livePollingId); _livePollingId = null; }
|
||
const el = document.getElementById('liveIndicator');
|
||
if (el) el.classList.remove('active');
|
||
}
|
||
|
||
function toggleLivePolling() {
|
||
if (_livePollingId) { stopLivePolling(); toast('Live updates paused', 'info'); }
|
||
else { startLivePolling(); toast('Live updates enabled', 'info'); }
|
||
}
|
||
|
||
function renderTasks() {
|
||
const list = document.getElementById('taskList');
|
||
const search = (document.getElementById('taskSearch').value || '').toLowerCase();
|
||
let filtered = allTasks;
|
||
if (currentFilter !== 'all') filtered = filtered.filter(t => (t.status || 'new') === currentFilter);
|
||
if (search) filtered = filtered.filter(t => (t.title || '').toLowerCase().includes(search) || (t.description || '').toLowerCase().includes(search));
|
||
|
||
if (filtered.length === 0) {
|
||
list.innerHTML = `<div class="empty-state">
|
||
<svg viewBox="0 0 80 80"><rect x="16" y="12" width="48" height="56" rx="6" fill="none" stroke="currentColor" stroke-width="1.5"/><line x1="28" y1="28" x2="52" y2="28" stroke="currentColor" stroke-width="1.5"/><line x1="28" y1="38" x2="48" y2="38" stroke="currentColor" stroke-width="1.5"/><line x1="28" y1="48" x2="44" y2="48" stroke="currentColor" stroke-width="1.5"/><circle cx="24" cy="28" r="2" fill="currentColor"/><circle cx="24" cy="38" r="2" fill="currentColor"/><circle cx="24" cy="48" r="2" fill="currentColor"/></svg>
|
||
<h3>${currentFilter !== 'all' ? 'No matching tasks' : 'No tasks yet'}</h3>
|
||
<p>${currentFilter !== 'all' ? 'Try a different filter or create a new task.' : 'Create your first task to get started with agent task management.'}</p>
|
||
<button class="create-btn" onclick="openCreateModal()" style="margin:0 auto">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||
New Task
|
||
</button>
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
list.innerHTML = filtered.map((task, i) => {
|
||
const status = task.status || 'new';
|
||
const priority = task.priority || 'medium';
|
||
const isExpanded = expandedTaskId === task.id;
|
||
const notes = task.notes || [];
|
||
const date = task.createdAt ? new Date(task.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '';
|
||
const dueDate = task.dueDate ? new Date(task.dueDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '';
|
||
return `<div class="glass-card task-card status-${status} ${isExpanded ? 'expanded' : ''}" onclick="toggleTask('${task.id}')" style="animation:cardIn .4s ease backwards;animation-delay:${i * 0.05}s">
|
||
<div class="task-header">
|
||
${selectionMode ? `<div class="task-checkbox ${selectedTaskIds.has(task.id) ? 'checked' : ''}" data-task-id="${task.id}" onclick="toggleTaskSelection('${task.id}', event)"></div>` : ''}
|
||
<span class="task-title">${escHtml(task.title || 'Untitled')}</span>
|
||
<span class="badge badge-${status}">${statusLabel(status)}</span>
|
||
<span class="badge badge-priority ${priority}">${priority}</span>
|
||
</div>
|
||
<div class="task-meta">
|
||
${task.assignee ? `<span>👤 ${escHtml(task.assignee)}</span>` : ''}
|
||
${date ? `<span>📅 ${date}</span>` : ''}
|
||
${dueDate ? `<span>⏰ Due ${dueDate}</span>` : ''}
|
||
${notes.length ? `<span>📝 ${notes.length} note${notes.length > 1 ? 's' : ''}</span>` : ''}
|
||
${task.content ? '<span>📄 Content</span>' : ''}
|
||
</div>
|
||
<div class="task-body" onclick="event.stopPropagation()">
|
||
${task.description ? `<div class="task-description">${renderMarkdown(task.description)}</div>` : ''}
|
||
${task.content ? `<div class="task-content-preview" onclick="event.stopPropagation();this.classList.toggle('expanded')">
|
||
<div class="task-content-label"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg> Content</div>
|
||
<div class="task-content-md">${renderFullMarkdown(task.content)}</div>
|
||
</div>` : ''}
|
||
<div class="task-actions">
|
||
<button class="spawn-btn" onclick="spawnSingleTask('${task.id}')">⚡ Spawn</button>
|
||
${status !== 'in-progress' ? `<button class="action-btn primary" onclick="updateTaskStatus('${task.id}','in-progress')">▶ In Progress</button>` : ''}
|
||
${status !== 'done' ? `<button class="action-btn" onclick="updateTaskStatus('${task.id}','done')" style="border-color:rgba(45,212,160,0.3);color:var(--green)">✓ Done</button>` : ''}
|
||
${status !== 'failed' ? `<button class="action-btn danger" onclick="updateTaskStatus('${task.id}','failed')">✕ Failed</button>` : ''}
|
||
${status !== 'new' ? `<button class="action-btn" onclick="updateTaskStatus('${task.id}','new')">↩ Reset</button>` : ''}
|
||
</div>
|
||
<div class="notes-section">
|
||
<div class="notes-title">Notes</div>
|
||
<div class="notes-list">
|
||
${notes.length === 0 ? '<div style="font-size:.8rem;color:var(--text2);padding:4px 0 4px 16px;border-left:2px solid var(--border)">No notes yet</div>' : notes.map(n => `
|
||
<div class="note-item">
|
||
<span class="note-time">${n.createdAt ? new Date(n.createdAt).toLocaleString('en-US', { month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' }) : ''}</span>
|
||
<span class="note-text">${escHtml(n.text || n.content || '')}</span>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
<div class="add-note-row">
|
||
<input type="text" class="note-input" id="note-${task.id}" placeholder="Add a note…" onkeydown="if(event.key==='Enter')addNote('${task.id}')">
|
||
<button class="action-btn" onclick="addNote('${task.id}')">Add</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function statusLabel(s) {
|
||
return { 'new': 'New', 'in-progress': 'In Progress', 'done': 'Done', 'failed': 'Failed' }[s] || s;
|
||
}
|
||
|
||
function escHtml(s) {
|
||
const d = document.createElement('div');
|
||
d.textContent = s;
|
||
return d.innerHTML;
|
||
}
|
||
|
||
function toggleTask(id) {
|
||
openDetailModal(id);
|
||
}
|
||
|
||
async function updateTaskStatus(id, status) {
|
||
try {
|
||
await apiFetch(`/tasks/${id}`, { method: 'PATCH', body: JSON.stringify({ status }) });
|
||
toast(`Task updated to ${statusLabel(status)}`, 'success');
|
||
loadTasks(true);
|
||
} catch(e) { toast(`Failed: ${e.message}`, 'error'); }
|
||
}
|
||
|
||
async function addNote(taskId) {
|
||
const input = document.getElementById(`note-${taskId}`);
|
||
const text = input?.value?.trim();
|
||
if (!text) return;
|
||
try {
|
||
await apiFetch(`/tasks/${taskId}/notes`, { method: 'POST', body: JSON.stringify({ text }) });
|
||
input.value = '';
|
||
toast('Note added', 'success');
|
||
loadTasks(true);
|
||
} catch(e) { toast(`Failed: ${e.message}`, 'error'); }
|
||
}
|
||
|
||
// Filters (guarded for Ops view)
|
||
const _statusFilters = document.getElementById('statusFilters');
|
||
if (_statusFilters) {
|
||
_statusFilters.addEventListener('click', e => {
|
||
const btn = e.target.closest('.filter-btn');
|
||
if (!btn) return;
|
||
document.querySelectorAll('#statusFilters .filter-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
currentFilter = btn.dataset.filter;
|
||
renderTasks();
|
||
});
|
||
}
|
||
|
||
const _taskSearch = document.getElementById('taskSearch');
|
||
if (_taskSearch) {
|
||
_taskSearch.addEventListener('input', () => {
|
||
if (taskView === 'kanban') renderKanban();
|
||
else renderTasks();
|
||
});
|
||
}
|
||
|
||
// Create Modal — Pending Attachments
|
||
let pendingCreateFiles = [];
|
||
|
||
function openCreateModal() {
|
||
document.getElementById('createModal').classList.add('show');
|
||
document.getElementById('newTitle').focus();
|
||
setupCreateDropZone();
|
||
}
|
||
|
||
function closeCreateModal() {
|
||
document.getElementById('createModal').classList.remove('show');
|
||
['newTitle', 'newDesc', 'newContent', 'newDueDate'].forEach(id => document.getElementById(id).value = '');
|
||
document.getElementById('newPriority').value = 'medium';
|
||
document.getElementById('newAssignee').value = 'main';
|
||
pendingCreateFiles = [];
|
||
renderCreateAttQueue();
|
||
}
|
||
|
||
function setupCreateDropZone() {
|
||
const zone = document.getElementById('createAttDropZone');
|
||
const fileInput = document.getElementById('createAttFileInput');
|
||
if (!zone || !fileInput) return;
|
||
|
||
zone.ondragover = (e) => { e.preventDefault(); zone.classList.add('drag-over'); };
|
||
zone.ondragleave = () => zone.classList.remove('drag-over');
|
||
zone.ondrop = (e) => {
|
||
e.preventDefault();
|
||
zone.classList.remove('drag-over');
|
||
for (let i = 0; i < e.dataTransfer.files.length; i++) {
|
||
addPendingFile(e.dataTransfer.files[i]);
|
||
}
|
||
};
|
||
fileInput.onchange = () => {
|
||
for (let i = 0; i < fileInput.files.length; i++) {
|
||
addPendingFile(fileInput.files[i]);
|
||
}
|
||
fileInput.value = '';
|
||
};
|
||
}
|
||
|
||
function addPendingFile(file) {
|
||
if (file.size > 20 * 1024 * 1024) {
|
||
toast(`${file.name} is too large (max 20MB)`, 'error');
|
||
return;
|
||
}
|
||
// Prevent duplicates by name+size
|
||
if (pendingCreateFiles.some(f => f.name === file.name && f.size === file.size)) return;
|
||
pendingCreateFiles.push(file);
|
||
renderCreateAttQueue();
|
||
toast(`📎 ${file.name} queued`, 'success');
|
||
}
|
||
|
||
function removePendingFile(index) {
|
||
pendingCreateFiles.splice(index, 1);
|
||
renderCreateAttQueue();
|
||
}
|
||
|
||
function renderCreateAttQueue() {
|
||
const container = document.getElementById('createAttQueue');
|
||
if (!container) return;
|
||
if (pendingCreateFiles.length === 0) {
|
||
container.style.display = 'none';
|
||
container.innerHTML = '';
|
||
return;
|
||
}
|
||
container.style.display = 'flex';
|
||
container.innerHTML = pendingCreateFiles.map((file, i) => {
|
||
const ext = '.' + (file.name.split('.').pop() || '').toLowerCase();
|
||
const isImage = ['.png','.jpg','.jpeg','.gif','.webp','.svg','.bmp'].includes(ext);
|
||
const icon = isImage ? '🖼️' : (typeof getFileIcon === 'function' ? getFileIcon(ext) : '📎');
|
||
const thumbId = 'cthumb-' + i;
|
||
if (isImage) {
|
||
// Generate thumbnail async
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
const el = document.getElementById(thumbId);
|
||
if (el) { el.src = reader.result; el.style.display = 'block'; }
|
||
const iconEl = document.getElementById(thumbId + '-icon');
|
||
if (iconEl) iconEl.style.display = 'none';
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
return `<div class="create-att-item">
|
||
<span class="cai-icon" id="${thumbId}-icon">${icon}</span>
|
||
<img class="cai-thumb" id="${thumbId}" style="display:none" alt="">
|
||
<span class="cai-name">${escHtml(file.name)}</span>
|
||
<span class="cai-size">${formatSize(file.size)}</span>
|
||
<button class="cai-remove" onclick="removePendingFile(${i})" title="Remove">✕</button>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
async function createTask() {
|
||
const title = document.getElementById('newTitle').value.trim();
|
||
if (!title) { toast('Title is required', 'error'); return; }
|
||
|
||
const btn = document.getElementById('createTaskBtn');
|
||
const hasFiles = pendingCreateFiles.length > 0;
|
||
btn.disabled = true;
|
||
btn.textContent = hasFiles ? 'Creating & Uploading…' : 'Creating…';
|
||
|
||
const payload = {
|
||
title,
|
||
description: document.getElementById('newDesc').value.trim(),
|
||
content: document.getElementById('newContent').value.trim(),
|
||
priority: document.getElementById('newPriority').value,
|
||
assignee: document.getElementById('newAssignee').value,
|
||
status: 'new'
|
||
};
|
||
const due = document.getElementById('newDueDate').value;
|
||
if (due) payload.dueDate = due;
|
||
|
||
try {
|
||
const task = await apiFetch('/tasks', { method: 'POST', body: JSON.stringify(payload) });
|
||
|
||
// Upload pending attachments
|
||
if (hasFiles) {
|
||
let uploaded = 0;
|
||
for (const file of pendingCreateFiles) {
|
||
try {
|
||
btn.textContent = `Uploading ${++uploaded}/${pendingCreateFiles.length}…`;
|
||
const base64 = await fileToBase64(file);
|
||
await apiFetch(`/tasks/${task.id}/attachments`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ filename: file.name, data: base64, source: 'user' }),
|
||
});
|
||
} catch (e) {
|
||
toast(`Failed to upload ${file.name}: ${e.message}`, 'error');
|
||
}
|
||
}
|
||
toast(`Task created with ${uploaded} attachment${uploaded !== 1 ? 's' : ''}!`, 'success');
|
||
} else {
|
||
toast('Task created!', 'success');
|
||
}
|
||
|
||
closeCreateModal();
|
||
loadTasks(true);
|
||
} catch(e) {
|
||
toast(`Failed: ${e.message}`, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = 'Create Task';
|
||
}
|
||
}
|
||
|
||
// Close modal on overlay click
|
||
document.getElementById('createModal').addEventListener('click', e => {
|
||
if (e.target === e.currentTarget) closeCreateModal();
|
||
});
|
||
|
||
// ═══ KANBAN VIEW ═══
|
||
let taskView = localStorage.getItem('taskView') || 'list';
|
||
|
||
function setTaskView(view) {
|
||
taskView = view;
|
||
localStorage.setItem('taskView', view);
|
||
document.querySelectorAll('#taskViewToggle button').forEach(b => b.classList.toggle('active', b.dataset.view === view));
|
||
const listEl = document.getElementById('taskList');
|
||
const kanbanEl = document.getElementById('kanbanBoard');
|
||
const filtersEl = document.getElementById('statusFilters');
|
||
if (view === 'kanban') {
|
||
listEl.style.display = 'none';
|
||
kanbanEl.style.display = '';
|
||
filtersEl.style.display = 'none';
|
||
renderKanban();
|
||
} else {
|
||
listEl.style.display = '';
|
||
kanbanEl.style.display = 'none';
|
||
filtersEl.style.display = '';
|
||
renderTasks();
|
||
}
|
||
}
|
||
|
||
const KANBAN_COLUMNS = [
|
||
{ status: 'new', label: 'New' },
|
||
{ status: 'in-progress', label: 'In Progress' },
|
||
{ status: 'done', label: 'Done' },
|
||
{ status: 'failed', label: 'Failed' }
|
||
];
|
||
|
||
function renderKanban() {
|
||
const board = document.getElementById('kanbanBoard');
|
||
const search = (document.getElementById('taskSearch').value || '').toLowerCase();
|
||
let filtered = allTasks;
|
||
if (search) filtered = filtered.filter(t => (t.title || '').toLowerCase().includes(search) || (t.description || '').toLowerCase().includes(search));
|
||
|
||
board.innerHTML = KANBAN_COLUMNS.map(col => {
|
||
const colTasks = filtered.filter(t => (t.status || 'new') === col.status);
|
||
return `<div class="kanban-column" data-status="${col.status}"
|
||
ondragover="kanbanDragOver(event)" ondragleave="kanbanDragLeave(event)" ondrop="kanbanDrop(event)">
|
||
<div class="kanban-col-header">
|
||
<div class="kanban-col-title">
|
||
<span class="col-dot ${col.status}"></span>
|
||
${col.label}
|
||
</div>
|
||
<span class="kanban-col-count">${colTasks.length}</span>
|
||
</div>
|
||
<div class="kanban-col-body${colTasks.length === 0 ? ' empty-drop' : ''}">
|
||
${colTasks.length === 0
|
||
? '<div class="drop-hint">Drop tasks here</div>'
|
||
: colTasks.map(task => renderKanbanCard(task)).join('')}
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
// Add mobile horizontal scroll class
|
||
if (window.innerWidth <= 768) board.classList.add('horizontal-scroll');
|
||
else board.classList.remove('horizontal-scroll');
|
||
}
|
||
|
||
function renderKanbanCard(task) {
|
||
const status = task.status || 'new';
|
||
const priority = task.priority || 'medium';
|
||
return `<div class="kanban-card status-${status}" draggable="true"
|
||
data-task-id="${task.id}"
|
||
ondragstart="kanbanDragStart(event)" ondragend="kanbanDragEnd(event)"
|
||
onclick="kanbanCardClick('${task.id}')">
|
||
<div class="kanban-card-title">${escHtml(task.title || 'Untitled')}</div>
|
||
<div class="kanban-card-footer">
|
||
<span class="badge badge-priority ${priority}">${priority}</span>
|
||
${task.notes && task.notes.length ? `<span style="font-size:.68rem;color:var(--text2)">📝${task.notes.length}</span>` : ''}
|
||
${task.assignee ? `<span class="kanban-card-assignee">👤 ${escHtml(task.assignee)}</span>` : ''}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// ─── Drag & Drop ───
|
||
let draggedTaskId = null;
|
||
|
||
function kanbanDragStart(e) {
|
||
draggedTaskId = e.target.dataset.taskId;
|
||
e.target.classList.add('dragging');
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
e.dataTransfer.setData('text/plain', draggedTaskId);
|
||
}
|
||
|
||
function kanbanDragEnd(e) {
|
||
e.target.classList.remove('dragging');
|
||
draggedTaskId = null;
|
||
document.querySelectorAll('.kanban-column').forEach(c => c.classList.remove('drag-over'));
|
||
}
|
||
|
||
function kanbanDragOver(e) {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = 'move';
|
||
const col = e.target.closest('.kanban-column');
|
||
if (col) col.classList.add('drag-over');
|
||
}
|
||
|
||
function kanbanDragLeave(e) {
|
||
const col = e.target.closest('.kanban-column');
|
||
if (col && !col.contains(e.relatedTarget)) col.classList.remove('drag-over');
|
||
}
|
||
|
||
async function kanbanDrop(e) {
|
||
e.preventDefault();
|
||
const col = e.target.closest('.kanban-column');
|
||
if (!col) return;
|
||
col.classList.remove('drag-over');
|
||
const taskId = e.dataTransfer.getData('text/plain') || draggedTaskId;
|
||
const newStatus = col.dataset.status;
|
||
if (!taskId || !newStatus) return;
|
||
const task = allTasks.find(t => t.id === taskId);
|
||
if (!task || (task.status || 'new') === newStatus) return;
|
||
try {
|
||
await apiFetch(`/tasks/${taskId}`, { method: 'PATCH', body: JSON.stringify({ status: newStatus }) });
|
||
task.status = newStatus;
|
||
toast(`Task moved to ${statusLabel(newStatus)}`, 'success');
|
||
renderKanban();
|
||
} catch(err) {
|
||
toast(`Failed: ${err.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
function kanbanCardClick(taskId) {
|
||
openDetailModal(taskId);
|
||
}
|
||
|
||
// ═══ TASK DETAIL MODAL ═══
|
||
let detailTaskId = null;
|
||
|
||
function openDetailModal(taskId) {
|
||
const task = allTasks.find(t => t.id === taskId);
|
||
if (!task) return;
|
||
detailTaskId = taskId;
|
||
|
||
const status = task.status || 'new';
|
||
const priority = task.priority || 'medium';
|
||
const created = task.createdAt ? new Date(task.createdAt) : null;
|
||
const updated = task.updatedAt ? new Date(task.updatedAt) : null;
|
||
const due = task.dueDate ? new Date(task.dueDate) : null;
|
||
const notes = task.notes || [];
|
||
|
||
// Separate agent output notes from status/regular notes
|
||
const statusNotes = [];
|
||
const outputNotes = [];
|
||
const regularNotes = [];
|
||
notes.forEach(n => {
|
||
const txt = n.text || n.content || '';
|
||
if (txt.startsWith('Status changed')) statusNotes.push(n);
|
||
else if (txt.length > 150) outputNotes.push(n);
|
||
else regularNotes.push(n);
|
||
});
|
||
|
||
// Header
|
||
document.getElementById('detailStatusRow').innerHTML = `
|
||
<span class="badge badge-${status}">${statusLabel(status)}</span>
|
||
<span class="badge badge-priority ${priority}">${priority}</span>
|
||
${task.source ? `<span class="badge" style="background:rgba(139,148,158,0.1);color:var(--text2);border:1px solid var(--border)">${escHtml(task.source)}</span>` : ''}
|
||
`;
|
||
document.getElementById('detailTitle').textContent = task.title || 'Untitled';
|
||
document.getElementById('detailMeta').innerHTML = `
|
||
${task.assignee ? `<span>👤 ${escHtml(task.assignee)}</span>` : ''}
|
||
${created ? `<span>📅 Created ${created.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}</span>` : ''}
|
||
${updated ? `<span>🔄 Updated ${timeAgo(updated)}</span>` : ''}
|
||
${due ? `<span>⏰ Due ${due.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}</span>` : ''}
|
||
`;
|
||
|
||
// Body
|
||
let bodyHtml = '';
|
||
|
||
// Description section
|
||
if (task.description) {
|
||
bodyHtml += `
|
||
<div class="detail-section">
|
||
<div class="detail-section-title" onclick="this.classList.toggle('collapsed')">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
|
||
Description
|
||
</div>
|
||
<div class="detail-section-content">
|
||
<div class="detail-description">${renderMarkdown(task.description)}</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// Content section (rich markdown field)
|
||
bodyHtml += `
|
||
<div class="detail-content-section">
|
||
<div class="detail-content-area" id="detailContentArea">
|
||
<div class="detail-content-header">
|
||
<span class="content-label">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||
Content <span style="font-weight:400;font-size:.68rem;color:var(--text2);letter-spacing:0;text-transform:none;margin-left:4px">Markdown</span>
|
||
</span>
|
||
<div class="detail-content-actions">
|
||
<button class="content-edit-btn" id="contentEditBtn" onclick="toggleContentEdit()">
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-1px;margin-right:3px"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||
Edit
|
||
</button>
|
||
<button class="content-save-btn" id="contentSaveBtn" onclick="saveTaskContent()" style="display:none">Save</button>
|
||
</div>
|
||
</div>
|
||
<div class="detail-content-md" id="detailContentMd">
|
||
${task.content ? renderFullMarkdown(task.content) : `<div class="detail-content-empty">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||
No content yet — click Edit to add markdown content
|
||
</div>`}
|
||
</div>
|
||
<textarea class="detail-content-textarea" id="detailContentTextarea" placeholder="# Write your content here… Supports **bold**, *italic*, \`code\`, lists, tables, and more." style="display:none">${escHtml(task.content || '')}</textarea>
|
||
</div>
|
||
</div>`;
|
||
|
||
// Attachments section
|
||
bodyHtml += `
|
||
<div class="detail-section attachments-section">
|
||
<div class="detail-section-title" onclick="this.classList.toggle('collapsed')">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
|
||
Attachments <span style="font-weight:400;font-size:.7rem;color:var(--text2)" id="attachmentsCount">…</span>
|
||
</div>
|
||
<div class="detail-section-content">
|
||
<div class="attachments-grid" id="attachmentsGrid">
|
||
<div style="grid-column:1/-1;text-align:center;padding:12px;color:var(--text2);font-size:.82rem"><div class="spinner" style="margin:0 auto 8px"></div>Loading…</div>
|
||
</div>
|
||
<div class="att-drop-zone" id="attDropZone">
|
||
<input type="file" id="attFileInput" multiple>
|
||
<div class="att-drop-zone-icon">📁</div>
|
||
<div class="att-drop-zone-text">Drop files here or click to upload</div>
|
||
<div class="att-drop-zone-hint">Images, PDFs, documents — up to 20MB each</div>
|
||
</div>
|
||
<div class="att-upload-progress" id="attUploadProgress">
|
||
<div class="att-upload-bar"><div class="att-upload-bar-fill" id="attUploadBarFill"></div></div>
|
||
<div class="att-upload-status" id="attUploadStatus"></div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
// Agent Output section (long notes = agent results)
|
||
if (outputNotes.length > 0) {
|
||
bodyHtml += `
|
||
<div class="detail-section">
|
||
<div class="detail-section-title" onclick="this.classList.toggle('collapsed')">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
|
||
Agent Output <span style="font-weight:400;font-size:.7rem;color:var(--accent2)">${outputNotes.length} result${outputNotes.length > 1 ? 's' : ''}</span>
|
||
</div>
|
||
<div class="detail-section-content">
|
||
${outputNotes.map((n, i) => {
|
||
const txt = n.text || n.content || '';
|
||
const time = n.timestamp || n.createdAt;
|
||
const escaped = txt.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '');
|
||
return `<div class="detail-output" style="${i > 0 ? 'margin-top:10px' : ''}">
|
||
<div class="detail-output-header">
|
||
<span>🤖 Output${time ? ' · ' + new Date(time).toLocaleString('en-US', { month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' }) : ''}</span>
|
||
<button class="detail-output-copy" onclick="copyOutput(this, '${escaped}')">Copy</button>
|
||
</div>
|
||
<div class="detail-output-body">${renderMarkdown(txt)}</div>
|
||
</div>`;
|
||
}).join('')}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// Activity / Comments section (regular + status notes combined)
|
||
const activityNotes = [...regularNotes, ...statusNotes].sort((a, b) => {
|
||
const ta = new Date(a.timestamp || a.createdAt || 0).getTime();
|
||
const tb = new Date(b.timestamp || b.createdAt || 0).getTime();
|
||
return tb - ta;
|
||
});
|
||
|
||
bodyHtml += `
|
||
<div class="detail-section">
|
||
<div class="detail-section-title" onclick="this.classList.toggle('collapsed')">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
|
||
Activity & Comments <span style="font-weight:400;font-size:.7rem;color:var(--text2)">${activityNotes.length}</span>
|
||
</div>
|
||
<div class="detail-section-content">
|
||
<div class="detail-notes" id="detailNotes">
|
||
${activityNotes.length === 0 ? '<div style="font-size:.82rem;color:var(--text2);padding:12px 0">No activity yet</div>' : activityNotes.map(n => {
|
||
const txt = n.text || n.content || '';
|
||
const time = n.timestamp || n.createdAt;
|
||
const isStatus = txt.startsWith('Status changed');
|
||
const isLong = txt.length > 200;
|
||
const noteId = 'dn-' + Math.random().toString(36).slice(2, 8);
|
||
return `<div class="detail-note ${isStatus ? 'is-status' : ''}">
|
||
<div class="detail-note-time">${time ? new Date(time).toLocaleString('en-US', { month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' }) : ''}</div>
|
||
<div class="detail-note-text ${isLong ? 'truncated' : ''}" id="${noteId}">${escHtml(txt)}</div>
|
||
${isLong ? `<span class="detail-note-expand" onclick="toggleNoteExpand('${noteId}', this)">Show more</span>` : ''}
|
||
</div>`;
|
||
}).join('')}
|
||
</div>
|
||
<div class="detail-add-note">
|
||
<input type="text" class="detail-note-input" id="detailNoteInput" placeholder="Add a comment…" onkeydown="if(event.key==='Enter')addDetailNote()">
|
||
<button class="action-btn primary" onclick="addDetailNote()">Add</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
document.getElementById('detailBody').innerHTML = bodyHtml;
|
||
|
||
// Actions footer
|
||
document.getElementById('detailActions').innerHTML = `
|
||
<button class="spawn-btn" onclick="spawnSingleTask('${taskId}')">⚡ Run as Sub-Agent</button>
|
||
${status !== 'in-progress' ? '<button class="action-btn primary" onclick="detailUpdateStatus(\'in-progress\')">▶ In Progress</button>' : ''}
|
||
${status !== 'done' ? '<button class="action-btn" style="border-color:rgba(45,212,160,0.3);color:var(--green)" onclick="detailUpdateStatus(\'done\')">✓ Done</button>' : ''}
|
||
${status !== 'failed' ? '<button class="action-btn danger" onclick="detailUpdateStatus(\'failed\')">✕ Failed</button>' : ''}
|
||
${status !== 'new' ? '<button class="action-btn" onclick="detailUpdateStatus(\'new\')">↩ Reset</button>' : ''}
|
||
<span style="flex:1"></span>
|
||
<button class="action-btn danger" onclick="if(confirm('Delete this task?'))deleteTask('${taskId}')">🗑 Delete</button>
|
||
`;
|
||
|
||
document.getElementById('detailModal').classList.add('show');
|
||
|
||
// Load attachments and setup drop zone
|
||
loadAttachments(taskId);
|
||
setTimeout(() => setupDropZone(taskId), 100);
|
||
}
|
||
|
||
function closeDetailModal() {
|
||
document.getElementById('detailModal').classList.remove('show');
|
||
detailTaskId = null;
|
||
isContentEditing = false;
|
||
}
|
||
|
||
async function detailUpdateStatus(newStatus) {
|
||
if (!detailTaskId) return;
|
||
try {
|
||
await apiFetch('/tasks/' + detailTaskId, { method: 'PATCH', body: JSON.stringify({ status: newStatus }) });
|
||
toast('Task updated to ' + statusLabel(newStatus), 'success');
|
||
await loadTasks(true);
|
||
// Detail modal will be auto-refreshed by loadTasks when detailTaskId is set
|
||
} catch(e) { toast('Failed: ' + e.message, 'error'); }
|
||
}
|
||
|
||
async function addDetailNote() {
|
||
if (!detailTaskId) return;
|
||
const input = document.getElementById('detailNoteInput');
|
||
const text = input?.value?.trim();
|
||
if (!text) return;
|
||
try {
|
||
await apiFetch('/tasks/' + detailTaskId + '/notes', { method: 'POST', body: JSON.stringify({ text }) });
|
||
input.value = '';
|
||
toast('Comment added', 'success');
|
||
await loadTasks(true);
|
||
setTimeout(() => {
|
||
const body = document.getElementById('detailBody');
|
||
if (body) body.scrollTop = body.scrollHeight;
|
||
}, 100);
|
||
} catch(e) { toast('Failed: ' + e.message, 'error'); }
|
||
}
|
||
|
||
async function deleteTask(taskId) {
|
||
try {
|
||
await apiFetch('/tasks/' + taskId, { method: 'DELETE' });
|
||
toast('Task deleted', 'success');
|
||
closeDetailModal();
|
||
loadTasks(true);
|
||
} catch(e) { toast('Failed: ' + e.message, 'error'); }
|
||
}
|
||
|
||
function toggleNoteExpand(noteId, btn) {
|
||
const el = document.getElementById(noteId);
|
||
if (!el) return;
|
||
el.classList.toggle('truncated');
|
||
btn.textContent = el.classList.contains('truncated') ? 'Show more' : 'Show less';
|
||
}
|
||
|
||
function copyOutput(btn, text) {
|
||
navigator.clipboard.writeText(text).then(() => {
|
||
btn.textContent = 'Copied!';
|
||
setTimeout(() => { btn.textContent = 'Copy'; }, 2000);
|
||
}).catch(() => toast('Copy failed', 'error'));
|
||
}
|
||
|
||
// ─── Content Edit/Save ───
|
||
let isContentEditing = false;
|
||
|
||
function toggleContentEdit() {
|
||
isContentEditing = !isContentEditing;
|
||
const mdView = document.getElementById('detailContentMd');
|
||
const textarea = document.getElementById('detailContentTextarea');
|
||
const editBtn = document.getElementById('contentEditBtn');
|
||
const saveBtn = document.getElementById('contentSaveBtn');
|
||
|
||
if (isContentEditing) {
|
||
mdView.style.display = 'none';
|
||
textarea.style.display = '';
|
||
textarea.focus();
|
||
editBtn.classList.add('active');
|
||
editBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-1px;margin-right:3px"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg> Preview';
|
||
saveBtn.style.display = '';
|
||
} else {
|
||
// Update preview with current textarea content
|
||
const content = textarea.value;
|
||
mdView.innerHTML = content.trim()
|
||
? renderFullMarkdown(content)
|
||
: '<div class="detail-content-empty"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>No content yet — click Edit to add markdown content</div>';
|
||
mdView.style.display = '';
|
||
textarea.style.display = 'none';
|
||
editBtn.classList.remove('active');
|
||
editBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-1px;margin-right:3px"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg> Edit';
|
||
saveBtn.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
async function saveTaskContent() {
|
||
if (!detailTaskId) return;
|
||
const textarea = document.getElementById('detailContentTextarea');
|
||
const content = textarea.value;
|
||
const saveBtn = document.getElementById('contentSaveBtn');
|
||
saveBtn.textContent = 'Saving…';
|
||
saveBtn.disabled = true;
|
||
try {
|
||
await apiFetch('/tasks/' + detailTaskId, { method: 'PATCH', body: JSON.stringify({ content }) });
|
||
toast('Content saved!', 'success');
|
||
// Update local task data
|
||
const task = allTasks.find(t => t.id === detailTaskId);
|
||
if (task) task.content = content;
|
||
// Switch back to preview
|
||
if (isContentEditing) toggleContentEdit();
|
||
// Refresh the list/kanban behind the modal
|
||
if (taskView === 'kanban') renderKanban(); else renderTasks();
|
||
} catch(e) {
|
||
toast('Save failed: ' + e.message, 'error');
|
||
} finally {
|
||
saveBtn.textContent = 'Save';
|
||
saveBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
function timeAgo(date) {
|
||
const s = Math.floor((Date.now() - date.getTime()) / 1000);
|
||
if (s < 60) return 'just now';
|
||
if (s < 3600) return Math.floor(s / 60) + 'm ago';
|
||
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
|
||
return Math.floor(s / 86400) + 'd ago';
|
||
}
|
||
|
||
document.getElementById('detailModal').addEventListener('click', e => {
|
||
if (e.target === e.currentTarget) closeDetailModal();
|
||
});
|
||
|
||
// Restore view on load
|
||
if (taskView === 'kanban') {
|
||
document.querySelectorAll('#taskViewToggle button').forEach(b => b.classList.toggle('active', b.dataset.view === 'kanban'));
|
||
}
|
||
|
||
// ═══ DOCUMENTS ═══
|
||
const WORKSPACE_FILES = ['MEMORY.md', 'SOUL.md', 'USER.md', 'AGENTS.md', 'TOOLS.md', 'IDENTITY.md', 'HEARTBEAT.md'];
|
||
let currentFile = null;
|
||
let docView = 'editor';
|
||
let allSkills = [];
|
||
let memoryFiles = [];
|
||
let isEditMode = false;
|
||
let currentFileContent = '';
|
||
|
||
async function loadFileList() {
|
||
const sidebar = document.getElementById('fileSidebar');
|
||
// Load memory dir files
|
||
try {
|
||
const data = await apiFetch('/files?path=memory/&list=true');
|
||
memoryFiles = Array.isArray(data) ? data : (data.files || []);
|
||
} catch(e) { memoryFiles = []; }
|
||
|
||
sidebar.innerHTML = WORKSPACE_FILES.map(f => `
|
||
<div class="file-item ${currentFile === f ? 'active' : ''}" onclick="selectFile('${f}')">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||
<span class="file-name">${f}</span>
|
||
</div>
|
||
`).join('') + (memoryFiles.length ? `
|
||
<div class="file-divider"></div>
|
||
<div class="file-sidebar-header">memory/</div>
|
||
${memoryFiles.map(f => {
|
||
const path = typeof f === 'string' ? f : f.name || f.path || '';
|
||
const name = path.split('/').pop();
|
||
return `<div class="file-item indent ${currentFile === 'memory/' + name ? 'active' : ''}" onclick="selectFile('memory/${name}')">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||
<span class="file-name">${escHtml(name)}</span>
|
||
</div>`;
|
||
}).join('')}
|
||
` : '');
|
||
}
|
||
|
||
async function selectFile(path) {
|
||
currentFile = path;
|
||
isEditMode = false;
|
||
loadFileList();
|
||
const ta = document.getElementById('editorTextarea');
|
||
const preview = document.getElementById('mdPreview');
|
||
const fname = document.getElementById('editorFilename');
|
||
const saveBtn = document.getElementById('saveBtn');
|
||
const editBtn = document.getElementById('editToggleBtn');
|
||
const editLabel = document.getElementById('editToggleLabel');
|
||
|
||
fname.innerHTML = `${escHtml(path)}`;
|
||
preview.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text2)"><div class="spinner"></div><p style="margin-top:12px;font-size:.84rem">Loading…</p></div>';
|
||
|
||
// Show preview mode by default
|
||
preview.style.display = '';
|
||
ta.style.display = 'none';
|
||
saveBtn.style.display = 'none';
|
||
editBtn.style.display = '';
|
||
editBtn.classList.remove('editing');
|
||
editLabel.textContent = 'Edit';
|
||
|
||
try {
|
||
const data = await apiFetch(`/files?path=${encodeURIComponent(path)}`);
|
||
const content = typeof data === 'string' ? data : (data.content || JSON.stringify(data, null, 2));
|
||
currentFileContent = content;
|
||
ta.value = content;
|
||
ta.disabled = false;
|
||
saveBtn.disabled = false;
|
||
renderMarkdownPreview(content);
|
||
} catch(e) {
|
||
currentFileContent = '';
|
||
preview.innerHTML = `<div class="md-empty-hint"><p style="color:var(--red)">Error loading file: ${escHtml(e.message)}</p></div>`;
|
||
saveBtn.disabled = true;
|
||
editBtn.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function renderMarkdownPreview(content) {
|
||
const preview = document.getElementById('mdPreview');
|
||
if (!content || !content.trim()) {
|
||
preview.innerHTML = '<div class="md-empty-hint"><p>This file is empty</p></div>';
|
||
return;
|
||
}
|
||
try {
|
||
if (typeof marked !== 'undefined' && marked.parse) {
|
||
preview.innerHTML = marked.parse(content, { breaks: true, gfm: true });
|
||
} else {
|
||
// Fallback to simple rendering
|
||
preview.innerHTML = renderMarkdown(content);
|
||
}
|
||
} catch(e) {
|
||
preview.innerHTML = `<pre style="white-space:pre-wrap;color:var(--text2)">${escHtml(content)}</pre>`;
|
||
}
|
||
}
|
||
|
||
function toggleEditMode() {
|
||
isEditMode = !isEditMode;
|
||
const ta = document.getElementById('editorTextarea');
|
||
const preview = document.getElementById('mdPreview');
|
||
const saveBtn = document.getElementById('saveBtn');
|
||
const editBtn = document.getElementById('editToggleBtn');
|
||
const editLabel = document.getElementById('editToggleLabel');
|
||
|
||
if (isEditMode) {
|
||
// Switch to edit mode
|
||
ta.value = currentFileContent;
|
||
ta.style.display = '';
|
||
preview.style.display = 'none';
|
||
saveBtn.style.display = '';
|
||
editBtn.classList.add('editing');
|
||
editLabel.textContent = 'Preview';
|
||
ta.focus();
|
||
} else {
|
||
// Switch back to preview mode — pick up any edits
|
||
currentFileContent = ta.value;
|
||
ta.style.display = 'none';
|
||
preview.style.display = '';
|
||
saveBtn.style.display = 'none';
|
||
editBtn.classList.remove('editing');
|
||
editLabel.textContent = 'Edit';
|
||
renderMarkdownPreview(currentFileContent);
|
||
}
|
||
}
|
||
|
||
async function saveFile() {
|
||
if (!currentFile) return;
|
||
const btn = document.getElementById('saveBtn');
|
||
const content = document.getElementById('editorTextarea').value;
|
||
btn.disabled = true;
|
||
btn.textContent = 'Saving…';
|
||
try {
|
||
await apiFetch(`/files?path=${encodeURIComponent(currentFile)}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ content })
|
||
});
|
||
currentFileContent = content;
|
||
toast('File saved successfully', 'success');
|
||
} catch(e) {
|
||
toast(`Save failed: ${e.message}`, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = 'Save';
|
||
}
|
||
}
|
||
|
||
function setDocView(view) {
|
||
docView = view;
|
||
document.querySelectorAll('#viewToggle button').forEach(b => b.classList.toggle('active', b.dataset.view === view));
|
||
document.getElementById('editorArea').style.display = view === 'editor' ? '' : 'none';
|
||
document.getElementById('skillsArea').style.display = view === 'skills' ? '' : 'none';
|
||
if (view === 'skills') loadSkills();
|
||
}
|
||
|
||
async function loadSkills() {
|
||
const grid = document.getElementById('skillsGrid');
|
||
try {
|
||
const data = await apiFetch('/skills');
|
||
allSkills = Array.isArray(data) ? data : (data.skills || []);
|
||
renderSkills();
|
||
} catch(e) {
|
||
grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><h3>Unable to load skills</h3><p>${e.message}</p></div>`;
|
||
}
|
||
}
|
||
|
||
function renderSkills() {
|
||
const grid = document.getElementById('skillsGrid');
|
||
const search = (document.getElementById('skillSearch').value || '').toLowerCase();
|
||
const filtered = search ? allSkills.filter(s => (s.name || '').toLowerCase().includes(search) || (s.description || '').toLowerCase().includes(search)) : allSkills;
|
||
if (filtered.length === 0) {
|
||
grid.innerHTML = '<div class="empty-state" style="grid-column:1/-1"><h3>No skills found</h3></div>';
|
||
return;
|
||
}
|
||
grid.innerHTML = filtered.map((s, i) => `
|
||
<div class="glass-card skill-card" style="animation:cardIn .4s ease backwards;animation-delay:${i * 0.04}s">
|
||
<div class="skill-name">${escHtml(s.name || s.id || 'Unnamed')}</div>
|
||
<div class="skill-desc">${escHtml(s.description || 'No description')}</div>
|
||
${s.version ? `<div class="skill-meta">v${escHtml(s.version)}</div>` : ''}
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
document.getElementById('skillSearch')?.addEventListener('input', () => renderSkills());
|
||
|
||
// ═══ LOGS ═══
|
||
let logDays = 7;
|
||
let autoRefresh = false;
|
||
let autoRefreshInterval = null;
|
||
|
||
async function loadLogs() {
|
||
const timeline = document.getElementById('logsTimeline');
|
||
timeline.innerHTML = '<div class="skeleton skeleton-card"></div><div class="skeleton skeleton-card"></div><div class="skeleton skeleton-card"></div>';
|
||
|
||
let entries = [];
|
||
|
||
// Fetch memory files for log entries
|
||
try {
|
||
const data = await apiFetch('/files?path=memory/&list=true');
|
||
const files = Array.isArray(data) ? data : (data.files || []);
|
||
|
||
for (const f of files) {
|
||
const name = typeof f === 'string' ? f : (f.name || f.path || '');
|
||
const fname = name.split('/').pop();
|
||
// Check date filter
|
||
const dateMatch = fname.match(/(\d{4}-\d{2}-\d{2})/);
|
||
if (dateMatch && logDays > 0) {
|
||
const fileDate = new Date(dateMatch[1]);
|
||
const cutoff = new Date();
|
||
cutoff.setDate(cutoff.getDate() - logDays);
|
||
if (fileDate < cutoff) continue;
|
||
}
|
||
|
||
try {
|
||
const content = await apiFetch(`/files?path=memory/${encodeURIComponent(fname)}`);
|
||
const text = typeof content === 'string' ? content : (content.content || '');
|
||
entries.push({
|
||
type: 'memory',
|
||
date: dateMatch ? dateMatch[1] : fname,
|
||
title: fname,
|
||
content: text,
|
||
timestamp: dateMatch ? new Date(dateMatch[1]).getTime() : 0
|
||
});
|
||
} catch(e) { /* skip */ }
|
||
}
|
||
} catch(e) { /* ok */ }
|
||
|
||
// Fetch tasks for status history
|
||
try {
|
||
const data = await apiFetch('/tasks');
|
||
const tasks = Array.isArray(data) ? data : (data.tasks || []);
|
||
tasks.forEach(t => {
|
||
if (t.createdAt) {
|
||
entries.push({
|
||
type: 'task',
|
||
date: new Date(t.createdAt).toISOString().slice(0, 10),
|
||
title: `Task: ${t.title || 'Untitled'}`,
|
||
content: `Status: ${statusLabel(t.status || 'new')}${t.description ? '\n' + t.description : ''}`,
|
||
timestamp: new Date(t.createdAt).getTime()
|
||
});
|
||
}
|
||
// Add notes as log entries
|
||
(t.notes || []).forEach(n => {
|
||
if (n.createdAt) {
|
||
entries.push({
|
||
type: 'task',
|
||
date: new Date(n.createdAt).toISOString().slice(0, 10),
|
||
title: `Note on: ${t.title || 'Untitled'}`,
|
||
content: n.text || n.content || '',
|
||
timestamp: new Date(n.createdAt).getTime()
|
||
});
|
||
}
|
||
});
|
||
});
|
||
} catch(e) { /* ok */ }
|
||
|
||
// Sort by date desc
|
||
entries.sort((a, b) => b.timestamp - a.timestamp);
|
||
|
||
if (entries.length === 0) {
|
||
timeline.innerHTML = `<div class="empty-state">
|
||
<svg viewBox="0 0 80 80"><circle cx="40" cy="40" r="36" fill="none" stroke="currentColor" stroke-width="1.5"/><polyline points="40 20 40 40 52 46" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||
<h3>No log entries</h3>
|
||
<p>Memory files and task activity will appear here as a timeline.</p>
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
timeline.innerHTML = entries.map((entry, i) => {
|
||
const preview = entry.content.length > 200 ? entry.content.slice(0, 200) + '…' : entry.content;
|
||
return `<div class="timeline-item type-${entry.type}" style="animation-delay:${i * 0.06}s">
|
||
<div class="timeline-date">${entry.date}</div>
|
||
<div class="timeline-content" onclick="this.classList.toggle('expanded')">
|
||
<span class="timeline-tag ${entry.type}">${entry.type === 'memory' ? '📝 Memory' : '📋 Task'}</span>
|
||
<div><strong>${escHtml(entry.title)}</strong></div>
|
||
<div class="preview">${escHtml(preview)}</div>
|
||
<div style="display:none" class="full-content">${escHtml(entry.content)}</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
// Handle expand/collapse to show full content
|
||
timeline.querySelectorAll('.timeline-content').forEach(el => {
|
||
el.addEventListener('click', function() {
|
||
const preview = this.querySelector('.preview');
|
||
const full = this.querySelector('.full-content');
|
||
if (this.classList.contains('expanded')) {
|
||
preview.style.display = 'none';
|
||
full.style.display = 'block';
|
||
} else {
|
||
preview.style.display = '';
|
||
full.style.display = 'none';
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// Log date filters
|
||
document.getElementById('logDateFilters').addEventListener('click', e => {
|
||
const btn = e.target.closest('.filter-btn');
|
||
if (!btn) return;
|
||
document.querySelectorAll('#logDateFilters .filter-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
logDays = parseInt(btn.dataset.days) || 0;
|
||
loadLogs();
|
||
});
|
||
|
||
function toggleAutoRefresh() {
|
||
autoRefresh = !autoRefresh;
|
||
const toggle = document.getElementById('autoRefreshToggle');
|
||
toggle.classList.toggle('on', autoRefresh);
|
||
if (autoRefresh) {
|
||
autoRefreshInterval = setInterval(() => loadLogs(), 30000);
|
||
toast('Auto-refresh enabled (30s)', 'info');
|
||
} else {
|
||
clearInterval(autoRefreshInterval);
|
||
toast('Auto-refresh disabled', 'info');
|
||
}
|
||
}
|
||
|
||
// ═══ CONNECTED APIs ═══
|
||
const CONNECTED_APIS = [
|
||
{
|
||
id: 'discord',
|
||
name: 'Discord Bot',
|
||
provider: 'Discord API',
|
||
icon: '🎮',
|
||
iconClass: 'discord',
|
||
status: 'connected',
|
||
description: '主消息频道,用于接收指令、发送报告和互动。支持 Components v2 组件交互。',
|
||
capabilities: [
|
||
{ label: 'Send Messages', type: 'messaging' },
|
||
{ label: 'Read Messages', type: 'messaging' },
|
||
{ label: 'React', type: 'messaging' },
|
||
{ label: 'Components v2', type: 'messaging' }
|
||
]
|
||
},
|
||
{
|
||
id: 'anthropic',
|
||
name: 'Anthropic Claude',
|
||
provider: 'Anthropic API',
|
||
icon: '🧠',
|
||
iconClass: 'anthropic',
|
||
status: 'connected',
|
||
description: '主 LLM 引擎。Claude Sonnet 4.6 / Opus 4.6,用于推理、代码生成和对话。',
|
||
capabilities: [
|
||
{ label: 'Claude Sonnet 4.6', type: 'ai' },
|
||
{ label: 'Claude Opus 4.6', type: 'ai' },
|
||
{ label: 'Reasoning', type: 'ai' },
|
||
{ label: 'Code Gen', type: 'ai' }
|
||
]
|
||
},
|
||
{
|
||
id: 'brave-search',
|
||
name: 'Brave Search',
|
||
provider: 'Brave API',
|
||
icon: '🦁',
|
||
iconClass: 'brave',
|
||
status: 'connected',
|
||
description: '实时网页搜索 API,用于信息检索和最新内容获取。',
|
||
capabilities: [
|
||
{ label: 'Web Search', type: 'search' },
|
||
{ label: 'Region Filter', type: 'search' },
|
||
{ label: 'Freshness Filter', type: 'search' }
|
||
]
|
||
},
|
||
{
|
||
id: 'notion',
|
||
name: 'Notion',
|
||
provider: 'Notion API',
|
||
icon: 'N',
|
||
iconClass: 'notion',
|
||
status: 'connected',
|
||
description: '数据库读写,用于任务管理、项目跟踪和知识库存储。',
|
||
capabilities: [
|
||
{ label: 'Database Read', type: 'data' },
|
||
{ label: 'Database Write', type: 'data' },
|
||
{ label: 'Pages', type: 'data' },
|
||
{ label: 'Task Queue', type: 'ai' }
|
||
]
|
||
},
|
||
{
|
||
id: 'openai',
|
||
name: 'OpenAI',
|
||
provider: 'OpenAI API',
|
||
icon: '⚡',
|
||
iconClass: 'openai',
|
||
status: 'connected',
|
||
description: 'GPT-5.2 / Codex,用于代码补全、内容生成和多模态任务。',
|
||
capabilities: [
|
||
{ label: 'GPT-5.2', type: 'ai' },
|
||
{ label: 'Codex', type: 'ai' },
|
||
{ label: 'Code Completion', type: 'ai' },
|
||
{ label: 'Multimodal', type: 'ai' }
|
||
]
|
||
},
|
||
{
|
||
id: 'gemini',
|
||
name: 'Google Gemini',
|
||
provider: 'Google AI',
|
||
icon: 'G',
|
||
iconClass: 'google',
|
||
status: 'connected',
|
||
description: 'Gemini 3 Pro / Flash,用于多模态推理、图像理解和长上下文任务。',
|
||
capabilities: [
|
||
{ label: 'Gemini 3 Pro', type: 'ai' },
|
||
{ label: 'Gemini Flash', type: 'ai' },
|
||
{ label: 'Image Understanding', type: 'ai' },
|
||
{ label: 'Long Context', type: 'ai' }
|
||
]
|
||
},
|
||
{
|
||
id: 'x-api',
|
||
name: 'X API',
|
||
provider: 'Twitter API v2',
|
||
icon: '𝕏',
|
||
iconClass: 'twitter',
|
||
status: 'connected',
|
||
description: '推文抓取与监控,追踪 @steipete @openclaw 及相关话题动态。',
|
||
capabilities: [
|
||
{ label: 'Tweet Fetch', type: 'data' },
|
||
{ label: '@steipete', type: 'search' },
|
||
{ label: '@openclaw', type: 'search' },
|
||
{ label: 'Timeline', type: 'data' }
|
||
]
|
||
},
|
||
{
|
||
id: 'web-fetch',
|
||
name: 'Web Fetch',
|
||
provider: 'Built-in',
|
||
icon: '🌐',
|
||
iconClass: 'web',
|
||
status: 'connected',
|
||
description: '网页内容提取工具,用于抓取和解析任意 URL 的页面内容。',
|
||
capabilities: [
|
||
{ label: 'URL Fetch', type: 'data' },
|
||
{ label: 'Content Extract', type: 'data' },
|
||
{ label: 'HTML Parse', type: 'data' }
|
||
]
|
||
},
|
||
{
|
||
id: 'whisper',
|
||
name: 'OpenAI Whisper API',
|
||
provider: 'OpenAI API',
|
||
icon: '🎙️',
|
||
iconClass: 'whisper',
|
||
status: 'connected',
|
||
description: '语音转文字服务,支持多语言识别,用于音频内容处理。',
|
||
capabilities: [
|
||
{ label: 'Speech-to-Text', type: 'ai' },
|
||
{ label: 'Multi-Language', type: 'ai' },
|
||
{ label: 'Audio Transcribe', type: 'ai' }
|
||
]
|
||
}
|
||
];
|
||
|
||
function renderAPIs() {
|
||
const grid = document.getElementById('apisGrid');
|
||
const stats = document.getElementById('apiStats');
|
||
const search = (document.getElementById('apiSearch')?.value || '').toLowerCase();
|
||
|
||
const filtered = search
|
||
? CONNECTED_APIS.filter(a => a.name.toLowerCase().includes(search) || a.provider.toLowerCase().includes(search) || a.description.toLowerCase().includes(search) || a.capabilities.some(c => c.label.toLowerCase().includes(search)))
|
||
: CONNECTED_APIS;
|
||
|
||
const connected = CONNECTED_APIS.filter(a => a.status === 'connected').length;
|
||
const totalCaps = CONNECTED_APIS.reduce((sum, a) => sum + a.capabilities.length, 0);
|
||
const categories = new Set();
|
||
CONNECTED_APIS.forEach(a => a.capabilities.forEach(c => categories.add(c.type)));
|
||
|
||
document.getElementById('apiCount').textContent = CONNECTED_APIS.length;
|
||
|
||
stats.innerHTML = `
|
||
<div class="api-stat-card">
|
||
<div class="api-stat-value">${CONNECTED_APIS.length}</div>
|
||
<div class="api-stat-label">Total APIs</div>
|
||
</div>
|
||
<div class="api-stat-card">
|
||
<div class="api-stat-value">${connected}</div>
|
||
<div class="api-stat-label">Connected</div>
|
||
</div>
|
||
<div class="api-stat-card">
|
||
<div class="api-stat-value">${totalCaps}</div>
|
||
<div class="api-stat-label">Capabilities</div>
|
||
</div>
|
||
<div class="api-stat-card">
|
||
<div class="api-stat-value">${categories.size}</div>
|
||
<div class="api-stat-label">Categories</div>
|
||
</div>
|
||
`;
|
||
|
||
if (filtered.length === 0) {
|
||
grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><h3>No APIs match your search</h3><p>Try a different search term.</p></div>`;
|
||
return;
|
||
}
|
||
|
||
grid.innerHTML = filtered.map((api, i) => `
|
||
<div class="glass-card api-card" style="animation:cardIn .4s ease backwards;animation-delay:${i * 0.06}s">
|
||
<div class="api-card-header">
|
||
<div class="api-icon ${api.iconClass}">${api.icon}</div>
|
||
<div class="api-card-info">
|
||
<div class="api-card-name">
|
||
${escHtml(api.name)}
|
||
<span class="api-status ${api.status}" title="${api.status}"></span>
|
||
</div>
|
||
<div class="api-card-provider">${escHtml(api.provider)}</div>
|
||
</div>
|
||
</div>
|
||
<div class="api-card-desc">${escHtml(api.description)}</div>
|
||
<div class="api-capabilities">
|
||
${api.capabilities.map(cap => `<span class="api-cap ${cap.type}">${escHtml(cap.label)}</span>`).join('')}
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
document.getElementById('apiSearch')?.addEventListener('input', () => renderAPIs());
|
||
|
||
// ═══ AGENT MONITOR ═══
|
||
let agentData = null;
|
||
|
||
async function loadAgentMonitor() {
|
||
try {
|
||
agentData = await apiFetch('/agents');
|
||
renderAgentMonitor();
|
||
} catch(e) {
|
||
// Silently fail - monitor is non-critical
|
||
console.warn('Agent monitor fetch failed:', e.message);
|
||
}
|
||
}
|
||
|
||
function loadSystemInfo() {
|
||
apiFetch('/ops/system').then(sys => {
|
||
const el = document.getElementById('systemInfoBar');
|
||
const c = document.getElementById('systemInfoContent');
|
||
if (!el || !c) return;
|
||
const memPct = sys.memory?.usePct || '0';
|
||
const memColor = +memPct > 85 ? 'var(--red)' : +memPct > 60 ? 'var(--yellow)' : 'var(--green)';
|
||
const diskPct = parseInt(sys.disk?.usePct) || 0;
|
||
const diskColor = diskPct > 80 ? 'var(--red)' : diskPct > 60 ? 'var(--yellow)' : 'var(--green)';
|
||
c.innerHTML = `
|
||
<span>🖥️ <strong>${sys.macModel || sys.hostname}</strong></span>
|
||
<span>🍎 ${sys.macOS || '—'}</span>
|
||
<span>🧮 ${sys.cpus}核 · Load ${sys.loadAvg?.['1m']?.toFixed(1) || '—'}</span>
|
||
<span style="color:${memColor}">💾 RAM ${memPct}%</span>
|
||
<span style="color:${diskColor}">💿 Disk ${sys.disk?.usePct || '—'}</span>
|
||
<span>📦 Node ${sys.nodeVersion || '—'}</span>
|
||
<span>🦞 v${sys.clawVersion || '—'}</span>
|
||
`;
|
||
el.style.display = '';
|
||
}).catch(() => {});
|
||
}
|
||
|
||
function renderAgentMonitor() {
|
||
if (!agentData) return;
|
||
const d = agentData;
|
||
|
||
loadSystemInfo();
|
||
|
||
// Fetch today's ops data for header cards — use /ops/sessions for accurate totals (includes all channels)
|
||
apiFetch('/ops/sessions').then(sessData => {
|
||
let totalCost = 0, totalTokens = 0, totalMsgs = 0, models = {};
|
||
(sessData.sessions || []).forEach(s => {
|
||
const t = s.today || {};
|
||
totalCost += t.cost || 0;
|
||
totalTokens += t.totalTokens || 0;
|
||
totalMsgs += t.messages || 0;
|
||
// Aggregate by actual model used in today's messages
|
||
if (t.models) {
|
||
for (const [m, tk] of Object.entries(t.models)) {
|
||
models[m] = (models[m] || 0) + tk;
|
||
}
|
||
}
|
||
});
|
||
|
||
// Card 1: Today Cost
|
||
const mainBadge = document.getElementById('mainAgentBadge');
|
||
const mainValue = document.getElementById('mainAgentValue');
|
||
const mainDetail = document.getElementById('mainAgentDetail');
|
||
mainBadge.className = 'agent-stat-badge active';
|
||
mainBadge.innerHTML = '<span class="pulse-dot"></span> today';
|
||
mainValue.textContent = '$' + totalCost.toFixed(2);
|
||
mainDetail.textContent = totalMsgs + ' messages';
|
||
|
||
// Card 2: Today Tokens — show total + top model percentages
|
||
const subVal = document.getElementById('subagentValue');
|
||
const subBadge = document.getElementById('subagentBadge');
|
||
const subDetail = document.getElementById('subagentDetail');
|
||
subVal.textContent = fmtTokens(totalTokens);
|
||
subBadge.className = 'agent-stat-badge active';
|
||
subBadge.innerHTML = 'today';
|
||
const topModel = Object.entries(models).filter(([k]) => k !== 'delivery-mirror' && k !== 'unknown').sort((a, b) => b[1] - a[1])[0];
|
||
subDetail.textContent = topModel ? 'Top: ' + shortModel(topModel[0]) : '—';
|
||
|
||
// Card 5: Model Mix — visual bars
|
||
const sorted = Object.entries(models).filter(([k]) => k !== 'delivery-mirror' && k !== 'unknown').sort((a, b) => b[1] - a[1]);
|
||
const mixEl = document.getElementById('modelMixBars');
|
||
const totalVal5 = document.getElementById('totalValue');
|
||
const totalBadge5 = document.getElementById('totalBadge');
|
||
if (mixEl && sorted.length) {
|
||
totalVal5.textContent = sorted.length + ' models';
|
||
totalBadge5.className = 'agent-stat-badge active';
|
||
totalBadge5.innerHTML = 'today';
|
||
// Stacked bar
|
||
let barHtml = '<div style="display:flex;height:10px;border-radius:5px;overflow:hidden;margin-bottom:6px">';
|
||
const colors = {};
|
||
sorted.forEach(([m, tk]) => {
|
||
const pct = ((tk / (totalTokens || 1)) * 100);
|
||
const c = getModelColor(m);
|
||
colors[m] = c;
|
||
barHtml += `<div style="width:${pct}%;background:${c};min-width:2px" title="${shortModel(m)} ${pct.toFixed(0)}%"></div>`;
|
||
});
|
||
barHtml += '</div>';
|
||
// Legend
|
||
barHtml += '<div style="display:flex;flex-wrap:wrap;gap:4px 10px;font-size:.7rem">';
|
||
sorted.forEach(([m, tk]) => {
|
||
const pct = ((tk / (totalTokens || 1)) * 100).toFixed(0);
|
||
barHtml += `<span style="color:${colors[m]}">● ${shortModel(m)} <b>${pct}%</b></span>`;
|
||
});
|
||
barHtml += '</div>';
|
||
mixEl.innerHTML = barHtml;
|
||
}
|
||
}).catch(() => {});
|
||
|
||
// Card 3: Cron Jobs (from agent data)
|
||
const cronVal = document.getElementById('cronValue');
|
||
const cronBadge = document.getElementById('cronBadge');
|
||
const cronDetail = document.getElementById('cronDetail');
|
||
cronVal.textContent = d.crons?.total || 0;
|
||
cronBadge.className = `agent-stat-badge ${d.crons?.active > 0 ? 'active' : 'idle'}`;
|
||
cronBadge.innerHTML = d.crons?.active > 0 ? `<span class="pulse-dot"></span> ${d.crons.active} running` : `${d.crons?.total || 0} total`;
|
||
cronDetail.textContent = d.crons?.active > 0 ? d.crons.active + ' running' : 'all idle';
|
||
|
||
// Card 4: Sessions
|
||
const hookVal = document.getElementById('hookValue');
|
||
const hookBadge = document.getElementById('hookBadge');
|
||
const hookDetail = document.getElementById('hookDetail');
|
||
hookVal.textContent = d.activeSessions || 0;
|
||
hookBadge.className = `agent-stat-badge ${d.activeSessions > 0 ? 'active' : 'idle'}`;
|
||
hookBadge.innerHTML = d.activeSessions + ' active';
|
||
hookDetail.textContent = (d.totalSessions || 0) + ' total';
|
||
|
||
// Card 5: Model Mix — rendered by /ops/sessions fetch above
|
||
|
||
// Render sessions panel
|
||
renderSessionsPanel();
|
||
}
|
||
|
||
function renderSessionsPanel() {
|
||
// Load today's cron runs instead of active sessions
|
||
loadCronRuns();
|
||
}
|
||
|
||
async function loadCronRuns() {
|
||
const body = document.getElementById('cronRunsBody');
|
||
const count = document.getElementById('cronRunsCount');
|
||
if (!body) return;
|
||
|
||
try {
|
||
const data = await apiFetch('/cron/today');
|
||
const runs = data.todayJobs || [];
|
||
count.textContent = runs.length;
|
||
|
||
if (runs.length === 0) {
|
||
body.innerHTML = '<div style="padding:12px 18px;font-size:.8rem;color:var(--text2)">今日暂无 Cron 执行记录</div>';
|
||
return;
|
||
}
|
||
|
||
// Sort by last run time descending
|
||
runs.sort((a, b) => (b.last?.endedAt || b.last?.startedAt || 0) - (a.last?.endedAt || a.last?.startedAt || 0));
|
||
|
||
body.innerHTML = runs.map(r => {
|
||
const last = r.last || {};
|
||
const name = r.name || r.id?.slice(0, 8) || '—';
|
||
const status = last.status === 'ok' ? '✅' : last.status === 'error' ? '❌' : '⏳';
|
||
const time = last.endedAt ? new Date(last.endedAt).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'America/Los_Angeles' }) : (last.startedAt ? new Date(last.startedAt).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'America/Los_Angeles' }) : '—');
|
||
const dur = (last.endedAt && last.startedAt) ? ((last.endedAt - last.startedAt) / 1000).toFixed(1) + 's' : '';
|
||
const model = last.model ? shortModel(last.model) : '';
|
||
const tokens = Number.isFinite(last.tokens) ? (fmtTokens(last.tokens) + ' tok') : '';
|
||
const cost = Number.isFinite(last.costUsd) ? fmtUsd(last.costUsd, 3) : '';
|
||
const detail = [model, tokens, cost, dur].filter(Boolean).join(' · ');
|
||
|
||
return `<div class="agent-session-row">
|
||
<span style="font-size:.9rem">${status}</span>
|
||
<span class="agent-session-key" style="flex:1;font-family:inherit;font-size:.78rem">${escHtml(name)}</span>
|
||
<span class="agent-session-tokens">${escHtml(detail)}</span>
|
||
<span class="agent-session-age">${time}</span>
|
||
</div>`;
|
||
}).join('');
|
||
} catch (e) {
|
||
body.innerHTML = `<div style="padding:12px 18px;font-size:.8rem;color:var(--text2)">${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
function formatAge(minutes) {
|
||
if (minutes < 1) return 'just now';
|
||
if (minutes < 60) return `${minutes}m ago`;
|
||
if (minutes < 1440) return `${Math.floor(minutes / 60)}h ago`;
|
||
return `${Math.floor(minutes / 1440)}d ago`;
|
||
}
|
||
|
||
function toggleSessionsPanel() {
|
||
// Panel toggle handled by CSS via collapsed class
|
||
}
|
||
|
||
// ═══ PARALLEL EXECUTION ═══
|
||
let selectionMode = false;
|
||
let selectedTaskIds = new Set();
|
||
let parallelRuns = new Map(); // taskId -> { status, startTime, title }
|
||
|
||
function toggleSelectionMode() {
|
||
selectionMode = !selectionMode;
|
||
const btn = document.getElementById('selectionModeBtn');
|
||
btn.style.background = selectionMode ? 'var(--accent)' : '';
|
||
btn.style.color = selectionMode ? '#fff' : '';
|
||
btn.style.borderColor = selectionMode ? 'var(--accent)' : '';
|
||
selectedTaskIds.clear();
|
||
updateBatchBar();
|
||
// Re-render to show/hide checkboxes
|
||
if (taskView === 'kanban') renderKanban(); else renderTasks();
|
||
}
|
||
|
||
function toggleTaskSelection(taskId, event) {
|
||
if (event) event.stopPropagation();
|
||
if (selectedTaskIds.has(taskId)) {
|
||
selectedTaskIds.delete(taskId);
|
||
} else {
|
||
selectedTaskIds.add(taskId);
|
||
}
|
||
updateBatchBar();
|
||
// Update checkbox visual
|
||
const cb = document.querySelector(`.task-checkbox[data-task-id="${taskId}"]`);
|
||
if (cb) cb.classList.toggle('checked', selectedTaskIds.has(taskId));
|
||
}
|
||
|
||
function selectAllNew() {
|
||
allTasks.filter(t => t.status === 'new').forEach(t => selectedTaskIds.add(t.id));
|
||
updateBatchBar();
|
||
if (taskView === 'kanban') renderKanban(); else renderTasks();
|
||
}
|
||
|
||
function clearSelection() {
|
||
selectedTaskIds.clear();
|
||
updateBatchBar();
|
||
if (taskView === 'kanban') renderKanban(); else renderTasks();
|
||
}
|
||
|
||
function updateBatchBar() {
|
||
const bar = document.getElementById('batchBar');
|
||
const count = document.getElementById('batchCount');
|
||
if (selectionMode && selectedTaskIds.size > 0) {
|
||
bar.style.display = '';
|
||
count.textContent = `${selectedTaskIds.size} selected`;
|
||
} else {
|
||
bar.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
async function spawnSingleTask(taskId) {
|
||
try {
|
||
const task = allTasks.find(t => t.id === taskId);
|
||
await apiFetch(`/tasks/${taskId}/spawn`, { method: 'POST' });
|
||
parallelRuns.set(taskId, { status: 'running', startTime: Date.now(), title: task?.title || 'Untitled' });
|
||
renderParallelPanel();
|
||
toast('Task spawned as sub-agent', 'success');
|
||
loadTasks(true);
|
||
} catch(e) {
|
||
toast(`Spawn failed: ${e.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async function spawnBatch() {
|
||
const ids = Array.from(selectedTaskIds);
|
||
if (ids.length === 0) { toast('No tasks selected', 'error'); return; }
|
||
try {
|
||
const result = await apiFetch('/tasks/spawn-batch', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ taskIds: ids })
|
||
});
|
||
// Track spawned tasks
|
||
ids.forEach(id => {
|
||
const task = allTasks.find(t => t.id === id);
|
||
parallelRuns.set(id, { status: 'running', startTime: Date.now(), title: task?.title || 'Untitled' });
|
||
});
|
||
renderParallelPanel();
|
||
const spawned = result.spawned || ids.length;
|
||
const skipped = result.skipped?.length || 0;
|
||
toast(`⚡ ${spawned} tasks spawned in parallel${skipped ? ` (${skipped} skipped)` : ''}`, 'success');
|
||
selectedTaskIds.clear();
|
||
updateBatchBar();
|
||
loadTasks(true);
|
||
} catch(e) {
|
||
toast(`Batch spawn failed: ${e.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
function renderParallelPanel() {
|
||
const panel = document.getElementById('parallelPanel');
|
||
const container = document.getElementById('parallelTasks');
|
||
const countEl = document.getElementById('parallelCount');
|
||
|
||
// Update statuses from task data
|
||
parallelRuns.forEach((run, taskId) => {
|
||
const task = allTasks.find(t => t.id === taskId);
|
||
if (task) {
|
||
if (task.status === 'done') run.status = 'done';
|
||
else if (task.status === 'failed') run.status = 'failed';
|
||
else if (task.status === 'in-progress') run.status = 'running';
|
||
}
|
||
});
|
||
|
||
if (parallelRuns.size === 0) {
|
||
panel.style.display = 'none';
|
||
return;
|
||
}
|
||
panel.style.display = '';
|
||
|
||
const running = Array.from(parallelRuns.values()).filter(r => r.status === 'running').length;
|
||
countEl.textContent = `${running} running / ${parallelRuns.size} total`;
|
||
|
||
container.innerHTML = Array.from(parallelRuns.entries()).map(([taskId, run]) => {
|
||
const elapsed = Math.round((Date.now() - run.startTime) / 1000);
|
||
const elapsedStr = elapsed < 60 ? `${elapsed}s` : `${Math.floor(elapsed/60)}m ${elapsed%60}s`;
|
||
let icon = '<div class="parallel-spinner"></div>';
|
||
if (run.status === 'done') icon = '<div class="parallel-done-icon">✓</div>';
|
||
if (run.status === 'failed') icon = '<div class="parallel-fail-icon">✕</div>';
|
||
return `<div class="parallel-task-row">
|
||
${icon}
|
||
<span class="parallel-task-name" title="${escHtml(run.title)}">${escHtml(run.title)}</span>
|
||
<span class="parallel-task-status ${run.status}">${run.status}</span>
|
||
<span class="parallel-task-time">${elapsedStr}</span>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function clearCompletedParallel() {
|
||
parallelRuns.forEach((run, taskId) => {
|
||
if (run.status === 'done' || run.status === 'failed') parallelRuns.delete(taskId);
|
||
});
|
||
renderParallelPanel();
|
||
}
|
||
|
||
function hideParallelPanel() {
|
||
document.getElementById('parallelPanel').style.display = 'none';
|
||
}
|
||
|
||
// ─── Ops Dashboard (Cron + Vision) ───
|
||
async function loadOps() {
|
||
const visionEl = document.getElementById('visionGrid');
|
||
const visionStatus = document.getElementById('visionStatus');
|
||
if (!visionEl) return;
|
||
|
||
try {
|
||
const vision = await apiFetch('/vision/stats');
|
||
renderVisionStats(vision, visionEl, visionStatus);
|
||
} catch (e) {
|
||
visionEl.innerHTML = `<div class="empty-state"><h3>Unable to Load Vision Stats</h3><p>${e.message}</p></div>`;
|
||
}
|
||
}
|
||
|
||
function renderVisionStats(data, visionEl, statusEl) {
|
||
const cats = data?.categories || {};
|
||
if (statusEl) statusEl.textContent = data?.status === 'not_configured' ? 'Notion not configured' : 'Notion source';
|
||
const entries = Object.entries(cats);
|
||
if (entries.length === 0) {
|
||
visionEl.innerHTML = `<div class="empty-state"><h3>No vision stats</h3><p>Configure Notion databases to see ingestion counts.</p></div>`;
|
||
return;
|
||
}
|
||
visionEl.innerHTML = entries.map(([k, v]) => {
|
||
return `<div class="vision-card">
|
||
<div class="vision-label">${k}</div>
|
||
<div class="vision-count">${v.count ?? 0}</div>
|
||
<div class="vision-sub">Today</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
// Override tasks loader to cron loader
|
||
loadTasks = async function() {
|
||
await loadOps();
|
||
};
|
||
|
||
// ─── Sessions Panel ───
|
||
// ─── Model Selector ───
|
||
const MODEL_OPTIONS = [
|
||
{ value: 'default', label: '默认', short: 'default' },
|
||
{ value: 'opus', label: 'Opus', full: 'claude-opus-4-6' },
|
||
{ value: 'sonnet', label: 'Sonnet', full: 'claude-sonnet-4-6' },
|
||
{ value: 'flash', label: 'Flash', full: 'gemini-3.0-flash' },
|
||
{ value: 'pro', label: 'Pro', full: 'gemini-3.1-pro' },
|
||
{ value: 'codex', label: 'Codex', full: 'gpt-5.3-codex' },
|
||
];
|
||
|
||
function buildModelSelect(currentModel, id, type) {
|
||
// type: 'session' (channelId) or 'cron' (jobId)
|
||
const opts = MODEL_OPTIONS.map(o => {
|
||
const isCurrent = o.full ? currentModel.includes(o.full) : (!currentModel || currentModel === 'unknown');
|
||
return `<option value="${o.value}" ${isCurrent ? 'selected' : ''}>${o.label}</option>`;
|
||
}).join('');
|
||
const color = getModelColor(currentModel);
|
||
return `<select class="model-select" style="border-color:${color};color:${color}"
|
||
onchange="changeModel('${type}','${id}',this.value,this)">${opts}</select>`;
|
||
}
|
||
|
||
async function changeModel(type, id, model, el) {
|
||
el.disabled = true;
|
||
el.style.opacity = '0.5';
|
||
const endpoint = type === 'session' ? '/ops/session-model' : '/ops/cron-model';
|
||
const body = type === 'session' ? { channelId: id, model } : { jobId: id, model };
|
||
try {
|
||
await apiFetch(endpoint, { method: 'POST', body: JSON.stringify(body) });
|
||
el.style.borderColor = 'var(--green)';
|
||
el.style.color = 'var(--green)';
|
||
setTimeout(() => {
|
||
el.style.opacity = '1';
|
||
if (type === 'session') loadSessions();
|
||
else loadCronEnhanced();
|
||
}, 800);
|
||
} catch (e) {
|
||
el.style.borderColor = '#f87171';
|
||
el.style.color = '#f87171';
|
||
el.disabled = false;
|
||
el.style.opacity = '1';
|
||
alert('模型切换失败: ' + e.message);
|
||
}
|
||
}
|
||
|
||
const SESSION_SORT_DEFAULT_DIR = {
|
||
model: 'asc',
|
||
messages: 'desc',
|
||
tokens: 'desc',
|
||
cost: 'desc',
|
||
costPerMsg: 'desc',
|
||
fit: 'desc',
|
||
};
|
||
|
||
const sessionSortState = {
|
||
key: null,
|
||
dir: 'desc',
|
||
};
|
||
|
||
function toggleSessionSort(key) {
|
||
if (sessionSortState.key === key) {
|
||
sessionSortState.dir = sessionSortState.dir === 'asc' ? 'desc' : 'asc';
|
||
} else {
|
||
sessionSortState.key = key;
|
||
sessionSortState.dir = SESSION_SORT_DEFAULT_DIR[key] || 'desc';
|
||
}
|
||
loadSessions();
|
||
}
|
||
|
||
function sessionSortIndicator(key) {
|
||
if (sessionSortState.key !== key) return '↔';
|
||
return sessionSortState.dir === 'asc' ? '↑' : '↓';
|
||
}
|
||
|
||
async function loadSessions() {
|
||
const alertsEl = document.getElementById('sessionsAlerts');
|
||
const tableEl = document.getElementById('sessionsTable');
|
||
if (!tableEl) return;
|
||
|
||
try {
|
||
const data = await apiFetch('/ops/sessions');
|
||
const sessions = data.sessions || [];
|
||
const alerts = data.alerts || [];
|
||
const summary = data.summary || {};
|
||
|
||
// Update header cards from session data
|
||
const mainBadge = document.getElementById('mainAgentBadge');
|
||
const mainValue = document.getElementById('mainAgentValue');
|
||
const mainDetail = document.getElementById('mainAgentDetail');
|
||
if (mainValue) {
|
||
mainBadge.className = 'agent-stat-badge ' + (alerts.filter(a=>a.type==='error').length > 0 ? 'error' : 'active');
|
||
mainBadge.innerHTML = alerts.length > 0 ? `⚠️ ${alerts.length} alerts` : '✅ healthy';
|
||
mainValue.textContent = '$' + (summary.todayCost || 0).toFixed(2);
|
||
mainDetail.textContent = (summary.todayMessages || 0) + ' messages today';
|
||
}
|
||
const subVal = document.getElementById('subagentValue');
|
||
if (subVal) {
|
||
subVal.textContent = fmtTokens(sessions.reduce((s, r) => s + r.today.totalTokens, 0));
|
||
document.getElementById('subagentDetail').textContent = shortModel(summary.topModel);
|
||
}
|
||
const hookVal = document.getElementById('hookValue');
|
||
if (hookVal) {
|
||
hookVal.textContent = summary.active || 0;
|
||
document.getElementById('hookDetail').textContent = summary.total + ' total';
|
||
}
|
||
|
||
// Alerts
|
||
if (alertsEl) {
|
||
alertsEl.innerHTML = alerts.map(a =>
|
||
`<div class="sess-alert ${a.type}"><span>${a.type === 'error' ? '🔴' : a.type === 'waste' ? '🟡' : '⚪'}</span><strong>${escHtml(a.session)}</strong> ${escHtml(a.msg)}</div>`
|
||
).join('');
|
||
}
|
||
|
||
// Table
|
||
if (sessions.length === 0) {
|
||
tableEl.innerHTML = '<div class="empty-state"><h3>No sessions</h3></div>';
|
||
return;
|
||
}
|
||
|
||
// Chinese names + task type for Discord channels
|
||
const channelMeta = {
|
||
'#dev_build': { cn: '开发构建', task: '🔧 深度开发', tier: 'hard' },
|
||
'#ai-learning': { cn: 'AI学习', task: '🧠 架构讨论', tier: 'hard' },
|
||
'#ops-report': { cn: '运维报告', task: '📊 汇报转发', tier: 'easy' },
|
||
'#general': { cn: '综合频道', task: '💬 闲聊', tier: 'easy' },
|
||
'#openclaw-watch': { cn: '生态监控', task: '🔍 监控播报', tier: 'medium' },
|
||
'#jobs-intel': { cn: '求职情报', task: '💼 搜索整理', tier: 'medium' },
|
||
'#x-ai-socal-radar': { cn: 'X/AI雷达', task: '🐦 内容创作', tier: 'hard' },
|
||
'#tech-news': { cn: '科技新闻', task: '📰 摘要生成', tier: 'medium' },
|
||
'#podcast_video_article': { cn: '播客/视频', task: '📝 内容摘要', tier: 'medium' },
|
||
'#meta-vision-ingest': { cn: '视觉入口', task: '👁️ 图片路由', tier: 'easy' },
|
||
'#socal-ai-events': { cn: '南加AI活动', task: '🎯 活动搜索', tier: 'medium' },
|
||
'#event-planning': { cn: '活动策划', task: '📅 规划', tier: 'easy' },
|
||
'#networking-log': { cn: '人脉记录', task: '👤 信息录入', tier: 'easy' },
|
||
'#工作搭子碎碎念': { cn: '工作搭子', task: '💬 闲聊', tier: 'easy' },
|
||
'#饮酒': { cn: '饮酒', task: '🍷 品鉴记录', tier: 'easy' },
|
||
'#灰茄': { cn: '灰茄', task: '🚬 品鉴记录', tier: 'easy' },
|
||
'#品茶': { cn: '品茶', task: '🍵 品鉴记录', tier: 'easy' },
|
||
'#养花': { cn: '养花', task: '🌱 记录', tier: 'easy' },
|
||
'#灵修': { cn: '灵修', task: '📖 灵修提醒', tier: 'easy' },
|
||
};
|
||
|
||
// Model tier: what complexity level is this model suited for
|
||
const modelTier = m => {
|
||
if (!m) return 'easy';
|
||
if (m.includes('opus')) return 'hard';
|
||
if (m.includes('sonnet') || m.includes('codex') || m.includes('pro')) return 'medium';
|
||
return 'easy'; // flash, etc.
|
||
};
|
||
|
||
// Fit assessment
|
||
function fitLabel(s) {
|
||
const meta = channelMeta[s.displayName];
|
||
const taskTier = meta?.tier || 'medium';
|
||
const mTier = modelTier(s.model);
|
||
const tiers = { easy: 0, medium: 1, hard: 2 };
|
||
const diff = tiers[mTier] - tiers[taskTier];
|
||
if (diff >= 2) return { emoji: '🔴', text: '过高', tip: '模型远超任务需求,建议降级' };
|
||
if (diff === 1) return { emoji: '🟡', text: '偏高', tip: '可考虑降级节省成本' };
|
||
if (diff === 0) return { emoji: '🟢', text: '匹配', tip: '模型与任务复杂度匹配' };
|
||
if (diff === -1) return { emoji: '🔵', text: '偏低', tip: '任务较复杂,可考虑升级' };
|
||
return { emoji: '⚪', text: '—', tip: '' };
|
||
}
|
||
|
||
const fitSortRank = s => {
|
||
const text = fitLabel(s).text;
|
||
if (text === '过高') return 4;
|
||
if (text === '偏高') return 3;
|
||
if (text === '匹配') return 2;
|
||
if (text === '偏低') return 1;
|
||
return 0;
|
||
};
|
||
|
||
const getSortValue = (s, key) => {
|
||
const eff = s.today.effectiveMessages || 0;
|
||
if (key === 'model') return shortModel(s.model || '').toLowerCase();
|
||
if (key === 'messages') return s.today.messages || 0;
|
||
if (key === 'tokens') return s.today.totalTokens || 0;
|
||
if (key === 'cost') return s.today.cost || 0;
|
||
if (key === 'costPerMsg') return eff > 0 ? (s.today.cost || 0) / eff : null;
|
||
if (key === 'fit') return fitSortRank(s);
|
||
return null;
|
||
};
|
||
|
||
const sortedSessions = [...sessions];
|
||
if (sessionSortState.key) {
|
||
const dir = sessionSortState.dir === 'asc' ? 1 : -1;
|
||
sortedSessions.sort((a, b) => {
|
||
const av = getSortValue(a, sessionSortState.key);
|
||
const bv = getSortValue(b, sessionSortState.key);
|
||
if (av == null && bv == null) return a.displayName.localeCompare(b.displayName, 'zh-Hans');
|
||
if (av == null) return 1;
|
||
if (bv == null) return -1;
|
||
if (typeof av === 'string' || typeof bv === 'string') {
|
||
const cmp = String(av).localeCompare(String(bv), 'zh-Hans');
|
||
if (cmp !== 0) return cmp * dir;
|
||
return a.displayName.localeCompare(b.displayName, 'zh-Hans');
|
||
}
|
||
if (av === bv) return a.displayName.localeCompare(b.displayName, 'zh-Hans');
|
||
return (av - bv) * dir;
|
||
});
|
||
}
|
||
|
||
let html = `<table class="sessions-table">
|
||
<thead><tr>
|
||
<th>频道</th>
|
||
<th>任务</th>
|
||
<th><button type="button" class="sess-sort-btn ${sessionSortState.key === 'model' ? 'active' : ''}" onclick="toggleSessionSort('model')">模型 <span class="sess-sort-indicator">${sessionSortIndicator('model')}</span></button></th>
|
||
<th><button type="button" class="sess-sort-btn ${sessionSortState.key === 'messages' ? 'active' : ''}" onclick="toggleSessionSort('messages')">消息 <span class="sess-sort-indicator">${sessionSortIndicator('messages')}</span></button></th>
|
||
<th><button type="button" class="sess-sort-btn ${sessionSortState.key === 'tokens' ? 'active' : ''}" onclick="toggleSessionSort('tokens')">Tokens <span class="sess-sort-indicator">${sessionSortIndicator('tokens')}</span></button></th>
|
||
<th><button type="button" class="sess-sort-btn ${sessionSortState.key === 'cost' ? 'active' : ''}" onclick="toggleSessionSort('cost')">花费 <span class="sess-sort-indicator">${sessionSortIndicator('cost')}</span></button></th>
|
||
<th><button type="button" class="sess-sort-btn ${sessionSortState.key === 'costPerMsg' ? 'active' : ''}" onclick="toggleSessionSort('costPerMsg')">$/条 <span class="sess-sort-indicator">${sessionSortIndicator('costPerMsg')}</span></button></th>
|
||
<th><button type="button" class="sess-sort-btn ${sessionSortState.key === 'fit' ? 'active' : ''}" onclick="toggleSessionSort('fit')">匹配 <span class="sess-sort-indicator">${sessionSortIndicator('fit')}</span></button></th>
|
||
</tr></thead><tbody>`;
|
||
|
||
for (const s of sortedSessions) {
|
||
const meta = channelMeta[s.displayName] || {};
|
||
const cn = meta.cn || '';
|
||
const taskTag = meta.task || '';
|
||
const eff = s.today.effectiveMessages || 0;
|
||
const costPerMsg = eff > 0 ? (s.today.cost / eff) : 0;
|
||
const costColor = costPerMsg > 1.5 ? '#f87171' : costPerMsg > 0.5 ? '#fbbf24' : 'var(--green)';
|
||
const fit = fitLabel(s);
|
||
|
||
const nameHtml = cn
|
||
? `<span class="sess-name">${escHtml(s.displayName)}</span><br><span style="color:var(--text2);font-size:.65rem">${cn}</span>`
|
||
: `<span class="sess-name">${escHtml(s.displayName)}</span>`;
|
||
|
||
// Model selector dropdown
|
||
const modelSelect = s.channelId ? buildModelSelect(s.model, s.channelId, 'session') : `<span class="sess-model" style="border-color:${getModelColor(s.model)};color:${getModelColor(s.model)}">${shortModel(s.model)}</span>`;
|
||
|
||
html += `<tr>
|
||
<td><span class="sess-status ${s.status}"></span>${nameHtml}</td>
|
||
<td style="font-size:.7rem">${taskTag}</td>
|
||
<td>${modelSelect}</td>
|
||
<td>${s.today.messages}<span style="color:var(--text2);font-size:.65rem"><br>${eff} 有效</span></td>
|
||
<td>${fmtTokens(s.today.totalTokens)}</td>
|
||
<td style="font-weight:600">$${s.today.cost.toFixed(2)}</td>
|
||
<td style="color:${costColor};font-weight:600">${eff > 0 ? '$' + costPerMsg.toFixed(2) : '—'}</td>
|
||
<td title="${fit.tip}">${fit.emoji} <span style="font-size:.65rem">${fit.text}</span></td>
|
||
</tr>`;
|
||
}
|
||
html += '</tbody></table>';
|
||
tableEl.innerHTML = html;
|
||
} catch (e) {
|
||
tableEl.innerHTML = `<div class="empty-state"><p>${e.message}</p></div>`;
|
||
}
|
||
}
|
||
|
||
async function loadQuality() {
|
||
const el = document.getElementById('qualityContent');
|
||
if (!el) return;
|
||
try {
|
||
const data = await apiFetch('/ops/sessions');
|
||
const sessions = (data.sessions || []).filter(s => s.today.messages > 0);
|
||
// Sort by idle rate desc
|
||
sessions.sort((a, b) => b.today.noReplyRate - a.today.noReplyRate);
|
||
|
||
let html = '<div class="glass-card" style="padding:16px;margin-bottom:12px"><div class="card-title">Session Quality (Today)</div><div class="card-sub">静默率 = (NO_REPLY 无回复 + HEARTBEAT_OK 心跳) / 总消息数。高静默率说明该频道大量消息不需要回复,可考虑降级模型节省成本。</div></div>';
|
||
html += '<div class="ops-channel-list">';
|
||
for (const s of sessions) {
|
||
const barWidth = Math.min(s.today.noReplyRate, 100);
|
||
const color = s.today.noReplyRate > 60 ? '#f87171' : s.today.noReplyRate > 30 ? '#fbbf24' : '#34d399';
|
||
html += `<div class="ops-channel-card">
|
||
<div class="ops-ch-left" style="flex:1">
|
||
<div class="ops-ch-name">${escHtml(s.displayName)}</div>
|
||
<div style="height:6px;background:var(--border);border-radius:3px;margin-top:4px">
|
||
<div style="width:${barWidth}%;height:100%;background:${color};border-radius:3px"></div>
|
||
</div>
|
||
<div class="ops-ch-meta"><span>${s.today.messages} msgs</span><span>${s.today.effectiveMessages} effective</span><span>${s.today.noReply} silent</span><span>${s.today.heartbeat} heartbeat</span></div>
|
||
</div>
|
||
<div class="ops-ch-right"><div class="ops-ch-tokens" style="color:${color}">${s.today.noReplyRate}%</div><div class="ops-ch-cost">静默率</div></div>
|
||
</div>`;
|
||
}
|
||
html += '</div>';
|
||
el.innerHTML = html;
|
||
} catch (e) { el.innerHTML = `<p>${e.message}</p>`; }
|
||
}
|
||
|
||
async function loadAudit() {
|
||
const el = document.getElementById('auditContent2');
|
||
if (!el) return;
|
||
try {
|
||
const data = await apiFetch('/ops/sessions');
|
||
const sessions = (data.sessions || []).filter(s => s.today.messages > 0);
|
||
|
||
const recommendations = [];
|
||
for (const s of sessions) {
|
||
// Expensive model with high idle rate
|
||
if (s.model?.includes('opus') && s.today.noReplyRate > 40 && s.today.messages > 3) {
|
||
recommendations.push({ severity: 'high', session: s.displayName, msg: `Using Opus but ${s.today.noReplyRate}% idle → switch to Sonnet (save ~$${(s.today.cost * 0.8).toFixed(0)}/day)`, model: s.model });
|
||
}
|
||
// Opus for simple channel
|
||
if (s.model?.includes('opus') && s.today.effectiveMessages < 5 && s.today.cost > 1) {
|
||
recommendations.push({ severity: 'medium', session: s.displayName, msg: `Opus overkill — only ${s.today.effectiveMessages} effective msgs, costing $${s.today.cost.toFixed(2)}`, model: s.model });
|
||
}
|
||
// No thinking level set
|
||
if (s.thinkingLevel === '—' && s.today.messages > 0) {
|
||
recommendations.push({ severity: 'low', session: s.displayName, msg: 'No thinking level set — consider setting to "low" to save tokens', model: s.model });
|
||
}
|
||
}
|
||
|
||
let html = '<div class="glass-card" style="padding:16px;margin-bottom:12px"><div class="card-title">Config Audit</div><div class="card-sub">' + recommendations.length + ' recommendations</div></div>';
|
||
|
||
if (recommendations.length === 0) {
|
||
html += '<div class="empty-state"><h3>✅ All Good</h3><p>No optimization opportunities detected.</p></div>';
|
||
} else {
|
||
html += '<div class="ops-channel-list">';
|
||
const sevColors = { high: '#f87171', medium: '#fbbf24', low: '#6b7280' };
|
||
for (const r of recommendations) {
|
||
html += `<div class="sess-alert" style="background:${sevColors[r.severity]}15;border:1px solid ${sevColors[r.severity]}40">
|
||
<span style="font-size:1.1rem">${r.severity === 'high' ? '🔴' : r.severity === 'medium' ? '🟡' : '⚪'}</span>
|
||
<div><strong>${escHtml(r.session)}</strong><br><span style="font-size:.8rem;color:var(--text2)">${escHtml(r.msg)}</span></div>
|
||
</div>`;
|
||
}
|
||
html += '</div>';
|
||
}
|
||
|
||
// Provider audit (from existing)
|
||
try {
|
||
const audit = await apiFetch('/ops/audit');
|
||
html += '<div class="glass-card" style="padding:16px;margin-top:12px"><div class="card-title">Provider Verification</div>';
|
||
const oi = audit.openai;
|
||
if (oi?.status === 'ok') {
|
||
html += `<div style="margin:8px 0"><strong>OpenAI</strong> <span class="pill" style="border-color:#34d399;color:#34d399">✓</span> 7d: ${oi.totals.requests} reqs</div>`;
|
||
}
|
||
const ac = audit.anthropic;
|
||
if (ac?.org) {
|
||
html += `<div><strong>Anthropic</strong> <span class="pill" style="border-color:#c084fc;color:#c084fc">org ✓</span> ${ac.org.name} · ${ac.activeKeys?.length} keys</div>`;
|
||
}
|
||
html += '</div>';
|
||
} catch {}
|
||
|
||
// System info
|
||
try {
|
||
const sys = await apiFetch('/ops/system');
|
||
const memPct = sys.memory?.usePct || '—';
|
||
const memUsed = ((sys.memory?.used || 0) / 1073741824).toFixed(1);
|
||
const memTotal = ((sys.memory?.total || 0) / 1073741824).toFixed(1);
|
||
const load = sys.loadAvg?.['1m']?.toFixed(2) || '—';
|
||
const uptimeH = Math.floor((sys.dashboardUptime || 0) / 3600);
|
||
const uptimeM = Math.floor(((sys.dashboardUptime || 0) % 3600) / 60);
|
||
html += `<div class="glass-card" style="padding:16px;margin-top:12px">
|
||
<div class="card-title">🖥️ System (${escHtml(sys.hostname || '')})</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px;font-size:.82rem">
|
||
<div>💻 <strong>${escHtml(sys.macModel || sys.platform)}</strong></div>
|
||
<div>🍎 macOS ${escHtml(sys.macOS || '—')}</div>
|
||
<div>🧮 CPU: ${sys.cpus} cores · Load: ${load}</div>
|
||
<div>💾 RAM: ${memUsed}/${memTotal} GB (${memPct}%)</div>
|
||
<div>💿 Disk: ${sys.disk?.used || '—'} / ${sys.disk?.total || '—'} (${sys.disk?.usePct || '—'})</div>
|
||
<div>⏱️ Dashboard: ${uptimeH}h ${uptimeM}m</div>
|
||
<div>📦 Node: ${escHtml(sys.nodeVersion || '—')}</div>
|
||
<div>🦞 OpenClaw: ${escHtml(sys.clawVersion || '—')}</div>
|
||
</div>
|
||
<div style="margin-top:8px">
|
||
<div style="font-size:.72rem;color:var(--text2);margin-bottom:2px">Memory ${memPct}%</div>
|
||
<div style="height:8px;border-radius:4px;background:rgba(255,255,255,.1);overflow:hidden">
|
||
<div style="height:100%;width:${memPct}%;background:${+memPct>80?'var(--red)':+memPct>60?'var(--yellow)':'var(--green)'};border-radius:4px;transition:width .5s"></div>
|
||
</div>
|
||
<div style="font-size:.72rem;color:var(--text2);margin:4px 0 2px">Disk ${sys.disk?.usePct || '—'}</div>
|
||
<div style="height:8px;border-radius:4px;background:rgba(255,255,255,.1);overflow:hidden">
|
||
<div style="height:100%;width:${sys.disk?.usePct || '0%'};background:${parseInt(sys.disk?.usePct)>80?'var(--red)':parseInt(sys.disk?.usePct)>60?'var(--yellow)':'var(--green)'};border-radius:4px;transition:width .5s"></div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
} catch {}
|
||
|
||
el.innerHTML = html;
|
||
} catch (e) { el.innerHTML = `<p>${e.message}</p>`; }
|
||
}
|
||
|
||
function timeSince(ts) {
|
||
const s = Math.floor((Date.now() - ts) / 1000);
|
||
if (s < 60) return s + 's ago';
|
||
if (s < 3600) return Math.floor(s / 60) + 'm ago';
|
||
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
|
||
return Math.floor(s / 86400) + 'd ago';
|
||
}
|
||
|
||
// ─── Config Viewer ───
|
||
async function loadConfig() {
|
||
const el = document.getElementById('configContent');
|
||
if (!el) return;
|
||
try {
|
||
const data = await apiFetch('/ops/config');
|
||
const files = data.files || [];
|
||
const cats = { core: '⚙️ Core Config', keys: '🔑 API Keys', personality: '🎭 Personality & Agents' };
|
||
const grouped = {};
|
||
files.forEach(f => {
|
||
const cat = f.category || 'other';
|
||
if (!grouped[cat]) grouped[cat] = [];
|
||
grouped[cat].push(f);
|
||
});
|
||
|
||
let html = '';
|
||
for (const [cat, label] of Object.entries(cats)) {
|
||
if (!grouped[cat]) continue;
|
||
html += `<div class="card-title" style="margin:12px 0 8px">${label}</div>`;
|
||
for (const f of grouped[cat]) {
|
||
const id = 'cfg-' + f.label.replace(/[^a-z0-9]/gi, '-');
|
||
const sizeKb = (f.size / 1024).toFixed(1);
|
||
const modified = new Date(f.modified).toLocaleString('en-US', { timeZone: 'America/Los_Angeles', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||
html += `<div class="config-file">
|
||
<div class="config-file-header" onclick="document.getElementById('${id}').classList.toggle('open')">
|
||
<span>${escHtml(f.label)}<span class="config-cat ${cat}">${cat}</span></span>
|
||
<span class="config-file-meta">${sizeKb}KB · ${modified}</span>
|
||
</div>
|
||
<div class="config-file-body" id="${id}"><pre>${escHtml(f.content)}</pre></div>
|
||
</div>`;
|
||
}
|
||
}
|
||
el.innerHTML = html;
|
||
} catch (e) { el.innerHTML = `<p>${e.message}</p>`; }
|
||
}
|
||
|
||
// ─── Enhanced Cron ───
|
||
async function loadCronEnhanced() {
|
||
const panel = document.getElementById('panel-tasks');
|
||
if (!panel) return;
|
||
const opsGrid = panel.querySelector('.ops-grid');
|
||
if (!opsGrid) return;
|
||
|
||
let jobsContainer = document.getElementById('cronJobsContainer');
|
||
if (!jobsContainer) {
|
||
jobsContainer = document.createElement('div');
|
||
jobsContainer.id = 'cronJobsContainer';
|
||
jobsContainer.style.marginBottom = '12px';
|
||
panel.insertBefore(jobsContainer, opsGrid);
|
||
}
|
||
|
||
try {
|
||
const data = await apiFetch('/ops/cron');
|
||
const jobs = data.jobs || [];
|
||
|
||
let html = `<div class="glass-card" style="padding:14px;margin-bottom:12px">
|
||
<div class="card-title">Cron Jobs</div>
|
||
<div class="card-sub">${data.total} 个任务 · ${data.enabled} 启用 · ${data.disabled} 停用</div>
|
||
</div>`;
|
||
|
||
for (const j of jobs) {
|
||
const statusDot = !j.enabled ? 'off' : (j.lastRun?.status === 'finished' ? 'ok' : (j.lastRun?.status ? 'fail' : 'ok'));
|
||
const lastRunText = j.lastRun ? (() => {
|
||
const ago = timeSince(j.lastRun.ts || Date.now());
|
||
const dur = j.lastRun.durationMs ? (j.lastRun.durationMs / 1000).toFixed(0) + 's' : '';
|
||
const tokens = j.lastRun.tokens ? fmtTokens(j.lastRun.tokens) : '';
|
||
return [ago, dur, tokens, j.lastRun.model ? shortModel(j.lastRun.model) : ''].filter(Boolean).join(' · ');
|
||
})() : '尚未运行';
|
||
|
||
html += `<div class="cron-card ${j.enabled ? '' : 'disabled'}">
|
||
<div class="cron-header">
|
||
<div>
|
||
<div class="cron-name">${escHtml(j.name)}</div>
|
||
<div class="cron-schedule">🕐 ${escHtml(j.schedule)}</div>
|
||
</div>
|
||
<span class="cron-status"><span class="dot ${statusDot}"></span>${j.enabled ? '启用' : '停用'}</span>
|
||
</div>
|
||
<div class="cron-desc">${escHtml(j.description)}</div>
|
||
<div class="cron-footer">
|
||
<span>📋 ${j.payloadKind || '—'}</span>
|
||
<span>🧠 ${buildModelSelect(j.model || '', j.id, 'cron')}</span>
|
||
<span>⏱ 上次: ${lastRunText}</span>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
jobsContainer.innerHTML = html;
|
||
} catch (e) {
|
||
jobsContainer.innerHTML = `<div class="glass-card" style="padding:14px;margin-bottom:12px"><p>${escHtml(e.message)}</p></div>`;
|
||
}
|
||
}
|
||
|
||
// ─── Ops Channel Usage Panel ───
|
||
const MODEL_COLORS = {
|
||
'claude-opus-4-6': '#c084fc', 'claude-sonnet-4-6': '#818cf8',
|
||
'gpt-5.2-codex': '#34d399', 'gpt-5.2': '#6ee7b7',
|
||
'gemini-3-pro': '#fbbf24', 'gemini-3-flash': '#fcd34d',
|
||
'gemini-3-pro-preview': '#fbbf24', 'gemini-3-flash-preview': '#fcd34d',
|
||
};
|
||
|
||
function getModelColor(model) {
|
||
const key = Object.keys(MODEL_COLORS).find(k => (model || '').includes(k));
|
||
return key ? MODEL_COLORS[key] : '#6b7280';
|
||
}
|
||
|
||
function shortModel(m) {
|
||
return (m || 'unknown')
|
||
.replace(/-preview$/, '')
|
||
.replace('claude-', '')
|
||
.replace('gemini-3-pro', 'Gemini-3-Pro')
|
||
.replace('gemini-3-flash', 'Gemini-3-Flash')
|
||
.replace('gpt-5.3-', 'gpt-')
|
||
.replace('gpt-5.2-', 'gpt-');
|
||
}
|
||
|
||
function fmtTokens(n) {
|
||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
||
if (n >= 1_000) return (n / 1_000).toFixed(0) + 'k';
|
||
return String(n);
|
||
}
|
||
|
||
function fmtUsd(n, digits = 2) {
|
||
return '$' + (Number(n || 0)).toFixed(digits);
|
||
}
|
||
|
||
async function loadCronCosts() {
|
||
const summaryEl = document.getElementById('cronCostSummary');
|
||
const contentEl = document.getElementById('cronCostContent');
|
||
const canvas = document.getElementById('cronTrendChart');
|
||
const legendEl = document.getElementById('cronTrendLegend');
|
||
if (!contentEl) return;
|
||
|
||
try {
|
||
const data = await apiFetch('/ops/cron-costs');
|
||
const s = data.summary || {};
|
||
const today = s.today || {};
|
||
summaryEl.textContent = `累计 ${s.totalRuns || 0} 次执行 · 总固定成本 ${fmtUsd(s.totalCronCost, 2)}(${fmtTokens(s.totalCronTokens || 0)} tokens) · 日均固定成本 ${fmtUsd(s.avgDailyCronCost, 2)} · 基线固定 ${fmtUsd(s.avgFixedBaselineCost, 2)} / 任务量波动 ${fmtUsd(s.avgWorkloadVariableCost, 2)} / 交互波动 ${fmtUsd(s.avgInteractiveVariableCost, 2)} · 今日固定 ${fmtUsd(today.cronCost, 2)}(${fmtTokens(today.cronTokens || 0)}) · 今日浮动 ${fmtUsd(today.interactiveCost, 2)} · ${s.days || 0} 天`;
|
||
|
||
// Per-job cost table (each run + each day)
|
||
const jobs = data.jobs || [];
|
||
const review = data.review || {};
|
||
const rc = review.cron || {};
|
||
const ri = review.interactive || {};
|
||
const cov = review.coverage || {};
|
||
let html = `<div class="glass-card" style="padding:10px;margin-bottom:10px">
|
||
<div style="font-size:.8rem;font-weight:600;margin-bottom:6px">🔎 数据质量 Review</div>
|
||
<div style="font-size:.74rem;color:var(--text2);display:flex;gap:14px;flex-wrap:wrap">
|
||
<span>Cron finished: <b>${rc.finishedRuns || 0}</b></span>
|
||
<span>无 usage: <b style="color:${(rc.runsWithoutUsage || 0) > 0 ? '#fbbf24' : 'var(--green)'}">${rc.runsWithoutUsage || 0}</b></span>
|
||
<span>零 tokens: <b style="color:${(rc.runsWithZeroTokens || 0) > 0 ? '#fbbf24' : 'var(--green)'}">${rc.runsWithZeroTokens || 0}</b></span>
|
||
<span>交互覆盖天数: <b>${cov.daysWithInteractive || 0}/${cov.daysWithCron || 0}</b>(${cov.interactiveCoveragePct || 0}%)</span>
|
||
<span>交互 usage消息: <b>${ri.messagesWithUsage || 0}</b></span>
|
||
</div>
|
||
${(review.notes || []).length > 0 ? `<div style="margin-top:6px;font-size:.72rem;color:#fbbf24">${(review.notes || []).map(n => `• ${escHtml(n)}`).join('<br>')}</div>` : ''}
|
||
</div>`;
|
||
|
||
html += '<table class="sessions-table"><thead><tr><th>Cron 任务</th><th>总次数</th><th>平均时长/次</th><th>Tokens/次</th><th>$/次</th><th>今日(tokens / $)</th><th>日均 $</th><th>总花费</th></tr></thead><tbody>';
|
||
for (const j of jobs) {
|
||
html += `<tr>
|
||
<td style="font-weight:600;font-size:.78rem">
|
||
${escHtml(j.name)}
|
||
<div style="margin-top:4px"><span class="sess-model" style="border-color:${getModelColor(j.model)};color:${getModelColor(j.model)}">${shortModel(j.model)}</span></div>
|
||
</td>
|
||
<td>${j.runs}</td>
|
||
<td>${j.avgDurationSec ? `${j.avgDurationSec.toFixed(1)}s` : '—'}</td>
|
||
<td>${fmtTokens(j.tokensPerRun || 0)}</td>
|
||
<td style="color:${j.costPerRun > 0.2 ? '#fbbf24' : 'var(--green)'}">${fmtUsd(j.costPerRun, 3)}</td>
|
||
<td>${fmtTokens(j.today?.tokens || 0)} / ${fmtUsd(j.today?.cost, 3)}</td>
|
||
<td>${fmtUsd(j.avgDailyCost, 3)}</td>
|
||
<td style="font-weight:600">${fmtUsd(j.totalCost, 2)}</td>
|
||
</tr>`;
|
||
}
|
||
html += '</tbody></table>';
|
||
|
||
// Daily breakdown per cron (last 7 days)
|
||
if (jobs.length > 0) {
|
||
html += '<div style="margin-top:12px;display:grid;gap:8px">';
|
||
for (const j of jobs) {
|
||
const daily = (j.daily || []).slice(-7).reverse();
|
||
html += `<details class="glass-card" style="padding:10px">
|
||
<summary style="cursor:pointer;font-size:.78rem;font-weight:600">${escHtml(j.name)} · 最近 ${daily.length} 天每日成本</summary>
|
||
<div style="margin-top:8px;overflow:auto">
|
||
<table class="sessions-table" style="font-size:.74rem">
|
||
<thead><tr><th>日期</th><th>次数</th><th>Tokens</th><th>Tokens/次</th><th>$/次</th><th>当日总$</th></tr></thead>
|
||
<tbody>
|
||
${daily.map(d => `<tr>
|
||
<td>${d.date}</td>
|
||
<td>${d.runs}</td>
|
||
<td>${fmtTokens(d.tokens || 0)}</td>
|
||
<td>${fmtTokens(d.tokensPerRun || 0)}</td>
|
||
<td>${fmtUsd(d.costPerRun, 3)}</td>
|
||
<td style="font-weight:600">${fmtUsd(d.cost, 3)}</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</details>`;
|
||
}
|
||
html += '</div>';
|
||
}
|
||
|
||
contentEl.innerHTML = html;
|
||
|
||
// Trend chart: stacked bar (cron fixed + interactive variable)
|
||
const trend = data.dailyTrend || [];
|
||
if (canvas && trend.length > 1) {
|
||
const ctx = canvas.getContext('2d');
|
||
const W = canvas.parentElement.clientWidth - 32;
|
||
const H = 160;
|
||
canvas.width = W * 2; canvas.height = H * 2;
|
||
canvas.style.width = W + 'px'; canvas.style.height = H + 'px';
|
||
ctx.scale(2, 2);
|
||
|
||
const maxCost = Math.max(...trend.map(d => d.totalCost), 1);
|
||
const barW = Math.min(40, (W - 40) / trend.length - 4);
|
||
const startX = 36;
|
||
const chartH = H - 30;
|
||
|
||
ctx.clearRect(0, 0, W, H);
|
||
|
||
// Y axis labels
|
||
ctx.fillStyle = '#8b949e'; ctx.font = '10px sans-serif'; ctx.textAlign = 'right';
|
||
for (let i = 0; i <= 4; i++) {
|
||
const y = 10 + chartH - (i / 4) * chartH;
|
||
ctx.fillText('$' + (maxCost * i / 4).toFixed(0), 30, y + 3);
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.05)'; ctx.beginPath(); ctx.moveTo(startX, y); ctx.lineTo(W, y); ctx.stroke();
|
||
}
|
||
|
||
trend.forEach((d, i) => {
|
||
const x = startX + i * ((W - startX) / trend.length) + 2;
|
||
const fixedH = ((d.fixedBaselineCost || 0) / maxCost) * chartH;
|
||
const cronVarH = ((d.workloadVariableCost || 0) / maxCost) * chartH;
|
||
const interH = (d.interactiveCost / maxCost) * chartH;
|
||
const baseY = 10 + chartH;
|
||
|
||
// Interactive variable — bottom
|
||
ctx.fillStyle = 'rgba(124,92,252,0.6)';
|
||
ctx.fillRect(x, baseY - interH, barW, interH);
|
||
|
||
// Cron volume variable — middle
|
||
ctx.fillStyle = 'rgba(251,191,36,0.75)';
|
||
ctx.fillRect(x, baseY - interH - cronVarH, barW, cronVarH);
|
||
|
||
// Cron fixed baseline — top
|
||
ctx.fillStyle = 'rgba(45,212,160,0.8)';
|
||
ctx.fillRect(x, baseY - interH - cronVarH - fixedH, barW, fixedH);
|
||
|
||
// Date label
|
||
ctx.fillStyle = '#8b949e'; ctx.font = '9px sans-serif'; ctx.textAlign = 'center';
|
||
ctx.fillText(d.date.slice(5), x + barW / 2, baseY + 12);
|
||
|
||
// Total label
|
||
ctx.fillStyle = '#e6edf3'; ctx.font = 'bold 9px sans-serif';
|
||
ctx.fillText('$' + d.totalCost.toFixed(2), x + barW / 2, baseY - interH - cronVarH - fixedH - 3);
|
||
});
|
||
|
||
const avgFixed = trend.reduce((sum, d) => sum + (d.fixedCostSharePct || 0), 0) / trend.length;
|
||
legendEl.innerHTML = `<span style="color:#2dd4a0">■ 固定基线(Cron)</span><span style="color:#fbbf24">■ 任务量波动(Cron)</span><span style="color:#7c5cfc">■ 交互浮动成本</span><span style="color:#8b949e">固定占比均值 ${avgFixed.toFixed(0)}%</span>`;
|
||
} else if (legendEl) {
|
||
legendEl.textContent = '趋势数据不足(至少需要 2 天)';
|
||
}
|
||
} catch (e) {
|
||
contentEl.innerHTML = `<p>${e.message}</p>`;
|
||
}
|
||
}
|
||
|
||
async function loadOpsChannels() {
|
||
const listEl = document.getElementById('opsChannelList');
|
||
const subEl = document.getElementById('opsTotalSub');
|
||
const pillsEl = document.getElementById('opsTotalPills');
|
||
const barEl = document.getElementById('opsModelBar');
|
||
if (!listEl) return;
|
||
|
||
try {
|
||
const data = await apiFetch('/ops/channels');
|
||
const totals = data.totals || {};
|
||
const channels = data.channels || [];
|
||
|
||
// Total summary
|
||
if (subEl) subEl.textContent = `${fmtTokens(totals.totalTokens)} tokens · ${totals.messages || 0} messages · $${(totals.cost || 0).toFixed(2)}`;
|
||
if (pillsEl) {
|
||
const modelEntries = Object.entries(totals.models || {}).filter(([,t]) => t > 0).sort((a, b) => b[1] - a[1]);
|
||
pillsEl.innerHTML = modelEntries.slice(0, 4).map(([m, t]) =>
|
||
`<span class="pill" style="border-color:${getModelColor(m)};color:${getModelColor(m)}">${shortModel(m)} ${fmtTokens(t)}</span>`
|
||
).join('');
|
||
}
|
||
|
||
// Model distribution bar
|
||
if (barEl) {
|
||
const modelEntries = Object.entries(totals.models || {}).filter(([,t]) => t > 0).sort((a, b) => b[1] - a[1]);
|
||
const total = totals.totalTokens || 1;
|
||
// Bar segments (flex container) + legend below (separate div)
|
||
barEl.innerHTML =
|
||
'<div class="ops-bar-track">' +
|
||
modelEntries.map(([m, t]) =>
|
||
`<div style="width:${(t/total*100).toFixed(2)}%;background:${getModelColor(m)}" title="${shortModel(m)}: ${fmtTokens(t)}"></div>`
|
||
).join('') +
|
||
'</div>' +
|
||
'<div class="ops-model-legend">' + modelEntries.map(([m, t]) =>
|
||
`<span class="ops-model-legend-item"><span class="ops-model-dot" style="background:${getModelColor(m)}"></span>${shortModel(m)} ${((t/total)*100).toFixed(0)}%</span>`
|
||
).join('') + '</div>';
|
||
}
|
||
|
||
// Channel list
|
||
if (channels.length === 0) {
|
||
listEl.innerHTML = '<div class="empty-state"><h3>No activity today</h3><p>Channel usage will appear here once messages flow.</p></div>';
|
||
return;
|
||
}
|
||
listEl.innerHTML = channels.map(ch => {
|
||
const modelList = Object.entries(ch.today.models || {}).sort((a, b) => b[1] - a[1]);
|
||
const topModel = modelList[0] ? shortModel(modelList[0][0]) : '—';
|
||
const icon = ch.channel === 'discord' ? '🎮' : (ch.channel === 'whatsapp' ? '📱' : '💬');
|
||
const name = (ch.displayName || '').replace(/^discord:\d+#/, '#');
|
||
return `<div class="ops-channel-card">
|
||
<div class="ops-ch-left">
|
||
<div class="ops-ch-name">${icon} ${escHtml(name)}</div>
|
||
<div class="ops-ch-meta">
|
||
<span>${ch.today.messages} msgs</span>
|
||
<span>${topModel}</span>
|
||
<span>${ch.status}</span>
|
||
</div>
|
||
</div>
|
||
<div class="ops-ch-right">
|
||
<div class="ops-ch-tokens">${fmtTokens(ch.today.totalTokens)}</div>
|
||
<div class="ops-ch-cost">$${(ch.today.cost || 0).toFixed(2)}</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
} catch (e) {
|
||
listEl.innerHTML = `<div class="empty-state"><h3>Unable to load</h3><p>${e.message}</p></div>`;
|
||
}
|
||
}
|
||
|
||
async function loadOpsAlltime() {
|
||
const modelsEl = document.getElementById('alltimeModels');
|
||
const subEl = document.getElementById('alltimeSub');
|
||
const auditEl = document.getElementById('auditStatus');
|
||
const canvas = document.getElementById('dailyChart');
|
||
if (!modelsEl) return;
|
||
|
||
try {
|
||
const data = await apiFetch('/ops/alltime');
|
||
const t = data.totals || {};
|
||
|
||
if (subEl) subEl.textContent = `${fmtTokens(t.tokens)} tokens · ${t.messages || 0} messages · $${(t.cost || 0).toFixed(2)} est. · ${data.sessionFiles || 0} sessions`;
|
||
|
||
// Model breakdown
|
||
const models = data.models || [];
|
||
modelsEl.innerHTML = models.map(m => {
|
||
const pct = t.tokens > 0 ? ((m.tokens / t.tokens) * 100).toFixed(1) : '0';
|
||
return `<div class="ops-channel-card">
|
||
<div class="ops-ch-left">
|
||
<div class="ops-ch-name" style="font-size:.85rem"><span class="ops-model-dot" style="background:${getModelColor(m.name)};display:inline-block;margin-right:6px"></span>${shortModel(m.name)}</div>
|
||
<div class="ops-ch-meta"><span>${m.messages} msgs</span><span>${pct}%</span></div>
|
||
</div>
|
||
<div class="ops-ch-right">
|
||
<div class="ops-ch-tokens">${fmtTokens(m.tokens)}</div>
|
||
<div class="ops-ch-cost">$${(m.cost || 0).toFixed(2)}</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
// Daily charts
|
||
const daily = data.recentDaily || [];
|
||
if (canvas && daily.length > 0) {
|
||
drawDailyChart(canvas, daily);
|
||
}
|
||
const costCanvas = document.getElementById('dailyCostChart');
|
||
if (costCanvas && daily.length > 0) {
|
||
drawDailyCostChart(costCanvas, daily);
|
||
renderCostHeatmap(daily);
|
||
}
|
||
|
||
// Audit status
|
||
if (auditEl && data.audit) {
|
||
const a = data.audit;
|
||
auditEl.innerHTML = `🔍 <strong>Third-party Audit:</strong>
|
||
OpenAI <span class="pill">${a.openai?.status}</span>
|
||
Anthropic <span class="pill">${a.anthropic?.status}</span>
|
||
Google <span class="pill">${a.google?.status}</span>`;
|
||
}
|
||
} catch (e) {
|
||
modelsEl.innerHTML = `<div class="empty-state"><p>${e.message}</p></div>`;
|
||
}
|
||
}
|
||
|
||
async function loadOpsAudit() {
|
||
const el = document.getElementById('auditContent');
|
||
if (!el) return;
|
||
try {
|
||
const data = await apiFetch('/ops/audit');
|
||
let html = '';
|
||
|
||
// OpenAI
|
||
const oi = data.openai;
|
||
if (oi?.status === 'ok') {
|
||
const t = oi.totals;
|
||
const modelRows = Object.entries(oi.models || {}).sort((a, b) => b[1].input - a[1].input).map(([m, d]) =>
|
||
`<div class="ops-channel-card" style="padding:8px 12px">
|
||
<div class="ops-ch-left"><div class="ops-ch-name" style="font-size:.82rem">🟢 ${escHtml(m)}</div>
|
||
<div class="ops-ch-meta"><span>${d.requests} reqs</span><span>cached: ${fmtTokens(d.cached)}</span></div></div>
|
||
<div class="ops-ch-right"><div class="ops-ch-tokens">${fmtTokens(d.input + d.output)}</div></div></div>`
|
||
).join('');
|
||
html += `<div style="margin-bottom:12px">
|
||
<div style="font-weight:600;margin-bottom:6px">OpenAI <span class="pill" style="border-color:#34d399;color:#34d399">✓ verified</span></div>
|
||
<div class="ops-ch-meta" style="margin-bottom:8px">7d: ${fmtTokens(t.input)} in + ${fmtTokens(t.output)} out · ${t.requests} reqs · ${fmtTokens(t.cached)} cached</div>
|
||
<div class="ops-channel-list">${modelRows}</div>
|
||
${Object.keys(oi.days||{}).length > 0 ? `<div class="ops-ch-meta" style="margin-top:6px">Days: ${Object.entries(oi.days).sort().map(([d,v])=>d.slice(5)+':'+fmtTokens(v.input+v.output)).join(' · ')}</div>` : ''}
|
||
</div>`;
|
||
} else {
|
||
html += `<div style="margin-bottom:8px">OpenAI <span class="pill">${oi?.status || 'unknown'}</span> ${oi?.error || ''}</div>`;
|
||
}
|
||
|
||
// Anthropic
|
||
const ac = data.anthropic;
|
||
if (ac?.status === 'org_only') {
|
||
html += `<div style="margin-bottom:8px">
|
||
<div style="font-weight:600;margin-bottom:4px">Anthropic <span class="pill" style="border-color:#c084fc;color:#c084fc">org verified</span></div>
|
||
<div class="ops-ch-meta">Org: ${escHtml(ac.org?.name)} · ${ac.activeKeys?.length || 0} active keys</div>
|
||
<div class="ops-ch-meta" style="margin-top:2px;font-style:italic">${ac.note}</div>
|
||
</div>`;
|
||
} else {
|
||
html += `<div style="margin-bottom:8px">Anthropic <span class="pill">${ac?.status || 'unknown'}</span></div>`;
|
||
}
|
||
|
||
// Google
|
||
html += `<div>Google <span class="pill">${data.google?.status || 'no_api'}</span> <span class="ops-ch-meta">${data.google?.note || ''}</span></div>`;
|
||
|
||
el.innerHTML = html;
|
||
} catch (e) {
|
||
el.innerHTML = `<div class="ops-ch-meta">Failed: ${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
// ─── Ops Management Actions ───
|
||
async function opsAction(action) {
|
||
const btnMap = { backup: 'btnBackup', restore: 'btnRestore', updateOpenClaw: 'btnUpdateOpenClaw', restart: 'btnRestart' };
|
||
const badgeMap = { backup: 'badgeBackup', restore: 'badgeRestore', updateOpenClaw: 'badgeUpdateOpenClaw', restart: 'badgeRestart' };
|
||
const btn = document.getElementById(btnMap[action]);
|
||
const badge = document.getElementById(badgeMap[action]);
|
||
const resultBox = document.getElementById('opsMgmtResult');
|
||
const resultInner = document.getElementById('opsMgmtResultInner');
|
||
|
||
if (badge) badge.textContent = '⏳';
|
||
if (btn) btn.classList.add('loading');
|
||
if (resultBox) resultBox.style.display = 'none';
|
||
|
||
try {
|
||
let html = '';
|
||
|
||
if (action === 'restart') {
|
||
const r = await fetch('http://127.0.0.1:18789/hooks', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ action: 'restart', token: TOKEN })
|
||
});
|
||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||
if (badge) badge.textContent = '✅';
|
||
html = '<span style="color:var(--green)">✅ Restart signal sent to OpenClaw gateway.</span>';
|
||
|
||
} else if (action === 'backup') {
|
||
const data = await apiFetch('/backup', { method: 'POST' });
|
||
if (!data.ok) throw new Error(data.error || data.push?.error || 'backup failed');
|
||
if (badge) badge.textContent = '✅';
|
||
html = `<span style="color:var(--green)">✅ Backup + push completed.</span>
|
||
<div class="ops-cost-row"><span class="ops-cost-label">Remote</span><span class="ops-cost-value">${escHtml(data.push?.remote || 'n/a')}</span></div>
|
||
<div class="ops-cost-row"><span class="ops-cost-label">Branch</span><span class="ops-cost-value">${escHtml(data.push?.branch || 'n/a')}</span></div>
|
||
<pre style="margin-top:8px;font-size:.75rem;color:var(--text2);white-space:pre-wrap;word-break:break-all">${escHtml(data.output || '')}</pre>`;
|
||
|
||
} else if (action === 'restore') {
|
||
const ok = confirm('Load latest auto-backup now? This will overwrite current workspace changes.');
|
||
if (!ok) {
|
||
if (badge) badge.textContent = '';
|
||
html = '<span style="color:var(--text2)">Canceled.</span>';
|
||
} else {
|
||
const data = await apiFetch('/backup/load', { method: 'POST' });
|
||
if (badge) badge.textContent = '✅';
|
||
html = `<span style="color:var(--green)">✅ Backup loaded.</span>
|
||
<div class="ops-cost-row"><span class="ops-cost-label">Commit</span><span class="ops-cost-value">${escHtml((data.restoredCommit || '').slice(0, 12) || 'n/a')}</span></div>
|
||
<pre style="margin-top:8px;font-size:.75rem;color:var(--text2);white-space:pre-wrap;word-break:break-all">${escHtml(data.output || '')}</pre>`;
|
||
}
|
||
} else if (action === 'updateOpenClaw') {
|
||
const ok = confirm('Run OpenClaw update now? This will write memory, backup, push, then update.');
|
||
if (!ok) {
|
||
if (badge) badge.textContent = '';
|
||
html = '<span style="color:var(--text2)">Canceled.</span>';
|
||
} else {
|
||
const data = await apiFetch('/ops/update-openclaw', { method: 'POST' });
|
||
if (!data.ok) throw new Error('Update flow failed. See details below.');
|
||
if (badge) badge.textContent = '✅';
|
||
const stepRows = (data.steps || []).map(s =>
|
||
`<div class="ops-cost-row"><span class="ops-cost-label">${escHtml(s.step || 'step')}</span><span class="ops-cost-value">${s.ok ? '✅ ok' : '❌ failed'}</span></div>`
|
||
).join('');
|
||
const updateStep = (data.steps || []).find(s => s.step === 'update_openclaw') || {};
|
||
html = `<span style="color:var(--green)">✅ OpenClaw updated successfully.</span>
|
||
${stepRows}
|
||
<div class="ops-cost-row"><span class="ops-cost-label">Before</span><span class="ops-cost-value">${escHtml(updateStep.beforeVersion || 'n/a')}</span></div>
|
||
<div class="ops-cost-row"><span class="ops-cost-label">After</span><span class="ops-cost-value">${escHtml(updateStep.afterVersion || 'n/a')}</span></div>
|
||
<pre style="margin-top:8px;font-size:.75rem;color:var(--text2);white-space:pre-wrap;word-break:break-all">${escHtml(updateStep.output || '')}</pre>`;
|
||
}
|
||
}
|
||
|
||
if (resultInner) resultInner.innerHTML = html;
|
||
if (resultBox) resultBox.style.display = 'block';
|
||
} catch (e) {
|
||
if (badge) badge.textContent = '❌';
|
||
if (resultInner) resultInner.innerHTML = `<span style="color:var(--red)">❌ ${escHtml(e.message)}</span>`;
|
||
if (resultBox) resultBox.style.display = 'block';
|
||
} finally {
|
||
if (btn) btn.classList.remove('loading');
|
||
pollWatchdogStatus();
|
||
}
|
||
}
|
||
|
||
function drawDailyChart(canvas, daily) {
|
||
const ctx = canvas.getContext('2d');
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const rect = canvas.getBoundingClientRect();
|
||
canvas.width = rect.width * dpr;
|
||
canvas.height = rect.height * dpr;
|
||
ctx.scale(dpr, dpr);
|
||
const W = rect.width, H = rect.height;
|
||
const pad = { top: 10, right: 10, bottom: 24, left: 50 };
|
||
const cW = W - pad.left - pad.right;
|
||
const cH = H - pad.top - pad.bottom;
|
||
|
||
const maxTokens = Math.max(...daily.map(d => d.tokens), 1);
|
||
const barW = Math.max(4, (cW / daily.length) - 2);
|
||
|
||
// Collect all models across days
|
||
const allModels = new Set();
|
||
daily.forEach(d => Object.keys(d.models || {}).forEach(m => { if ((d.models[m] || 0) > 0) allModels.add(m); }));
|
||
const modelList = [...allModels].sort((a, b) => {
|
||
const ta = daily.reduce((s, d) => s + (d.models?.[a] || 0), 0);
|
||
const tb = daily.reduce((s, d) => s + (d.models?.[b] || 0), 0);
|
||
return tb - ta;
|
||
});
|
||
|
||
ctx.clearRect(0, 0, W, H);
|
||
|
||
// Grid lines
|
||
ctx.strokeStyle = '#1a1f2e';
|
||
ctx.lineWidth = 0.5;
|
||
for (let i = 0; i <= 4; i++) {
|
||
const y = pad.top + (cH * i / 4);
|
||
ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(W - pad.right, y); ctx.stroke();
|
||
}
|
||
|
||
// Stacked bars by model
|
||
daily.forEach((d, i) => {
|
||
const x = pad.left + (i * cW / daily.length) + 1;
|
||
let yOffset = pad.top + cH; // bottom
|
||
|
||
modelList.forEach(m => {
|
||
const t = d.models?.[m] || 0;
|
||
if (t <= 0) return;
|
||
const h = (t / maxTokens) * cH;
|
||
yOffset -= h;
|
||
ctx.fillStyle = getModelColor(m);
|
||
ctx.beginPath();
|
||
ctx.roundRect(x, yOffset, barW, h, 1);
|
||
ctx.fill();
|
||
});
|
||
|
||
// Token count above bar
|
||
if (d.tokens > 0) {
|
||
ctx.fillStyle = '#c9d1d9';
|
||
ctx.font = 'bold 9px -apple-system, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(fmtTokens(d.tokens), x + barW / 2, yOffset - 3);
|
||
}
|
||
|
||
// Date label
|
||
ctx.fillStyle = '#8b949e';
|
||
ctx.font = '9px -apple-system, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(d.date.slice(5), x + barW / 2, pad.top + cH + 14);
|
||
});
|
||
|
||
// Y-axis labels
|
||
ctx.fillStyle = '#8b949e';
|
||
ctx.font = '9px -apple-system, sans-serif';
|
||
ctx.textAlign = 'right';
|
||
for (let i = 0; i <= 4; i++) {
|
||
const val = maxTokens * (4 - i) / 4;
|
||
const y = pad.top + (cH * i / 4) + 3;
|
||
ctx.fillText(fmtTokens(val), pad.left - 6, y);
|
||
}
|
||
|
||
// Legend below chart
|
||
const legendEl = canvas.parentElement?.querySelector('.chart-legend');
|
||
if (legendEl) {
|
||
legendEl.innerHTML = modelList.map(m =>
|
||
`<span class="ops-model-legend-item"><span class="ops-model-dot" style="background:${getModelColor(m)}"></span>${shortModel(m)}</span>`
|
||
).join('');
|
||
}
|
||
}
|
||
|
||
function renderCostHeatmap(daily) {
|
||
const el = document.getElementById('costHeatmap');
|
||
if (!el || !daily.length) return;
|
||
|
||
// Collect all models with cost > 0
|
||
const allModels = new Set();
|
||
daily.forEach(d => Object.entries(d.modelCosts || {}).forEach(([m, c]) => { if (c > 0.01) allModels.add(m); }));
|
||
const models = [...allModels].sort((a, b) => {
|
||
const ta = daily.reduce((s, d) => s + (d.modelCosts?.[a] || 0), 0);
|
||
const tb = daily.reduce((s, d) => s + (d.modelCosts?.[b] || 0), 0);
|
||
return tb - ta;
|
||
});
|
||
|
||
// Find max cell value for heat coloring
|
||
let maxCell = 0;
|
||
daily.forEach(d => models.forEach(m => { maxCell = Math.max(maxCell, d.modelCosts?.[m] || 0); }));
|
||
|
||
function heatBg(val) {
|
||
if (val < 0.01) return 'transparent';
|
||
const intensity = Math.min(val / maxCell, 1);
|
||
// From dark to bright: low=dim, high=vivid
|
||
const alpha = 0.15 + intensity * 0.65;
|
||
return `rgba(124, 92, 252, ${alpha.toFixed(2)})`;
|
||
}
|
||
|
||
function fmtCost(v) {
|
||
if (v < 0.01) return '—';
|
||
if (v < 1) return '$' + v.toFixed(2);
|
||
return '$' + v.toFixed(0);
|
||
}
|
||
|
||
// Header: Model | Day1 | Day2 | ... | Total
|
||
let html = '<table><thead><tr><th></th>';
|
||
daily.forEach(d => { html += `<th>${d.date.slice(5)}</th>`; });
|
||
html += '<th>Total</th></tr></thead><tbody>';
|
||
|
||
// Rows: one per model
|
||
models.forEach(m => {
|
||
html += `<tr><td><span class="ops-model-dot" style="background:${getModelColor(m)};display:inline-block;margin-right:4px;vertical-align:middle"></span>${shortModel(m)}</td>`;
|
||
let rowTotal = 0;
|
||
daily.forEach(d => {
|
||
const v = d.modelCosts?.[m] || 0;
|
||
rowTotal += v;
|
||
html += `<td><span class="heat-cell" style="background:${heatBg(v)}">${fmtCost(v)}</span></td>`;
|
||
});
|
||
html += `<td><strong>${fmtCost(rowTotal)}</strong></td></tr>`;
|
||
});
|
||
|
||
// Total row
|
||
html += '<tr class="total-row"><td>Total</td>';
|
||
let grandTotal = 0;
|
||
daily.forEach(d => {
|
||
const dayTotal = d.cost || 0;
|
||
grandTotal += dayTotal;
|
||
html += `<td><strong>${fmtCost(dayTotal)}</strong></td>`;
|
||
});
|
||
html += `<td><strong>${fmtCost(grandTotal)}</strong></td></tr>`;
|
||
|
||
html += '</tbody></table>';
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
function drawDailyCostChart(canvas, daily) {
|
||
const ctx = canvas.getContext('2d');
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const rect = canvas.getBoundingClientRect();
|
||
canvas.width = rect.width * dpr;
|
||
canvas.height = rect.height * dpr;
|
||
ctx.scale(dpr, dpr);
|
||
const W = rect.width, H = rect.height;
|
||
const pad = { top: 14, right: 10, bottom: 24, left: 50 };
|
||
const cW = W - pad.left - pad.right;
|
||
const cH = H - pad.top - pad.bottom;
|
||
|
||
// Collect models sorted by total cost
|
||
const allModels = new Set();
|
||
daily.forEach(d => Object.keys(d.modelCosts || {}).forEach(m => { if ((d.modelCosts[m] || 0) > 0) allModels.add(m); }));
|
||
const modelList = [...allModels].sort((a, b) => {
|
||
const ta = daily.reduce((s, d) => s + (d.modelCosts?.[a] || 0), 0);
|
||
const tb = daily.reduce((s, d) => s + (d.modelCosts?.[b] || 0), 0);
|
||
return tb - ta;
|
||
});
|
||
|
||
const maxCost = Math.max(...daily.map(d => d.cost || 0), 1);
|
||
const barW = Math.max(4, (cW / daily.length) - 2);
|
||
|
||
ctx.clearRect(0, 0, W, H);
|
||
|
||
// Grid
|
||
ctx.strokeStyle = '#1a1f2e';
|
||
ctx.lineWidth = 0.5;
|
||
for (let i = 0; i <= 4; i++) {
|
||
const y = pad.top + (cH * i / 4);
|
||
ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(W - pad.right, y); ctx.stroke();
|
||
}
|
||
|
||
// Stacked cost bars
|
||
daily.forEach((d, i) => {
|
||
const x = pad.left + (i * cW / daily.length) + 1;
|
||
let yOffset = pad.top + cH;
|
||
|
||
modelList.forEach(m => {
|
||
const c = d.modelCosts?.[m] || 0;
|
||
if (c <= 0) return;
|
||
const h = (c / maxCost) * cH;
|
||
yOffset -= h;
|
||
ctx.fillStyle = getModelColor(m);
|
||
ctx.beginPath();
|
||
ctx.roundRect(x, yOffset, barW, h, 1);
|
||
ctx.fill();
|
||
});
|
||
|
||
// Total cost label above bar
|
||
if ((d.cost || 0) > 0) {
|
||
ctx.fillStyle = '#c9d1d9';
|
||
ctx.font = 'bold 9px -apple-system, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('$' + (d.cost).toFixed(0), x + barW / 2, yOffset - 3);
|
||
}
|
||
|
||
// Date label
|
||
ctx.fillStyle = '#8b949e';
|
||
ctx.font = '9px -apple-system, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(d.date.slice(5), x + barW / 2, pad.top + cH + 14);
|
||
});
|
||
|
||
// Y-axis (dollar)
|
||
ctx.fillStyle = '#8b949e';
|
||
ctx.font = '9px -apple-system, sans-serif';
|
||
ctx.textAlign = 'right';
|
||
for (let i = 0; i <= 4; i++) {
|
||
const val = maxCost * (4 - i) / 4;
|
||
const y = pad.top + (cH * i / 4) + 3;
|
||
ctx.fillText('$' + val.toFixed(0), pad.left - 6, y);
|
||
}
|
||
|
||
// Legend
|
||
const legendEl = canvas.parentElement?.querySelector('.chart-cost-legend');
|
||
if (legendEl) {
|
||
legendEl.innerHTML = modelList.map(m =>
|
||
`<span class="ops-model-legend-item"><span class="ops-model-dot" style="background:${getModelColor(m)}"></span>${shortModel(m)}</span>`
|
||
).join('');
|
||
}
|
||
}
|
||
|
||
// ═══ ATTACHMENTS ═══
|
||
let currentAttachments = [];
|
||
|
||
async function loadAttachments(taskId) {
|
||
try {
|
||
currentAttachments = await apiFetch(`/tasks/${taskId}/attachments`);
|
||
if (!Array.isArray(currentAttachments)) currentAttachments = [];
|
||
} catch (e) {
|
||
currentAttachments = [];
|
||
}
|
||
renderAttachments(taskId);
|
||
}
|
||
|
||
function renderAttachments(taskId) {
|
||
const container = document.getElementById('attachmentsGrid');
|
||
const countEl = document.getElementById('attachmentsCount');
|
||
if (!container) return;
|
||
|
||
countEl.textContent = currentAttachments.length;
|
||
|
||
if (currentAttachments.length === 0) {
|
||
container.innerHTML = `<div class="att-empty" style="grid-column:1/-1">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/></svg>
|
||
No attachments yet — drag & drop or click below to upload
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = currentAttachments.map(file => {
|
||
const fileUrl = `${API}/tasks/${taskId}/attachments/${encodeURIComponent(file.name)}`;
|
||
const fileIcon = getFileIcon(file.ext);
|
||
|
||
return `<div class="attachment-card" onclick="${file.isImage ? `openLightbox('${escAttr(fileUrl)}', '${escAttr(file.name)}', '${formatSize(file.size)}')` : `window.open('${escAttr(fileUrl)}&download=1','_blank')`}">
|
||
<button class="att-delete" onclick="event.stopPropagation();deleteAttachment('${taskId}','${escAttr(file.name)}')" title="Delete">✕</button>
|
||
<div class="att-preview">
|
||
${file.isImage
|
||
? `<img src="${fileUrl}" alt="${escHtml(file.name)}" loading="lazy">`
|
||
: `<span class="att-icon">${fileIcon}</span>`}
|
||
</div>
|
||
<div class="att-info">
|
||
<div class="att-name" title="${escHtml(file.name)}">${escHtml(file.name)}</div>
|
||
<div class="att-size">${formatSize(file.size)}</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function getFileIcon(ext) {
|
||
const icons = {
|
||
'.pdf': '📄', '.doc': '📝', '.docx': '📝', '.txt': '📃', '.md': '📃',
|
||
'.xls': '📊', '.xlsx': '📊', '.csv': '📊',
|
||
'.pptx': '📽️', '.ppt': '📽️',
|
||
'.zip': '📦', '.rar': '📦', '.tar': '📦', '.gz': '📦',
|
||
'.json': '{ }', '.xml': '🏷️', '.html': '🌐',
|
||
'.mp4': '🎬', '.mov': '🎬', '.avi': '🎬',
|
||
'.mp3': '🎵', '.wav': '🎵', '.ogg': '🎵',
|
||
'.py': '🐍', '.js': '📜', '.ts': '📜',
|
||
};
|
||
return icons[ext] || '📎';
|
||
}
|
||
|
||
function formatSize(bytes) {
|
||
if (bytes < 1024) return bytes + ' B';
|
||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||
return (bytes / 1048576).toFixed(1) + ' MB';
|
||
}
|
||
|
||
function escAttr(s) {
|
||
return s.replace(/'/g, "\\'").replace(/"/g, '"');
|
||
}
|
||
|
||
async function uploadAttachment(taskId, file, source) {
|
||
const progressEl = document.getElementById('attUploadProgress');
|
||
const barFill = document.getElementById('attUploadBarFill');
|
||
const statusEl = document.getElementById('attUploadStatus');
|
||
|
||
if (file.size > 20 * 1024 * 1024) {
|
||
toast('File too large (max 20MB)', 'error');
|
||
return;
|
||
}
|
||
|
||
progressEl.style.display = '';
|
||
barFill.style.width = '20%';
|
||
statusEl.textContent = `Uploading ${file.name}…`;
|
||
|
||
try {
|
||
const base64 = await fileToBase64(file);
|
||
barFill.style.width = '60%';
|
||
|
||
await apiFetch(`/tasks/${taskId}/attachments`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
filename: file.name,
|
||
data: base64,
|
||
source: source || 'user',
|
||
}),
|
||
});
|
||
|
||
barFill.style.width = '100%';
|
||
statusEl.textContent = 'Upload complete!';
|
||
toast(`📎 ${file.name} attached`, 'success');
|
||
|
||
setTimeout(() => {
|
||
progressEl.style.display = 'none';
|
||
barFill.style.width = '0%';
|
||
}, 1500);
|
||
|
||
loadAttachments(taskId);
|
||
loadTasks(false); // refresh notes
|
||
} catch (e) {
|
||
progressEl.style.display = 'none';
|
||
barFill.style.width = '0%';
|
||
toast(`Upload failed: ${e.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
function fileToBase64(file) {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve(reader.result);
|
||
reader.onerror = reject;
|
||
reader.readAsDataURL(file);
|
||
});
|
||
}
|
||
|
||
async function deleteAttachment(taskId, filename) {
|
||
if (!confirm(`Delete "${filename}"?`)) return;
|
||
try {
|
||
await apiFetch(`/tasks/${taskId}/attachments/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
||
toast('Attachment deleted', 'success');
|
||
loadAttachments(taskId);
|
||
loadTasks(false);
|
||
} catch (e) {
|
||
toast(`Delete failed: ${e.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
function setupDropZone(taskId) {
|
||
const zone = document.getElementById('attDropZone');
|
||
const fileInput = document.getElementById('attFileInput');
|
||
if (!zone || !fileInput) return;
|
||
|
||
zone.ondragover = (e) => { e.preventDefault(); zone.classList.add('drag-over'); };
|
||
zone.ondragleave = () => zone.classList.remove('drag-over');
|
||
zone.ondrop = (e) => {
|
||
e.preventDefault();
|
||
zone.classList.remove('drag-over');
|
||
const files = e.dataTransfer.files;
|
||
for (let i = 0; i < files.length; i++) {
|
||
uploadAttachment(taskId, files[i], 'user');
|
||
}
|
||
};
|
||
|
||
fileInput.onchange = () => {
|
||
for (let i = 0; i < fileInput.files.length; i++) {
|
||
uploadAttachment(taskId, fileInput.files[i], 'user');
|
||
}
|
||
fileInput.value = '';
|
||
};
|
||
}
|
||
|
||
// ─── Lightbox ───
|
||
function openLightbox(url, name, size) {
|
||
const overlay = document.getElementById('lightboxOverlay');
|
||
const img = document.getElementById('lightboxImg');
|
||
const nameEl = document.getElementById('lightboxName');
|
||
const sizeEl = document.getElementById('lightboxSize');
|
||
const downloadEl = document.getElementById('lightboxDownload');
|
||
|
||
img.src = url;
|
||
nameEl.textContent = name;
|
||
sizeEl.textContent = size;
|
||
downloadEl.href = url + '&download=1';
|
||
overlay.classList.add('show');
|
||
document.body.style.overflow = 'hidden';
|
||
}
|
||
|
||
function closeLightbox() {
|
||
const overlay = document.getElementById('lightboxOverlay');
|
||
overlay.classList.remove('show');
|
||
document.body.style.overflow = '';
|
||
}
|
||
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape' && document.getElementById('lightboxOverlay').classList.contains('show')) {
|
||
closeLightbox();
|
||
}
|
||
});
|
||
|
||
// ═══ INIT ═══
|
||
checkConnection();
|
||
loadSessions(); // Load sessions tab (default)
|
||
loadAgentMonitor();
|
||
pollWatchdogStatus();
|
||
startLivePolling(); // Auto-poll tasks every 3s for live updates
|
||
setInterval(checkConnection, 30000);
|
||
setInterval(loadAgentMonitor, 60000); // was 15s, reduced to 60s // Refresh agent monitor every 15s
|
||
setInterval(pollWatchdogStatus, 10000);
|
||
|
||
// Kanban mobile resize handler
|
||
window.addEventListener('resize', () => {
|
||
if (taskView === 'kanban') {
|
||
const board = document.getElementById('kanbanBoard');
|
||
if (window.innerWidth <= 768) board.classList.add('horizontal-scroll');
|
||
else board.classList.remove('horizontal-scroll');
|
||
}
|
||
});
|
||
|
||
// Keyboard shortcuts
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape') { closeDetailModal(); closeCreateModal(); }
|
||
if (e.key === 'n' && e.ctrlKey && e.shiftKey) { e.preventDefault(); openCreateModal(); }
|
||
});
|
||
</script>
|
||
<footer style="text-align:center;padding:24px 0 16px;color:#8b949e;font-size:0.85rem;font-family:'Outfit',sans-serif;">
|
||
Built by <a href="https://github.com/JonathanJing" target="_blank" style="color:#7c5cfc;text-decoration:none;">Jony Jing</a> ·
|
||
<a href="https://github.com/JonathanJing/openclaw-dashboard" target="_blank" style="color:#8b949e;text-decoration:none;">GitHub</a>
|
||
</footer>
|
||
</body>
|
||
</html>
|