Update: General project updates

This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2025-12-06 11:09:12 -03:00
parent 4f5606d898
commit b0929db172
25 changed files with 23775 additions and 1248 deletions

472
AI_IMPLEMENTATION_PROMPT.md Normal file
View file

@ -0,0 +1,472 @@
# AI Implementation Prompt - BotUI Full Product Build
**Budget:** $200,000 USD | **Timeline:** 15 weeks | **Version:** 6.1.0
---
## CONTEXT
You are implementing frontend UI for General Bots Suite. ALL backend APIs already exist in BotServer - your job is purely to wire HTMX-based UI to existing endpoints.
### Tech Stack
- **Templates:** Rust Askama (HTML templates)
- **Interactivity:** HTMX (NO custom JavaScript unless absolutely necessary)
- **Styling:** CSS with CSS variables for theming
- **WebSocket:** htmx-ws extension for real-time features
- **Icons:** Use ONLY existing icons from `ui/suite/assets/icons/`
### Critical Rules
1. **HTMX ONLY** - No fetch(), no axios, no custom JS for API calls
2. **Server-side rendering** - All HTML generated by Rust/Askama
3. **Local assets** - NEVER use CDN links
4. **Zero warnings** - Clean Rust compilation required
5. **6 themes** - Use CSS variables, not hardcoded colors
---
## PHASE 1: CORE APPS ($45,000 - 3 weeks)
### Task 1.1: Paper App (Document Editor)
**Create:** `ui/suite/paper/paper.html`
Wire these endpoints:
```
POST /api/paper/new → Create new document
GET /api/paper/list → List documents
GET /api/paper/search?q= → Search documents
POST /api/paper/save → Save document
POST /api/paper/autosave → Auto-save (5s delay)
GET /api/paper/{id} → Get document
POST /api/paper/{id}/delete → Delete document
POST /api/paper/template/blank → Blank template
POST /api/paper/template/meeting → Meeting notes template
POST /api/paper/template/todo → Todo template
POST /api/paper/template/research→ Research template
POST /api/paper/ai/summarize → AI summarize
POST /api/paper/ai/expand → AI expand text
POST /api/paper/ai/improve → AI improve writing
POST /api/paper/ai/simplify → AI simplify
POST /api/paper/ai/translate → AI translate
POST /api/paper/ai/custom → AI custom prompt
GET /api/paper/export/pdf → Export PDF
GET /api/paper/export/docx → Export DOCX
GET /api/paper/export/md → Export Markdown
GET /api/paper/export/html → Export HTML
GET /api/paper/export/txt → Export Text
```
Structure:
- Sidebar: document list + search
- Main: rich text editor area
- Right panel: AI assistant tools
- Toolbar: save, templates, export
---
### Task 1.2: Research App
**Create:** `ui/suite/research/research.html`
Wire these endpoints:
```
GET /api/research/collections → List collections
POST /api/research/collections/new → Create collection
GET /api/research/collections/{id} → Get collection
POST /api/research/search → Search across sources
GET /api/research/recent → Recent searches
GET /api/research/trending → Trending topics
GET /api/research/prompts → Suggested prompts
GET /api/research/export-citations → Export citations
```
---
### Task 1.3: Sources App
**Create:** `ui/suite/sources/sources.html`
Wire these endpoints:
```
GET /api/sources/prompts → Prompt library
GET /api/sources/templates → Template library
GET /api/sources/news → News feed
GET /api/sources/mcp-servers → MCP server list
GET /api/sources/llm-tools → LLM tools list
GET /api/sources/models → Available models
GET /api/sources/search?q= → Search sources
```
Structure: Tab-based navigation between source types
---
### Task 1.4: Meet App (Video Conferencing)
**Create:** `ui/suite/meet/meet.html`
Wire these endpoints:
```
POST /api/meet/create → Create meeting
GET /api/meet/rooms → List active rooms
GET /api/meet/rooms/{room_id} → Get room details
POST /api/meet/rooms/{room_id}/join → Join room
POST /api/meet/transcription/{room_id} → Start transcription
POST /api/meet/token → Get meeting token
POST /api/meet/invite → Send invites
WS /ws/meet → Real-time meeting
POST /api/voice/start → Start voice
POST /api/voice/stop → Stop voice
```
---
### Task 1.5: Conversations (Enhance Chat)
**Enhance:** `ui/suite/chat/chat.html`
Add these endpoints:
```
POST /conversations/create → Create conversation
POST /conversations/{id}/join → Join
POST /conversations/{id}/leave → Leave
GET /conversations/{id}/members → List members
GET /conversations/{id}/messages → Get messages
POST /conversations/{id}/messages/send → Send message
POST /conversations/{id}/messages/{msg}/edit → Edit message
POST /conversations/{id}/messages/{msg}/delete → Delete message
POST /conversations/{id}/messages/{msg}/react → Add reaction
POST /conversations/{id}/messages/{msg}/pin → Pin message
GET /conversations/{id}/messages/search?q= → Search
POST /conversations/{id}/calls/start → Start call
POST /conversations/{id}/calls/join → Join call
POST /conversations/{id}/calls/leave → Leave call
POST /conversations/{id}/calls/mute → Mute
POST /conversations/{id}/calls/unmute → Unmute
POST /conversations/{id}/screen/share → Share screen
POST /conversations/{id}/screen/stop → Stop share
POST /conversations/{id}/recording/start → Start recording
POST /conversations/{id}/recording/stop → Stop recording
POST /conversations/{id}/whiteboard/create → Create whiteboard
POST /conversations/{id}/whiteboard/collaborate → Collaborate
```
---
### Task 1.6: Drive Enhancement
**Enhance:** `ui/suite/drive/index.html`
Add missing endpoints:
```
POST /files/copy → Copy file
POST /files/move → Move file
GET /files/shared → Shared with me
GET /files/permissions → Get permissions
GET /files/quota → Storage quota
GET /files/sync/status → Sync status
POST /files/sync/start → Start sync
POST /files/sync/stop → Stop sync
GET /files/versions → File versions
POST /files/restore → Restore version
POST /docs/merge → Merge documents
POST /docs/convert → Convert format
POST /docs/fill → Fill template
POST /docs/export → Export document
POST /docs/import → Import document
```
---
### Task 1.7: Calendar Enhancement
**Enhance:** `ui/suite/calendar/calendar.html`
Add missing endpoints:
```
GET /api/calendar/events/{id} → Get event
PUT /api/calendar/events/{id} → Update event
DELETE /api/calendar/events/{id} → Delete event
GET /api/calendar/export.ics → Export iCal
POST /api/calendar/import → Import iCal
```
---
### Task 1.8: Email Enhancement
**Enhance:** `ui/suite/mail/mail.html`
Add missing endpoints:
```
GET /api/email/accounts → List accounts
POST /api/email/accounts/add → Add account
DELETE /api/email/accounts/{id} → Remove account
GET /api/email/compose → Compose form
POST /api/email/send → Send email
POST /api/email/draft → Save draft
GET /api/email/folders/{account_id} → Get folders
GET /api/email/tracking/stats → Tracking stats
```
---
## PHASE 2: ADMIN PANEL ($55,000 - 4 weeks)
### Task 2.1: User Management
**Create:** `ui/suite/admin/users.html`
Wire ALL user endpoints:
```
POST /users/create → Create user
PUT /users/{id}/update → Update user
DELETE /users/{id}/delete → Delete user
GET /users/list → List users
GET /users/search?q= → Search users
GET /users/{id}/profile → Get profile
GET /users/{id}/settings → Get settings
GET /users/{id}/permissions → Get permissions
GET /users/{id}/roles → Get roles
GET /users/{id}/status → Get status
GET /users/{id}/presence → Get presence
GET /users/{id}/activity → Get activity
POST /users/{id}/security/2fa/enable → Enable 2FA
POST /users/{id}/security/2fa/disable → Disable 2FA
GET /users/{id}/security/devices → List devices
GET /users/{id}/security/sessions → List sessions
POST /users/{id}/notifications/preferences/update → Update prefs
```
---
### Task 2.2: Group Management
**Create:** `ui/suite/admin/groups.html`
Wire ALL group endpoints:
```
POST /groups/create → Create group
PUT /groups/{id}/update → Update group
DELETE /groups/{id}/delete → Delete group
GET /groups/list → List groups
GET /groups/search?q= → Search groups
GET /groups/{id}/members → Get members
POST /groups/{id}/members/add → Add member
POST /groups/{id}/members/roles → Set member role
DELETE /groups/{id}/members/remove → Remove member
GET /groups/{id}/permissions → Get permissions
GET /groups/{id}/settings → Get settings
GET /groups/{id}/analytics → Get analytics
POST /groups/{id}/join/request → Request join
POST /groups/{id}/join/approve → Approve join
POST /groups/{id}/join/reject → Reject join
POST /groups/{id}/invites/send → Send invite
GET /groups/{id}/invites/list → List invites
```
---
### Task 2.3: DNS Management
**Create:** `ui/suite/admin/dns.html`
```
POST /api/dns/register → Register hostname
POST /api/dns/remove → Remove hostname
```
---
### Task 2.4: Admin Shell
**Create:** `ui/suite/admin/index.html`
Sidebar navigation to: Dashboard, Users, Groups, Bots, DNS, Audit, Billing
---
## PHASE 3: SETTINGS ($30,000 - 2 weeks)
**Create directory:** `ui/suite/settings/`
Files needed:
- `index.html` - Settings shell with sidebar nav
- `profile.html` - User profile editing
- `security.html` - 2FA, sessions, devices, password change
- `appearance.html` - Theme selection (6 themes)
- `notifications.html` - Email/push/in-app preferences
- `storage.html` - Cloud sync configuration
- `integrations.html` - API keys, webhooks, OAuth connections
- `privacy.html` - Data export, account deletion
- `billing.html` - Subscription management
---
## PHASE 4: MONITORING ($25,000 - 2 weeks)
**Create directory:** `ui/suite/monitoring/`
Files needed:
- `index.html` - Monitoring shell
- `services.html` - Service health grid
- `resources.html` - CPU/Memory/Disk charts
- `logs.html` - Real-time log viewer (WebSocket)
- `metrics.html` - Prometheus metrics display
- `alerts.html` - Alert rule configuration
- `health.html` - Health check endpoints
Key endpoints:
```
GET /api/services/status → Service health
GET /api/analytics/dashboard → Dashboard metrics
GET /api/analytics/metric → Individual metric
GET /metrics → Prometheus export
WS /ws/logs → Real-time logs
```
---
## PHASE 5: AUTH ($25,000 - 2 weeks)
**Enhance:** `ui/suite/auth/`
- `login.html` - Add 2FA challenge flow
- `register.html` - New user registration
- `forgot-password.html` - Password reset request
- `reset-password.html` - Password reset form
Key endpoints:
```
POST /api/auth/login → Login
POST /api/auth/register → Register
POST /api/auth/2fa/verify → Verify 2FA code
POST /api/auth/forgot-password → Request reset
POST /api/auth/reset-password → Reset password
GET /api/auth/oauth/google → Google OAuth
GET /api/auth/oauth/microsoft → Microsoft OAuth
```
---
## PHASE 6: POLISH ($20,000 - 2 weeks)
1. Update `base.html` navigation with all new apps
2. Mobile responsiveness for all views
3. ARIA labels and keyboard navigation
4. Extract strings for i18n preparation
5. Error states and loading indicators
6. Comprehensive testing
---
## HTMX PATTERNS TO USE
### Basic GET
```html
<div hx-get="/api/items" hx-trigger="load" hx-swap="innerHTML"></div>
```
### Search with Debounce
```html
<input type="search" name="q"
hx-get="/api/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#results">
```
### Form Submit
```html
<form hx-post="/api/items" hx-target="#list" hx-swap="beforeend">
<input name="title" required>
<button type="submit">Add</button>
</form>
```
### Delete with Confirm
```html
<button hx-delete="/api/items/{id}"
hx-confirm="Delete this item?"
hx-target="closest tr"
hx-swap="outerHTML">Delete</button>
```
### Polling
```html
<div hx-get="/api/status" hx-trigger="every 10s"></div>
```
### WebSocket
```html
<div hx-ext="ws" ws-connect="/ws/chat">
<div id="messages"></div>
<form ws-send>
<input name="message">
<button>Send</button>
</form>
</div>
```
### Tabs
```html
<nav>
<button hx-get="/tab1" hx-target="#content" class="active">Tab 1</button>
<button hx-get="/tab2" hx-target="#content">Tab 2</button>
</nav>
<div id="content"></div>
```
---
## FILE CREATION ORDER
Week 1-2:
1. `ui/suite/paper/paper.html`
2. `ui/suite/research/research.html`
Week 3-4:
3. `ui/suite/sources/sources.html`
4. `ui/suite/meet/meet.html`
Week 5-6:
5. Enhance `ui/suite/chat/chat.html` (conversations)
6. Enhance `ui/suite/drive/index.html`
7. Enhance `ui/suite/calendar/calendar.html`
8. Enhance `ui/suite/mail/mail.html`
Week 7-8:
9. `ui/suite/admin/index.html`
10. `ui/suite/admin/users.html`
11. `ui/suite/admin/groups.html`
12. `ui/suite/admin/dns.html`
Week 9-10:
13. `ui/suite/settings/index.html`
14. `ui/suite/settings/profile.html`
15. `ui/suite/settings/security.html`
16. `ui/suite/settings/appearance.html`
17. `ui/suite/settings/notifications.html`
18. `ui/suite/settings/storage.html`
19. `ui/suite/settings/integrations.html`
20. `ui/suite/settings/privacy.html`
Week 11-12:
21. `ui/suite/monitoring/index.html`
22. `ui/suite/monitoring/services.html`
23. `ui/suite/monitoring/logs.html`
24. `ui/suite/monitoring/metrics.html`
25. `ui/suite/monitoring/alerts.html`
Week 13-14:
26. `ui/suite/auth/register.html`
27. `ui/suite/auth/forgot-password.html`
28. Enhance `ui/suite/auth/login.html`
Week 15:
29. Update `ui/suite/base.html` navigation
30. Polish and testing
---
## SUCCESS CHECKLIST
- [ ] 100+ API endpoints wired to UI
- [ ] All CRUD operations working
- [ ] Real-time features via WebSocket
- [ ] Mobile responsive
- [ ] Admin panel complete
- [ ] Settings self-service complete
- [ ] Monitoring operational
- [ ] OAuth working (Google, Microsoft)
- [ ] 2FA implemented
- [ ] Zero custom JS where HTMX works
- [ ] All 6 themes working
- [ ] No compilation warnings

