Files
echo-core/dashboard/index.html
Marius Mutu 5e930ade02 feat(dashboard): unified workspace hub — cookie auth, 9-state projects, planning chat
Merges workspace.html + ralph.html into a single unified project hub with:
- Cookie-based auth (DASHBOARD_TOKEN, HttpOnly, SameSite=Strict)
- 9-state project badge system (running-ralph/manual, planning, approved,
  pending, blocked, failed, complete, idle) with BUTTONS_FOR_STATE matrix
- SSE realtime + polling fallback, version-based optimistic concurrency (If-Match)
- Planning chat modal (phase stepper, markdown bubbles, 50s+ wait state, auto-resume)
- Propose modal (Variant B: inline Plan-with-Echo checkbox)
- 5-type toast taxonomy (success/info/warning/busy/error, 3px colored left-bar)
- Inter font self-hosted + shared tokens.css design system + DESIGN.md
- src/jsonlock.py (flock helper, sidecar .lock for stable inode)
- src/approved_tasks_cli.py (shell-safe wrapper for cron/ralph.sh)
- 55 new tests (T#1–T#30) + real jsonlock bug fix caught by T#16/T#28
- No emoji anywhere (enforced by test_dashboard_no_emoji.py)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 07:26:19 +00:00

3238 lines
127 KiB
HTML

<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/svg+xml" href="/echo/favicon.svg">
<title>Echo · Dashboard</title>
<link rel="stylesheet" href="/echo/static/tokens.css">
<link rel="stylesheet" href="/echo/common.css">
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<script src="/echo/swipe-nav.js"></script>
<style>
.main {
max-width: 1400px;
margin: 0 auto;
padding: var(--space-5);
}
.page-header {
margin-bottom: var(--space-4);
}
.page-title {
font-size: var(--text-xl);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--space-1);
}
.page-subtitle {
font-size: var(--text-sm);
color: var(--text-muted);
}
/* Status bar */
.status-bar {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
margin-bottom: var(--space-4);
overflow: hidden;
}
.status-header {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: linear-gradient(135deg, rgba(34, 197, 94, 0.15), rgba(22, 163, 74, 0.1));
border-bottom: 1px solid var(--border);
cursor: pointer;
user-select: none;
}
.status-header:hover {
filter: brightness(1.05);
}
.status-title {
display: flex;
align-items: center;
gap: var(--space-2);
font-weight: 600;
font-size: var(--text-sm);
color: var(--text-primary);
}
.status-title svg {
width: 16px;
height: 16px;
color: #22c55e;
}
.status-actions {
display: flex;
gap: var(--space-2);
margin-left: auto;
}
.status-summary {
flex: 1;
font-size: var(--text-xs);
color: var(--text-muted);
text-align: right;
}
.status-toggle {
width: 16px;
height: 16px;
color: var(--text-muted);
transition: transform var(--transition-fast);
}
.status-bar.collapsed .status-toggle {
transform: rotate(-90deg);
}
.status-bar.collapsed .status-content {
display: none;
}
.status-content {
padding: 0;
}
/* Status sections */
.status-section {
border-bottom: 1px solid var(--border);
}
.status-section:last-child {
border-bottom: none;
}
.status-section-header {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
cursor: pointer;
user-select: none;
transition: background var(--transition-fast);
}
.status-section-header:hover {
background: var(--bg-elevated);
}
.status-section-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
flex-shrink: 0;
}
.status-section-icon svg {
width: 18px;
height: 18px;
}
.status-section-icon.git {
background: rgba(249, 115, 22, 0.15);
color: #f97316;
}
.status-section-icon.anaf {
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
}
.status-section-icon.cron {
background: rgba(99, 102, 241, 0.15);
color: #818cf8;
}
.status-section-icon.services {
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
}
.status-section-icon.sessions {
background: rgba(168, 85, 247, 0.15);
color: #a855f7;
}
.status-section-icon.logs {
background: rgba(156, 163, 175, 0.15);
color: #9ca3af;
}
.status-section-icon.doctor {
background: rgba(20, 184, 166, 0.15);
color: #14b8a6;
}
.status-section-actions {
display: flex;
gap: var(--space-2);
align-items: center;
margin-left: auto;
}
.status-section-actions .btn {
font-size: var(--text-xs);
padding: var(--space-1) var(--space-2);
}
/* Heavy content (services grid, sessions list, logs, doctor) — full width, no left indent */
.status-section-details.full-width {
padding: 0 var(--space-4) var(--space-3);
}
.status-section-info {
flex: 1;
min-width: 0;
}
.status-section-title {
font-size: var(--text-sm);
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: var(--space-2);
}
.status-section-subtitle {
font-size: 13px;
color: var(--text-secondary);
margin-top: 2px;
}
.status-badge {
padding: 3px 10px;
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 600;
}
.status-badge.ok {
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
}
.status-badge.warning {
background: rgba(249, 115, 22, 0.15);
color: #f97316;
}
.status-badge.error {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
.status-section-toggle {
width: 16px;
height: 16px;
color: var(--text-muted);
transition: transform var(--transition-fast);
}
.status-section.collapsed .status-section-toggle {
transform: rotate(-90deg);
}
.status-section.collapsed .status-section-details {
display: none;
}
.status-section-details {
padding: 0 var(--space-4) var(--space-3);
padding-left: calc(var(--space-4) + 32px + var(--space-3));
}
.status-detail-item {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: 13px;
color: var(--text-primary);
padding: 2px 0;
}
.status-detail-item svg {
width: 14px;
height: 14px;
color: var(--text-secondary);
}
.status-detail-item.uncommitted {
color: #f97316;
}
.status-detail-item code {
font-family: monospace;
background: var(--bg-elevated);
padding: 2px 6px;
border-radius: 3px;
font-size: 12px;
}
/* Cron items - desktop compact grid */
.cron-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 2px var(--space-4);
}
@media (max-width: 900px) {
.cron-list {
grid-template-columns: 1fr;
}
}
.cron-item {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
padding: 2px 0;
}
.cron-item.done {
opacity: 0.5;
}
.cron-item.pending {
color: var(--text-primary);
}
.cron-time {
font-family: monospace;
min-width: 50px;
font-size: 13px;
color: var(--text-primary);
}
.cron-date {
color: #9ca3af;
font-size: 12px;
margin-left: 4px;
}
.cron-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
}
.cron-icon.done { color: #22c55e; }
.cron-icon.pending { color: #9ca3af; }
.cron-icon.failed { color: #ef4444; }
.cron-name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-primary);
}
/* Two-column dashboard */
.dashboard-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
}
@media (max-width: 900px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
}
/* Panel styling */
.panel {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border);
cursor: pointer;
user-select: none;
}
.panel-header:hover {
filter: brightness(1.05);
}
.panel-header-left {
display: flex;
align-items: center;
gap: var(--space-2);
}
.panel-header-right {
display: flex;
align-items: center;
gap: var(--space-2);
margin-left: auto;
}
.panel-title {
display: flex;
align-items: center;
gap: var(--space-2);
font-weight: 600;
font-size: var(--text-sm);
color: var(--text-primary);
}
.panel-title svg {
width: 18px;
height: 18px;
}
.panel-toggle {
width: 16px;
height: 16px;
color: var(--text-muted);
transition: transform var(--transition-fast);
}
.panel.collapsed .panel-toggle {
transform: rotate(-90deg);
}
.panel.collapsed .panel-body {
display: none;
}
.panel-actions {
display: flex;
gap: var(--space-2);
}
.panel-count {
font-size: var(--text-xs);
color: var(--text-muted);
background: var(--bg-elevated);
padding: 2px 8px;
border-radius: var(--radius-sm);
}
.panel-body {
padding: var(--space-3);
max-height: 500px;
overflow-y: auto;
}
.progress-bar {
height: 4px;
background: var(--bg-elevated);
border-radius: 2px;
margin-top: var(--space-1);
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--accent);
border-radius: 2px;
transition: width 0.3s ease;
}
/* Issues panel */
.issues-panel .panel-header {
background: linear-gradient(135deg, rgba(249, 115, 22, 0.15), rgba(234, 88, 12, 0.1));
}
.issues-panel .panel-title svg {
color: #f97316;
}
.issues-filters {
display: flex;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.filter-btn {
padding: var(--space-1) var(--space-2);
font-size: var(--text-xs);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-muted);
cursor: pointer;
transition: all var(--transition-fast);
}
.filter-btn:hover, .filter-btn.active {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-subtle);
}
.priority-group {
margin-bottom: var(--space-5);
}
.priority-header {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--space-3);
cursor: pointer;
padding: var(--space-2) 0;
}
.priority-header:hover {
color: var(--text-secondary);
}
.priority-header svg {
display: none;
}
.priority-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.priority-dot.urgent-important { background: #ef4444; }
.priority-dot.important { background: #f59e0b; }
.priority-dot.urgent { background: #eab308; }
.priority-dot.backlog { background: #6b7280; }
.priority-content {
display: block;
}
.priority-content.hidden {
display: none;
}
.issue-item {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-3);
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-md);
margin-bottom: var(--space-2);
cursor: pointer;
transition: all var(--transition-fast);
}
.issue-item:hover {
border-color: var(--border-focus);
transform: translateX(2px);
}
.issue-item.done {
opacity: 0.6;
}
.issue-item.done .issue-title {
text-decoration: line-through;
}
.issue-checkbox {
width: 18px;
height: 18px;
border: 2px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all var(--transition-fast);
}
.issue-checkbox:hover {
border-color: var(--accent);
}
.issue-checkbox.checked {
background: var(--accent);
border-color: var(--accent);
}
.issue-checkbox.in-progress {
background: rgba(59, 130, 246, 0.5);
border-color: #3b82f6;
}
.issue-checkbox svg {
width: 12px;
height: 12px;
color: white;
display: none;
}
.issue-checkbox.checked svg {
display: block;
}
.issue-checkbox.in-progress::after {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
background: #3b82f6;
}
.issue-content {
flex: 1;
min-width: 0;
}
.issue-title {
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-primary);
margin-bottom: var(--space-1);
}
.issue-meta {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
font-size: var(--text-xs);
}
.issue-tag {
padding: 2px 6px;
border-radius: var(--radius-sm);
background: var(--bg-surface);
color: var(--text-muted);
}
.issue-tag.program {
background: rgba(99, 102, 241, 0.15);
color: #818cf8;
}
.issue-owner {
display: flex;
align-items: center;
gap: 4px;
}
.issue-owner.marius { color: #22c55e; }
.issue-owner.robert { color: #f59e0b; }
.issue-status {
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.issue-status.todo {
background: rgba(156, 163, 175, 0.2);
color: #9ca3af;
}
.issue-status.in-progress {
background: rgba(59, 130, 246, 0.2);
color: #3b82f6;
}
.issue-status.done {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
/* Todo's Panel */
.todos-panel { border-left: 3px solid #8b5cf6; }
.todo-section { margin-bottom: 16px; }
.todo-section-title {
font-size: 12px;
font-weight: 600;
color: #9ca3af;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.todo-section-title.overdue { color: #ef4444; }
.todo-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px 12px;
background: var(--bg-elevated);
border-radius: 6px;
margin-bottom: 6px;
transition: all 0.2s;
border: 1px solid var(--border-color);
}
.todo-item:hover { background: var(--bg-hover); }
.todo-item.done { opacity: 0.6; }
.todo-item.done .todo-text { text-decoration: line-through; }
.todo-checkbox {
width: 22px;
height: 22px;
border: 2px solid #6b7280;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.2s;
background: transparent;
}
.todo-checkbox:hover { border-color: #8b5cf6; background: rgba(139, 92, 246, 0.1); }
.todo-checkbox.checked {
background: #8b5cf6;
border-color: #8b5cf6;
}
.todo-checkbox svg { display: none; width: 14px; height: 14px; color: white; }
.todo-checkbox.checked svg { display: block; }
.todo-content { flex: 1; min-width: 0; }
.todo-text { font-size: 14px; color: var(--text-primary); margin-bottom: 10px; font-weight: 500; }
.todo-context { font-size: 13px; color: var(--text-muted); margin-bottom: 10px; line-height: 1.5; }
.todo-example { font-size: 13px; color: var(--text-muted); margin-bottom: 10px; line-height: 1.5; font-style: italic; background: var(--bg-elevated); padding: 8px 10px; border-radius: 6px; border-left: 3px solid #8b5cf6; }
.todo-meta { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.todo-domain {
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
font-weight: 500;
}
.todo-domain.work { background: #1e40af; color: #93c5fd; }
.todo-domain.self { background: #6b21a8; color: #d8b4fe; }
.todo-domain.sprijin { background: #166534; color: #86efac; }
.todo-domain.scout { background: #c2410c; color: #fdba74; }
.todo-due {
font-size: 11px;
color: var(--text-muted);
}
.todo-due.overdue { color: #ef4444; font-weight: 500; }
.todo-source { font-size: 11px; color: var(--text-muted); }
.todo-source-link { font-size: 11px; color: #3b82f6; text-decoration: none; }
.todo-source-link:hover { text-decoration: underline; }
/* Note Overlay */
.note-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
z-index: 1000;
padding: 20px;
}
.note-overlay.active { display: flex; align-items: center; justify-content: center; }
.note-overlay-content {
background: var(--bg-surface);
border-radius: 12px;
width: 90%;
max-width: 900px;
height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.note-overlay-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
}
.note-overlay-header span { font-weight: 600; color: var(--text-primary); }
#noteOverlayFrame {
flex: 1;
border: none;
width: 100%;
}
/* Button icon only */
.btn-icon {
padding: 6px;
background: transparent;
border: 1px solid #374151;
border-radius: 6px;
color: #9ca3af;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.btn-icon:hover {
background: #374151;
color: #f3f4f6;
border-color: #4b5563;
}
.btn-icon svg { width: 16px; height: 16px; }
.issue-owner.clawdbot { color: #8b5cf6; }
.issue-date {
color: var(--text-muted);
}
/* Add issue */
.add-issue-btn {
width: 100%;
padding: var(--space-3);
background: transparent;
border: 1px dashed var(--border);
border-radius: var(--radius-md);
color: var(--text-muted);
font-size: var(--text-sm);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
transition: all var(--transition-fast);
margin-top: var(--space-3);
}
.add-issue-btn:hover {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-subtle);
}
.add-issue-btn svg {
width: 16px;
height: 16px;
}
/* Add form modal */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal-overlay.active {
display: flex;
}
.modal {
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-5);
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
.modal-title {
font-size: var(--text-lg);
font-weight: 600;
margin-bottom: var(--space-4);
}
.form-group {
margin-bottom: var(--space-4);
}
.form-label {
display: block;
font-size: var(--text-sm);
font-weight: 500;
margin-bottom: var(--space-1);
color: var(--text-secondary);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
margin-top: var(--space-5);
}
/* Toast */
.toast {
position: fixed;
bottom: var(--space-5);
left: 50%;
transform: translateX(-50%) translateY(100px);
background: var(--bg-elevated);
border: 1px solid var(--border);
padding: var(--space-3) var(--space-5);
border-radius: var(--radius-md);
font-size: var(--text-sm);
opacity: 0;
transition: all var(--transition-base);
z-index: 1001;
}
.toast.show {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
/* Empty state */
.empty-state {
text-align: center;
padding: var(--space-6);
color: var(--text-muted);
}
.empty-state svg {
width: 48px;
height: 48px;
margin-bottom: var(--space-3);
opacity: 0.5;
}
/* Danger button */
.btn-danger {
background: #dc2626;
color: white;
border: none;
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-md);
font-size: var(--text-sm);
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-danger:hover {
background: #b91c1c;
}
/* Clickable todo item */
.todo-item {
cursor: pointer;
}
/* ─────────────────────────────────────────────────────────
Eco panels (merged from eco.html — now nested under Status)
───────────────────────────────────────────────────────── */
/* Service cards */
.services-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-4);
}
.service-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-4);
transition: border-color var(--transition-base);
}
.service-card:hover { border-color: var(--border-focus); }
.service-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-3);
}
.service-name {
font-size: var(--text-base);
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: var(--space-2);
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--text-muted);
flex-shrink: 0;
}
.status-dot.active {
background: var(--success);
box-shadow: 0 0 6px rgba(34, 197, 94, 0.5);
}
.status-dot.inactive { background: var(--error); }
.service-meta {
display: flex;
flex-direction: column;
gap: var(--space-1);
font-size: var(--text-xs);
color: var(--text-muted);
margin-bottom: var(--space-3);
}
.service-meta span {
display: flex;
align-items: center;
gap: var(--space-1);
}
.service-actions {
display: flex;
gap: var(--space-2);
}
.service-actions .btn {
font-size: var(--text-xs);
padding: var(--space-1) var(--space-3);
min-height: 44px;
}
/* Sessions */
.sessions-empty {
text-align: center;
padding: var(--space-4);
color: var(--text-muted);
font-size: var(--text-xs);
}
.sessions-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.session-item { border-bottom: 1px solid var(--border); }
.session-item:last-child { border-bottom: none; }
.session-row {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 6px var(--space-3);
cursor: pointer;
transition: background var(--transition-fast);
user-select: none;
}
.session-row:hover { background: var(--bg-elevated); }
.p-icon {
flex-shrink: 0;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
}
.p-icon.discord { color: #5865F2; }
.p-icon.telegram { color: #26A5E4; }
.p-icon.whatsapp { color: #25D366; }
.p-icon.unknown { color: var(--text-muted); }
.s-name {
font-size: var(--text-xs);
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.s-tag {
font-size: 9px;
font-weight: 600;
padding: 0 4px;
border-radius: 3px;
flex-shrink: 0;
}
.s-tag.grup { background: rgba(37, 211, 102, 0.12); color: #25D366; }
.s-tag.dm { background: rgba(156, 163, 175, 0.12); color: var(--text-muted); }
.s-stats {
flex: 1;
display: flex;
gap: var(--space-2);
font-size: 10px;
color: var(--text-muted);
justify-content: flex-end;
white-space: nowrap;
font-family: var(--font-mono);
}
.s-time {
font-size: 10px;
color: var(--text-muted);
white-space: nowrap;
flex-shrink: 0;
}
.s-chevron {
color: var(--text-muted);
transition: transform var(--transition-fast);
flex-shrink: 0;
width: 12px;
height: 12px;
}
.s-chevron.open { transform: rotate(180deg); }
.session-details {
display: none;
padding: 4px var(--space-3) var(--space-3);
padding-left: calc(18px + var(--space-2) + var(--space-3));
}
.session-details.open { display: block; }
.s-detail-row {
display: flex;
gap: var(--space-3);
font-size: 10px;
color: var(--text-muted);
margin-bottom: 6px;
font-family: var(--font-mono);
flex-wrap: wrap;
}
.s-detail-row span { white-space: nowrap; }
.s-messages {
max-height: 300px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-elevated);
font-size: 11px;
line-height: 1.5;
}
.s-msg {
padding: 4px 8px;
border-bottom: 1px solid var(--border);
}
.s-msg:last-child { border-bottom: none; }
.s-msg.user { color: var(--text-secondary); }
.s-msg.assistant { color: var(--accent); }
.s-msg-role {
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-right: 6px;
}
.s-msg.user .s-msg-role { color: var(--warning); }
.s-msg.assistant .s-msg-role { color: var(--accent); }
.s-msg-text {
white-space: pre-wrap;
word-break: break-word;
}
.s-loading {
padding: 8px;
text-align: center;
color: var(--text-muted);
font-size: 10px;
}
.s-actions {
display: flex;
justify-content: flex-end;
margin-top: 6px;
}
.s-actions .btn {
font-size: 10px;
padding: 2px 8px;
}
/* Log viewer */
.log-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.log-toolbar {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.log-toolbar label {
font-size: var(--text-xs);
color: var(--text-muted);
}
.log-toolbar select {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: var(--text-xs);
padding: var(--space-1) var(--space-2);
}
.log-content {
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.6;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-all;
padding: var(--space-4);
max-height: 400px;
overflow-y: auto;
overflow-x: auto;
background: var(--bg-elevated);
}
/* Auto-refresh toggle */
.toggle-switch {
position: relative;
display: inline-block;
width: 36px;
height: 20px;
}
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0; left: 0; right: 0; bottom: 0;
background: var(--bg-surface-active);
border-radius: 20px;
transition: var(--transition-fast);
}
.toggle-slider:before {
content: "";
position: absolute;
height: 14px;
width: 14px;
left: 3px;
bottom: 3px;
background: var(--text-muted);
border-radius: 50%;
transition: var(--transition-fast);
}
.toggle-switch input:checked + .toggle-slider { background: var(--accent); }
.toggle-switch input:checked + .toggle-slider:before {
transform: translateX(16px);
background: white;
}
/* Doctor */
.doctor-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.check-list { list-style: none; padding: 0; margin: 0; }
.check-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border);
font-size: var(--text-sm);
}
.check-item:last-child { border-bottom: none; }
.check-icon {
flex-shrink: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
}
.check-icon.pass { color: var(--success); }
.check-icon.fail { color: var(--error); }
.check-name {
font-weight: 500;
color: var(--text-primary);
min-width: 160px;
}
.check-detail {
color: var(--text-muted);
font-size: var(--text-xs);
}
/* Spinner */
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid var(--text-muted);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Button variants (eco) */
.btn-warning {
border-color: var(--warning);
color: var(--warning);
}
.btn-warning:hover {
background: var(--warning);
color: #000;
}
/* Skeleton loading (design spec) */
.skeleton-row {
height: 14px;
margin: 6px 0;
background: linear-gradient(90deg, var(--bg-surface) 0%, var(--bg-elevated) 50%, var(--bg-surface) 100%);
background-size: 200% 100%;
border-radius: var(--radius-sm);
animation: skeleton-pulse 1.5s infinite;
}
@keyframes skeleton-pulse {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Panel error state (design spec) */
.panel-error {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4);
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: var(--radius-md);
color: var(--error);
font-size: var(--text-sm);
}
.panel-error button {
margin-left: auto;
background: transparent;
border: 1px solid var(--error);
color: var(--error);
padding: 4px 10px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: var(--text-xs);
min-height: 32px;
}
.panel-error button:hover {
background: var(--error);
color: white;
}
/* Empty state — warm + action (design spec) */
.empty-state-warm {
padding: var(--space-4);
text-align: center;
color: var(--text-muted);
font-size: var(--text-sm);
line-height: 1.5;
}
/* Focus ring for a11y (design spec) */
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Responsive grid per plan: 1-col mobile / 2-col 768-1199 / 3-col ≥1200 */
@media (min-width: 768px) {
.services-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (min-width: 1200px) {
.services-grid { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 768px) {
.service-header {
flex-direction: column;
align-items: flex-start;
gap: var(--space-2);
}
.check-item { flex-wrap: wrap; }
.check-name { min-width: auto; }
.s-stats { display: none; }
}
</style>
</head>
<body>
<!--NAV-->
<main class="main">
<div class="page-header">
<h1 class="page-title">Dashboard</h1>
</div>
<!-- Status Bar -->
<div class="status-bar" id="statusBar">
<div class="status-header" onclick="toggleSection('statusBar')">
<div class="status-title">
<i data-lucide="activity"></i>
<span>Status</span>
</div>
<div class="status-summary" id="statusSummary">Se încarcă...</div>
<div class="status-actions" onclick="event.stopPropagation()">
<button class="btn btn-icon" onclick="gitCommit()" title="Git Commit">
<i data-lucide="git-commit"></i>
</button>
<button class="btn btn-icon" onclick="restartTaskboard()" title="Restart Taskboard">
<i data-lucide="rotate-cw"></i>
</button>
<button class="btn btn-icon" onclick="refreshStatus()" title="Refresh">
<i data-lucide="refresh-cw"></i>
</button>
</div>
<i data-lucide="chevron-down" class="status-toggle"></i>
</div>
<div class="status-content">
<!-- Git Section -->
<div class="status-section" id="gitSection">
<div class="status-section-header" onclick="toggleStatusSection('gitSection')">
<div class="status-section-icon git">
<i data-lucide="git-branch"></i>
</div>
<div class="status-section-info">
<div class="status-section-title">
Git
<span class="status-badge ok" id="gitBadge">curat</span>
</div>
<div class="status-section-subtitle" id="gitSubtitle">Se încarcă...</div>
</div>
<div class="status-section-actions" onclick="event.stopPropagation()">
<button class="btn btn-secondary" onclick="ecoGitCommit()" title="Commit & Push" aria-label="Commit și push">
<i data-lucide="git-commit"></i> Commit
</button>
<button class="btn btn-secondary" onclick="loadGitStatus()" title="Refresh" aria-label="Refresh git status">
<i data-lucide="refresh-cw"></i>
</button>
</div>
<i data-lucide="chevron-down" class="status-section-toggle"></i>
</div>
<div class="status-section-details" id="gitDetails">
<div class="skeleton-row" style="width:70%"></div>
<div class="skeleton-row" style="width:40%"></div>
</div>
</div>
<!-- Services Section -->
<div class="status-section collapsed" id="servicesSection">
<div class="status-section-header" onclick="toggleStatusSection('servicesSection')">
<div class="status-section-icon services">
<i data-lucide="server"></i>
</div>
<div class="status-section-info">
<div class="status-section-title">
Services
<span class="status-badge ok" id="servicesBadge"></span>
</div>
<div class="status-section-subtitle" id="servicesSubtitle">Se încarcă...</div>
</div>
<div class="status-section-actions" onclick="event.stopPropagation()">
<button class="btn btn-secondary" onclick="loadServices()" title="Refresh services" aria-label="Refresh services">
<i data-lucide="refresh-cw"></i>
</button>
</div>
<i data-lucide="chevron-down" class="status-section-toggle"></i>
</div>
<div class="status-section-details full-width">
<div class="services-grid" id="servicesGrid">
<div class="service-card"><div class="skeleton-row" style="width:60%"></div><div class="skeleton-row" style="width:80%"></div><div class="skeleton-row" style="width:40%"></div></div>
<div class="service-card"><div class="skeleton-row" style="width:70%"></div><div class="skeleton-row" style="width:50%"></div><div class="skeleton-row" style="width:60%"></div></div>
<div class="service-card"><div class="skeleton-row" style="width:50%"></div><div class="skeleton-row" style="width:70%"></div><div class="skeleton-row" style="width:55%"></div></div>
</div>
</div>
</div>
<!-- Sessions Section -->
<div class="status-section collapsed" id="sessionsSection">
<div class="status-section-header" onclick="toggleStatusSection('sessionsSection')">
<div class="status-section-icon sessions">
<i data-lucide="message-square"></i>
</div>
<div class="status-section-info">
<div class="status-section-title">
Sessions
<span class="status-badge ok" id="sessionsBadge"></span>
</div>
<div class="status-section-subtitle" id="sessionsSubtitle">Canale active</div>
</div>
<div class="status-section-actions" onclick="event.stopPropagation()">
<button class="btn btn-secondary" onclick="loadSessions()" title="Refresh sessions" aria-label="Refresh sessions">
<i data-lucide="refresh-cw"></i>
</button>
<button class="btn btn-secondary btn-danger" onclick="clearAllSessions()" id="clearAllBtn" aria-label="Clear all sessions" style="display:none;">
<i data-lucide="trash-2"></i> Clear All
</button>
</div>
<i data-lucide="chevron-down" class="status-section-toggle"></i>
</div>
<div class="status-section-details full-width">
<div class="sessions-card">
<div id="sessionsContent">
<div style="padding:var(--space-3)">
<div class="skeleton-row" style="width:70%"></div>
<div class="skeleton-row" style="width:50%"></div>
</div>
</div>
</div>
</div>
</div>
<!-- ANAF Section -->
<div class="status-section collapsed" id="anafSection">
<div class="status-section-header" onclick="toggleStatusSection('anafSection')">
<div class="status-section-icon anaf">
<i data-lucide="building-2"></i>
</div>
<div class="status-section-info">
<div class="status-section-title">
ANAF Monitor
<span class="status-badge ok" id="anafBadge">OK</span>
</div>
<div class="status-section-subtitle" id="anafSubtitle">Nicio modificare detectată</div>
</div>
<i data-lucide="chevron-down" class="status-section-toggle"></i>
</div>
<div class="status-section-details" id="anafDetails">
<div class="status-detail-item">
<i data-lucide="clock"></i>
<span id="anafLastCheck">Ultima verificare: -</span>
</div>
</div>
</div>
<!-- Cron Section -->
<div class="status-section" id="cronSection">
<div class="status-section-header" onclick="toggleStatusSection('cronSection')">
<div class="status-section-icon cron">
<i data-lucide="clock"></i>
</div>
<div class="status-section-info">
<div class="status-section-title">
Cron Jobs
<span class="status-badge ok" id="cronBadge">0/0</span>
</div>
<div class="status-section-subtitle" id="cronSubtitle">Jobs programate azi</div>
</div>
<i data-lucide="chevron-down" class="status-section-toggle"></i>
</div>
<div class="status-section-details" id="cronDetails">
<!-- Populated by JS -->
</div>
</div>
<!-- Logs Section (lazy-loaded) -->
<div class="status-section collapsed" id="logsSection" data-loaded="0">
<div class="status-section-header" onclick="toggleStatusSection('logsSection')">
<div class="status-section-icon logs">
<i data-lucide="scroll-text"></i>
</div>
<div class="status-section-info">
<div class="status-section-title">Logs</div>
<div class="status-section-subtitle">echo-core.service journalctl</div>
</div>
<i data-lucide="chevron-down" class="status-section-toggle"></i>
</div>
<div class="status-section-details full-width">
<div class="log-card">
<div class="log-toolbar">
<label for="logLines">Lines:</label>
<select id="logLines" onchange="loadLogs()">
<option value="50">50</option>
<option value="100" selected>100</option>
<option value="200">200</option>
</select>
<button class="btn btn-secondary" onclick="loadLogs()" aria-label="Refresh logs" style="font-size:var(--text-xs);padding:var(--space-1) var(--space-2);">
<i data-lucide="refresh-cw"></i>
Refresh
</button>
<div style="margin-left:auto;display:flex;align-items:center;gap:var(--space-2);">
<label for="autoRefreshLogs" style="font-size:var(--text-xs);color:var(--text-muted);">Auto</label>
<label class="toggle-switch">
<input type="checkbox" id="autoRefreshLogs" onchange="toggleAutoRefreshLogs()">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="log-content" id="logContent">Apasă Refresh sau expandă secțiunea pentru a încărca.</div>
</div>
</div>
</div>
<!-- Doctor Section -->
<div class="status-section collapsed" id="doctorSection">
<div class="status-section-header" onclick="toggleStatusSection('doctorSection')">
<div class="status-section-icon doctor">
<i data-lucide="stethoscope"></i>
</div>
<div class="status-section-info">
<div class="status-section-title">Doctor</div>
<div class="status-section-subtitle">Diagnosticare sistem</div>
</div>
<div class="status-section-actions" onclick="event.stopPropagation()">
<button class="btn btn-primary" onclick="runDoctor()" id="doctorBtn" aria-label="Run diagnostics">
<i data-lucide="play"></i> Run
</button>
</div>
<i data-lucide="chevron-down" class="status-section-toggle"></i>
</div>
<div class="status-section-details full-width">
<div class="doctor-card" id="doctorCard">
<ul class="check-list" id="doctorChecks">
<li class="check-item" style="color:var(--text-muted);justify-content:center;">Apasă pentru a rula verificările.</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div class="dashboard-grid">
<!-- Issues Panel -->
<div class="panel issues-panel" id="issuesPanel">
<div class="panel-header" onclick="toggleSection('issuesPanel')">
<div class="panel-header-left">
<div class="panel-title">
<span>Issues</span>
</div>
<span class="panel-count" id="issuesCount">0</span>
</div>
<div class="panel-header-right" onclick="event.stopPropagation()">
<button class="btn btn-icon" onclick="showAddModal()" title="Adaugă issue">
<i data-lucide="plus"></i>
</button>
<i data-lucide="chevron-down" class="panel-toggle" onclick="event.stopPropagation(); toggleSection('issuesPanel')"></i>
</div>
</div>
<div class="issues-filters" id="issuesFilters">
<button class="filter-btn active" data-filter="all">Toate</button>
<button class="filter-btn" data-filter="marius">👤 Marius</button>
<button class="filter-btn" data-filter="robert">👷 Robert</button>
<button class="filter-btn" data-filter="clawdbot">🤖 Clawdbot</button>
</div>
<div class="panel-body" id="issuesBody">
<div class="empty-state">
<i data-lucide="loader"></i>
<p>Se încarcă...</p>
</div>
</div>
</div>
<!-- Todo's Panel -->
<div class="panel todos-panel" id="todosPanel">
<div class="panel-header" onclick="toggleSection('todosPanel')">
<div class="panel-header-left">
<div class="panel-title">
<span>Todo's</span>
</div>
<span class="panel-count" id="todosCount">0</span>
</div>
<div class="panel-header-right" onclick="event.stopPropagation()">
<button class="btn btn-icon" onclick="showAddTodoModal()" title="Adaugă todo">
<i data-lucide="plus"></i>
</button>
<i data-lucide="chevron-down" class="panel-toggle" onclick="event.stopPropagation(); toggleSection('todosPanel')"></i>
</div>
</div>
<div class="panel-body" id="todosBody">
<div class="empty-state">
<i data-lucide="loader"></i>
<p>Se încarcă...</p>
</div>
</div>
</div>
</div>
</main>
<!-- Note Overlay -->
<div class="note-overlay" id="noteOverlay" onclick="if(event.target === this) closeNoteOverlay()">
<div class="note-overlay-content">
<div class="note-overlay-header">
<span id="noteOverlayTitle">Notă</span>
<button class="btn btn-icon" onclick="closeNoteOverlay()">
<i data-lucide="x"></i>
</button>
</div>
<iframe id="noteOverlayFrame" src=""></iframe>
</div>
</div>
<!-- Issue Modal (Add/Edit) -->
<div class="modal-overlay" id="issueModal">
<div class="modal">
<h2 class="modal-title" id="issueModalTitle">Issue nou</h2>
<input type="hidden" id="issueEditId">
<div class="form-group">
<label class="form-label">Titlu *</label>
<input type="text" class="input" id="issueTitle" placeholder="Ce trebuie făcut?">
</div>
<div class="form-group">
<label class="form-label">Descriere</label>
<textarea class="input" id="issueDesc" rows="3" placeholder="Detalii..."></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Program</label>
<select class="input" id="issueProgram">
<option value="">— Selectează —</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Owner</label>
<select class="input" id="issueOwner">
<option value="marius">👤 Marius</option>
<option value="robert">👷 Robert</option>
<option value="clawdbot">🤖 Clawdbot</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Prioritate</label>
<select class="input" id="issuePriority">
<option value="urgent-important">🔴 Urgent + Important</option>
<option value="important">🟠 Important</option>
<option value="urgent">🟡 Urgent</option>
<option value="backlog">⚪ Backlog</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Status</label>
<select class="input" id="issueStatus">
<option value="todo">Todo</option>
<option value="in-progress">In Progress</option>
<option value="done">Done</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Deadline</label>
<input type="date" class="input" id="issueDeadline">
</div>
<div class="form-group"></div>
</div>
<div class="modal-actions">
<button class="btn btn-danger" id="issueDeleteBtn" onclick="deleteIssue()" style="margin-right: auto; display: none;">Șterge</button>
<button class="btn btn-secondary" onclick="hideIssueModal()">Anulează</button>
<button class="btn btn-primary" id="issueSaveBtn" onclick="saveIssue()">Adaugă</button>
</div>
</div>
</div>
<!-- Todo Modal (Add/Edit) -->
<div class="modal-overlay" id="todoModal">
<div class="modal">
<h2 class="modal-title" id="todoModalTitle">Todo nou</h2>
<input type="hidden" id="todoEditId">
<div class="form-group">
<label class="form-label">Ce trebuie făcut? *</label>
<input type="text" class="input" id="todoText" placeholder="Ex: Verifică client X">
</div>
<div class="form-group">
<label class="form-label">Context</label>
<textarea class="input" id="todoContext" rows="2" placeholder="De ce? Detalii..."></textarea>
</div>
<div class="form-group">
<label class="form-label">Exemplu</label>
<textarea class="input" id="todoExample" rows="2" placeholder="Un exemplu concret..."></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Domeniu</label>
<select class="input" id="todoDomain">
<option value="work">💼 Work</option>
<option value="self">🧘 Self</option>
<option value="sprijin">💚 Sprijin</option>
<option value="scout">⚜️ Scout</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Data</label>
<input type="date" class="input" id="todoDueDate">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Sursă</label>
<input type="text" class="input" id="todoSource" placeholder="Ex: video, articol...">
</div>
<div class="form-group">
<label class="form-label">Link sursă</label>
<input type="text" class="input" id="todoSourceUrl" placeholder="URL...">
</div>
</div>
<div class="modal-actions">
<button class="btn btn-danger" id="todoDeleteBtn" onclick="deleteTodo()" style="margin-right: auto; display: none;">Șterge</button>
<button class="btn btn-secondary" onclick="hideTodoModal()">Anulează</button>
<button class="btn btn-primary" id="todoSaveBtn" onclick="saveTodoForm()">Adaugă</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
// Theme
function initTheme() {
const saved = localStorage.getItem('theme') || 'dark';
document.documentElement.setAttribute('data-theme', saved);
updateThemeIcon(saved);
}
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme') || 'dark';
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
updateThemeIcon(next);
}
function updateThemeIcon(theme) {
const icon = document.getElementById('themeIcon');
if (icon) {
icon.setAttribute('data-lucide', theme === 'dark' ? 'sun' : 'moon');
lucide.createIcons();
}
}
initTheme();
lucide.createIcons();
// Collapsible sections
function getCollapsedSections() {
try {
return JSON.parse(localStorage.getItem('collapsedSections') || '["statusBar"]');
} catch { return ['statusBar']; }
}
function setCollapsedSections(sections) {
localStorage.setItem('collapsedSections', JSON.stringify(sections));
}
function initCollapsedSections() {
const collapsed = getCollapsedSections();
collapsed.forEach(id => {
const el = document.getElementById(id);
if (el) el.classList.add('collapsed');
});
}
function toggleSection(id) {
const el = document.getElementById(id);
if (!el) return;
el.classList.toggle('collapsed');
const collapsed = getCollapsedSections();
const idx = collapsed.indexOf(id);
if (el.classList.contains('collapsed')) {
if (idx === -1) collapsed.push(id);
} else {
if (idx > -1) collapsed.splice(idx, 1);
}
setCollapsedSections(collapsed);
}
initCollapsedSections();
// Data
let issuesData = null;
let currentFilter = 'all';
let collapsedPriorities = new Set(['backlog', 'done']);
// Priority labels
const priorityLabels = {
'urgent-important': 'Urgent + Important',
'important': 'Important',
'urgent': 'Urgent',
'backlog': 'Backlog'
};
const priorityOrder = ['urgent-important', 'important', 'urgent', 'backlog'];
// Status sections collapse state
function getCollapsedStatusSections() {
const DEFAULTS = ['servicesSection', 'sessionsSection', 'anafSection', 'logsSection', 'doctorSection'];
try {
return JSON.parse(localStorage.getItem('collapsedStatusSections') || JSON.stringify(DEFAULTS));
} catch { return DEFAULTS; }
}
function setCollapsedStatusSections(sections) {
localStorage.setItem('collapsedStatusSections', JSON.stringify(sections));
}
function initStatusSections() {
const collapsed = new Set(getCollapsedStatusSections());
document.querySelectorAll('#statusBar .status-section').forEach(el => {
if (collapsed.has(el.id)) {
el.classList.add('collapsed');
} else {
el.classList.remove('collapsed');
}
});
}
function toggleStatusSection(id) {
const el = document.getElementById(id);
if (!el) return;
const wasCollapsed = el.classList.contains('collapsed');
el.classList.toggle('collapsed');
// Lazy-load logs the first time the section opens.
if (id === 'logsSection' && wasCollapsed && el.dataset.loaded !== '1') {
el.dataset.loaded = '1';
loadLogs();
}
const collapsed = getCollapsedStatusSections();
const idx = collapsed.indexOf(id);
if (el.classList.contains('collapsed')) {
if (idx === -1) collapsed.push(id);
} else {
if (idx > -1) collapsed.splice(idx, 1);
}
setCollapsedStatusSections(collapsed);
}
// Status loading
async function loadStatus() {
await Promise.all([
loadGitStatus(),
loadAnafStatus(),
loadCronStatus()
]);
updateStatusSummary();
}
async function restartTaskboard() {
if (!confirm('Restart taskboard? Pagina va fi indisponibilă ~5 secunde.')) return;
showToast('Se restartează taskboard...', 'info');
try {
await fetch('/echo/api/eco/restart-taskboard', { method: 'POST' });
} catch (e) { /* expected — server dies */ }
setTimeout(() => {
const check = setInterval(async () => {
try {
const r = await fetch('/echo/api/status');
if (r.ok) { clearInterval(check); location.reload(); }
} catch (e) { /* still restarting */ }
}, 1000);
}, 2000);
}
async function gitCommit() {
if (!confirm('Fac commit și push la toate modificările?')) return;
showToast('Se execută commit...', 'info');
try {
const response = await fetch('/echo/api/eco/git-commit', { method: 'POST' });
const text = await response.text();
let result;
try {
result = JSON.parse(text);
} catch {
showToast('Proxy timeout — verific status în 3s...', 'info');
setTimeout(refreshStatus, 3000);
return;
}
if (result.success) {
showToast('Commit reușit: ' + (result.files || 0) + ' fișiere', 'success');
setTimeout(refreshStatus, 1000);
} else {
showToast('Eroare: ' + (result.error || 'necunoscută'), 'error');
}
} catch (error) {
showToast('Eroare la commit: ' + error.message, 'error');
}
}
async function refreshStatus() {
showToast('Se reîmprospătează...');
await loadStatus();
showToast('Status actualizat!');
}
async function loadGitStatus() {
const badge = document.getElementById('gitBadge');
const subtitle = document.getElementById('gitSubtitle');
const details = document.getElementById('gitDetails');
try {
const response = await fetch('/echo/api/git?' + Date.now());
if (!response.ok) throw new Error('HTTP ' + response.status);
const git = await response.json();
if (git.clean) {
badge.textContent = 'curat';
badge.className = 'status-badge ok';
} else {
badge.textContent = git.uncommittedCount + ' modificări';
badge.className = 'status-badge warning';
}
subtitle.textContent = `${git.branch} · ${git.lastCommit.time}`;
const GITEA_URL = 'https://gitea.romfast.ro/romfast/echo-core';
let html = `
<div class="status-detail-item">
<i data-lucide="git-commit"></i>
<span><a href="${GITEA_URL}/commit/${git.lastCommit.hash}" target="_blank" style="color:var(--accent)">${git.lastCommit.hash}</a> ${git.lastCommit.message.substring(0, 50)}${git.lastCommit.message.length > 50 ? '...' : ''} (${git.lastCommit.time})</span>
</div>
`;
if (git.uncommittedCount > 0) {
const files = (git.uncommittedParsed || []).slice(0, 5).map(f => f.path).join(', ');
const more = git.uncommittedCount > 5 ? ` +${git.uncommittedCount - 5}` : '';
html += `<div class="status-detail-item uncommitted">
<i data-lucide="alert-circle"></i>
<span><a href="/echo/files.html?git=1" style="color:var(--warning)"><strong>${git.uncommittedCount}</strong> necomise</a>: ${files}${more}</span>
</div>`;
}
if (git.diffStat) {
html += `<div class="status-detail-item" style="font-family:var(--font-mono);white-space:pre-wrap;"><i data-lucide="bar-chart-3"></i><span>${escapeHtml(git.diffStat)}</span></div>`;
}
html += `<div class="status-detail-item">
<i data-lucide="external-link"></i>
<a href="${GITEA_URL}" target="_blank" style="color:var(--accent)">gitea.romfast.ro/romfast/echo-core</a>
</div>`;
details.innerHTML = html;
lucide.createIcons();
return git;
} catch (e) {
console.error('Git status error:', e);
badge.textContent = 'eroare';
badge.className = 'status-badge error';
subtitle.textContent = 'nu se poate încărca status-ul git';
details.innerHTML = `<div class="panel-error" role="alert"><i data-lucide="alert-triangle"></i><span>${escapeHtml(e.message || 'eroare necunoscută')}</span><button onclick="loadGitStatus()">Retry</button></div>`;
lucide.createIcons();
}
}
async function loadAnafStatus() {
try {
const response = await fetch('/echo/status.json?' + Date.now());
if (!response.ok) throw new Error('No status.json');
const status = await response.json();
if (status.anaf) {
const badge = document.getElementById('anafBadge');
badge.textContent = status.anaf.status || 'OK';
badge.className = 'status-badge ' + (status.anaf.ok !== false ? 'ok' : 'warning');
const subtitle = document.getElementById('anafSubtitle');
const lastCheck = status.anaf.lastCheck || '-';
const msg = status.anaf.ok !== false ? 'Nicio modificare' : (status.anaf.message || 'Modificări!');
subtitle.textContent = `${msg} · ${lastCheck}`;
// Actualizează detaliile
const details = document.getElementById('anafDetails');
let html = '';
// Adaugă detaliile modificărilor dacă există
if (status.anaf.changes && status.anaf.changes.length > 0) {
status.anaf.changes.forEach(change => {
const summaryText = change.summary && change.summary.length > 0
? ' - ' + change.summary.join(', ')
: '';
html += `
<div class="status-detail-item uncommitted">
<i data-lucide="alert-circle"></i>
<span><a href="${change.url}" target="_blank" style="color:var(--warning)">${change.name}</a>${summaryText}</span>
</div>
`;
});
} else {
html = `
<div class="status-detail-item">
<i data-lucide="check-circle"></i>
<span>Toate paginile sunt la zi</span>
</div>
`;
}
details.innerHTML = html;
lucide.createIcons();
}
return status;
} catch (e) {
console.log('No ANAF status');
}
}
async function loadCronStatus() {
try {
const response = await fetch('/echo/api/cron?' + Date.now());
if (!response.ok) throw new Error('API error');
const data = await response.json();
const jobs = data.jobs || [];
const ranToday = data.ranToday || 0;
// Update badge
const badge = document.getElementById('cronBadge');
badge.textContent = `${ranToday}/${jobs.length}`;
badge.className = 'status-badge ok';
// Update subtitle - find next job to run
const subtitle = document.getElementById('cronSubtitle');
const now = Date.now();
const pendingJobs = jobs.filter(j => !j.ranToday);
const nextJob = pendingJobs.length > 0 ? pendingJobs[0] : null;
subtitle.textContent = nextJob
? `Următorul: ${nextJob.time} ${nextJob.name}`
: 'Toate job-urile au rulat azi';
// Sort jobs: today first (by time), then other days (by date+time)
const today = new Date().toDateString();
jobs.sort((a, b) => {
const aDate = new Date(a.nextRunAtMs || 0);
const bDate = new Date(b.nextRunAtMs || 0);
const aIsToday = aDate.toDateString() === today;
const bIsToday = bDate.toDateString() === today;
// Today jobs come first
if (aIsToday && !bIsToday) return -1;
if (!aIsToday && bIsToday) return 1;
// Within same category, sort by time
return (a.nextRunAtMs || 0) - (b.nextRunAtMs || 0);
});
// Update details - compact grid layout
const details = document.getElementById('cronDetails');
const jobsHtml = jobs.map(job => {
const done = job.ranToday;
const failed = job.lastStatus === 'error';
const statusClass = failed ? 'failed' : (done ? 'done' : 'pending');
const icon = failed ? 'x-circle' : (done ? 'check-circle' : 'clock');
// Check if job is today
const jobDate = new Date(job.nextRunAtMs || 0);
const isToday = jobDate.toDateString() === today;
const dateStr = isToday ? '' : `<span class="cron-date">${jobDate.getDate()} ${jobDate.toLocaleDateString('ro-RO', { month: 'short' })}</span>`;
return `
<div class="cron-item ${statusClass}">
<i data-lucide="${icon}" class="cron-icon ${statusClass}"></i>
<span class="cron-time">${job.time}${dateStr}</span>
<span class="cron-name">${job.name}</span>
</div>
`;
}).join('');
details.innerHTML = `<div class="cron-list">${jobsHtml}</div>`;
lucide.createIcons();
} catch (e) {
console.error('Error loading cron status:', e);
const badge = document.getElementById('cronBadge');
badge.textContent = 'eroare';
badge.className = 'status-badge error';
}
}
function updateStatusSummary() {
const gitBadge = document.getElementById('gitBadge');
const anafBadge = document.getElementById('anafBadge');
const cronBadge = document.getElementById('cronBadge');
const summary = document.getElementById('statusSummary');
summary.textContent = `Git: ${gitBadge?.textContent || '-'} · ANAF: ${anafBadge?.textContent || '-'} · Cron: ${cronBadge?.textContent || '-'}`;
}
// Auto-refresh on page focus
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
loadStatus();
}
});
// Load data
// ===== TODO'S =====
let todosData = { items: [] };
async function loadTodos() {
try {
const response = await fetch('/echo/todos.json?' + Date.now());
if (response.ok) {
todosData = await response.json();
renderTodos();
}
} catch (error) {
console.error('Error loading todos:', error);
}
}
function renderTodos() {
const container = document.getElementById('todosBody');
const today = new Date().toISOString().split('T')[0];
const tomorrow = new Date(Date.now() + 86400000).toISOString().split('T')[0];
// Group by status
const todayItems = todosData.items.filter(t => !t.done && t.dueDate === today);
const tomorrowItems = todosData.items.filter(t => !t.done && t.dueDate === tomorrow);
const overdueItems = todosData.items.filter(t => !t.done && t.dueDate < today);
const futureItems = todosData.items.filter(t => !t.done && t.dueDate > tomorrow);
const doneToday = todosData.items.filter(t => t.done && t.doneAt && t.doneAt.startsWith(today));
// Update count
const activeCount = todosData.items.filter(t => !t.done).length;
document.getElementById('todosCount').textContent = activeCount;
let html = '';
if (overdueItems.length > 0) {
html += '<div class="todo-section"><div class="todo-section-title overdue">⚠️ RESTANTE</div>';
html += overdueItems.map(t => renderTodoItem(t)).join('');
html += '</div>';
}
if (todayItems.length > 0) {
html += '<div class="todo-section"><div class="todo-section-title">📅 AZI</div>';
html += todayItems.map(t => renderTodoItem(t)).join('');
html += '</div>';
}
if (tomorrowItems.length > 0) {
html += '<div class="todo-section"><div class="todo-section-title">📅 MÂINE</div>';
html += tomorrowItems.map(t => renderTodoItem(t)).join('');
html += '</div>';
}
if (futureItems.length > 0) {
html += '<div class="todo-section"><div class="todo-section-title">📆 VIITOR</div>';
html += futureItems.map(t => renderTodoItem(t)).join('');
html += '</div>';
}
if (doneToday.length > 0) {
html += '<div class="todo-section"><div class="todo-section-title">✅ COMPLETATE AZI</div>';
html += doneToday.map(t => renderTodoItem(t)).join('');
html += '</div>';
}
if (html === '') {
html = '<div class="empty-state"><i data-lucide="check-circle"></i><p>Niciun todo activ</p></div>';
}
container.innerHTML = html;
lucide.createIcons();
}
function renderTodoItem(todo) {
const isOverdue = !todo.done && todo.dueDate < new Date().toISOString().split('T')[0];
return `
<div class="todo-item ${todo.done ? 'done' : ''}" data-id="${todo.id}" onclick="editTodo('${todo.id}')">
<div class="todo-checkbox ${todo.done ? 'checked' : ''}" onclick="event.stopPropagation(); toggleTodo('${todo.id}')">
<i data-lucide="check"></i>
</div>
<div class="todo-content">
<div class="todo-text">${todo.text}</div>
${todo.context ? `<div class="todo-context"><strong>Context:</strong> ${todo.context}</div>` : ''}
${todo.example ? `<div class="todo-example"><strong>Exemplu:</strong> ${todo.example}</div>` : ''}
<div class="todo-meta">
<span class="todo-domain ${todo.domain}">@${todo.domain}</span>
${todo.dueDate ? `<span class="todo-due ${isOverdue ? 'overdue' : ''}">${formatDate(todo.dueDate)}</span>` : ''}
${todo.source ? (todo.sourceUrl
? `<a href="javascript:void(0)" class="todo-source-link" onclick="event.stopPropagation(); openNoteOverlay('${todo.sourceUrl}')">${todo.source}</a>`
: `<span class="todo-source">${todo.source}</span>`) : ''}
</div>
</div>
</div>
`;
}
function formatDate(dateStr) {
const date = new Date(dateStr);
const today = new Date();
const tomorrow = new Date(Date.now() + 86400000);
if (dateStr === today.toISOString().split('T')[0]) return 'Azi';
if (dateStr === tomorrow.toISOString().split('T')[0]) return 'Mâine';
return date.toLocaleDateString('ro-RO', { day: 'numeric', month: 'short' });
}
async function toggleTodo(id) {
const todo = todosData.items.find(t => t.id === id);
if (todo) {
todo.done = !todo.done;
todo.doneAt = todo.done ? new Date().toISOString() : null;
await saveTodos();
renderTodos();
}
}
async function saveTodos() {
todosData.lastUpdated = new Date().toISOString();
try {
const response = await fetch('/echo/api/files', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
path: 'dashboard/todos.json',
content: JSON.stringify(todosData, null, 2)
})
});
if (!response.ok) throw new Error('Save failed');
} catch (error) {
showToast('Eroare la salvare', 'error');
}
}
function openNoteOverlay(url) {
// Convert files.html URL to the actual file path for iframe
const overlay = document.getElementById('noteOverlay');
const frame = document.getElementById('noteOverlayFrame');
const title = document.getElementById('noteOverlayTitle');
// Extract filename from URL
const match = url.match(/#(.+)$/);
if (match) {
const path = match[1];
const filename = path.split('/').pop().replace('.md', '');
title.textContent = filename;
}
frame.src = url;
overlay.classList.add('active');
}
function closeNoteOverlay() {
const overlay = document.getElementById('noteOverlay');
const frame = document.getElementById('noteOverlayFrame');
overlay.classList.remove('active');
frame.src = '';
}
function showAddTodoModal() {
document.getElementById('todoModalTitle').textContent = 'Todo nou';
document.getElementById('todoEditId').value = '';
document.getElementById('todoText').value = '';
document.getElementById('todoContext').value = '';
document.getElementById('todoExample').value = '';
document.getElementById('todoDomain').value = 'work';
document.getElementById('todoDueDate').value = new Date().toISOString().split('T')[0];
document.getElementById('todoSource').value = '';
document.getElementById('todoSourceUrl').value = '';
document.getElementById('todoDeleteBtn').style.display = 'none';
document.getElementById('todoSaveBtn').textContent = 'Adaugă';
document.getElementById('todoModal').classList.add('active');
document.getElementById('todoText').focus();
}
function editTodo(id) {
const todo = todosData.items.find(t => t.id === id);
if (!todo) return;
document.getElementById('todoModalTitle').textContent = 'Editare todo';
document.getElementById('todoEditId').value = id;
document.getElementById('todoText').value = todo.text || '';
document.getElementById('todoContext').value = todo.context || '';
document.getElementById('todoExample').value = todo.example || '';
document.getElementById('todoDomain').value = todo.domain || 'work';
document.getElementById('todoDueDate').value = todo.dueDate || '';
document.getElementById('todoSource').value = todo.source || '';
document.getElementById('todoSourceUrl').value = todo.sourceUrl || '';
document.getElementById('todoDeleteBtn').style.display = 'block';
document.getElementById('todoSaveBtn').textContent = 'Salvează';
document.getElementById('todoModal').classList.add('active');
document.getElementById('todoText').focus();
}
function hideTodoModal() {
document.getElementById('todoModal').classList.remove('active');
}
async function saveTodoForm() {
const text = document.getElementById('todoText').value.trim();
if (!text) {
showToast('Textul este obligatoriu', 'error');
return;
}
const editId = document.getElementById('todoEditId').value;
if (editId) {
// Edit existing
const todo = todosData.items.find(t => t.id === editId);
if (todo) {
todo.text = text;
todo.context = document.getElementById('todoContext').value.trim() || null;
todo.example = document.getElementById('todoExample').value.trim() || null;
todo.domain = document.getElementById('todoDomain').value;
todo.dueDate = document.getElementById('todoDueDate').value || null;
todo.source = document.getElementById('todoSource').value.trim() || null;
todo.sourceUrl = document.getElementById('todoSourceUrl').value.trim() || null;
todo.updatedAt = new Date().toISOString();
}
showToast('Todo actualizat');
} else {
// Add new
const todo = {
id: 'todo-' + Date.now(),
text: text,
context: document.getElementById('todoContext').value.trim() || null,
example: document.getElementById('todoExample').value.trim() || null,
domain: document.getElementById('todoDomain').value,
dueDate: document.getElementById('todoDueDate').value || new Date().toISOString().split('T')[0],
done: false,
doneAt: null,
source: document.getElementById('todoSource').value.trim() || 'manual',
sourceUrl: document.getElementById('todoSourceUrl').value.trim() || null,
createdAt: new Date().toISOString()
};
todosData.items.push(todo);
showToast('Todo adăugat');
}
hideTodoModal();
await saveTodos();
renderTodos();
}
async function deleteTodo() {
const editId = document.getElementById('todoEditId').value;
if (!editId) return;
if (!confirm('Sigur ștergi acest todo?')) return;
todosData.items = todosData.items.filter(t => t.id !== editId);
hideTodoModal();
await saveTodos();
renderTodos();
showToast('Todo șters');
}
// ===== ISSUES =====
async function loadIssues() {
try {
const response = await fetch('/echo/issues.json?' + Date.now());
issuesData = await response.json();
populateProgramSelect();
renderIssues();
updateIssuesCount();
} catch (error) {
console.error('Error loading issues:', error);
document.getElementById('issuesBody').innerHTML = `
<div class="empty-state">
<i data-lucide="alert-circle"></i>
<p>Eroare la încărcare</p>
</div>
`;
lucide.createIcons();
}
}
function updateIssuesCount() {
if (!issuesData) return;
const todoCount = issuesData.issues.filter(i => i.status !== 'done').length;
document.getElementById('issuesCount').textContent = todoCount;
}
function renderIssues() {
const body = document.getElementById('issuesBody');
if (!issuesData || issuesData.issues.length === 0) {
body.innerHTML = `
<div class="empty-state">
<i data-lucide="check-circle"></i>
<p>Niciun issue.</p>
</div>
`;
lucide.createIcons();
return;
}
// Filter issues
let filtered = issuesData.issues;
if (currentFilter !== 'all') {
filtered = filtered.filter(i => i.owner === currentFilter);
}
// Separate done from active
const activeIssues = filtered.filter(i => i.status !== 'done');
const doneIssues = filtered.filter(i => i.status === 'done');
// Group active by priority
const grouped = {};
priorityOrder.forEach(p => grouped[p] = []);
activeIssues.forEach(issue => {
const p = issue.priority || 'backlog';
if (grouped[p]) grouped[p].push(issue);
else grouped['backlog'].push(issue);
});
// Sort each group by date (newest first)
Object.keys(grouped).forEach(p => {
grouped[p].sort((a, b) => new Date(b.created) - new Date(a.created));
});
let html = '';
// Render active issues by priority
priorityOrder.forEach(priority => {
const issues = grouped[priority];
if (issues.length === 0) return;
const isCollapsed = collapsedPriorities.has(priority);
html += `
<div class="priority-group">
<div class="priority-header ${isCollapsed ? 'collapsed' : ''}" onclick="togglePriority('${priority}')">
<span>${priorityLabels[priority]}</span>
<span style="margin-left: auto; opacity: 0.7; font-size: var(--text-xs);">${issues.length}</span>
</div>
<div class="priority-content ${isCollapsed ? 'hidden' : ''}">
${issues.map(issue => renderIssueItem(issue)).join('')}
</div>
</div>
`;
});
// Render done issues separately at the end
if (doneIssues.length > 0) {
const isDoneCollapsed = collapsedPriorities.has('done');
doneIssues.sort((a, b) => new Date(b.completed || b.updated) - new Date(a.completed || a.updated));
html += `
<div class="priority-group">
<div class="priority-header ${isDoneCollapsed ? 'collapsed' : ''}" onclick="togglePriority('done')">
<span>Executate</span>
<span style="margin-left: auto; opacity: 0.7; font-size: var(--text-xs);">${doneIssues.length}</span>
</div>
<div class="priority-content ${isDoneCollapsed ? 'hidden' : ''}">
${doneIssues.map(issue => renderIssueItem(issue)).join('')}
</div>
</div>
`;
}
body.innerHTML = html;
lucide.createIcons();
}
function renderIssueItem(issue) {
const isDone = issue.status === 'done';
const isInProgress = issue.status === 'in-progress';
const ownerIcons = { 'clawdbot': '🤖', 'robert': '👷', 'marius': '👤' };
const ownerIcon = ownerIcons[issue.owner] || '👤';
const dateStr = new Date(issue.created).toLocaleDateString('ro-RO', { day: 'numeric', month: 'short' });
const statusLabels = { 'todo': 'Todo', 'in-progress': 'In Progress', 'done': 'Done' };
const statusLabel = statusLabels[issue.status] || 'Todo';
const checkboxClass = isDone ? 'checked' : (isInProgress ? 'in-progress' : '');
return `
<div class="issue-item ${isDone ? 'done' : ''}" data-id="${issue.id}">
<div class="issue-checkbox ${checkboxClass}" onclick="toggleIssue('${issue.id}')" title="Click pentru a schimba statusul">
<i data-lucide="check"></i>
</div>
<div class="issue-content" onclick="editIssue('${issue.id}')">
<div class="issue-title">${issue.title}</div>
<div class="issue-meta">
<span class="issue-status ${issue.status || 'todo'}">${statusLabel}</span>
${issue.program ? `<span class="issue-tag program">${issue.program}</span>` : ''}
<span class="issue-owner ${issue.owner}">${ownerIcon} ${issue.owner === 'clawdbot' ? 'Clawdbot' : (issue.owner === 'robert' ? 'Robert' : 'Marius')}</span>
<span class="issue-date">${dateStr}</span>
</div>
</div>
</div>
`;
}
function togglePriority(priority) {
if (collapsedPriorities.has(priority)) {
collapsedPriorities.delete(priority);
} else {
collapsedPriorities.add(priority);
}
renderIssues();
}
async function toggleIssue(id) {
const issue = issuesData.issues.find(i => i.id === id);
if (!issue) return;
// Cycle: todo → in-progress → done → todo
const statusCycle = { 'todo': 'in-progress', 'in-progress': 'done', 'done': 'todo' };
const currentStatus = issue.status || 'todo';
issue.status = statusCycle[currentStatus] || 'in-progress';
if (issue.status === 'done') {
issue.completed = new Date().toISOString();
} else {
delete issue.completed;
}
const statusMessages = {
'in-progress': '🔄 In Progress',
'done': '✅ Done!',
'todo': '📋 Todo'
};
renderIssues();
updateIssuesCount();
await saveIssues();
showToast(statusMessages[issue.status]);
}
// Filters
document.getElementById('issuesFilters').addEventListener('click', (e) => {
if (e.target.classList.contains('filter-btn')) {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
e.target.classList.add('active');
currentFilter = e.target.dataset.filter;
renderIssues();
}
});
// Issue Modal
function showAddModal() {
document.getElementById('issueModalTitle').textContent = 'Issue nou';
document.getElementById('issueEditId').value = '';
document.getElementById('issueTitle').value = '';
document.getElementById('issueDesc').value = '';
document.getElementById('issueProgram').value = '';
document.getElementById('issueOwner').value = 'marius';
document.getElementById('issuePriority').value = 'urgent-important';
document.getElementById('issueStatus').value = 'todo';
document.getElementById('issueDeadline').value = '';
document.getElementById('issueDeleteBtn').style.display = 'none';
document.getElementById('issueSaveBtn').textContent = 'Adaugă';
document.getElementById('issueModal').classList.add('active');
document.getElementById('issueTitle').focus();
}
function editIssue(id) {
const issue = issuesData.issues.find(i => i.id === id);
if (!issue) return;
document.getElementById('issueModalTitle').textContent = 'Editare issue';
document.getElementById('issueEditId').value = id;
document.getElementById('issueTitle').value = issue.title || '';
document.getElementById('issueDesc').value = issue.description || '';
document.getElementById('issueProgram').value = issue.program || '';
document.getElementById('issueOwner').value = issue.owner || 'marius';
document.getElementById('issuePriority').value = issue.priority || 'backlog';
document.getElementById('issueStatus').value = issue.status || 'todo';
document.getElementById('issueDeadline').value = issue.deadline || '';
document.getElementById('issueDeleteBtn').style.display = 'block';
document.getElementById('issueSaveBtn').textContent = 'Salvează';
document.getElementById('issueModal').classList.add('active');
document.getElementById('issueTitle').focus();
}
function hideIssueModal() {
document.getElementById('issueModal').classList.remove('active');
}
// Legacy alias
function hideAddModal() { hideIssueModal(); }
function populateProgramSelect() {
const select = document.getElementById('issueProgram');
select.innerHTML = '<option value="">— Selectează —</option>';
if (issuesData && issuesData.programs) {
issuesData.programs.forEach(p => {
select.innerHTML += `<option value="${p}">${p}</option>`;
});
}
}
async function saveIssue() {
const title = document.getElementById('issueTitle').value.trim();
if (!title) {
showToast('Titlul este obligatoriu', 'error');
return;
}
const editId = document.getElementById('issueEditId').value;
if (editId) {
// Edit existing
const issue = issuesData.issues.find(i => i.id === editId);
if (issue) {
issue.title = title;
issue.description = document.getElementById('issueDesc').value.trim();
issue.program = document.getElementById('issueProgram').value;
issue.owner = document.getElementById('issueOwner').value;
issue.priority = document.getElementById('issuePriority').value;
const newStatus = document.getElementById('issueStatus').value;
if (newStatus === 'done' && issue.status !== 'done') {
issue.completed = new Date().toISOString();
} else if (newStatus !== 'done') {
delete issue.completed;
}
issue.status = newStatus;
issue.deadline = document.getElementById('issueDeadline').value || null;
issue.updated = new Date().toISOString();
}
showToast('Issue actualizat!');
} else {
// Add new
const newIssue = {
id: 'ROA-' + String(issuesData.issues.length + 1).padStart(3, '0'),
title: title,
description: document.getElementById('issueDesc').value.trim(),
program: document.getElementById('issueProgram').value,
owner: document.getElementById('issueOwner').value,
priority: document.getElementById('issuePriority').value,
status: document.getElementById('issueStatus').value || 'todo',
created: new Date().toISOString(),
deadline: document.getElementById('issueDeadline').value || null
};
issuesData.issues.unshift(newIssue);
showToast('Issue adăugat!');
}
hideIssueModal();
renderIssues();
updateIssuesCount();
await saveIssues();
}
async function deleteIssue() {
const editId = document.getElementById('issueEditId').value;
if (!editId) return;
if (!confirm('Sigur ștergi acest issue?')) return;
issuesData.issues = issuesData.issues.filter(i => i.id !== editId);
hideIssueModal();
renderIssues();
updateIssuesCount();
await saveIssues();
showToast('Issue șters');
}
// Legacy alias
async function addIssue() { await saveIssue(); }
async function saveIssues() {
issuesData.lastUpdated = new Date().toISOString();
try {
const response = await fetch('/echo/api/files', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
path: 'dashboard/issues.json',
content: JSON.stringify(issuesData, null, 2)
})
});
if (!response.ok) throw new Error('Save failed');
} catch (error) {
showToast('Eroare la salvare', 'error');
}
}
function showToast(message, type = 'success') {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = 'toast show';
setTimeout(() => toast.classList.remove('show'), 3000);
}
// Close modals on outside click
document.getElementById('issueModal').addEventListener('click', (e) => {
if (e.target.id === 'issueModal') hideIssueModal();
});
document.getElementById('todoModal').addEventListener('click', (e) => {
if (e.target.id === 'todoModal') hideTodoModal();
});
// ─────────────────────────────────────────────────────────
// Eco panel functions (merged from eco.html)
// ─────────────────────────────────────────────────────────
let servicesRefreshInterval = null;
let logRefreshInterval = null;
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text == null ? '' : String(text);
return div.innerHTML;
}
function formatUptime(seconds) {
if (!seconds && seconds !== 0) return '-';
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function svcLabel(name) {
return name.replace('echo-', '').replace('-', ' ');
}
// ── Services ────────────────────────────────────────────
async function loadServices() {
const grid = document.getElementById('servicesGrid');
try {
const res = await fetch('/echo/api/eco/status');
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
if (data.error) throw new Error(data.error);
if (!data.services || data.services.length === 0) {
grid.innerHTML = '<div class="empty-state-warm">Niciun serviciu detectat. Adaugă unul în <code>ECO_SERVICES</code> din <code>dashboard/constants.py</code>.</div>';
return;
}
renderServices(data.services);
} catch (e) {
grid.innerHTML = `<div class="panel-error" role="alert"><i data-lucide="alert-triangle"></i><span>Nu am putut încărca services: ${escapeHtml(e.message)}</span><button onclick="loadServices()">Retry</button></div>`;
lucide.createIcons();
}
}
function renderServices(services) {
const grid = document.getElementById('servicesGrid');
// Update the Status sub-section header (badge + subtitle).
const badge = document.getElementById('servicesBadge');
const subtitle = document.getElementById('servicesSubtitle');
if (badge && subtitle) {
const total = services.length;
const active = services.filter(s => s.active).length;
badge.textContent = `${active}/${total}`;
badge.className = active === total ? 'status-badge ok' : 'status-badge warning';
const stopped = services.filter(s => !s.active).map(s => svcLabel(s.name));
subtitle.textContent = stopped.length === 0
? 'Toate servicile rulează'
: `Oprite: ${stopped.join(', ')}`;
}
grid.innerHTML = services.map(svc => {
const isTaskboard = svc.name === 'echo-taskboard';
const canControl = !isTaskboard;
let actionsHtml = '';
if (canControl) {
if (svc.active) {
actionsHtml = `
<button class="btn btn-secondary" onclick="restartService('${svc.name}')" aria-label="Restart ${svcLabel(svc.name)}">
<i data-lucide="rotate-cw"></i> Restart
</button>
<button class="btn btn-secondary btn-danger" onclick="stopService('${svc.name}')" aria-label="Stop ${svcLabel(svc.name)}">
<i data-lucide="square"></i> Stop
</button>
`;
} else {
actionsHtml = `
<button class="btn btn-primary" onclick="restartService('${svc.name}')" aria-label="Start ${svcLabel(svc.name)}">
<i data-lucide="play"></i> Start
</button>
`;
}
}
return `
<div class="service-card">
<div class="service-header">
<div class="service-name">
<span class="status-dot ${svc.active ? 'active' : 'inactive'}" aria-hidden="true"></span>
${escapeHtml(svcLabel(svc.name))}
</div>
<span style="font-size:var(--text-xs);color:${svc.active ? 'var(--success)' : 'var(--error)'};">
${svc.active ? 'running' : 'stopped'}
</span>
</div>
<div class="service-meta">
<span><i data-lucide="hash" style="width:12px;height:12px;"></i> PID: ${svc.pid || '-'}</span>
<span><i data-lucide="clock" style="width:12px;height:12px;"></i> Uptime: ${formatUptime(svc.uptime)}</span>
<span><i data-lucide="memory-stick" style="width:12px;height:12px;"></i> Memory: ${escapeHtml(svc.memory || '-')}</span>
</div>
<div class="service-actions">
${actionsHtml}
</div>
</div>
`;
}).join('');
lucide.createIcons();
}
async function restartService(name) {
try {
const res = await fetch('/echo/api/eco/restart', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({service: name})
});
const data = await res.json();
if (data.success) {
setTimeout(loadServices, 1500);
} else {
alert('Error: ' + data.error);
}
} catch (e) {
alert('Failed: ' + e.message);
}
}
async function stopService(name) {
if (!confirm(`Stop ${svcLabel(name)}?`)) return;
try {
const res = await fetch('/echo/api/eco/stop', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({service: name})
});
const data = await res.json();
if (data.success) {
setTimeout(loadServices, 1000);
} else {
alert('Error: ' + data.error);
}
} catch (e) {
alert('Failed: ' + e.message);
}
}
// ── Sessions ────────────────────────────────────────────
const pIcon = {
discord: '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128c.12-.098.246-.198.373-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/></svg>',
telegram: '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/></svg>',
whatsapp: '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 0 1-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 0 1-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 0 1 2.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0 0 12.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 0 0 5.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 0 0-3.48-8.413z"/></svg>',
unknown: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg>'
};
function fmtTime(iso) {
if (!iso) return '-';
const d = new Date(iso), now = new Date();
const t = d.toLocaleTimeString('ro-RO', {hour:'2-digit', minute:'2-digit'});
if (d.toDateString() === now.toDateString()) return t;
const yesterday = new Date(now); yesterday.setDate(yesterday.getDate()-1);
if (d.toDateString() === yesterday.toDateString()) return 'ieri ' + t;
return d.toLocaleDateString('ro-RO', {day:'2-digit', month:'2-digit'}) + ' ' + t;
}
function fmtCost(u) { return u ? '$' + u.toFixed(3) : '-'; }
function fmtTok(n) { return !n ? '0' : n >= 1000 ? (n/1000).toFixed(1)+'k' : String(n); }
function fmtDur(ms) { if (!ms) return '-'; const s = Math.round(ms/1000); return s<60 ? s+'s' : Math.floor(s/60)+'m'+((s%60)?(' '+s%60+'s'):''); }
let sessionsCache = [];
async function loadSessions() {
const container = document.getElementById('sessionsContent');
try {
const res = await fetch('/echo/api/eco/sessions');
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
sessionsCache = data.sessions || [];
renderSessions(sessionsCache);
} catch (e) {
container.innerHTML = `<div class="panel-error" role="alert"><i data-lucide="alert-triangle"></i><span>Nu am putut încărca sessions: ${escapeHtml(e.message)}</span><button onclick="loadSessions()">Retry</button></div>`;
lucide.createIcons();
}
}
function toggleSession(i) {
const det = document.getElementById('sd-'+i);
const chv = document.getElementById('sc-'+i);
if (!det) return;
const opening = !det.classList.contains('open');
det.classList.toggle('open');
if (chv) chv.classList.toggle('open');
if (opening && !det.dataset.loaded) {
const s = sessionsCache[i];
if (s && s.session_id) {
det.dataset.loaded = '1';
loadSessionContent(s.session_id, i);
}
}
}
async function loadSessionContent(sessionId, idx) {
const el = document.getElementById('sm-'+idx);
if (!el) return;
el.innerHTML = '<div class="s-loading"><span class="spinner"></span></div>';
try {
const res = await fetch(`/echo/api/eco/sessions/content?id=${sessionId}`);
const data = await res.json();
if (!data.messages || data.messages.length === 0) {
el.innerHTML = '<div class="s-loading">No messages</div>';
return;
}
el.innerHTML = data.messages.map(m =>
`<div class="s-msg ${m.role}"><span class="s-msg-role">${m.role === 'user' ? 'USR' : 'ECH'}</span><span class="s-msg-text">${escapeHtml(m.text)}</span></div>`
).join('');
el.scrollTop = el.scrollHeight;
} catch (e) {
el.innerHTML = `<div class="s-loading" style="color:var(--error);">${escapeHtml(e.message)}</div>`;
}
}
function renderSessions(sessions) {
const container = document.getElementById('sessionsContent');
const clearBtn = document.getElementById('clearAllBtn');
// Update the Status sub-section header (badge + subtitle).
const badge = document.getElementById('sessionsBadge');
const subtitle = document.getElementById('sessionsSubtitle');
if (badge && subtitle) {
const count = sessions ? sessions.length : 0;
badge.textContent = String(count);
badge.className = 'status-badge ok';
if (count === 0) {
subtitle.textContent = 'Nicio sesiune activă';
} else {
const platforms = [...new Set(sessions.map(s => s.platform || 'unknown'))];
subtitle.textContent = platforms.join(', ');
}
}
if (!sessions || sessions.length === 0) {
container.innerHTML = '<div class="empty-state-warm">Nicio sesiune activă. Un DM către Echo pornește una.</div>';
clearBtn.style.display = 'none';
return;
}
clearBtn.style.display = '';
container.innerHTML = sessions.map((s, i) => {
const pl = s.platform || 'unknown';
const ic = pIcon[pl] || pIcon.unknown;
const nm = s.channel_name || s.channel_id || '-';
const tag = s.is_group ? '<span class="s-tag grup">GRUP</span>' : '';
const model = (s.model||'?').toUpperCase();
return `<div class="session-item">
<div class="session-row" onclick="toggleSession(${i})">
<div class="p-icon ${pl}" aria-hidden="true">${ic}</div>
<span class="s-name">${escapeHtml(nm)}</span>
${tag}
<div class="s-stats">
<span>${s.message_count||0}msg</span>
<span>${escapeHtml(model)}</span>
<span>${fmtCost(s.total_cost_usd)}</span>
</div>
<span class="s-time">${fmtTime(s.last_message_at)}</span>
<svg class="s-chevron" id="sc-${i}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="session-details" id="sd-${i}">
<div class="s-detail-row">
<span>created ${fmtTime(s.created_at)}</span>
<span>${s.message_count||0} msgs</span>
<span>${escapeHtml(model)}</span>
<span>${fmtTok(s.total_input_tokens)}/${fmtTok(s.total_output_tokens)} tok</span>
<span>ctx ${fmtTok(s.context_tokens)}</span>
<span>${fmtCost(s.total_cost_usd)}</span>
<span>${fmtDur(s.duration_ms)}</span>
</div>
<div class="s-messages" id="sm-${i}">
<div class="s-loading">Click to load messages...</div>
</div>
<div class="s-actions">
<button class="btn btn-secondary btn-danger" onclick="event.stopPropagation();clearSession('${escapeHtml(s.channel_id||'')}')" aria-label="Clear session">
<i data-lucide="trash-2"></i> Clear
</button>
</div>
</div>
</div>`;
}).join('');
lucide.createIcons();
}
async function clearSession(channelId) {
if (!confirm('Clear this session?')) return;
try {
const res = await fetch('/echo/api/eco/sessions/clear', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({channel: channelId})
});
const data = await res.json();
if (data.success) loadSessions();
else alert('Error: ' + data.error);
} catch (e) {
alert('Failed: ' + e.message);
}
}
async function clearAllSessions() {
if (!confirm('Clear all active sessions?')) return;
try {
const res = await fetch('/echo/api/eco/sessions/clear', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({})
});
const data = await res.json();
if (data.success) loadSessions();
else alert('Error: ' + data.error);
} catch (e) {
alert('Failed: ' + e.message);
}
}
// ── Logs (lazy-loaded) ──────────────────────────────────
async function loadLogs() {
const lines = document.getElementById('logLines').value;
const content = document.getElementById('logContent');
try {
const res = await fetch(`/echo/api/eco/logs?lines=${lines}`);
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
const arr = data.lines || [];
if (arr.length === 0) {
content.textContent = 'Nu există log-uri pentru ultimele ' + lines + ' linii. Dacă serviciul a fost restartat recent, logarea începe de la zero.';
} else {
content.textContent = arr.join('\n');
content.scrollTop = content.scrollHeight;
}
} catch (e) {
content.textContent = 'Eroare: ' + e.message;
}
}
function toggleAutoRefreshLogs() {
const enabled = document.getElementById('autoRefreshLogs').checked;
if (logRefreshInterval) {
clearInterval(logRefreshInterval);
logRefreshInterval = null;
}
if (enabled) {
logRefreshInterval = setInterval(loadLogs, 5000);
}
}
// ── Doctor ──────────────────────────────────────────────
async function runDoctor() {
// Expand both the parent Status bar and the Doctor sub-section so the user sees results.
document.getElementById('statusBar').classList.remove('collapsed');
const doctorSection = document.getElementById('doctorSection');
if (doctorSection.classList.contains('collapsed')) {
toggleStatusSection('doctorSection');
}
const list = document.getElementById('doctorChecks');
const btn = document.getElementById('doctorBtn');
list.innerHTML = '<li class="check-item"><span class="spinner"></span> Running checks...</li>';
btn.disabled = true;
try {
const res = await fetch('/echo/api/eco/doctor');
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
list.innerHTML = (data.checks || []).map(c => `
<li class="check-item">
<span class="check-icon ${c.pass ? 'pass' : 'fail'}" aria-hidden="true">
${c.pass ? '&#10003;' : '&#10007;'}
</span>
<span class="check-name">${escapeHtml(c.name)}</span>
<span class="check-detail">${escapeHtml(c.detail)}</span>
</li>
`).join('') || '<li class="check-item">No checks returned</li>';
} catch (e) {
list.innerHTML = `<li class="check-item" style="color:var(--error);" role="alert">Error: ${escapeHtml(e.message)}</li>`;
}
btn.disabled = false;
}
async function ecoGitCommit() {
if (!confirm('Fac commit și push la toate modificările echo-core?')) return;
try {
const res = await fetch('/echo/api/eco/git-commit', { method: 'POST' });
const text = await res.text();
let result;
try {
result = JSON.parse(text);
} catch {
showToast('Proxy timeout — verific status în 3s...', 'info');
setTimeout(loadGitStatus, 3000);
return;
}
if (result.success) {
showToast('Commit reușit: ' + (result.files || 0) + ' fișiere', 'success');
setTimeout(loadGitStatus, 1000);
} else {
showToast('Eroare: ' + (result.error || 'necunoscută'), 'error');
}
} catch (e) {
showToast('Eroare la commit: ' + e.message, 'error');
}
}
// Init
initStatusSections();
loadStatus();
loadIssues();
loadTodos();
loadServices();
loadSessions();
// Auto-refresh services every 10s (sessions/git refresh manually).
servicesRefreshInterval = setInterval(loadServices, 10000);
</script>
</body>
</html>