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.name}

+ + ⭐ ${repo.stars} 🍴 ${repo.forks} + +
+
+ + 🔀 ${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 => ` +
+
+ ${pr.repo} + #${pr.pr_number} +
+

+ ${pr.pr_title} +

+
+ 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.repo} + ${timeAgo(act.created_at)} +
+
+ ${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 page
- -
-
-
-

📚 Select a wiki page from the sidebar or create a new one.

-
-
- -
-
+
+
-
-
-

Agent Fleet

-
- - -
-
-
- - - - - - +
-
-
-

API Usage & Statistics

-
-
- - - - - -
-
- - -
-
-
- -
-
-

Total Requests

-
0
-
-
-

Total Tokens

-
0
-
-
-

Estimated Cost

-
$0.00
-
-
- -
-
-

Usage by Provider

- -
-
-

Usage by Agent

- -
-
- -
-

Provider Details

-
-
+
+ +
+

🔧 Gitea Integration

+
+
+ + + +
+ +
+ +
+
+
+

Total Repos

+
-
+
+
+

Open PRs

+
-
+
+
+

Open Issues

+
-
+
+
+

Total Branches

+
-
+
+
+
+

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 @@ +
+
+

🔧 Gitea Integration

+

Real-time repository and PR tracking

+
+ +
+ + + +
+ +
+ +
+
+
+

Total Repos

+
-
+
+
+

Open PRs

+
-
+
+
+

Open Issues

+
-
+
+
+

Total Branches

+
-
+
+
+ +
+

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

- + + + + + +
+ +
+
+
+
+ 🦞 +
+
+
+

OpenClaw

+

Fleet Dashboard

+
+
+ +
+ +
+
- -
- {{content}} + + + + + +
+
+ {{content}} +
+ + +
+

OpenClaw Fleet Dashboard • v2.0

+
+ {{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}} + + +