1384
IMPLEMENTATION_PLAN.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -5,6 +5,19 @@
---
## Version Management - CRITICAL
**Current version is 6.1.0 - DO NOT CHANGE without explicit approval!**
### Rules
1. **Version is 6.1.0 across ALL workspace crates**
2. **NEVER change version without explicit user approval**
3. **BotUI does not have migrations - all migrations are in botserver/**
4. **All workspace crates share version 6.1.0**
---
## Official Icons - MANDATORY
**NEVER generate icons with LLM. ALWAYS use official SVG icons from:**
@ -363,4 +376,6 @@ ui/minimal/index.html # Minimal chat UI
- **cargo audit**: Must pass with 0 warnings
- **No business logic**: All logic in botserver
- **Feature gates**: Unused code never compiles
- **HTML responses**: Server returns fragments, not JSON
- **HTML responses**: Server returns fragments, not JSON
- **Version**: Always 6.1.0 - do not change without approval
- **Theme system**: Use data-theme attribute on body, 6 themes available

791
ui/suite/admin/dns.html Normal file
View file

@ -0,0 +1,791 @@
<div class="dns-view">
<div class="page-header">
<div class="header-left">
<h1>DNS Management</h1>
<p class="subtitle">Register and manage DNS hostnames for your bots</p>
</div>
<div class="header-actions">
<button class="btn-primary" onclick="document.getElementById('register-dns-modal').showModal()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="16"></line>
<line x1="8" y1="12" x2="16" y2="12"></line>
</svg>
Register Hostname
</button>
</div>
</div>
<!-- DNS Records Table -->
<div class="table-container">
<div class="table-header">
<h2>Registered Hostnames</h2>
<div class="table-actions">
<div class="search-box">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input type="text"
placeholder="Search hostnames..."
name="q"
hx-get="/api/dns/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#dns-table-body"
hx-swap="innerHTML">
</div>
<button class="btn-secondary"
hx-get="/api/dns/list"
hx-target="#dns-table-body"
hx-swap="innerHTML">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"></polyline>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
</svg>
Refresh
</button>
</div>
</div>
<table class="data-table">
<thead>
<tr>
<th>Hostname</th>
<th>Type</th>
<th>Target/IP</th>
<th>TTL</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="dns-table-body"
hx-get="/api/dns/list"
hx-trigger="load"
hx-swap="innerHTML">
<tr>
<td colspan="7" class="loading-cell">
<div class="loading-state">
<div class="spinner"></div>
<p>Loading DNS records...</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- DNS Info Cards -->
<div class="info-section">
<h2>DNS Configuration Help</h2>
<div class="info-grid">
<div class="info-card">
<div class="info-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="2" y1="12" x2="22" y2="12"></line>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
</svg>
</div>
<h3>A Record</h3>
<p>Maps a domain name to an IPv4 address. Use this to point your hostname directly to a server IP.</p>
</div>
<div class="info-card">
<div class="info-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="16 18 22 12 16 6"></polyline>
<polyline points="8 6 2 12 8 18"></polyline>
</svg>
</div>
<h3>AAAA Record</h3>
<p>Maps a domain name to an IPv6 address. Similar to A record but for IPv6 connectivity.</p>
</div>
<div class="info-card">
<div class="info-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
</div>
<h3>CNAME Record</h3>
<p>Creates an alias from one domain to another. Useful for pointing subdomains to your main domain.</p>
</div>
<div class="info-card">
<div class="info-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
</div>
<h3>SSL/TLS</h3>
<p>SSL certificates are automatically provisioned for registered hostnames using Let's Encrypt.</p>
</div>
</div>
</div>
</div>
<!-- Register DNS Modal -->
<dialog id="register-dns-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Register Hostname</h2>
<button type="button" class="close-btn" onclick="document.getElementById('register-dns-modal').close()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<form hx-post="/api/dns/register"
hx-target="#dns-result"
hx-swap="innerHTML"
hx-on::after-request="if(event.detail.successful) { setTimeout(() => { document.getElementById('register-dns-modal').close(); htmx.trigger('#dns-table-body', 'load'); }, 1500); }">
<div class="form-group">
<label></label>Hostname <span class="required">*</span></label>
<input type="text" name="hostname" required placeholder="mybot.example.com">
<p class="help-text">Enter the full domain name you want to register</p>
</div>
<div class="form-row">
<div class="form-group">
<label>Record Type</label>
<select name="record_type" id="record-type" onchange="updateTargetPlaceholder()">
<option value="A">A (IPv4)</option>
<option value="AAAA">AAAA (IPv6)</option>
<option value="CNAME">CNAME</option>
</select>
</div>
<div class="form-group">
<label>TTL (seconds)</label>
<select name="ttl">
<option value="300">5 minutes (300)</option>
<option value="3600" selected>1 hour (3600)</option>
<option value="86400">1 day (86400)</option>
</select>
</div>
</div>
<div class="form-group">
<label>Target/IP Address <span class="required">*</span></label>
<input type="text" name="target" id="target-input" required placeholder="192.168.1.1">
<p class="help-text" id="target-help">Enter the IPv4 address to point to</p>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" name="auto_ssl" checked>
<span>Automatically provision SSL certificate</span>
</label>
</div>
<div id="dns-result"></div>
<div class="modal-footer">
<button type="button" class="btn-secondary" onclick="document.getElementById('register-dns-modal').close()">Cancel</button>
<button type="submit" class="btn-primary">Register Hostname</button>
</div>
</form>
</div>
</dialog>
<!-- Remove DNS Modal -->
<dialog id="remove-dns-modal" class="modal modal-small">
<div class="modal-content">
<div class="modal-header">
<h2>Remove Hostname</h2>
<button type="button" class="close-btn" onclick="document.getElementById('remove-dns-modal').close()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="modal-body">
<div class="warning-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
</div>
<p>Are you sure you want to remove <strong id="remove-hostname-name"></strong>?</p>
<p class="warning-text">This will delete the DNS record and any associated SSL certificates. The hostname will no longer resolve.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn-secondary" onclick="document.getElementById('remove-dns-modal').close()">Cancel</button>
<button type="button" class="btn-danger" id="confirm-remove-btn"
hx-post="/api/dns/remove"
hx-target="#dns-table-body"
hx-swap="innerHTML"
hx-on::after-request="document.getElementById('remove-dns-modal').close(); showToast('Hostname removed');">
Remove Hostname
</button>
</div>
</div>
</dialog>
<!-- Edit DNS Modal -->
<dialog id="edit-dns-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Edit DNS Record</h2>
<button type="button" class="close-btn" onclick="document.getElementById('edit-dns-modal').close()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div id="edit-dns-form-container">
<!-- Form loaded via HTMX -->
</div>
</div>
</dialog>
<style>
/* DNS View */
.dns-view {
max-width: 1200px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
}
.header-left h1 {
font-size: 28px;
font-weight: 700;
margin: 0 0 8px 0;
}
.header-left .subtitle {
color: var(--text-secondary);
margin: 0;
}
/* Table Container */
.table-container {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
margin-bottom: 32px;
}
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.table-header h2 {
font-size: 16px;
font-weight: 600;
margin: 0;
}
.table-actions {
display: flex;
gap: 12px;
}
.search-box {
display: flex;
align-items: center;
gap: 8px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 12px;
}
.search-box svg {
color: var(--text-secondary);
}
.search-box input {
border: none;
background: none;
outline: none;
color: var(--text);
font-size: 14px;
width: 200px;
}
/* Data Table */
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 14px 16px;
text-align: left;
border-bottom: 1px solid var(--border);
}
.data-table th {
background: var(--surface-hover);
font-weight: 600;
font-size: 12px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.data-table tbody tr:hover {
background: var(--surface-hover);
}
.data-table tbody tr:last-child td {
border-bottom: none;
}
.loading-cell {
text-align: center;
}
/* Status Badge */
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
}
.status-badge.active {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
.status-badge.pending {
background: rgba(249, 115, 22, 0.1);
color: #f97316;
}
.status-badge.error {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
/* Record Type Badge */
.type-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
font-family: monospace;
background: var(--surface-hover);
color: var(--text);
}
/* Action Buttons */
.action-buttons {
display: flex;
gap: 8px;
}
.action-btn {
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
}
.action-btn:hover {
background: var(--surface-hover);
color: var(--text);
}
.action-btn.danger:hover {
background: rgba(239, 68, 68, 0.1);
border-color: #ef4444;
color: #ef4444;
}
/* Info Section */
.info-section {
margin-top: 32px;
}
.info-section h2 {
font-size: 18px;
font-weight: 600;
margin: 0 0 16px 0;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
}
.info-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
}
.info-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
}
.info-card h3 {
font-size: 16px;
font-weight: 600;
margin: 0 0 8px 0;
}
.info-card p {
font-size: 14px;
color: var(--text-secondary);
margin: 0;
line-height: 1.5;
}
/* Modal */
.modal {
padding: 0;
border: none;
border-radius: 16px;
background: var(--surface);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
max-width: 520px;
width: 90%;
}
.modal.modal-small {
max-width: 400px;
}
.modal::backdrop {
background: rgba(0, 0, 0, 0.5);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid var(--border);
}
.modal-header h2 {
font-size: 18px;
font-weight: 600;
margin: 0;
}
.modal-body {
padding: 24px;
text-align: center;
}
.warning-icon {
color: #f59e0b;
margin-bottom: 16px;
}
.warning-text {
font-size: 13px;
color: var(--text-secondary);
}
.modal form {
padding: 24px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
}
.form-group label .required {
color: #ef4444;
}
.form-group input,
.form-group select {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--text);
font-size: 14px;
box-sizing: border-box;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--primary);
}
.help-text {
font-size: 12px;
color: var(--text-secondary);
margin-top: 6px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 14px;
}
.checkbox-label input {
width: auto;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid var(--border);
background: var(--surface-hover);
border-radius: 0 0 16px 16px;
}
/* Buttons */
.close-btn {
background: none;
border: none;
padding: 8px;
cursor: pointer;
color: var(--text-secondary);
border-radius: 8px;
}
.close-btn:hover {
background: var(--surface-hover);
color: var(--text);
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
background: var(--primary);
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.btn-secondary:hover {
background: var(--surface-hover);
}
.btn-danger {
padding: 10px 20px;
background: #ef4444;
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.btn-danger:hover {
background: #dc2626;
}
/* Loading */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
color: var(--text-secondary);
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 12px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Success/Error Messages */
.result-message {
padding: 12px 16px;
border-radius: 8px;
margin-top: 16px;
display: flex;
align-items: center;
gap: 10px;
}
.result-message.success {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
border: 1px solid rgba(16, 185, 129, 0.2);
}
.result-message.error {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
border: 1px solid rgba(239, 68, 68, 0.2);
}
/* Responsive */
@media (max-width: 768px) {
.page-header {
flex-direction: column;
gap: 16px;
}
.table-header {
flex-direction: column;
gap: 12px;
align-items: stretch;
}
.table-actions {
flex-direction: column;
}
.search-box input {
width: 100%;
}
.form-row {
grid-template-columns: 1fr;
}
.data-table {
display: block;
overflow-x: auto;
}
.info-grid {
grid-template-columns: 1fr;
}
}
</style>
<script></script>
function updateTargetPlaceholder() {
const recordType = document.getElementById('record-type').value;
const targetInput = document.getElementById('target-input');
const targetHelp = document.getElementById('target-help');
switch (recordType) {
case 'A':
targetInput.placeholder = '192.168.1.1';
targetHelp.textContent = 'Enter the IPv4 address to point to';
break;
case 'AAAA':
targetInput.placeholder = '2001:0db8:85a3:0000:0000:8a2e:0370:7334';
targetHelp.textContent = 'Enter the IPv6 address to point to';
break;
case 'CNAME':
targetInput.placeholder = 'target.example.com';
targetHelp.textContent = 'Enter the target domain name (without trailing dot)';
break;
}
}
function confirmRemoveHostname(hostname) {
document.getElementById('remove-hostname-name').textContent = hostname;
const removeBtn = document.getElementById('confirm-remove-btn');
removeBtn.setAttribute('hx-vals', JSON.stringify({ hostname: hostname }));
htmx.process(removeBtn);
document.getElementById('remove-dns-modal').showModal();
}
function editDnsRecord(hostname) {
htmx.ajax('GET', `/api/dns/edit?hostname=${encodeURIComponent(hostname)}`, {
target: '#edit-dns-form-container'
});
document.getElementById('edit-dns-modal').showModal();
}
function showToast(message) {
const toast = document.createElement('div');
toast.textContent = message;
toast.style.cssText = `
position: fixed;
bottom: 24px;
right: 24px;
background: var(--surface);
border: 1px solid var(--border);
padding: 12px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 1000;
`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
</script>

1096
ui/suite/admin/groups.html Normal file

File diff suppressed because it is too large Load diff

934
ui/suite/admin/index.html Normal file
View file

@ -0,0 +1,934 @@
<div class="admin-layout">
<!-- Sidebar Navigation -->
<aside class="admin-sidebar">
<div class="admin-header">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"></path>
<path d="M2 17l10 5 10-5"></path>
<path d="M2 12l10 5 10-5"></path>
</svg>
<span>Admin Panel</span>
</div>
<nav class="admin-nav">
<a href="#dashboard" class="nav-item active"
hx-get="/api</span>/admin/dashboard"
hx-target="#admin-content"
hx-swap="innerHTML"
hx-push-url="false"
onclick="setActiveNav(this)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="9"></rect>
<rect x="14" y="3" width="7" height="5"></rect>
<rect x="14" y="12" width="7" height="9"></rect>
<rect x="3" y="16" width="7" height="5"></rect>
</svg>
<span>Dashboard</span>
</a>
<a href="#users" class="nav-item"
hx-get="/api/admin/users"
hx-target="#admin-content"
hx-swap="innerHTML"
hx-push-url="false"
onclick="setActiveNav(this)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
<span>Users</span>
</a>
<a href="#groups" class="nav-item"
hx-get="/api/admin/groups"
hx-target="#admin-content"
hx-swap="innerHTML"
hx-push-url="false"
onclick="setActiveNav(this)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<circle cx="19" cy="11" r="2"></circle>
<path d="M23 21v-2a4 4 0 0 0-2-3.46"></path>
</svg>
<span>Groups</span>
</a>
<a href="#bots" class="nav-item"
hx-get="/api/admin/bots"
hx-target="#admin-content"
hx-swap="innerHTML"
hx-push-url="false"
onclick="setActiveNav(this)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="10" rx="2"></rect>
<circle cx="12" cy="5" r="2"></circle>
<path d="M12 7v4"></path>
<line x1="8" y1="16" x2="8" y2="16"></line>
<line x1="16" y1="16" x2="16" y2="16"></line>
</svg>
<span>Bots</span>
</a>
<a href="#dns" class="nav-item"
hx-get="/api/admin/dns"
hx-target="#admin-content"
hx-swap="innerHTML"
hx-push-url="false"
onclick="setActiveNav(this)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="2" y1="12" x2="22" y2="12"></line>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
</svg>
<span>DNS</span>
</a>
<a href="#audit" class="nav-item"
hx-get="/api/admin/audit"
hx-target="#admin-content"
hx-swap="innerHTML"
hx-push-url="false"
onclick="setActiveNav(this)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
<span>Audit Log</span>
</a>
<a href="#billing" class="nav-item"
hx-get="/api/admin/billing"
hx-target="#admin-content"
hx-swap="innerHTML"
hx-push-url="false"
onclick="setActiveNav(this)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="1" y="4" width="22" height="16" rx="2" ry="2"></rect>
<line x1="1" y1="10" x2="23" y2="10"></line>
</svg>
<span>Billing</span>
</a>
</nav>
<div class="admin-footer">
<a href="/suite" class="back-link">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
</svg>
Back to Suite
</a>
</div>
</aside>
<!-- Main Content Area -->
<main class="admin-main">
<div id="admin-content"
hx-get="/api/admin/dashboard"
hx-trigger="load"
hx-swap="innerHTML">
<!-- Dashboard content loaded here -->
<div class="loading-state">
<div class="spinner"></div>
<p>Loading dashboard...</p>
</div>
</div>
</main>
</div>
<!-- Dashboard Template (inline for initial load fallback) -->
<template id="dashboard-template">
<div class="dashboard-view">
<div class="page-header">
<h1>Dashboard</h1>
<p class="subtitle">System overview and quick statistics</p>
</div>
<!-- Stats Grid -->
<div class="stats-grid">
<div class="stat-card"
hx-get="/api/admin/stats/users"
hx-trigger="load, every 30s"
hx-swap="innerHTML">
<div class="stat-icon users">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
</div>
<div class="stat-content">
<span class="stat-value">--</span>
<span class="stat-label">Total Users</span>
</div>
</div>
<div class="stat-card"
hx-get="/api/admin/stats/groups"
hx-trigger="load, every 30s"
hx-swap="innerHTML">
<div class="stat-icon groups">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<circle cx="19" cy="11" r="2"></circle>
</svg>
</div>
<div class="stat-content">
<span class="stat-value">--</span>
<span class="stat-label">Active Groups</span>
</div>
</div>
<div class="stat-card"
hx-get="/api/admin/stats/bots"
hx-trigger="load, every 30s"
hx-swap="innerHTML">
<div class="stat-icon bots">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="10" rx="2"></rect>
<circle cx="12" cy="5" r="2"></circle>
<path d="M12 7v4"></path>
</svg>
</div>
<div class="stat-content">
<span class="stat-value">--</span>
<span class="stat-label">Running Bots</span>
</div>
</div>
<div class="stat-card"
hx-get="/api/admin/stats/storage"
hx-trigger="load, every 30s"
hx-swap="innerHTML">
<div class="stat-icon storage">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
</svg>
</div>
<div class="stat-content">
<span class="stat-value">--</span>
<span class="stat-label">Storage Used</span>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="section">
<h2>Quick Actions</h2>
<div class="quick-actions-grid">
<button class="action-card" onclick="document.getElementById('create-user-modal').showModal()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="8.5" cy="7" r="4"></circle>
<line x1="20" y1="8" x2="20" y2="14"></line>
<line x1="23" y1="11" x2="17" y2="11"></line>
</svg>
<span>Create User</span>
</button>
<button class="action-card" onclick="document.getElementById('create-group-modal').showModal()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<line x1="19" y1="8" x2="19" y2="14"></line>
<line x1="22" y1="11" x2="16" y2="11"></line>
</svg>
<span>Create Group</span>
</button>
<button class="action-card" onclick="document.getElementById('register-dns-modal').showModal()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="16"></line>
<line x1="8" y1="12" x2="16" y2="12"></line>
</svg>
<span>Register DNS</span>
</button>
<button class="action-card"
hx-get="/api/admin/audit?limit=100"
hx-target="#admin-content"
hx-swap="innerHTML">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
<span>View Audit Log</span>
</button>
</div>
</div>
<!-- Recent Activity -->
<div class="section">
<h2>Recent Activity</h2>
<div class="activity-list"
hx-get="/api/admin/activity/recent"
hx-trigger="load, every 60s"
hx-swap="innerHTML">
<div class="loading-state">
<div class="spinner"></div>
</div>
</div>
</div>
<!-- System Health -->
<div class="section">
<h2>System Health</h2>
<div class="health-grid"
hx-get="/api/admin/health"
hx-trigger="load, every 30s"
hx-swap="innerHTML">
<div class="loading-state">
<div class="spinner"></div>
</div>
</div>
</div>
</div>
</template>
<!-- Create User Modal -->
<dialog id="create-user-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Create User</h2>
<button type="button" class="close-btn" onclick="document.getElementById('create-user-modal').close()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<form hx-post="/users/create"
hx-target="#admin-content"
hx-swap="innerHTML"
hx-on::after-request="if(event.detail.successful) document.getElementById('create-user-modal').close()">
<div class="form-group">
<label>Username</label>
<input type="text" name="username" required placeholder="username">
</div>
<div class="form-group">
<label>Email</label>
<input type="email" name="email" required placeholder="user@example.com">
</div>
<div class="form-group">
<label>Display Name</label>
<input type="text" name="display_name" placeholder="John Doe">
</div>
<div class="form-group">
<label>Password</label>
<input type="password" name="password" required placeholder="••••••••">
</div>
<div class="form-group">
<label>Role</label>
<select name="role">
<option value="user">User</option>
<option value="admin">Admin</option>
<option value="moderator">Moderator</option>
</select>
</div>
<div class="modal-footer">
<button type="button" class="btn-secondary" onclick="document.getElementById('create-user-modal').close()">Cancel</button>
<button type="submit" class="btn-primary">Create User</button>
</div>
</form>
</div>
</dialog>
<!-- Create Group Modal -->
<dialog id="create-group-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Create Group</h2>
<button type="button" class="close-btn" onclick="document.getElementById('create-group-modal').close()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<form hx-post="/groups/create"
hx-target="#admin-content"
hx-swap="innerHTML"
hx-on::after-request="if(event.detail.successful) document.getElementById('create-group-modal').close()">
<div class="form-group">
<label>Group Name</label>
<input type="text" name="name" required placeholder="Engineering Team">
</div>
<div class="form-group">
<label>Description</label>
<textarea name="description" placeholder="Group description..." rows="3"></textarea>
</div>
<div class="form-group">
<label>Visibility</label>
<select name="visibility">
<option value="private">Private</option>
<option value="public">Public</option>
<option value="hidden">Hidden</option>
</select>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" name="allow_join_requests" checked>
<span>Allow join requests</span>
</label>
</div>
<div class="modal-footer">
<button type="button" class="btn-secondary" onclick="document.getElementById('create-group-modal').close()">Cancel</button>
<button type="submit" class="btn-primary">Create Group</button>
</div>
</form>
</div>
</dialog>
<!-- Register DNS Modal -->
<dialog id="register-dns-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Register DNS Hostname</h2>
<button type="button" class="close-btn" onclick="document.getElementById('register-dns-modal').close()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<form hx-post="/api/dns/register"
hx-target="#dns-result"
hx-swap="innerHTML">
<div class="form-group">
<label>Hostname</label>
<input type="text" name="hostname" required placeholder="mybot.example.com">
</div>
<div class="form-group">
<label>Record Type</label>
<select name="record_type">
<option value="A">A (IPv4)</option>
<option value="AAAA">AAAA (IPv6)</option>
<option value="CNAME">CNAME</option>
</select>
</div>
<div class="form-group">
<label>Target/IP</label>
<input type="text" name="target" placeholder="192.168.1.1 or target.domain.com">
</div>
<div id="dns-result"></div>
<div class="modal-footer">
<button type="button" class="btn-secondary" onclick="document.getElementById('register-dns-modal').close()">Cancel</button>
<button type="submit" class="btn-primary">Register</button>
</div>
</form>
</div>
</dialog>
<style>
/* Admin Layout */
.admin-layout {
display: grid;
grid-template-columns: 260px 1fr;
min-height: calc(100vh - 56px);
background: var(--bg);
}
/* Sidebar */
.admin-sidebar {
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
position: sticky;
top: 56px;
height: calc(100vh - 56px);
}
.admin-header {
display: flex;
align-items: center;
gap: 12px;
padding: 20px 16px;
border-bottom: 1px solid var(--border);
font-weight: 600;
font-size: 16px;
}
.admin-header svg {
color: var(--primary);
}
.admin-nav {
flex: 1;
padding: 12px 8px;
overflow-y: auto;
}
.admin-nav .nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: 8px;
color: var(--text-secondary);
text-decoration: none;
transition: all 0.2s;
margin-bottom: 4px;
}
.admin-nav .nav-item:hover {
background: var(--surface-hover);
color: var(--text);
}
.admin-nav .nav-item.active {
background: var(--primary);
color: white;
}
.admin-nav .nav-item svg {
flex-shrink: 0;
}
.admin-footer {
padding: 16px;
border-top: 1px solid var(--border);
}
.back-link {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-secondary);
text-decoration: none;
font-size: 14px;
transition: color 0.2s;
}
.back-link:hover {
color: var(--primary);
}
/* Main Content */
.admin-main {
padding: 24px;
overflow-y: auto;
}
/* Dashboard View */
.dashboard-view {
max-width: 1400px;
margin: 0 auto;
}
.page-header {
margin-bottom: 32px;
}
.page-header h1 {
font-size: 28px;
font-weight: 700;
margin: 0 0 8px 0;
}
.page-header .subtitle {
color: var(--text-secondary);
margin: 0;
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 20px;
margin-bottom: 32px;
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.stat-icon.users { background: rgba(59, 130, 246, 0.1); color: #3b82f6; }
.stat-icon.groups { background: rgba(16, 185, 129, 0.1); color: #10b981; }
.stat-icon.bots { background: rgba(168, 85, 247, 0.1); color: #a855f7; }
.stat-icon.storage { background: rgba(249, 115, 22, 0.1); color: #f97316; }
.stat-content {
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 28px;
font-weight: 700;
line-height: 1;
}
.stat-label {
font-size: 14px;
color: var(--text-secondary);
margin-top: 4px;
}
/* Sections */
.section {
margin-bottom: 32px;
}
.section h2 {
font-size: 18px;
font-weight: 600;
margin: 0 0 16px 0;
}
/* Quick Actions Grid */
.quick-actions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
}
.action-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
color: var(--text);
}
.action-card:hover {
border-color: var(--primary);
background: var(--surface-hover);
}
.action-card svg {
color: var(--primary);
}
.action-card span {
font-size: 14px;
font-weight: 500;
}
/* Activity List */
.activity-list {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.activity-item {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-bottom: 1px solid var(--border);
}
.activity-item:last-child {
border-bottom: none;
}
.activity-icon {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--surface-hover);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
}
.activity-content {
flex: 1;
}
.activity-text {
font-size: 14px;
}
.activity-text strong {
font-weight: 600;
}
.activity-time {
font-size: 12px;
color: var(--text-secondary);
}
/* Health Grid */
.health-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.health-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
}
.health-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.health-card-title {
font-size: 14px;
font-weight: 500;
}
.health-status {
width: 10px;
height: 10px;
border-radius: 50%;
}
.health-status.healthy { background: #10b981; }
.health-status.warning { background: #f59e0b; }
.health-status.error { background: #ef4444; }
.health-value {
font-size: 24px;
font-weight: 700;
}
.health-label {
font-size: 12px;
color: var(--text-secondary);
}
/* Modal Styles */
.modal {
padding: 0;
border: none;
border-radius: 16px;
background: var(--surface);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
max-width: 480px;
width: 90%;
}
.modal::backdrop {
background: rgba(0, 0, 0, 0.5);
}
.modal-content {
padding: 0;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid var(--border);
}
.modal-header h2 {
font-size: 18px;
font-weight: 600;
margin: 0;
}
.close-btn {
background: none;
border: none;
padding: 8px;
cursor: pointer;
color: var(--text-secondary);
border-radius: 8px;
transition: all 0.2s;
}
.close-btn:hover {
background: var(--surface-hover);
color: var(--text);
}
.modal form {
padding: 24px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--text);
font-size: 14px;
box-sizing: border-box;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--primary);
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.checkbox-label input {
width: auto;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid var(--border);
background: var(--surface-hover);
border-radius: 0 0 16px 16px;
}
.btn-primary {
padding: 10px 20px;
background: var(--primary);
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-secondary {
padding: 10px 20px;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary:hover {
background: var(--surface-hover);
}
/* Loading State */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
color: var(--text-secondary);
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 12px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Responsive */
@media (max-width: 1024px) {
.admin-layout {
grid-template-columns: 1fr;
}
.admin-sidebar {
position: fixed;
left: -260px;
top: 56px;
z-index: 100;
transition: left 0.3s;
}
.admin-sidebar.open {
left: 0;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
.stats-grid {
grid-template-columns: 1fr;
}
.quick-actions-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
<script>
function setActiveNav(el) {
document.querySelectorAll('.admin-nav .nav-item').forEach(item => {
item.classList.remove('active');
});
el.classList.add('active');
}
// Load dashboard template as fallback
document.addEventListener('htmx:responseError', function(e) {
if (e.detail.target.id === 'admin-content') {
const</script> template = document.getElementById('dashboard-template');
if (template) {
e.detail.target.innerHTML = template.innerHTML;
}
}
});
</script>

897
ui/suite/admin/users.html Normal file
View file

@ -0,0 +1,897 @@
<div class="users-view">
<div class="page-header">
<div class="header-left">
<h1>User Management</h1>
<p class="subtitle">Manage users, roles, and permissions</p>
</div>
<div class="header-actions">
<button class="btn-primary" onclick="document.getElementById('create-user-modal').showModal()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="8.5" cy="7" r="4"></circle>
<line x1="20" y1="8" x2="20" y2="14"></line>
<line x1="23" y1="11" x2="17" y2="11"></line>
</svg>
Add User
</button>
</div>
</div>
<!-- Search and Filters -->
<div class="toolbar">
<div class="search-box">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input type="text"
placeholder="Search users..."
name="q"
hx-get="/users/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#users-table-body"
hx-swap="innerHTML">
</div>
<div class="filters">
<select name="role"
hx-get="/users/list"
hx-trigger="change"
hx-target="#users-table-body"
hx-swap="innerHTML"
hx-include="[name='status']">
<option value="">All Roles</option>
<option value="admin">Admin</option>
<option value="moderator">Moderator</option>
<option value="user">User</option>
</select>
<select name="status"
hx-get="/users/list"
hx-trigger="change"
hx-target="#users-table-body"
hx-swap="innerHTML"
hx-include="[name='role']">
<option value="">All Status</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="suspended">Suspended</option>
</select>
</div>
</div>
<!-- Users Table -->
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>
<input type="checkbox" id="select-all" onchange="toggleSelectAll(this)">
</th>
<th>User</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Last Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="users-table-body"
hx-get="/users/list"
hx-trigger="load"
hx-swap="innerHTML">
<tr>
<td colspan="7" class="loading-cell">
<div class="loading-state">
<div class="spinner"></div>
<p>Loading users...</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="pagination" id="users-pagination"
hx-get="/users/list/pagination"
hx-trigger="load"
hx-swap="innerHTML">
</div>
</div>
<!-- User Detail Panel (slides in from right) -->
<div class="detail-panel" id="user-detail-panel">
<div class="panel-header">
<h2 id="panel-user-name">User Details</h2>
<button class="close-btn" onclick="closeDetailPanel()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="panel-tabs">
<button class="tab-btn active" data-tab="profile" onclick="switchTab(this, 'profile')">Profile</button>
<button class="tab-btn" data-tab="settings" onclick="switchTab(this, 'settings')">Settings</button>
<button class="tab-btn" data-tab="permissions" onclick="switchTab(this, 'permissions')">Permissions</button>
<button class="tab-btn" data-tab="security" onclick="switchTab(this, 'security')">Security</button>
<button class="tab-btn" data-tab="activity" onclick="switchTab(this, 'activity')">Activity</button>
</div>
<div class="panel-content" id="user-detail-content">
<!-- Content loaded via HTMX -->
</div>
</div>
<!-- Create User Modal -->
<dialog id="create-user-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Create New User</h2>
<button type="button" class="close-btn" onclick="document.getElementById('create-user-modal').close()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<form hx-post="/users/create"
hx-target="#users-table-body"
hx-swap="innerHTML"
hx-on::after-request="if(event.detail.successful) { document.getElementById('create-user-modal').close(); showToast('User created successfully'); }">
<div class="form-row">
<div class="form-group">
<label>Username <span class="required">*</span></label>
<input type="text" name="username" required placeholder="johndoe">
</div>
<div class="form-group">
<label>Display Name</label>
<input type="text" name="display_name" placeholder="John Doe">
</div>
</div>
<div class="form-group">
<label>Email <span class="required">*</span></label>
<input type="email" name="email" required placeholder="john@example.com">
</div>
<div class="form-row">
<div class="form-group">
<label>Password <span class="required">*</span></label>
<input type="password" name="password" required placeholder="••••••••" minlength="8">
</div>
<div class="form-group">
<label>Confirm Password <span class="required">*</span></label>
<input type="password" name="password_confirm" required placeholder="••••••••">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Role</label>
<select name="role">
<option value="user">User</option>
<option value="moderator">Moderator</option>
<option value="admin">Admin</option>
</select>
</div>
<div class="form-group">
<label>Status</label>
<select name="status">
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" name="send_welcome_email" checked>
<span>Send welcome email</span>
</label>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" name="require_password_change">
<span>Require password change on first login</span>
</label>
</div>
<div class="modal-footer">
<button type="button" class="btn-secondary" onclick="document.getElementById('create-user-modal').close()">Cancel</button>
<button type="submit" class="btn-primary">Create User</button>
</div>
</form>
</div>
</dialog>
<!-- Edit User Modal -->
<dialog id="edit-user-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Edit User</h2>
<button type="button" class="close-btn" onclick="document.getElementById('edit-user-modal').close()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div id="edit-user-form-container">
<!-- Form loaded via HTMX -->
</div>
</div>
</dialog>
<!-- Delete Confirmation Modal -->
<dialog id="delete-user-modal" class="modal modal-small">
<div class="modal-content">
<div class="modal-header">
<h2>Delete User</h2>
<button type="button" class="close-btn" onclick="document.getElementById('delete-user-modal').close()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="modal-body">
<div class="warning-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
</div>
<p>Are you sure you want to delete <strong id="delete-user-name"></strong>?</p>
<p class="warning-text">This action cannot be undone. All user data will be permanently removed.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn-secondary" onclick="document.getElementById('delete-user-modal').close()">Cancel</button>
<button type="button" class="btn-danger" id="confirm-delete-btn"
hx-delete="/users/{user_id}/delete"
hx-target="#users-table-body"
hx-swap="innerHTML"
hx-on::after-request="document.getElementById('delete-user-modal').close(); showToast('User deleted');">
Delete User
</button>
</div>
</div>
</dialog>
<style>
/* Users View */
.users-view {
max-width: 1400px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
}
.header-left h1 {
font-size: 28px;
font-weight: 700;
margin: 0 0 8px 0;
}
.header-left .subtitle {
color: var(--text-secondary);
margin: 0;
}
.header-actions {
display: flex;
gap: 12px;
}
/* Toolbar */
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
gap: 16px;
flex-wrap: wrap;
}
.search-box {
display: flex;
align-items: center;
gap: 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 14px;
flex: 1;
max-width: 400px;
}
.search-box svg {
color: var(--text-secondary);
flex-shrink: 0;
}
.search-box input {
border: none;
background: none;
outline: none;
color: var(--text);
font-size: 14px;
width: 100%;
}
.filters {
display: flex;
gap: 12px;
}
.filters select {
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
color: var(--text);
font-size: 14px;
cursor: pointer;
}
/* Table */
.table-container {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 14px 16px;
text-align: left;
border-bottom: 1px solid var(--border);
}
.data-table th {
background: var(--surface-hover);
font-weight: 600;
font-size: 13px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.data-table tbody tr:hover {
background: var(--surface-hover);
}
.data-table tbody tr:last-child td {
border-bottom: none;
}
.loading-cell {
text-align: center;
}
/* User Cell */
.user-cell {
display: flex;
align-items: center;
gap: 12px;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 14px;
}
.user-info {
display: flex;
flex-direction: column;
}
.user-name {
font-weight: 500;
}
.user-username {
font-size: 12px;
color: var(--text-secondary);
}
/* Status Badge */
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
}
.status-badge.active {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
.status-badge.inactive {
background: rgba(107, 114, 128, 0.1);
color: #6b7280;
}
.status-badge.suspended {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
/* Role Badge */
.role-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
}
.role-badge.admin {
background: rgba(168, 85, 247, 0.1);
color: #a855f7;
}
.role-badge.moderator {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.role-badge.user {
background: rgba(107, 114, 128, 0.1);
color: #6b7280;
}
/* Action Buttons */
.action-buttons {
display: flex;
gap: 8px;
}
.action-btn {
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
}
.action-btn:hover {
background: var(--surface-hover);
color: var(--text);
}
.action-btn.danger:hover {
background: rgba(239, 68, 68, 0.1);
border-color: #ef4444;
color: #ef4444;
}
/* Pagination */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
margin-top: 20px;
padding: 16px;
}
.page-btn {
padding: 8px 14px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
color: var(--text);
cursor: pointer;
transition: all 0.2s;
}
.page-btn:hover:not(:disabled) {
background: var(--surface-hover);
}
.page-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-btn.active {
background: var(--primary);
border-color: var(--primary);
color: white;
}
/* Detail Panel */
.detail-panel {
position: fixed;
top: 56px;
right: -480px;
width: 480px;
height: calc(100vh - 56px);
background: var(--surface);
border-left: 1px solid var(--border);
z-index: 100;
transition: right 0.3s ease;
display: flex;
flex-direction: column;
}
.detail-panel.open {
right: 0;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid var(--border);
}
.panel-header h2 {
font-size: 18px;
font-weight: 600;
margin: 0;
}
.panel-tabs {
display: flex;
border-bottom: 1px solid var(--border);
padding: 0 20px;
overflow-x: auto;
}
.tab-btn {
padding: 12px 16px;
border: none;
background: none;
color: var(--text-secondary);
font-size: 14px;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s;
white-space: nowrap;
}
.tab-btn:hover {
color: var(--text);
}
.tab-btn.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.panel-content {
flex: 1;
overflow-y: auto;
padding: 20px;
}
/* Modal Styles */
.modal {
padding: 0;
border: none;
border-radius: 16px;
background: var(--surface);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
max-width: 560px;
width: 90%;
}
.modal.modal-small {
max-width: 400px;
}
.modal::backdrop {
background: rgba(0, 0, 0, 0.5);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid var(--border);
}
.modal-header h2 {
font-size: 18px;
font-weight: 600;
margin: 0;
}
.modal-body {
padding: 24px;
text-align: center;
}
.warning-icon {
color: #f59e0b;
margin-bottom: 16px;
}
.warning-text {
font-size: 13px;
color: var(--text-secondary);
}
.modal form {
padding: 24px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
}
.form-group label .required {
color: #ef4444;
}
.form-group input,
.form-group select {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--text);
font-size: 14px;
box-sizing: border-box;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--primary);
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 14px;
}
.checkbox-label input {
width: auto;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid var(--border);
background: var(--surface-hover);
border-radius: 0 0 16px 16px;
}
.close-btn {
background: none;
border: none;
padding: 8px;
cursor: pointer;
color: var(--text-secondary);
border-radius: 8px;
}
.close-btn:hover {
background: var(--surface-hover);
color: var(--text);
}
/* Buttons */
.btn-primary {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
background: var(--primary);
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-secondary {
padding: 10px 20px;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.btn-secondary:hover {
background: var(--surface-hover);
}
.btn-danger {
padding: 10px 20px;
background: #ef4444;
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.btn-danger:hover {
background: #dc2626;
}
/* Loading */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
color: var(--text-secondary);
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 12px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Responsive */
@media (max-width: 768px) {
.page-header {
flex-direction: column;
gap: 16px;
}
.toolbar {
flex-direction: column;
align-items: stretch;
}
.search-box {
max-width: none;
}
.filters {
flex-wrap: wrap;
}
.form-row {
grid-template-columns: 1fr;
}
.detail-panel {
width: 100%;
right: -100%;
}
.data-table {
display: block;
overflow-x: auto;
}
}
</style>
<script></script>
function toggleSelectAll(checkbox) {
const checkboxes = document.querySelectorAll('#users-table-body input[type="checkbox"]');
checkboxes.forEach(cb => cb.checked = checkbox.checked);
}
function openDetailPanel(userId) {
const panel = document.getElementById('user-detail-panel');
panel.classList.add('open');
// Load user profile by default
htmx.ajax('GET', `/users/${userId}/profile`, {target: '#user-detail-content'});
}
function closeDetailPanel() {
document.getElementById('user-detail-panel').classList.remove('open');
}
function switchTab(btn, tab) {
// Update active tab
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Get current user ID from panel
const userId = document.getElementById('user-detail-panel').dataset.userId;
if (userId) {
htmx.ajax('GET', `/users/${userId}/${tab}`, {target: '#user-detail-content'});
}
}
function editUser(userId) {
htmx.ajax('GET', `/users/${userId}/edit`, {target: '#edit-user-form-container'});
document.getElementById('edit-user-modal').showModal();
}
function confirmDeleteUser(userId, userName) {
document.getElementById('delete-user-name').textContent = userName;
const deleteBtn = document.getElementById('confirm-delete-btn');
deleteBtn.setAttribute('hx-delete', `/users/${userId}/delete`);
htmx.process(deleteBtn);
document.getElementById('delete-user-modal').showModal();
}
function showToast(message) {
// Simple toast notification
const toast = document.createElement('div');
toast.className = 'toast';
toast.textContent = message;
toast.style.cssText = `
position: fixed;
bottom: 24px;
right: 24px;
background: var(--surface);
border: 1px solid var(--border);
padding: 12px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 1000;
animation: slideIn 0.3s ease;
`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
</script>

View file

@ -1,4 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19.4 15C19.2669 15.3016 19.2272 15.6362 19.286 15.9606C19.3448 16.285 19.4995 16.5843 19.73 16.82L19.79 16.88C19.976 17.0657 20.1235 17.2863 20.2241 17.5291C20.3248 17.7719 20.3766 18.0322 20.3766 18.295C20.3766 18.5578 20.3248 18.8181 20.2241 19.0609C20.1235 19.3037 19.976 19.5243 19.79 19.71C19.6043 19.896 19.3837 20.0435 19.1409 20.1441C18.8981 20.2448 18.6378 20.2966 18.375 20.2966C18.1122 20.2966 17.8519 20.2448 17.6091 20.1441C17.3663 20.0435 17.1457 19.896 16.96 19.71L16.9 19.65C16.6643 19.4195 16.365 19.2648 16.0406 19.206C15.7162 19.1472 15.3816 19.1869 15.08 19.32C14.7842 19.4468 14.532 19.6572 14.3543 19.9255C14.1766 20.1938 14.0813 20.5082 14.08 20.83V21C14.08 21.5304 13.8693 22.0391 13.4942 22.4142C13.1191 22.7893 12.6104 23 12.08 23C11.5496 23 11.0409 22.7893 10.6658 22.4142C10.2907 22.0391 10.08 21.5304 10.08 21V20.91C10.0723 20.579 9.96512 20.258 9.77251 19.9887C9.5799 19.7194 9.31074 19.5143 9 19.4C8.69838 19.2669 8.36381 19.2272 8.03941 19.286C7.71502 19.3448 7.41568 19.4995 7.18 19.73L7.12 19.79C6.93425 19.976 6.71368 20.1235 6.47088 20.2241C6.22808 20.3248 5.96783 20.3766 5.705 20.3766C5.44217 20.3766 5.18192 20.3248 4.93912 20.2241C4.69632 20.1235 4.47575 19.976 4.29 19.79C4.10405 19.6043 3.95653 19.3837 3.85588 19.1409C3.75523 18.8981 3.70343 18.6378 3.70343 18.375C3.70343 18.1122 3.75523 17.8519 3.85588 17.6091C3.95653 17.3663 4.10405 17.1457 4.29 16.96L4.35 16.9C4.58054 16.6643 4.73519 16.365 4.794 16.0406C4.85282 15.7162 4.81312 15.3816 4.68 15.08C4.55324 14.7842 4.34276 14.532 4.07447 14.3543C3.80618 14.1766 3.49179 14.0813 3.17 14.08H3C2.46957 14.08 1.96086 13.8693 1.58579 13.4942C1.21071 13.1191 1 12.6104 1 12.08C1 11.5496 1.21071 11.0409 1.58579 10.6658C1.96086 10.2907 2.46957 10.08 3 10.08H3.09C3.42099 10.0723 3.742 9.96512 4.0113 9.77251C4.28059 9.5799 4.48572 9.31074 4.6 9C4.73312 8.69838 4.77282 8.36381 4.714 8.03941C4.65519 7.71502 4.50054 7.41568 4.27 7.18L4.21 7.12C4.02405 6.93425 3.87653 6.71368 3.77588 6.47088C3.67523 6.22808 3.62343 5.96783 3.62343 5.705C3.62343 5.44217 3.67523 5.18192 3.77588 4.93912C3.87653 4.69632 4.02405 4.47575 4.21 4.29C4.39575 4.10405 4.61632 3.95653 4.85912 3.85588C5.10192 3.75523 5.36217 3.70343 5.625 3.70343C5.88783 3.70343 6.14808 3.75523 6.39088 3.85588C6.63368 3.95653 6.85425 4.10405 7.04 4.29L7.1 4.35C7.33568 4.58054 7.63502 4.73519 7.95941 4.794C8.28381 4.85282 8.61838 4.81312 8.92 4.68H9C9.29577 4.55324 9.54802 4.34276 9.72569 4.07447C9.90337 3.80618 9.99872 3.49179 10 3.17V3C10 2.46957 10.2107 1.96086 10.5858 1.58579C10.9609 1.21071 11.4696 1 12 1C12.5304 1 13.0391 1.21071 13.4142 1.58579C13.7893 1.96086 14 2.46957 14 3V3.09C14.0013 3.41179 14.0966 3.72618 14.2743 3.99447C14.452 4.26276 14.7042 4.47324 15 4.6C15.3016 4.73312 15.6362 4.77282 15.9606 4.714C16.285 4.65519 16.5843 4.50054 16.82 4.27L16.88 4.21C17.0657 4.02405 17.2863 3.87653 17.5291 3.77588C17.7719 3.67523 18.0322 3.62343 18.295 3.62343C18.5578 3.62343 18.8181 3.67523 19.0609 3.77588C19.3037 3.87653 19.5243 4.02405 19.71 4.21C19.896 4.39575 20.0435 4.61632 20.1441 4.85912C20.2448 5.10192 20.2966 5.36217 20.2966 5.625C20.2966 5.88783 20.2448 6.14808 20.1441 6.39088C20.0435 6.63368 19.896 6.85425 19.71 7.04L19.65 7.1C19.4195 7.33568 19.2648 7.63502 19.206 7.95941C19.1472 8.28381 19.1869 8.61838 19.32 8.92V9C19.4468 9.29577 19.6572 9.54802 19.9255 9.72569C20.1938 9.90337 20.5082 9.99872 20.83 10H21C21.5304 10 22.0391 10.2107 22.4142 10.5858C22.7893 10.9609 23 11.4696 23 12C23 12.5304 22.7893 13.0391 22.4142 13.4142C22.0391 13.7893 21.5304 14 21 14H20.91C20.5882 14.0013 20.2738 14.0966 20.0055 14.2743C19.7372 14.452 19.5268 14.7042 19.4 15Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 974 B

View file

@ -0,0 +1,740 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Forgot Password - General Bots</title>
<script src="/js/vendor/htmx.min.js"></script>
<style>
:root {
--primary: #3b82f6;
--primary-hover: #2563eb;
--primary-light: rgba(59, 130, 246, 0.1);
--bg: #0f172a;
--surface: #1e293b;
--surface-hover: #334155;
--border: #334155;
--text: #f8fafc;
--text-secondary: #94a3b8;
--error: #ef4444;
--success: #22c55e;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-image:
radial-gradient(
ellipse at top,
rgba(59, 130, 246, 0.1) 0%,
transparent 50%
),
radial-gradient(
ellipse at bottom,
rgba(139, 92, 246, 0.1) 0%,
transparent 50%
);
}
.forgot-container {
width: 100%;
max-width: 420px;
padding: 2rem;
}
.forgot-header {
text-align: center;
margin-bottom: 2rem;
}
.forgot-logo {
width: 72px;
height: 72px;
margin: 0 auto 1.25rem;
background: linear-gradient(135deg, var(--primary), #8b5cf6);
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
box-shadow: 0 8px 32px rgba(59, 130, 246, 0.3);
}
.forgot-title {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 0.5rem;
background: linear-gradient(
135deg,
var(--text),
var(--text-secondary)
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.forgot-subtitle {
color: var(--text-secondary);
font-size: 0.9375rem;
line-height: 1.5;
}
.forgot-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
padding: 1.75rem;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2);
}
/* Message Box */
.message-box {
padding: 0.875rem 1rem;
border-radius: 10px;
margin-bottom: 1.25rem;
font-size: 0.875rem;
display: none;
align-items: flex-start;
gap: 0.625rem;
}
.message-box.visible {
display: flex;
}
.message-box.error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: var(--error);
}
.message-box.success {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
color: var(--success);
}
.message-box svg {
flex-shrink: 0;
margin-top: 0.125rem;
}
/* Form Styles */
.form-group {
margin-bottom: 1.5rem;
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
color: var(--text);
}
.input-wrapper {
position: relative;
}
.input-icon {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
color: var(--text-secondary);
pointer-events: none;
}
.form-input {
width: 100%;
padding: 0.875rem 1rem 0.875rem 2.75rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 10px;
color: var(--text);
font-size: 1rem;
transition: all 0.2s ease;
}
.form-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-light);
}
.form-input::placeholder {
color: var(--text-secondary);
}
/* Buttons */
.btn {
width: 100%;
padding: 0.875rem 1.25rem;
border-radius: 10px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
position: relative;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary), #6366f1);
color: white;
border: none;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.4);
}
.btn-primary:active:not(:disabled) {
transform: translateY(0);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-text {
transition: opacity 0.2s ease;
}
.btn.loading .btn-text {
opacity: 0;
}
.btn.loading .spinner {
display: block;
}
.spinner {
display: none;
position: absolute;
width: 22px;
height: 22px;
border: 2px solid transparent;
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Back Link */
.back-link {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-top: 1.5rem;
color: var(--text-secondary);
text-decoration: none;
font-size: 0.9375rem;
transition: color 0.2s ease;
}
.back-link:hover {
color: var(--primary);
}
/* Success Section */
.success-section {
display: none;
text-align: center;
padding: 1rem 0;
}
.success-section.visible {
display: block;
}
.success-icon {
width: 72px;
height: 72px;
margin: 0 auto 1.5rem;
background: rgba(34, 197, 94, 0.1);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: var(--success);
}
.success-title {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 0.75rem;
}
.success-text {
color: var(--text-secondary);
margin-bottom: 1.5rem;
line-height: 1.6;
font-size: 0.9375rem;
}
.success-email {
color: var(--primary);
font-weight: 500;
}
.email-tips {
background: var(--bg);
border-radius: 10px;
padding: 1rem;
margin-top: 1.5rem;
text-align: left;
}
.email-tips-title {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
margin-bottom: 0.625rem;
}
.email-tips-list {
list-style: none;
font-size: 0.8125rem;
color: var(--text-secondary);
}
.email-tips-list li {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.375rem;
}
.email-tips-list li:last-child {
margin-bottom: 0;
}
.email-tips-list svg {
flex-shrink: 0;
color: var(--primary);
}
.resend-section {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
text-align: center;
}
.resend-text {
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: 0.75rem;
}
.resend-btn {
background: none;
border: none;
color: var(--primary);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
padding: 0;
}
.resend-btn:hover {
text-decoration: underline;
}
.resend-btn:disabled {
color: var(--text-secondary);
cursor: not-allowed;
text-decoration: none;
}
/* Footer */
.forgot-footer {
text-align: center;
margin-top: 1.75rem;
font-size: 0.9375rem;
color: var(--text-secondary);
}
.forgot-footer a {
color: var(--primary);
text-decoration: none;
font-weight: 500;
}
.forgot-footer a:hover {
text-decoration: underline;
}
/* Responsive */
@media (max-width: 480px) {
.forgot-container {
padding: 1rem;
}
.forgot-card {
padding: 1.25rem;
}
}
</style>
</head>
<body>
<div class="forgot-container">
<div class="forgot-header">
<div class="forgot-logo">🔑</div>
<h1 class="forgot-title">Forgot Password?</h1>
<p class="forgot-subtitle">
No worries! Enter your email address and we'll send you a
link to reset your password.
</p>
</div>
<div class="forgot-card">
<!-- Request Form Section -->
<div id="request-section">
<div class="message-box error" id="error-message">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10"></circle>
<line x1="15" y1="9" x2="9" y2="15"></line>
<line x1="9" y1="9" x2="15" y2="15"></line>
</svg>
<span id="error-text">An error occurred</span>
</div>
<form
id="forgot-form"
hx-post="/api/auth/forgot-password"
hx-target="#forgot-response"
hx-swap="innerHTML"
hx-indicator="#submit-btn"
>
<div class="form-group">
<label class="form-label" for="email"
>Email Address</label
>
<div class="input-wrapper">
<svg
class="input-icon"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"
></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
<input
type="email"
id="email"
name="email"
class="form-input"
placeholder="you@example.com"
required
autocomplete="email"
/>
</div>
</div>
<button
type="submit"
class="btn btn-primary"
id="submit-btn"
>
<span class="btn-text">Send Reset Link</span>
<div class="spinner"></div>
</button>
</form>
<div id="forgot-response"></div>
<a href="/auth/login" class="back-link">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
</svg>
Back to Login
</a>
</div>
<!-- Success Section -->
<div id="success-section" class="success-section">
<div class="success-icon">
<svg
width="36"
height="36"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"
></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
</div>
<h2 class="success-title">Check Your Email</h2>
<p class="success-text">
We've sent a password reset link to<br />
<span class="success-email" id="success-email"
>your@email.com</span
>
</p>
<div class="email-tips">
<div class="email-tips-title">
Didn't receive the email?
</div>
<ul class="email-tips-list">
<li>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline
points="9 11 12 14 22 4"
></polyline>
<path
d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"
></path>
</svg>
Check your spam or junk folder
</li>
<li>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline
points="9 11 12 14 22 4"
></polyline>
<path
d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"
></path>
</svg>
Make sure you entered the correct email
</li>
<li>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline
points="9 11 12 14 22 4"
></polyline>
<path
d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"
></path>
</svg>
The link expires in 1 hour
</li>
</ul>
</div>
<div class="resend-section">
<p class="resend-text">Still no email?</p>
<button
type="button"
class="resend-btn"
id="resend-btn"
onclick="resendEmail()"
>
Resend reset link
</button>
</div>
<a href="/auth/login" class="back-link">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
</svg>
Back to Login
</a>
</div>
</div>
<div class="forgot-footer">
<p>
Remember your password?
<a href="/auth/login">Sign in</a>
</p>
</div>
</div>
<script>
let submittedEmail = "";
let resendCooldown = 0;
// Show error message
function showError(message) {
const errorBox = document.getElementById("error-message");
const errorText = document.getElementById("error-text");
errorText.textContent = message;
errorBox.classList.add("visible");
}
// Hide error message
function hideError() {
document
.getElementById("error-message")
.classList.remove("visible");
}
// Show success section
function showSuccess(email) {
submittedEmail = email;
document.getElementById("request-section").style.display =
"none";
document
.getElementById("success-section")
.classList.add("visible");
document.getElementById("success-email").textContent = email;
}
// Loading state
function setLoading(loading) {
const btn = document.getElementById("submit-btn");
if (loading) {
btn.classList.add("loading");
btn.disabled = true;
} else {
btn.classList.remove("loading");
btn.disabled = false;
}
}
// Resend email with cooldown
function resendEmail() {
if (resendCooldown > 0) return;
fetch("/api/auth/forgot-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: submittedEmail }),
});
// Start cooldown
resendCooldown = 60;
const resendBtn = document.getElementById("resend-btn");
resendBtn.disabled = true;
const interval = setInterval(() => {
resendCooldown--;
resendBtn.textContent = `Resend reset link (${resendCooldown}s)`;
if (resendCooldown <= 0) {
clearInterval(interval);
resendBtn.textContent = "Resend reset link";
resendBtn.disabled = false;
}
}, 1000);
}
// Handle HTMX events
document.body.addEventListener(
"htmx:beforeRequest",
function (event) {
if (event.target.id === "forgot-form") {
hideError();
setLoading(true);
}
},
);
document.body.addEventListener(
"htmx:afterRequest",
function (event) {
if (event.target.id === "forgot-form") {
setLoading(false);
if (event.detail.successful) {
const email = document.getElementById("email").value;
showSuccess(email);
} else {
try {
const response = JSON.parse(
event.detail.xhr.responseText,
);
showError(
response.error ||
"Failed to send reset link. Please try again.",
);
} catch (e) {
// Even if there's an error, for security we might show success
// to prevent email enumeration
const email = document.getElementById("email").value;
showSuccess(email);
}
}
}
},
);
// Clear error when user starts typing
document.getElementById("email").addEventListener("input", hideError);
</script>
</body>
</html>

File diff suppressed because it is too large Load diff

1322
ui/suite/auth/register.html Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -81,6 +81,34 @@
<!-- Upcoming events loaded here -->
</div>
</div>
<!-- iCal Import/Export -->
<div class="sidebar-section">
<div class="section-header">
<h3>Import / Export</h3>
</div>
<div class="ical-actions">
<a href="/api/calendar/export.ics"
class="btn-secondary ical-btn"
download="calendar.ics">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
Export iCal
</a>
<button class="btn-secondary ical-btn"
onclick="document.getElementById('ical-import-modal').classList.remove('hidden')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
Import iCal
</button>
</div>
</div>
</aside>
<!-- Main Calendar -->
@ -369,6 +397,48 @@
</div>
</div>
<!-- iCal Import Modal -->
<div class="modal hidden" id="ical-import-modal">
<div class="modal-content">
<div class="modal-header">
<h3>Import iCal File</h3>
<button type="button" class="btn-icon close-modal" onclick="document.getElementById('ical-import-modal').classList.add('hidden')">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="modal-body">
<form hx-post="/api/calendar/import"
hx-encoding="multipart/form-data"
hx-target="#import-result"
hx-swap="innerHTML"
hx-on::after-request="if(event.detail.successful) { setTimeout(() => { document.getElementById('ical-import-modal').classList.add('hidden'); location.reload(); }, 1500); }">
<div class="form-group">
<label>Select .ics file</label>
<input type="file" name="file" accept=".ics,.ical,text/calendar" required
style="padding: 12px; border: 2px dashed var(--border); border-radius: 8px; width: 100%; box-sizing: border-box;">
</div>
<div class="form-group">
<label>Import to calendar</label>
<select name="calendar_id"
hx-get="/api/calendar/list"
hx-trigger="load"
hx-swap="innerHTML">
<option value="">Default Calendar</option>
</select>
</div>
<div id="import-result" style="margin-top: 12px;"></div>
<div class="form-actions">
<button type="button" class="btn-secondary" onclick="document.getElementById('ical-import-modal').classList.add('hidden')">Cancel</button>
<button type="submit" class="btn-primary">Import</button>
</div>
</form>
</div>
</div>
</div>
<style>
/* Calendar Container */
.calendar-container {
@ -1303,6 +1373,35 @@
}
/* Responsive */
/* iCal Import/Export */
.ical-actions {
display: flex;
flex-direction: column;
gap: 8px;
}
.ical-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 12px;
font-size: 13px;
text-decoration: none;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.ical-btn:hover {
background: var(--surface-hover);
border-color: var(--primary);
}
.ical-btn svg {
flex-shrink: 0;
}
@media (max-width: 768px) {
.calendar-sidebar {
position: absolute;

View file

@ -94,6 +94,42 @@
<span id="storage-detail">Calculating...</span>
</div>
</div>
<!-- Sync Status Panel -->
<div class="sync-panel">
<div class="storage-header">
<span class="storage-label">Sync Status</span>
<span class="storage-value" id="sync-status"
hx-get="/files/sync/status"
hx-trigger="load, every 10s"
hx-swap="innerHTML">
Checking...
</span>
</div>
<div class="sync-actions" style="display: flex; gap: 8px; margin-top: 8px;">
<button class="btn-secondary" style="flex: 1; padding: 6px 12px; font-size: 12px;"
hx-post="/files/sync/start"
hx-swap="none"
hx-on::after-request="htmx.trigger('#sync-status', 'load')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: middle; margin-right: 4px;">
<polyline points="23 4 23 10 17 10"></polyline>
<polyline points="1 20 1 14 7 14"></polyline>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
</svg>
Start
</button>
<button class="btn-secondary" style="flex: 1; padding: 6px 12px; font-size: 12px;"
hx-post="/files/sync/stop"
hx-swap="none"
hx-on::after-request="htmx.trigger('#sync-status', 'load')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: middle; margin-right: 4px;">
<rect x="6" y="4" width="4" height="16"></rect>
<rect x="14" y="4" width="4" height="16"></rect>
</svg>
Stop
</button>
</div>
</div>
</aside>
<!-- Main Content -->
@ -161,6 +197,15 @@
<option value="type">Type</option>
</select>
<button class="icon-btn" title="Document Tools" onclick="document.getElementById('docs-modal').showModal()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="12" y1="18" x2="12" y2="12"></line>
<line x1="9" y1="15" x2="15" y2="15"></line>
</svg>
</button>
<button class="icon-btn" title="Info" onclick="toggleInfoPanel()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
@ -214,8 +259,16 @@
</svg>
Download
</button>
<button class="action-btn" title="Copy"
onclick="document.getElementById('copy-modal').showModal()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
Copy
</button>
<button class="action-btn" title="Move"
onclick="showMoveModal()">
onclick="document.getElementById('move-modal').showModal()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
</svg>
@ -375,6 +428,300 @@
</div>
</dialog>
<!-- Copy Modal -->
<dialog class="modal" id="copy-modal">
<form class="modal-content"
hx-post="/files/copy"
hx-target="#file-grid"
hx-swap="innerHTML"
hx-on::after-request="document.getElementById('copy-modal').close()">
<div class="modal-header">
<h2>Copy to</h2>
<button type="button" class="close-btn" onclick="document.getElementById('copy-modal').close()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="modal-body">
<input type="hidden" name="source" id="copy-source" value="">
<div class="form-group">
<label for="copy-destination">Destination folder</label>
<select id="copy-destination" name="destination" class="sort-dropdown" style="width: 100%;"
hx-get="/api/drive/folders"
hx-trigger="load"
hx-swap="innerHTML">
<option value="/">/ (Root)</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn-secondary" onclick="document.getElementById('copy-modal').close()">Cancel</button>
<button type="submit" class="btn-primary">Copy</button>
</div>
</form>
</dialog>
<!-- Move Modal -->
<dialog class="modal" id="move-modal">
<form class="modal-content"
hx-post="/files/move"
hx-target="#file-grid"
hx-swap="innerHTML"
hx-on::after-request="document.getElementById('move-modal').close()">
<div class="modal-header">
<h2>Move to</h2>
<button type="button" class="close-btn" onclick="document.getElementById('move-modal').close()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="modal-body">
<input type="hidden" name="source" id="move-source" value="">
<div class="form-group">
<label for="move-destination">Destination folder</label>
<select id="move-destination" name="destination" class="sort-dropdown" style="width: 100%;"
hx-get="/api/drive/folders"
hx-trigger="load"
hx-swap="innerHTML">
<option value="/">/ (Root)</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn-secondary" onclick="document.getElementById('move-modal').close()">Cancel</button>
<button type="submit" class="btn-primary">Move</button>
</div>
</form>
</dialog>
<!-- Permissions Modal -->
<dialog class="modal" id="permissions-modal">
<div class="modal-content">
<div class="modal-header">
<h2>Permissions</h2>
<button type="button" class="close-btn" onclick="document.getElementById('permissions-modal').close()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="modal-body">
<input type="hidden" name="path" id="permissions-path" value="">
<div id="permissions-content"
hx-get="/files/permissions"
hx-trigger="load"
hx-vals="js:{path: document.getElementById('permissions-path').value}"
hx-swap="innerHTML">
<div class="loading-state">
<div class="spinner"></div>
<p>Loading permissions...</p>
</div>
</div>
<div class="form-group" style="margin-top: 16px;">
<label>Share with</label>
<div style="display: flex; gap: 8px;">
<input type="email" id="share-email" placeholder="Enter email address" style="flex: 1;">
<select id="share-permission" class="sort-dropdown">
<option value="read">Can view</option>
<option value="write">Can edit</option>
<option value="admin">Full access</option>
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn-secondary" onclick="document.getElementById('permissions-modal').close()">Close</button>
<button type="button" class="btn-primary"
hx-post="/files/shareFolder"
hx-include="#permissions-path, #share-email, #share-permission"
hx-swap="none"
hx-on::after-request="htmx.trigger('#permissions-content', 'load')">
Share
</button>
</div>
</div>
</dialog>
<!-- Document Processing Modal -->
<dialog class="modal modal-large" id="docs-modal">
<div class="modal-content">
<div class="modal-header">
<h2>Document Tools</h2>
<button type="button" class="close-btn" onclick="document.getElementById('docs-modal').close()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="modal-body">
<div class="docs-tools-grid">
<!-- Merge Documents -->
<div class="docs-tool-card">
<h3>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M8 6h13"></path>
<path d="M8 12h13"></path>
<path d="M8 18h13"></path>
<path d="M3 6h.01"></path>
<path d="M3 12h.01"></path>
<path d="M3 18h.01"></path>
</svg>
Merge Documents
</h3>
<p>Combine multiple documents into one</p>
<form hx-post="/docs/merge"
hx-target="#docs-result"
hx-swap="innerHTML"
hx-encoding="multipart/form-data">
<input type="file" name="files" multiple accept=".pdf,.docx,.doc,.txt" class="form-group" style="margin-bottom: 8px;">
<input type="text" name="output_name" placeholder="Output filename" class="form-group" style="width: 100%; margin-bottom: 8px;">
<button type="submit" class="btn-primary" style="width: 100%;">Merge</button>
</form>
</div>
<!-- Convert Document -->
<div class="docs-tool-card">
<h3>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="17 1 21 5 17 9"></polyline>
<path d="M3 11V9a4 4 0 0 1 4-4h14"></path>
<polyline points="7 23 3 19 7 15"></polyline>
<path d="M21 13v2a4 4 0 0 1-4 4H3"></path>
</svg>
Convert Format
</h3>
<p>Convert between document formats</p>
<form hx-post="/docs/convert"
hx-target="#docs-result"
hx-swap="innerHTML"
hx-encoding="multipart/form-data">
<input type="file" name="file" accept=".pdf,.docx,.doc,.txt,.md,.html" class="form-group" style="margin-bottom: 8px;">
<select name="format" class="sort-dropdown" style="width: 100%; margin-bottom: 8px;">
<option value="pdf">PDF</option>
<option value="docx">Word (DOCX)</option>
<option value="txt">Plain Text</option>
<option value="md">Markdown</option>
<option value="html">HTML</option>
</select>
<button type="submit" class="btn-primary" style="width: 100%;">Convert</button>
</form>
</div>
<!-- Fill Template -->
<div class="docs-tool-card">
<h3>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
</svg>
Fill Template
</h3>
<p>Populate template with data</p>
<form hx-post="/docs/fill"
hx-target="#docs-result"
hx-swap="innerHTML"
hx-encoding="multipart/form-data">
<input type="file" name="template" accept=".docx,.doc" class="form-group" style="margin-bottom: 8px;">
<input type="file" name="data" accept=".json,.csv" class="form-group" style="margin-bottom: 8px;">
<button type="submit" class="btn-primary" style="width: 100%;">Fill</button>
</form>
</div>
<!-- Export Document -->
<div class="docs-tool-card">
<h3>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
Export
</h3>
<p>Export document in specified format</p>
<form hx-post="/docs/export"
hx-target="#docs-result"
hx-swap="innerHTML">
<input type="text" name="path" placeholder="File path (e.g., /documents/report.docx)" class="form-group" style="width: 100%; margin-bottom: 8px;">
<select name="format" class="sort-dropdown" style="width: 100%; margin-bottom: 8px;">
<option value="pdf">PDF</option>
<option value="docx">Word (DOCX)</option>
<option value="txt">Plain Text</option>
</select>
<button type="submit" class="btn-primary" style="width: 100%;">Export</button>
</form>
</div>
<!-- Import Document -->
<div class="docs-tool-card">
<h3>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
Import
</h3>
<p>Import document from URL or upload</p>
<form hx-post="/docs/import"
hx-target="#docs-result"
hx-swap="innerHTML"
hx-encoding="multipart/form-data">
<input type="url" name="url" placeholder="Document URL (optional)" class="form-group" style="width: 100%; margin-bottom: 8px;">
<input type="file" name="file" class="form-group" style="margin-bottom: 8px;">
<input type="text" name="destination" placeholder="Destination folder" value="/" class="form-group" style="width: 100%; margin-bottom: 8px;">
<button type="submit" class="btn-primary" style="width: 100%;">Import</button>
</form>
</div>
</div>
<!-- Result area -->
<div id="docs-result" style="margin-top: 16px;"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn-secondary" onclick="document.getElementById('docs-modal').close()">Close</button>
</div>
</div>
</dialog>
<!-- Versions Modal -->
<dialog class="modal" id="versions-modal">
<div class="modal-content">
<div class="modal-header">
<h2>Version History</h2>
<button type="button" class="close-btn" onclick="document.getElementById('versions-modal').close()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="modal-body">
<input type="hidden" name="path" id="versions-path" value="">
<div id="versions-list"
hx-get="/files/versions"
hx-trigger="load"
hx-vals="js:{path: document.getElementById('versions-path').value}"
hx-swap="innerHTML">
<div class="loading-state">
<div class="spinner"></div>
<p>Loading versions...</p>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn-secondary" onclick="document.getElementById('versions-modal').close()">Close</button>
</div>
</div>
</dialog>
<!-- Context Menu -->
<div class="context-menu" id="context-menu" style="display: none;">
<button class="context-item" data-action="open">
@ -401,7 +748,8 @@
</svg>
Download
</button>
<button class="context-item" data-action="share">
<button class="context-item" data-action="share"
onclick="document.getElementById('context-menu').style.display='none'; document.getElementById('permissions-modal').showModal();">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="18" cy="5" r="3"></circle>
<circle cx="6" cy="12" r="3"></circle>
@ -419,7 +767,16 @@
</svg>
Rename
</button>
<button class="context-item" data-action="move">
<button class="context-item" data-action="copy"
onclick="document.getElementById('context-menu').style.display='none'; document.getElementById('copy-modal').showModal();">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
Copy to
</button>
<button class="context-item" data-action="move"
onclick="document.getElementById('context-menu').style.display='none'; document.getElementById('move-modal').showModal();">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
</svg>
@ -431,6 +788,14 @@
</svg>
Add to starred
</button>
<button class="context-item" data-action="versions"
onclick="document.getElementById('context-menu').style.display='none'; document.getElementById('versions-modal').showModal(); htmx.trigger('#versions-list', 'load');">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
Version history
</button>
<div class="context-divider"></div>
<button class="context-item danger" data-action="delete">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@ -570,6 +935,91 @@
color: var(--text-secondary);
}
/* Document Tools Grid */
.docs-tools-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.docs-tool-card {
background: var(--surface-hover);
border-radius: 12px;
padding: 16px;
border: 1px solid var(--border);
}
.docs-tool-card h3 {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
margin: 0 0 8px 0;
}
.docs-tool-card h3 svg {
color: var(--primary);
}
.docs-tool-card p {
font-size: 12px;
color: var(--text-secondary);
margin: 0 0 12px 0;
}
.docs-tool-card input[type="file"] {
font-size: 12px;
padding: 8px;
border: 1px dashed var(--border);
border-radius: 6px;
background: var(--surface);
width: 100%;
box-sizing: border-box;
}
.docs-tool-card input[type="text"],
.docs-tool-card input[type="url"] {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
color: var(--text);
font-size: 13px;
}
.docs-tool-card input[type="text"]:focus,
.docs-tool-card input[type="url"]:focus {
outline: none;
border-color: var(--primary);
}
/* Sync Panel */
.sync-panel {
padding: 16px;
background: var(--surface-hover);
border-radius: 12px;
margin-top: 12px;
}
.sync-panel .storage-header {
margin-bottom: 8px;
}
.sync-actions button {
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
color: var(--text);
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.sync-actions button:hover {
background: var(--surface-hover);
border-color: var(--primary);
}
/* Main Content */
.drive-main {
display: flex;
@ -1362,4 +1812,121 @@
}
</style>
<script
<script>
// View toggle
function setView(view) {
const grid = document.getElementById('file-grid');
const gridBtn = document.getElementById('grid-view-btn');
const listBtn = document.getElementById('list-view-btn');
if (view === 'list') {
grid.classList.add('list-view');
listBtn.classList.add('active');
gridBtn.classList.remove('active');
} else {
grid.classList.remove('list-view');
gridBtn.classList.add('active');
listBtn.classList.remove('active');
}
}
// Navigation active state
function setActiveNav(el) {
document.querySelectorAll('.nav-item').forEach(item => item.classList.remove('active'));
el.classList.add('active');
}
// Info panel toggle
function toggleInfoPanel() {
const panel = document.getElementById('info-panel');
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
}
// Selection handling
function updateSelection() {
const checked = document.querySelectorAll('.file-checkbox:checked');
const count = checked.length;
const bar = document.getElementById('selection-bar');
const countEl = document.getElementById('selected-count');
countEl.textContent = count;
bar.style.display = count > 0 ? 'flex' : 'none';
// Update hidden inputs for copy/move modals
const paths = Array.from(checked).map(cb => cb.value).join(',');
document.getElementById('copy-source').value = paths;
document.getElementById('move-source').value = paths;
}
function clearSelection() {
document.querySelectorAll('.file-checkbox:checked').forEach(cb => {
cb.checked = false;
});
updateSelection();
}
// File upload handling
function triggerUpload() {
document.getElementById('file-input').click();
}
// Context menu handling
let contextTarget = null;
document.addEventListener('contextmenu', function(e) {
const fileCard = e.target.closest('.file-card');
if (fileCard) {
e.preventDefault();
contextTarget = fileCard;
const menu = document.getElementById('context-menu');
menu.style.display = 'block';
menu.style.left = e.pageX + 'px';
menu.style.top = e.pageY + 'px';
// Update copy/move/permissions/versions source for single file
const filePath = fileCard.dataset.path || '';
document.getElementById('copy-source').value = filePath;
document.getElementById('move-source').value = filePath;
document.getElementById('permissions-path').value = filePath;
document.getElementById('versions-path').value = filePath;
}
});
document.addEventListener('click', function(e) {
const menu = document.getElementById('context-menu');
if (!menu.contains(e.target)) {
menu.style.display = 'none';
}
});
// Drag and drop
const dropOverlay = document.getElementById('drop-overlay');
document.addEventListener('dragenter', function(e) {
e.preventDefault();
dropOverlay.classList.add('visible');
});
dropOverlay.addEventListener('dragleave', function(e) {
if (e.target === dropOverlay) {
dropOverlay.classList.remove('visible');
}
});
dropOverlay.addEventListener('drop', function(e) {
e.preventDefault();
dropOverlay.classList.remove('visible');
// Handle file drop via HTMX
});
dropOverlay.addEventListener('dragover', function(e) {
e.preventDefault();
});
// Listen for checkbox changes
document.addEventListener('change', function(e) {
if (e.target.classList.contains('file-checkbox')) {
updateSelection();
}
});
</script>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,994 @@
<div class="health-container">
<!-- Health Overview -->
<div class="health-overview"
hx-get="/api/monitoring/health/overview"
hx-trigger="load, every 10s"
hx-swap="innerHTML">
<div class="health-status healthy">
<div class="status-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
<polyline points="22 4 12 14.01 9 11.01"></polyline>
</svg>
</div>
<div class="status-info">
<h2>All Systems Operational</h2>
<p>All health checks are passing</p>
</div>
<div class="status-badge healthy">Healthy</div>
</div>
</div>
<!-- Uptime Stats -->
<div class="uptime-stats">
<div class="stat-card"
hx-get="/api/monitoring/health/uptime"
hx-trigger="load, every 60s"
hx-swap="innerHTML">
<div class="stat-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
</div>
<div class="stat-content">
<span class="stat-value">--</span>
<span class="stat-label">Uptime</span>
</div>
</div>
<div class="stat-card"
hx-get="/api/monitoring/health/uptime-percent"
hx-trigger="load, every 60s"
hx-swap="innerHTML">
<div class="stat-icon success">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline>
</svg>
</div>
<div class="stat-content">
<span class="stat-value">--%</span>
<span class="stat-label">Uptime (30 days)</span>
</div>
</div>
<div class="stat-card"
hx-get="/api/monitoring/health/last-incident"
hx-trigger="load, every 60s"
hx-swap="innerHTML">
<div class="stat-icon warning">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
</div>
<div class="stat-content">
<span class="stat-value">--</span>
<span class="stat-label">Last Incident</span>
</div>
</div>
<div class="stat-card"
hx-get="/api/monitoring/health/response-time"
hx-trigger="load, every 30s"
hx-swap="innerHTML">
<div class="stat-icon info">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"></path>
</svg>
</div>
<div class="stat-content">
<span class="stat-value">-- ms</span>
<span class="stat-label">Avg Response Time</span>
</div>
</div>
</div>
<!-- Health Checks Grid -->
<div class="health-section">
<div class="section-header">
<h3></h3>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"></path>
</svg>
Health Check Endpoints
</h3>
<div class="section-actions">
<button class="action-btn secondary" onclick="refreshAllChecks()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"></polyline>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
</svg>
Refresh All
</button>
<a href="/health" target="_blank" class="action-btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
</svg>
View Raw
</a>
</div>
</div>
<div class="health-checks-grid" id="health-checks"
hx-get="/api/monitoring/health/checks"
hx-trigger="load, every 10s"
hx-swap="innerHTML">
<!-- Health checks loaded via HTMX -->
<div class="health-check-card">
<div class="check-header">
<span class="check-status healthy"></span>
<span class="check-name">Database</span>
<span class="check-badge healthy">Healthy</span>
</div>
<div class="check-details">
<div class="check-row">
<span class="check-label">Response Time</span>
<span class="check-value">-- ms</span>
</div>
<div class="check-row">
<span class="check-label">Last Check</span>
<span class="check-value">--</span>
</div>
</div>
</div>
<div class="health-check-card">
<div class="check-header">
<span class="check-status healthy"></span>
<span class="check-name">Cache</span>
<span class="check-badge healthy">Healthy</span>
</div>
<div class="check-details">
<div class="check-row">
<span class="check-label">Response Time</span>
<span class="check-value">-- ms</span>
</div>
<div class="check-row">
<span class="check-label">Last Check</span>
<span class="check-value">--</span>
</div>
</div>
</div>
<div class="health-check-card">
<div class="check-header">
<span class="check-status healthy"></span>
<span class="check-name">Message Queue</span>
<span class="check-badge healthy">Healthy</span>
</div>
<div class="check-details">
<div class="check-row">
<span class="check-label">Response Time</span>
<span class="check-value">-- ms</span>
</div>
<div class="check-row">
<span class="check-label">Last Check</span>
<span class="check-value">--</span>
</div>
</div>
</div>
<div class="health-check-card">
<div class="check-header">
<span class="check-status healthy"></span>
<span class="check-name">Storage</span>
<span class="check-badge healthy">Healthy</span>
</div>
<div class="check-details">
<div class="check-row">
<span class="check-label">Response Time</span>
<span class="check-value">-- ms</span>
</div>
<div class="check-row">
<span class="check-label">Last Check</span>
<span class="check-value">--</span>
</div>
</div>
</div>
</div>
</div>
<!-- Dependencies Section -->
<div class="health-section">
<div class="section-header">
<h3>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="16 18 22 12 16 6"></polyline>
<polyline points="8 6 2 12 8 18"></polyline>
</svg>
External Dependencies
</h3>
</div>
<div class="dependencies-list" id="dependencies"
hx-get="/api/monitoring/health/dependencies"
hx-trigger="load, every 30s"
hx-swap="innerHTML">
<div class="dependency-row">
<div class="dependency-info">
<span class="dependency-status healthy"></span>
<span class="dependency-name">OpenAI API</span>
<span class="dependency-url">api.openai.com</span>
</div>
<div class="dependency-stats">
<span class="stat">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
-- ms
</span>
<span class="dependency-badge healthy">Online</span>
</div>
</div>
<div class="dependency-row">
<div class="dependency-info">
<span class="dependency-status healthy"></span>
<span class="dependency-name">WhatsApp Business API</span>
<span class="dependency-url">graph.facebook.com</span>
</div>
<div class="dependency-stats">
<span class="stat">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
-- ms
</span>
<span class="dependency-badge healthy">Online</span>
</div>
</div>
<div class="dependency-row">
<div class="dependency-info">
<span class="dependency-status healthy"></span>
<span class="dependency-name">Email Service</span>
<span class="dependency-url">smtp.sendgrid.net</span>
</div>
<div class="dependency-stats">
<span class="stat">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
-- ms
</span>
<span class="dependency-badge healthy">Online</span>
</div>
</div>
</div>
</div>
<!-- Uptime History -->
<div class="health-section">
<div class="section-header">
<h3>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
Uptime History (Last 90 Days)
</h3>
<div class="uptime-legend">
<span class="legend-item"><span class="legend-dot healthy"></span>Operational</span>
<span class="legend-item"><span class="legend-dot degraded"></span>Degraded</span>
<span class="legend-item"><span class="legend-dot outage"></span>Outage</span>
</div>
</div>
<div class="uptime-chart" id="uptime-chart"
hx-get="/api/monitoring/health/uptime-history"
hx-trigger="load"
hx-swap="innerHTML">
<div class="uptime-bars">
<!-- 90 days of uptime bars -->
<div class="uptime-bar healthy" title="Jan 15 - 100%"></div>
<div class="uptime-bar healthy" title="Jan 14 - 100%"></div>
<div class="uptime-bar healthy" title="Jan 13 - 100%"></div>
<div class="uptime-bar healthy" title="Jan 12 - 100%"></div>
<div class="uptime-bar healthy" title="Jan 11 - 100%"></div>
<div class="uptime-bar healthy" title="Jan 10 - 100%"></div>
<div class="uptime-bar degraded" title="Jan 9 - 99.5%"></div>
<div class="uptime-bar healthy" title="Jan 8 - 100%"></div>
<div class="uptime-bar healthy" title="Jan 7 - 100%"></div>
<div class="uptime-bar healthy" title="Jan 6 - 100%"></div>
<div class="uptime-bar healthy" title="Jan 5 - 100%"></div>
<div class="uptime-bar healthy" title="Jan 4 - 100%"></div>
<div class="uptime-bar healthy" title="Jan 3 - 100%"></div>
<div class="uptime-bar healthy" title="Jan 2 - 100%"></div>
<div class="uptime-bar healthy" title="Jan 1 - 100%"></div>
<!-- ... more bars ... -->
</div>
<div class="uptime-labels">
<span>90 days ago</span>
<span>Today</span>
</div>
</div>
</div>
<!-- Recent Incidents -->
<div class="health-section">
<div class="section-header">
<h3>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
Recent Incidents
</h3>
<a href="#" class="view-all-link">View All</a>
</div>
<div class="incidents-list" id="incidents"
hx-get="/api/monitoring/health/incidents"
hx-trigger="load"
hx-swap="innerHTML">
<div class="incident-placeholder">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
<polyline points="22 4 12 14.01 9 11.01"></polyline>
</svg>
<p>No recent incidents</p>
<span>System</span> has been stable</span>
</div>
</div>
</div>
</div>
<style>
.health-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* Health Overview */
.health-overview {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
}
.health-status {
display: flex;
align-items: center;
gap: 1.25rem;
}
.status-icon {
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 16px;
flex-shrink: 0;
}
.health-status.healthy .status-icon {
background: rgba(34, 197, 94, 0.1);
color: var(--success);
}
.health-status.degraded .status-icon {
background: rgba(245, 158, 11, 0.1);
color: var(--warning);
}
.health-status.unhealthy .status-icon {
background: rgba(239, 68, 68, 0.1);
color: var(--error);
}
.status-info {
flex: 1;
}
.status-info h2 {
font-size: 1.25rem;
font-weight: 600;
color: var(--text);
margin-bottom: 0.25rem;
}
.status-info p {
font-size: 0.875rem;
color: var(--text-secondary);
}
.status-badge {
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.875rem;
font-weight: 600;
}
.status-badge.healthy {
background: rgba(34, 197, 94, 0.1);
color: var(--success);
}
.status-badge.degraded {
background: rgba(245, 158, 11, 0.1);
color: var(--warning);
}
.status-badge.unhealthy {
background: rgba(239, 68, 68, 0.1);
color: var(--error);
}
/* Uptime Stats */
.uptime-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.stat-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 1.25rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
}
.stat-icon {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
background: var(--primary-light);
color: var(--primary);
border-radius: 10px;
flex-shrink: 0;
}
.stat-icon.success {
background: rgba(34, 197, 94, 0.1);
color: var(--success);
}
.stat-icon.warning {
background: rgba(245, 158, 11, 0.1);
color: var(--warning);
}
.stat-icon.info {
background: rgba(139, 92, 246, 0.1);
color: #8b5cf6;
}
.stat-content {
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 1.375rem;
font-weight: 700;
color: var(--text);
font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
}
.stat-label {
font-size: 0.75rem;
color: var(--text-secondary);
}
/* Health Section */
.health-section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
gap: 0.75rem;
}
.section-header h3 {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9375rem;
font-weight: 600;
color: var(--text);
}
.section-header h3 svg {
color: var(--primary);
}
.section-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
background: var(--primary);
color: white;
border: none;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
text-decoration: none;
transition: all 0.2s ease;
}
.action-btn:hover {
background: var(--primary-hover);
}
.action-btn.secondary {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
}
.action-btn.secondary:hover {
background: var(--surface-hover);
border-color: var(--primary);
}
.view-all-link {
font-size: 0.8125rem;
color: var(--primary);
text-decoration: none;
}
.view-all-link:hover {
text-decoration: underline;
}
/* Health Checks Grid */
.health-checks-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
padding: 1.25rem;
}
.health-check-card {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.check-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.875rem 1rem;
border-bottom: 1px solid var(--border);
}
.check-status {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.check-status.healthy {
background: var(--success);
box-shadow: 0 0 8px var(--success);
}
.check-status.degraded {
background: var(--warning);
box-shadow: 0 0 8px var(--warning);
}
.check-status.unhealthy {
background: var(--error);
box-shadow: 0 0 8px var(--error);
}
.check-name {
flex: 1;
font-size: 0.9375rem;
font-weight: 600;
color: var(--text);
}
.check-badge {
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
}
.check-badge.healthy {
background: rgba(34, 197, 94, 0.1);
color: var(--success);
}
.check-badge.degraded {
background: rgba(245, 158, 11, 0.1);
color: var(--warning);
}
.check-badge.unhealthy {
background: rgba(239, 68, 68, 0.1);
color: var(--error);
}
.check-details {
padding: 0.75rem 1rem;
}
.check-row {
display: flex;
justify-content: space-between;
padding: 0.375rem 0;
}
.check-row:not(:last-child) {
border-bottom: 1px solid var(--border);
}
.check-label {
font-size: 0.8125rem;
color: var(--text-secondary);
}
.check-value {
font-size: 0.8125rem;
color: var(--text);
font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
}
/* Dependencies */
.dependencies-list {
padding: 0.5rem 0;
}
.dependency-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.875rem 1.25rem;
border-bottom: 1px solid var(--border);
}
.dependency-row:last-child {
border-bottom: none;
}
.dependency-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.dependency-status {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.dependency-status.healthy { background: var(--success); }
.dependency-status.degraded { background: var(--warning); }
.dependency-status.unhealthy { background: var(--error); }
.dependency-name {
font-size: 0.9375rem;
font-weight: 500;
color: var(--text);
}
.dependency-url {
font-size: 0.75rem;
color: var(--text-secondary);
font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
}
.dependency-stats {
display: flex;
align-items: center;
gap: 1rem;
}
.dependency-stats .stat {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8125rem;
color: var(--text-secondary);
font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
}
.dependency-stats .stat svg {
color: var(--primary);
}
.dependency-badge {
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 600;
}
.dependency-badge.healthy {
background: rgba(34, 197, 94, 0.1);
color: var(--success);
}
.dependency-badge.unhealthy {
background: rgba(239, 68, 68, 0.1);
color: var(--error);
}
/* Uptime Chart */
.uptime-legend {
display: flex;
gap: 1rem;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--text-secondary);
}
.legend-dot {
width: 12px;
height: 12px;
border-radius: 2px;
}
.legend-dot.healthy { background: var(--success); }
.legend-dot.degraded { background: var(--warning); }
.legend-dot.outage { background: var(--error); }
.uptime-chart {
padding: 1.25rem;
}
.uptime-bars {
display: flex;
gap: 2px;
height: 32px;
margin-bottom: 0.5rem;
}
.uptime-bar {
flex: 1;
border-radius: 2px;
cursor: pointer;
transition: opacity 0.2s ease;
}
.uptime-bar:hover {
opacity: 0.8;
}
.uptime-bar.healthy { background: var(--success); }
.uptime-bar.degraded { background: var(--warning); }
.uptime-bar.outage { background: var(--error); }
.uptime-labels {
display: flex;
justify-content: space-between;
font-size: 0.6875rem;
color: var(--text-secondary);
}
/* Incidents */
.incidents-list {
padding: 1rem 1.25rem;
}
.incident-item {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 1rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 0.75rem;
}
.incident-item:last-child {
margin-bottom: 0;
}
.incident-icon {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
flex-shrink: 0;
}
.incident-item.resolved .incident-icon {
background: rgba(34, 197, 94, 0.1);
color: var(--success);
}
.incident-item.ongoing .incident-icon {
background: rgba(239, 68, 68, 0.1);
color: var(--error);
}
.incident-content {
flex: 1;
}
.incident-title {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text);
margin-bottom: 0.25rem;
}
.incident-description {
font-size: 0.8125rem;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.incident-meta {
display: flex;
gap: 1rem;
font-size: 0.75rem;
color: var(--text-secondary);
}
.incident-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
text-align: center;
color: var(--text-secondary);
}
.incident-placeholder svg {
margin-bottom: 0.75rem;
color: var(--success);
opacity: 0.6;
}
.incident-placeholder p {
font-size: 0.9375rem;
color: var(--text);
margin-bottom: 0.25rem;
}
.incident-placeholder span {
font-size: 0.8125rem;
}
/* Responsive */
@media (max-width: 768px) {
.health-status {
flex-direction: column;
text-align: center;
}
.uptime-stats {
grid-template-columns: repeat(2, 1fr);
}
.health-checks-grid {
grid-template-columns: 1fr;
}
.section-header {
flex-direction: column;
align-items: flex-start;
}
.dependency-row {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.dependency-stats {
width: 100%;
justify-content: space-between;
}
.uptime-legend {
flex-wrap: wrap;
}
.uptime-bars {
gap: 1px;
}
}
@media (max-width: 480px) {
.uptime-stats {
grid-template-columns: 1fr;
}
}
</style>
<script>
function refreshAllChecks() {
htmx.trigger('#health-checks', 'refresh');
htmx.trigger('#dependencies', 'refresh');
htmx.trigger('.health-overview', 'refresh');
// Visual feedback
const btn = event.currentTarget;
const originalText = btn.innerHTML;
btn.innerHTML = `
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spin">
<polyline points="23 4 23 10 17 10"></polyline>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
</svg>
Refreshing...
`;
btn.disabled = true;
setTimeout(() => {
btn.innerHTML = originalText;
btn.disabled = false;
}, 1000);
}
// Add tooltip functionality for uptime bars
document.querySelectorAll('.uptime-bar').forEach(bar => {
bar.addEventListener('mouseenter', function(e) {
const tooltip = document.createElement('div');
tooltip.className = 'uptime-tooltip';
tooltip.textContent = this.title;
tooltip.style.cssText = `
position: fixed;
background: var(--surface);
border: 1px solid var(--border);
padding: 0.375rem 0.625rem;
border-radius: 4px;
font-size: 0.75rem;
color: var(--text);
pointer-events: none;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
`;
document.body.appendChild(tooltip);
const rect = this.getBoundingClientRect();
tooltip.style.left = `${rect.left + rect.width/2 - tooltip.offsetWidth/2}px`;
tooltip.style.top = `${rect.top - tooltip.offsetHeight - 8}px`;
this._tooltip = tooltip;
});
bar.addEventListener('mouseleave', function() {
if (this._tooltip) {
this._tooltip.remove();
this._tooltip = null;
}
});
});
</script>

View file

@ -0,0 +1,783 @@
<div class="monitoring-layout">
<!-- Sidebar Navigation -->
<aside class="monitoring-sidebar">
<div class="monitoring-header">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" opacity="0.3"></circle>
<circle cx="12" cy="12" r="6.5" opacity="0.6"></circle>
<circle cx="12" cy="12" r="2" fill="currentColor" stroke="none"></circle>
<line x1="12" y1="2" x2="12" y2="5"></line>
<line x1="12" y1="19" x2="12" y2="22"></line>
<line x1="2" y1="12" x2="5" y2="12"></line>
<line x1="19" y1="12" x2="22" y2="12"></line>
</svg>
<span>Monitoring</span>
</div>
<nav class="monitoring-nav">
<a href="#dashboard" class="nav-item active"
hx-get="/api/monitoring/dashboard"
hx-target="#monitoring-content"
hx-swap="innerHTML"
hx-push-url="false"
onclick="setActiveNav(this)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="9"></rect>
<rect x="14" y="3" width="7" height="5"></rect>
<rect x="14" y="12" width="7" height="9"></rect>
<rect x="3" y="16" width="7" height="5"></rect>
</svg>
<span>Dashboard</span>
</a>
<a href="#services" class="nav-item"
hx-get="/api/monitoring/services"
hx-target="#monitoring-content"
hx-swap="innerHTML"
hx-push-url="false"
onclick="setActiveNav(this)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
<line x1="6" y1="6" x2="6.01" y2="6"></line>
<line x1="6" y1="18" x2="6.01" y2="18"></line>
</svg>
<span>Services</span>
</a>
<a href="#resources" class="nav-item"
hx-get="/api/monitoring/resources"
hx-target="#monitoring-content"
hx-swap="innerHTML"
hx-push-url="false"
onclick="setActiveNav(this)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="4" y="4" width="16" height="16" rx="2"></rect>
<rect x="9" y="9" width="6" height="6"></rect>
<line x1="9" y1="1" x2="9" y2="4"></line>
<line x1="15" y1="1" x2="15" y2="4"></line>
<line x1="9" y1="20" x2="9" y2="23"></line>
<line x1="15" y1="20" x2="15" y2="23"></line>
<line x1="20" y1="9" x2="23" y2="9"></line>
<line x1="20" y1="14" x2="23" y2="14"></line>
<line x1="1" y1="9" x2="4" y2="9"></line>
<line x1="1" y1="14" x2="4" y2="14"></line>
</svg>
<span>Resources</span>
</a>
<a href="#logs" class="nav-item"
hx-get="/api/monitoring/logs"
hx-target="#monitoring-content"
hx-swap="innerHTML"
hx-push-url="false"
onclick="setActiveNav(this)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
<span>Logs</span>
</a>
<a href="#metrics" class="nav-item"
hx-get="/api/monitoring/metrics"
hx-target="#monitoring-content"
hx-swap="innerHTML"
hx-push-url="false"
onclick="setActiveNav(this)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="20" x2="18" y2="10"></line>
<line x1="12" y1="20" x2="12" y2="4"></line>
<line x1="6" y1="20" x2="6" y2="14"></line>
</svg>
<span>Metrics</span>
</a>
<a href="#alerts" class="nav-item"
hx-get="/api/monitoring/alerts"
hx-target="#monitoring-content"
hx-swap="innerHTML"
hx-push-url="false"
onclick="setActiveNav(this)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>
</svg>
<span>Alerts</span>
<span class="alert-badge" id="alert-count"
hx-get="/api/monitoring/alerts/count"
hx-trigger="load, every 30s"
hx-swap="innerHTML">0</span>
</a>
<a href="#health" class="nav-item"
hx-get="/api/monitoring/health"
hx-target="#monitoring-content"
hx-swap="innerHTML"
hx-push-url="false"
onclick="setActiveNav(this)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"></path>
</svg>
<span>Health</span>
<span class="health-indicator"
hx-get="/api/monitoring/health/status"
hx-trigger="load, every 10s"
hx-swap="outerHTML"></span>
</a>
</nav>
<div class="monitoring-footer">
<a href="/suite" class="back-link">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
</svg>
Back to Suite
</a>
<div class="system-status">
<span class="status-indicator running"></span>
<span class="status-text"
hx-get="/api/monitoring/system/status"
hx-trigger="load, every 15s"
hx-swap="innerHTML">All Systems Operational</span>
</div>
</div>
</aside>
<!-- Main Content Area -->
<main class="monitoring-main">
<div class="monitoring-toolbar">
<div class="toolbar-left">
<h1 id="page-title">Dashboard</h1>
<span class="last-updated"
hx-get="/api/monitoring/timestamp"
hx-trigger="load, every 5s"
hx-swap="innerHTML">--</span>
</div>
<div class="toolbar-right">
<div class="time-range-selector">
<select id="time-range" onchange="updateTimeRange(this.value)">
<option value="15m">Last 15 minutes</option>
<option value="1h" selected>Last 1 hour</option>
<option value="6h">Last 6 hours</option>
<option value="24h">Last 24 hours</option>
<option value="7d">Last 7 days</option>
<option value="30d">Last 30 days</option>
</select>
</div>
<button class="toolbar-btn" onclick="refreshMonitoring()" title="Refresh">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"></polyline>
<polyline points="1 20 1 14 7 14"></polyline>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
</svg>
</button>
<button class="toolbar-btn" onclick="toggleAutoRefresh()" id="auto-refresh-btn" title="Auto Refresh">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
</button>
<button class="toolbar-btn" onclick="exportData()" title="Export">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
</button>
<a href="/metrics" target="_blank" class="toolbar-btn" title="Prometheus Metrics">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
</a>
</div>
</div>
<div class="monitoring-content" id="monitoring-content"
hx-get="/api/monitoring/dashboard"
hx-trigger="load"
hx-swap="innerHTML">
<!-- Dashboard content loaded via HTMX -->
<div class="loading-state">
<div class="spinner"></div>
<p>Loading monitoring data...</p>
</div>
</div>
</main>
</div>
<!-- Quick Stats Bar (always visible) -->
<div class="quick-stats-bar">
<div class="quick-stat"
hx-get="/api/monitoring/quick/cpu"
hx-trigger="load, every 5s"
hx-swap="innerHTML">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="4" y="4" width="16" height="16" rx="2"></rect>
<rect x="9" y="9" width="6" height="6"></rect>
</svg>
<span class="stat-label">CPU</span>
<span class="stat-value">--%</span>
</div>
<div class="quick-stat"
hx-get="/api/monitoring/quick/memory"
hx-trigger="load, every 5s"
hx-swap="innerHTML">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="6" width="20" height="12" rx="2"></rect>
<line x1="6" y1="12" x2="6" y2="12"></line>
<line x1="10" y1="12" x2="10" y2="12"></line>
<line x1="14" y1="12" x2="14" y2="12"></line>
<line x1="18" y1="12" x2="18" y2="12"></line>
</svg>
<span class="stat-label">Memory</span>
<span class="stat-value">--%</span>
</div>
<div class="quick-stat"
hx-get="/api/monitoring/quick/disk"
hx-trigger="load, every 30s"
hx-swap="innerHTML">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
</svg>
<span class="stat-label">Disk</span>
<span class="stat-value">--%</span>
</div>
<div class="quick-stat"
hx-get="/api/monitoring/quick/network"
hx-trigger="load, every 5s"
hx-swap="innerHTML">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12.55a11 11 0 0 1 14.08 0"></path>
<path d="M1.42 9a16 16 0 0 1 21.16 0"></path>
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"></path>
<line x1="12" y1="20" x2="12.01" y2="20"></line>
</svg>
<span class="stat-label">Network</span>
<span class="stat-value">-- MB/s</span>
</div>
<div class="quick-stat"
hx-get="/api/monitoring/quick/requests"
hx-trigger="load, every 5s"
hx-swap="innerHTML">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline>
</svg>
<span class="stat-label">Requests</span>
<span class="stat-value">--/s</span>
</div>
</div>
<style>
.monitoring-layout {
display: flex;
min-height: calc(100vh - 64px);
background: var(--bg);
}
/* Sidebar */
.monitoring-sidebar {
width: 240px;
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
position: sticky;
top: 64px;
height: calc(100vh - 64px);
overflow-y: auto;
}
.monitoring-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1.25rem 1rem;
border-bottom: 1px solid var(--border);
color: var(--text);
font-weight: 600;
font-size: 1rem;
}
.monitoring-header svg {
color: var(--primary);
}
.monitoring-nav {
flex: 1;
padding: 0.75rem 0;
}
.nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
color: var(--text-secondary);
text-decoration: none;
transition: all 0.2s ease;
position: relative;
}
.nav-item:hover {
background: var(--surface-hover);
color: var(--text);
}
.nav-item.active {
background: var(--primary-light);
color: var(--primary);
border-right: 3px solid var(--primary);
}
.nav-item svg {
flex-shrink: 0;
}
.alert-badge {
margin-left: auto;
background: var(--error);
color: white;
font-size: 0.7rem;
font-weight: 600;
padding: 0.15rem 0.4rem;
border-radius: 10px;
min-width: 18px;
text-align: center;
}
.alert-badge:empty,
.alert-badge:contains("0") {
display: none;
}
.health-indicator {
margin-left: auto;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--success);
}
.health-indicator.warning {
background: var(--warning);
}
.health-indicator.error {
background: var(--error);
}
.monitoring-footer {
padding: 1rem;
border-top: 1px solid var(--border);
}
.back-link {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--text-secondary);
text-decoration: none;
font-size: 0.875rem;
padding: 0.5rem 0;
transition: color 0.2s ease;
}
.back-link:hover {
color: var(--primary);
}
.system-status {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border);
font-size: 0.75rem;
color: var(--text-secondary);
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.status-indicator.running {
background: var(--success);
box-shadow: 0 0 8px var(--success);
}
.status-indicator.warning {
background: var(--warning);
box-shadow: 0 0 8px var(--warning);
}
.status-indicator.error {
background: var(--error);
box-shadow: 0 0 8px var(--error);
}
/* Main Content */
.monitoring-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.monitoring-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky;
top: 64px;
z-index: 100;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 1rem;
}
.toolbar-left h1 {
font-size: 1.25rem;
font-weight: 600;
color: var(--text);
}
.last-updated {
font-size: 0.75rem;
color: var(--text-secondary);
background: var(--bg);
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.time-range-selector select {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 0.5rem 0.75rem;
border-radius: 6px;
font-size: 0.875rem;
cursor: pointer;
}
.time-range-selector select:focus {
outline: none;
border-color: var(--primary);
}
.toolbar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
}
.toolbar-btn:hover {
background: var(--surface-hover);
color: var(--text);
border-color: var(--primary);
}
.toolbar-btn.active {
background: var(--primary-light);
color: var(--primary);
border-color: var(--primary);
}
.monitoring-content {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
}
/* Loading State */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem;
color: var(--text-secondary);
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Quick Stats Bar */
.quick-stats-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 2rem;
padding: 0.75rem 1.5rem;
background: var(--surface);
border-top: 1px solid var(--border);
position: fixed;
bottom: 0;
left: 240px;
right: 0;
z-index: 100;
}
.quick-stat {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
}
.quick-stat svg {
color: var(--primary);
}
.stat-label {
color: var(--text-secondary);
}
.stat-value {
color: var(--text);
font-weight: 600;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
}
.stat-value.warning {
color: var(--warning);
}
.stat-value.error {
color: var(--error);
}
/* Dashboard Grid (default content) */
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
.dashboard-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.25rem;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.card-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
color: var(--text);
}
.card-title svg {
color: var(--primary);
}
.card-action {
font-size: 0.75rem;
color: var(--primary);
text-decoration: none;
}
.card-action:hover {
text-decoration: underline;
}
/* Responsive */
@media (max-width: 1024px) {
.monitoring-sidebar {
width: 200px;
}
.quick-stats-bar {
left: 200px;
gap: 1rem;
}
.quick-stat .stat-label {
display: none;
}
}
@media (max-width: 768px) {
.monitoring-layout {
flex-direction: column;
}
.monitoring-sidebar {
width: 100%;
height: auto;
position: relative;
top: 0;
}
.monitoring-nav {
display: flex;
overflow-x: auto;
padding: 0.5rem;
gap: 0.25rem;
}
.nav-item {
flex-direction: column;
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
white-space: nowrap;
border-right: none !important;
border-radius: 6px;
}
.nav-item.active {
border-bottom: 2px solid var(--primary);
}
.nav-item span:not(.alert-badge):not(.health-indicator) {
display: none;
}
.monitoring-footer {
display: none;
}
.monitoring-toolbar {
flex-direction: column;
gap: 0.75rem;
align-items: stretch;
position: relative;
top: 0;
}
.toolbar-left,
.toolbar-right {
justify-content: space-between;
}
.quick-stats-bar {
left: 0;
gap: 0.5rem;
padding: 0.5rem;
flex-wrap: wrap;
}
.quick-stat {
font-size: 0.75rem;
}
.monitoring-content {
padding-bottom: 80px;
}
}
</style>
<script>
function setActiveNav(element) {
document.querySelectorAll('.monitoring-nav .nav-item').forEach(item => {
item.classList.remove('active');
});
element.classList.add('active');
// Update page title
const title = element.querySelector('span:not(.alert-badge):not(.health-indicator)').textContent;
document.getElementById('page-title').textContent = title;
}
function updateTimeRange(range) {
// Store selected time range
localStorage.setItem('monitoring-time-range', range);
// Trigger refresh of current view
htmx.trigger('#monitoring-content', 'refresh');
}
function refreshMonitoring() {
htmx.trigger('#monitoring-content', 'refresh');
// Visual feedback
const btn = event.currentTarget;
btn.classList.add('active');
setTimeout(() => btn.classList.remove('active'), 500);
}
let autoRefresh = true;
function toggleAutoRefresh() {
autoRefresh = !autoRefresh;
const btn = document.getElementById('auto-refresh-btn');
btn.classList.toggle('active', autoRefresh);
if (autoRefresh) {
// Re-enable polling by refreshing the page content
htmx.trigger('#monitoring-content', 'refresh');
}
}
function exportData() {
const timeRange = document.getElementById('time-range').value;
window.open(`/api/monitoring/export?range=${timeRange}`, '_blank');
}
// Initialize
document.addEventListener('DOMContentLoaded', function() {
// Restore time range preference
const savedRange = localStorage.getItem('monitoring-time-range');
if (savedRange) {
document.getElementById('time-range').value = savedRange;
}
// Set auto-refresh button state
document.getElementById('auto-refresh-btn').classList.toggle('active', autoRefresh);
});
// Handle HTMX events for loading states
document.body.addEventListener('htmx:beforeRequest', function(evt) {
if (evt.target.id === 'monitoring-content') {
evt.target.innerHTML = '<div class="loading-state"><div class="spinner"></div><p>Loading...</p></div>';
}
});
</script>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,895 @@
<div class="metrics-container">
<!-- Metrics Header -->
<div class="metrics-header">
<div class="header-info">
<h2></h2>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="20" x2="18" y2="10"></line>
<line x1="12" y1="20" x2="12" y2="4"></line>
<line x1="6" y1="20" x2="6" y2="14"></line>
</svg>
Metrics Dashboard
</h2>
<span class="last-sync"
hx-get="/api/monitoring/metrics/last-sync"
hx-trigger="load, every 30s"
hx-swap="innerHTML">Last sync: --</span>
</div>
<div class="header-actions">
<select id="metrics-time-range" onchange="updateMetricsRange(this.value)">
<option value="15m">Last 15 minutes</option>
<option value="1h" selected>Last 1 hour</option>
<option value="6h">Last 6 hours</option>
<option value="24h">Last 24 hours</option>
<option value="7d">Last 7 days</option>
</select>
<button class="action-btn secondary" onclick="refreshMetrics()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"></polyline>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
</svg>
Refresh
</button>
<a href="/metrics" target="_blank" class="action-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
</svg>
Prometheus Export
</a>
</div>
</div>
<!-- Key Metrics Overview -->
<div class="key-metrics"
hx-get="/api/analytics/dashboard"
hx-trigger="load, every 30s"
hx-swap="innerHTML">
<div class="metric-card">
<div class="metric-icon requests">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline>
</svg>
</div>
<div class="metric-info">
<span class="metric-value">--</span>
<span class="metric-label">Requests/sec</span>
</div>
<div class="metric-trend up">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="18 15 12 9 6 15"></polyline>
</svg>
<span>--%</span>
</div>
</div>
<div class="metric-card">
<div class="metric-icon latency">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
</div>
<div class="metric-info">
<span class="metric-value">-- ms</span>
<span class="metric-label">Avg Latency</span>
</div>
<div class="metric-trend down">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
<span>--%</span>
</div>
</div>
<div class="metric-card">
<div class="metric-icon errors">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="15" y1="9" x2="9" y2="15"></line>
<line x1="9" y1="9" x2="15" y2="15"></line>
</svg>
</div>
<div class="metric-info">
<span class="metric-value">--%</span>
<span class="metric-label">Error Rate</span>
</div>
<div class="metric-trend neutral">
<span>--</span>
</div>
</div>
<div class="metric-card">
<div class="metric-icon users">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
</div>
<div class="metric-info">
<span class="metric-value">--</span>
<span class="metric-label">Active Users</span>
</div>
<div class="metric-trend up">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="18 15 12 9 6 15"></polyline>
</svg>
<span>--%</span>
</div>
</div>
<div class="metric-card">
<div class="metric-icon conversations">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
</div>
<div class="metric-info">
<span class="metric-value">--</span>
<span class="metric-label">Conversations</span>
</div>
<div class="metric-trend up">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="18 15 12 9 6 15"></polyline>
</svg>
<span>--%</span>
</div>
</div>
<div class="metric-card">
<div class="metric-icon uptime">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
<polyline points="22 4 12 14.01 9 11.01"></polyline>
</svg>
</div>
<div class="metric-info">
<span class="metric-value">--%</span>
<span class="metric-label">Uptime</span>
</div>
<div class="metric-trend neutral">
<span>--</span>
</div>
</div>
</div>
<!-- Charts Grid -->
<div class="charts-grid">
<!-- Request Rate Chart -->
<div class="chart-panel">
<div class="chart-header">
<h3>Request Rate</h3>
<div class="chart-controls">
<select onchange="updateChartType('requests', this.value)">
<option value="line">Line</option>
<option value="area">Area</option>
<option value="bar">Bar</option>
</select>
</div>
</div>
<div class="chart-body" id="requests-chart"
hx-get="/api/analytics/metric?name=requests"
hx-trigger="load, every 30s"
hx-swap="innerHTML">
<svg viewBox="0 0 600 200" class="chart-svg">
<defs>
<linearGradient id="requests-gradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:var(--primary);stop-opacity:0.3"/>
<stop offset="100%" style="stop-color:var(--primary);stop-opacity:0"/>
</linearGradient>
</defs>
<g class="chart-grid">
<line x1="50" y1="20" x2="580" y2="20" stroke="var(--border)" stroke-dasharray="4"/>
<line x1="50" y1="70" x2="580" y2="70" stroke="var(--border)" stroke-dasharray="4"/>
<line x1="50" y1="120" x2="580" y2="120" stroke="var(--border)" stroke-dasharray="4"/>
<line x1="50" y1="170" x2="580" y2="170" stroke="var(--border)" stroke-dasharray="4"/>
</g>
<g class="chart-axis-y">
<text x="45" y="25" fill="var(--text-secondary)" font-size="10" text-anchor="end">1000</text>
<text x="45" y="75" fill="var(--text-secondary)" font-size="10" text-anchor="end">750</text>
<text x="45" y="125" fill="var(--text-secondary)" font-size="10" text-anchor="end">500</text>
<text x="45" y="175" fill="var(--text-secondary)" font-size="10" text-anchor="end">250</text>
</g>
<path class="chart-area" d="M50,170 Q100,160 150,140 T250,100 T350,120 T450,90 T550,110 L550,170 Z" fill="url(#requests-gradient)"/>
<path class="chart-line" d="M50,170 Q100,160 150,140 T250,100 T350,120 T450,90 T550,110" fill="none" stroke="var(--primary)" stroke-width="2"/>
</svg>
</div>
</div>
<!-- Latency Chart -->
<div class="chart-panel">
<div class="chart-header">
<h3>Response Latency</h3>
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot p50"></span>P50</span>
<span class="legend-item"><span class="legend-dot p95"></span>P95</span>
<span class="legend-item"><span class="legend-dot p99"></span>P99</span>
</div>
</div>
<div class="chart-body" id="latency-chart"
hx-get="/api/analytics/metric?name=latency"
hx-trigger="load, every 30s"
hx-swap="innerHTML">
<svg viewBox="0 0 600 200" class="chart-svg">
<defs>
<linearGradient id="latency-gradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:var(--success);stop-opacity:0.2"/>
<stop offset="100%" style="stop-color:var(--success);stop-opacity:0"/>
</linearGradient>
</defs>
<g class="chart-grid">
<line x1="50" y1="20" x2="580" y2="20" stroke="var(--border)" stroke-dasharray="4"/>
<line x1="50" y1="70" x2="580" y2="70" stroke="var(--border)" stroke-dasharray="4"/>
<line x1="50" y1="120" x2="580" y2="120" stroke="var(--border)" stroke-dasharray="4"/>
<line x1="50" y1="170" x2="580" y2="170" stroke="var(--border)" stroke-dasharray="4"/>
</g>
<g class="chart-axis-y">
<text x="45" y="25" fill="var(--text-secondary)" font-size="10" text-anchor="end">500ms</text>
<text x="45" y="75" fill="var(--text-secondary)" font-size="10" text-anchor="end">375ms</text>
<text x="45" y="125" fill="var(--text-secondary)" font-size="10" text-anchor="end">250ms</text>
<text x="45" y="175" fill="var(--text-secondary)" font-size="10" text-anchor="end">125ms</text>
</g>
<path class="chart-line p99" d="M50,80 Q150,75 250,85 T450,70 T550,80" fill="none" stroke="var(--error)" stroke-width="1.5" stroke-dasharray="4"/>
<path class="chart-line p95" d="M50,110 Q150,100 250,115 T450,95 T550,105" fill="none" stroke="var(--warning)" stroke-width="1.5"/>
<path class="chart-area" d="M50,170 Q150,160 250,155 T450,150 T550,160 L550,170 Z" fill="url(#latency-gradient)"/>
<path class="chart-line p50" d="M50,170 Q150,160 250,155 T450,150 T550,160" fill="none" stroke="var(--success)" stroke-width="2"/>
</svg>
</div>
</div>
<!-- Error Rate Chart -->
<div class="chart-panel">
<div class="chart-header">
<h3>Error Rate</h3>
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot error-4xx"></span>4xx</span>
<span class="legend-item"><span class="legend-dot error-5xx"></span>5xx</span>
</div>
</div>
<div class="chart-body" id="errors-chart"
hx-get="/api/analytics/metric?name=errors"
hx-trigger="load, every 30s"
hx-swap="innerHTML">
<svg viewBox="0 0 600 200" class="chart-svg">
<g class="chart-grid">
<line x1="50" y1="20" x2="580" y2="20" stroke="var(--border)" stroke-dasharray="4"/>
<line x1="50" y1="70" x2="580" y2="70" stroke="var(--border)" stroke-dasharray="4"/>
<line x1="50" y1="120" x2="580" y2="120" stroke="var(--border)" stroke-dasharray="4"/>
<line x1="50" y1="170" x2="580" y2="170" stroke="var(--border)" stroke-dasharray="4"/>
</g>
<g class="chart-axis-y">
<text x="45" y="25" fill="var(--text-secondary)" font-size="10" text-anchor="end">10%</text>
<text x="45" y="75" fill="var(--text-secondary)" font-size="10" text-anchor="end">7.5%</text>
<text x="45" y="125" fill="var(--text-secondary)" font-size="10" text-anchor="end">5%</text>
<text x="45" y="175" fill="var(--text-secondary)" font-size="10" text-anchor="end">2.5%</text>
</g>
<g class="chart-bars">
<rect x="70" y="160" width="20" height="10" fill="var(--warning)" rx="2"/>
<rect x="70" y="165" width="20" height="5" fill="var(--error)" rx="2"/>
<rect x="120" y="155" width="20" height="15" fill="var(--warning)" rx="2"/>
<rect x="120" y="162" width="20" height="8" fill="var(--error)" rx="2"/>
<rect x="170" y="150" width="20" height="20" fill="var(--warning)" rx="2"/>
<rect x="170" y="160" width="20" height="10" fill="var(--error)" rx="2"/>
<rect x="220" y="158" width="20" height="12" fill="var(--warning)" rx="2"/>
<rect x="220" y="165" width="20" height="5" fill="var(--error)" rx="2"/>
<rect x="270" y="162" width="20" height="8" fill="var(--warning)" rx="2"/>
<rect x="270" y="167" width="20" height="3" fill="var(--error)" rx="2"/>
</g>
</svg>
</div>
</div>
<!-- Throughput Chart -->
<div class="chart-panel">
<div class="chart-header">
<h3>Throughput</h3>
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot incoming"></span>In</span>
<span class="legend-item"><span class="legend-dot outgoing"></span>Out</span>
</div>
</div>
<div class="chart-body" id="throughput-chart"
hx-get="/api/analytics/metric?name=throughput"
hx-trigger="load, every 30s"
hx-swap="innerHTML">
<svg viewBox="0 0 600 200" class="chart-svg">
<defs>
<linearGradient id="throughput-in-gradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#8b5cf6;stop-opacity:0.3"/>
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:0"/>
</linearGradient>
<linearGradient id="throughput-out-gradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#06b6d4;stop-opacity:0.3"/>
<stop offset="100%" style="stop-color:#06b6d4;stop-opacity:0"/>
</linearGradient>
</defs>
<g class="chart-grid">
<line x1="50" y1="20" x2="580" y2="20" stroke="var(--border)" stroke-dasharray="4"/>
<line x1="50" y1="70" x2="580" y2="70" stroke="var(--border)" stroke-dasharray="4"/>
<line x1="50" y1="120" x2="580" y2="120" stroke="var(--border)" stroke-dasharray="4"/>
<line x1="50" y1="170" x2="580" y2="170" stroke="var(--border)" stroke-dasharray="4"/>
</g>
<g class="chart-axis-y">
<text x="45" y="25" fill="var(--text-secondary)" font-size="10" text-anchor="end">100MB</text>
<text x="45" y="75" fill="var(--text-secondary)" font-size="10" text-anchor="end">75MB</text>
<text x="45" y="125" fill="var(--text-secondary)" font-size="10" text-anchor="end">50MB</text>
<text x="45" y="175" fill="var(--text-secondary)" font-size="10" text-anchor="end">25MB</text>
</g>
<path class="chart-area" d="M50,130 Q150,110 250,120 T450,100 T550,115 L550,170 Z" fill="url(#throughput-in-gradient)"/>
<path class="chart-line" d="M50,130 Q150,110 250,120 T450,100 T550,115" fill="none" stroke="#8b5cf6" stroke-width="2"/>
<path class="chart-area" d="M50,150 Q150,140 250,145 T450,130 T550,140 L550,170 Z" fill="url(#throughput-out-gradient)"/>
<path class="chart-line" d="M50,150 Q150,140 250,145 T450,130 T550,140" fill="none" stroke="#06b6d4" stroke-width="2"/>
</svg>
</div>
</div>
</div>
<!-- Individual Metrics Table -->
<div class="metrics-table-section">
<div class="section-header">
<h3></h3>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
</svg>
All Metrics
</h3>
<div class="section-actions">
<div class="search-box">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input type="text" id="metric-search" placeholder="Search metrics..." onkeyup="filterMetrics(this.value)">
</div>
<select id="metric-category" onchange="filterByCategory(this.value)">
<option value="all">All Categories</option>
<option value="http">HTTP</option>
<option value="system">System</option>
<option value="database">Database</option>
<option value="cache">Cache</option>
<option value="queue">Queue</option>
</select>
</div>
</div>
<div class="metrics-table-container">
<table class="metrics-table" id="metrics-table"
hx-get="/api/analytics/metrics/list"
hx-trigger="load"
hx-swap="innerHTML">
<thead>
<tr>
<th>Metric Name</th>
<th>Type</th>
<th>Value</th>
<th>Category</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="6" class="loading-cell">Loading metrics...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<style>
.metrics-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* Header */
.metrics-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem;
}
.header-info h2 {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.25rem;
font-weight: 600;
color: var(--text);
margin-bottom: 0.25rem;
}
.header-info h2 svg {
color: var(--primary);
}
.last-sync {
font-size: 0.75rem;
color: var(--text-secondary);
}
.header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.header-actions select {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
padding: 0.5rem 0.75rem;
border-radius: 6px;
font-size: 0.8125rem;
cursor: pointer;
}
.header-actions select:focus {
outline: none;
border-color: var(--primary);
}
.action-btn {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
background: var(--primary);
color: white;
border: none;
border-radius: 6px;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
text-decoration: none;
transition: all 0.2s ease;
}
.action-btn:hover {
background: var(--primary-hover);
}
.action-btn.secondary {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
}
.action-btn.secondary:hover {
background: var(--surface-hover);
border-color: var(--primary);
}
/* Key Metrics */
.key-metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
}
.metric-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.25rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
transition: all 0.2s ease;
}
.metric-card:hover {
border-color: var(--primary);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.metric-icon {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
flex-shrink: 0;
}
.metric-icon.requests { background: var(--primary-light); color: var(--primary); }
.metric-icon.latency { background: rgba(34, 197, 94, 0.1); color: var(--success); }
.metric-icon.errors { background: rgba(239, 68, 68, 0.1); color: var(--error); }
.metric-icon.users { background: rgba(139, 92, 246, 0.1); color: #8b5cf6; }
.metric-icon.conversations { background: rgba(6, 182, 212, 0.1); color: #06b6d4; }
.metric-icon.uptime { background: rgba(245, 158, 11, 0.1); color: var(--warning); }
.metric-info {
flex: 1;
min-width: 0;
}
.metric-value {
display: block;
font-size: 1.375rem;
font-weight: 700;
color: var(--text);
font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
}
.metric-label {
font-size: 0.75rem;
color: var(--text-secondary);
}
.metric-trend {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.metric-trend.up {
background: rgba(34, 197, 94, 0.1);
color: var(--success);
}
.metric-trend.down {
background: rgba(239, 68, 68, 0.1);
color: var(--error);
}
.metric-trend.neutral {
background: var(--bg);
color: var(--text-secondary);
}
/* Charts Grid */
.charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 1rem;
}
.chart-panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.chart-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border);
}
.chart-header h3 {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text);
}
.chart-controls select {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
cursor: pointer;
}
.chart-legend {
display: flex;
gap: 0.75rem;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--text-secondary);
}
.legend-dot {
width: 8px;
height: 8px;
border-radius: 2px;
}
.legend-dot.p50 { background: var(--success); }
.legend-dot.p95 { background: var(--warning); }
.legend-dot.p99 { background: var(--error); }
.legend-dot.error-4xx { background: var(--warning); }
.legend-dot.error-5xx { background: var(--error); }
.legend-dot.incoming { background: #8b5cf6; }
.legend-dot.outgoing { background: #06b6d4; }
.chart-body {
padding: 1rem;
min-height: 220px;
}
.chart-svg {
width: 100%;
height: 180px;
}
/* Metrics Table */
.metrics-table-section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
gap: 1rem;
}
.section-header h3 {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9375rem;
font-weight: 600;
color: var(--text);
}
.section-header h3 svg {
color: var(--primary);
}
.section-actions {
display: flex;
gap: 0.5rem;
}
.search-box {
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.375rem 0.625rem;
}
.search-box svg {
color: var(--text-secondary);
flex-shrink: 0;
}
.search-box input {
background: transparent;
border: none;
color: var(--text);
font-size: 0.8125rem;
width: 160px;
outline: none;
}
.search-box input::placeholder {
color: var(--text-secondary);
}
.section-actions select {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 0.375rem 0.625rem;
border-radius: 6px;
font-size: 0.8125rem;
cursor: pointer;
}
.metrics-table-container {
overflow-x: auto;
}
.metrics-table {
width: 100%;
border-collapse: collapse;
}
.metrics-table th,
.metrics-table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
.metrics-table th {
font-size: 0.6875rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
background: var(--bg);
}
.metrics-table td {
font-size: 0.8125rem;
color: var(--text);
}
.metrics-table tr:last-child td {
border-bottom: none;
}
.metrics-table tr:hover td {
background: var(--surface-hover);
}
.metrics-table .metric-name {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
font-weight: 500;
}
.metrics-table .metric-type {
display: inline-block;
padding: 0.125rem 0.375rem;
background: var(--primary-light);
color: var(--primary);
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
}
.metrics-table .metric-value-cell {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
font-weight: 600;
}
.metrics-table .metric-category {
color: var(--text-secondary);
}
.metrics-table .metric-desc {
color: var(--text-secondary);
font-size: 0.75rem;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.metrics-table .action-link {
color: var(--primary);
text-decoration: none;
font-size: 0.75rem;
}
.metrics-table .action-link:hover {
text-decoration: underline;
}
.loading-cell {
text-align: center !important;
color: var(--text-secondary) !important;
padding: 2rem !important;
}
/* Responsive */
@media (max-width: 1024px) {
.charts-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.metrics-header {
flex-direction: column;
align-items: stretch;
}
.header-actions {
flex-wrap: wrap;
}
.key-metrics {
grid-template-columns: repeat(2, 1fr);
}
.metric-card {
flex-direction: column;
text-align: center;
padding: 1rem;
}
.metric-trend {
margin-top: 0.5rem;
}
.section-header {
flex-direction: column;
align-items: stretch;
}
.section-actions {
flex-wrap: wrap;
}
.search-box {
flex: 1;
}
.search-box input {
width: 100%;
}
}
@media (max-width: 480px) {
.key-metrics {
grid-template-columns: 1fr;
}
}
</style>
<script>
function updateMetricsRange(range) {
localStorage.setItem('metrics-time-range', range);
refreshMetrics();
}
function refreshMetrics() {
// Refresh all metric components
htmx.trigger('.key-metrics', 'refresh');
htmx.trigger('#requests-chart', 'refresh');
htmx.trigger('#latency-chart', 'refresh');
htmx.trigger('#errors-chart', 'refresh');
htmx.trigger('#throughput-chart', 'refresh');
}
function updateChartType(chart, type) {
// This would update the chart visualization type
console.log(`Updating ${chart} chart to ${type} type`);
// Implementation depends on charting library
}
function filterMetrics(query) {
const rows = document.querySelectorAll('.metrics-table tbody tr');
const lowerQuery = query.toLowerCase();
rows.forEach(row => {
const text = row.textContent.toLowerCase();
row.style.display = text.includes(lowerQuery) ? '' : 'none';
});
}
function filterByCategory(category) {
const rows = document.querySelectorAll('.metrics-table tbody tr');
rows.forEach(row => {
if (category === 'all') {
row.style.display = '';
} else {
const rowCategory = row.dataset.category || '';
row.style.display = rowCategory === category ? '' : 'none';
}
});
}
// Initialize
document.addEventListener('DOMContentLoaded', function() {
// Restore time range preference
const savedRange = localStorage.getItem('metrics-time-range');
if (savedRange) {
document.getElementById('metrics-time-range').value = savedRange;
}
});
</script>

View file

@ -0,0 +1,937 @@
<div class="resources-container">
<!-- Resource Overview Cards -->
<div class="resource-cards">
<!-- CPU Card -->
<div class="resource-card cpu-card"
hx-get="/api/monitoring/resources/cpu"
hx-trigger="load, every 5s"
hx-swap="innerHTML">
<div class="card-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="4" y="4" width="16" height="16" rx="2"></rect>
<rect x="9" y="9" width="6" height="6"></rect>
<line x1="9" y1="1" x2="9" y2="4"></line>
<line x1="15" y1="1" x2="15" y2="4"></line>
<line x1="9" y1="20" x2="9" y2="23"></line>
<line x1="15" y1="20" x2="15" y2="23"></line>
<line x1="20" y1="9" x2="23" y2="9"></line>
<line x1="20" y1="14" x2="23" y2="14"></line>
<line x1="1" y1="9" x2="4" y2="9"></line>
<line x1="1" y1="14" x2="4" y2="14"></line>
</svg>
</div>
<div class="card-content">
<span class="card-label">CPU Usage</span>
<span class="card-value">--%</span>
<div class="progress-bar">
<div class="progress-fill cpu" style="width: 0%"></div>
</div>
<span class="card-detail">-- cores @ -- GHz</span>
</div>
</div>
<!-- Memory Card -->
<div class="resource-card memory-card"
hx-get="/api/monitoring/resources/memory"
hx-trigger="load, every 5s"
hx-swap="innerHTML">
<div class="card-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="6" width="20" height="12" rx="2"></rect>
<line x1="6" y1="12" x2="6" y2="12"></line>
<line x1="10" y1="12" x2="10" y2="12"></line>
<line x1="14" y1="12" x2="14" y2="12"></line>
<line x1="18" y1="12" x2="18" y2="12"></line>
</svg>
</div>
<div class="card-content">
<span class="card-label">Memory Usage</span>
<span class="card-value">--%</span>
<div class="progress-bar">
<div class="progress-fill memory" style="width: 0%"></div>
</div>
<span class="card-detail">-- GB / -- GB</span>
</div>
</div>
<!-- Disk Card -->
<div class="resource-card disk-card"
hx-get="/api/monitoring/resources/disk"
hx-trigger="load, every 30s"
hx-swap="innerHTML">
<div class="card-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
</svg>
</div>
<div class="card-content">
<span class="card-label">Disk Usage</span>
<span class="card-value">--%</span>
<div class="progress-bar">
<div class="progress-fill disk" style="width: 0%"></div>
</div>
<span class="card-detail">-- GB / -- GB</span>
</div>
</div>
<!-- Network Card -->
<div class="resource-card network-card"
hx-get="/api/monitoring/resources/network"
hx-trigger="load, every 5s"
hx-swap="innerHTML">
<div class="card-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12.55a11 11 0 0 1 14.08 0"></path>
<path d="M1.42 9a16 16 0 0 1 21.16 0"></path>
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"></path>
<line x1="12" y1="20" x2="12.01" y2="20"></line>
</svg>
</div>
<div class="card-content">
<span class="card-label">Network I/O</span>
<span class="card-value">-- MB/s</span>
<div class="network-stats">
<span class="net-stat">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="18 15 12 9 6 15"></polyline>
</svg>
-- MB/s
</span>
<span class="net-stat">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
-- MB/s
</span>
</div>
<span class="card-detail">-- connections active</span>
</div>
</div>
</div>
<!-- Charts Section -->
<div class="charts-section">
<!-- CPU Chart -->
<div class="chart-card">
<div class="chart-header">
<h3>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="4" y="4" width="16" height="16" rx="2"></rect>
<rect x="9" y="9" width="6" height="6"></rect>
</svg>
CPU Usage Over Time
</h3>
<div class="chart-legend">
<span class="legend-item"><span class="legend-color cpu"></span>User</span>
<span class="legend-item"><span class="legend-color system"></span>System</span>
<span class="legend-item"><span class="legend-color io"></span>I/O Wait</span>
</div>
</div>
<div class="chart-container" id="cpu-chart"
hx-get="/api/monitoring/charts/cpu"
hx-trigger="load, every 10s"
hx-swap="innerHTML">
<div class="chart-placeholder">
<svg viewBox="0 0 400 150" class="sparkline-chart">
<defs>
<linearGradient id="cpu-gradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:var(--primary);stop-opacity:0.3"/>
<stop offset="100%" style="stop-color:var(--primary);stop-opacity:0"/>
</linearGradient>
</defs>
<path d="M0,150 L0,100 Q50,80 100,90 T200,70 T300,85 T400,75 L400,150 Z" fill="url(#cpu-gradient)"/>
<path d="M0,100 Q50,80 100,90 T200,70 T300,85 T400,75" fill="none" stroke="var(--primary)" stroke-width="2"/>
</svg>
<div class="chart-axis-y">
<span></span>100%</span>
<span>50%</span>
<span>0%</span>
</div>
</div>
</div>
</div>
<!-- Memory Chart -->
<div class="chart-card">
<div class="chart-header">
<h3>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="6" width="20" height="12" rx="2"></rect>
<line x1="6" y1="12" x2="6" y2="12"></line>
<line x1="10" y1="12" x2="10" y2="12"></line>
</svg>
Memory Usage Over Time
</h3>
<div class="chart-legend">
<span class="legend-item"><span class="legend-color memory"></span>Used</span>
<span class="legend-item"><span class="legend-color cached"></span>Cached</span>
<span class="legend-item"><span class="legend-color buffers"></span>Buffers</span>
</div>
</div>
<div class="chart-container" id="memory-chart"
hx-get="/api/monitoring/charts/memory"
hx-trigger="load, every 10s"
hx-swap="innerHTML">
<div class="chart-placeholder">
<svg viewBox="0 0 400 150" class="sparkline-chart">
<defs>
<linearGradient id="memory-gradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:var(--success);stop-opacity:0.3"/>
<stop offset="100%" style="stop-color:var(--success);stop-opacity:0"/>
</linearGradient>
</defs>
<path d="M0,150 L0,60 Q50,55 100,58 T200,52 T300,55 T400,50 L400,150 Z" fill="url(#memory-gradient)"/>
<path d="M0,60 Q50,55 100,58 T200,52 T300,55 T400,50" fill="none" stroke="var(--success)" stroke-width="2"/>
</svg>
<div class="chart-axis-y">
<span>16 GB</span>
<span>8 GB</span>
<span>0 GB</span>
</div>
</div>
</div>
</div>
</div>
<!-- Detailed Resources -->
<div class="detailed-section">
<!-- Disk Breakdown -->
<div class="detail-card">
<div class="detail-header">
<h3>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
</svg>
Disk Partitions
</h3>
<button class="refresh-btn" onclick="refreshDiskInfo()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"></polyline>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
</svg>
</button>
</div>
<div class="detail-content" id="disk-partitions"
hx-get="/api/monitoring/resources/disk/partitions"
hx-trigger="load, every 60s"
hx-swap="innerHTML">
<div class="partition-row">
<div class="partition-info">
<span class="partition-name">/</span>
<span class="partition-type">ext4</span>
</div>
<div class="partition-usage">
<div class="usage-bar">
<div class="usage-fill" style="width: 0%"></div>
</div>
<span class="usage-text">-- GB / -- GB</span>
</div>
</div>
</div>
</div>
<!-- Process List -->
<div class="detail-card">
<div class="detail-header">
<h3>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="4" y1="21" x2="4" y2="14"></line>
<line x1="4" y1="10" x2="4" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12" y2="3"></line>
<line x1="20" y1="21" x2="20" y2="16"></line>
<line x1="20" y1="12" x2="20" y2="3"></line>
<line x1="1" y1="14" x2="7" y2="14"></line>
<line x1="9" y1="8" x2="15" y2="8"></line>
<line x1="17" y1="16" x2="23" y2="16"></line>
</svg>
Top Processes
</h3>
<select id="process-sort" onchange="sortProcesses(this.value)">
<option value="cpu">Sort by CPU</option>
<option value="memory">Sort by Memory</option>
<option value="name">Sort by Name</option>
</select>
</div>
<div class="detail-content" id="process-list"
hx-get="/api/monitoring/resources/processes"
hx-trigger="load, every 5s"
hx-swap="innerHTML">
<table class="process-table">
<thead>
<tr>
<th>PID</th>
<th>Process</th>
<th>CPU</th>
<th>Memory</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="5" class="loading-row">Loading processes...</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Network Interfaces -->
<div class="detail-card">
<div class="detail-header">
<h3>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
<line x1="6" y1="6" x2="6.01" y2="6"></line>
<line x1="6" y1="18" x2="6.01" y2="18"></line>
</svg>
Network Interfaces
</h3>
</div>
<div class="detail-content" id="network-interfaces"
hx-get="/api/monitoring/resources/network/interfaces"
hx-trigger="load, every 10s"
hx-swap="innerHTML">
<div class="interface-row">
<div class="interface-info">
<span class="interface-name">eth0</span>
<span class="interface-ip">--</span>
</div>
<div class="interface-stats">
<span class="stat-in">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="18 15 12 9 6 15"></polyline>
</svg>
-- MB/s
</span>
<span class="stat-out">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
-- MB/s
</span>
</div>
</div>
</div>
</div>
<!-- System Info -->
<div class="detail-card">
<div class="detail-header">
<h3>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>
System Information
</h3>
</div>
<div class="detail-content system-info"
hx-get="/api/monitoring/resources/system"
hx-trigger="load"
hx-swap="innerHTML">
<div class="info-row">
<span class="info-label">Hostname</span>
<span class="info-value">--</span>
</div>
<div class="info-row">
<span class="info-label">OS</span>
<span class="info-value">--</span>
</div>
<div class="info-row">
<span class="info-label">Kernel</span>
<span class="info-value">--</span>
</div>
<div class="info-row">
<span class="info-label">Uptime</span>
<span class="info-value">--</span>
</div>
<div class="info-row">
<span class="info-label">Load Average</span>
<span class="info-value">--</span>
</div>
</div>
</div>
</div>
</div>
<style>
.resources-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* Resource Cards */
.resource-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1rem;
}
.resource-card {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 1.25rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
transition: all 0.2s ease;
}
.resource-card:hover {
border-color: var(--primary);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.card-icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: var(--primary-light);
border-radius: 10px;
color: var(--primary);
flex-shrink: 0;
}
.cpu-card .card-icon { color: var(--primary); background: var(--primary-light); }
.memory-card .card-icon { color: var(--success); background: rgba(34, 197, 94, 0.1); }
.disk-card .card-icon { color: var(--warning); background: rgba(245, 158, 11, 0.1); }
.network-card .card-icon { color: #8b5cf6; background: rgba(139, 92, 246, 0.1); }
.card-content {
flex: 1;
min-width: 0;
}
.card-label {
display: block;
font-size: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.25rem;
}
.card-value {
display: block;
font-size: 1.75rem;
font-weight: 700;
color: var(--text);
font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
margin-bottom: 0.5rem;
}
.card-value.warning { color: var(--warning); }
.card-value.error { color: var(--error); }
.progress-bar {
height: 6px;
background: var(--bg);
border-radius: 3px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.progress-fill {
height: 100%;
border-radius: 3px;
transition: width 0.5s ease;
}
.progress-fill.cpu { background: var(--primary); }
.progress-fill.memory { background: var(--success); }
.progress-fill.disk { background: var(--warning); }
.card-detail {
font-size: 0.75rem;
color: var(--text-secondary);
}
.network-stats {
display: flex;
gap: 1rem;
margin-bottom: 0.5rem;
}
.net-stat {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8125rem;
color: var(--text);
}
.net-stat:first-child svg { color: var(--success); }
.net-stat:last-child svg { color: var(--error); }
/* Charts Section */
.charts-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 1rem;
}
.chart-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.chart-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border);
}
.chart-header h3 {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9375rem;
font-weight: 600;
color: var(--text);
}
.chart-header h3 svg {
color: var(--primary);
}
.chart-legend {
display: flex;
gap: 1rem;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--text-secondary);
}
.legend-color {
width: 10px;
height: 10px;
border-radius: 2px;
}
.legend-color.cpu { background: var(--primary); }
.legend-color.system { background: var(--warning); }
.legend-color.io { background: var(--error); }
.legend-color.memory { background: var(--success); }
.legend-color.cached { background: #8b5cf6; }
.legend-color.buffers { background: var(--info); }
.chart-container {
padding: 1rem;
min-height: 180px;
}
.chart-placeholder {
position: relative;
height: 150px;
}
.sparkline-chart {
width: 100%;
height: 100%;
}
.chart-axis-y {
position: absolute;
right: 0;
top: 0;
bottom: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
font-size: 0.625rem;
color: var(--text-secondary);
padding: 0.25rem 0;
}
/* Detailed Section */
.detailed-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 1rem;
}
.detail-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border);
}
.detail-header h3 {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9375rem;
font-weight: 600;
color: var(--text);
}
.detail-header h3 svg {
color: var(--primary);
}
.detail-header select {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 0.375rem 0.625rem;
border-radius: 4px;
font-size: 0.75rem;
cursor: pointer;
}
.detail-header select:focus {
outline: none;
border-color: var(--primary);
}
.refresh-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: transparent;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s ease;
}
.refresh-btn:hover {
background: var(--surface-hover);
color: var(--primary);
border-color: var(--primary);
}
.detail-content {
padding: 1rem 1.25rem;
max-height: 300px;
overflow-y: auto;
}
/* Partition Rows */
.partition-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 0;
border-bottom: 1px solid var(--border);
}
.partition-row:last-child {
border-bottom: none;
}
.partition-info {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.partition-name {
font-size: 0.875rem;
font-weight: 500;
color: var(--text);
font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
}
.partition-type {
font-size: 0.6875rem;
color: var(--text-secondary);
}
.partition-usage {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
max-width: 200px;
margin-left: 1rem;
}
.usage-bar {
flex: 1;
height: 8px;
background: var(--bg);
border-radius: 4px;
overflow: hidden;
}
.usage-fill {
height: 100%;
background: var(--primary);
border-radius: 4px;
transition: width 0.5s ease;
}
.usage-fill.warning { background: var(--warning); }
.usage-fill.error { background: var(--error); }
.usage-text {
font-size: 0.6875rem;
color: var(--text-secondary);
white-space: nowrap;
}
/* Process Table */
.process-table {
width: 100%;
border-collapse: collapse;
}
.process-table th,
.process-table td {
padding: 0.625rem 0.5rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
.process-table th {
font-size: 0.6875rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.process-table td {
font-size: 0.8125rem;
color: var(--text);
}
.process-table td:first-child {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
color: var(--text-secondary);
}
.process-table td:nth-child(3),
.process-table td:nth-child(4) {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
}
.process-table tr:last-child td {
border-bottom: none;
}
.loading-row {
text-align: center !important;
color: var(--text-secondary) !important;
padding: 2rem !important;
}
.process-status {
display: inline-block;
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 500;
}
.process-status.running {
background: rgba(34, 197, 94, 0.1);
color: var(--success);
}
.process-status.sleeping {
background: var(--primary-light);
color: var(--primary);
}
.process-status.stopped {
background: rgba(239, 68, 68, 0.1);
color: var(--error);
}
/* Network Interfaces */
.interface-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 0;
border-bottom: 1px solid var(--border);
}
.interface-row:last-child {
border-bottom: none;
}
.interface-info {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.interface-name {
font-size: 0.875rem;
font-weight: 500;
color: var(--text);
font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
}
.interface-ip {
font-size: 0.6875rem;
color: var(--text-secondary);
}
.interface-stats {
display: flex;
gap: 1rem;
}
.stat-in, .stat-out {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
color: var(--text);
}
.stat-in svg { color: var(--success); }
.stat-out svg { color: var(--error); }
/* System Info */
.system-info .info-row {
display: flex;
justify-content: space-between;
padding: 0.625rem 0;
border-bottom: 1px solid var(--border);
}
.system-info .info-row:last-child {
border-bottom: none;
}
.info-label {
font-size: 0.8125rem;
color: var(--text-secondary);
}
.info-value {
font-size: 0.8125rem;
color: var(--text);
font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
}
/* Responsive */
@media (max-width: 768px) {
.resource-cards {
grid-template-columns: 1fr 1fr;
}
.charts-section {
grid-template-columns: 1fr;
}
.chart-header {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.detailed-section {
grid-template-columns: 1fr;
}
.partition-usage {
max-width: 150px;
}
}
@media (max-width: 480px) {
.resource-cards {
grid-template-columns: 1fr;
}
.resource-card {
flex-direction: column;
text-align: center;
}
.card-icon {
margin: 0 auto;
}
}
</style>
<script>
function refreshDiskInfo() {
htmx.trigger('#disk-partitions', 'refresh');
}
function sortProcesses(sortBy) {
const sortParam = `?sort=${sortBy}`;
htmx.ajax('GET', `/api/monitoring/resources/processes${sortParam}`, {
target: '#process-list',
swap: 'innerHTML'
});
}
// Color-code usage based on percentage
function updateUsageColors() {
document.querySelectorAll('.card-value').forEach(el => {
const value = parseInt(el.textContent);
if (value >= 90) {
el.classList.add('error');
el.classList.remove('warning');
} else if (value >= 75) {
el.classList.add('warning');
el.classList.remove('error');
} else {
el.classList.remove('warning', 'error');
}
});
document.querySelectorAll('.usage-fill, .progress-fill').forEach(el => {
const width = parseInt(el.style.width);
if (width >= 90) {
el.classList.add('error');
el.classList.remove('warning');
} else if (width >= 75) {
el.classList.add('warning');
el.classList.remove('error');
} else {
el.classList.remove('warning', 'error');
}
});
}
// Run on HTMX swap
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.target.closest('.resources-container')) {
updateUsageColors();
}
});
// Initial color update
document.addEventListener('DOMContentLoaded', updateUsageColors);
</script>

View file

@ -0,0 +1,765 @@
<div class="services-container">
<!-- Services Header -->
<div class="services-header">
<div class="header-stats"
hx-get="/api/services/summary"
hx-trigger="load, every 10s"
hx-swap="innerHTML">
<div class="stat-item running">
<span class="stat-number">--</span>
<span class="stat-label">Running</span>
</div>
<div class="stat-item warning">
<span class="stat-number">--</span>
<span class="stat-label">Warning</span>
</div>
<div class="stat-item stopped">
<span class="stat-number">--</span>
<span class="stat-label">Stopped</span>
</div>
<div class="stat-item total">
<span class="stat-number">--</span>
<span class="stat-label">Total</span>
</div>
</div>
<div class="header-actions">
<div class="search-box">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input type="text"
id="service-search"
placeholder="Search services..."
onkeyup="filterServices(this.value)">
</div>
<select id="status-filter" onchange="filterByStatus(this.value)">
<option value="all">All Status</option>
<option value="running">Running</option>
<option value="warning">Warning</option>
<option value="stopped">Stopped</option>
</select>
<button class="action-btn" onclick="restartAllServices()" title="Restart All">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"></polyline>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
</svg>
Restart All
</button>
</div>
</div>
<!-- Services Grid -->
<div class="services-grid" id="services-grid"
hx-get="/api/services/status"
hx-trigger="load, every 10s"
hx-swap="innerHTML">
<!-- Loading placeholder -->
<div class="service-card skeleton">
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
</div>
<div class="service-card skeleton">
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
</div>
<div class="service-card skeleton">
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
</div>
<div class="service-card skeleton">
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
</div>
</div>
<!-- Service Detail Panel (slides in from right) -->
<div class="service-detail-panel" id="service-detail-panel">
<div class="panel-header">
<h3 id="detail-service-name">Service Details</h3>
<button class="close-btn" onclick="closeServiceDetail()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="panel-content" id="service-detail-content">
<!-- Loaded via HTMX -->
</div>
</div>
</div>
<!-- Service Card Template (for reference - rendered by server) -->
<template id="service-card-template">
<div class="service-card" data-status="running" data-service="service-name">
<div class="card-header">
<div class="service-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
<line x1="6" y1="6" x2="6.01" y2="6"></line>
<line x1="6" y1="18" x2="6.01" y2="18"></line>
</svg>
</div>
<div class="status-badge running">
<span class="status-dot"></span>
<span class="status-text">Running</span>
</div>
</div>
<div class="card-body">
<h4 class="service-name">Service Name</h4>
<p class="service-description">Service description goes here</p>
<div class="service-meta">
<span class="meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
Uptime: 24d 5h
</span>
<span class="meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="4" y="4" width="16" height="16" rx="2"></rect>
<rect x="9" y="9" width="6" height="6"></rect>
</svg>
CPU: 12%
</span>
<span class="meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="6" width="20" height="12" rx="2"></rect>
</svg>
Mem: 256MB
</span>
</div>
</div>
<div class="card-actions">
<button class="card-btn" onclick="viewServiceDetails('service-id')" title="Details">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>
</button>
<button class="card-btn" onclick="restartService('service-id')" title="Restart">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"></polyline>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
</svg>
</button>
<button class="card-btn" onclick="stopService('service-id')" title="Stop">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="6" y="4" width="4" height="16"></rect>
<rect x="14" y="4" width="4" height="16"></rect>
</svg>
</button>
<button class="card-btn" onclick="viewServiceLogs('service-id')" title="Logs">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
</button>
</div>
</div>
</template>
<style>
.services-container {
position: relative;
}
/* Header */
.services-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
.header-stats {
display: flex;
gap: 1.5rem;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.75rem 1.25rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
min-width: 80px;
}
.stat-item .stat-number {
font-size: 1.5rem;
font-weight: 700;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
}
.stat-item .stat-label {
font-size: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-item.running .stat-number { color: var(--success); }
.stat-item.warning .stat-number { color: var(--warning); }
.stat-item.stopped .stat-number { color: var(--error); }
.stat-item.total .stat-number { color: var(--primary); }
.header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.search-box {
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.5rem 0.75rem;
}
.search-box svg {
color: var(--text-secondary);
flex-shrink: 0;
}
.search-box input {
background: transparent;
border: none;
color: var(--text);
font-size: 0.875rem;
width: 180px;
outline: none;
}
.search-box input::placeholder {
color: var(--text-secondary);
}
.header-actions select {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
padding: 0.5rem 0.75rem;
border-radius: 6px;
font-size: 0.875rem;
cursor: pointer;
}
.header-actions select:focus {
outline: none;
border-color: var(--primary);
}
.action-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--primary);
color: white;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s ease;
}
.action-btn:hover {
background: var(--primary-hover);
}
/* Services Grid */
.services-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1rem;
}
.service-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
transition: all 0.2s ease;
}
.service-card:hover {
border-color: var(--primary);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.service-card.hidden {
display: none;
}
.service-card .card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
background: var(--bg);
border-bottom: 1px solid var(--border);
}
.service-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--primary-light);
border-radius: 8px;
color: var(--primary);
}
.status-badge {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.625rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 500;
}
.status-badge.running {
background: rgba(34, 197, 94, 0.1);
color: var(--success);
}
.status-badge.warning {
background: rgba(245, 158, 11, 0.1);
color: var(--warning);
}
.status-badge.stopped {
background: rgba(239, 68, 68, 0.1);
color: var(--error);
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.status-badge.running .status-dot {
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.card-body {
padding: 1rem;
}
.service-name {
font-size: 1rem;
font-weight: 600;
color: var(--text);
margin-bottom: 0.25rem;
}
.service-description {
font-size: 0.8125rem;
color: var(--text-secondary);
margin-bottom: 0.75rem;
line-height: 1.4;
}
.service-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.meta-item {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: var(--text-secondary);
}
.meta-item svg {
color: var(--primary);
}
.card-actions {
display: flex;
border-top: 1px solid var(--border);
}
.card-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 0.75rem;
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s ease;
}
.card-btn:hover {
background: var(--surface-hover);
color: var(--primary);
}
.card-btn:not(:last-child) {
border-right: 1px solid var(--border);
}
/* Skeleton Loading */
.service-card.skeleton {
pointer-events: none;
}
.service-card.skeleton .card-header,
.service-card.skeleton .card-body,
.service-card.skeleton .card-actions {
display: none;
}
.skeleton-line {
height: 16px;
background: linear-gradient(90deg, var(--border) 25%, var(--surface-hover) 50%, var(--border) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
margin: 1rem;
}
.skeleton-line.short {
width: 60%;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Detail Panel */
.service-detail-panel {
position: fixed;
top: 64px;
right: -450px;
width: 450px;
height: calc(100vh - 64px);
background: var(--surface);
border-left: 1px solid var(--border);
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.2);
z-index: 200;
transition: right 0.3s ease;
display: flex;
flex-direction: column;
}
.service-detail-panel.open {
right: 0;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border);
}
.panel-header h3 {
font-size: 1rem;
font-weight: 600;
color: var(--text);
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: transparent;
border: none;
color: var(--text-secondary);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.close-btn:hover {
background: var(--surface-hover);
color: var(--text);
}
.panel-content {
flex: 1;
overflow-y: auto;
padding: 1.25rem;
}
/* Detail Content Sections */
.detail-section {
margin-bottom: 1.5rem;
}
.detail-section-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.75rem;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid var(--border);
}
.detail-row:last-child {
border-bottom: none;
}
.detail-label {
color: var(--text-secondary);
font-size: 0.875rem;
}
.detail-value {
color: var(--text);
font-size: 0.875rem;
font-weight: 500;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
}
.detail-actions {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.detail-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.625rem;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.detail-btn.primary {
background: var(--primary);
color: white;
border: none;
}
.detail-btn.primary:hover {
background: var(--primary-hover);
}
.detail-btn.secondary {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
}
.detail-btn.secondary:hover {
background: var(--surface-hover);
border-color: var(--primary);
}
.detail-btn.danger {
background: transparent;
color: var(--error);
border: 1px solid var(--error);
}
.detail-btn.danger:hover {
background: rgba(239, 68, 68, 0.1);
}
/* Responsive */
@media (max-width: 768px) {
.services-header {
flex-direction: column;
align-items: stretch;
}
.header-stats {
justify-content: space-between;
}
.stat-item {
flex: 1;
min-width: auto;
padding: 0.5rem;
}
.stat-item .stat-number {
font-size: 1.25rem;
}
.header-actions {
flex-wrap: wrap;
}
.search-box {
flex: 1;
min-width: 150px;
}
.search-box input {
width: 100%;
}
.services-grid {
grid-template-columns: 1fr;
}
.service-detail-panel {
width: 100%;
right: -100%;
}
}
</style>
<script>
function filterServices(query) {
const cards = document.querySelectorAll('.service-card:not(.skeleton)');
const lowerQuery = query.toLowerCase();
cards.forEach(card => {
const name = card.dataset.service?.toLowerCase() || '';
const text = card.textContent.toLowerCase();
const matches = name.includes(lowerQuery) || text.includes(lowerQuery);
card.classList.toggle('hidden', !matches);
});
}
function filterByStatus(status) {
const cards = document.querySelectorAll('.service-card:not(.skeleton)');
cards.forEach(card => {
if (status === 'all') {
card.classList.remove('hidden');
} else {
const cardStatus = card.dataset.status;
card.classList.toggle('hidden', cardStatus !== status);
}
});
}
function viewServiceDetails(serviceId) {
const panel = document.getElementById('service-detail-panel');
const content = document.getElementById('service-detail-content');
// Load service details via HTMX
htmx.ajax('GET', `/api/services/${serviceId}/details`, {
target: content,
swap: 'innerHTML'
});
document.getElementById('detail-service-name').textContent = serviceId;
panel.classList.add('open');
}
function closeServiceDetail() {
document.getElementById('service-detail-panel').classList.remove('open');
}
function restartService(serviceId) {
if (confirm(`Are you sure you want to restart ${serviceId}?`)) {
htmx.ajax('POST', `/api/services/${serviceId}/restart`, {
swap: 'none'
}).then(() => {
htmx.trigger('#services-grid', 'refresh');
});
}
}
function stopService(serviceId) {
if (confirm(`Are you sure you want to stop ${serviceId}?`)) {
htmx.ajax('POST', `/api/services/${serviceId}/stop`, {
swap: 'none'
}).then(() => {
htmx.trigger('#services-grid', 'refresh');
});
}
}
function startService(serviceId) {
htmx.ajax('POST', `/api/services/${serviceId}/start`, {
swap: 'none'
}).then(() => {
htmx.trigger('#services-grid', 'refresh');
});
}
function viewServiceLogs(serviceId) {
// Navigate to logs with service filter
const logsLink = document.querySelector('.nav-item[href="#logs"]');
if (logsLink) {
logsLink.click();
setTimeout(() => {
const serviceFilter = document.getElementById('service-filter');
if (serviceFilter) {
serviceFilter.value = serviceId;
serviceFilter.dispatchEvent(new Event('change'));
}
}, 300);
}
}
function restartAllServices() {
if (confirm('Are you sure you want to restart all services? This may cause temporary downtime.')) {
htmx.ajax('POST', '/api/services/restart-all', {
swap: 'none'
}).then(() => {
htmx.trigger('#services-grid', 'refresh');
});
}
}
// Close panel on escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeServiceDetail();
}
});
// Close panel when clicking outside
document.addEventListener('click', function(e) {
const panel = document.getElementById('service-detail-panel');
if (panel.classList.contains('open') &&
!panel.contains(e.target) &&
!e.target.closest('.card-btn')) {
closeServiceDetail();
}
});
</script>

1977
ui/suite/settings/index.html Normal file

File diff suppressed because it is too large Load diff