Update: General project updates
This commit is contained in:
parent
4f5606d898
commit
b0929db172
25 changed files with 23775 additions and 1248 deletions
472
AI_IMPLEMENTATION_PROMPT.md
Normal file
472
AI_IMPLEMENTATION_PROMPT.md
Normal 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
1384
IMPLEMENTATION_PLAN.md
Normal file
File diff suppressed because it is too large
Load diff
17
PROMPT.md
17
PROMPT.md
|
|
@ -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
|
## Official Icons - MANDATORY
|
||||||
|
|
||||||
**NEVER generate icons with LLM. ALWAYS use official SVG icons from:**
|
**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
|
- **cargo audit**: Must pass with 0 warnings
|
||||||
- **No business logic**: All logic in botserver
|
- **No business logic**: All logic in botserver
|
||||||
- **Feature gates**: Unused code never compiles
|
- **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
791
ui/suite/admin/dns.html
Normal 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
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
934
ui/suite/admin/index.html
Normal 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
897
ui/suite/admin/users.html
Normal 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>
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<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">
|
||||||
<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"/>
|
<circle cx="12" cy="12" r="3"/>
|
||||||
<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"/>
|
<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>
|
</svg>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 4 KiB After Width: | Height: | Size: 974 B |
740
ui/suite/auth/forgot-password.html
Normal file
740
ui/suite/auth/forgot-password.html
Normal 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
1322
ui/suite/auth/register.html
Normal file
File diff suppressed because it is too large
Load diff
1116
ui/suite/auth/reset-password.html
Normal file
1116
ui/suite/auth/reset-password.html
Normal file
File diff suppressed because it is too large
Load diff
1409
ui/suite/base.html
1409
ui/suite/base.html
File diff suppressed because it is too large
Load diff
|
|
@ -81,6 +81,34 @@
|
||||||
<!-- Upcoming events loaded here -->
|
<!-- Upcoming events loaded here -->
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</aside>
|
||||||
|
|
||||||
<!-- Main Calendar -->
|
<!-- Main Calendar -->
|
||||||
|
|
@ -369,6 +397,48 @@
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<style>
|
||||||
/* Calendar Container */
|
/* Calendar Container */
|
||||||
.calendar-container {
|
.calendar-container {
|
||||||
|
|
@ -1303,6 +1373,35 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* 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) {
|
@media (max-width: 768px) {
|
||||||
.calendar-sidebar {
|
.calendar-sidebar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,42 @@
|
||||||
<span id="storage-detail">Calculating...</span>
|
<span id="storage-detail">Calculating...</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</aside>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
|
|
@ -161,6 +197,15 @@
|
||||||
<option value="type">Type</option>
|
<option value="type">Type</option>
|
||||||
</select>
|
</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()">
|
<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">
|
<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>
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
|
@ -214,8 +259,16 @@
|
||||||
</svg>
|
</svg>
|
||||||
Download
|
Download
|
||||||
</button>
|
</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"
|
<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">
|
<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>
|
<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>
|
</svg>
|
||||||
|
|
@ -375,6 +428,300 @@
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</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 -->
|
<!-- Context Menu -->
|
||||||
<div class="context-menu" id="context-menu" style="display: none;">
|
<div class="context-menu" id="context-menu" style="display: none;">
|
||||||
<button class="context-item" data-action="open">
|
<button class="context-item" data-action="open">
|
||||||
|
|
@ -401,7 +748,8 @@
|
||||||
</svg>
|
</svg>
|
||||||
Download
|
Download
|
||||||
</button>
|
</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">
|
<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="18" cy="5" r="3"></circle>
|
||||||
<circle cx="6" cy="12" r="3"></circle>
|
<circle cx="6" cy="12" r="3"></circle>
|
||||||
|
|
@ -419,7 +767,16 @@
|
||||||
</svg>
|
</svg>
|
||||||
Rename
|
Rename
|
||||||
</button>
|
</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">
|
<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>
|
<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>
|
</svg>
|
||||||
|
|
@ -431,6 +788,14 @@
|
||||||
</svg>
|
</svg>
|
||||||
Add to starred
|
Add to starred
|
||||||
</button>
|
</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>
|
<div class="context-divider"></div>
|
||||||
<button class="context-item danger" data-action="delete">
|
<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">
|
<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);
|
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 */
|
/* Main Content */
|
||||||
.drive-main {
|
.drive-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -1362,4 +1812,121 @@
|
||||||
}
|
}
|
||||||
</style>
|
</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
1573
ui/suite/monitoring/alerts.html
Normal file
1573
ui/suite/monitoring/alerts.html
Normal file
File diff suppressed because it is too large
Load diff
994
ui/suite/monitoring/health.html
Normal file
994
ui/suite/monitoring/health.html
Normal 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>
|
||||||
783
ui/suite/monitoring/index.html
Normal file
783
ui/suite/monitoring/index.html
Normal 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>
|
||||||
1087
ui/suite/monitoring/logs.html
Normal file
1087
ui/suite/monitoring/logs.html
Normal file
File diff suppressed because it is too large
Load diff
895
ui/suite/monitoring/metrics.html
Normal file
895
ui/suite/monitoring/metrics.html
Normal 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>
|
||||||
937
ui/suite/monitoring/resources.html
Normal file
937
ui/suite/monitoring/resources.html
Normal 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>
|
||||||
765
ui/suite/monitoring/services.html
Normal file
765
ui/suite/monitoring/services.html
Normal 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
1977
ui/suite/settings/index.html
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue