testing claude code
This commit is contained in:
parent
1ad4663e6e
commit
a7660d0b8d
66 changed files with 10837 additions and 457 deletions
935
packages/Dashboard/dashboard-prd.md
Normal file
935
packages/Dashboard/dashboard-prd.md
Normal file
|
@ -0,0 +1,935 @@
|
|||
# Home Lab Dashboard - Product Requirements Document (PRD)
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The Home Lab Dashboard is a Vue 3-based Single Page Application (SPA) designed to provide comprehensive introspection and monitoring of a home lab infrastructure at a glance. The dashboard serves as a central command center for observing system health, service status, resource utilization, and operational metrics across the entire home lab ecosystem.
|
||||
|
||||
**Core Value Proposition**: Deliver instant visibility into home lab operations through a clean, modern interface that transforms complex infrastructure data into actionable insights.
|
||||
|
||||
## Product Vision
|
||||
|
||||
**Vision Statement**: "A unified dashboard that transforms home lab complexity into clarity, enabling operators to understand, monitor, and manage their infrastructure through elegant data visualization and real-time insights."
|
||||
|
||||
**Mission**: Provide home lab operators with immediate situational awareness of their infrastructure through intuitive data presentation, reducing time-to-insight from minutes to seconds.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Product Overview](#product-overview)
|
||||
2. [User Personas & Use Cases](#user-personas--use-cases)
|
||||
3. [Functional Requirements](#functional-requirements)
|
||||
4. [Technical Architecture](#technical-architecture)
|
||||
5. [User Interface Design](#user-interface-design)
|
||||
6. [Data Requirements](#data-requirements)
|
||||
7. [Performance Requirements](#performance-requirements)
|
||||
8. [Security Requirements](#security-requirements)
|
||||
9. [Implementation Roadmap](#implementation-roadmap)
|
||||
|
||||
## Product Overview
|
||||
|
||||
### Problem Statement
|
||||
|
||||
Home lab operators face several challenges:
|
||||
|
||||
- **Information Scatter**: Critical metrics are spread across multiple tools and interfaces - solution: use netdata already part of the project not implemented
|
||||
- **Context Switching**: Constant jumping between different monitoring solutions
|
||||
- **Alert Fatigue**: Too many notifications without proper context or prioritization
|
||||
- **Lack of Holistic View**: Difficulty understanding system interdependencies
|
||||
- **Response Delay**: Slow access to critical information during incidents
|
||||
|
||||
### Solution Overview
|
||||
|
||||
The Home Lab Dashboard addresses these challenges by providing:
|
||||
|
||||
- **Unified Data Presentation**: All critical metrics in a single interface
|
||||
- **Real-time Monitoring**: Live updates without manual refresh
|
||||
- **Contextual Information**: Related data grouped logically
|
||||
- **Priority-based Alerts**: Intelligent notification system
|
||||
- **Quick Action Access**: Direct links to detailed tools when needed
|
||||
|
||||
### Key Success Metrics
|
||||
|
||||
- **Time to Information**: < 3 seconds to load critical system status
|
||||
- **User Engagement**: 95% of daily monitoring tasks completed within dashboard
|
||||
- **Alert Accuracy**: < 5% false positive rate for critical alerts
|
||||
- **Performance**: Dashboard loads in < 2 seconds on typical home network
|
||||
- **Uptime**: 99.9% availability during normal operations
|
||||
|
||||
## User Personas & Use Cases
|
||||
|
||||
### Primary Persona: The Home Lab Operator
|
||||
|
||||
**Profile**:
|
||||
|
||||
- Technical background with system administration experience
|
||||
- Manages 5-20 services across 2-5 physical machines
|
||||
- Values efficiency and quick problem resolution
|
||||
- Prefers clean, information-dense interfaces
|
||||
- Uses dashboard multiple times daily for routine checks
|
||||
|
||||
**Primary Use Cases**:
|
||||
|
||||
#### UC-1: Daily Health Check
|
||||
|
||||
**Goal**: Quickly assess overall system health
|
||||
**Frequency**: 2-3 times daily
|
||||
**Scenario**: Operator opens dashboard to verify all services are running normally
|
||||
**Success Criteria**: Complete system status visible within 3 seconds
|
||||
|
||||
#### UC-2: Incident Response
|
||||
|
||||
**Goal**: Rapidly identify and assess system issues
|
||||
**Frequency**: Ad-hoc (when alerts trigger)
|
||||
**Scenario**: Operator receives alert and needs immediate context
|
||||
**Success Criteria**: Problem source and impact clearly identified within 10 seconds
|
||||
|
||||
#### UC-3: Capacity Planning
|
||||
|
||||
**Goal**: Monitor resource utilization trends
|
||||
**Frequency**: Weekly
|
||||
**Scenario**: Operator reviews resource usage to plan upgrades
|
||||
**Success Criteria**: Historical and current resource data easily accessible
|
||||
|
||||
#### UC-4: Service Management
|
||||
|
||||
**Goal**: Monitor specific service health and performance
|
||||
**Frequency**: Daily
|
||||
**Scenario**: Operator checks critical services (NAS, reverse proxy, etc.)
|
||||
**Success Criteria**: Service-specific metrics clearly presented
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
### F-1: Authentication & Access Control
|
||||
|
||||
#### F-1.1: User Authentication
|
||||
|
||||
- **Requirement**: Secure login system with session management
|
||||
- **Implementation**: Simple username/password authentication
|
||||
- **Validation**: Session timeout after 24 hours of inactivity
|
||||
- **Security**: Password hashing, CSRF protection, secure cookies
|
||||
|
||||
#### F-1.2: Single Session Management
|
||||
|
||||
- **Requirement**: Prevent multiple concurrent sessions
|
||||
- **Implementation**: Token-based session invalidation
|
||||
- **Behavior**: New login invalidates previous sessions
|
||||
|
||||
### F-2: Dashboard Layout & Navigation
|
||||
|
||||
#### F-2.1: Two-Page Architecture
|
||||
|
||||
- **Page 1**: Login page with centered login component
|
||||
- **Page 2**: Dashboard page with header, aside, and main sections
|
||||
- **Navigation**: SPA routing between pages
|
||||
- **State**: Preserve application state during navigation
|
||||
|
||||
#### F-2.2: Responsive Layout Structure
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Header │
|
||||
├─────────┬───────────────────────────┤
|
||||
│ │ │
|
||||
│ Aside │ Main │
|
||||
│ │ Content │
|
||||
│ (Menu) │ (Widgets) │
|
||||
│ │ │
|
||||
└─────────┴───────────────────────────┘
|
||||
```
|
||||
|
||||
### F-3: Core Dashboard Components
|
||||
|
||||
#### F-3.1: System Overview Widget
|
||||
|
||||
- **Purpose**: High-level system health status
|
||||
- **Metrics**: Overall health score, critical alerts count, services running
|
||||
- **Visual**: Status indicators (green/yellow/red) with summary numbers
|
||||
- **Update Frequency**: Real-time (< 30 seconds)
|
||||
|
||||
#### F-3.2: Infrastructure Status Widget
|
||||
|
||||
- **Purpose**: Physical host and VM status
|
||||
- **Metrics**: CPU, RAM, disk usage per host
|
||||
- **Visual**: Progress bars or gauges with percentage values
|
||||
- **Drill-down**: Click to view detailed host information
|
||||
|
||||
#### F-3.3: Service Health Matrix
|
||||
|
||||
- **Purpose**: Status of all monitored services
|
||||
- **Display**: Grid layout with service names and status indicators
|
||||
- **Information**: Service name, status, uptime, last check time
|
||||
- **Interaction**: Click service for detailed metrics
|
||||
|
||||
#### F-3.4: Network Status Widget
|
||||
|
||||
- **Purpose**: Network connectivity and performance
|
||||
- **Metrics**: Internet connectivity, internal network status, bandwidth usage
|
||||
- **Visual**: Connection status icons with performance indicators
|
||||
- **Alerts**: Network outage or degraded performance warnings
|
||||
|
||||
#### F-3.5: Storage Overview Widget
|
||||
|
||||
- **Purpose**: Storage capacity and health across all systems
|
||||
- **Metrics**: Used/available space, RAID status, backup status
|
||||
- **Visual**: Capacity bars with health indicators
|
||||
- **Critical Alerts**: Storage approaching capacity (>80% full)
|
||||
|
||||
#### F-3.6: Recent Events Feed
|
||||
|
||||
- **Purpose**: Chronological list of system events
|
||||
- **Content**: Service starts/stops, alerts, configuration changes
|
||||
- **Display**: Time-ordered list with event type icons
|
||||
- **Retention**: Last 50 events or 24 hours, whichever is more
|
||||
|
||||
#### F-3.7: Quick Actions Panel
|
||||
|
||||
- **Purpose**: Common administrative tasks
|
||||
- **Actions**: Restart services, view logs, access admin interfaces
|
||||
- **Implementation**: Direct links or API calls where possible
|
||||
- **Security**: Role-based action availability
|
||||
|
||||
### F-4: Data Management & State
|
||||
|
||||
#### F-4.1: Centralized Store Architecture
|
||||
|
||||
- **Implementation**: Pinia store as single source of truth
|
||||
- **Pattern**: All components receive data via props
|
||||
- **Communication**: Components emit events to store
|
||||
- **State Management**: Store handles all business logic and API calls
|
||||
|
||||
#### F-4.2: Real-time Data Updates
|
||||
|
||||
- **Mechanism**: WebSocket or Server-Sent Events
|
||||
- **Frequency**: Configurable (default 30 seconds)
|
||||
- **Efficiency**: Only updated data transmitted
|
||||
- **Fallback**: Polling mechanism if real-time unavailable
|
||||
|
||||
#### F-4.3: Data Persistence
|
||||
|
||||
- **Client-side**: Store critical state in localStorage
|
||||
- **Recovery**: Restore state on page reload
|
||||
- **Expiration**: Clear stale data after 24 hours
|
||||
- **Privacy**: No sensitive data in local storage
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Technology Stack
|
||||
|
||||
#### Frontend Framework
|
||||
|
||||
- **Vue 3.5.13**: Latest stable version with Composition API and `<script setup>` syntax
|
||||
- **TypeScript 5.8**: Full type safety throughout application with strict mode
|
||||
- **Vite 6.2.4**: Next-generation build tool for fast development and optimized production builds
|
||||
- **Pinia 3.0.1**: Official Vue state management library with TypeScript support
|
||||
- **Vue Router 4**: Client-side routing for SPA navigation with route guards
|
||||
|
||||
#### Styling & Design System
|
||||
|
||||
- **Tailwind CSS v4 (Latest)**: Modern utility-first CSS framework with new engine
|
||||
- **@tailwindcss/vite**: Dedicated Vite plugin for optimized integration
|
||||
- **CSS-first Configuration**: Using `@theme` directive for design tokens
|
||||
- **No Config File**: Streamlined setup without separate `tailwind.config.js`
|
||||
- **Built-in Import Support**: Native `@import` handling without PostCSS plugins
|
||||
- **Improved Performance**: Faster builds and better dev experience
|
||||
- **Component Design System**: Custom component library built on Tailwind utilities
|
||||
- **Responsive Design**: Mobile-first approach with desktop optimization
|
||||
- **Modern Color System**: Using oklch() color space for better color consistency
|
||||
|
||||
#### Development Tools & Quality
|
||||
|
||||
- **ESLint 9.22**: Code linting with Vue 3 and TypeScript rules
|
||||
- **Prettier 3.5.3**: Consistent code formatting across the project
|
||||
- **Vitest 3.1.1**: Fast unit testing framework with Vue Test Utils
|
||||
- **Vue DevTools**: Development debugging and inspection
|
||||
- **Oxlint 0.16**: Fast linting for additional code quality checks
|
||||
- **TypeScript Strict Mode**: Comprehensive type checking and safety
|
||||
|
||||
### Application Architecture
|
||||
|
||||
#### Implemented Component Hierarchy
|
||||
|
||||
```plaintext
|
||||
App.vue (Router outlet with auth initialization)
|
||||
├── LoginView.vue (Route: /)
|
||||
│ └── LoginForm.vue (Reactive form with validation)
|
||||
└── DashboardView.vue (Route: /dashboard)
|
||||
└── DashboardLayout.vue
|
||||
├── DashboardHeader.vue (User info, logout, refresh)
|
||||
├── DashboardSidebar.vue (Navigation, quick links)
|
||||
└── Main Content Area
|
||||
├── SystemCard.vue (System info overview)
|
||||
├── ServiceStatusCard.vue (Service health summary)
|
||||
├── UptimeCard.vue (System uptime display)
|
||||
├── LoadAverageCard.vue (CPU load metrics)
|
||||
├── ServicesTable.vue (Detailed service status)
|
||||
├── QuickActions.vue (Action buttons grid)
|
||||
├── ErrorAlert.vue (Error message display)
|
||||
└── LoadingSpinner.vue (Loading indicator)
|
||||
```
|
||||
|
||||
#### Store Architecture (Pinia)
|
||||
|
||||
```typescript
|
||||
// Implemented Store Modules
|
||||
├── authStore.ts // Authentication state, login/logout actions
|
||||
│ ├── State: user, isAuthenticated, isLoading, error
|
||||
│ ├── Getters: currentUser, isLoggedIn
|
||||
│ └── Actions: login(), logout(), initializeAuth()
|
||||
│
|
||||
├── dashboardStore.ts // System metrics and service status
|
||||
│ ├── State: systemInfo, services, isLoading, error, lastUpdated
|
||||
│ ├── Getters: runningServices, stoppedServices, errorServices,
|
||||
│ │ memoryUsagePercent, diskUsagePercent
|
||||
│ └── Actions: fetchSystemInfo(), fetchServices(), refreshData()
|
||||
│
|
||||
└── Future Modules:
|
||||
├── networkStore.ts // Network metrics and connectivity
|
||||
├── storageStore.ts // Storage capacity and health
|
||||
├── eventsStore.ts // Event feed and notifications
|
||||
└── configStore.ts // User preferences and settings
|
||||
```
|
||||
|
||||
#### Component Communication Pattern
|
||||
|
||||
```plaintext
|
||||
┌─────────────┐ Props ┌─────────────┐
|
||||
│ Pinia │ ──────────> │ Component │
|
||||
│ Store │ │ │
|
||||
│ │ <────────── │ │
|
||||
└─────────────┘ Emits └─────────────┘
|
||||
│
|
||||
│ API Calls (Future)
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ Backend │ ← Netdata Integration
|
||||
│ APIs │ ← System APIs
|
||||
└─────────────┘ ← Service Discovery
|
||||
```
|
||||
|
||||
### Data Flow Architecture
|
||||
|
||||
#### Current Implementation
|
||||
|
||||
- **Mock Data**: Demo system info and service status for development
|
||||
- **Local Storage**: Authentication state persistence
|
||||
- **Reactive State**: Real-time UI updates via Pinia stores
|
||||
- **Route Guards**: Authentication-based navigation protection
|
||||
|
||||
#### Planned API Integration Strategy
|
||||
|
||||
- **Netdata API**: Primary data source for system metrics (port 19999)
|
||||
- **System APIs**: Direct integration with home lab services
|
||||
- **WebSocket/SSE**: Real-time updates for dynamic metrics (future)
|
||||
- **Service Discovery**: Automatic detection of running services
|
||||
- **Caching Strategy**: Browser and application-level caching
|
||||
|
||||
#### State Management Pattern
|
||||
|
||||
```typescript
|
||||
// Implemented Store Pattern (authStore example)
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// State (reactive refs)
|
||||
const user = ref<User | null>(null)
|
||||
const isAuthenticated = ref<boolean>(false)
|
||||
const isLoading = ref<boolean>(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Getters (computed properties)
|
||||
const currentUser = computed(() => user.value)
|
||||
const isLoggedIn = computed(() => isAuthenticated.value && user.value !== null)
|
||||
|
||||
// Actions (methods)
|
||||
const login = async (username: string, password: string) => {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
// Authentication logic with error handling
|
||||
const result = await authenticateUser(username, password)
|
||||
if (result.success) {
|
||||
user.value = result.user
|
||||
isAuthenticated.value = true
|
||||
localStorage.setItem('isAuthenticated', 'true')
|
||||
localStorage.setItem('user', JSON.stringify(user.value))
|
||||
}
|
||||
return result
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
return { success: false, error: error.value }
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
user.value = null
|
||||
isAuthenticated.value = false
|
||||
error.value = null
|
||||
localStorage.removeItem('isAuthenticated')
|
||||
localStorage.removeItem('user')
|
||||
}
|
||||
|
||||
return {
|
||||
// Readonly state exposure
|
||||
user: readonly(user),
|
||||
isAuthenticated: readonly(isAuthenticated),
|
||||
isLoading: readonly(isLoading),
|
||||
error: readonly(error),
|
||||
|
||||
// Computed getters
|
||||
currentUser,
|
||||
isLoggedIn,
|
||||
|
||||
// Actions
|
||||
login,
|
||||
logout,
|
||||
initializeAuth,
|
||||
clearError
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Build & Deployment Configuration
|
||||
|
||||
#### Vite Configuration
|
||||
|
||||
```typescript
|
||||
// vite.config.ts - Optimized for Tailwind CSS v4
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
tailwindcss(), // New Tailwind CSS v4 Vite plugin
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
#### Tailwind CSS v4 Integration
|
||||
|
||||
```css
|
||||
/* src/assets/main.css - New v4 syntax */
|
||||
@import "tailwindcss";
|
||||
|
||||
#app {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Future: Custom design tokens with @theme directive */
|
||||
/*
|
||||
@theme {
|
||||
--color-homelab-primary: oklch(0.5 0.2 250);
|
||||
--color-homelab-secondary: oklch(0.7 0.15 180);
|
||||
--font-display: "Inter", sans-serif;
|
||||
}
|
||||
*/
|
||||
```
|
||||
|
||||
## User Interface Design
|
||||
|
||||
### Design Principles
|
||||
|
||||
#### Clarity Over Complexity
|
||||
|
||||
- **Information Hierarchy**: Most critical data prominently displayed
|
||||
- **Visual Hierarchy**: Size, color, and position indicate importance
|
||||
- **Reduced Cognitive Load**: Group related information together
|
||||
- **Progressive Disclosure**: Summary first, details on demand
|
||||
|
||||
#### Consistency & Standards
|
||||
|
||||
- **Component Library**: Reusable UI components with consistent styling
|
||||
- **Color System**: Semantic colors for status indication
|
||||
- **Typography**: Clear typography hierarchy
|
||||
- **Spacing**: Consistent spacing system using Tailwind utilities
|
||||
|
||||
#### Performance & Accessibility
|
||||
|
||||
- **Fast Rendering**: Optimized for quick loading and updates
|
||||
- **Keyboard Navigation**: Full keyboard accessibility
|
||||
- **Screen Reader Support**: Proper ARIA labels and semantic HTML
|
||||
- **Color Contrast**: WCAG 2.1 AA compliance
|
||||
|
||||
### Page Layouts
|
||||
|
||||
#### Login Page
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
<!-- Logo/Title -->
|
||||
<div class="text-center">
|
||||
<h2 class="text-3xl font-bold text-gray-900">
|
||||
Home Lab Dashboard
|
||||
</h2>
|
||||
<p class="text-gray-600 mt-2">
|
||||
Monitor your infrastructure at a glance
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Login Component -->
|
||||
<LoginComponent @login="handleLogin" :loading="isLoading" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
#### Dashboard Page
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-100">
|
||||
<!-- Header -->
|
||||
<HeaderComponent
|
||||
:user="currentUser"
|
||||
@logout="handleLogout"
|
||||
@refresh="handleRefresh"
|
||||
/>
|
||||
|
||||
<div class="flex">
|
||||
<!-- Sidebar -->
|
||||
<AsideComponent
|
||||
:active-section="activeSection"
|
||||
@section-change="handleSectionChange"
|
||||
/>
|
||||
|
||||
<!-- Main Content -->
|
||||
<MainComponent class="flex-1">
|
||||
<!-- Widget Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 p-6">
|
||||
<!-- System Overview -->
|
||||
<SystemOverviewWidget
|
||||
:system-health="systemHealth"
|
||||
:loading="systemLoading"
|
||||
class="col-span-1 md:col-span-2 lg:col-span-1"
|
||||
/>
|
||||
|
||||
<!-- Infrastructure Status -->
|
||||
<InfrastructureStatusWidget
|
||||
:hosts="infrastructure"
|
||||
:loading="infraLoading"
|
||||
class="col-span-1 md:col-span-2 lg:col-span-2"
|
||||
/>
|
||||
|
||||
<!-- Service Health Matrix -->
|
||||
<ServiceHealthMatrix
|
||||
:services="services"
|
||||
:loading="servicesLoading"
|
||||
@service-click="handleServiceClick"
|
||||
class="col-span-1 md:col-span-2 lg:col-span-3"
|
||||
/>
|
||||
|
||||
<!-- Additional widgets... -->
|
||||
</div>
|
||||
</MainComponent>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Widget Design Specifications
|
||||
|
||||
#### System Overview Widget
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 🟢 System Health GOOD │
|
||||
├─────────────────────────────────────┤
|
||||
│ Services Running 24/25 │
|
||||
│ Critical Alerts 0 │
|
||||
│ Last Update 2 min ago │
|
||||
│ │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │CPU 45% │ │RAM 67% │ │Net ✓ │ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Service Health Matrix
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Service Health Matrix │
|
||||
├─────────────────────────────────────┤
|
||||
│ 🟢 nginx-proxy ⏱ 25d 14h │
|
||||
│ 🟢 homeassistant ⏱ 12d 6h │
|
||||
│ 🟡 jellyfin ⏱ 2h 15m │
|
||||
│ 🔴 grafana ❌ Down │
|
||||
│ 🟢 pihole ⏱ 45d 3h │
|
||||
│ 🟢 nextcloud ⏱ 8d 11h │
|
||||
│ │
|
||||
│ [View All Services] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Color System
|
||||
|
||||
#### Status Colors
|
||||
|
||||
- **Green (#10B981)**: Healthy, operational, success
|
||||
- **Yellow (#F59E0B)**: Warning, degraded performance, attention needed
|
||||
- **Red (#EF4444)**: Critical, error, down, immediate action required
|
||||
- **Blue (#3B82F6)**: Information, in progress, neutral status
|
||||
- **Gray (#6B7280)**: Unknown, disabled, inactive
|
||||
|
||||
#### Interface Colors
|
||||
|
||||
- **Background**: Gray-50 to Gray-100 gradient
|
||||
- **Cards**: White with subtle shadow
|
||||
- **Text**: Gray-900 (primary), Gray-600 (secondary)
|
||||
- **Borders**: Gray-200 for subtle separation
|
||||
- **Accents**: Blue-600 for interactive elements
|
||||
|
||||
## Data Requirements
|
||||
|
||||
### Data Sources & APIs
|
||||
|
||||
#### System Metrics API
|
||||
|
||||
```typescript
|
||||
interface SystemHealth {
|
||||
status: 'healthy' | 'degraded' | 'critical'
|
||||
uptime: number // seconds
|
||||
lastUpdate: Date
|
||||
cpu: ResourceUsage
|
||||
memory: ResourceUsage
|
||||
disk: ResourceUsage
|
||||
network: NetworkStatus
|
||||
}
|
||||
|
||||
interface ResourceUsage {
|
||||
used: number
|
||||
total: number
|
||||
percentage: number
|
||||
trend: 'increasing' | 'stable' | 'decreasing'
|
||||
}
|
||||
```
|
||||
|
||||
#### Service Status API
|
||||
|
||||
```typescript
|
||||
interface ServiceStatus {
|
||||
id: string
|
||||
name: string
|
||||
status: 'running' | 'stopped' | 'error' | 'unknown'
|
||||
uptime: number
|
||||
lastCheck: Date
|
||||
healthCheck: HealthCheck
|
||||
metrics: ServiceMetrics
|
||||
}
|
||||
|
||||
interface HealthCheck {
|
||||
url?: string
|
||||
status: number
|
||||
responseTime: number
|
||||
message?: string
|
||||
}
|
||||
```
|
||||
|
||||
#### Infrastructure Data
|
||||
|
||||
```typescript
|
||||
interface Host {
|
||||
id: string
|
||||
name: string
|
||||
type: 'physical' | 'vm' | 'container'
|
||||
status: 'online' | 'offline' | 'maintenance'
|
||||
resources: ResourceUsage
|
||||
services: string[] // Service IDs running on this host
|
||||
lastSeen: Date
|
||||
}
|
||||
```
|
||||
|
||||
### Data Update Strategies
|
||||
|
||||
#### Real-time Updates
|
||||
|
||||
- **High Frequency (10-30s)**: System health, service status
|
||||
- **Medium Frequency (1-5min)**: Resource usage, network status
|
||||
- **Low Frequency (5-15min)**: Storage status, event logs
|
||||
- **On-demand**: Detailed service information, logs
|
||||
|
||||
#### Caching Strategy
|
||||
|
||||
- **Browser Cache**: Static resources (6 hours)
|
||||
- **Application Cache**: API responses (30 seconds to 5 minutes)
|
||||
- **Local Storage**: User preferences, dashboard layout
|
||||
- **Memory Cache**: Frequently accessed computed values
|
||||
|
||||
### Error Handling & Resilience
|
||||
|
||||
#### API Error Scenarios
|
||||
|
||||
- **Network Timeout**: Retry with exponential backoff
|
||||
- **Service Unavailable**: Show cached data with staleness indicator
|
||||
- **Authentication Failure**: Redirect to login page
|
||||
- **Data Corruption**: Validate and sanitize all incoming data
|
||||
|
||||
#### Graceful Degradation
|
||||
|
||||
- **Partial Data**: Show available information, indicate missing data
|
||||
- **Real-time Failure**: Fall back to polling mechanism
|
||||
- **Critical Service Down**: Maintain basic functionality with reduced features
|
||||
|
||||
## Performance Requirements
|
||||
|
||||
### Load Time Requirements
|
||||
|
||||
- **Initial Page Load**: < 2 seconds on typical home network
|
||||
- **Dashboard Transition**: < 500ms from login to dashboard
|
||||
- **Widget Updates**: < 200ms per widget refresh
|
||||
- **API Response Time**: < 1 second for most endpoints
|
||||
|
||||
### Scalability Considerations
|
||||
|
||||
- **Concurrent Users**: Support 5-10 simultaneous sessions
|
||||
- **Data Volume**: Handle 100+ services and 10+ hosts efficiently
|
||||
- **Update Frequency**: Support sub-30-second refresh intervals
|
||||
- **Browser Performance**: Maintain 60fps scrolling and interactions
|
||||
|
||||
### Optimization Strategies
|
||||
|
||||
- **Code Splitting**: Lazy load non-critical components
|
||||
- **Asset Optimization**: Minimize bundle size, compress images
|
||||
- **API Efficiency**: Batch requests, implement pagination
|
||||
- **Rendering Optimization**: Virtual scrolling for large lists
|
||||
|
||||
## Security Requirements
|
||||
|
||||
### Authentication Security
|
||||
|
||||
- **Password Policy**: Minimum 8 characters, complexity requirements
|
||||
- **Session Management**: Secure tokens, automatic timeout
|
||||
- **CSRF Protection**: Token-based request validation
|
||||
- **XSS Prevention**: Content Security Policy, input sanitization
|
||||
|
||||
### Network Security
|
||||
|
||||
- **HTTPS Only**: Force secure connections in production
|
||||
- **API Security**: API keys, rate limiting, input validation
|
||||
- **CORS Policy**: Restrict cross-origin requests appropriately
|
||||
- **Security Headers**: Implement security headers (HSTS, etc.)
|
||||
|
||||
### Data Protection
|
||||
|
||||
- **Sensitive Data**: No passwords or API keys in client storage
|
||||
- **Local Storage**: Only non-sensitive configuration data
|
||||
- **API Communication**: Encrypt all API communications
|
||||
- **Error Handling**: No sensitive information in error messages
|
||||
|
||||
## Implementation Roadmap
|
||||
|
||||
### ✅ Phase 1: Foundation (COMPLETED - December 2024)
|
||||
|
||||
**Goal**: Basic application structure and authentication
|
||||
|
||||
#### ✅ Week 1: Project Setup (COMPLETED)
|
||||
|
||||
- [x] Initialize Vue 3.5.13 + TypeScript 5.8 + Vite 6.2.4 project
|
||||
- [x] Configure Tailwind CSS v4 with @tailwindcss/vite plugin
|
||||
- [x] Set up ESLint 9.22, Prettier 3.5.3, and development tools
|
||||
- [x] Create optimized project structure and build pipeline
|
||||
- [x] Implement routing with Vue Router 4 and authentication guards
|
||||
|
||||
#### ✅ Week 2: Authentication & Layout (COMPLETED)
|
||||
|
||||
- [x] Create LoginView.vue with LoginForm.vue component
|
||||
- [x] Implement authentication store (authStore.ts) with Pinia 3.0.1
|
||||
- [x] Build DashboardLayout.vue with DashboardHeader.vue and DashboardSidebar.vue
|
||||
- [x] Add navigation, session management, and localStorage persistence
|
||||
- [x] Create complete component templates with TypeScript interfaces
|
||||
|
||||
**✅ DELIVERED**: Fully functional SPA with authentication, responsive layout, and modern tooling. Live development server running at <http://localhost:5173>
|
||||
|
||||
### 🚧 Phase 2: Core Widgets (IN PROGRESS - January 2025)
|
||||
|
||||
**Goal**: Essential dashboard functionality
|
||||
|
||||
#### ✅ Week 3: System & Infrastructure Widgets (COMPLETED)
|
||||
|
||||
- [x] Implement SystemCard.vue for system information display
|
||||
- [x] Create ServiceStatusCard.vue for service health summary
|
||||
- [x] Build ServicesTable.vue component for detailed service status
|
||||
- [x] Add UptimeCard.vue and LoadAverageCard.vue for system metrics
|
||||
- [x] Create dashboardStore.ts with mock data and reactive state management
|
||||
- [x] Implement real-time UI updates via Pinia stores
|
||||
|
||||
#### 🚧 Week 4: Additional Widgets & Polish (IN PROGRESS)
|
||||
|
||||
- [x] Create QuickActions.vue component grid
|
||||
- [x] Implement ErrorAlert.vue and LoadingSpinner.vue
|
||||
- [x] Add comprehensive error handling and loading states
|
||||
- [x] Build responsive design with Tailwind CSS v4 utilities
|
||||
- [ ] **NEXT**: Integrate with Netdata API (localhost:19999)
|
||||
- [ ] **NEXT**: Add real service discovery and health checks
|
||||
- [ ] **NEXT**: Implement WebSocket/SSE for real-time updates
|
||||
|
||||
**🎯 CURRENT STATUS**: Basic UI complete with mock data. Ready for backend integration.
|
||||
|
||||
### 📋 Phase 3: Data Integration (Weeks 5-6)
|
||||
|
||||
**Goal**: Real data sources and live monitoring
|
||||
|
||||
#### Week 5: Netdata Integration
|
||||
|
||||
- [ ] Connect to existing Netdata instance (port 19999)
|
||||
- [ ] Implement system metrics API calls
|
||||
- [ ] Add real-time performance data
|
||||
- [ ] Create data transformation layer
|
||||
- [ ] Implement caching strategy
|
||||
|
||||
#### Week 6: Service Discovery & Management
|
||||
|
||||
- [ ] Build service discovery mechanism
|
||||
- [ ] Integrate with system services (SSH, NFS, etc.)
|
||||
- [ ] Add Ollama monitoring integration
|
||||
- [ ] Implement service health endpoints
|
||||
- [ ] Create service management actions
|
||||
|
||||
### 📊 Phase 4: Advanced Features (Weeks 7-8)
|
||||
|
||||
**Goal**: Enhanced monitoring and management capabilities
|
||||
|
||||
#### Week 7: Advanced Monitoring
|
||||
|
||||
- [ ] Historical data visualization
|
||||
- [ ] Alert management system
|
||||
- [ ] Custom dashboard configuration
|
||||
- [ ] Export/import functionality
|
||||
- [ ] Advanced filtering and search
|
||||
|
||||
#### Week 8: Performance & Polish
|
||||
|
||||
- [ ] Performance optimization
|
||||
- [ ] Accessibility improvements
|
||||
- [ ] Mobile responsiveness enhancements
|
||||
- [ ] Documentation and user guide
|
||||
- [ ] Production deployment configuration
|
||||
|
||||
### 🚀 Current Development Environment
|
||||
|
||||
**Running Services**:
|
||||
|
||||
- **Development Server**: <http://localhost:5173> (Vite dev server)
|
||||
- **Vue DevTools**: Available for debugging
|
||||
- **Hot Module Replacement**: Active for instant updates
|
||||
|
||||
**Available Scripts**:
|
||||
|
||||
```bash
|
||||
npm run dev # Start development server
|
||||
npm run build # Production build
|
||||
npm run preview # Preview production build
|
||||
npm run test:unit # Run unit tests
|
||||
npm run lint # Code linting
|
||||
npm run format # Code formatting
|
||||
npm run type-check # TypeScript checking
|
||||
```
|
||||
|
||||
**Next Immediate Tasks**:
|
||||
|
||||
1. **Netdata Integration**: Connect to existing Netdata instance
|
||||
2. **Real Data Flow**: Replace mock data with live system metrics
|
||||
3. **Service Health**: Implement actual service status checking
|
||||
4. **WebSocket Updates**: Add real-time data streaming
|
||||
|
||||
### Phase 3: Integration & Data (Weeks 5-6)
|
||||
|
||||
**Goal**: Connect to real data sources
|
||||
|
||||
#### Week 5: API Integration
|
||||
|
||||
- [ ] Connect to actual system monitoring APIs
|
||||
- [ ] Implement WebSocket/SSE for real-time updates
|
||||
- [ ] Add data caching and offline support
|
||||
- [ ] Create API error handling and retry logic
|
||||
- [ ] Implement data validation and sanitization
|
||||
|
||||
#### Week 6: Advanced Features
|
||||
|
||||
- [ ] Add user preferences and customization
|
||||
- [ ] Implement advanced filtering and sorting
|
||||
- [ ] Create detailed drill-down views
|
||||
- [ ] Add export and sharing capabilities
|
||||
- [ ] Optimize performance and bundle size
|
||||
|
||||
### Phase 4: Testing & Production (Weeks 7-8)
|
||||
|
||||
**Goal**: Production-ready application
|
||||
|
||||
#### Week 7: Testing & Quality
|
||||
|
||||
- [ ] Write comprehensive unit tests with Vitest
|
||||
- [ ] Implement integration tests for critical paths
|
||||
- [ ] Add accessibility testing and improvements
|
||||
- [ ] Perform security audit and vulnerability testing
|
||||
- [ ] Optimize for mobile devices and various screen sizes
|
||||
|
||||
#### Week 8: Deployment & Documentation
|
||||
|
||||
- [ ] Set up production build and deployment pipeline
|
||||
- [ ] Create user documentation and help system
|
||||
- [ ] Implement monitoring and analytics
|
||||
- [ ] Conduct user acceptance testing
|
||||
- [ ] Prepare for production deployment
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Functional Acceptance
|
||||
|
||||
- [ ] User can log in securely and session persists appropriately
|
||||
- [ ] Dashboard loads and displays all widgets within 3 seconds
|
||||
- [ ] Real-time data updates work correctly across all widgets
|
||||
- [ ] All system components are accurately represented
|
||||
- [ ] Error states are handled gracefully with user feedback
|
||||
|
||||
### Technical Acceptance
|
||||
|
||||
- [ ] Code follows Vue 3 Composition API best practices
|
||||
- [ ] TypeScript strict mode passes without errors
|
||||
- [ ] All components are properly typed and documented
|
||||
- [ ] Bundle size is optimized for fast loading
|
||||
- [ ] Application works correctly on desktop and tablet devices
|
||||
|
||||
### User Experience Acceptance
|
||||
|
||||
- [ ] Interface is intuitive and requires minimal learning
|
||||
- [ ] Information hierarchy is clear and logical
|
||||
- [ ] Visual design is consistent and professional
|
||||
- [ ] Accessibility requirements are met (WCAG 2.1 AA)
|
||||
- [ ] Performance meets specified requirements
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Short-term (3-6 months)
|
||||
|
||||
- **Mobile App**: Native mobile application for on-the-go monitoring
|
||||
- **Advanced Analytics**: Historical data analysis and trending
|
||||
- **Custom Dashboards**: User-configurable widget layouts
|
||||
- **Alert Management**: Advanced alerting with escalation rules
|
||||
- **API Integration**: Connect to additional monitoring tools
|
||||
|
||||
### Long-term (6-12 months)
|
||||
|
||||
- **Multi-tenancy**: Support for multiple home lab environments
|
||||
- **Machine Learning**: Predictive analytics for capacity planning
|
||||
- **Automation**: Integration with home automation systems
|
||||
- **Advanced Visualization**: 3D network topology and service maps
|
||||
- **Collaborative Features**: Multi-user support with role-based access
|
||||
|
||||
## Conclusion
|
||||
|
||||
The Home Lab Dashboard represents a focused solution for home lab monitoring that prioritizes clarity, performance, and ease of use. By following Vue 3 best practices and implementing a clean, component-based architecture, the dashboard will provide operators with the instant visibility they need to effectively manage their infrastructure.
|
||||
|
||||
The emphasis on introspection "at a glance" drives every design decision, ensuring that the most critical information is immediately accessible while maintaining the flexibility to drill down into details when needed. The SPA architecture with centralized state management will provide a smooth, responsive user experience that scales with the complexity of the underlying infrastructure.
|
||||
|
||||
This PRD serves as the foundation for building a tool that transforms the complexity of home lab operations into clear, actionable insights, ultimately making infrastructure management more efficient and enjoyable.
|
694
packages/Dashboard/package-lock.json
generated
694
packages/Dashboard/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -16,8 +16,11 @@
|
|||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.10",
|
||||
"pinia": "^3.0.1",
|
||||
"vue": "^3.5.13"
|
||||
"tailwindcss": "^4.1.10",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node22": "^22.0.1",
|
||||
|
|
|
@ -1,47 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import HelloWorld from './components/HelloWorld.vue'
|
||||
import TheWelcome from './components/TheWelcome.vue'
|
||||
import { onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Initialize authentication state on app mount
|
||||
onMounted(() => {
|
||||
authStore.initializeAuth()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header>
|
||||
<img alt="Vue logo" class="logo" src="./assets/logo.svg" width="125" height="125" />
|
||||
|
||||
<div class="wrapper">
|
||||
<HelloWorld msg="You did it!" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<TheWelcome />
|
||||
</main>
|
||||
<div id="app">
|
||||
<RouterView />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
header {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
margin: 0 auto 2rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
header {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
padding-right: calc(var(--section-gap) / 2);
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin: 0 2rem 0 0;
|
||||
}
|
||||
|
||||
header .wrapper {
|
||||
display: flex;
|
||||
place-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
/* App-specific styles can go here */
|
||||
</style>
|
||||
|
|
|
@ -1,35 +1,5 @@
|
|||
@import './base.css';
|
||||
@import "tailwindcss";
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
a,
|
||||
.green {
|
||||
text-decoration: none;
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
transition: 0.4s;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
a:hover {
|
||||
background-color: hsla(160, 100%, 37%, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
body {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
height: 100vh;
|
||||
}
|
||||
|
|
87
packages/Dashboard/src/components/DashboardHeader.vue
Normal file
87
packages/Dashboard/src/components/DashboardHeader.vue
Normal file
|
@ -0,0 +1,87 @@
|
|||
<template>
|
||||
<header class="bg-white shadow-sm border-b border-gray-200">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<!-- Logo and title -->
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 flex items-center">
|
||||
<div class="h-8 w-8 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||
<svg class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="ml-3 text-xl font-semibold text-gray-900">
|
||||
Home Lab Dashboard
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User menu -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Refresh button -->
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
title="Refresh data"
|
||||
@click="$emit('refresh')"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- User info and logout -->
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="h-8 w-8 bg-gray-300 rounded-full flex items-center justify-center">
|
||||
<span class="text-sm font-medium text-gray-700">
|
||||
{{ userInitials }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="hidden md:block">
|
||||
<p class="text-sm font-medium text-gray-900">{{ user.username }}</p>
|
||||
<p class="text-xs text-gray-500">{{ user.email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="ml-3 inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
@click="$emit('logout')"
|
||||
>
|
||||
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { User } from '@/stores/auth'
|
||||
|
||||
interface Props {
|
||||
user: User
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'logout'): void
|
||||
(e: 'refresh'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
defineEmits<Emits>()
|
||||
|
||||
const userInitials = computed(() => {
|
||||
const names = props.user.username.split(' ')
|
||||
if (names.length >= 2) {
|
||||
return (names[0][0] + names[1][0]).toUpperCase()
|
||||
}
|
||||
return props.user.username.slice(0, 2).toUpperCase()
|
||||
})
|
||||
</script>
|
217
packages/Dashboard/src/components/DashboardSidebar.vue
Normal file
217
packages/Dashboard/src/components/DashboardSidebar.vue
Normal file
|
@ -0,0 +1,217 @@
|
|||
<template>
|
||||
<aside class="fixed inset-y-0 left-0 z-50 w-64 bg-gray-900 overflow-y-auto">
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Sidebar header spacer -->
|
||||
<div class="h-16 flex items-center px-4">
|
||||
<!-- This matches the header height -->
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 px-4 pb-4 space-y-2">
|
||||
<div class="space-y-1">
|
||||
<button
|
||||
v-for="item in navigationItems"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
:class="[
|
||||
'group flex items-center w-full px-2 py-2 text-sm font-medium rounded-md transition-colors',
|
||||
activeSection === item.id
|
||||
? 'bg-gray-800 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
|
||||
]"
|
||||
@click="$emit('section-change', item.id)"
|
||||
>
|
||||
<component
|
||||
:is="item.icon"
|
||||
:class="[
|
||||
'mr-3 h-5 w-5 flex-shrink-0',
|
||||
activeSection === item.id ? 'text-white' : 'text-gray-400 group-hover:text-white'
|
||||
]"
|
||||
/>
|
||||
{{ item.name }}
|
||||
|
||||
<!-- Badge for notifications -->
|
||||
<span
|
||||
v-if="item.badge"
|
||||
:class="[
|
||||
'ml-auto inline-block py-0.5 px-2 text-xs rounded-full',
|
||||
item.badgeColor || 'bg-red-100 text-red-800'
|
||||
]"
|
||||
>
|
||||
{{ item.badge }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Section divider -->
|
||||
<div class="border-t border-gray-700 pt-4 mt-4">
|
||||
<h3 class="px-2 text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
||||
Quick Links
|
||||
</h3>
|
||||
<div class="mt-2 space-y-1">
|
||||
<a
|
||||
v-for="link in quickLinks"
|
||||
:key="link.name"
|
||||
:href="link.href"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-300 hover:bg-gray-700 hover:text-white transition-colors"
|
||||
>
|
||||
<component
|
||||
:is="link.icon"
|
||||
class="mr-3 h-5 w-5 flex-shrink-0 text-gray-400 group-hover:text-white"
|
||||
/>
|
||||
{{ link.name }}
|
||||
<svg class="ml-auto h-4 w-4 text-gray-400 group-hover:text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" clip-rule="evenodd" />
|
||||
<path fill-rule="evenodd" d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex-shrink-0 px-4 py-4 border-t border-gray-700">
|
||||
<p class="text-xs text-gray-400">
|
||||
Last updated: {{ formatTime(new Date()) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { h } from 'vue'
|
||||
|
||||
interface Props {
|
||||
activeSection: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'section-change', section: string): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
defineEmits<Emits>()
|
||||
|
||||
// Simple icon components using h() function
|
||||
const DashboardIcon = () => h('svg', {
|
||||
fill: 'none',
|
||||
viewBox: '0 0 24 24',
|
||||
stroke: 'currentColor'
|
||||
}, [
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
'stroke-width': '2',
|
||||
d: 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z'
|
||||
}),
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
'stroke-width': '2',
|
||||
d: 'M8 5v4M16 5v4'
|
||||
})
|
||||
])
|
||||
|
||||
const ServerIcon = () => h('svg', {
|
||||
fill: 'none',
|
||||
viewBox: '0 0 24 24',
|
||||
stroke: 'currentColor'
|
||||
}, [
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
'stroke-width': '2',
|
||||
d: 'M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01'
|
||||
})
|
||||
])
|
||||
|
||||
const ChartIcon = () => h('svg', {
|
||||
fill: 'none',
|
||||
viewBox: '0 0 24 24',
|
||||
stroke: 'currentColor'
|
||||
}, [
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
'stroke-width': '2',
|
||||
d: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z'
|
||||
})
|
||||
])
|
||||
|
||||
const SettingsIcon = () => h('svg', {
|
||||
fill: 'none',
|
||||
viewBox: '0 0 24 24',
|
||||
stroke: 'currentColor'
|
||||
}, [
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
'stroke-width': '2',
|
||||
d: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z'
|
||||
}),
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
'stroke-width': '2',
|
||||
d: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z'
|
||||
})
|
||||
])
|
||||
|
||||
const ExternalLinkIcon = () => h('svg', {
|
||||
fill: 'none',
|
||||
viewBox: '0 0 24 24',
|
||||
stroke: 'currentColor'
|
||||
}, [
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
'stroke-width': '2',
|
||||
d: 'M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14'
|
||||
})
|
||||
])
|
||||
|
||||
const navigationItems = [
|
||||
{
|
||||
id: 'overview',
|
||||
name: 'Overview',
|
||||
icon: DashboardIcon
|
||||
},
|
||||
{
|
||||
id: 'services',
|
||||
name: 'Services',
|
||||
icon: ServerIcon,
|
||||
badge: '2',
|
||||
badgeColor: 'bg-red-100 text-red-800'
|
||||
},
|
||||
{
|
||||
id: 'monitoring',
|
||||
name: 'Monitoring',
|
||||
icon: ChartIcon
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
name: 'Settings',
|
||||
icon: SettingsIcon
|
||||
}
|
||||
]
|
||||
|
||||
const quickLinks = [
|
||||
{
|
||||
name: 'Netdata',
|
||||
href: 'http://localhost:19999',
|
||||
icon: ExternalLinkIcon
|
||||
},
|
||||
{
|
||||
name: 'Ollama',
|
||||
href: 'http://localhost:11434',
|
||||
icon: ExternalLinkIcon
|
||||
}
|
||||
]
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
</script>
|
46
packages/Dashboard/src/components/ErrorAlert.vue
Normal file
46
packages/Dashboard/src/components/ErrorAlert.vue
Normal file
|
@ -0,0 +1,46 @@
|
|||
<template>
|
||||
<div class="rounded-md bg-red-50 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800">
|
||||
Error
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-red-700">
|
||||
<p>{{ message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-auto pl-3">
|
||||
<div class="-mx-1.5 -my-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex bg-red-50 rounded-md p-1.5 text-red-500 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-red-50 focus:ring-red-600"
|
||||
@click="$emit('dismiss')"
|
||||
>
|
||||
<span class="sr-only">Dismiss</span>
|
||||
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
message: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'dismiss'): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
defineEmits<Emits>()
|
||||
</script>
|
43
packages/Dashboard/src/components/LoadAverageCard.vue
Normal file
43
packages/Dashboard/src/components/LoadAverageCard.vue
Normal file
|
@ -0,0 +1,43 @@
|
|||
<template>
|
||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Load Average</dt>
|
||||
<dd class="text-lg font-medium text-gray-900">{{ load[0].toFixed(2) }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-5 py-3">
|
||||
<div class="grid grid-cols-3 gap-4 text-sm">
|
||||
<div class="text-center">
|
||||
<span class="block font-medium">{{ load[0].toFixed(2) }}</span>
|
||||
<span class="text-gray-500">1m</span>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<span class="block font-medium">{{ load[1].toFixed(2) }}</span>
|
||||
<span class="text-gray-500">5m</span>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<span class="block font-medium">{{ load[2].toFixed(2) }}</span>
|
||||
<span class="text-gray-500">15m</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
load: readonly number[]
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
13
packages/Dashboard/src/components/LoadingSpinner.vue
Normal file
13
packages/Dashboard/src/components/LoadingSpinner.vue
Normal file
|
@ -0,0 +1,13 @@
|
|||
<template>
|
||||
<div class="flex items-center justify-center space-x-2">
|
||||
<svg class="animate-spin h-5 w-5 text-blue-500" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span class="text-gray-500">Loading...</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// No props needed for this simple loading spinner
|
||||
</script>
|
124
packages/Dashboard/src/components/LoginForm.vue
Normal file
124
packages/Dashboard/src/components/LoginForm.vue
Normal file
|
@ -0,0 +1,124 @@
|
|||
<template>
|
||||
<form class="mt-8 space-y-6" @submit.prevent="handleSubmit">
|
||||
<div class="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label for="username" class="sr-only">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
v-model="form.username"
|
||||
name="username"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
required
|
||||
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Username"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="sr-only">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Password"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="rounded-md bg-red-50 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800">
|
||||
Login Failed
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-red-700">
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-auto pl-3">
|
||||
<div class="-mx-1.5 -my-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex bg-red-50 rounded-md p-1.5 text-red-500 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-red-50 focus:ring-red-600"
|
||||
@click="$emit('clear-error')"
|
||||
>
|
||||
<span class="sr-only">Dismiss</span>
|
||||
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm">
|
||||
<p class="text-gray-600">
|
||||
Demo credentials: any username/password combination
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isLoading || !form.username || !form.password"
|
||||
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span class="absolute left-0 inset-y-0 flex items-center pl-3">
|
||||
<svg v-if="!isLoading" class="h-5 w-5 text-blue-500 group-hover:text-blue-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<svg v-else class="animate-spin h-5 w-5 text-blue-500" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
{{ isLoading ? 'Signing in...' : 'Sign in' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue'
|
||||
|
||||
interface Props {
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'submit', credentials: { username: string; password: string }): void
|
||||
(e: 'clear-error'): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (form.username && form.password) {
|
||||
emit('submit', {
|
||||
username: form.username,
|
||||
password: form.password
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
143
packages/Dashboard/src/components/QuickActions.vue
Normal file
143
packages/Dashboard/src/components/QuickActions.vue
Normal file
|
@ -0,0 +1,143 @@
|
|||
<template>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<button
|
||||
v-for="action in actions"
|
||||
:key="action.id"
|
||||
type="button"
|
||||
:class="[
|
||||
'relative flex items-center space-x-3 rounded-lg border border-gray-300 bg-white px-6 py-5 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
|
||||
action.enabled
|
||||
? 'hover:border-gray-400 cursor-pointer'
|
||||
: 'opacity-50 cursor-not-allowed'
|
||||
]"
|
||||
:disabled="!action.enabled"
|
||||
@click="handleAction(action.id)"
|
||||
>
|
||||
<div class="flex-shrink-0">
|
||||
<component
|
||||
:is="action.icon"
|
||||
:class="[
|
||||
'h-6 w-6',
|
||||
action.enabled ? action.color : 'text-gray-400'
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<span class="absolute inset-0" aria-hidden="true"></span>
|
||||
<p class="text-sm font-medium text-gray-900">{{ action.name }}</p>
|
||||
<p class="text-sm text-gray-500 truncate">{{ action.description }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { h } from 'vue'
|
||||
|
||||
interface Emits {
|
||||
(e: 'action', actionId: string): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// Icon components
|
||||
const RefreshIcon = () => h('svg', {
|
||||
fill: 'none',
|
||||
viewBox: '0 0 24 24',
|
||||
stroke: 'currentColor'
|
||||
}, [
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
'stroke-width': '2',
|
||||
d: 'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15'
|
||||
})
|
||||
])
|
||||
|
||||
const TerminalIcon = () => h('svg', {
|
||||
fill: 'none',
|
||||
viewBox: '0 0 24 24',
|
||||
stroke: 'currentColor'
|
||||
}, [
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
'stroke-width': '2',
|
||||
d: 'M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z'
|
||||
})
|
||||
])
|
||||
|
||||
const ChartIcon = () => h('svg', {
|
||||
fill: 'none',
|
||||
viewBox: '0 0 24 24',
|
||||
stroke: 'currentColor'
|
||||
}, [
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
'stroke-width': '2',
|
||||
d: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z'
|
||||
})
|
||||
])
|
||||
|
||||
const SettingsIcon = () => h('svg', {
|
||||
fill: 'none',
|
||||
viewBox: '0 0 24 24',
|
||||
stroke: 'currentColor'
|
||||
}, [
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
'stroke-width': '2',
|
||||
d: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z'
|
||||
}),
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
'stroke-width': '2',
|
||||
d: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z'
|
||||
})
|
||||
])
|
||||
|
||||
const actions = [
|
||||
{
|
||||
id: 'refresh',
|
||||
name: 'Refresh All',
|
||||
description: 'Update all service status',
|
||||
icon: RefreshIcon,
|
||||
color: 'text-blue-600',
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: 'netdata',
|
||||
name: 'Open Netdata',
|
||||
description: 'View detailed metrics',
|
||||
icon: ChartIcon,
|
||||
color: 'text-green-600',
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: 'terminal',
|
||||
name: 'SSH Access',
|
||||
description: 'Connect via terminal',
|
||||
icon: TerminalIcon,
|
||||
color: 'text-gray-600',
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
name: 'System Settings',
|
||||
description: 'Configure services',
|
||||
icon: SettingsIcon,
|
||||
color: 'text-purple-600',
|
||||
enabled: false
|
||||
}
|
||||
]
|
||||
|
||||
const handleAction = (actionId: string) => {
|
||||
const action = actions.find(a => a.id === actionId)
|
||||
if (action?.enabled) {
|
||||
emit('action', actionId)
|
||||
}
|
||||
}
|
||||
</script>
|
46
packages/Dashboard/src/components/ServiceStatusCard.vue
Normal file
46
packages/Dashboard/src/components/ServiceStatusCard.vue
Normal file
|
@ -0,0 +1,46 @@
|
|||
<template>
|
||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Service Status</dt>
|
||||
<dd class="text-lg font-medium text-gray-900">{{ totalCount }} Services</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-5 py-3">
|
||||
<div class="grid grid-cols-3 gap-4 text-sm">
|
||||
<div class="text-center">
|
||||
<span class="block text-lg font-medium text-green-600">{{ runningCount }}</span>
|
||||
<span class="text-gray-500">Running</span>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<span class="block text-lg font-medium text-red-600">{{ errorCount }}</span>
|
||||
<span class="text-gray-500">Errors</span>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<span class="block text-lg font-medium text-yellow-600">{{ stoppedCount }}</span>
|
||||
<span class="text-gray-500">Stopped</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
runningCount: number
|
||||
stoppedCount: number
|
||||
errorCount: number
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
107
packages/Dashboard/src/components/ServicesTable.vue
Normal file
107
packages/Dashboard/src/components/ServicesTable.vue
Normal file
|
@ -0,0 +1,107 @@
|
|||
<template>
|
||||
<div class="overflow-hidden">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h4 class="text-sm font-medium text-gray-900">Service Status</h4>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center px-3 py-1 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
@click="$emit('refresh')"
|
||||
>
|
||||
<svg class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Service
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Port
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="service in services" :key="service.name">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900">{{ service.name }}</div>
|
||||
<div v-if="service.description" class="text-sm text-gray-500">{{ service.description }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span :class="getStatusClass(service.status)" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full">
|
||||
{{ service.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ service.port || '-' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<a
|
||||
v-if="service.url"
|
||||
:href="service.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-blue-600 hover:text-blue-900 mr-3"
|
||||
>
|
||||
Open
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-600 hover:text-gray-900"
|
||||
@click="showServiceDetails(service)"
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ServiceStatus } from '@/stores/dashboard'
|
||||
|
||||
interface Props {
|
||||
services: readonly ServiceStatus[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'refresh'): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
defineEmits<Emits>()
|
||||
|
||||
const getStatusClass = (status: string) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'bg-green-100 text-green-800'
|
||||
case 'stopped':
|
||||
return 'bg-yellow-100 text-yellow-800'
|
||||
case 'error':
|
||||
return 'bg-red-100 text-red-800'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
}
|
||||
|
||||
const showServiceDetails = (service: ServiceStatus) => {
|
||||
// Placeholder for service details functionality
|
||||
console.log('Show details for:', service.name)
|
||||
}
|
||||
</script>
|
43
packages/Dashboard/src/components/SystemCard.vue
Normal file
43
packages/Dashboard/src/components/SystemCard.vue
Normal file
|
@ -0,0 +1,43 @@
|
|||
<template>
|
||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">System Information</dt>
|
||||
<dd class="text-lg font-medium text-gray-900">{{ systemInfo.hostname }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-5 py-3">
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-500">Memory:</span>
|
||||
<span class="ml-2 font-medium">{{ memoryUsage }}%</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">Disk:</span>
|
||||
<span class="ml-2 font-medium">{{ diskUsage }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SystemInfo } from '@/stores/dashboard'
|
||||
|
||||
interface Props {
|
||||
systemInfo: SystemInfo
|
||||
memoryUsage: number
|
||||
diskUsage: number
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
|
@ -1,94 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import WelcomeItem from './WelcomeItem.vue'
|
||||
import DocumentationIcon from './icons/IconDocumentation.vue'
|
||||
import ToolingIcon from './icons/IconTooling.vue'
|
||||
import EcosystemIcon from './icons/IconEcosystem.vue'
|
||||
import CommunityIcon from './icons/IconCommunity.vue'
|
||||
import SupportIcon from './icons/IconSupport.vue'
|
||||
|
||||
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<DocumentationIcon />
|
||||
</template>
|
||||
<template #heading>Documentation</template>
|
||||
|
||||
Vue’s
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
|
||||
provides you with all information you need to get started.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<ToolingIcon />
|
||||
</template>
|
||||
<template #heading>Tooling</template>
|
||||
|
||||
This project is served and bundled with
|
||||
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
|
||||
recommended IDE setup is
|
||||
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
|
||||
+
|
||||
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener">Vue - Official</a>. If
|
||||
you need to test your components and web pages, check out
|
||||
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
|
||||
and
|
||||
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
|
||||
/
|
||||
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
|
||||
|
||||
<br />
|
||||
|
||||
More instructions are available in
|
||||
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
|
||||
>.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<EcosystemIcon />
|
||||
</template>
|
||||
<template #heading>Ecosystem</template>
|
||||
|
||||
Get official tools and libraries for your project:
|
||||
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
|
||||
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
|
||||
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
|
||||
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
|
||||
you need more resources, we suggest paying
|
||||
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
|
||||
a visit.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<CommunityIcon />
|
||||
</template>
|
||||
<template #heading>Community</template>
|
||||
|
||||
Got stuck? Ask your question on
|
||||
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
|
||||
(our official Discord server), or
|
||||
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
|
||||
>StackOverflow</a
|
||||
>. You should also follow the official
|
||||
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
|
||||
Bluesky account or the
|
||||
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
|
||||
X account for latest news in the Vue world.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<SupportIcon />
|
||||
</template>
|
||||
<template #heading>Support Vue</template>
|
||||
|
||||
As an independent project, Vue relies on community backing for its sustainability. You can help
|
||||
us by
|
||||
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
|
||||
</WelcomeItem>
|
||||
</template>
|
33
packages/Dashboard/src/components/UptimeCard.vue
Normal file
33
packages/Dashboard/src/components/UptimeCard.vue
Normal file
|
@ -0,0 +1,33 @@
|
|||
<template>
|
||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">System Uptime</dt>
|
||||
<dd class="text-lg font-medium text-gray-900">{{ uptime }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-5 py-3">
|
||||
<div class="text-sm text-gray-500">
|
||||
Host: <span class="font-medium text-gray-900">{{ hostname }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
uptime: string
|
||||
hostname: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
|
@ -1,87 +0,0 @@
|
|||
<template>
|
||||
<div class="item">
|
||||
<i>
|
||||
<slot name="icon"></slot>
|
||||
</i>
|
||||
<div class="details">
|
||||
<h3>
|
||||
<slot name="heading"></slot>
|
||||
</h3>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.item {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.details {
|
||||
flex: 1;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
i {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
place-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.item {
|
||||
margin-top: 0;
|
||||
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
|
||||
}
|
||||
|
||||
i {
|
||||
top: calc(50% - 25px);
|
||||
left: -26px;
|
||||
position: absolute;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-background);
|
||||
border-radius: 8px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.item:before {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:after {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:first-of-type:before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.item:last-of-type:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
52
packages/Dashboard/src/layouts/DashboardLayout.vue
Normal file
52
packages/Dashboard/src/layouts/DashboardLayout.vue
Normal file
|
@ -0,0 +1,52 @@
|
|||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<DashboardHeader
|
||||
v-if="user"
|
||||
:user="user"
|
||||
@logout="handleLogout"
|
||||
/>
|
||||
|
||||
<div class="flex">
|
||||
<DashboardSidebar
|
||||
:active-section="activeSection"
|
||||
@section-change="handleSectionChange"
|
||||
/>
|
||||
|
||||
<main class="flex-1 p-6 ml-64">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import DashboardHeader from '@/components/DashboardHeader.vue'
|
||||
import DashboardSidebar from '@/components/DashboardSidebar.vue'
|
||||
import type { User } from '@/stores/auth'
|
||||
|
||||
interface Props {
|
||||
user: User | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'logout'): void
|
||||
(e: 'section-change', section: string): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const activeSection = ref('overview')
|
||||
|
||||
const handleLogout = () => {
|
||||
emit('logout')
|
||||
}
|
||||
|
||||
const handleSectionChange = (section: string) => {
|
||||
activeSection.value = section
|
||||
emit('section-change', section)
|
||||
}
|
||||
</script>
|
|
@ -2,10 +2,12 @@ import './assets/main.css'
|
|||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
|
|
49
packages/Dashboard/src/router/index.ts
Normal file
49
packages/Dashboard/src/router/index.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
// Views
|
||||
import LoginView from '@/views/LoginView.vue'
|
||||
import DashboardView from '@/views/DashboardView.vue'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/login'
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: LoginView,
|
||||
meta: {
|
||||
requiresAuth: false
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: DashboardView,
|
||||
meta: {
|
||||
requiresAuth: true
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
// Navigation guard for authentication
|
||||
router.beforeEach((to, from, next) => {
|
||||
const isAuthenticated = localStorage.getItem('isAuthenticated') === 'true'
|
||||
|
||||
if (to.meta.requiresAuth && !isAuthenticated) {
|
||||
next('/login')
|
||||
} else if (to.name === 'Login' && isAuthenticated) {
|
||||
next('/dashboard')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
95
packages/Dashboard/src/stores/auth.ts
Normal file
95
packages/Dashboard/src/stores/auth.ts
Normal file
|
@ -0,0 +1,95 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, readonly } from 'vue'
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
username: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// State
|
||||
const user = ref<User | null>(null)
|
||||
const isAuthenticated = ref<boolean>(false)
|
||||
const isLoading = ref<boolean>(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Getters
|
||||
const currentUser = computed(() => user.value)
|
||||
const isLoggedIn = computed(() => isAuthenticated.value && user.value !== null)
|
||||
|
||||
// Actions
|
||||
const login = async (username: string, password: string) => {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
// Simulate API call - replace with actual authentication logic
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// For demo purposes, accept any username/password combination
|
||||
if (username && password) {
|
||||
user.value = {
|
||||
id: '1',
|
||||
username,
|
||||
email: `${username}@homelab.local`
|
||||
}
|
||||
isAuthenticated.value = true
|
||||
localStorage.setItem('isAuthenticated', 'true')
|
||||
localStorage.setItem('user', JSON.stringify(user.value))
|
||||
return { success: true }
|
||||
} else {
|
||||
throw new Error('Username and password are required')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Login failed'
|
||||
return { success: false, error: error.value }
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
user.value = null
|
||||
isAuthenticated.value = false
|
||||
error.value = null
|
||||
localStorage.removeItem('isAuthenticated')
|
||||
localStorage.removeItem('user')
|
||||
}
|
||||
|
||||
const initializeAuth = () => {
|
||||
const stored = localStorage.getItem('isAuthenticated')
|
||||
const storedUser = localStorage.getItem('user')
|
||||
|
||||
if (stored === 'true' && storedUser) {
|
||||
try {
|
||||
user.value = JSON.parse(storedUser)
|
||||
isAuthenticated.value = true
|
||||
} catch {
|
||||
logout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const clearError = () => {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
user: readonly(user),
|
||||
isAuthenticated: readonly(isAuthenticated),
|
||||
isLoading: readonly(isLoading),
|
||||
error: readonly(error),
|
||||
|
||||
// Getters
|
||||
currentUser,
|
||||
isLoggedIn,
|
||||
|
||||
// Actions
|
||||
login,
|
||||
logout,
|
||||
initializeAuth,
|
||||
clearError
|
||||
}
|
||||
})
|
172
packages/Dashboard/src/stores/dashboard.ts
Normal file
172
packages/Dashboard/src/stores/dashboard.ts
Normal file
|
@ -0,0 +1,172 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, readonly } from 'vue'
|
||||
|
||||
export interface SystemInfo {
|
||||
hostname: string
|
||||
uptime: string
|
||||
load: readonly number[]
|
||||
memory: {
|
||||
total: number
|
||||
used: number
|
||||
free: number
|
||||
}
|
||||
disk: {
|
||||
total: number
|
||||
used: number
|
||||
free: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface ServiceStatus {
|
||||
name: string
|
||||
status: 'running' | 'stopped' | 'error' | 'unknown'
|
||||
port?: number
|
||||
url?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export const useDashboardStore = defineStore('dashboard', () => {
|
||||
// State
|
||||
const systemInfo = ref<SystemInfo | null>(null)
|
||||
const services = ref<ServiceStatus[]>([])
|
||||
const isLoading = ref<boolean>(false)
|
||||
const error = ref<string | null>(null)
|
||||
const lastUpdated = ref<Date | null>(null)
|
||||
|
||||
// Getters
|
||||
const runningServices = computed(() =>
|
||||
services.value.filter(service => service.status === 'running')
|
||||
)
|
||||
|
||||
const stoppedServices = computed(() =>
|
||||
services.value.filter(service => service.status === 'stopped')
|
||||
)
|
||||
|
||||
const errorServices = computed(() =>
|
||||
services.value.filter(service => service.status === 'error')
|
||||
)
|
||||
|
||||
const memoryUsagePercent = computed(() => {
|
||||
if (!systemInfo.value) return 0
|
||||
const { total, used } = systemInfo.value.memory
|
||||
return Math.round((used / total) * 100)
|
||||
})
|
||||
|
||||
const diskUsagePercent = computed(() => {
|
||||
if (!systemInfo.value) return 0
|
||||
const { total, used } = systemInfo.value.disk
|
||||
return Math.round((used / total) * 100)
|
||||
})
|
||||
|
||||
// Actions
|
||||
const fetchSystemInfo = async () => {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
// Simulate API call - replace with actual system info fetching
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
// Mock data for demonstration
|
||||
systemInfo.value = {
|
||||
hostname: 'congenital-optimist',
|
||||
uptime: '5 days, 14 hours',
|
||||
load: [0.15, 0.25, 0.30],
|
||||
memory: {
|
||||
total: 16384,
|
||||
used: 8192,
|
||||
free: 8192
|
||||
},
|
||||
disk: {
|
||||
total: 1000000,
|
||||
used: 450000,
|
||||
free: 550000
|
||||
}
|
||||
}
|
||||
|
||||
lastUpdated.value = new Date()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch system info'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchServices = async () => {
|
||||
try {
|
||||
// Simulate API call - replace with actual service status fetching
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
|
||||
// Mock data for demonstration
|
||||
services.value = [
|
||||
{
|
||||
name: 'Netdata',
|
||||
status: 'running',
|
||||
port: 19999,
|
||||
url: 'http://localhost:19999',
|
||||
description: 'Real-time performance monitoring'
|
||||
},
|
||||
{
|
||||
name: 'Ollama',
|
||||
status: 'running',
|
||||
port: 11434,
|
||||
url: 'http://localhost:11434',
|
||||
description: 'Local LLM inference server'
|
||||
},
|
||||
{
|
||||
name: 'SSH',
|
||||
status: 'running',
|
||||
port: 22,
|
||||
description: 'Secure Shell access'
|
||||
},
|
||||
{
|
||||
name: 'NFS',
|
||||
status: 'running',
|
||||
port: 2049,
|
||||
description: 'Network File System'
|
||||
},
|
||||
{
|
||||
name: 'Reverse Proxy',
|
||||
status: 'error',
|
||||
port: 80,
|
||||
description: 'HTTP reverse proxy'
|
||||
}
|
||||
]
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch services'
|
||||
}
|
||||
}
|
||||
|
||||
const refreshData = async () => {
|
||||
await Promise.all([
|
||||
fetchSystemInfo(),
|
||||
fetchServices()
|
||||
])
|
||||
}
|
||||
|
||||
const clearError = () => {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
systemInfo: readonly(systemInfo),
|
||||
services: readonly(services),
|
||||
isLoading: readonly(isLoading),
|
||||
error: readonly(error),
|
||||
lastUpdated: readonly(lastUpdated),
|
||||
|
||||
// Getters
|
||||
runningServices,
|
||||
stoppedServices,
|
||||
errorServices,
|
||||
memoryUsagePercent,
|
||||
diskUsagePercent,
|
||||
|
||||
// Actions
|
||||
fetchSystemInfo,
|
||||
fetchServices,
|
||||
refreshData,
|
||||
clearError
|
||||
}
|
||||
})
|
149
packages/Dashboard/src/views/DashboardView.vue
Normal file
149
packages/Dashboard/src/views/DashboardView.vue
Normal file
|
@ -0,0 +1,149 @@
|
|||
<template>
|
||||
<DashboardLayout
|
||||
:user="currentUser"
|
||||
@logout="handleLogout"
|
||||
@section-change="handleSectionChange"
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<!-- Dashboard Header -->
|
||||
<div class="border-b border-gray-200 pb-5">
|
||||
<h1 class="text-3xl font-bold leading-tight text-gray-900">
|
||||
Dashboard Overview
|
||||
</h1>
|
||||
<p class="mt-2 max-w-4xl text-sm text-gray-500">
|
||||
Real-time monitoring and status of your home lab infrastructure
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Alert -->
|
||||
<ErrorAlert
|
||||
v-if="error"
|
||||
:message="error"
|
||||
@dismiss="clearError"
|
||||
/>
|
||||
|
||||
<!-- Loading State -->
|
||||
<LoadingSpinner v-if="isLoading && !systemInfo" />
|
||||
|
||||
<!-- Dashboard Content -->
|
||||
<template v-else>
|
||||
<!-- System Overview Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<SystemCard
|
||||
v-if="systemInfo"
|
||||
:system-info="systemInfo"
|
||||
:memory-usage="memoryUsagePercent"
|
||||
:disk-usage="diskUsagePercent"
|
||||
/>
|
||||
|
||||
<ServiceStatusCard
|
||||
:running-count="runningServices.length"
|
||||
:stopped-count="stoppedServices.length"
|
||||
:error-count="errorServices.length"
|
||||
:total-count="services.length"
|
||||
/>
|
||||
|
||||
<UptimeCard
|
||||
v-if="systemInfo"
|
||||
:uptime="systemInfo.uptime"
|
||||
:hostname="systemInfo.hostname"
|
||||
/>
|
||||
|
||||
<LoadAverageCard
|
||||
v-if="systemInfo"
|
||||
:load="systemInfo.load"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Services Table -->
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">
|
||||
Service Status
|
||||
</h3>
|
||||
<ServicesTable
|
||||
:services="services"
|
||||
@refresh="refreshData"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">
|
||||
Quick Actions
|
||||
</h3>
|
||||
<QuickActions @action="handleQuickAction" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
|
||||
// Layout and UI Components
|
||||
import DashboardLayout from '@/layouts/DashboardLayout.vue'
|
||||
import ErrorAlert from '@/components/ErrorAlert.vue'
|
||||
import LoadingSpinner from '@/components/LoadingSpinner.vue'
|
||||
|
||||
// Dashboard Components
|
||||
import SystemCard from '@/components/SystemCard.vue'
|
||||
import ServiceStatusCard from '@/components/ServiceStatusCard.vue'
|
||||
import UptimeCard from '@/components/UptimeCard.vue'
|
||||
import LoadAverageCard from '@/components/LoadAverageCard.vue'
|
||||
import ServicesTable from '@/components/ServicesTable.vue'
|
||||
import QuickActions from '@/components/QuickActions.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const dashboardStore = useDashboardStore()
|
||||
|
||||
const { currentUser } = storeToRefs(authStore)
|
||||
const {
|
||||
systemInfo,
|
||||
services,
|
||||
isLoading,
|
||||
error,
|
||||
runningServices,
|
||||
stoppedServices,
|
||||
errorServices,
|
||||
memoryUsagePercent,
|
||||
diskUsagePercent
|
||||
} = storeToRefs(dashboardStore)
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const handleSectionChange = (section: string) => {
|
||||
console.log('Section changed to:', section)
|
||||
// Handle section changes - could trigger different data fetching
|
||||
}
|
||||
|
||||
const refreshData = () => {
|
||||
dashboardStore.refreshData()
|
||||
}
|
||||
|
||||
const clearError = () => {
|
||||
dashboardStore.clearError()
|
||||
}
|
||||
|
||||
const handleQuickAction = (action: string) => {
|
||||
console.log('Quick action:', action)
|
||||
// Handle quick actions like restarting services, etc.
|
||||
}
|
||||
|
||||
// Initialize dashboard data on mount
|
||||
onMounted(() => {
|
||||
dashboardStore.refreshData()
|
||||
})
|
||||
</script>
|
49
packages/Dashboard/src/views/LoginView.vue
Normal file
49
packages/Dashboard/src/views/LoginView.vue
Normal file
|
@ -0,0 +1,49 @@
|
|||
<template>
|
||||
<div class="min-h-screen bg-gray-50 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<div class="mx-auto h-12 w-12 flex items-center justify-center bg-blue-600 rounded-lg">
|
||||
<svg class="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Home Lab Dashboard
|
||||
</h2>
|
||||
<p class="mt-2 text-center text-sm text-gray-600">
|
||||
Sign in to access your infrastructure monitoring
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<LoginForm
|
||||
:is-loading="isLoading"
|
||||
:error="error"
|
||||
@submit="handleLogin"
|
||||
@clear-error="clearError"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import LoginForm from '@/components/LoginForm.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const { isLoading, error } = storeToRefs(authStore)
|
||||
|
||||
const handleLogin = async (credentials: { username: string; password: string }) => {
|
||||
const result = await authStore.login(credentials.username, credentials.password)
|
||||
|
||||
if (result.success) {
|
||||
router.push('/dashboard')
|
||||
}
|
||||
}
|
||||
|
||||
const clearError = () => {
|
||||
authStore.clearError()
|
||||
}
|
||||
</script>
|
|
@ -3,12 +3,14 @@ import { fileURLToPath, URL } from 'node:url'
|
|||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
tailwindcss(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue