Update agents, dashboard, kb +2 more (+14 ~20 -3)
This commit is contained in:
222
dashboard/api.py
222
dashboard/api.py
@@ -102,6 +102,10 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
|
||||
self.handle_git_status()
|
||||
elif self.path == '/api/agents' or self.path.startswith('/api/agents?'):
|
||||
self.handle_agents_status()
|
||||
elif self.path == '/api/cron' or self.path.startswith('/api/cron?'):
|
||||
self.handle_cron_status()
|
||||
elif self.path == '/api/activity' or self.path.startswith('/api/activity?'):
|
||||
self.handle_activity()
|
||||
elif self.path.startswith('/api/files'):
|
||||
self.handle_files_get()
|
||||
elif self.path.startswith('/api/'):
|
||||
@@ -215,6 +219,215 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
|
||||
def handle_cron_status(self):
|
||||
"""Get cron jobs status from ~/.clawdbot/cron/jobs.json"""
|
||||
try:
|
||||
jobs_file = Path.home() / '.clawdbot' / 'cron' / 'jobs.json'
|
||||
|
||||
if not jobs_file.exists():
|
||||
self.send_json({'jobs': [], 'error': 'No jobs file found'})
|
||||
return
|
||||
|
||||
data = json.loads(jobs_file.read_text())
|
||||
all_jobs = data.get('jobs', [])
|
||||
|
||||
# Filter enabled jobs and format for dashboard
|
||||
now_ms = datetime.now().timestamp() * 1000
|
||||
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_start_ms = today_start.timestamp() * 1000
|
||||
|
||||
jobs = []
|
||||
for job in all_jobs:
|
||||
if not job.get('enabled', False):
|
||||
continue
|
||||
|
||||
# Parse cron expression to get time
|
||||
schedule = job.get('schedule', {})
|
||||
expr = schedule.get('expr', '')
|
||||
|
||||
# Simple cron parsing for display - convert UTC to Bucharest
|
||||
parts = expr.split()
|
||||
if len(parts) >= 2:
|
||||
minute = parts[0]
|
||||
hour = parts[1]
|
||||
if minute.isdigit() and (hour.isdigit() or '-' in hour):
|
||||
# Handle hour ranges like "7-17"
|
||||
if '-' in hour:
|
||||
hour_start, hour_end = hour.split('-')
|
||||
hour = hour_start # Show first hour
|
||||
# Convert UTC to Bucharest (UTC+2 winter, UTC+3 summer)
|
||||
from datetime import timezone as dt_timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
try:
|
||||
bucharest = ZoneInfo('Europe/Bucharest')
|
||||
utc_hour = int(hour)
|
||||
utc_minute = int(minute)
|
||||
# Create UTC datetime for today
|
||||
utc_dt = datetime.now(dt_timezone.utc).replace(hour=utc_hour, minute=utc_minute, second=0, microsecond=0)
|
||||
local_dt = utc_dt.astimezone(bucharest)
|
||||
time_str = f"{local_dt.hour:02d}:{local_dt.minute:02d}"
|
||||
except:
|
||||
time_str = f"{int(hour):02d}:{int(minute):02d}"
|
||||
else:
|
||||
time_str = expr[:15]
|
||||
else:
|
||||
time_str = expr[:15]
|
||||
|
||||
# Check if ran today
|
||||
state = job.get('state', {})
|
||||
last_run = state.get('lastRunAtMs', 0)
|
||||
ran_today = last_run >= today_start_ms
|
||||
last_status = state.get('lastStatus', 'unknown')
|
||||
|
||||
jobs.append({
|
||||
'id': job.get('id'),
|
||||
'name': job.get('name'),
|
||||
'agentId': job.get('agentId'),
|
||||
'time': time_str,
|
||||
'schedule': expr,
|
||||
'ranToday': ran_today,
|
||||
'lastStatus': last_status if ran_today else None,
|
||||
'lastRunAtMs': last_run,
|
||||
'nextRunAtMs': state.get('nextRunAtMs')
|
||||
})
|
||||
|
||||
# Sort by time
|
||||
jobs.sort(key=lambda j: j['time'])
|
||||
|
||||
self.send_json({
|
||||
'jobs': jobs,
|
||||
'total': len(jobs),
|
||||
'ranToday': sum(1 for j in jobs if j['ranToday'])
|
||||
})
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
|
||||
def handle_activity(self):
|
||||
"""Aggregate activity from multiple sources: cron jobs, git commits, file changes."""
|
||||
from datetime import timezone as dt_timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
try:
|
||||
activities = []
|
||||
bucharest = ZoneInfo('Europe/Bucharest')
|
||||
workspace = Path('/home/moltbot/clawd')
|
||||
|
||||
# 1. Cron jobs ran today
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['clawdbot', 'cron', 'list', '--json'],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if result.returncode == 0:
|
||||
cron_data = json.loads(result.stdout)
|
||||
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_start_ms = today_start.timestamp() * 1000
|
||||
|
||||
for job in cron_data.get('jobs', []):
|
||||
state = job.get('state', {})
|
||||
last_run = state.get('lastRunAtMs', 0)
|
||||
if last_run >= today_start_ms:
|
||||
run_time = datetime.fromtimestamp(last_run / 1000, tz=dt_timezone.utc)
|
||||
local_time = run_time.astimezone(bucharest)
|
||||
activities.append({
|
||||
'type': 'cron',
|
||||
'icon': 'clock',
|
||||
'text': f"Job: {job.get('name', 'unknown')}",
|
||||
'agent': job.get('agentId', 'echo'),
|
||||
'time': local_time.strftime('%H:%M'),
|
||||
'timestamp': last_run,
|
||||
'status': state.get('lastStatus', 'ok')
|
||||
})
|
||||
except:
|
||||
pass
|
||||
|
||||
# 2. Git commits (last 24h)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['git', 'log', '--oneline', '--since=24 hours ago', '--format=%H|%s|%at'],
|
||||
cwd=workspace, capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if '|' in line:
|
||||
parts = line.split('|')
|
||||
if len(parts) >= 3:
|
||||
commit_hash, message, timestamp = parts[0], parts[1], int(parts[2])
|
||||
commit_time = datetime.fromtimestamp(timestamp, tz=dt_timezone.utc)
|
||||
local_time = commit_time.astimezone(bucharest)
|
||||
activities.append({
|
||||
'type': 'git',
|
||||
'icon': 'git-commit',
|
||||
'text': message[:60] + ('...' if len(message) > 60 else ''),
|
||||
'agent': 'git',
|
||||
'time': local_time.strftime('%H:%M'),
|
||||
'timestamp': timestamp * 1000
|
||||
})
|
||||
except:
|
||||
pass
|
||||
|
||||
# 3. Recent files in kb/ (last 24h)
|
||||
try:
|
||||
kb_dir = workspace / 'kb'
|
||||
cutoff = datetime.now().timestamp() - (24 * 3600)
|
||||
for md_file in kb_dir.rglob('*.md'):
|
||||
stat = md_file.stat()
|
||||
if stat.st_mtime > cutoff:
|
||||
file_time = datetime.fromtimestamp(stat.st_mtime, tz=dt_timezone.utc)
|
||||
local_time = file_time.astimezone(bucharest)
|
||||
rel_path = md_file.relative_to(workspace)
|
||||
activities.append({
|
||||
'type': 'file',
|
||||
'icon': 'file-text',
|
||||
'text': f"Fișier: {md_file.name}",
|
||||
'agent': str(rel_path.parent),
|
||||
'time': local_time.strftime('%H:%M'),
|
||||
'timestamp': int(stat.st_mtime * 1000),
|
||||
'path': str(rel_path)
|
||||
})
|
||||
except:
|
||||
pass
|
||||
|
||||
# 4. Tasks from tasks.json
|
||||
try:
|
||||
tasks_file = workspace / 'dashboard' / 'tasks.json'
|
||||
if tasks_file.exists():
|
||||
tasks_data = json.loads(tasks_file.read_text())
|
||||
for col in tasks_data.get('columns', []):
|
||||
for task in col.get('tasks', []):
|
||||
ts_str = task.get('completed') or task.get('created', '')
|
||||
if ts_str:
|
||||
try:
|
||||
ts = datetime.fromisoformat(ts_str.replace('Z', '+00:00'))
|
||||
if ts.timestamp() > (datetime.now().timestamp() - 7 * 24 * 3600):
|
||||
local_time = ts.astimezone(bucharest)
|
||||
activities.append({
|
||||
'type': 'task',
|
||||
'icon': 'check-circle' if task.get('completed') else 'circle',
|
||||
'text': task.get('title', ''),
|
||||
'agent': task.get('agent', 'Echo'),
|
||||
'time': local_time.strftime('%d %b %H:%M'),
|
||||
'timestamp': int(ts.timestamp() * 1000),
|
||||
'status': 'done' if task.get('completed') else col['id']
|
||||
})
|
||||
except:
|
||||
pass
|
||||
except:
|
||||
pass
|
||||
|
||||
# Sort by timestamp descending
|
||||
activities.sort(key=lambda x: x.get('timestamp', 0), reverse=True)
|
||||
|
||||
# Limit to 30 items
|
||||
activities = activities[:30]
|
||||
|
||||
self.send_json({
|
||||
'activities': activities,
|
||||
'total': len(activities)
|
||||
})
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
|
||||
def handle_files_get(self):
|
||||
"""List files or get file content."""
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
@@ -259,12 +472,12 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
|
||||
items = []
|
||||
try:
|
||||
for item in sorted(target.iterdir()):
|
||||
if item.name.startswith('.'):
|
||||
continue
|
||||
stat = item.stat()
|
||||
items.append({
|
||||
'name': item.name,
|
||||
'type': 'dir' if item.is_dir() else 'file',
|
||||
'size': item.stat().st_size if item.is_file() else None,
|
||||
'size': stat.st_size if item.is_file() else None,
|
||||
'mtime': stat.st_mtime,
|
||||
'path': str(item.relative_to(workspace))
|
||||
})
|
||||
self.send_json({
|
||||
@@ -314,6 +527,9 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
|
||||
self.send_response(code)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')
|
||||
self.send_header('Pragma', 'no-cache')
|
||||
self.send_header('Expires', '0')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(data).encode())
|
||||
|
||||
|
||||
@@ -102,6 +102,149 @@
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.sort-select {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sort-select option {
|
||||
background: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ========== LIST VIEW - Windows Explorer style ========== */
|
||||
.file-grid.view-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.file-grid.view-list .file-item {
|
||||
flex-direction: row;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
gap: var(--space-2);
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.file-grid.view-list .file-item:hover {
|
||||
background: var(--bg-surface-hover);
|
||||
}
|
||||
|
||||
.file-grid.view-list .file-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-grid.view-list .file-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.file-grid.view-list .file-name {
|
||||
font-size: var(--text-sm);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.file-grid.view-list .file-meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ========== DETAILS VIEW - Windows Explorer style with columns ========== */
|
||||
.file-grid.view-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.file-grid.view-details .file-header {
|
||||
display: grid;
|
||||
grid-template-columns: 24px 1fr 100px 80px 120px;
|
||||
align-items: center;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
gap: var(--space-3);
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 2px solid var(--border);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.file-grid.view-details .file-header span {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-grid.view-details .file-header span:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.file-grid.view-details .file-item {
|
||||
display: grid;
|
||||
grid-template-columns: 24px 1fr 100px 80px 120px;
|
||||
align-items: center;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
gap: var(--space-3);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.file-grid.view-details .file-item:hover {
|
||||
background: var(--bg-surface-hover);
|
||||
}
|
||||
|
||||
.file-grid.view-details .file-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.file-grid.view-details .file-icon svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.file-grid.view-details .file-name {
|
||||
font-size: var(--text-sm);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.file-grid.view-details .file-meta {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.file-grid.view-details .file-type,
|
||||
.file-grid.view-details .file-size,
|
||||
.file-grid.view-details .file-date {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-grid.view-details .file-type {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ========== TILES VIEW - Original grid style ========== */
|
||||
.file-grid.view-tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
/* Content area */
|
||||
.content-area {
|
||||
flex: 1;
|
||||
@@ -269,6 +412,8 @@
|
||||
#markdownPreview code { background: var(--bg-surface); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); }
|
||||
#markdownPreview pre { background: var(--bg-surface); padding: 1em; border-radius: 8px; overflow-x: auto; }
|
||||
#markdownPreview blockquote { border-left: 3px solid var(--accent); padding-left: 1em; margin-left: 0; color: var(--text-muted); }
|
||||
#markdownPreview a, #markdownPreview .file-link { color: var(--accent); text-decoration: none; }
|
||||
#markdownPreview a:hover, #markdownPreview .file-link:hover { text-decoration: underline; }
|
||||
|
||||
.preview-active #codeEditor { display: none; }
|
||||
.preview-active #markdownPreview { display: block; }
|
||||
@@ -348,9 +493,36 @@
|
||||
<span class="breadcrumb-item current" onclick="loadPath('')">~/clawd</span>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<!-- View Mode Toggle -->
|
||||
<div class="view-toggle" id="viewModeToggle">
|
||||
<button class="view-btn" data-view="list" onclick="setViewMode('list')" title="Listă">
|
||||
<i data-lucide="list"></i>
|
||||
</button>
|
||||
<button class="view-btn" data-view="details" onclick="setViewMode('details')" title="Detalii">
|
||||
<i data-lucide="layout-list"></i>
|
||||
</button>
|
||||
<button class="view-btn active" data-view="tiles" onclick="setViewMode('tiles')" title="Tiles">
|
||||
<i data-lucide="layout-grid"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Sort Toggle -->
|
||||
<div class="view-toggle">
|
||||
<select class="sort-select" id="sortBy" onchange="sortFiles()">
|
||||
<option value="name">Nume</option>
|
||||
<option value="type">Tip</option>
|
||||
<option value="size">Mărime</option>
|
||||
<option value="date">Dată</option>
|
||||
</select>
|
||||
<button class="view-btn" id="sortDirBtn" onclick="toggleSortDir()" title="Ordine">
|
||||
<i data-lucide="arrow-down-a-z" id="sortDirIcon"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Browse/Editor Toggle -->
|
||||
<div class="view-toggle">
|
||||
<button class="view-btn active" id="browseBtn" onclick="showBrowse()" title="Browse">
|
||||
<i data-lucide="layout-grid"></i>
|
||||
<i data-lucide="folder"></i>
|
||||
</button>
|
||||
<button class="view-btn" id="editorBtn" onclick="showEditor()" title="Editor">
|
||||
<i data-lucide="code"></i>
|
||||
@@ -435,6 +607,73 @@
|
||||
let currentFile = null;
|
||||
let originalContent = '';
|
||||
let isModified = false;
|
||||
let currentViewMode = localStorage.getItem('filesViewMode') || 'tiles';
|
||||
let currentSortBy = localStorage.getItem('filesSortBy') || 'name';
|
||||
let currentSortDir = localStorage.getItem('filesSortDir') || 'asc';
|
||||
let currentItems = [];
|
||||
|
||||
// Initialize view mode
|
||||
function initViewMode() {
|
||||
setViewMode(currentViewMode, false);
|
||||
document.getElementById('sortBy').value = currentSortBy;
|
||||
updateSortIcon();
|
||||
}
|
||||
|
||||
function setViewMode(mode, reload = true) {
|
||||
currentViewMode = mode;
|
||||
localStorage.setItem('filesViewMode', mode);
|
||||
|
||||
// Update buttons
|
||||
document.querySelectorAll('#viewModeToggle .view-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.view === mode);
|
||||
});
|
||||
|
||||
// Update grid class
|
||||
const grid = document.getElementById('fileGrid');
|
||||
grid.classList.remove('view-list', 'view-details', 'view-tiles');
|
||||
grid.classList.add('view-' + mode);
|
||||
|
||||
if (reload && currentItems.length > 0) {
|
||||
renderFileGrid(currentItems);
|
||||
}
|
||||
}
|
||||
|
||||
function sortFiles() {
|
||||
currentSortBy = document.getElementById('sortBy').value;
|
||||
localStorage.setItem('filesSortBy', currentSortBy);
|
||||
if (currentItems.length > 0) {
|
||||
renderFileGrid(currentItems);
|
||||
}
|
||||
}
|
||||
|
||||
function setSortBy(field) {
|
||||
if (currentSortBy === field) {
|
||||
toggleSortDir();
|
||||
} else {
|
||||
currentSortBy = field;
|
||||
document.getElementById('sortBy').value = field;
|
||||
localStorage.setItem('filesSortBy', currentSortBy);
|
||||
if (currentItems.length > 0) {
|
||||
renderFileGrid(currentItems);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSortDir() {
|
||||
currentSortDir = currentSortDir === 'asc' ? 'desc' : 'asc';
|
||||
localStorage.setItem('filesSortDir', currentSortDir);
|
||||
updateSortIcon();
|
||||
if (currentItems.length > 0) {
|
||||
renderFileGrid(currentItems);
|
||||
}
|
||||
}
|
||||
|
||||
function updateSortIcon() {
|
||||
const icon = document.getElementById('sortDirIcon');
|
||||
const iconName = currentSortDir === 'asc' ? 'arrow-down-a-z' : 'arrow-up-z-a';
|
||||
icon.setAttribute('data-lucide', iconName);
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function showBrowse() {
|
||||
if (isModified && !confirm('Ai modificări nesalvate. Continui?')) return;
|
||||
@@ -498,7 +737,12 @@
|
||||
}
|
||||
|
||||
function renderFileGrid(items) {
|
||||
// Store items for re-rendering on view/sort change
|
||||
currentItems = items;
|
||||
|
||||
const grid = document.getElementById('fileGrid');
|
||||
grid.classList.remove('view-list', 'view-details', 'view-tiles');
|
||||
grid.classList.add('view-' + currentViewMode);
|
||||
|
||||
if (items.length === 0) {
|
||||
grid.innerHTML = `
|
||||
@@ -511,20 +755,82 @@
|
||||
return;
|
||||
}
|
||||
|
||||
items.sort((a, b) => {
|
||||
// Sort items
|
||||
const getExt = (name) => name.includes('.') ? name.split('.').pop().toLowerCase() : '';
|
||||
const sorted = [...items].sort((a, b) => {
|
||||
// Directories always first
|
||||
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
|
||||
let cmp = 0;
|
||||
if (currentSortBy === 'name') {
|
||||
cmp = a.name.localeCompare(b.name);
|
||||
} else if (currentSortBy === 'date') {
|
||||
cmp = (a.mtime || 0) - (b.mtime || 0);
|
||||
} else if (currentSortBy === 'size') {
|
||||
cmp = (a.size || 0) - (b.size || 0);
|
||||
} else if (currentSortBy === 'type') {
|
||||
cmp = getExt(a.name).localeCompare(getExt(b.name));
|
||||
}
|
||||
return currentSortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
|
||||
grid.innerHTML = items.map(item => `
|
||||
<div class="file-item ${currentFile === item.path ? 'active' : ''}" onclick="handleClick('${item.path}', '${item.type}')">
|
||||
<div class="file-icon ${item.type === 'dir' ? 'folder' : ''}">
|
||||
<i data-lucide="${item.type === 'dir' ? 'folder' : getFileIcon(item.name)}"></i>
|
||||
// Add header for details view
|
||||
let headerHtml = '';
|
||||
if (currentViewMode === 'details') {
|
||||
const arrow = (field) => currentSortBy === field ? (currentSortDir === 'asc' ? ' ▲' : ' ▼') : '';
|
||||
headerHtml = `
|
||||
<div class="file-header">
|
||||
<span></span>
|
||||
<span onclick="setSortBy('name')" style="cursor:pointer">Nume${arrow('name')}</span>
|
||||
<span onclick="setSortBy('type')" style="cursor:pointer">Tip${arrow('type')}</span>
|
||||
<span onclick="setSortBy('size')" style="cursor:pointer">Mărime${arrow('size')}</span>
|
||||
<span onclick="setSortBy('date')" style="cursor:pointer">Dată${arrow('date')}</span>
|
||||
</div>
|
||||
<div class="file-name">${item.name}</div>
|
||||
${item.size ? `<div class="file-size">${formatSize(item.size)}</div>` : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
`;
|
||||
}
|
||||
|
||||
grid.innerHTML = headerHtml + sorted.map(item => {
|
||||
const dateStr = item.mtime ? new Date(item.mtime * 1000).toLocaleString('ro-RO', {
|
||||
day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit'
|
||||
}) : '';
|
||||
const fileType = item.type === 'dir' ? 'Folder' : getFileType(item.name);
|
||||
const sizeStr = item.size !== undefined ? formatSize(item.size) : '-';
|
||||
|
||||
if (currentViewMode === 'details') {
|
||||
return `
|
||||
<div class="file-item ${currentFile === item.path ? 'active' : ''}" onclick="handleClick('${item.path}', '${item.type}')">
|
||||
<div class="file-icon ${item.type === 'dir' ? 'folder' : ''}">
|
||||
<i data-lucide="${item.type === 'dir' ? 'folder' : getFileIcon(item.name)}"></i>
|
||||
</div>
|
||||
<div class="file-name">${item.name}</div>
|
||||
<div class="file-meta">
|
||||
<span class="file-type">${fileType}</span>
|
||||
<span class="file-size">${sizeStr}</span>
|
||||
<span class="file-date">${dateStr || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (currentViewMode === 'list') {
|
||||
return `
|
||||
<div class="file-item ${currentFile === item.path ? 'active' : ''}" onclick="handleClick('${item.path}', '${item.type}')">
|
||||
<div class="file-icon ${item.type === 'dir' ? 'folder' : ''}">
|
||||
<i data-lucide="${item.type === 'dir' ? 'folder' : getFileIcon(item.name)}"></i>
|
||||
</div>
|
||||
<div class="file-name">${item.name}</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
// Tiles view - original style
|
||||
return `
|
||||
<div class="file-item ${currentFile === item.path ? 'active' : ''}" onclick="handleClick('${item.path}', '${item.type}')">
|
||||
<div class="file-icon ${item.type === 'dir' ? 'folder' : ''}">
|
||||
<i data-lucide="${item.type === 'dir' ? 'folder' : getFileIcon(item.name)}"></i>
|
||||
</div>
|
||||
<div class="file-name">${item.name}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}).join('');
|
||||
|
||||
lucide.createIcons();
|
||||
}
|
||||
@@ -549,6 +855,26 @@
|
||||
return icons[ext] || 'file';
|
||||
}
|
||||
|
||||
function getFileType(name) {
|
||||
const ext = name.split('.').pop().toLowerCase();
|
||||
const types = {
|
||||
'md': 'Markdown',
|
||||
'txt': 'Text',
|
||||
'json': 'JSON',
|
||||
'js': 'JavaScript',
|
||||
'py': 'Python',
|
||||
'html': 'HTML',
|
||||
'css': 'CSS',
|
||||
'sh': 'Shell',
|
||||
'yml': 'YAML',
|
||||
'yaml': 'YAML',
|
||||
'log': 'Log',
|
||||
'xsd': 'XML Schema',
|
||||
'pdf': 'PDF'
|
||||
};
|
||||
return types[ext] || ext.toUpperCase();
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
@@ -701,6 +1027,7 @@
|
||||
});
|
||||
|
||||
// Init
|
||||
initViewMode();
|
||||
loadPath(getPathFromURL());
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -498,7 +498,7 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.activity-icon.done {
|
||||
.activity-icon.done, .activity-icon.task {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #22c55e;
|
||||
}
|
||||
@@ -508,11 +508,33 @@
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.activity-icon.cron {
|
||||
background: rgba(168, 85, 247, 0.2);
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
.activity-icon.git {
|
||||
background: rgba(249, 115, 22, 0.2);
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.activity-icon.file {
|
||||
background: rgba(20, 184, 166, 0.2);
|
||||
color: #14b8a6;
|
||||
}
|
||||
|
||||
.activity-icon svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.activity-type {
|
||||
font-size: var(--text-xs);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.activity-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -732,6 +754,7 @@
|
||||
}
|
||||
|
||||
.issue-owner.marius { color: #22c55e; }
|
||||
.issue-owner.robert { color: #f59e0b; }
|
||||
.issue-owner.clawdbot { color: #8b5cf6; }
|
||||
|
||||
.issue-date {
|
||||
@@ -786,7 +809,7 @@
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-surface);
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
@@ -794,6 +817,7 @@
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
@@ -978,28 +1002,28 @@
|
||||
|
||||
<!-- Stats Summary -->
|
||||
<div class="stats-summary" id="statsSummary">
|
||||
<div class="stat-card">
|
||||
<div class="stat-card" title="Task-uri completate azi">
|
||||
<div class="stat-icon"><i data-lucide="check-circle"></i></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value" id="statToday">0</div>
|
||||
<div class="stat-label">Azi</div>
|
||||
<div class="stat-label">Tasks azi</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card" title="Task-uri completate săptămâna aceasta">
|
||||
<div class="stat-icon"><i data-lucide="calendar"></i></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value" id="statWeek">0</div>
|
||||
<div class="stat-label">Săptămâna</div>
|
||||
<div class="stat-label">Tasks săpt.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card" title="Task-uri completate luna aceasta">
|
||||
<div class="stat-icon"><i data-lucide="trending-up"></i></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value" id="statMonth">0</div>
|
||||
<div class="stat-label">Luna</div>
|
||||
<div class="stat-label">Tasks lună</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card" title="Număr de fișiere insights">
|
||||
<div class="stat-icon"><i data-lucide="lightbulb"></i></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value" id="statInsights">0</div>
|
||||
@@ -1055,6 +1079,7 @@
|
||||
<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">
|
||||
@@ -1090,6 +1115,7 @@
|
||||
<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>
|
||||
@@ -1343,42 +1369,53 @@
|
||||
}
|
||||
|
||||
async function loadCronStatus() {
|
||||
const now = new Date();
|
||||
const hour = now.getHours();
|
||||
|
||||
// TODO: În viitor, fetch din /api/cron
|
||||
const jobs = [
|
||||
{ time: '06:30', name: 'morning-report', done: hour >= 7 },
|
||||
{ time: '07:00', name: 'morning-coaching', done: hour >= 7 },
|
||||
{ time: '18:00', name: 'evening-report', done: hour >= 18 },
|
||||
{ time: '19:00', name: 'evening-coaching', done: hour >= 19 }
|
||||
];
|
||||
|
||||
const doneCount = jobs.filter(j => j.done).length;
|
||||
|
||||
// Update badge
|
||||
const badge = document.getElementById('cronBadge');
|
||||
badge.textContent = `${doneCount}/${jobs.length}`;
|
||||
badge.className = 'status-badge ok';
|
||||
|
||||
// Update subtitle
|
||||
const subtitle = document.getElementById('cronSubtitle');
|
||||
const nextJob = jobs.find(j => !j.done);
|
||||
subtitle.textContent = nextJob
|
||||
? `Următorul: ${nextJob.time} ${nextJob.name}`
|
||||
: 'Toate job-urile au rulat azi';
|
||||
|
||||
// Update details
|
||||
const details = document.getElementById('cronDetails');
|
||||
details.innerHTML = jobs.map(job => `
|
||||
<div class="cron-item ${job.done ? 'done' : 'pending'}">
|
||||
<i data-lucide="${job.done ? 'check-circle' : 'clock'}" class="cron-icon ${job.done ? 'done' : 'pending'}"></i>
|
||||
<span class="cron-time">${job.time}</span>
|
||||
<span class="cron-name">${job.name}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
lucide.createIcons();
|
||||
try {
|
||||
const response = await fetch('./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';
|
||||
|
||||
// Update details
|
||||
const details = document.getElementById('cronDetails');
|
||||
details.innerHTML = 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');
|
||||
|
||||
return `
|
||||
<div class="cron-item ${statusClass}">
|
||||
<i data-lucide="${icon}" class="cron-icon ${statusClass}"></i>
|
||||
<span class="cron-time">${job.time}</span>
|
||||
<span class="cron-name">${job.name}</span>
|
||||
<span class="cron-agent" style="color: var(--text-muted); font-size: 0.75rem; margin-left: auto;">${job.agentId || ''}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
lucide.createIcons();
|
||||
} catch (e) {
|
||||
console.error('Error loading cron status:', e);
|
||||
const badge = document.getElementById('cronBadge');
|
||||
badge.textContent = 'eroare';
|
||||
badge.className = 'status-badge error';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAgentsStatus() {
|
||||
@@ -1461,30 +1498,22 @@
|
||||
|
||||
async function loadActivity() {
|
||||
try {
|
||||
// Fetch from tasks.json
|
||||
const response = await fetch('tasks.json?t=' + Date.now());
|
||||
// Fetch from unified activity API
|
||||
const response = await fetch('./api/activity?t=' + Date.now());
|
||||
const data = await response.json();
|
||||
|
||||
// Collect all tasks from all columns with their status
|
||||
let allTasks = [];
|
||||
data.columns.forEach(col => {
|
||||
col.tasks.forEach(task => {
|
||||
const timestamp = task.completed || task.created || '';
|
||||
allTasks.push({
|
||||
type: col.id === 'done' ? 'done' : (col.id === 'in-progress' ? 'running' : 'pending'),
|
||||
text: task.title,
|
||||
agent: task.agent || 'Echo',
|
||||
time: formatActivityTime(timestamp),
|
||||
timestamp: new Date(timestamp).getTime() || 0
|
||||
});
|
||||
});
|
||||
});
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
||||
// Sort descending by timestamp (newest first)
|
||||
allTasks.sort((a, b) => b.timestamp - a.timestamp);
|
||||
|
||||
// Take only recent items (last 20)
|
||||
activityData = allTasks.slice(0, 20);
|
||||
activityData = (data.activities || []).map(a => ({
|
||||
type: a.type,
|
||||
icon: a.icon || 'activity',
|
||||
text: a.text,
|
||||
agent: a.agent || 'Echo',
|
||||
time: a.time,
|
||||
timestamp: a.timestamp,
|
||||
status: a.status,
|
||||
path: a.path
|
||||
}));
|
||||
|
||||
} catch (e) {
|
||||
console.error('Failed to load activity:', e);
|
||||
@@ -1574,28 +1603,32 @@
|
||||
|
||||
const today = new Date().toLocaleDateString('ro-RO', { day: 'numeric', month: 'short' });
|
||||
|
||||
// Group by type for better display
|
||||
const typeLabels = {
|
||||
'cron': '⏰ Cron Jobs',
|
||||
'git': '📦 Git Commits',
|
||||
'file': '📄 Fișiere',
|
||||
'task': '✅ Task-uri'
|
||||
};
|
||||
|
||||
body.innerHTML = `
|
||||
<div class="activity-section">
|
||||
<div class="activity-section-title">
|
||||
<i data-lucide="calendar"></i>
|
||||
Azi (${today})
|
||||
<i data-lucide="activity"></i>
|
||||
Ultimele 24h
|
||||
</div>
|
||||
${activityData.map(item => `
|
||||
<div class="activity-item">
|
||||
<div class="activity-item" ${item.path ? `onclick="window.open('files.html#${item.path}', '_blank')" style="cursor:pointer"` : ''}>
|
||||
<div class="activity-icon ${item.type}">
|
||||
<i data-lucide="${item.type === 'running' ? 'loader' : 'check'}"></i>
|
||||
<i data-lucide="${item.icon || 'activity'}"></i>
|
||||
</div>
|
||||
<div class="activity-content">
|
||||
<div class="activity-text">${item.text}</div>
|
||||
<div class="activity-meta">
|
||||
<span class="activity-type">${typeLabels[item.type] || item.type}</span>
|
||||
<span class="activity-agent">${item.agent}</span>
|
||||
<span>${item.time}</span>
|
||||
</div>
|
||||
${item.progress ? `
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: ${item.progress}%"></div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
@@ -1683,7 +1716,8 @@
|
||||
|
||||
function renderIssueItem(issue) {
|
||||
const isDone = issue.status === 'done';
|
||||
const ownerIcon = issue.owner === 'clawdbot' ? '🤖' : '👤';
|
||||
const ownerIcons = { 'clawdbot': '🤖', 'robert': '👷', 'marius': '👤' };
|
||||
const ownerIcon = ownerIcons[issue.owner] || '👤';
|
||||
const dateStr = new Date(issue.created).toLocaleDateString('ro-RO', { day: 'numeric', month: 'short' });
|
||||
|
||||
return `
|
||||
@@ -1695,7 +1729,7 @@
|
||||
<div class="issue-title">${issue.title}</div>
|
||||
<div class="issue-meta">
|
||||
${issue.program ? `<span class="issue-tag program">${issue.program}</span>` : ''}
|
||||
<span class="issue-owner ${issue.owner}">${ownerIcon} ${issue.owner === 'clawdbot' ? 'Clawdbot' : 'Marius'}</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>
|
||||
|
||||
@@ -152,6 +152,25 @@
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.note-file-link {
|
||||
opacity: 0.4;
|
||||
transition: opacity var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.note-file-link:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.note-file-link svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.note-tags {
|
||||
@@ -226,17 +245,37 @@
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-base);
|
||||
z-index: 10;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.note-viewer-header h2 {
|
||||
font-size: var(--text-lg);
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.viewer-path {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
text-decoration: none;
|
||||
opacity: 0.7;
|
||||
transition: opacity var(--transition-fast);
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.viewer-path:hover {
|
||||
opacity: 1;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.note-viewer-body {
|
||||
@@ -702,6 +741,7 @@
|
||||
<div class="note-viewer-content">
|
||||
<div class="note-viewer-header">
|
||||
<h2 id="viewerTitle">Titlu</h2>
|
||||
<a id="viewerPath" href="#" class="viewer-path" target="_blank"></a>
|
||||
<button class="btn btn-ghost" onclick="closeNote()">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
@@ -1122,9 +1162,16 @@
|
||||
tagsHtml
|
||||
].filter(Boolean).join('');
|
||||
|
||||
// Convert notes-data/ to kb/ for files.html links
|
||||
const filesPath = note.file.replace(/^notes-data\//, 'kb/');
|
||||
return `
|
||||
<div class="note-card" onclick="openNote('${note.file}')">
|
||||
<div class="note-title">${note.title}</div>
|
||||
<div class="note-title">
|
||||
${note.title}
|
||||
<a href="files.html#${filesPath}" class="note-file-link" onclick="event.stopPropagation()" title="${filesPath}">
|
||||
<i data-lucide="external-link"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="note-tags">${allTags}</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1140,6 +1187,11 @@
|
||||
if (!note) return;
|
||||
|
||||
document.getElementById('viewerTitle').textContent = note.title;
|
||||
const pathEl = document.getElementById('viewerPath');
|
||||
// Convert notes-data/ to kb/ for display and links
|
||||
const filesPath = note.file.replace(/^notes-data\//, 'kb/');
|
||||
pathEl.textContent = filesPath;
|
||||
pathEl.href = 'files.html#' + filesPath;
|
||||
document.getElementById('viewerContent').innerHTML = '<p style="color: var(--text-muted)">Se încarcă...</p>';
|
||||
document.getElementById('noteViewer').classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
Reference in New Issue
Block a user