diff --git a/gitea-append.js b/gitea-append.js
new file mode 100644
index 0000000..7de304b
--- /dev/null
+++ b/gitea-append.js
@@ -0,0 +1,110 @@
+
+// ============ GITEA INTEGRATION ============
+const GiteaIntegration = require('./gitea-integration.js');
+
+const giteaConfig = {
+ baseUrl: process.env.GITEA_URL || 'https://gitea.tophermayor.com',
+ token: process.env.GITEA_TOKEN,
+ owner: 'TopherMayor',
+ cacheTimeout: 30000
+};
+
+const gitea = new GiteaIntegration(giteaConfig);
+
+// Gitea page route
+app.get('/gitea', (req, res) => {
+ res.send(renderPage('gitea', 'gitea', 'OpenClaw Agent Fleet Dashboard - Gitea'));
+});
+
+// Gitea API routes
+app.get('/api/gitea/swarm', async (req, res) => {
+ try {
+ const summary = await gitea.getSwarmSummary();
+ res.json(summary);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+app.get('/api/gitea/reviews', async (req, res) => {
+ try {
+ const reviews = await gitea.getPendingReviews();
+ res.json(reviews);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+app.get('/api/gitea/activity', async (req, res) => {
+ try {
+ const activity = await gitea.getRecentActivity();
+ res.json(activity);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+app.get('/api/gitea/user', async (req, res) => {
+ try {
+ const user = await gitea.getUser();
+ res.json(user);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+app.get('/api/gitea/repos/:repo', async (req, res) => {
+ try {
+ const repo = await gitea.getRepo(req.params.repo);
+ res.json(repo);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+app.get('/api/gitea/repos/:repo/pulls', async (req, res) => {
+ try {
+ const state = req.query.state || 'open';
+ const prs = await gitea.getPullRequests(req.params.repo, state);
+ res.json(prs);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+app.get('/api/gitea/repos/:repo/issues', async (req, res) => {
+ try {
+ const state = req.query.state || 'open';
+ const issues = await gitea.getIssues(req.params.repo, state);
+ res.json(issues);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+app.get('/api/gitea/repos/:repo/commits', async (req, res) => {
+ try {
+ const branch = req.query.branch || 'main';
+ const limit = parseInt(req.query.limit) || 10;
+ const commits = await gitea.getCommits(req.params.repo, branch, limit);
+ res.json(commits);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+app.get('/api/gitea/repos/:repo/branches', async (req, res) => {
+ try {
+ const branches = await gitea.getBranches(req.params.repo);
+ res.json(branches);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+app.post('/api/gitea/cache/clear', (req, res) => {
+ gitea.clearCache();
+ res.json({ success: true, message: 'Cache cleared' });
+});
+
+console.log('✅ Gitea integration loaded');
diff --git a/gitea-integration.js b/gitea-integration.js
new file mode 100644
index 0000000..19c2740
--- /dev/null
+++ b/gitea-integration.js
@@ -0,0 +1,254 @@
+/**
+ * Gitea Integration for AgentDash
+ * Provides real-time data from Gitea API
+ */
+
+const https = require('https');
+const http = require('http');
+
+class GiteaIntegration {
+ constructor(config = {}) {
+ this.baseUrl = config.baseUrl || 'https://gitea.tophermayor.com';
+ this.token = config.token || process.env.GITEA_TOKEN;
+ this.owner = config.owner || 'TopherMayor';
+ this.cache = new Map();
+ this.cacheTimeout = config.cacheTimeout || 30000; // 30 seconds
+ }
+
+ /**
+ * Make authenticated request to Gitea API
+ */
+ async request(endpoint) {
+ return new Promise((resolve, reject) => {
+ const url = new URL(endpoint, this.baseUrl);
+ const client = url.protocol === 'https:' ? https : http;
+
+ const options = {
+ hostname: url.hostname,
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
+ path: url.pathname + url.search,
+ method: 'GET',
+ headers: {
+ 'Authorization': `token ${this.token}`,
+ 'Accept': 'application/json'
+ }
+ };
+
+ const req = client.request(options, (res) => {
+ let data = '';
+ res.on('data', chunk => data += chunk);
+ res.on('end', () => {
+ try {
+ resolve(JSON.parse(data));
+ } catch (e) {
+ reject(new Error(`Failed to parse response: ${e.message}`));
+ }
+ });
+ });
+
+ req.on('error', reject);
+ req.setTimeout(10000, () => {
+ req.destroy();
+ reject(new Error('Request timeout'));
+ });
+ req.end();
+ });
+ }
+
+ /**
+ * Get cached data or fetch fresh
+ */
+ async getCached(key, fetcher) {
+ const cached = this.cache.get(key);
+ if (cached && Date.now() - cached.timestamp < this.cacheTimeout) {
+ return cached.data;
+ }
+
+ const data = await fetcher();
+ this.cache.set(key, { data, timestamp: Date.now() });
+ return data;
+ }
+
+ /**
+ * Get all repositories for the owner
+ */
+ async getRepos() {
+ return this.getCached('repos', () =>
+ this.request(`/api/v1/user/repos?limit=50`)
+ );
+ }
+
+ /**
+ * Get repository details
+ */
+ async getRepo(repoName) {
+ return this.getCached(`repo:${repoName}`, () =>
+ this.request(`/api/v1/repos/${this.owner}/${repoName}`)
+ );
+ }
+
+ /**
+ * Get open pull requests for a repository
+ */
+ async getPullRequests(repoName, state = 'open') {
+ return this.getCached(`prs:${repoName}:${state}`, () =>
+ this.request(`/api/v1/repos/${this.owner}/${repoName}/pulls?state=${state}&limit=20`)
+ );
+ }
+
+ /**
+ * Get issues for a repository
+ */
+ async getIssues(repoName, state = 'open') {
+ return this.getCached(`issues:${repoName}:${state}`, () =>
+ this.request(`/api/v1/repos/${this.owner}/${repoName}/issues?state=${state}&limit=20`)
+ );
+ }
+
+ /**
+ * Get recent commits for a repository
+ */
+ async getCommits(repoName, branch = 'main', limit = 10) {
+ return this.getCached(`commits:${repoName}:${branch}`, () =>
+ this.request(`/api/v1/repos/${this.owner}/${repoName}/commits?sha=${branch}&limit=${limit}`)
+ );
+ }
+
+ /**
+ * Get branches for a repository
+ */
+ async getBranches(repoName) {
+ return this.getCached(`branches:${repoName}`, () =>
+ this.request(`/api/v1/repos/${this.owner}/${repoName}/branches`)
+ );
+ }
+
+ /**
+ * Get repository activity feed
+ */
+ async getActivity(repoName) {
+ return this.getCached(`activity:${repoName}`, () =>
+ this.request(`/api/v1/repos/${this.owner}/${repoName}/activities/feeds?limit=20`)
+ );
+ }
+
+ /**
+ * Get user info
+ */
+ async getUser() {
+ return this.getCached('user', () =>
+ this.request('/api/v1/user')
+ );
+ }
+
+ /**
+ * Get organization info (if applicable)
+ */
+ async getOrgs() {
+ return this.getCached('orgs', () =>
+ this.request('/api/v1/user/orgs')
+ );
+ }
+
+ /**
+ * Clear cache
+ */
+ clearCache() {
+ this.cache.clear();
+ }
+
+ /**
+ * Get swarm summary - all repos with PRs and issues
+ */
+ async getSwarmSummary() {
+ const repos = await this.getRepos();
+ const summary = [];
+
+ for (const repo of repos.slice(0, 10)) { // Limit to 10 repos
+ try {
+ const [prs, issues, branches] = await Promise.all([
+ this.getPullRequests(repo.name, 'open').catch(() => []),
+ this.getIssues(repo.name, 'open').catch(() => []),
+ this.getBranches(repo.name).catch(() => [])
+ ]);
+
+ summary.push({
+ name: repo.name,
+ full_name: repo.full_name,
+ stars: repo.stars_count || 0,
+ forks: repo.forks_count || 0,
+ open_prs: prs.length,
+ open_issues: issues.length,
+ branches: branches.length,
+ updated_at: repo.updated_at,
+ html_url: repo.html_url
+ });
+ } catch (e) {
+ console.error(`Error fetching data for ${repo.name}:`, e.message);
+ }
+ }
+
+ return summary;
+ }
+
+ /**
+ * Get pending reviews (PRs needing attention)
+ */
+ async getPendingReviews() {
+ const repos = await this.getRepos();
+ const pending = [];
+
+ for (const repo of repos.slice(0, 10)) {
+ try {
+ const prs = await this.getPullRequests(repo.name, 'open');
+ for (const pr of prs) {
+ pending.push({
+ repo: repo.name,
+ repo_url: repo.html_url,
+ pr_number: pr.number,
+ pr_title: pr.title,
+ pr_url: pr.html_url,
+ author: pr.user?.login,
+ created_at: pr.created_at,
+ mergeable: pr.mergeable,
+ draft: pr.draft,
+ labels: pr.labels || []
+ });
+ }
+ } catch (e) {
+ console.error(`Error fetching PRs for ${repo.name}:`, e.message);
+ }
+ }
+
+ return pending;
+ }
+
+ /**
+ * Get recent activity across all repos
+ */
+ async getRecentActivity() {
+ const repos = await this.getRepos();
+ const activities = [];
+
+ for (const repo of repos.slice(0, 5)) {
+ try {
+ const activity = await this.getActivity(repo.name);
+ for (const act of (activity || []).slice(0, 5)) {
+ activities.push({
+ repo: repo.name,
+ repo_url: repo.html_url,
+ ...act
+ });
+ }
+ } catch (e) {
+ // Skip repos without activity access
+ }
+ }
+
+ return activities
+ .sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
+ .slice(0, 20);
+ }
+}
+
+module.exports = GiteaIntegration;
diff --git a/gitea-routes.js b/gitea-routes.js
new file mode 100644
index 0000000..f29ad00
--- /dev/null
+++ b/gitea-routes.js
@@ -0,0 +1,118 @@
+/**
+ * Gitea Routes Module
+ * Adds Gitea integration routes to Express app
+ */
+
+const GiteaIntegration = require('./gitea-integration.js');
+
+function setupGiteaRoutes(app) {
+ // Initialize Gitea client
+ const giteaConfig = {
+ baseUrl: process.env.GITEA_URL || 'https://gitea.tophermayor.com',
+ token: process.env.GITEA_TOKEN,
+ owner: 'TopherMayor',
+ cacheTimeout: 30000
+ };
+
+ const gitea = new GiteaIntegration(giteaConfig);
+
+ // Gitea page route - redirect to main SPA with hash
+ app.get('/gitea', (req, res) => {
+ res.redirect('/#gitea');
+ });
+
+ // Gitea API routes
+ app.get('/api/gitea/swarm', async (req, res) => {
+ try {
+ const summary = await gitea.getSwarmSummary();
+ res.json(summary);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+ app.get('/api/gitea/reviews', async (req, res) => {
+ try {
+ const reviews = await gitea.getPendingReviews();
+ res.json(reviews);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+ app.get('/api/gitea/activity', async (req, res) => {
+ try {
+ const activity = await gitea.getRecentActivity();
+ res.json(activity);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+ app.get('/api/gitea/user', async (req, res) => {
+ try {
+ const user = await gitea.getUser();
+ res.json(user);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+ app.get('/api/gitea/repos/:repo', async (req, res) => {
+ try {
+ const repo = await gitea.getRepo(req.params.repo);
+ res.json(repo);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+ app.get('/api/gitea/repos/:repo/pulls', async (req, res) => {
+ try {
+ const state = req.query.state || 'open';
+ const prs = await gitea.getPullRequests(req.params.repo, state);
+ res.json(prs);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+ app.get('/api/gitea/repos/:repo/issues', async (req, res) => {
+ try {
+ const state = req.query.state || 'open';
+ const issues = await gitea.getIssues(req.params.repo, state);
+ res.json(issues);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+ app.get('/api/gitea/repos/:repo/commits', async (req, res) => {
+ try {
+ const branch = req.query.branch || 'main';
+ const limit = parseInt(req.query.limit) || 10;
+ const commits = await gitea.getCommits(req.params.repo, branch, limit);
+ res.json(commits);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+ app.get('/api/gitea/repos/:repo/branches', async (req, res) => {
+ try {
+ const branches = await gitea.getBranches(req.params.repo);
+ res.json(branches);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+ app.post('/api/gitea/cache/clear', (req, res) => {
+ gitea.clearCache();
+ res.json({ success: true, message: 'Cache cleared' });
+ });
+
+ console.log('✅ Gitea integration loaded');
+}
+
+module.exports = { setupGiteaRoutes };
diff --git a/public/app.js b/public/app.js
index 9866eea..e1fd290 100644
--- a/public/app.js
+++ b/public/app.js
@@ -599,7 +599,14 @@ function initAgentsPage() {
async function loadUsage() {
// Try real usage first, fallback to tracked usage
try {
- const realRes = await fetch("/api/usage/real");
+ const from = document.getElementById("usage-from")?.value;
+ const to = document.getElementById("usage-to")?.value;
+ let url = "/api/usage/real";
+ const params = [];
+ if (from) params.push(`from=${from}`);
+ if (to) params.push(`to=${to}`);
+ if (params.length) url += `?${params.join("&")}`;
+ const realRes = await fetch(url);
if (realRes.ok) {
const realData = await realRes.json();
usageStats = {
@@ -829,7 +836,7 @@ function initUsagePage() {
exportJsonBtn.addEventListener('click', () => {
const fromValue = fromInput.value;
const toValue = toInput.value;
- let url = '/api/usage/export?format=json';
+ let url = '/api/usage/export/real?format=json';
if (fromValue) url += `&from=${fromValue}`;
if (toValue) url += `&to=${toValue}`;
window.open(url, '_blank');
@@ -838,7 +845,7 @@ function initUsagePage() {
exportCsvBtn.addEventListener('click', () => {
const fromValue = fromInput.value;
const toValue = toInput.value;
- let url = '/api/usage/export?format=csv';
+ let url = '/api/usage/export/real?format=csv';
if (fromValue) url += `&from=${fromValue}`;
if (toValue) url += `&to=${toValue}`;
window.open(url, '_blank');
@@ -881,3 +888,208 @@ document.addEventListener("DOMContentLoaded", () => {
if (CURRENT_PAGE === 'agents') initAgentsPage();
if (CURRENT_PAGE === 'usage') initUsagePage();
});
+
+// ============ GITEA DASHBOARD ============
+let giteaData = {
+ repos: [],
+ reviews: [],
+ activity: []
+};
+
+// Tab switching
+document.querySelectorAll('.gitea-tabs .tab-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ // Update active tab button
+ document.querySelectorAll('.gitea-tabs .tab-btn').forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+
+ // Show corresponding content
+ const tabName = btn.dataset.tab;
+ document.querySelectorAll('.tab-content').forEach(content => {
+ content.classList.remove('active');
+ });
+ document.getElementById(`${tabName}-tab`).classList.add('active');
+
+ // Load data for tab
+ if (tabName === 'swarm') loadGiteaSwarm();
+ else if (tabName === 'reviews') loadGiteaReviews();
+ else if (tabName === 'activity') loadGiteaActivity();
+ });
+});
+
+async function loadGiteaSwarm() {
+ try {
+ const response = await fetch('/api/gitea/swarm');
+ const repos = await response.json();
+ giteaData.repos = repos;
+
+ // Update stats
+ const totalRepos = repos.length;
+ const totalPRs = repos.reduce((sum, r) => sum + r.open_prs, 0);
+ const totalIssues = repos.reduce((sum, r) => sum + r.open_issues, 0);
+ const totalBranches = repos.reduce((sum, r) => sum + r.branches, 0);
+
+ document.getElementById('total-repos').textContent = totalRepos;
+ document.getElementById('total-prs').textContent = totalPRs;
+ document.getElementById('total-issues').textContent = totalIssues;
+ document.getElementById('total-branches').textContent = totalBranches;
+
+ // Render repo list
+ const repoList = document.getElementById('repo-list');
+ if (repos.length === 0) {
+ repoList.innerHTML = '
No repositories found
';
+ return;
+ }
+
+ repoList.innerHTML = repos.map(repo => `
+
+
+
+
+ 🔀 ${repo.open_prs} PRs
+
+
+ 🐛 ${repo.open_issues} Issues
+
+
+ 🌿 ${repo.branches} Branches
+
+
+
+
+ `).join('');
+
+ } catch (error) {
+ console.error('Error loading Gitea swarm:', error);
+ document.getElementById('repo-list').innerHTML =
+ `Failed to load repositories: ${error.message}
`;
+ }
+}
+
+async function loadGiteaReviews() {
+ try {
+ const response = await fetch('/api/gitea/reviews');
+ const reviews = await response.json();
+ giteaData.reviews = reviews;
+
+ const reviewsList = document.getElementById('reviews-list');
+ if (reviews.length === 0) {
+ reviewsList.innerHTML = 'No pending reviews
';
+ return;
+ }
+
+ reviewsList.innerHTML = reviews.map(pr => `
+
+
+
+
+ by ${pr.author}
+ ${new Date(pr.created_at).toLocaleDateString()}
+
+
+ ${pr.labels.map(label =>
+ `${label.name}`
+ ).join('')}
+
+ ${pr.draft ? '
Draft' : ''}
+ ${!pr.mergeable ? '
Merge Conflict' : ''}
+
+ `).join('');
+
+ } catch (error) {
+ console.error('Error loading Gitea reviews:', error);
+ document.getElementById('reviews-list').innerHTML =
+ `Failed to load reviews: ${error.message}
`;
+ }
+}
+
+async function loadGiteaActivity() {
+ try {
+ const response = await fetch('/api/gitea/activity');
+ const activities = await response.json();
+ giteaData.activity = activities;
+
+ const activityFeed = document.getElementById('activity-feed');
+ if (activities.length === 0) {
+ activityFeed.innerHTML = 'No recent activity
';
+ return;
+ }
+
+ activityFeed.innerHTML = activities.map(act => `
+
+
${getActivityIcon(act.op_type)}
+
+
+
+ ${act.content || act.op_type}
+
+
+
+ `).join('');
+
+ } catch (error) {
+ console.error('Error loading Gitea activity:', error);
+ document.getElementById('activity-feed').innerHTML =
+ `Failed to load activity: ${error.message}
`;
+ }
+}
+
+function getActivityIcon(type) {
+ const icons = {
+ 'create': '✨',
+ 'push': '📤',
+ 'merge': '🔀',
+ 'close': '✅',
+ 'reopen': '🔄',
+ 'comment': '💬',
+ 'label': '🏷️',
+ 'milestone': '🎯',
+ 'assign': '👤',
+ 'review': '👀',
+ 'release': '🚀'
+ };
+ return icons[type] || '📝';
+}
+
+function timeAgo(dateString) {
+ const now = new Date();
+ const date = new Date(dateString);
+ const seconds = Math.floor((now - date) / 1000);
+
+ if (seconds < 60) return 'just now';
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
+ if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`;
+ return date.toLocaleDateString();
+}
+
+// Auto-load Gitea data on page load
+if (document.querySelector('.gitea-dashboard')) {
+ loadGiteaSwarm();
+
+ // Refresh every 30 seconds
+ setInterval(() => {
+ const activeTab = document.querySelector('.gitea-tabs .tab-btn.active');
+ if (activeTab) {
+ const tabName = activeTab.dataset.tab;
+ if (tabName === 'swarm') loadGiteaSwarm();
+ else if (tabName === 'reviews') loadGiteaReviews();
+ else if (tabName === 'activity') loadGiteaActivity();
+ }
+ }, 30000);
+}
diff --git a/public/index.html b/public/index.html
index ffd6f15..09c71dd 100644
--- a/public/index.html
+++ b/public/index.html
@@ -4,11 +4,12 @@
OpenClaw Agent Fleet Dashboard
+
+
+
+
+
-
-
-
-
@@ -19,12 +20,11 @@
Wiki
Agents
Usage
-
+
Gitea
-
Create Task
@@ -84,137 +84,71 @@
-
-
-
-
-
-
-
-
📚 Select a wiki page from the sidebar or create a new one.
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
Estimated Cost
-
$0.00
-
-
-
-
-
-
Usage by Provider
-
-
-
-
Usage by Agent
-
-
-
-
-
+
+
+
+ 🔧 Gitea Integration
+
+
+
+
+
+
+
+
+
+
+
+
+
Loading repositories...
+
+
+
+
+
+
+
Loading pending reviews...
+
+
+
+
+
+
+
Loading recent activity...
+
+
+
+
+
+
diff --git a/public/styles.css b/public/styles.css
index f144389..09575bd 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -1,1033 +1,785 @@
+/* ============================================
+ OpenClaw Fleet Dashboard - Distinctive Design
+ Typography: JetBrains Mono, Outfit, Space Mono
+ Aesthetic: Modern, clean, sophisticated
+ ============================================ */
+
+/* CSS Custom Properties - Color System */
:root {
- --bg: #f5f7fb;
- --fg: #1f2937;
- --border: #c8d1df;
- --primary: #3498db;
- --secondary: #2ecc71;
- --danger: #e74c3c;
- --warning: #f39c12;
- --dark: #1f2937;
- --light: #f8fafc;
- --card-bg: #ffffff;
- --card-fg: #111827;
- --modal-bg: rgba(0, 0, 0, 0.45);
+ /* Light theme */
+ --bg-primary: #fafbfc;
+ --bg-secondary: #ffffff;
+ --bg-tertiary: #f0f2f5;
+ --text-primary: #0f1419;
+ --text-secondary: #536471;
+ --text-tertiary: #6e7781;
+ --border-color: #d0d7de;
+ --border-subtle: #e6e8eb;
+
+ /* Accent colors */
+ --accent-primary: #0969da;
+ --accent-secondary: #1f6feb;
+ --accent-success: #1a7f37;
+ --accent-warning: #9a6700;
+ --accent-danger: #cf222e;
+ --accent-info: #0550ae;
+
+ /* Special effects */
+ --glass-bg: rgba(255, 255, 255, 0.7);
+ --glass-border: rgba(255, 255, 255, 0.3);
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
+ --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
+ --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
+ --shadow-xl: 0 12px 48px rgba(0, 0, 0, 0.15);
+
+ /* Animation timing */
+ --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
+ --transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1);
+ --transition-slow: 350ms cubic-bezier(0.4, 0, 0.2, 1);
+
+ /* Spacing */
+ --space-xs: 0.25rem;
+ --space-sm: 0.5rem;
+ --space-md: 1rem;
+ --space-lg: 1.5rem;
+ --space-xl: 2rem;
+ --space-2xl: 3rem;
+
+ /* Border radius */
+ --radius-sm: 6px;
+ --radius-md: 10px;
+ --radius-lg: 16px;
+ --radius-xl: 24px;
+ --radius-full: 9999px;
}
+/* Dark theme */
:root[data-theme='dark'] {
- --bg: #1a1a1a;
- --fg: #e0e0e0;
- --border: #444;
- --dark: #121212;
- --light: #f0f0f0;
- --card-bg: #2a2a2a;
- --card-fg: #e0e0e0;
- --modal-bg: rgba(0, 0, 0, 0.7);
+ --bg-primary: #0d1117;
+ --bg-secondary: #161b22;
+ --bg-tertiary: #21262d;
+ --text-primary: #f0f6fc;
+ --text-secondary: #8b949e;
+ --text-tertiary: #6e7681;
+ --border-color: #30363d;
+ --border-subtle: #21262d;
+
+ --accent-primary: #58a6ff;
+ --accent-secondary: #79c0ff;
+ --accent-success: #3fb950;
+ --accent-warning: #d29922;
+ --accent-danger: #f85149;
+ --accent-info: #58a6ff;
+
+ --glass-bg: rgba(13, 17, 23, 0.8);
+ --glass-border: rgba(48, 54, 61, 0.5);
+
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
+ --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
+ --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
+ --shadow-xl: 0 12px 48px rgba(0, 0, 0, 0.6);
}
+/* System dark mode preference */
@media (prefers-color-scheme: dark) {
- :root:not([data-theme='light']) {
- --bg: #1a1a1a;
- --fg: #e0e0e0;
- --border: #444;
- --dark: #121212;
- --light: #f0f0f0;
- --card-bg: #2a2a2a;
- --card-fg: #e0e0e0;
- --modal-bg: rgba(0, 0, 0, 0.7);
- }
+ :root:not([data-theme='light']) {
+ --bg-primary: #0d1117;
+ --bg-secondary: #161b22;
+ --bg-tertiary: #21262d;
+ --text-primary: #f0f6fc;
+ --text-secondary: #8b949e;
+ --text-tertiary: #6e7681;
+ --border-color: #30363d;
+ --border-subtle: #21262d;
+
+ --accent-primary: #58a6ff;
+ --accent-secondary: #79c0ff;
+ --accent-success: #3fb950;
+ --accent-warning: #d29922;
+ --accent-danger: #f85149;
+ --accent-info: #58a6ff;
+
+ --glass-bg: rgba(13, 17, 23, 0.8);
+ --glass-border: rgba(48, 54, 61, 0.5);
+
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
+ --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
+ --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
+ --shadow-xl: 0 12px 48px rgba(0, 0, 0, 0.6);
+ }
}
+/* ============================================
+ Base Styles
+ ============================================ */
+
* {
- margin: 0;
- padding: 0;
- box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+html {
+ font-size: 16px;
+ scroll-behavior: smooth;
}
body {
- font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
- background-color: var(--bg);
- color: var(--fg);
- line-height: 1.6;
- transition: background-color 0.3s ease, color 0.3s ease;
+ font-family: 'Outfit', -apple-system, BlinkMacSystemFont, sans-serif;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ line-height: 1.6;
+ min-height: 100vh;
+ overflow-x: hidden;
+ transition: background-color var(--transition-base), color var(--transition-base);
}
-body * {
- transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
+/* Animated background particles */
+.bg-particles {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+ z-index: 0;
+ opacity: 0.4;
+ background:
+ radial-gradient(circle at 20% 30%, var(--accent-primary) 0%, transparent 50%),
+ radial-gradient(circle at 80% 70%, var(--accent-secondary) 0%, transparent 50%);
+ filter: blur(100px);
+ animation: particleFloat 20s ease-in-out infinite;
}
-.container {
- max-width: 1400px;
- margin: 0 auto;
- padding: 20px;
+@keyframes particleFloat {
+ 0%, 100% { transform: translate(0, 0) scale(1); }
+ 33% { transform: translate(30px, -30px) scale(1.1); }
+ 66% { transform: translate(-30px, 30px) scale(0.9); }
}
-header {
- background-color: var(--card-bg);
- padding: 20px;
- border-radius: 12px;
- margin-bottom: 20px;
- box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+/* ============================================
+ Layout Container
+ ============================================ */
+
+.app-container {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
}
-header h1 {
- color: var(--primary);
- margin-bottom: 15px;
+/* ============================================
+ Header - Glassmorphism Design
+ ============================================ */
+
+.app-header {
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ background: var(--glass-bg);
+ backdrop-filter: blur(20px) saturate(180%);
+ -webkit-backdrop-filter: blur(20px) saturate(180%);
+ border-bottom: 1px solid var(--glass-border);
+ box-shadow: var(--shadow-md);
+ animation: slideDown 0.5s ease-out;
}
-nav {
- display: flex;
- gap: 15px;
- align-items: center;
+@keyframes slideDown {
+ from {
+ transform: translateY(-100%);
+ opacity: 0;
+ }
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
+
+.header-content {
+ max-width: 1400px;
+ margin: 0 auto;
+ padding: var(--space-lg) var(--space-xl);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: var(--space-xl);
+}
+
+.logo-section {
+ display: flex;
+ align-items: center;
+ gap: var(--space-md);
+}
+
+.logo-icon {
+ position: relative;
+ width: 48px;
+ height: 48px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 2rem;
+ animation: logoFloat 3s ease-in-out infinite;
+}
+
+@keyframes logoFloat {
+ 0%, 100% { transform: translateY(0px); }
+ 50% { transform: translateY(-8px); }
+}
+
+.logo-glow {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ background: radial-gradient(circle, var(--accent-primary) 0%, transparent 70%);
+ opacity: 0.3;
+ animation: glowPulse 2s ease-in-out infinite;
+}
+
+@keyframes glowPulse {
+ 0%, 100% { transform: scale(1); opacity: 0.3; }
+ 50% { transform: scale(1.2); opacity: 0.5; }
+}
+
+.logo-emoji {
+ position: relative;
+ z-index: 1;
+ filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.3));
+}
+
+.logo-text {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.logo-title {
+ font-family: 'Space Mono', monospace;
+ font-size: 1.75rem;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.logo-subtitle {
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: var(--text-secondary);
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+}
+
+/* Theme Toggle Button */
+.theme-btn {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ padding: var(--space-sm) var(--space-lg);
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-full);
+ cursor: pointer;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: var(--text-primary);
+ transition: all var(--transition-base);
+ box-shadow: var(--shadow-sm);
+}
+
+.theme-btn:hover {
+ background: var(--bg-tertiary);
+ border-color: var(--accent-primary);
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-md);
+}
+
+.theme-btn:active {
+ transform: translateY(0);
+}
+
+.theme-icon {
+ font-size: 1.25rem;
+ transition: transform var(--transition-base);
+}
+
+.theme-btn:hover .theme-icon {
+ transform: rotate(180deg);
+}
+
+/* ============================================
+ Navigation - Modern Tab Design
+ ============================================ */
+
+.app-nav {
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border-color);
+ box-shadow: var(--shadow-sm);
+ animation: slideUp 0.6s ease-out;
+}
+
+@keyframes slideUp {
+ from {
+ transform: translateY(-20px);
+ opacity: 0;
+ }
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
+
+.nav-container {
+ max-width: 1400px;
+ margin: 0 auto;
+ padding: 0 var(--space-xl);
+ display: flex;
+ gap: var(--space-xs);
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+
+ /* Hide scrollbar but keep functionality */
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+
+.nav-container::-webkit-scrollbar {
+ display: none;
}
.nav-link {
- color: var(--fg);
- text-decoration: none;
- padding: 8px 16px;
- border-radius: 6px;
- transition: background-color 0.3s ease;
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ padding: var(--space-lg) var(--space-lg);
+ color: var(--text-secondary);
+ text-decoration: none;
+ font-weight: 500;
+ font-size: 0.9375rem;
+ transition: all var(--transition-base);
+ white-space: nowrap;
+ border-bottom: 2px solid transparent;
}
.nav-link:hover {
- background-color: var(--border);
+ color: var(--text-primary);
+ background: var(--bg-tertiary);
+}
+
+.nav-icon {
+ font-size: 1.25rem;
+ transition: transform var(--transition-base);
+}
+
+.nav-link:hover .nav-icon {
+ transform: scale(1.1);
+}
+
+.nav-indicator {
+ position: absolute;
+ bottom: 0;
+ left: 50%;
+ transform: translateX(-50%) scaleX(0);
+ width: 100%;
+ height: 2px;
+ background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
+ transition: transform var(--transition-base);
}
.nav-link.active {
- background-color: var(--primary);
- color: white;
+ color: var(--accent-primary);
+ font-weight: 600;
}
-#theme-toggle {
- margin-left: auto;
+.nav-link.active .nav-indicator {
+ transform: translateX(-50%) scaleX(0.8);
}
-/* Buttons */
-.btn-primary {
- background-color: var(--primary);
- color: white;
- border: none;
- padding: 8px 16px;
- border-radius: 6px;
- cursor: pointer;
- font-size: 0.9rem;
- transition: background-color 0.3s ease;
+.nav-link.active .nav-icon {
+ animation: iconBounce 0.5s ease-out;
}
-.btn-primary:hover {
- background-color: #2980b9;
+@keyframes iconBounce {
+ 0%, 100% { transform: scale(1); }
+ 50% { transform: scale(1.2); }
}
-.btn-secondary {
- background-color: var(--border);
- color: var(--fg);
- border: none;
- padding: 8px 16px;
- border-radius: 6px;
- cursor: pointer;
- font-size: 0.9rem;
- transition: background-color 0.3s ease;
+/* ============================================
+ Main Content Area
+ ============================================ */
+
+.app-main {
+ flex: 1;
+ padding: var(--space-xl) 0;
+ animation: fadeIn 0.8s ease-out;
}
-.btn-secondary:hover {
- background-color: #555;
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
}
-.btn-danger {
- background-color: var(--danger);
- color: white;
- border: none;
- padding: 8px 16px;
- border-radius: 6px;
- cursor: pointer;
- font-size: 0.9rem;
- transition: background-color 0.3s ease;
+.content-wrapper {
+ max-width: 1400px;
+ margin: 0 auto;
+ padding: 0 var(--space-xl);
}
-.btn-danger:hover {
- background-color: #c0392b;
+/* ============================================
+ Footer
+ ============================================ */
+
+.app-footer {
+ padding: var(--space-lg);
+ text-align: center;
+ background: var(--bg-secondary);
+ border-top: 1px solid var(--border-color);
+ color: var(--text-tertiary);
+ font-size: 0.875rem;
+ font-family: 'JetBrains Mono', monospace;
}
-/* ============ TASKS PAGE ============ */
-.composer {
- background-color: var(--card-bg);
- padding: 20px;
- border-radius: 12px;
- margin-bottom: 20px;
- box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+.footer-version {
+ color: var(--accent-primary);
+ font-weight: 600;
}
-.composer h2 {
- color: var(--primary);
- margin-bottom: 15px;
-}
-
-#task-form {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 15px;
-}
-
-#task-form input,
-#task-form textarea,
-#task-form select {
- padding: 12px;
- border: 1px solid var(--border);
- border-radius: 8px;
- background-color: var(--bg);
- color: var(--fg);
- font-size: 1rem;
-}
-
-#task-form textarea {
- grid-column: span 2;
- resize: vertical;
- min-height: 100px;
-}
-
-#task-form button {
- grid-column: span 2;
- padding: 12px 24px;
- background-color: var(--primary);
- color: white;
- border: none;
- border-radius: 8px;
- font-size: 1rem;
- cursor: pointer;
- transition: background-color 0.3s ease;
-}
-
-#task-form button:hover {
- background-color: #2980b9;
-}
-
-#board {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
- gap: 20px;
-}
-
-.column {
- background-color: var(--card-bg);
- border-radius: 12px;
- box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
- overflow: hidden;
-}
-
-.column-header {
- padding: 15px;
- background-color: var(--dark);
- display: flex;
- justify-content: space-between;
- align-items: center;
-}
-
-.column-header h3 {
- color: var(--fg);
-}
-
-.column-count {
- background-color: var(--border);
- color: var(--fg);
- padding: 4px 8px;
- border-radius: 4px;
- font-size: 0.9rem;
-}
-
-.cards {
- padding: 15px;
- min-height: 200px;
-}
+/* ============================================
+ Cards - Modern Elevated Design
+ ============================================ */
.card {
- background-color: var(--bg);
- border: 1px solid var(--border);
- border-radius: 8px;
- padding: 15px;
- margin-bottom: 15px;
- transition: transform 0.2s ease, box-shadow 0.2s ease;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-lg);
+ padding: var(--space-xl);
+ margin-bottom: var(--space-lg);
+ box-shadow: var(--shadow-sm);
+ transition: all var(--transition-base);
+ animation: cardAppear 0.5s ease-out backwards;
+}
+
+@keyframes cardAppear {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
}
.card:hover {
- transform: translateY(-2px);
- box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
+ box-shadow: var(--shadow-lg);
+ transform: translateY(-4px);
+ border-color: var(--accent-primary);
}
-.card-head {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 10px;
+.card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: var(--space-lg);
+ padding-bottom: var(--space-md);
+ border-bottom: 1px solid var(--border-subtle);
}
.card-title {
- color: var(--card-fg);
- font-size: 1.1rem;
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: var(--text-primary);
+ font-family: 'Space Mono', monospace;
}
-.badge {
- padding: 4px 8px;
- border-radius: 4px;
- font-size: 0.8rem;
- font-weight: bold;
+.card-body {
+ color: var(--text-secondary);
+ line-height: 1.7;
}
-.badge.priority-Low {
- background-color: var(--secondary);
- color: white;
-}
-
-.badge.priority-Medium {
- background-color: var(--warning);
- color: white;
-}
+/* ============================================
+ Buttons - Modern Interactive Design
+ ============================================ */
-.badge.priority-High {
- background-color: var(--danger);
- color: white;
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-sm);
+ padding: var(--space-sm) var(--space-lg);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all var(--transition-base);
+ text-decoration: none;
+ white-space: nowrap;
}
-.badge.priority-Critical {
- background-color: #9c27b0;
- color: white;
+.btn-primary {
+ background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
+ color: white;
+ border: none;
+ box-shadow: var(--shadow-sm);
}
-.card-desc {
- color: var(--fg);
- margin-bottom: 10px;
+.btn-primary:hover {
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-lg);
}
-.meta {
- font-size: 0.9rem;
- color: var(--border);
- margin-bottom: 5px;
+.btn-secondary {
+ background: var(--bg-secondary);
+ color: var(--text-primary);
}
-.meta.assignee {
- font-weight: bold;
+.btn-secondary:hover {
+ background: var(--bg-tertiary);
+ border-color: var(--accent-primary);
}
-.tag {
- display: inline-block;
- background-color: var(--border);
- color: var(--fg);
- padding: 2px 6px;
- border-radius: 4px;
- margin-right: 5px;
- margin-bottom: 5px;
+.btn:active {
+ transform: translateY(0);
}
-.card-check {
- margin-right: 10px;
-}
+/* ============================================
+ Form Elements
+ ============================================ */
-/* ============ WIKI PAGE ============ */
-#page-wiki {
- background-color: var(--card-bg);
- padding: 20px;
- border-radius: 12px;
- box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+input, textarea, select {
+ width: 100%;
+ padding: var(--space-md);
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ font-family: 'Outfit', sans-serif;
+ font-size: 0.9375rem;
+ color: var(--text-primary);
+ transition: all var(--transition-base);
}
-.wiki-container {
- display: grid;
- grid-template-columns: 280px 1fr;
- gap: 20px;
- min-height: 600px;
+input:focus, textarea:focus, select:focus {
+ outline: none;
+ border-color: var(--accent-primary);
+ box-shadow: 0 0 0 3px rgba(9, 105, 218, 0.1);
}
-.wiki-sidebar {
- display: flex;
- flex-direction: column;
- gap: 15px;
+input::placeholder, textarea::placeholder {
+ color: var(--text-tertiary);
}
-.wiki-actions {
- display: flex;
- gap: 10px;
+label {
+ display: block;
+ margin-bottom: var(--space-sm);
+ font-weight: 500;
+ color: var(--text-primary);
+ font-size: 0.875rem;
}
-.wiki-search input {
- width: 100%;
- padding: 10px;
- border: 1px solid var(--border);
- border-radius: 6px;
- background-color: var(--bg);
- color: var(--fg);
-}
-
-.wiki-list {
- flex: 1;
- overflow-y: auto;
- max-height: 500px;
-}
-
-.wiki-item {
- background-color: var(--bg);
- border: 1px solid var(--border);
- border-radius: 8px;
- padding: 12px;
- margin-bottom: 10px;
- cursor: pointer;
- transition: all 0.2s ease;
-}
-
-.wiki-item:hover {
- background-color: var(--dark);
- border-color: var(--primary);
-}
-
-.wiki-item.active {
- background-color: var(--primary);
- border-color: var(--primary);
-}
-
-.wiki-item.active .wiki-title,
-.wiki-item.active .wiki-date {
- color: white;
-}
-
-.wiki-title {
- color: var(--card-fg);
- margin-bottom: 5px;
- font-size: 0.95rem;
-}
-
-.wiki-date {
- color: var(--border);
- font-size: 0.8rem;
-}
-
-.wiki-main {
- display: flex;
- flex-direction: column;
- gap: 15px;
-}
-
-.wiki-toolbar {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding-bottom: 15px;
- border-bottom: 1px solid var(--border);
-}
-
-.wiki-page-title {
- font-size: 1.3rem;
- font-weight: bold;
- color: var(--primary);
-}
-
-.wiki-page-actions {
- display: flex;
- gap: 10px;
-}
-
-.wiki-content {
- background-color: var(--bg);
- border: 1px solid var(--border);
- border-radius: 8px;
- padding: 20px;
- overflow-y: auto;
- flex: 1;
-}
-
-.wiki-content h1, .wiki-content h2, .wiki-content h3 {
- color: var(--primary);
- margin-top: 20px;
- margin-bottom: 10px;
-}
-
-.wiki-content h1:first-child {
- margin-top: 0;
-}
-
-.wiki-content p {
- margin-bottom: 15px;
-}
-
-.wiki-content ul, .wiki-content ol {
- margin-left: 20px;
- margin-bottom: 15px;
-}
-
-.wiki-content code {
- background-color: var(--dark);
- padding: 2px 6px;
- border-radius: 4px;
- font-family: 'Consolas', monospace;
-}
-
-.wiki-content pre {
- background-color: var(--dark);
- padding: 15px;
- border-radius: 8px;
- overflow-x: auto;
- margin-bottom: 15px;
-}
-
-.wiki-content pre code {
- padding: 0;
-}
-
-.wiki-content blockquote {
- border-left: 3px solid var(--primary);
- padding-left: 15px;
- margin-left: 0;
- color: #aaa;
-}
-
-.wiki-placeholder {
- display: flex;
- align-items: center;
- justify-content: center;
- height: 100%;
- min-height: 300px;
- color: var(--border);
- font-size: 1.1rem;
-}
-
-.wiki-editor {
- display: flex;
- flex-direction: column;
- gap: 15px;
- flex: 1;
-}
-
-.wiki-editor input {
- padding: 12px;
- border: 1px solid var(--border);
- border-radius: 8px;
- background-color: var(--bg);
- color: var(--fg);
- font-size: 1.1rem;
-}
-
-.wiki-editor textarea {
- flex: 1;
- min-height: 400px;
- padding: 15px;
- border: 1px solid var(--border);
- border-radius: 8px;
- background-color: var(--bg);
- color: var(--fg);
- font-family: 'Consolas', monospace;
- font-size: 0.95rem;
- resize: vertical;
-}
-
-.editor-actions {
- display: flex;
- gap: 10px;
-}
-
-/* ============ AGENTS PAGE ============ */
-#page-agents {
- background-color: var(--card-bg);
- padding: 20px;
- border-radius: 12px;
- box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
-}
-
-.agents-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 20px;
-}
-
-.agents-header h2 {
- color: var(--primary);
-}
-
-.agents-controls {
- display: flex;
- gap: 15px;
-}
-
-.agents-controls input,
-.agents-controls select {
- padding: 10px 15px;
- border: 1px solid var(--border);
- border-radius: 6px;
- background-color: var(--bg);
- color: var(--fg);
-}
-
-.agents-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
- gap: 20px;
-}
-
-.agent-card {
- background-color: var(--bg);
- border: 1px solid var(--border);
- border-radius: 12px;
- padding: 15px;
- transition: transform 0.2s ease, box-shadow 0.2s ease;
-}
-
-.agent-card:hover {
- transform: translateY(-2px);
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
-}
-
-.agent-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 10px;
-}
-
-.agent-name {
- color: var(--card-fg);
- font-size: 1.2rem;
-}
-
-.agent-status {
- padding: 4px 10px;
- border-radius: 20px;
- font-size: 0.8rem;
- font-weight: bold;
-}
-
-.agent-status.status-active {
- background-color: var(--secondary);
- color: white;
-}
-
-.agent-status.status-busy {
- background-color: var(--warning);
- color: white;
-}
-
-.agent-status.status-idle {
- background-color: var(--border);
- color: var(--fg);
-}
-
-.agent-workload {
- margin-bottom: 15px;
-}
-
-.workload-badge {
- background-color: var(--primary);
- color: white;
- padding: 4px 8px;
- border-radius: 4px;
- font-size: 0.85rem;
-}
-
-.agent-section {
- margin-bottom: 15px;
-}
-
-.agent-section h4 {
- color: var(--primary);
- margin-bottom: 8px;
- font-size: 0.95rem;
-}
-
-.agent-task {
- color: var(--fg);
-}
-
-.agent-tools,
-.agent-files {
- display: flex;
- flex-wrap: wrap;
- gap: 5px;
-}
-
-.tool-tag,
-.file-tag {
- background-color: var(--border);
- color: var(--fg);
- padding: 2px 8px;
- border-radius: 4px;
- font-size: 0.8rem;
-}
-
-.capability-tag {
- background-color: var(--secondary);
- color: white;
- padding: 2px 8px;
- border-radius: 4px;
- font-size: 0.8rem;
-}
-
-.more-tag {
- background-color: var(--dark);
- color: var(--fg);
- padding: 2px 8px;
- border-radius: 4px;
- font-size: 0.8rem;
-}
-
-.no-data {
- color: var(--border);
- font-style: italic;
-}
-
-.agent-actions {
- display: flex;
- gap: 10px;
- padding-top: 15px;
- border-top: 1px solid var(--border);
-}
-
-.agent-actions button {
- flex: 1;
-}
-
-/* ============ MODALS ============ */
-.modal {
- display: none;
- position: fixed;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- background-color: var(--modal-bg);
- z-index: 1000;
- justify-content: center;
- align-items: center;
-}
-
-.modal.active {
- display: flex;
-}
-
-.modal-content {
- background-color: var(--card-bg);
- border-radius: 12px;
- max-width: 600px;
- width: 90%;
- max-height: 80vh;
- overflow-y: auto;
- box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
-}
-
-.modal-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 20px;
- border-bottom: 1px solid var(--border);
-}
-
-.modal-header h3 {
- color: var(--primary);
-}
-
-.modal-close {
- background: none;
- border: none;
- color: var(--fg);
- font-size: 1.5rem;
- cursor: pointer;
- padding: 0 5px;
-}
-
-.modal-close:hover {
- color: var(--danger);
-}
-
-.modal-body {
- padding: 20px;
-}
-
-.detail-section {
- margin-bottom: 20px;
-}
-
-.detail-section h4 {
- color: var(--primary);
- margin-bottom: 10px;
- font-size: 1rem;
-}
-
-.task-list {
- list-style: none;
- padding: 0;
-}
-
-.task-list li {
- padding: 8px 12px;
- background-color: var(--bg);
- border-radius: 6px;
- margin-bottom: 5px;
-}
-
-.tag-list {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
-}
-
-#assign-task-select {
- width: 100%;
- padding: 12px;
- border: 1px solid var(--border);
- border-radius: 8px;
- background-color: var(--bg);
- color: var(--fg);
- margin-bottom: 15px;
-}
-
-#confirm-assign-btn {
- width: 100%;
-}
-
-/* ============ USAGE PAGE ============ */
-#page-usage {
- background-color: var(--card-bg);
- padding: 20px;
- border-radius: 12px;
- box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
-}
-
-.usage-header {
- display: flex;
- justify-content: space-between;
- align-items: flex-start;
- margin-bottom: 20px;
- flex-wrap: wrap;
- gap: 15px;
-}
-
-.usage-header h2 {
- color: var(--primary);
-}
-
-.usage-controls {
- display: flex;
- gap: 20px;
- flex-wrap: wrap;
-}
-
-.date-range {
- display: flex;
- align-items: center;
- gap: 10px;
-}
-
-.date-range label {
- font-size: 0.9rem;
-}
-
-.date-range input {
- padding: 8px 12px;
- border: 1px solid var(--border);
- border-radius: 6px;
- background-color: var(--bg);
- color: var(--fg);
-}
-
-.export-actions {
- display: flex;
- gap: 10px;
-}
-
-.usage-stats {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
- gap: 20px;
- margin-bottom: 30px;
-}
-
-.stat-card {
- background-color: var(--bg);
- border: 1px solid var(--border);
- border-radius: 12px;
- padding: 20px;
- text-align: center;
-}
-
-.stat-card h4 {
- color: var(--border);
- font-size: 0.9rem;
- margin-bottom: 10px;
-}
-
-.stat-card .stat-value {
- color: var(--primary);
- font-size: 2rem;
- font-weight: bold;
-}
-
-.usage-charts {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
- gap: 20px;
- margin-bottom: 30px;
-}
-
-.chart-container {
- background-color: var(--bg);
- border: 1px solid var(--border);
- border-radius: 12px;
- padding: 20px;
-}
-
-.chart-container h4 {
- color: var(--fg);
- margin-bottom: 15px;
-}
-
-.usage-details h3 {
- color: var(--primary);
- margin-bottom: 20px;
-}
-
-.usage-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
- gap: 20px;
-}
-
-.provider-card {
- background-color: var(--bg);
- border: 1px solid var(--border);
- border-radius: 12px;
- padding: 20px;
-}
-
-.provider-card h4 {
- color: var(--card-fg);
- margin-bottom: 15px;
- font-size: 1.1rem;
-}
-
-.provider-stats {
- display: flex;
- gap: 20px;
- margin-bottom: 15px;
-}
-
-.provider-stat {
- display: flex;
- flex-direction: column;
-}
-
-.provider-stat .stat-label {
- color: var(--border);
- font-size: 0.8rem;
-}
-
-.provider-stat .stat-value {
- color: var(--fg);
- font-size: 1.1rem;
- font-weight: bold;
-}
-
-.model-list h5 {
- color: var(--primary);
- margin-bottom: 10px;
- font-size: 0.9rem;
-}
-
-.model-item {
- display: flex;
- justify-content: space-between;
- background-color: var(--dark);
- padding: 10px;
- border-radius: 6px;
- margin-bottom: 5px;
-}
-
-.model-name {
- color: var(--primary);
- font-weight: bold;
-}
-
-.model-type {
- color: var(--fg);
- font-size: 0.9rem;
-}
-
-/* ============ RESPONSIVE ============ */
-@media (max-width: 900px) {
- .wiki-container {
- grid-template-columns: 1fr;
- }
-
- .wiki-sidebar {
- max-height: 200px;
- }
-
- .wiki-list {
- max-height: 150px;
- }
-}
+/* ============================================
+ Responsive Design - Mobile First
+ ============================================ */
+/* Tablet and smaller */
@media (max-width: 768px) {
- #task-form {
- grid-template-columns: 1fr;
- }
-
- #task-form textarea {
- grid-column: span 1;
- }
-
- #task-form button {
- grid-column: span 1;
- }
-
- .usage-grid {
- grid-template-columns: 1fr;
- }
-
- .agents-grid {
- grid-template-columns: 1fr;
- }
-
- .agents-header {
- flex-direction: column;
- align-items: flex-start;
- }
-
- .agents-controls {
- width: 100%;
- }
-
- .agents-controls input {
- flex: 1;
- }
-
- .usage-header {
- flex-direction: column;
- }
-
- .usage-controls {
- width: 100%;
- flex-direction: column;
- }
-
- .date-range {
- flex-wrap: wrap;
- }
-
- .usage-charts {
- grid-template-columns: 1fr;
- }
+ html {
+ font-size: 14px;
+ }
+
+ .header-content {
+ padding: var(--space-md);
+ }
+
+ .logo-title {
+ font-size: 1.5rem;
+ }
+
+ .logo-subtitle {
+ font-size: 0.625rem;
+ }
+
+ .nav-container {
+ padding: 0 var(--space-md);
+ }
+
+ .nav-link {
+ padding: var(--space-md);
+ font-size: 0.875rem;
+ }
+
+ .nav-label {
+ display: none; /* Hide text on mobile, show only icons */
+ }
+
+ .nav-icon {
+ font-size: 1.5rem;
+ }
+
+ .content-wrapper {
+ padding: 0 var(--space-md);
+ }
+
+ .card {
+ padding: var(--space-lg);
+ }
+
+ .theme-btn .theme-text {
+ display: none; /* Hide text on mobile */
+ }
}
-/* ============ SCROLLBAR ============ */
-::-webkit-scrollbar {
- width: 8px;
- height: 8px;
+/* Mobile */
+@media (max-width: 480px) {
+ .header-content {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: var(--space-md);
+ }
+
+ .logo-icon {
+ width: 40px;
+ height: 40px;
+ font-size: 1.75rem;
+ }
+
+ .logo-title {
+ font-size: 1.25rem;
+ }
+
+ .nav-link {
+ padding: var(--space-sm) var(--space-md);
+ }
+
+ .card {
+ padding: var(--space-md);
+ margin-bottom: var(--space-md);
+ }
}
-::-webkit-scrollbar-track {
- background: var(--dark);
+/* Large screens */
+@media (min-width: 1400px) {
+ .content-wrapper {
+ padding: 0 var(--space-2xl);
+ }
}
-::-webkit-scrollbar-thumb {
- background: var(--border);
- border-radius: 4px;
+/* ============================================
+ Utility Classes
+ ============================================ */
+
+.text-center { text-align: center; }
+.text-left { text-align: left; }
+.text-right { text-align: right; }
+
+.mt-sm { margin-top: var(--space-sm); }
+.mt-md { margin-top: var(--space-md); }
+.mt-lg { margin-top: var(--space-lg); }
+.mt-xl { margin-top: var(--space-xl); }
+
+.mb-sm { margin-bottom: var(--space-sm); }
+.mb-md { margin-bottom: var(--space-md); }
+.mb-lg { margin-bottom: var(--space-lg); }
+.mb-xl { margin-bottom: var(--space-xl); }
+
+.flex { display: flex; }
+.flex-col { flex-direction: column; }
+.items-center { align-items: center; }
+.justify-between { justify-content: space-between; }
+.gap-sm { gap: var(--space-sm); }
+.gap-md { gap: var(--space-md); }
+.gap-lg { gap: var(--space-lg); }
+
+/* ============================================
+ Special Effects
+ ============================================ */
+
+/* Gradient text */
+.gradient-text {
+ background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
}
-::-webkit-scrollbar-thumb:hover {
- background: #555;
+/* Glassmorphism card */
+.glass-card {
+ background: var(--glass-bg);
+ backdrop-filter: blur(20px) saturate(180%);
+ -webkit-backdrop-filter: blur(20px) saturate(180%);
+ border: 1px solid var(--glass-border);
+}
+
+/* Shimmer loading effect */
+.shimmer {
+ background: linear-gradient(
+ 90deg,
+ var(--bg-tertiary) 0%,
+ var(--bg-secondary) 50%,
+ var(--bg-tertiary) 100%
+ );
+ background-size: 200% 100%;
+ animation: shimmer 1.5s ease-in-out infinite;
+}
+
+@keyframes shimmer {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+}
+
+/* Fade in animation */
+.fade-in {
+ animation: fadeIn 0.5s ease-out;
+}
+
+/* Slide up animation */
+.slide-up {
+ animation: slideUp 0.5s ease-out;
+}
+
+/* Pulse animation */
+.pulse {
+ animation: pulse 2s ease-in-out infinite;
+}
+
+@keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.5; }
+}
+
+/* ============================================
+ Print Styles
+ ============================================ */
+
+@media print {
+ .bg-particles,
+ .app-header,
+ .app-nav,
+ .app-footer,
+ .theme-btn {
+ display: none;
+ }
+
+ body {
+ background: white;
+ color: black;
+ }
+
+ .card {
+ box-shadow: none;
+ border: 1px solid #ddd;
+ page-break-inside: avoid;
+ }
}
diff --git a/public/styles.css.backup b/public/styles.css.backup
index 330c089..34c3bd7 100644
--- a/public/styles.css.backup
+++ b/public/styles.css.backup
@@ -1,15 +1,40 @@
:root {
- --bg: #1a1a1a;
- --fg: #e0e0e0;
- --border: #444;
+ --bg: #f5f7fb;
+ --fg: #1f2937;
+ --border: #c8d1df;
--primary: #3498db;
--secondary: #2ecc71;
--danger: #e74c3c;
--warning: #f39c12;
+ --dark: #1f2937;
+ --light: #f8fafc;
+ --card-bg: #ffffff;
+ --card-fg: #111827;
+ --modal-bg: rgba(0, 0, 0, 0.45);
+}
+
+:root[data-theme='dark'] {
+ --bg: #1a1a1a;
+ --fg: #e0e0e0;
+ --border: #444;
--dark: #121212;
--light: #f0f0f0;
--card-bg: #2a2a2a;
--card-fg: #e0e0e0;
+ --modal-bg: rgba(0, 0, 0, 0.7);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme='light']) {
+ --bg: #1a1a1a;
+ --fg: #e0e0e0;
+ --border: #444;
+ --dark: #121212;
+ --light: #f0f0f0;
+ --card-bg: #2a2a2a;
+ --card-fg: #e0e0e0;
+ --modal-bg: rgba(0, 0, 0, 0.7);
+ }
}
* {
@@ -23,10 +48,15 @@ body {
background-color: var(--bg);
color: var(--fg);
line-height: 1.6;
+ transition: background-color 0.3s ease, color 0.3s ease;
+}
+
+body * {
+ transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
}
.container {
- max-width: 1200px;
+ max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
@@ -47,6 +77,7 @@ header h1 {
nav {
display: flex;
gap: 15px;
+ align-items: center;
}
.nav-link {
@@ -66,6 +97,57 @@ nav {
color: white;
}
+#theme-toggle {
+ margin-left: auto;
+}
+
+/* Buttons */
+.btn-primary {
+ background-color: var(--primary);
+ color: white;
+ border: none;
+ padding: 8px 16px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 0.9rem;
+ transition: background-color 0.3s ease;
+}
+
+.btn-primary:hover {
+ background-color: #2980b9;
+}
+
+.btn-secondary {
+ background-color: var(--border);
+ color: var(--fg);
+ border: none;
+ padding: 8px 16px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 0.9rem;
+ transition: background-color 0.3s ease;
+}
+
+.btn-secondary:hover {
+ background-color: #555;
+}
+
+.btn-danger {
+ background-color: var(--danger);
+ color: white;
+ border: none;
+ padding: 8px 16px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 0.9rem;
+ transition: background-color 0.3s ease;
+}
+
+.btn-danger:hover {
+ background-color: #c0392b;
+}
+
+/* ============ TASKS PAGE ============ */
.composer {
background-color: var(--card-bg);
padding: 20px;
@@ -120,7 +202,7 @@ nav {
#board {
display: grid;
- grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
@@ -238,89 +320,302 @@ nav {
margin-right: 10px;
}
-/* Wiki */
+/* ============ WIKI PAGE ============ */
#page-wiki {
background-color: var(--card-bg);
padding: 20px;
border-radius: 12px;
- margin-bottom: 20px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
+.wiki-container {
+ display: grid;
+ grid-template-columns: 280px 1fr;
+ gap: 20px;
+ min-height: 600px;
+}
+
+.wiki-sidebar {
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+}
+
+.wiki-actions {
+ display: flex;
+ gap: 10px;
+}
+
+.wiki-search input {
+ width: 100%;
+ padding: 10px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background-color: var(--bg);
+ color: var(--fg);
+}
+
+.wiki-list {
+ flex: 1;
+ overflow-y: auto;
+ max-height: 500px;
+}
+
.wiki-item {
background-color: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
- padding: 15px;
+ padding: 12px;
margin-bottom: 10px;
cursor: pointer;
- transition: background-color 0.2s ease;
+ transition: all 0.2s ease;
}
.wiki-item:hover {
background-color: var(--dark);
+ border-color: var(--primary);
}
.wiki-item.active {
background-color: var(--primary);
+ border-color: var(--primary);
+}
+
+.wiki-item.active .wiki-title,
+.wiki-item.active .wiki-date {
color: white;
}
.wiki-title {
color: var(--card-fg);
margin-bottom: 5px;
+ font-size: 0.95rem;
}
.wiki-date {
color: var(--border);
- font-size: 0.9rem;
+ font-size: 0.8rem;
}
-#wiki-content {
+.wiki-main {
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+}
+
+.wiki-toolbar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding-bottom: 15px;
+ border-bottom: 1px solid var(--border);
+}
+
+.wiki-page-title {
+ font-size: 1.3rem;
+ font-weight: bold;
+ color: var(--primary);
+}
+
+.wiki-page-actions {
+ display: flex;
+ gap: 10px;
+}
+
+.wiki-content {
background-color: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
- padding: 15px;
- margin-top: 20px;
- white-space: pre-wrap;
- color: var(--fg);
+ padding: 20px;
+ overflow-y: auto;
+ flex: 1;
}
-/* Agents */
+.wiki-content h1, .wiki-content h2, .wiki-content h3 {
+ color: var(--primary);
+ margin-top: 20px;
+ margin-bottom: 10px;
+}
+
+.wiki-content h1:first-child {
+ margin-top: 0;
+}
+
+.wiki-content p {
+ margin-bottom: 15px;
+}
+
+.wiki-content ul, .wiki-content ol {
+ margin-left: 20px;
+ margin-bottom: 15px;
+}
+
+.wiki-content code {
+ background-color: var(--dark);
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-family: 'Consolas', monospace;
+}
+
+.wiki-content pre {
+ background-color: var(--dark);
+ padding: 15px;
+ border-radius: 8px;
+ overflow-x: auto;
+ margin-bottom: 15px;
+}
+
+.wiki-content pre code {
+ padding: 0;
+}
+
+.wiki-content blockquote {
+ border-left: 3px solid var(--primary);
+ padding-left: 15px;
+ margin-left: 0;
+ color: #aaa;
+}
+
+.wiki-placeholder {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ min-height: 300px;
+ color: var(--border);
+ font-size: 1.1rem;
+}
+
+.wiki-editor {
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+ flex: 1;
+}
+
+.wiki-editor input {
+ padding: 12px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background-color: var(--bg);
+ color: var(--fg);
+ font-size: 1.1rem;
+}
+
+.wiki-editor textarea {
+ flex: 1;
+ min-height: 400px;
+ padding: 15px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background-color: var(--bg);
+ color: var(--fg);
+ font-family: 'Consolas', monospace;
+ font-size: 0.95rem;
+ resize: vertical;
+}
+
+.editor-actions {
+ display: flex;
+ gap: 10px;
+}
+
+/* ============ AGENTS PAGE ============ */
#page-agents {
background-color: var(--card-bg);
padding: 20px;
border-radius: 12px;
- margin-bottom: 20px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
+.agents-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 20px;
+}
+
+.agents-header h2 {
+ color: var(--primary);
+}
+
+.agents-controls {
+ display: flex;
+ gap: 15px;
+}
+
+.agents-controls input,
+.agents-controls select {
+ padding: 10px 15px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background-color: var(--bg);
+ color: var(--fg);
+}
+
+.agents-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
+ gap: 20px;
+}
+
.agent-card {
background-color: var(--bg);
border: 1px solid var(--border);
- border-radius: 8px;
+ border-radius: 12px;
padding: 15px;
- margin-bottom: 15px;
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
+}
+
+.agent-card:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.agent-header {
display: flex;
justify-content: space-between;
align-items: center;
- margin-bottom: 15px;
+ margin-bottom: 10px;
}
.agent-name {
color: var(--card-fg);
- font-size: 1.1rem;
+ font-size: 1.2rem;
}
.agent-status {
+ padding: 4px 10px;
+ border-radius: 20px;
+ font-size: 0.8rem;
+ font-weight: bold;
+}
+
+.agent-status.status-active {
background-color: var(--secondary);
color: white;
+}
+
+.agent-status.status-busy {
+ background-color: var(--warning);
+ color: white;
+}
+
+.agent-status.status-idle {
+ background-color: var(--border);
+ color: var(--fg);
+}
+
+.agent-workload {
+ margin-bottom: 15px;
+}
+
+.workload-badge {
+ background-color: var(--primary);
+ color: white;
padding: 4px 8px;
border-radius: 4px;
- font-size: 0.8rem;
+ font-size: 0.85rem;
}
.agent-section {
@@ -330,6 +625,7 @@ nav {
.agent-section h4 {
color: var(--primary);
margin-bottom: 8px;
+ font-size: 0.95rem;
}
.agent-task {
@@ -347,20 +643,243 @@ nav {
.file-tag {
background-color: var(--border);
color: var(--fg);
- padding: 2px 6px;
+ padding: 2px 8px;
border-radius: 4px;
font-size: 0.8rem;
}
-/* Usage */
+.capability-tag {
+ background-color: var(--secondary);
+ color: white;
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-size: 0.8rem;
+}
+
+.more-tag {
+ background-color: var(--dark);
+ color: var(--fg);
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-size: 0.8rem;
+}
+
+.no-data {
+ color: var(--border);
+ font-style: italic;
+}
+
+.agent-actions {
+ display: flex;
+ gap: 10px;
+ padding-top: 15px;
+ border-top: 1px solid var(--border);
+}
+
+.agent-actions button {
+ flex: 1;
+}
+
+/* ============ MODALS ============ */
+.modal {
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: var(--modal-bg);
+ z-index: 1000;
+ justify-content: center;
+ align-items: center;
+}
+
+.modal.active {
+ display: flex;
+}
+
+.modal-content {
+ background-color: var(--card-bg);
+ border-radius: 12px;
+ max-width: 600px;
+ width: 90%;
+ max-height: 80vh;
+ overflow-y: auto;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
+}
+
+.modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 20px;
+ border-bottom: 1px solid var(--border);
+}
+
+.modal-header h3 {
+ color: var(--primary);
+}
+
+.modal-close {
+ background: none;
+ border: none;
+ color: var(--fg);
+ font-size: 1.5rem;
+ cursor: pointer;
+ padding: 0 5px;
+}
+
+.modal-close:hover {
+ color: var(--danger);
+}
+
+.modal-body {
+ padding: 20px;
+}
+
+.detail-section {
+ margin-bottom: 20px;
+}
+
+.detail-section h4 {
+ color: var(--primary);
+ margin-bottom: 10px;
+ font-size: 1rem;
+}
+
+.task-list {
+ list-style: none;
+ padding: 0;
+}
+
+.task-list li {
+ padding: 8px 12px;
+ background-color: var(--bg);
+ border-radius: 6px;
+ margin-bottom: 5px;
+}
+
+.tag-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+#assign-task-select {
+ width: 100%;
+ padding: 12px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background-color: var(--bg);
+ color: var(--fg);
+ margin-bottom: 15px;
+}
+
+#confirm-assign-btn {
+ width: 100%;
+}
+
+/* ============ USAGE PAGE ============ */
#page-usage {
background-color: var(--card-bg);
padding: 20px;
border-radius: 12px;
- margin-bottom: 20px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
+.usage-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 20px;
+ flex-wrap: wrap;
+ gap: 15px;
+}
+
+.usage-header h2 {
+ color: var(--primary);
+}
+
+.usage-controls {
+ display: flex;
+ gap: 20px;
+ flex-wrap: wrap;
+}
+
+.date-range {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.date-range label {
+ font-size: 0.9rem;
+}
+
+.date-range input {
+ padding: 8px 12px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background-color: var(--bg);
+ color: var(--fg);
+}
+
+.export-actions {
+ display: flex;
+ gap: 10px;
+}
+
+.usage-stats {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 20px;
+ margin-bottom: 30px;
+}
+
+.stat-card {
+ background-color: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 20px;
+ text-align: center;
+}
+
+.stat-card h4 {
+ color: var(--border);
+ font-size: 0.9rem;
+ margin-bottom: 10px;
+}
+
+.stat-card .stat-value {
+ color: var(--primary);
+ font-size: 2rem;
+ font-weight: bold;
+}
+
+.usage-charts {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
+ gap: 20px;
+ margin-bottom: 30px;
+}
+
+.chart-container {
+ background-color: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 20px;
+}
+
+.chart-container h4 {
+ color: var(--fg);
+ margin-bottom: 15px;
+}
+
+.usage-details h3 {
+ color: var(--primary);
+ margin-bottom: 20px;
+}
+
.usage-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
@@ -370,27 +889,51 @@ nav {
.provider-card {
background-color: var(--bg);
border: 1px solid var(--border);
- border-radius: 8px;
- padding: 15px;
+ border-radius: 12px;
+ padding: 20px;
}
.provider-card h4 {
color: var(--card-fg);
- margin-bottom: 10px;
+ margin-bottom: 15px;
+ font-size: 1.1rem;
}
-.model-list {
+.provider-stats {
+ display: flex;
+ gap: 20px;
+ margin-bottom: 15px;
+}
+
+.provider-stat {
display: flex;
flex-direction: column;
- gap: 8px;
+}
+
+.provider-stat .stat-label {
+ color: var(--border);
+ font-size: 0.8rem;
+}
+
+.provider-stat .stat-value {
+ color: var(--fg);
+ font-size: 1.1rem;
+ font-weight: bold;
+}
+
+.model-list h5 {
+ color: var(--primary);
+ margin-bottom: 10px;
+ font-size: 0.9rem;
}
.model-item {
display: flex;
justify-content: space-between;
background-color: var(--dark);
- padding: 8px;
- border-radius: 4px;
+ padding: 10px;
+ border-radius: 6px;
+ margin-bottom: 5px;
}
.model-name {
@@ -398,40 +941,26 @@ nav {
font-weight: bold;
}
-.model-type,
-.model-context {
+.model-type {
color: var(--fg);
font-size: 0.9rem;
}
-/* Agent Dropdown Styling */
-#task-form select {
- width: 100%;
- padding: 0.75rem;
- border: 1px solid var(--border);
- border-radius: 8px;
- font-size: 1rem;
- background: var(--bg);
- color: var(--fg);
- cursor: pointer;
- transition: border-color 0.2s ease, box-shadow 0.2s ease;
+/* ============ RESPONSIVE ============ */
+@media (max-width: 900px) {
+ .wiki-container {
+ grid-template-columns: 1fr;
+ }
+
+ .wiki-sidebar {
+ max-height: 200px;
+ }
+
+ .wiki-list {
+ max-height: 150px;
+ }
}
-#task-form select:hover {
- border-color: var(--primary);
-}
-
-#task-form select:focus {
- outline: none;
- border-color: var(--primary);
- box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
-}
-
-#task-form select option {
- padding: 0.5rem;
-}
-
-/* Responsive Design */
@media (max-width: 768px) {
#task-form {
grid-template-columns: 1fr;
@@ -448,4 +977,376 @@ nav {
.usage-grid {
grid-template-columns: 1fr;
}
+
+ .agents-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .agents-header {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .agents-controls {
+ width: 100%;
+ }
+
+ .agents-controls input {
+ flex: 1;
+ }
+
+ .usage-header {
+ flex-direction: column;
+ }
+
+ .usage-controls {
+ width: 100%;
+ flex-direction: column;
+ }
+
+ .date-range {
+ flex-wrap: wrap;
+ }
+
+ .usage-charts {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* ============ SCROLLBAR ============ */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--dark);
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: #555;
+}
+
+/* ============ GITEA DASHBOARD ============ */
+.gitea-dashboard {
+ padding: 20px;
+ max-width: 1400px;
+ margin: 0 auto;
+}
+
+.gitea-header {
+ margin-bottom: 30px;
+ text-align: center;
+}
+
+.gitea-header h2 {
+ font-size: 2em;
+ margin-bottom: 10px;
+}
+
+.gitea-header .subtitle {
+ color: var(--text-secondary);
+ font-size: 1.1em;
+}
+
+.gitea-tabs {
+ display: flex;
+ gap: 10px;
+ margin-bottom: 20px;
+ justify-content: center;
+ flex-wrap: wrap;
+}
+
+.gitea-tabs .tab-btn {
+ padding: 12px 24px;
+ border: 1px solid var(--border-color);
+ background: var(--card-bg);
+ color: var(--text-primary);
+ border-radius: 8px;
+ cursor: pointer;
+ font-size: 1em;
+ transition: all 0.2s;
+}
+
+.gitea-tabs .tab-btn:hover {
+ background: var(--hover-bg);
+}
+
+.gitea-tabs .tab-btn.active {
+ background: var(--primary-color);
+ color: white;
+ border-color: var(--primary-color);
+}
+
+.tab-content {
+ display: none;
+}
+
+.tab-content.active {
+ display: block;
+}
+
+/* Swarm Stats */
+.swarm-stats {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 20px;
+ margin-bottom: 30px;
+}
+
+.stat-card {
+ background: var(--card-bg);
+ border: 1px solid var(--border-color);
+ border-radius: 12px;
+ padding: 20px;
+ text-align: center;
+}
+
+.stat-card h3 {
+ font-size: 0.9em;
+ color: var(--text-secondary);
+ margin-bottom: 10px;
+ text-transform: uppercase;
+}
+
+.stat-value {
+ font-size: 2.5em;
+ font-weight: bold;
+ color: var(--primary-color);
+}
+
+/* Repo List */
+.repo-list {
+ display: grid;
+ gap: 15px;
+}
+
+.repo-card {
+ background: var(--card-bg);
+ border: 1px solid var(--border-color);
+ border-radius: 12px;
+ padding: 20px;
+ transition: transform 0.2s, box-shadow 0.2s;
+}
+
+.repo-card:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+}
+
+.repo-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 15px;
+}
+
+.repo-header h3 {
+ margin: 0;
+}
+
+.repo-header h3 a {
+ color: var(--text-primary);
+ text-decoration: none;
+}
+
+.repo-header h3 a:hover {
+ color: var(--primary-color);
+}
+
+.repo-stats {
+ color: var(--text-secondary);
+ font-size: 0.9em;
+}
+
+.repo-metrics {
+ display: flex;
+ gap: 20px;
+ margin-bottom: 15px;
+}
+
+.metric {
+ color: var(--text-secondary);
+ font-size: 0.95em;
+}
+
+.metric.has-items {
+ color: var(--warning-color);
+ font-weight: 500;
+}
+
+.repo-footer {
+ font-size: 0.85em;
+ color: var(--text-secondary);
+}
+
+/* PR Cards */
+.reviews-list, .activity-feed {
+ display: grid;
+ gap: 15px;
+}
+
+.pr-card {
+ background: var(--card-bg);
+ border: 1px solid var(--border-color);
+ border-radius: 12px;
+ padding: 20px;
+ transition: transform 0.2s;
+}
+
+.pr-card:hover {
+ transform: translateY(-2px);
+}
+
+.pr-card.draft {
+ opacity: 0.8;
+}
+
+.pr-card.conflict {
+ border-color: var(--danger-color);
+}
+
+.pr-header {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 10px;
+}
+
+.pr-repo {
+ color: var(--text-secondary);
+ font-size: 0.9em;
+}
+
+.pr-number {
+ color: var(--primary-color);
+ font-weight: 500;
+}
+
+.pr-title {
+ margin: 0 0 10px 0;
+ font-size: 1.1em;
+}
+
+.pr-title a {
+ color: var(--text-primary);
+ text-decoration: none;
+}
+
+.pr-title a:hover {
+ color: var(--primary-color);
+}
+
+.pr-meta {
+ display: flex;
+ gap: 20px;
+ margin-bottom: 10px;
+ font-size: 0.9em;
+ color: var(--text-secondary);
+}
+
+.pr-labels {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.label {
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 0.85em;
+ color: white;
+}
+
+.badge {
+ display: inline-block;
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 0.8em;
+ margin-left: 10px;
+}
+
+.draft-badge {
+ background: var(--text-secondary);
+}
+
+.conflict-badge {
+ background: var(--danger-color);
+ color: white;
+}
+
+/* Activity Feed */
+.activity-item {
+ display: flex;
+ gap: 15px;
+ padding: 15px;
+ background: var(--card-bg);
+ border: 1px solid var(--border-color);
+ border-radius: 12px;
+}
+
+.activity-icon {
+ font-size: 1.5em;
+ width: 40px;
+ height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--hover-bg);
+ border-radius: 8px;
+}
+
+.activity-content {
+ flex: 1;
+}
+
+.activity-header {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 5px;
+}
+
+.activity-repo {
+ color: var(--primary-color);
+ text-decoration: none;
+ font-weight: 500;
+}
+
+.activity-time {
+ color: var(--text-secondary);
+ font-size: 0.9em;
+}
+
+.activity-desc {
+ color: var(--text-primary);
+}
+
+/* Empty and Error States */
+.empty, .loading, .error {
+ text-align: center;
+ padding: 40px;
+ color: var(--text-secondary);
+}
+
+.error {
+ color: var(--danger-color);
+}
+
+/* Responsive */
+@media (max-width: 768px) {
+ .swarm-stats {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ .gitea-tabs {
+ flex-direction: column;
+ }
+
+ .gitea-tabs .tab-btn {
+ width: 100%;
+ }
}
diff --git a/server.js b/server.js
index 7843c36..5c927b1 100644
--- a/server.js
+++ b/server.js
@@ -4,6 +4,7 @@ const fs = require('fs');
const http = require('http');
const sqlite3 = require('sqlite3').verbose();
const { WebSocketServer } = require('ws');
+const { setupGiteaRoutes } = require('./gitea-routes.js');
const PORT = process.env.PORT || 8395;
const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'data', 'tasks.db');
@@ -937,6 +938,9 @@ wss.on('connection', (socket) => {
socket.send(JSON.stringify({ type: 'connected', payload: { ok: true } }));
});
+// Setup Gitea integration
+setupGiteaRoutes(app, renderPage);
+
server.listen(PORT, '0.0.0.0', () => {
console.log(`openclaw-taskboard listening on ${PORT}`);
});
@@ -946,8 +950,13 @@ const REAL_SESSIONS_DIR = process.env.SESSIONS_DIR || '/app/agents';
const SWARM_TASKS_FILE = process.env.SWARM_TASKS_FILE || '/app/swarm/active-tasks.json';
// GET /api/usage/real - Aggregate usage from session files
+// GET /api/usage/real - Aggregate usage from session files with date filtering
app.get('/api/usage/real', async (req, res) => {
try {
+ const { from, to } = req.query;
+ const fromDate = from ? new Date(from) : null;
+ const toDate = to ? new Date(to) : null;
+
const usageByAgent = {};
const usageByModel = {};
let totalInput = 0, totalOutput = 0, totalCost = 0;
@@ -975,6 +984,14 @@ app.get('/api/usage/real', async (req, res) => {
if (!line.trim()) continue;
try {
const msg = JSON.parse(line);
+
+ // Date filtering
+ if (fromDate || toDate) {
+ const msgDate = new Date(msg.timestamp);
+ if (fromDate && msgDate < fromDate) continue;
+ if (toDate && msgDate > toDate) continue;
+ }
+
if (msg.message?.usage) {
const u = msg.message.usage;
agentInput += u.input || 0;
@@ -1014,6 +1031,7 @@ app.get('/api/usage/real', async (req, res) => {
total: totalInput + totalOutput,
cost: totalCost
},
+ filters: { from, to },
lastUpdated: new Date().toISOString()
});
} catch (err) {
@@ -1022,7 +1040,79 @@ app.get('/api/usage/real', async (req, res) => {
}
});
-// GET /api/swarm/tasks - Get swarm task registry
+// GET /api/usage/export/real - Export real usage data
+app.get('/api/usage/export/real', (req, res) => {
+ const { format = 'json', from, to } = req.query;
+
+ try {
+ const fromDate = from ? new Date(from) : null;
+ const toDate = to ? new Date(to) : null;
+ const usageData = [];
+
+ if (!fs.existsSync(REAL_SESSIONS_DIR)) {
+ return res.status(404).json({ error: 'sessions_dir_not_found' });
+ }
+
+ const agents = fs.readdirSync(REAL_SESSIONS_DIR).filter(d => {
+ return fs.statSync(path.join(REAL_SESSIONS_DIR, d)).isDirectory();
+ });
+
+ for (const agent of agents) {
+ const sessionsDir = path.join(REAL_SESSIONS_DIR, agent, 'sessions');
+ if (!fs.existsSync(sessionsDir)) continue;
+
+ const sessions = fs.readdirSync(sessionsDir).filter(f => f.endsWith('.jsonl'));
+ for (const sessionFile of sessions) {
+ const filePath = path.join(sessionsDir, sessionFile);
+ const lines = fs.readFileSync(filePath, 'utf8').split('\n');
+
+ for (const line of lines) {
+ if (!line.trim()) continue;
+ try {
+ const msg = JSON.parse(line);
+
+ if (fromDate || toDate) {
+ const msgDate = new Date(msg.timestamp);
+ if (fromDate && msgDate < fromDate) continue;
+ if (toDate && msgDate > toDate) continue;
+ }
+
+ if (msg.message?.usage) {
+ usageData.push({
+ timestamp: msg.timestamp,
+ agent,
+ model: msg.message.model || 'unknown',
+ provider: msg.message.provider || 'unknown',
+ input: msg.message.usage.input || 0,
+ output: msg.message.usage.output || 0,
+ total: (msg.message.usage.input || 0) + (msg.message.usage.output || 0),
+ cost: msg.message.usage.cost?.total || 0
+ });
+ }
+ } catch {}
+ }
+ }
+ }
+
+ if (format === 'csv') {
+ const csv = [
+ 'timestamp,agent,model,provider,input,output,total,cost',
+ ...usageData.map(r => `${r.timestamp},${r.agent},${r.model},${r.provider},${r.input},${r.output},${r.total},${r.cost}`)
+ ].join('\n');
+
+ res.setHeader('Content-Type', 'text/csv');
+ res.setHeader('Content-Disposition', 'attachment; filename="usage-export.csv"');
+ res.send(csv);
+ } else {
+ res.setHeader('Content-Type', 'application/json');
+ res.setHeader('Content-Disposition', 'attachment; filename="usage-export.json"');
+ res.json(usageData);
+ }
+ } catch (err) {
+ console.error('Error exporting usage:', err);
+ res.status(500).json({ error: 'failed_to_export_usage' });
+ }
+});
app.get('/api/swarm/tasks', (req, res) => {
try {
if (fs.existsSync(SWARM_TASKS_FILE)) {
diff --git a/server.js.broken b/server.js.broken
new file mode 100644
index 0000000..34aa266
--- /dev/null
+++ b/server.js.broken
@@ -0,0 +1,1165 @@
+const express = require('express');
+const path = require('path');
+const fs = require('fs');
+const http = require('http');
+const sqlite3 = require('sqlite3').verbose();
+const { WebSocketServer } = require('ws');
+
+const PORT = process.env.PORT || 8395;
+const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'data', 'tasks.db');
+const WIKI_DIR = process.env.WIKI_DIR || '/home/bear/.openclaw/workspace/wiki';
+
+const AGENTS_DIR = process.env.AGENTS_DIR || '/home/bear/.openclaw/agents';
+const OPENCLAW_CONFIG = process.env.OPENCLAW_CONFIG || '/home/bear/.openclaw/openclaw.json';
+const VALID_STATUSES = ['Backlog', 'Todo', 'In Progress', 'Review', 'Done'];
+const VALID_PRIORITIES = ['Low', 'Medium', 'High', 'Critical'];
+
+fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
+fs.mkdirSync(WIKI_DIR, { recursive: true });
+
+const db = new sqlite3.Database(DB_PATH);
+
+db.serialize(() => {
+ db.run(`
+ CREATE TABLE IF NOT EXISTS tasks (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ title TEXT NOT NULL,
+ description TEXT DEFAULT '',
+ assignee TEXT DEFAULT '',
+ priority TEXT NOT NULL DEFAULT 'Medium',
+ status TEXT NOT NULL DEFAULT 'Backlog',
+ tags TEXT NOT NULL DEFAULT '[]',
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
+ completed_at TEXT
+ )
+ `);
+
+ // Usage tracking table
+ db.run(`
+ CREATE TABLE IF NOT EXISTS usage_tracking (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ agent TEXT NOT NULL,
+ provider TEXT NOT NULL,
+ model TEXT NOT NULL,
+ request_type TEXT DEFAULT 'chat',
+ tokens_used INTEGER DEFAULT 0,
+ cost_estimate REAL DEFAULT 0,
+ timestamp TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ `);
+});
+
+const app = express();
+const server = http.createServer(app);
+const wss = new WebSocketServer({ server });
+const VIEWS_DIR = path.join(__dirname, 'views');
+
+app.use(express.json());
+app.use(express.static(path.join(__dirname, 'public'), { index: false }));
+
+function renderTemplate(template, vars = {}) {
+ return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
+ const value = vars[key];
+ return value === undefined || value === null ? '' : String(value);
+ });
+}
+
+function renderPage(viewName, activeTab, pageTitle) {
+ const layoutPath = path.join(VIEWS_DIR, 'layout.html');
+ const viewPath = path.join(VIEWS_DIR, `${viewName}.html`);
+ const layout = fs.readFileSync(layoutPath, 'utf8');
+ const content = fs.readFileSync(viewPath, 'utf8');
+
+ return renderTemplate(layout, {
+ pageTitle,
+ pageName: viewName,
+ content,
+ tasksActive: activeTab === 'tasks' ? 'active' : '',
+ wikiActive: activeTab === 'wiki' ? 'active' : '',
+ agentsActive: activeTab === 'agents' ? 'active' : '',
+ usageActive: activeTab === 'usage' ? 'active' : '',
+ giteaActive: activeTab === 'gitea' ? 'active' : '',
+ markedScript: viewName === 'wiki'
+ ? ''
+ : '',
+ chartScript: viewName === 'usage'
+ ? ''
+ : '',
+ });
+}
+
+// ============ SERVER-RENDERED PAGES ============
+
+app.get('/', (req, res) => {
+ res.redirect('/tasks');
+});
+
+app.get('/tasks', (req, res) => {
+ res.send(renderPage('tasks', 'tasks', 'OpenClaw Agent Fleet Dashboard - Tasks'));
+});
+
+app.get('/wiki', (req, res) => {
+ res.send(renderPage('wiki', 'wiki', 'OpenClaw Agent Fleet Dashboard - Wiki'));
+});
+
+app.get('/agents', (req, res) => {
+ res.send(renderPage('agents', 'agents', 'OpenClaw Agent Fleet Dashboard - Agents'));
+});
+
+app.get('/usage', (req, res) => {
+
+app.get('/gitea', (req, res) => {
+ res.send(renderPage('gitea', 'gitea', 'OpenClaw Agent Fleet Dashboard - Gitea'));
+});
+
+app.get('/gitea', (req, res) => {
+ res.send(renderPage('gitea', 'gitea', 'OpenClaw Agent Fleet Dashboard - Gitea'));
+});
+ res.send(renderPage('usage', 'usage', 'OpenClaw Agent Fleet Dashboard - Usage'));
+});
+
+function normalizeTask(row) {
+ return {
+ ...row,
+ tags: (() => {
+ try {
+ return JSON.parse(row.tags || '[]');
+ } catch {
+ return [];
+ }
+ })(),
+ };
+}
+
+function writeWiki(task) {
+ const safeTitle = task.title
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, 80) || `task-${task.id}`;
+
+ const fileName = `${new Date().toISOString().slice(0, 10)}-task-${task.id}-${safeTitle}.md`;
+ const filePath = path.join(WIKI_DIR, fileName);
+
+ const md = `# ${task.title}\n\n` +
+ `- Task ID: ${task.id}\n` +
+ `- Assignee: ${task.assignee || 'Unassigned'}\n` +
+ `- Priority: ${task.priority}\n` +
+ `- Status: ${task.status}\n` +
+ `- Tags: ${task.tags.length ? task.tags.join(', ') : 'None'}\n` +
+ `- Created: ${task.created_at}\n` +
+ `- Completed: ${task.completed_at || new Date().toISOString()}\n\n` +
+ `## Description\n\n${task.description || 'No description provided.'}\n`;
+
+ fs.writeFileSync(filePath, md, 'utf8');
+}
+
+function broadcast(type, payload) {
+ const data = JSON.stringify({ type, payload });
+ for (const client of wss.clients) {
+ if (client.readyState === 1) {
+ client.send(data);
+ }
+ }
+}
+
+function validatePayload(body, partial = false) {
+ const errors = [];
+
+ if (!partial || body.title !== undefined) {
+ if (typeof body.title !== 'string' || body.title.trim().length === 0) {
+ errors.push('title is required');
+ }
+ }
+
+ if (body.status !== undefined && !VALID_STATUSES.includes(body.status)) {
+ errors.push(`status must be one of: ${VALID_STATUSES.join(', ')}`);
+ }
+
+ if (body.priority !== undefined && !VALID_PRIORITIES.includes(body.priority)) {
+ errors.push(`priority must be one of: ${VALID_PRIORITIES.join(', ')}`);
+ }
+
+ if (body.tags !== undefined && !Array.isArray(body.tags)) {
+ errors.push('tags must be an array of strings');
+ }
+
+ return errors;
+}
+
+// ============ TASKS API ============
+
+app.get('/api/tasks', (req, res) => {
+ db.all('SELECT * FROM tasks ORDER BY id DESC', (err, rows) => {
+ if (err) {
+ return res.status(500).json({ error: 'failed_to_fetch_tasks' });
+ }
+
+ return res.json(rows.map(normalizeTask));
+ });
+});
+
+app.post('/api/tasks', (req, res) => {
+ const errors = validatePayload(req.body, false);
+ if (errors.length) {
+ return res.status(400).json({ error: 'validation_error', details: errors });
+ }
+
+ const title = req.body.title.trim();
+ const description = typeof req.body.description === 'string' ? req.body.description : '';
+ const assignee = typeof req.body.assignee === 'string' ? req.body.assignee : '';
+ const priority = req.body.priority || 'Medium';
+ const status = req.body.status || 'Backlog';
+ const tags = Array.isArray(req.body.tags) ? req.body.tags.filter((t) => typeof t === 'string') : [];
+
+ db.run(
+ `INSERT INTO tasks (title, description, assignee, priority, status, tags)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ [title, description, assignee, priority, status, JSON.stringify(tags)],
+ function onInsert(err) {
+ if (err) {
+ return res.status(500).json({ error: 'failed_to_create_task' });
+ }
+
+ db.get('SELECT * FROM tasks WHERE id = ?', [this.lastID], (fetchErr, row) => {
+ if (fetchErr || !row) {
+ return res.status(500).json({ error: 'failed_to_fetch_created_task' });
+ }
+
+ const task = normalizeTask(row);
+ broadcast('task_created', task);
+ return res.status(201).json(task);
+ });
+ }
+ );
+});
+
+app.patch('/api/tasks/:id', (req, res) => {
+ const id = Number(req.params.id);
+ if (!Number.isInteger(id) || id <= 0) {
+ return res.status(400).json({ error: 'invalid_task_id' });
+ }
+
+ const errors = validatePayload(req.body, true);
+ if (errors.length) {
+ return res.status(400).json({ error: 'validation_error', details: errors });
+ }
+
+ db.get('SELECT * FROM tasks WHERE id = ?', [id], (err, existing) => {
+ if (err) {
+ return res.status(500).json({ error: 'failed_to_find_task' });
+ }
+ if (!existing) {
+ return res.status(404).json({ error: 'task_not_found' });
+ }
+
+ const existingTask = normalizeTask(existing);
+ const next = {
+ title: req.body.title !== undefined ? req.body.title.trim() : existingTask.title,
+ description: req.body.description !== undefined ? String(req.body.description) : existingTask.description,
+ assignee: req.body.assignee !== undefined ? String(req.body.assignee) : existingTask.assignee,
+ priority: req.body.priority !== undefined ? req.body.priority : existingTask.priority,
+ status: req.body.status !== undefined ? req.body.status : existingTask.status,
+ tags: req.body.tags !== undefined
+ ? req.body.tags.filter((t) => typeof t === 'string')
+ : existingTask.tags,
+ };
+
+ const nowDone = next.status === 'Done';
+ const wasDone = existingTask.status === 'Done';
+ const completedAt = nowDone && !wasDone
+ ? new Date().toISOString()
+ : nowDone
+ ? existing.completed_at
+ : null;
+
+ db.run(
+ `UPDATE tasks
+ SET title = ?, description = ?, assignee = ?, priority = ?, status = ?, tags = ?,
+ completed_at = ?, updated_at = datetime('now')
+ WHERE id = ?`,
+ [
+ next.title,
+ next.description,
+ next.assignee,
+ next.priority,
+ next.status,
+ JSON.stringify(next.tags),
+ completedAt,
+ id,
+ ],
+ (updateErr) => {
+ if (updateErr) {
+ return res.status(500).json({ error: 'failed_to_update_task' });
+ }
+
+ db.get('SELECT * FROM tasks WHERE id = ?', [id], (fetchErr, row) => {
+ if (fetchErr || !row) {
+ return res.status(500).json({ error: 'failed_to_fetch_updated_task' });
+ }
+
+ const task = normalizeTask(row);
+
+ if (nowDone && !wasDone) {
+ try {
+ writeWiki(task);
+ } catch (wikiErr) {
+ console.error('wiki_creation_error', wikiErr);
+ }
+ }
+
+ broadcast('task_updated', task);
+ return res.json(task);
+ });
+ });
+ });
+ });
+
+// ============ WIKI API ============
+
+// Helper to extract frontmatter metadata from markdown
+function extractMetadata(content) {
+ const metadata = {
+ title: '',
+ created: null,
+ modified: null,
+ tags: []
+ };
+
+ // Check for YAML-like frontmatter
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
+ if (frontmatterMatch) {
+ const frontmatter = frontmatterMatch[1];
+ const titleMatch = frontmatter.match(/title:\s*(.+)/i);
+ const createdMatch = frontmatter.match(/created:\s*(.+)/i);
+ const modifiedMatch = frontmatter.match(/modified:\s*(.+)/i);
+ const tagsMatch = frontmatter.match(/tags:\s*\[(.+)\]/i);
+
+ if (titleMatch) metadata.title = titleMatch[1].trim();
+ if (createdMatch) metadata.created = createdMatch[1].trim();
+ if (modifiedMatch) metadata.modified = modifiedMatch[1].trim();
+ if (tagsMatch) metadata.tags = tagsMatch[1].split(',').map(t => t.trim());
+ }
+
+ // Extract title from first heading if not in frontmatter
+ if (!metadata.title) {
+ const headingMatch = content.match(/^#\s+(.+)$/m);
+ if (headingMatch) {
+ metadata.title = headingMatch[1].trim();
+ }
+ }
+
+ return metadata;
+}
+
+// GET /api/wiki - List all wiki pages
+app.get('/api/wiki', (req, res) => {
+ try {
+ if (!fs.existsSync(WIKI_DIR)) {
+ fs.mkdirSync(WIKI_DIR, { recursive: true });
+ return res.json([]);
+ }
+
+ const files = fs.readdirSync(WIKI_DIR)
+ .filter(f => f.endsWith('.md'))
+ .map(filename => {
+ const filePath = path.join(WIKI_DIR, filename);
+ const stats = fs.statSync(filePath);
+ const content = fs.readFileSync(filePath, 'utf8');
+ const metadata = extractMetadata(content);
+
+ return {
+ filename,
+ title: metadata.title || filename.replace('.md', '').replace(/-/g, ' '),
+ created: stats.birthtime.toISOString(),
+ modified: stats.mtime.toISOString(),
+ tags: metadata.tags
+ };
+ })
+ .sort((a, b) => new Date(b.modified) - new Date(a.modified));
+
+ res.json(files);
+ } catch (err) {
+ console.error('Error listing wiki pages:', err);
+ res.status(500).json({ error: 'failed_to_list_wiki_pages' });
+ }
+});
+
+// GET /api/wiki/:filename - Get specific wiki page content
+app.get('/api/wiki/:filename', (req, res) => {
+ try {
+ const filename = req.params.filename;
+ // Security: prevent path traversal
+ if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
+ return res.status(400).json({ error: 'invalid_filename' });
+ }
+
+ const filePath = path.join(WIKI_DIR, filename);
+
+ if (!fs.existsSync(filePath)) {
+ return res.status(404).json({ error: 'wiki_page_not_found' });
+ }
+
+ const content = fs.readFileSync(filePath, 'utf8');
+ const stats = fs.statSync(filePath);
+ const metadata = extractMetadata(content);
+
+ res.json({
+ filename,
+ content,
+ metadata: {
+ ...metadata,
+ created: stats.birthtime.toISOString(),
+ modified: stats.mtime.toISOString()
+ }
+ });
+ } catch (err) {
+ console.error('Error reading wiki page:', err);
+ res.status(500).json({ error: 'failed_to_read_wiki_page' });
+ }
+});
+
+// POST /api/wiki - Create new wiki page
+app.post('/api/wiki', (req, res) => {
+ try {
+ const { title, content } = req.body;
+
+ if (!title || typeof title !== 'string' || title.trim().length === 0) {
+ return res.status(400).json({ error: 'title_is_required' });
+ }
+
+ const safeTitle = title
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, 80);
+
+ const timestamp = new Date().toISOString().slice(0, 10);
+ let filename = `${timestamp}-${safeTitle}.md`;
+
+ // Ensure unique filename
+ let counter = 1;
+ while (fs.existsSync(path.join(WIKI_DIR, filename))) {
+ filename = `${timestamp}-${safeTitle}-${counter}.md`;
+ counter++;
+ }
+
+ const filePath = path.join(WIKI_DIR, filename);
+ const pageContent = content || `# ${title}\n\n## Description\n\nEnter description here.\n\n## Implementation Status\n\n- [ ] Not started\n\n## Technical Details\n\nAdd technical notes here.\n`;
+
+ fs.writeFileSync(filePath, pageContent, 'utf8');
+
+ broadcast('wiki_created', { filename, title });
+ res.status(201).json({ filename, success: true, title });
+ } catch (err) {
+ console.error('Error creating wiki page:', err);
+ res.status(500).json({ error: 'failed_to_create_wiki_page' });
+ }
+});
+
+// PUT /api/wiki/:filename - Update wiki page
+app.put('/api/wiki/:filename', (req, res) => {
+ try {
+ const filename = req.params.filename;
+ const { content } = req.body;
+
+ // Security: prevent path traversal
+ if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
+ return res.status(400).json({ error: 'invalid_filename' });
+ }
+
+ if (typeof content !== 'string') {
+ return res.status(400).json({ error: 'content_is_required' });
+ }
+
+ const filePath = path.join(WIKI_DIR, filename);
+
+ if (!fs.existsSync(filePath)) {
+ return res.status(404).json({ error: 'wiki_page_not_found' });
+ }
+
+ fs.writeFileSync(filePath, content, 'utf8');
+
+ broadcast('wiki_updated', { filename });
+ res.json({ success: true });
+ } catch (err) {
+ console.error('Error updating wiki page:', err);
+ res.status(500).json({ error: 'failed_to_update_wiki_page' });
+ }
+});
+
+// DELETE /api/wiki/:filename - Delete wiki page
+app.delete('/api/wiki/:filename', (req, res) => {
+ try {
+ const filename = req.params.filename;
+
+ // Security: prevent path traversal
+ if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
+ return res.status(400).json({ error: 'invalid_filename' });
+ }
+
+ const filePath = path.join(WIKI_DIR, filename);
+
+ if (!fs.existsSync(filePath)) {
+ return res.status(404).json({ error: 'wiki_page_not_found' });
+ }
+
+ fs.unlinkSync(filePath);
+
+ broadcast('wiki_deleted', { filename });
+ res.json({ success: true });
+ } catch (err) {
+ console.error('Error deleting wiki page:', err);
+ res.status(500).json({ error: 'failed_to_delete_wiki_page' });
+ }
+});
+
+// ============ AGENTS API (Enhanced) ============
+
+app.get('/api/agents', (req, res) => {
+ try {
+ const agents = [];
+
+ if (fs.existsSync(AGENTS_DIR)) {
+ const agentDirs = fs.readdirSync(AGENTS_DIR).filter(d => {
+ return fs.statSync(path.join(AGENTS_DIR, d)).isDirectory();
+ });
+
+ // Get task counts per agent (workload) and completed tasks (history)
+ const getAgentTaskData = (agentName) => {
+ return new Promise((resolve) => {
+ const result = {
+ workload: 0,
+ activeTasks: [],
+ completedTasks: []
+ };
+
+ // Get workload (tasks in Todo, In Progress, Review)
+ db.all(
+ `SELECT * FROM tasks
+ WHERE assignee = ? AND status IN ('Todo', 'In Progress', 'Review')
+ ORDER BY priority DESC, created_at ASC`,
+ [agentName],
+ (err, activeRows) => {
+ if (!err && activeRows) {
+ result.workload = activeRows.length;
+ result.activeTasks = activeRows.map(normalizeTask);
+ }
+
+ // Get last 5 completed tasks
+ db.all(
+ `SELECT * FROM tasks
+ WHERE assignee = ? AND status = 'Done'
+ ORDER BY completed_at DESC
+ LIMIT 5`,
+ [agentName],
+ (err2, completedRows) => {
+ if (!err2 && completedRows) {
+ result.completedTasks = completedRows.map(normalizeTask);
+ }
+ resolve(result);
+ }
+ );
+ }
+ );
+ });
+ };
+
+ const agentPromises = agentDirs.map(async (agentName) => {
+ const agentPath = path.join(AGENTS_DIR, agentName);
+ const workspacePath = path.join(agentPath, 'workspace');
+
+ const agent = {
+ name: agentName,
+ status: 'active',
+ currentTask: null,
+ tools: [],
+ files: [],
+ permissions: [],
+ workload: 0,
+ activeTasks: [],
+ completedTasks: [],
+ capabilities: []
+ };
+
+ if (fs.existsSync(workspacePath)) {
+ const files = fs.readdirSync(workspacePath);
+ agent.files = files.filter(f => f.endsWith('.md'));
+
+ const memoryPath = path.join(workspacePath, 'MEMORY.md');
+ if (fs.existsSync(memoryPath)) {
+ const memory = fs.readFileSync(memoryPath, 'utf8');
+ const toolMatches = memory.match(/##\s+Tools([\s\S]*?)(?=##|$)/i);
+ if (toolMatches) {
+ agent.tools = toolMatches[1].split('\n')
+ .filter(line => line.trim().startsWith('-'))
+ .map(line => line.replace(/^-\s*/, '').trim());
+ }
+
+ // Extract capabilities/skills
+ const skillsMatch = memory.match(/##\s+Skills([\s\S]*?)(?=##|$)/i);
+ if (skillsMatch) {
+ agent.capabilities = skillsMatch[1].split('\n')
+ .filter(line => line.trim().startsWith('-'))
+ .map(line => line.replace(/^-\s*/, '').trim());
+ }
+ }
+
+ const heartbeatPath = path.join(workspacePath, 'HEARTBEAT.md');
+ if (fs.existsSync(heartbeatPath)) {
+ const heartbeat = fs.readFileSync(heartbeatPath, 'utf8');
+ const taskMatch = heartbeat.match(/Current Task:\s*(.+)/i);
+ if (taskMatch) {
+ agent.currentTask = taskMatch[1].trim();
+ }
+
+ // Check last heartbeat time for status
+ const timeMatch = heartbeat.match(/Last Heartbeat:\s*(.+)/i);
+ if (timeMatch) {
+ const lastBeat = new Date(timeMatch[1]);
+ const now = new Date();
+ const minutesAgo = (now - lastBeat) / 1000 / 60;
+
+ if (minutesAgo > 30) {
+ agent.status = 'idle';
+ } else if (minutesAgo > 10) {
+ agent.status = 'busy';
+ }
+ }
+ }
+ }
+
+ // Get task data from database
+ const taskData = await getAgentTaskData(agentName);
+ agent.workload = taskData.workload;
+ agent.activeTasks = taskData.activeTasks;
+ agent.completedTasks = taskData.completedTasks;
+
+ return agent;
+ });
+
+ Promise.all(agentPromises).then(results => {
+ res.json(results);
+ });
+ } else {
+ res.json([]);
+ }
+ } catch (err) {
+ console.error('Error reading agents:', err);
+ res.status(500).json({ error: 'failed_to_fetch_agents' });
+ }
+});
+
+// POST /api/agents/:name/assign - Assign task to agent
+app.post('/api/agents/:name/assign', (req, res) => {
+ const agentName = req.params.name;
+ const { taskId } = req.body;
+
+ if (!taskId) {
+ return res.status(400).json({ error: 'taskId_is_required' });
+ }
+
+ db.run(
+ 'UPDATE tasks SET assignee = ?, updated_at = datetime("now") WHERE id = ?',
+ [agentName, taskId],
+ function(err) {
+ if (err) {
+ return res.status(500).json({ error: 'failed_to_assign_task' });
+ }
+
+ if (this.changes === 0) {
+ return res.status(404).json({ error: 'task_not_found' });
+ }
+
+ db.get('SELECT * FROM tasks WHERE id = ?', [taskId], (fetchErr, row) => {
+ if (fetchErr || !row) {
+ return res.status(500).json({ error: 'failed_to_fetch_task' });
+ }
+
+ const task = normalizeTask(row);
+ broadcast('task_assigned', { agent: agentName, task });
+ res.json({ success: true, task });
+ });
+ }
+ );
+});
+
+// ============ USAGE API (Enhanced) ============
+
+// GET /api/usage - Basic usage info (existing)
+app.get('/api/usage', (req, res) => {
+ try {
+ const usage = {
+ providers: [],
+ lastUpdated: new Date().toISOString()
+ };
+
+ if (fs.existsSync(OPENCLAW_CONFIG)) {
+ const config = JSON.parse(fs.readFileSync(OPENCLAW_CONFIG, 'utf8'));
+
+ if (config.models) {
+ const providerMap = {};
+
+ Object.entries(config.models).forEach(([modelName, modelConfig]) => {
+ const provider = modelConfig.provider || 'unknown';
+
+ if (!providerMap[provider]) {
+ providerMap[provider] = {
+ name: provider,
+ models: [],
+ quota: {
+ requests: 0,
+ tokens: 0,
+ limit: 'unlimited'
+ }
+ };
+ }
+
+ providerMap[provider].models.push({
+ name: modelName,
+ type: modelConfig.type || 'chat',
+ contextWindow: modelConfig.context_window || 'unknown'
+ });
+ });
+
+ usage.providers = Object.values(providerMap);
+ }
+ }
+
+ res.json(usage);
+ } catch (err) {
+ console.error('Error reading usage:', err);
+ res.status(500).json({ error: 'failed_to_fetch_usage' });
+ }
+});
+
+// GET /api/usage/stats - Usage statistics with date range
+app.get('/api/usage/stats', (req, res) => {
+ const { from, to } = req.query;
+
+ let query = 'SELECT * FROM usage_tracking';
+ const params = [];
+ const conditions = [];
+
+ if (from) {
+ conditions.push('timestamp >= ?');
+ params.push(from);
+ }
+
+ if (to) {
+ conditions.push('timestamp <= ?');
+ params.push(to);
+ }
+
+ if (conditions.length > 0) {
+ query += ' WHERE ' + conditions.join(' AND ');
+ }
+
+ query += ' ORDER BY timestamp DESC';
+
+ db.all(query, params, (err, rows) => {
+ if (err) {
+ console.error('Error fetching usage stats:', err);
+ return res.status(500).json({ error: 'failed_to_fetch_usage_stats' });
+ }
+
+ // Aggregate stats
+ const stats = {
+ totalRequests: rows.length,
+ totalTokens: rows.reduce((sum, r) => sum + (r.tokens_used || 0), 0),
+ totalCost: rows.reduce((sum, r) => sum + (r.cost_estimate || 0), 0),
+ byProvider: {},
+ byAgent: {},
+ byModel: {},
+ records: rows
+ };
+
+ rows.forEach(record => {
+ // By provider
+ if (!stats.byProvider[record.provider]) {
+ stats.byProvider[record.provider] = { requests: 0, tokens: 0, cost: 0 };
+ }
+ stats.byProvider[record.provider].requests++;
+ stats.byProvider[record.provider].tokens += record.tokens_used || 0;
+ stats.byProvider[record.provider].cost += record.cost_estimate || 0;
+
+ // By agent
+ if (!stats.byAgent[record.agent]) {
+ stats.byAgent[record.agent] = { requests: 0, tokens: 0, cost: 0 };
+ }
+ stats.byAgent[record.agent].requests++;
+ stats.byAgent[record.agent].tokens += record.tokens_used || 0;
+ stats.byAgent[record.agent].cost += record.cost_estimate || 0;
+
+ // By model
+ if (!stats.byModel[record.model]) {
+ stats.byModel[record.model] = { requests: 0, tokens: 0, cost: 0 };
+ }
+ stats.byModel[record.model].requests++;
+ stats.byModel[record.model].tokens += record.tokens_used || 0;
+ stats.byModel[record.model].cost += record.cost_estimate || 0;
+ });
+
+ res.json(stats);
+ });
+});
+
+// GET /api/usage/agents - Usage breakdown by agent
+app.get('/api/usage/agents', (req, res) => {
+ const { from, to } = req.query;
+
+ let query = `
+ SELECT agent,
+ COUNT(*) as requests,
+ SUM(tokens_used) as tokens,
+ SUM(cost_estimate) as cost,
+ provider,
+ model
+ FROM usage_tracking
+ `;
+ const params = [];
+ const conditions = [];
+
+ if (from) {
+ conditions.push('timestamp >= ?');
+ params.push(from);
+ }
+
+ if (to) {
+ conditions.push('timestamp <= ?');
+ params.push(to);
+ }
+
+ if (conditions.length > 0) {
+ query += ' WHERE ' + conditions.join(' AND ');
+ }
+
+ query += ' GROUP BY agent ORDER BY requests DESC';
+
+ db.all(query, params, (err, rows) => {
+ if (err) {
+ console.error('Error fetching agent usage:', err);
+ return res.status(500).json({ error: 'failed_to_fetch_agent_usage' });
+ }
+
+ res.json(rows);
+ });
+});
+
+// POST /api/usage/track - Track usage (for external callers)
+app.post('/api/usage/track', (req, res) => {
+ const { agent, provider, model, requestType, tokensUsed, costEstimate } = req.body;
+
+ if (!agent || !provider || !model) {
+ return res.status(400).json({ error: 'agent, provider, and model are required' });
+ }
+
+ db.run(
+ `INSERT INTO usage_tracking (agent, provider, model, request_type, tokens_used, cost_estimate)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ [agent, provider, model, requestType || 'chat', tokensUsed || 0, costEstimate || 0],
+ function(err) {
+ if (err) {
+ console.error('Error tracking usage:', err);
+ return res.status(500).json({ error: 'failed_to_track_usage' });
+ }
+
+ res.status(201).json({ success: true, id: this.lastID });
+ }
+ );
+});
+
+// GET /api/usage/export - Export usage data
+app.get('/api/usage/export', (req, res) => {
+ const { format = 'json', from, to } = req.query;
+
+ let query = 'SELECT * FROM usage_tracking';
+ const params = [];
+ const conditions = [];
+
+ if (from) {
+ conditions.push('timestamp >= ?');
+ params.push(from);
+ }
+
+ if (to) {
+ conditions.push('timestamp <= ?');
+ params.push(to);
+ }
+
+ if (conditions.length > 0) {
+ query += ' WHERE ' + conditions.join(' AND ');
+ }
+
+ query += ' ORDER BY timestamp DESC';
+
+ db.all(query, params, (err, rows) => {
+ if (err) {
+ console.error('Error exporting usage:', err);
+ return res.status(500).json({ error: 'failed_to_export_usage' });
+ }
+
+ if (format === 'csv') {
+ const csv = [
+ 'id,agent,provider,model,request_type,tokens_used,cost_estimate,timestamp',
+ ...rows.map(r => `${r.id},${r.agent},${r.provider},${r.model},${r.request_type},${r.tokens_used},${r.cost_estimate},${r.timestamp}`)
+ ].join('\n');
+
+ res.setHeader('Content-Type', 'text/csv');
+ res.setHeader('Content-Disposition', 'attachment; filename="usage-export.csv"');
+ res.send(csv);
+ } else {
+ res.setHeader('Content-Type', 'application/json');
+ res.setHeader('Content-Disposition', 'attachment; filename="usage-export.json"');
+ res.json(rows);
+ }
+ });
+});
+
+// ============ HEARTBEAT ============
+
+app.get('/api/heartbeat/:agent', (req, res) => {
+ const agent = req.params.agent;
+
+ db.all(
+ 'SELECT * FROM tasks WHERE assignee = ? AND status IN (?, ?, ?) ORDER BY priority DESC, created_at ASC',
+ [agent, 'Todo', 'In Progress', 'Review'],
+ (err, rows) => {
+ if (err) {
+ return res.status(500).json({ error: 'failed_to_fetch_tasks' });
+ }
+
+ const tasks = rows.map(normalizeTask);
+ res.json({
+ agent,
+ pending_tasks: tasks.length,
+ tasks
+ });
+ }
+ );
+});
+
+// ============ WEBSOCKET ============
+
+wss.on('connection', (socket) => {
+ socket.send(JSON.stringify({ type: 'connected', payload: { ok: true } }));
+});
+
+server.listen(PORT, '0.0.0.0', () => {
+ console.log(`openclaw-taskboard listening on ${PORT}`);
+});
+
+// ============ REAL USAGE TRACKING ============
+const REAL_SESSIONS_DIR = process.env.SESSIONS_DIR || '/app/agents';
+const SWARM_TASKS_FILE = process.env.SWARM_TASKS_FILE || '/app/swarm/active-tasks.json';
+
+// GET /api/usage/real - Aggregate usage from session files
+app.get('/api/usage/real', async (req, res) => {
+ try {
+ const usageByAgent = {};
+ const usageByModel = {};
+ let totalInput = 0, totalOutput = 0, totalCost = 0;
+
+ if (!fs.existsSync(REAL_SESSIONS_DIR)) {
+ return res.json({ error: 'sessions_dir_not_found', agents: {}, totals: {} });
+ }
+
+ const agents = fs.readdirSync(REAL_SESSIONS_DIR).filter(d => {
+ return fs.statSync(path.join(REAL_SESSIONS_DIR, d)).isDirectory();
+ });
+
+ for (const agent of agents) {
+ const sessionsDir = path.join(REAL_SESSIONS_DIR, agent, 'sessions');
+ if (!fs.existsSync(sessionsDir)) continue;
+
+ let agentInput = 0, agentOutput = 0, agentCost = 0;
+
+ const sessions = fs.readdirSync(sessionsDir).filter(f => f.endsWith('.jsonl'));
+ for (const sessionFile of sessions) {
+ const filePath = path.join(sessionsDir, sessionFile);
+ const lines = fs.readFileSync(filePath, 'utf8').split('\n');
+
+ for (const line of lines) {
+ if (!line.trim()) continue;
+ try {
+ const msg = JSON.parse(line);
+ if (msg.message?.usage) {
+ const u = msg.message.usage;
+ agentInput += u.input || 0;
+ agentOutput += u.output || 0;
+ agentCost += u.cost?.total || 0;
+
+ // Track by model
+ const model = msg.message.model || 'unknown';
+ if (!usageByModel[model]) {
+ usageByModel[model] = { input: 0, output: 0, requests: 0 };
+ }
+ usageByModel[model].input += u.input || 0;
+ usageByModel[model].output += u.output || 0;
+ usageByModel[model].requests++;
+ }
+ } catch {}
+ }
+ }
+
+ usageByAgent[agent] = {
+ input: agentInput,
+ output: agentOutput,
+ total: agentInput + agentOutput,
+ cost: agentCost
+ };
+
+ totalInput += agentInput;
+ totalOutput += agentOutput;
+ totalCost += agentCost;
+ }
+
+ res.json({
+ agents: usageByAgent,
+ models: usageByModel,
+ totals: {
+ input: totalInput,
+ output: totalOutput,
+ total: totalInput + totalOutput,
+ cost: totalCost
+ },
+ lastUpdated: new Date().toISOString()
+ });
+ } catch (err) {
+ console.error('Error calculating real usage:', err);
+ res.status(500).json({ error: 'failed_to_calculate_usage' });
+ }
+});
+
+// GET /api/swarm/tasks - Get swarm task registry
+app.get('/api/swarm/tasks', (req, res) => {
+ try {
+ if (fs.existsSync(SWARM_TASKS_FILE)) {
+ const data = JSON.parse(fs.readFileSync(SWARM_TASKS_FILE, 'utf8'));
+ res.json(data);
+ } else {
+ res.json({ tasks: [], message: 'swarm_registry_not_found' });
+ }
+ } catch (err) {
+ console.error('Error reading swarm tasks:', err);
+ res.status(500).json({ error: 'failed_to_read_swarm_tasks' });
+ }
+});
+
+// ============ GITEA INTEGRATION ============
+const GiteaIntegration = require('./gitea-integration.js');
+
+// Initialize Gitea client
+const giteaConfig = {
+ baseUrl: process.env.GITEA_URL || 'https://gitea.tophermayor.com',
+ token: process.env.GITEA_TOKEN,
+ owner: 'TopherMayor',
+ cacheTimeout: 30000 // 30 seconds
+};
+
+const gitea = new GiteaIntegration(giteaConfig);
+
+// Gitea API Routes
+
+// Get swarm summary
+app.get('/api/gitea/swarm', async (req, res) => {
+ try {
+ const summary = await gitea.getSwarmSummary();
+ res.json(summary);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+// Get pending reviews
+app.get('/api/gitea/reviews', async (req, res) => {
+ try {
+ const reviews = await gitea.getPendingReviews();
+ res.json(reviews);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+// Get recent activity
+app.get('/api/gitea/activity', async (req, res) => {
+ try {
+ const activity = await gitea.getRecentActivity();
+ res.json(activity);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+// Get specific repository info
+app.get('/api/gitea/repos/:repo', async (req, res) => {
+ try {
+ const repo = await gitea.getRepo(req.params.repo);
+ res.json(repo);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+// Get repository PRs
+app.get('/api/gitea/repos/:repo/pulls', async (req, res) => {
+ try {
+ const state = req.query.state || 'open';
+ const prs = await gitea.getPullRequests(req.params.repo, state);
+ res.json(prs);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+// Get repository issues
+app.get('/api/gitea/repos/:repo/issues', async (req, res) => {
+ try {
+ const state = req.query.state || 'open';
+ const issues = await gitea.getIssues(req.params.repo, state);
+ res.json(issues);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+// Get repository commits
+app.get('/api/gitea/repos/:repo/commits', async (req, res) => {
+ try {
+ const branch = req.query.branch || 'main';
+ const limit = parseInt(req.query.limit) || 10;
+ const commits = await gitea.getCommits(req.params.repo, branch, limit);
+ res.json(commits);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+// Get repository branches
+app.get('/api/gitea/repos/:repo/branches', async (req, res) => {
+ try {
+ const branches = await gitea.getBranches(req.params.repo);
+ res.json(branches);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+// Clear Gitea cache (admin)
+app.post('/api/gitea/cache/clear', (req, res) => {
+ gitea.clearCache();
+ res.json({ success: true, message: 'Cache cleared' });
+});
+
+// Get user info
+app.get('/api/gitea/user', async (req, res) => {
+ try {
+ const user = await gitea.getUser();
+ res.json(user);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+console.log('✅ Gitea integration loaded');
diff --git a/views/gitea.html b/views/gitea.html
new file mode 100644
index 0000000..cb1c263
--- /dev/null
+++ b/views/gitea.html
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Loading repositories...
+
+
+
+
+
+
+
Loading pending reviews...
+
+
+
+
+
+
+
Loading recent activity...
+
+
+
+
diff --git a/views/layout.html b/views/layout.html
index d412aa4..40658a7 100644
--- a/views/layout.html
+++ b/views/layout.html
@@ -1,31 +1,92 @@
-
+
-
-
+
+
{{pageTitle}}
-
- {{markedScript}}
- {{chartScript}}
+
+
+
+
+
+
+
-
-
-
- 🦞 OpenClaw Agent Fleet Dashboard
-
+
+
+
+
+
+
+
+
-
-
- {{content}}
+
+
+
+
+
+
+
+ {{content}}
+
+
+
+
+ {{markedScript}}
+ {{chartScript}}
diff --git a/views/layout.html.backup b/views/layout.html.backup
new file mode 100644
index 0000000..584e732
--- /dev/null
+++ b/views/layout.html.backup
@@ -0,0 +1,33 @@
+
+
+
+
+
+ {{pageTitle}}
+
+
+
+
+ 🦞 OpenClaw Fleet Dashboard
+
+
+
+
+
+
+ {{content}}
+
+
+ {{markedScript}}
+ {{chartScript}}
+
+
+