Compare commits

...

5 Commits

Author SHA1 Message Date
rafaeldpsilva
a480466dd0 format 2025-12-20 00:18:45 +00:00
rafaeldpsilva
c3364cc422 format 2025-12-20 00:17:21 +00:00
rafaeldpsilva
4b4338fb91 update 2025-12-20 00:15:44 +00:00
rafaeldpsilva
1c7288b778 update 2025-12-20 00:14:43 +00:00
rafaeldpsilva
7accc66710 update 2025-12-20 00:13:33 +00:00
40 changed files with 852 additions and 653 deletions

5
.env
View File

@@ -1,5 +0,0 @@
# API Configuration
VITE_API_BASE_URL=http://localhost:8000
# WebSocket Configuration
VITE_WS_URL=ws://localhost:8000/ws

1
.gitattributes vendored
View File

@@ -1 +0,0 @@
* text=auto eol=lf

5
.gitignore vendored
View File

@@ -18,7 +18,7 @@ coverage
/cypress/screenshots/
# Editor directories and files
.vscode/*
.vscode/
!.vscode/extensions.json
.idea
*.suo
@@ -28,3 +28,6 @@ coverage
*.sw?
*.tsbuildinfo
.env
CLAUDE.md

View File

@@ -1,9 +0,0 @@
{
"recommendations": [
"Vue.volar",
"vitest.explorer",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"esbenp.prettier-vscode"
]
}

View File

@@ -1,19 +0,0 @@
# Repository Guidelines
## Project Structure & Module Organization
The Vue 3 frontend lives under `src/`. `main.ts` wires the router and Pinia, while `App.vue` hosts global layout. Page-level views sit in `src/views/` (e.g. `AnalyticsView.vue`), with shared widgets under `src/components/`. Pinia logic is grouped in `src/stores/` (one file per domain such as `energy.ts` or `room.ts`), and API/WebSocket helpers in `src/services/`. Reusable hooks belong in `src/composables/`. Static files and icons stay in `public/` or `src/assets/`. Keep demo tooling like `test-websocket.html` at the repo root; production builds land in `dist/`.
## Build, Test, and Development Commands
Run `npm install` once after cloning. `npm run dev` starts Vite locally; use `npm run dev-server` when you need LAN access. `npm run build` performs a type-safe production build (calls `npm run type-check` plus `vite build`). `npm run preview` serves the built bundle. Execute `npm run test:unit` for Vitest suites, `npm run lint` for ESLint (auto-fix enabled), and `npm run format` to apply Prettier to `src/`.
## Coding Style & Naming Conventions
Follow the ESLint + Prettier flat config: 2-space indentation, single quotes in TS, and script setup in Vue SFCs when practical. Name Vue files in PascalCase (`EnergyOverviewCard.vue`), stores in camelCase (`energy.ts` exporting `useEnergyStore`), and composables with the `use` prefix. Keep Tailwind utility classes readable by grouping per concern. Avoid unchecked `console.log`; prefer the logging helpers already present in stores.
## Testing Guidelines
Vitest with the `jsdom` environment powers unit tests; place suites alongside features in `src/**/__tests__/` using `*.spec.ts`. Mock API and WebSocket calls by leveraging Pinia store injection or `vi.mock('../services/api')`. Every new store action or view-level computed branch should gain coverage. Run `npm run test:unit -- --run --coverage` before opening a PR if you add complex domain logic.
## Commit & Pull Request Guidelines
Commits use short, imperative summaries without prefixes (see `git log`). Keep subject lines under ~70 characters and include the affected area, e.g., `Refine room status badges`. Squash fixups locally rather than pushing noisy history. PRs should link to Jira/GitHub issues when relevant, include screenshots or GIFs for UI changes, list test commands run, and call out backend dependencies (e.g., new API fields).
## Environment & Configuration
Frontend defaults to `http://localhost:8000`; override with `VITE_API_BASE_URL` in a `.env.local`. Document new environment flags in `README.md`. Never commit real credentials—use the provided TypeScript definitions in `env.d.ts` to keep variable access typed.

189
CLAUDE.md
View File

@@ -1,189 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Vue.js 3 frontend for a **Real-Time Energy Monitoring Dashboard** that displays sensor data, room metrics, air quality monitoring, and energy analytics for smart buildings. The frontend connects to either a monolithic backend or microservices architecture via WebSocket and REST APIs.
## Development Commands
```bash
# Development
npm run dev # Start dev server (http://localhost:5173)
npm run dev-server # Dev server with host binding
# Building
npm run build # Full production build (type-check + build)
npm run build-only # Vite build without type checking
npm run type-check # TypeScript validation only
# Testing & Quality
npm run test:unit # Run Vitest unit tests
npm run lint # ESLint with auto-fix
npm run format # Prettier code formatting
```
## Architecture
### State Management (Pinia Stores)
The application uses **5 specialized Pinia stores** with clear separation of concerns:
1. **`websocket.ts`** - WebSocket connection management
- Connects to `ws://localhost:8000/ws` or `ws://localhost:8007/ws`
- Buffers incoming messages to prevent UI blocking
- Handles proxy info messages for microservices routing
- Delegates data processing to sensor/room stores
2. **`sensor.ts`** - Sensor device management
- Maintains `Map<sensorId, SensorDevice>` for all devices
- Tracks `latestReadings` as `Map<sensorId, SensorReading>`
- Provides aggregated CO2 metrics (`averageCO2Level`, `maxCO2Level`)
- API integration with auth retry logic via `WindowWithAuth` pattern
3. **`room.ts`** - Room-based metrics aggregation
- Groups sensor data by room into `RoomMetrics`
- Calculates per-room energy consumption and CO2 levels
- Provides CO2 status classification (good/moderate/poor/critical)
- Estimates occupancy based on environmental data
4. **`energy.ts`** - Energy-specific aggregations
- Delegates to other stores (sensor, room, analytics, websocket)
- Maintains energy history with configurable `MAX_HISTORY_POINTS`
- Provides convenience functions for legacy AnalyticsView compatibility
5. **`analytics.ts`** - System-wide analytics
- Fetches analytics summaries from backend API
- Tracks system-wide health status
- Provides aggregated metrics across all sensors/rooms
6. **`auth.ts`** - JWT authentication
- Token generation, validation, and lifecycle management
- LocalStorage persistence with expiry checking
- Exposed globally via `window.__AUTH_STORE__` for API clients
### API Services (`src/services/`)
Service layer organized by domain:
- **`api.ts`** - Base API client with types and health checks
- **`sensorsApi.ts`** - Sensor CRUD operations
- **`roomsApi.ts`** - Room management and data queries
- **`analyticsApi.ts`** - Analytics summaries and trends
- **`authApi.ts`** - Token generation and validation
- **`index.ts`** - Central export point
All services use the `WindowWithAuth` pattern to access auth store without circular dependencies.
### Data Flow
**Real-time updates:**
1. WebSocket receives sensor reading from backend
2. `websocket.ts` buffers and processes message
3. Data sent to `sensor.ts` (individual readings) AND `room.ts` (aggregations)
4. Vue components react to store changes via computed properties
**API data fetching:**
1. Component calls store action (e.g., `sensorStore.fetchApiSensors()`)
2. Store delegates to API service (e.g., `sensorsApi.getSensors()`)
3. Service includes auth header via `useAuthStore().getAuthHeader()`
4. On 401 error, service calls `window.__AUTH_STORE__.ensureAuthenticated()`
5. Retries request with new token
### WindowWithAuth Pattern
To avoid circular imports when stores need authentication, the auth store is attached to the global window object:
```typescript
// In main.ts
(window as any).__AUTH_STORE__ = authStore
// In other stores/services
interface WindowWithAuth extends Window {
__AUTH_STORE__?: {
ensureAuthenticated: () => Promise<boolean>
}
}
const authStore = (window as WindowWithAuth).__AUTH_STORE__
if (authStore) await authStore.ensureAuthenticated()
```
Used in: `sensor.ts`, `room.ts`, `analytics.ts`, `api.ts`
## Data Models
### Dual Format Support
The system handles two data formats for backward compatibility:
**Legacy Format (energy only):**
```typescript
{ sensorId: string, timestamp: number, value: number, unit: string }
```
**Multi-Metric Format (current):**
```typescript
{
sensor_id: string
room: string
timestamp: number
energy: { value: number, unit: string }
co2: { value: number, unit: string }
temperature?: { value: number, unit: string }
}
```
### Partial Reading Support
The system now supports **partial sensor readings** where a single message may contain only energy OR only CO2 data.
**Implementation:**
- `src/stores/room.ts:44-121` - `updateRoomData()` accepts partial readings and aggregates by metric type
- `src/stores/websocket.ts:118-144` - Processes any reading with a room field
- Energy metrics aggregate from sensors with `energy.value !== undefined`
- CO2 metrics aggregate from sensors with `co2.value !== undefined`
This allows `data_simulator_enhanced.py` to send single-metric readings while still populating room-level aggregations correctly.
## Routing
5 main views in `src/router/index.ts`:
- `/` - HomeView (dashboard overview)
- `/sensors` - SensorManagementView (sensor CRUD)
- `/ai-optimization` - AIOptimizationView (AI features)
- `/analytics` - AnalyticsView (detailed analytics)
- `/settings` - SettingsView (configuration)
## Backend Integration
**WebSocket endpoints:**
- Monolithic: `ws://localhost:8000/ws`
- Microservices: `ws://localhost:8007/ws` (sensor service direct)
**REST API:** `http://localhost:8000` (API Gateway for microservices)
**Authentication:** JWT tokens via Token Service (port 8001)
## Component Structure
**Views:** Page-level components in `/views/`
**Cards:** Reusable metric cards in `/components/cards/`
**Charts:** Visualization components (multiple chart libraries: ApexCharts, Chart.js, ECharts)
## Key Technologies
- **Vue 3** - Composition API with `<script setup>`
- **TypeScript** - Type-safe development
- **Pinia** - State management
- **Vue Router** - Client-side routing
- **Vite** - Build tool with HMR
- **Tailwind CSS** - Utility-first styling
- **ECharts** - Primary charting library
- **Vitest** - Unit testing framework
## Prerequisites
- **Node.js 20.19.0+ or 22.12.0+**
- **Redis** on `localhost:6379` (for real-time data)
- **Backend** on `localhost:8000` (monolithic or API Gateway)

15
Tasks.md Normal file
View File

@@ -0,0 +1,15 @@
Medium
- Replace `any` usage with specific types to satisfy lint checks in src/main.ts:27, src/stores/room.ts:315, src/stores/room.ts:319, src/views/ModelsView.vue:707, src/views/ModelsView.vue:714
Low
- Remove or use unused Vue emits and lifecycle hooks flagged by lint in src/components/cards/DetailedSensorCard.vue:177, src/components/cards/SimpleSensorCard.vue:83, src/components/modals/RoomManagementModal.vue:175, src/components/modals/RoomManagementModal.vue:181, src/main.ts:21, src/stores/energy.ts:19, src/views/AnalyticsView.vue:324, src/views/SensorManagementView.vue:260
Possible Features
- Add real-time toast notifications for critical events surfaced by analyticsApi.getEvents
- Provide comparative trend dashboards with configurable periods and benchmarks
- Introduce role-based access control screens for managing API tokens and room permissions
Database Enhancements
- Persist user dashboard preferences (selected rooms, time ranges, chart settings) for personalized views
- Track sensor metadata history (calibration, firmware updates, maintenance logs) to power diagnostics
- Store aggregated room efficiency scores and anomaly flags to speed up analytics queries

View File

@@ -1,4 +0,0 @@
docker build -t rdpds/sa4cps-dashboard .
docker tag rdpds/sa4cps-dashboard rdpds/sa4cps-dashboard:latest
docker tag rdpds/sa4cps-dashboard rdpds/sa4cps-dashboard:v0.1.0
docker push --all-tags rdpds/sa4cps-dashboard

View File

@@ -30,7 +30,12 @@
}
// Grid utilities
@mixin grid-responsive($columns-mobile: 1, $columns-tablet: 2, $columns-desktop: 3, $gap: $spacing-md) {
@mixin grid-responsive(
$columns-mobile: 1,
$columns-tablet: 2,
$columns-desktop: 3,
$gap: $spacing-md
) {
display: grid;
gap: $gap;
grid-template-columns: repeat($columns-mobile, 1fr);
@@ -178,4 +183,4 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}

View File

@@ -66,9 +66,15 @@ $radius-2xl: 1.5rem;
// Shadows
$shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
$shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
$shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
$shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
$shadow-md:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
$shadow-lg:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
$shadow-xl:
0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04);
// Transitions
$transition-fast: 150ms ease-in-out;
@@ -80,4 +86,4 @@ $breakpoint-sm: 640px;
$breakpoint-md: 768px;
$breakpoint-lg: 1024px;
$breakpoint-xl: 1280px;
$breakpoint-2xl: 1536px;
$breakpoint-2xl: 1536px;

View File

@@ -1,5 +1,10 @@
// Typography Styles
h1, h2, h3, h4, h5, h6 {
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
font-weight: 600;
}
@@ -40,17 +45,41 @@ p {
}
// Text utilities
.text-xs { font-size: 0.75rem; }
.text-sm { font-size: 0.875rem; }
.text-base { font-size: 1rem; }
.text-lg { font-size: 1.125rem; }
.text-xl { font-size: 1.25rem; }
.text-2xl { font-size: 1.5rem; }
.text-xs {
font-size: 0.75rem;
}
.text-sm {
font-size: 0.875rem;
}
.text-base {
font-size: 1rem;
}
.text-lg {
font-size: 1.125rem;
}
.text-xl {
font-size: 1.25rem;
}
.text-2xl {
font-size: 1.5rem;
}
.font-medium { font-weight: 500; }
.font-semibold { font-weight: 600; }
.font-bold { font-weight: 700; }
.font-medium {
font-weight: 500;
}
.font-semibold {
font-weight: 600;
}
.font-bold {
font-weight: 700;
}
.text-center { text-align: center; }
.text-left { text-align: left; }
.text-right { text-align: right; }
.text-center {
text-align: center;
}
.text-left {
text-align: left;
}
.text-right {
text-align: right;
}

View File

@@ -130,4 +130,4 @@
height: 1.25rem;
color: $gray-400;
}
}
}

View File

@@ -449,4 +449,4 @@
.sensor-security {
@include sensor-type-style($sensor-security-bg, $sensor-security-text);
}
}

View File

@@ -44,4 +44,4 @@
// Custom Range Input
.range-slider {
@include custom-slider;
}
}

View File

@@ -253,4 +253,4 @@
}
}
}
}
}

View File

@@ -24,4 +24,4 @@
margin-top: $spacing-xl;
}
}
}
}

View File

@@ -2,14 +2,28 @@
.grid {
display: grid;
&--1 { grid-template-columns: 1fr; }
&--2 { grid-template-columns: repeat(2, 1fr); }
&--3 { grid-template-columns: repeat(3, 1fr); }
&--4 { grid-template-columns: repeat(4, 1fr); }
&--1 {
grid-template-columns: 1fr;
}
&--2 {
grid-template-columns: repeat(2, 1fr);
}
&--3 {
grid-template-columns: repeat(3, 1fr);
}
&--4 {
grid-template-columns: repeat(4, 1fr);
}
&--gap-2 { gap: $spacing-sm; }
&--gap-4 { gap: $spacing-md; }
&--gap-6 { gap: $spacing-lg; }
&--gap-2 {
gap: $spacing-sm;
}
&--gap-4 {
gap: $spacing-md;
}
&--gap-6 {
gap: $spacing-lg;
}
// Responsive grids
&--responsive-simple {
@@ -45,8 +59,16 @@
flex-wrap: wrap;
}
&--gap-1 { gap: $spacing-xs; }
&--gap-2 { gap: $spacing-sm; }
&--gap-3 { gap: $spacing-md; }
&--gap-4 { gap: $spacing-lg; }
}
&--gap-1 {
gap: $spacing-xs;
}
&--gap-2 {
gap: $spacing-sm;
}
&--gap-3 {
gap: $spacing-md;
}
&--gap-4 {
gap: $spacing-lg;
}
}

View File

@@ -164,4 +164,4 @@
height: 2rem;
// Chart styling handled by chart library
}
}
}

View File

@@ -111,4 +111,4 @@
color: $gray-600;
}
}
}
}

View File

@@ -12,98 +12,226 @@
}
// Spacing utilities
.space-y-2 > * + * { margin-top: $spacing-sm; }
.space-y-3 > * + * { margin-top: $spacing-md; }
.space-y-4 > * + * { margin-top: $spacing-lg; }
.space-y-6 > * + * { margin-top: $spacing-xl; }
.space-y-2 > * + * {
margin-top: $spacing-sm;
}
.space-y-3 > * + * {
margin-top: $spacing-md;
}
.space-y-4 > * + * {
margin-top: $spacing-lg;
}
.space-y-6 > * + * {
margin-top: $spacing-xl;
}
.space-x-2 > * + * { margin-left: $spacing-sm; }
.space-x-3 > * + * { margin-left: $spacing-md; }
.space-x-4 > * + * { margin-left: $spacing-lg; }
.space-x-2 > * + * {
margin-left: $spacing-sm;
}
.space-x-3 > * + * {
margin-left: $spacing-md;
}
.space-x-4 > * + * {
margin-left: $spacing-lg;
}
// Margin utilities
.m-0 { margin: 0; }
.mb-1 { margin-bottom: $spacing-xs; }
.mb-2 { margin-bottom: $spacing-sm; }
.mb-3 { margin-bottom: $spacing-md; }
.mb-4 { margin-bottom: $spacing-lg; }
.m-0 {
margin: 0;
}
.mb-1 {
margin-bottom: $spacing-xs;
}
.mb-2 {
margin-bottom: $spacing-sm;
}
.mb-3 {
margin-bottom: $spacing-md;
}
.mb-4 {
margin-bottom: $spacing-lg;
}
.mt-1 { margin-top: $spacing-xs; }
.mt-2 { margin-top: $spacing-sm; }
.mt-3 { margin-top: $spacing-md; }
.mt-4 { margin-top: $spacing-lg; }
.mt-1 {
margin-top: $spacing-xs;
}
.mt-2 {
margin-top: $spacing-sm;
}
.mt-3 {
margin-top: $spacing-md;
}
.mt-4 {
margin-top: $spacing-lg;
}
// Padding utilities
.p-0 { padding: 0; }
.p-1 { padding: $spacing-xs; }
.p-2 { padding: $spacing-sm; }
.p-3 { padding: $spacing-md; }
.p-4 { padding: $spacing-lg; }
// Padding utilities
.p-0 {
padding: 0;
}
.p-1 {
padding: $spacing-xs;
}
.p-2 {
padding: $spacing-sm;
}
.p-3 {
padding: $spacing-md;
}
.p-4 {
padding: $spacing-lg;
}
// Width utilities
.w-full { width: 100%; }
.w-auto { width: auto; }
.w-full {
width: 100%;
}
.w-auto {
width: auto;
}
// Height utilities
.h-full { height: 100%; }
.h-auto { height: auto; }
.h-full {
height: 100%;
}
.h-auto {
height: auto;
}
// Color utilities
.text-primary { color: $primary; }
.text-secondary { color: $secondary; }
.text-success { color: $success; }
.text-warning { color: $warning; }
.text-danger { color: $danger; }
.text-primary {
color: $primary;
}
.text-secondary {
color: $secondary;
}
.text-success {
color: $success;
}
.text-warning {
color: $warning;
}
.text-danger {
color: $danger;
}
.text-gray-400 { color: $gray-400; }
.text-gray-500 { color: $gray-500; }
.text-gray-600 { color: $gray-600; }
.text-gray-700 { color: $gray-700; }
.text-gray-900 { color: $gray-900; }
.text-gray-400 {
color: $gray-400;
}
.text-gray-500 {
color: $gray-500;
}
.text-gray-600 {
color: $gray-600;
}
.text-gray-700 {
color: $gray-700;
}
.text-gray-900 {
color: $gray-900;
}
// Background utilities
.bg-primary { background-color: $primary; }
.bg-white { background-color: white; }
.bg-gray-50 { background-color: $gray-50; }
.bg-gray-100 { background-color: $gray-100; }
.bg-primary {
background-color: $primary;
}
.bg-white {
background-color: white;
}
.bg-gray-50 {
background-color: $gray-50;
}
.bg-gray-100 {
background-color: $gray-100;
}
// Border utilities
.border { border: 1px solid $gray-200; }
.border-0 { border: 0; }
.border-gray-100 { border-color: $gray-100; }
.border-gray-200 { border-color: $gray-200; }
.border {
border: 1px solid $gray-200;
}
.border-0 {
border: 0;
}
.border-gray-100 {
border-color: $gray-100;
}
.border-gray-200 {
border-color: $gray-200;
}
// Border radius utilities
.rounded { border-radius: $radius-md; }
.rounded-lg { border-radius: $radius-lg; }
.rounded-xl { border-radius: $radius-xl; }
.rounded-2xl { border-radius: $radius-2xl; }
.rounded-full { border-radius: 9999px; }
.rounded {
border-radius: $radius-md;
}
.rounded-lg {
border-radius: $radius-lg;
}
.rounded-xl {
border-radius: $radius-xl;
}
.rounded-2xl {
border-radius: $radius-2xl;
}
.rounded-full {
border-radius: 9999px;
}
// Shadow utilities
.shadow-sm { box-shadow: $shadow-sm; }
.shadow-md { box-shadow: $shadow-md; }
.shadow-lg { box-shadow: $shadow-lg; }
.shadow-xl { box-shadow: $shadow-xl; }
.shadow-sm {
box-shadow: $shadow-sm;
}
.shadow-md {
box-shadow: $shadow-md;
}
.shadow-lg {
box-shadow: $shadow-lg;
}
.shadow-xl {
box-shadow: $shadow-xl;
}
// Position utilities
.relative { position: relative; }
.absolute { position: absolute; }
.fixed { position: fixed; }
.relative {
position: relative;
}
.absolute {
position: absolute;
}
.fixed {
position: fixed;
}
// Z-index utilities
.z-10 { z-index: 10; }
.z-20 { z-index: 20; }
.z-50 { z-index: 50; }
.z-10 {
z-index: 10;
}
.z-20 {
z-index: 20;
}
.z-50 {
z-index: 50;
}
// Overflow utilities
.overflow-hidden { overflow: hidden; }
.overflow-y-auto { overflow-y: auto; }
.overflow-hidden {
overflow: hidden;
}
.overflow-y-auto {
overflow-y: auto;
}
// Cursor utilities
.cursor-pointer { cursor: pointer; }
.cursor-not-allowed { cursor: not-allowed; }
.cursor-pointer {
cursor: pointer;
}
.cursor-not-allowed {
cursor: not-allowed;
}
// Opacity utilities
.opacity-50 { opacity: 0.5; }
.opacity-75 { opacity: 0.75; }
.opacity-50 {
opacity: 0.5;
}
.opacity-75 {
opacity: 0.75;
}

View File

@@ -1,7 +1,7 @@
<template>
<div class="bg-white rounded-2xl shadow-sm p-4">
<h6 class="text-sm font-bold text-gray-500 mb-4">Air Quality Status</h6>
<!-- Overall Status -->
<div class="mb-4 p-3 rounded-lg" :class="getOverallStatusBg()">
<div class="flex items-center justify-between">
@@ -13,11 +13,28 @@
Building Average: {{ overallCO2.toFixed(0) }} ppm
</div>
</div>
<div class="w-12 h-12 rounded-full flex items-center justify-center" :class="getOverallStatusIconBg()">
<svg class="w-6 h-6" :class="getOverallStatusText()" fill="currentColor" viewBox="0 0 24 24">
<path d="M12,2C6.5,2 2,6.5 2,12C2,17.5 6.5,22 12,22C17.5,22 22,17.5 22,12C22,6.5 17.5,2 12,2M10,16.5L6,12.5L7.5,11L10,13.5L16.5,7L18,8.5L10,16.5Z" v-if="overallStatus === 'good'"/>
<path d="M12,2C6.5,2 2,6.5 2,12C2,17.5 6.5,22 12,22C17.5,22 22,17.5 22,12C22,6.5 17.5,2 12,2M12,7L17,12L12,17L7,12L12,7Z" v-else-if="overallStatus === 'moderate'"/>
<path d="M12,2C6.5,2 2,6.5 2,12C2,17.5 6.5,22 12,22C17.5,22 22,17.5 22,12C22,6.5 17.5,2 12,2M12,7L17,12L12,17L7,12L12,7Z" v-else/>
<div
class="w-12 h-12 rounded-full flex items-center justify-center"
:class="getOverallStatusIconBg()"
>
<svg
class="w-6 h-6"
:class="getOverallStatusText()"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12,2C6.5,2 2,6.5 2,12C2,17.5 6.5,22 12,22C17.5,22 22,17.5 22,12C22,6.5 17.5,2 12,2M10,16.5L6,12.5L7.5,11L10,13.5L16.5,7L18,8.5L10,16.5Z"
v-if="overallStatus === 'good'"
/>
<path
d="M12,2C6.5,2 2,6.5 2,12C2,17.5 6.5,22 12,22C17.5,22 22,17.5 22,12C22,6.5 17.5,2 12,2M12,7L17,12L12,17L7,12L12,7Z"
v-else-if="overallStatus === 'moderate'"
/>
<path
d="M12,2C6.5,2 2,6.5 2,12C2,17.5 6.5,22 12,22C17.5,22 22,17.5 22,12C22,6.5 17.5,2 12,2M12,7L17,12L12,17L7,12L12,7Z"
v-else
/>
</svg>
</div>
</div>
@@ -28,8 +45,12 @@
<div v-if="roomsList.length === 0" class="text-center text-gray-500 py-4">
No air quality data available
</div>
<div v-for="room in roomsList" :key="room.room" class="flex items-center justify-between p-2 rounded">
<div
v-for="room in roomsList"
:key="room.room"
class="flex items-center justify-between p-2 rounded"
>
<div class="flex items-center gap-2">
<div
class="w-3 h-3 rounded-full"
@@ -76,9 +97,9 @@ const roomStore = useRoomStore()
const roomsList = computed(() => {
return Array.from(roomStore.roomsData.values())
.filter(room => room.co2) // Only include rooms with CO2 data
.sort((a, b) =>
(b.co2?.current || 0) - (a.co2?.current || 0) // Sort by CO2 level descending
.filter((room) => room.co2) // Only include rooms with CO2 data
.sort(
(a, b) => (b.co2?.current || 0) - (a.co2?.current || 0), // Sort by CO2 level descending
)
})
@@ -93,23 +114,27 @@ const overallStatus = computed(() => {
})
const roomsWithGoodAir = computed(() => {
return roomsList.value.filter(room => room.co2?.status === 'good').length
return roomsList.value.filter((room) => room.co2?.status === 'good').length
})
const roomsNeedingAttention = computed(() => {
return roomsList.value.filter(room => room.co2?.status && ['poor', 'critical'].includes(room.co2.status)).length
return roomsList.value.filter(
(room) => room.co2?.status && ['poor', 'critical'].includes(room.co2.status),
).length
})
const recommendations = computed(() => {
const recs = []
const criticalRooms = roomsList.value.filter(room => room.co2?.status === 'critical')
const poorRooms = roomsList.value.filter(room => room.co2?.status === 'poor')
const criticalRooms = roomsList.value.filter((room) => room.co2?.status === 'critical')
const poorRooms = roomsList.value.filter((room) => room.co2?.status === 'poor')
if (criticalRooms.length > 0) {
recs.push(`Immediate ventilation needed in ${criticalRooms[0].room}`)
}
if (poorRooms.length > 0) {
recs.push(`Increase air circulation in ${poorRooms.length} room${poorRooms.length > 1 ? 's' : ''}`)
recs.push(
`Increase air circulation in ${poorRooms.length} room${poorRooms.length > 1 ? 's' : ''}`,
)
}
if (overallCO2.value > 800) {
recs.push('Consider adjusting HVAC settings building-wide')
@@ -120,61 +145,91 @@ const recommendations = computed(() => {
const getOverallStatus = () => {
switch (overallStatus.value) {
case 'good': return 'Excellent Air Quality'
case 'moderate': return 'Moderate Air Quality'
case 'poor': return 'Poor Air Quality'
case 'critical': return 'Critical - Action Required'
default: return 'Unknown Status'
case 'good':
return 'Excellent Air Quality'
case 'moderate':
return 'Moderate Air Quality'
case 'poor':
return 'Poor Air Quality'
case 'critical':
return 'Critical - Action Required'
default:
return 'Unknown Status'
}
}
const getOverallStatusBg = () => {
switch (overallStatus.value) {
case 'good': return 'bg-green-50 border border-green-200'
case 'moderate': return 'bg-yellow-50 border border-yellow-200'
case 'poor': return 'bg-orange-50 border border-orange-200'
case 'critical': return 'bg-red-50 border border-red-200'
default: return 'bg-gray-50 border border-gray-200'
case 'good':
return 'bg-green-50 border border-green-200'
case 'moderate':
return 'bg-yellow-50 border border-yellow-200'
case 'poor':
return 'bg-orange-50 border border-orange-200'
case 'critical':
return 'bg-red-50 border border-red-200'
default:
return 'bg-gray-50 border border-gray-200'
}
}
const getOverallStatusText = () => {
switch (overallStatus.value) {
case 'good': return 'text-green-700'
case 'moderate': return 'text-yellow-700'
case 'poor': return 'text-orange-700'
case 'critical': return 'text-red-700'
default: return 'text-gray-700'
case 'good':
return 'text-green-700'
case 'moderate':
return 'text-yellow-700'
case 'poor':
return 'text-orange-700'
case 'critical':
return 'text-red-700'
default:
return 'text-gray-700'
}
}
const getOverallStatusIconBg = () => {
switch (overallStatus.value) {
case 'good': return 'bg-green-100'
case 'moderate': return 'bg-yellow-100'
case 'poor': return 'bg-orange-100'
case 'critical': return 'bg-red-100'
default: return 'bg-gray-100'
case 'good':
return 'bg-green-100'
case 'moderate':
return 'bg-yellow-100'
case 'poor':
return 'bg-orange-100'
case 'critical':
return 'bg-red-100'
default:
return 'bg-gray-100'
}
}
const getCO2StatusColor = (status: string) => {
switch (status) {
case 'good': return 'bg-green-500'
case 'moderate': return 'bg-yellow-500'
case 'poor': return 'bg-orange-500'
case 'critical': return 'bg-red-500'
default: return 'bg-gray-500'
case 'good':
return 'bg-green-500'
case 'moderate':
return 'bg-yellow-500'
case 'poor':
return 'bg-orange-500'
case 'critical':
return 'bg-red-500'
default:
return 'bg-gray-500'
}
}
const getCO2TextColor = (status: string) => {
switch (status) {
case 'good': return 'text-green-600'
case 'moderate': return 'text-yellow-600'
case 'poor': return 'text-orange-600'
case 'critical': return 'text-red-600'
default: return 'text-gray-600'
case 'good':
return 'text-green-600'
case 'moderate':
return 'text-yellow-600'
case 'poor':
return 'text-orange-600'
case 'critical':
return 'text-red-600'
default:
return 'text-gray-600'
}
}
</script>
</script>

View File

@@ -17,7 +17,7 @@
class="w-2 h-2 rounded-full transition-all duration-300"
:class="[
getSensorStatusColor(sensor.status),
isRecentlyUpdated ? 'animate-pulse shadow-lg shadow-green-400/50' : ''
isRecentlyUpdated ? 'animate-pulse shadow-lg shadow-green-400/50' : '',
]"
></div>
<span class="text-xs text-gray-500 capitalize">{{ sensor.status }}</span>
@@ -32,7 +32,9 @@
<label class="block text-sm font-medium text-gray-700 mb-2">Room Assignment</label>
<select
:value="sensor.room"
@change="$emit('updateRoom', sensor.sensor_id, ($event.target as HTMLSelectElement).value)"
@change="
$emit('updateRoom', sensor.sensor_id, ($event.target as HTMLSelectElement).value)
"
class="w-full px-3 py-2 border border-gray-200 rounded-lg bg-white text-sm"
>
<option value="">Unassigned</option>
@@ -46,7 +48,7 @@
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Tags</label>
<div class="flex flex-wrap gap-1 mb-2">
<span
<span
v-for="tag in sensor.tags || getDefaultTags(sensor)"
:key="tag"
class="px-2 py-1 bg-gray-100 text-gray-700 rounded-full text-xs font-medium"
@@ -60,7 +62,7 @@
<div>
<div class="text-sm font-medium text-gray-700 mb-2">Monitoring Capabilities</div>
<div class="flex flex-wrap gap-1">
<span
<span
v-for="capability in sensor.capabilities.monitoring"
:key="capability"
class="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs font-medium"
@@ -74,8 +76,7 @@
<div>
<div class="text-sm font-medium text-gray-700 mb-2">Current Values</div>
<div class="grid grid-cols-2 gap-2 text-xs">
<div v-for="metric in sensorValues" :key="metric.type"
class="bg-gray-50 rounded p-2">
<div v-for="metric in sensorValues" :key="metric.type" class="bg-gray-50 rounded p-2">
<div class="text-gray-600 mb-1">{{ metric.label }}</div>
<div class="font-medium text-gray-900">
{{ metric.value }} <span class="text-gray-500">{{ metric.unit }}</span>
@@ -109,7 +110,7 @@
<div class="flex items-center gap-1">
<span>{{ sensor.metadata.battery }}%</span>
<div class="w-3 h-1 bg-gray-200 rounded-full">
<div
<div
class="h-full rounded-full transition-all"
:class="getBatteryColor(sensor.metadata.battery)"
:style="{ width: sensor.metadata.battery + '%' }"
@@ -122,11 +123,11 @@
<div class="flex items-center gap-1">
<span>{{ sensor.metadata.signalStrength }}%</span>
<div class="flex gap-0.5">
<div
v-for="i in 4"
<div
v-for="i in 4"
:key="i"
class="w-1 h-2 bg-gray-200 rounded-sm"
:class="{ 'bg-green-500': (sensor.metadata.signalStrength / 25) >= i }"
:class="{ 'bg-green-500': sensor.metadata.signalStrength / 25 >= i }"
></div>
</div>
</div>
@@ -155,7 +156,9 @@
<!-- No Actions State -->
<div v-else>
<div class="text-sm font-medium text-gray-700 mb-2">Device Actions</div>
<div class="text-xs text-gray-500 text-center py-3 bg-gray-50 rounded border-2 border-dashed border-gray-200">
<div
class="text-xs text-gray-500 text-center py-3 bg-gray-50 rounded border-2 border-dashed border-gray-200"
>
This device is monitor-only and has no available actions
</div>
</div>
@@ -186,7 +189,10 @@ const getSensorValues = (sensor: SensorDevice) => {
// Get real-time sensor reading from store
const latestReading = sensorStore.latestReadings.get(sensor.sensor_id)
console.log(`[Detailed] Getting values for sensor ${sensor.sensor_id}, found reading:`, latestReading)
console.log(
`[Detailed] Getting values for sensor ${sensor.sensor_id}, found reading:`,
latestReading,
)
console.log('[Detailed] Available readings:', Array.from(sensorStore.latestReadings.keys()))
console.log(`[Detailed] Sensor capabilities:`, sensor.capabilities?.monitoring)
@@ -197,7 +203,7 @@ const getSensorValues = (sensor: SensorDevice) => {
type: 'energy',
label: 'Energy Consumption',
value: energyValue,
unit: latestReading?.energy?.unit || 'kWh'
unit: latestReading?.energy?.unit || 'kWh',
})
}
@@ -208,19 +214,19 @@ const getSensorValues = (sensor: SensorDevice) => {
type: 'co2',
label: 'CO2 Level',
value: co2Value,
unit: latestReading?.co2?.unit || 'ppm'
unit: latestReading?.co2?.unit || 'ppm',
})
}
// Only show temperature if the sensor monitors temperature
if (sensor.capabilities?.monitoring?.includes('temperature')) {
const tempValue = latestReading?.temperature?.value?.toFixed(1) ||
(Math.random() * 8 + 18).toFixed(1)
const tempValue =
latestReading?.temperature?.value?.toFixed(1) || (Math.random() * 8 + 18).toFixed(1)
values.push({
type: 'temperature',
label: 'Temperature',
value: tempValue,
unit: latestReading?.temperature?.unit || '°C'
unit: latestReading?.temperature?.unit || '°C',
})
}
@@ -230,7 +236,7 @@ const getSensorValues = (sensor: SensorDevice) => {
type: 'humidity',
label: 'Humidity',
value: Math.floor(Math.random() * 40 + 30),
unit: '%'
unit: '%',
})
}
@@ -240,7 +246,7 @@ const getSensorValues = (sensor: SensorDevice) => {
type: 'motion',
label: 'Motion Status',
value: Math.random() > 0.7 ? 'Detected' : 'Clear',
unit: ''
unit: '',
})
}
@@ -252,13 +258,13 @@ const getSensorValues = (sensor: SensorDevice) => {
type: 'brightness',
label: 'Brightness Level',
value: Math.floor(Math.random() * 100),
unit: '%'
unit: '%',
})
values.push({
type: 'power',
label: 'Power Draw',
value: Math.floor(Math.random() * 50 + 5),
unit: 'W'
unit: 'W',
})
break
case 'hvac':
@@ -266,13 +272,13 @@ const getSensorValues = (sensor: SensorDevice) => {
type: 'setpoint',
label: 'Target Temperature',
value: (Math.random() * 6 + 18).toFixed(1),
unit: '°C'
unit: '°C',
})
values.push({
type: 'mode',
label: 'Operating Mode',
value: ['Heat', 'Cool', 'Auto', 'Fan'][Math.floor(Math.random() * 4)],
unit: ''
unit: '',
})
break
case 'security':
@@ -280,13 +286,13 @@ const getSensorValues = (sensor: SensorDevice) => {
type: 'status',
label: 'Security Status',
value: Math.random() > 0.8 ? 'Alert' : 'Normal',
unit: ''
unit: '',
})
values.push({
type: 'armed',
label: 'System Armed',
value: Math.random() > 0.5 ? 'Yes' : 'No',
unit: ''
unit: '',
})
break
default:
@@ -295,7 +301,7 @@ const getSensorValues = (sensor: SensorDevice) => {
type: 'status',
label: 'Device Status',
value: sensor.status === 'online' ? 'Active' : 'Inactive',
unit: ''
unit: '',
})
}
}
@@ -305,7 +311,7 @@ const getSensorValues = (sensor: SensorDevice) => {
type: 'uptime',
label: 'Uptime',
value: Math.floor(Math.random() * 30 + 1),
unit: 'days'
unit: 'days',
})
return values
@@ -345,7 +351,7 @@ const getSensorTypeIcon = (type: string) => {
humidity: '💧',
hvac: '❄️',
lighting: '💡',
security: '🔒'
security: '🔒',
}
return icons[type as keyof typeof icons] || '📱'
}
@@ -358,17 +364,21 @@ const getSensorTypeStyle = (type: string) => {
humidity: { bg: 'bg-blue-100', text: 'text-blue-700' },
hvac: { bg: 'bg-cyan-100', text: 'text-cyan-700' },
lighting: { bg: 'bg-amber-100', text: 'text-amber-700' },
security: { bg: 'bg-purple-100', text: 'text-purple-700' }
security: { bg: 'bg-purple-100', text: 'text-purple-700' },
}
return styles[type as keyof typeof styles] || { bg: 'bg-gray-100', text: 'text-gray-700' }
}
const getSensorStatusColor = (status: string) => {
switch (status) {
case 'online': return 'bg-green-500'
case 'offline': return 'bg-gray-400'
case 'error': return 'bg-red-500'
default: return 'bg-gray-400'
case 'online':
return 'bg-green-500'
case 'offline':
return 'bg-gray-400'
case 'error':
return 'bg-red-500'
default:
return 'bg-gray-400'
}
}
@@ -383,7 +393,7 @@ const formatTime = (timestamp: number) => {
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffSecs = Math.floor(diffMs / 1000)
if (diffSecs < 60) {
return `${diffSecs}s ago`
} else if (diffSecs < 3600) {
@@ -394,4 +404,4 @@ const formatTime = (timestamp: number) => {
return date.toLocaleDateString()
}
}
</script>
</script>

View File

@@ -1,26 +1,25 @@
<template>
<div class="bg-white rounded-2xl shadow-sm p-4">
<h6 class="text-sm font-bold text-gray-500 mb-4">Room Overview</h6>
<div class="space-y-4">
<div v-if="roomsList.length === 0" class="text-center text-gray-500 py-8">
No room data available. Connect sensors to see room metrics.
</div>
<div v-for="room in roomsList" :key="room.room" class="border border-gray-100 rounded-lg p-3">
<!-- Room Header -->
<div class="flex items-center justify-between mb-3">
<h3 class="font-medium text-gray-900">{{ room.room }}</h3>
<div class="flex items-center gap-2">
<!-- CO2 Status Indicator -->
<div
class="w-3 h-3 rounded-full"
:class="getCO2StatusColor(room.co2!.status)"
></div>
<div class="w-3 h-3 rounded-full" :class="getCO2StatusColor(room.co2!.status)"></div>
<!-- Occupancy Indicator -->
<div class="flex items-center gap-1 text-xs text-gray-500">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
<path
d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"
/>
</svg>
<span class="capitalize">{{ room.occupancyEstimate }}</span>
</div>
@@ -32,15 +31,21 @@
<!-- Energy -->
<div class="bg-blue-50 rounded p-2">
<div class="text-blue-600 font-medium">Energy</div>
<div class="text-blue-900">{{ room.energy!.current.toFixed(2) }} {{ room.energy!.unit }}</div>
<div class="text-blue-900">
{{ room.energy!.current.toFixed(2) }} {{ room.energy!.unit }}
</div>
<div class="text-blue-600 text-xs">Total: {{ room.energy!.total.toFixed(2) }}</div>
</div>
<!-- CO2 -->
<div class="rounded p-2" :class="getCO2BackgroundColor(room.co2!.status)">
<div class="font-medium" :class="getCO2TextColor(room.co2!.status)">CO2</div>
<div :class="getCO2TextColor(room.co2!.status)">{{ Math.round(room.co2!.current) }} {{ room.co2!.unit }}</div>
<div class="text-xs" :class="getCO2TextColor(room.co2!.status)">{{ room.co2!.status.toUpperCase() }}</div>
<div :class="getCO2TextColor(room.co2!.status)">
{{ Math.round(room.co2!.current) }} {{ room.co2!.unit }}
</div>
<div class="text-xs" :class="getCO2TextColor(room.co2!.status)">
{{ room.co2!.status.toUpperCase() }}
</div>
</div>
</div>
@@ -53,7 +58,10 @@
</div>
<!-- Summary Stats -->
<div v-if="roomsList.length > 0" class="mt-4 pt-4 border-t border-gray-100 grid grid-cols-3 gap-4 text-center text-xs">
<div
v-if="roomsList.length > 0"
class="mt-4 pt-4 border-t border-gray-100 grid grid-cols-3 gap-4 text-center text-xs"
>
<div>
<div class="font-medium text-gray-900">{{ roomsList.length }}</div>
<div class="text-gray-500">Rooms</div>
@@ -78,7 +86,7 @@ const roomStore = useRoomStore()
const roomsList = computed(() => {
return Array.from(roomStore.roomsData.values())
.filter(room => room.energy && room.co2) // Only show rooms with both metrics
.filter((room) => room.energy && room.co2) // Only show rooms with both metrics
.sort((a, b) => a.room.localeCompare(b.room))
})
@@ -94,31 +102,46 @@ const averageCO2 = computed(() => {
const getCO2StatusColor = (status: string) => {
switch (status) {
case 'good': return 'bg-green-500'
case 'moderate': return 'bg-yellow-500'
case 'poor': return 'bg-orange-500'
case 'critical': return 'bg-red-500'
default: return 'bg-gray-500'
case 'good':
return 'bg-green-500'
case 'moderate':
return 'bg-yellow-500'
case 'poor':
return 'bg-orange-500'
case 'critical':
return 'bg-red-500'
default:
return 'bg-gray-500'
}
}
const getCO2BackgroundColor = (status: string) => {
switch (status) {
case 'good': return 'bg-green-50'
case 'moderate': return 'bg-yellow-50'
case 'poor': return 'bg-orange-50'
case 'critical': return 'bg-red-50'
default: return 'bg-gray-50'
case 'good':
return 'bg-green-50'
case 'moderate':
return 'bg-yellow-50'
case 'poor':
return 'bg-orange-50'
case 'critical':
return 'bg-red-50'
default:
return 'bg-gray-50'
}
}
const getCO2TextColor = (status: string) => {
switch (status) {
case 'good': return 'text-green-700'
case 'moderate': return 'text-yellow-700'
case 'poor': return 'text-orange-700'
case 'critical': return 'text-red-700'
default: return 'text-gray-700'
case 'good':
return 'text-green-700'
case 'moderate':
return 'text-yellow-700'
case 'poor':
return 'text-orange-700'
case 'critical':
return 'text-red-700'
default:
return 'text-gray-700'
}
}
@@ -127,7 +150,7 @@ const formatTime = (timestamp: number) => {
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffSecs = Math.floor(diffMs / 1000)
if (diffSecs < 60) {
return `${diffSecs}s ago`
} else if (diffSecs < 3600) {
@@ -136,4 +159,4 @@ const formatTime = (timestamp: number) => {
return date.toLocaleTimeString()
}
}
</script>
</script>

View File

@@ -9,9 +9,7 @@
Token expires in: {{ formatTimeUntilExpiry() }}
</div>
<div v-if="authStore.error" class="auth-status__error">
Auth Error: {{ authStore.error }}
</div>
<div v-if="authStore.error" class="auth-status__error">Auth Error: {{ authStore.error }}</div>
<button
v-if="!authStore.isAuthenticated"
@@ -33,13 +31,13 @@ const authStore = useAuthStore()
const authStatusClass = computed(() => ({
'auth-status--authenticated': authStore.isAuthenticated,
'auth-status--error': !authStore.isAuthenticated || authStore.error,
'auth-status--loading': authStore.isLoading
'auth-status--loading': authStore.isLoading,
}))
const statusDotClass = computed(() => ({
'auth-status__dot--green': authStore.isAuthenticated && !authStore.error,
'auth-status__dot--red': !authStore.isAuthenticated || authStore.error,
'auth-status__dot--yellow': authStore.isLoading
'auth-status__dot--yellow': authStore.isLoading,
}))
const statusText = computed(() => {
@@ -116,4 +114,4 @@ async function handleReauth() {
@apply bg-blue-500 text-white px-2 py-1 rounded text-xs hover:bg-blue-600 disabled:opacity-50;
}
}
</style>
</style>

View File

@@ -4,7 +4,9 @@
<div class="absolute inset-0 bg-black/50" @click="$emit('close')"></div>
<!-- Modal -->
<div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto">
<div
class="relative bg-white rounded-2xl shadow-xl max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto"
>
<!-- Header -->
<div class="p-6 border-b border-gray-100">
<div class="flex items-center justify-between">

View File

@@ -5,12 +5,17 @@
<div class="p-6 border-b border-gray-200">
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900">Room Management</h2>
<button
<button
@click="$emit('close')"
class="text-gray-400 hover:text-gray-600 transition-colors"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
@@ -53,49 +58,54 @@
</div>
<div class="grid grid-cols-1 gap-3">
<div
v-for="room in roomsWithStats"
:key="room.name"
class="bg-gray-50 rounded-lg p-4"
>
<div v-for="room in roomsWithStats" :key="room.name" class="bg-gray-50 rounded-lg p-4">
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<h4 class="font-medium text-gray-900">{{ room.name }}</h4>
<span class="text-xs px-2 py-1 bg-blue-100 text-blue-700 rounded-full font-medium">
<span
class="text-xs px-2 py-1 bg-blue-100 text-blue-700 rounded-full font-medium"
>
{{ room.sensorCount }} sensors
</span>
</div>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 text-sm">
<div>
<span class="text-gray-600">Types:</span>
<div class="flex flex-wrap gap-1 mt-1">
<span
v-for="type in room.sensorTypes"
<span
v-for="type in room.sensorTypes"
:key="type"
class="text-xs px-2 py-0.5 bg-gray-200 text-gray-700 rounded"
>
{{ type }}
</span>
<span v-if="room.sensorTypes.length === 0" class="text-xs text-gray-500">None</span>
<span v-if="room.sensorTypes.length === 0" class="text-xs text-gray-500"
>None</span
>
</div>
</div>
<div>
<span class="text-gray-600">Energy:</span>
<div class="font-medium" :class="room.hasMetrics ? 'text-gray-900' : 'text-gray-400'">
{{ room.hasMetrics ? room.energyConsumption.toFixed(2) + ' kWh' : 'No data' }}
<div
class="font-medium"
:class="room.hasMetrics ? 'text-gray-900' : 'text-gray-400'"
>
{{
room.hasMetrics ? room.energyConsumption.toFixed(2) + ' kWh' : 'No data'
}}
</div>
</div>
<div>
<span class="text-gray-600">CO2:</span>
<div class="font-medium" :class="getCO2Color(room.co2Level)">
{{ room.hasMetrics ? Math.round(room.co2Level) + ' ppm' : 'No data' }}
</div>
</div>
<div>
<span class="text-gray-600">Last Update:</span>
<div class="text-xs text-gray-500">
@@ -104,7 +114,7 @@
</div>
</div>
</div>
<div class="ml-4">
<button
@click="confirmDeleteRoom(room.name)"
@@ -118,7 +128,7 @@
</div>
</div>
</div>
<!-- Empty state -->
<div v-if="roomsWithStats.length === 0" class="text-center py-8">
<div class="text-gray-400 text-4xl mb-2">🏢</div>
@@ -132,7 +142,7 @@
<!-- Footer -->
<div class="p-6 border-t border-gray-200 bg-gray-50">
<div class="flex justify-end gap-3">
<button
<button
@click="$emit('close')"
class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-50 transition-colors"
>
@@ -143,23 +153,28 @@
</div>
<!-- Delete Confirmation Modal -->
<div v-if="roomToDelete" class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-60">
<div
v-if="roomToDelete"
class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-60"
>
<div class="bg-white rounded-xl max-w-md w-full p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-2">Delete Room</h3>
<p class="text-gray-600 mb-4">
Are you sure you want to delete <strong>"{{ roomToDelete }}"</strong>?
{{ getRoomStats(roomToDelete).sensorCount > 0
? `This will unassign ${getRoomStats(roomToDelete).sensorCount} sensor(s).`
: 'This action cannot be undone.' }}
Are you sure you want to delete <strong>"{{ roomToDelete }}"</strong>?
{{
getRoomStats(roomToDelete).sensorCount > 0
? `This will unassign ${getRoomStats(roomToDelete).sensorCount} sensor(s).`
: 'This action cannot be undone.'
}}
</p>
<div class="flex gap-3">
<button
<button
@click="roomToDelete = null"
class="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-50 transition-colors"
>
Cancel
</button>
<button
<button
@click="deleteRoom"
class="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium transition-colors"
>
@@ -274,7 +289,7 @@ const formatTime = (timestamp: number) => {
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffSecs = Math.floor(diffMs / 1000)
if (diffSecs < 60) {
return `${diffSecs}s ago`
} else if (diffSecs < 3600) {
@@ -296,4 +311,4 @@ const clearError = () => {
// Watch for changes in newRoomName to clear errors
import { watch } from 'vue'
watch(newRoomName, clearError)
</script>
</script>

View File

@@ -21,7 +21,7 @@ import {
type HealthCheck,
type SystemStatus,
type DataQuery,
type DataResponse
type DataResponse,
} from '@/services'
interface ApiState {
@@ -33,16 +33,16 @@ export function useApi() {
// Global API state
const globalState = reactive<ApiState>({
loading: false,
error: null
error: null,
})
// Helper to handle API calls with state management
async function handleApiCall<T>(
apiCall: () => Promise<T>,
localState?: { loading: boolean; error: string | null }
localState?: { loading: boolean; error: string | null },
): Promise<T | null> {
const state = localState || globalState
state.loading = true
state.error = null
@@ -61,7 +61,7 @@ export function useApi() {
return {
globalState,
handleApiCall
handleApiCall,
}
}
@@ -69,7 +69,7 @@ export function useApi() {
export function useSensorsApi() {
const state = reactive<ApiState>({
loading: false,
error: null
error: null,
})
const sensors = ref<SensorDevice[]>([])
@@ -83,10 +83,7 @@ export function useSensorsApi() {
sensor_type?: SensorType
status?: SensorStatus
}) => {
const result = await handleApiCall(
() => sensorsApi.getSensors(params),
state
)
const result = await handleApiCall(() => sensorsApi.getSensors(params), state)
if (result && result.sensors) {
sensors.value = result.sensors
}
@@ -94,10 +91,7 @@ export function useSensorsApi() {
}
const fetchSensor = async (sensorId: string) => {
const result = await handleApiCall(
() => sensorsApi.getSensor(sensorId),
state
)
const result = await handleApiCall(() => sensorsApi.getSensor(sensorId), state)
if (result) {
currentSensor.value = result
}
@@ -111,12 +105,9 @@ export function useSensorsApi() {
end_time?: number
limit?: number
offset?: number
}
},
) => {
const result = await handleApiCall(
() => sensorsApi.getSensorData(sensorId, params),
state
)
const result = await handleApiCall(() => sensorsApi.getSensorData(sensorId, params), state)
if (result) {
sensorData.value = result
}
@@ -124,27 +115,15 @@ export function useSensorsApi() {
}
const queryData = async (query: DataQuery) => {
return handleApiCall(
() => sensorsApi.queryData(query),
state
)
return handleApiCall(() => sensorsApi.queryData(query), state)
}
const updateSensorMetadata = async (
sensorId: string,
metadata: SensorMetadata
) => {
return handleApiCall(
() => sensorsApi.updateSensorMetadata(sensorId, metadata),
state
)
const updateSensorMetadata = async (sensorId: string, metadata: SensorMetadata) => {
return handleApiCall(() => sensorsApi.updateSensorMetadata(sensorId, metadata), state)
}
const deleteSensor = async (sensorId: string) => {
return handleApiCall(
() => sensorsApi.deleteSensor(sensorId),
state
)
return handleApiCall(() => sensorsApi.deleteSensor(sensorId), state)
}
const exportData = async (params: {
@@ -153,10 +132,7 @@ export function useSensorsApi() {
sensor_ids?: string
format?: 'json' | 'csv'
}) => {
return handleApiCall(
() => sensorsApi.exportData(params),
state
)
return handleApiCall(() => sensorsApi.exportData(params), state)
}
return {
@@ -170,7 +146,7 @@ export function useSensorsApi() {
queryData,
updateSensorMetadata,
deleteSensor,
exportData
exportData,
}
}
@@ -178,7 +154,7 @@ export function useSensorsApi() {
export function useRoomsApi() {
const state = reactive<ApiState>({
loading: false,
error: null
error: null,
})
const rooms = ref<RoomInfo[]>([])
@@ -187,10 +163,7 @@ export function useRoomsApi() {
const { handleApiCall } = useApi()
const fetchRooms = async () => {
const result = await handleApiCall(
() => roomsApi.getRooms(),
state
)
const result = await handleApiCall(() => roomsApi.getRooms(), state)
if (result) {
rooms.value = result
}
@@ -203,12 +176,9 @@ export function useRoomsApi() {
start_time?: number
end_time?: number
limit?: number
}
},
) => {
const result = await handleApiCall(
() => roomsApi.getRoomData(roomName, params),
state
)
const result = await handleApiCall(() => roomsApi.getRoomData(roomName, params), state)
if (result) {
currentRoomData.value = result
}
@@ -220,7 +190,7 @@ export function useRoomsApi() {
rooms,
currentRoomData,
fetchRooms,
fetchRoomData
fetchRoomData,
}
}
@@ -228,7 +198,7 @@ export function useRoomsApi() {
export function useAnalyticsApi() {
const state = reactive<ApiState>({
loading: false,
error: null
error: null,
})
const summary = ref<AnalyticsSummary | null>(null)
@@ -239,10 +209,7 @@ export function useAnalyticsApi() {
const { handleApiCall } = useApi()
const fetchAnalyticsSummary = async (hours: number = 24) => {
const result = await handleApiCall(
() => analyticsApi.getAnalyticsSummary(hours),
state
)
const result = await handleApiCall(() => analyticsApi.getAnalyticsSummary(hours), state)
if (result) {
summary.value = result
}
@@ -250,10 +217,7 @@ export function useAnalyticsApi() {
}
const fetchEnergyTrends = async (hours: number = 168) => {
const result = await handleApiCall(
() => analyticsApi.getEnergyTrends(hours),
state
)
const result = await handleApiCall(() => analyticsApi.getEnergyTrends(hours), state)
if (result) {
trends.value = result
}
@@ -261,10 +225,7 @@ export function useAnalyticsApi() {
}
const fetchRoomComparison = async (hours: number = 24) => {
const result = await handleApiCall(
() => analyticsApi.getRoomComparison(hours),
state
)
const result = await handleApiCall(() => analyticsApi.getRoomComparison(hours), state)
if (result) {
roomComparison.value = result
}
@@ -277,10 +238,7 @@ export function useAnalyticsApi() {
hours?: number
limit?: number
}) => {
const result = await handleApiCall(
() => analyticsApi.getEvents(params),
state
)
const result = await handleApiCall(() => analyticsApi.getEvents(params), state)
if (result) {
events.value = result.events
}
@@ -296,7 +254,7 @@ export function useAnalyticsApi() {
fetchAnalyticsSummary,
fetchEnergyTrends,
fetchRoomComparison,
fetchEvents
fetchEvents,
}
}
@@ -304,7 +262,7 @@ export function useAnalyticsApi() {
export function useHealthApi() {
const state = reactive<ApiState>({
loading: false,
error: null
error: null,
})
const health = ref<HealthCheck | null>(null)
@@ -313,10 +271,7 @@ export function useHealthApi() {
const { handleApiCall } = useApi()
const fetchHealth = async () => {
const result = await handleApiCall(
() => healthApi.getHealth(),
state
)
const result = await handleApiCall(() => healthApi.getHealth(), state)
if (result) {
health.value = result
}
@@ -324,10 +279,7 @@ export function useHealthApi() {
}
const fetchStatus = async () => {
const result = await handleApiCall(
() => healthApi.getStatus(),
state
)
const result = await handleApiCall(() => healthApi.getStatus(), state)
if (result) {
status.value = result
}
@@ -339,6 +291,6 @@ export function useHealthApi() {
health,
status,
fetchHealth,
fetchStatus
fetchStatus,
}
}
}

View File

@@ -346,7 +346,10 @@ class ApiClient {
}
}
async get<T>(endpoint: string, params?: Record<string, string | number | boolean | string[]>): Promise<T> {
async get<T>(
endpoint: string,
params?: Record<string, string | number | boolean | string[]>,
): Promise<T> {
const url = new URL(`${this.baseUrl}${endpoint}`)
if (params) {

View File

@@ -25,7 +25,10 @@ export const authApi = {
},
async saveToken(token: string): Promise<{ token: string; datetime: string; active: boolean }> {
return apiClient.post<{ token: string; datetime: string; active: boolean }>('/api/v1/tokens/save', { token })
return apiClient.post<{ token: string; datetime: string; active: boolean }>(
'/api/v1/tokens/save',
{ token },
)
},
async validateToken(token: string): Promise<TokenValidation> {

View File

@@ -26,5 +26,5 @@ export type {
RoomComparison,
SystemEvent,
HealthCheck,
SystemStatus
} from './api'
SystemStatus,
} from './api'

View File

@@ -131,11 +131,7 @@ export const useAnalyticsStore = defineStore('analytics', () => {
// Initialize data from APIs
async function initializeAnalyticsFromApi() {
await Promise.allSettled([
fetchAnalyticsSummary(),
fetchSystemStatus(),
fetchHealthStatus(),
])
await Promise.allSettled([fetchAnalyticsSummary(), fetchSystemStatus(), fetchHealthStatus()])
}
return {
@@ -155,4 +151,4 @@ export const useAnalyticsStore = defineStore('analytics', () => {
fetchHealthStatus,
initializeAnalyticsFromApi,
}
})
})

View File

@@ -199,7 +199,7 @@ export const useAuthStore = defineStore('auth', () => {
}
// Initialize on store creation (async)
loadTokenFromStorage().catch(error => {
loadTokenFromStorage().catch((error) => {
console.warn('Failed to load token from storage:', error)
})

View File

@@ -13,4 +13,4 @@ export type {
RoomComparison,
SystemStatus,
HealthCheck,
} from '@/services'
} from '@/services'

View File

@@ -316,7 +316,9 @@ export const useRoomStore = defineStore('room', () => {
apiRooms.value = roomsArray
// Update available rooms from API data
const roomNames = roomsArray.map((room: any) => room.name || room.room).filter((name: string) => name)
const roomNames = roomsArray
.map((room: any) => room.name || room.room)
.filter((name: string) => name)
if (roomNames.length > 0) {
availableRooms.value = [...new Set([...availableRooms.value, ...roomNames])].sort()
}

View File

@@ -38,7 +38,7 @@ export const useSensorStore = defineStore('sensor', () => {
// Aggregated CO2 metrics
const averageCO2Level = computed<number>(() => {
const readings = Array.from(latestReadings.values())
const co2Readings = readings.filter(r => r.co2?.value !== undefined)
const co2Readings = readings.filter((r) => r.co2?.value !== undefined)
if (co2Readings.length === 0) return 0
@@ -49,8 +49,8 @@ export const useSensorStore = defineStore('sensor', () => {
const maxCO2Level = computed<number>(() => {
const readings = Array.from(latestReadings.values())
const co2Values = readings
.filter(r => r.co2?.value !== undefined)
.map(r => r.co2?.value || 0)
.filter((r) => r.co2?.value !== undefined)
.map((r) => r.co2?.value || 0)
return co2Values.length > 0 ? Math.max(...co2Values) : 0
})

View File

@@ -117,7 +117,10 @@ export const useWebSocketStore = defineStore('websocket', () => {
function processIncomingData(data: WebSocketReading): void {
// Skip non-data messages
if ('type' in data && (data as any).type === 'connection_established' || (data as any).type === 'proxy_info') {
if (
('type' in data && (data as any).type === 'connection_established') ||
(data as any).type === 'proxy_info'
) {
return
}

View File

@@ -220,11 +220,7 @@
No rooms found from API
</div>
<div v-else class="space-y-3">
<div
v-for="room in apiRooms"
:key="room.room"
class="p-3 bg-gray-50 rounded-lg"
>
<div v-for="room in apiRooms" :key="room.room" class="p-3 bg-gray-50 rounded-lg">
<div class="flex items-center justify-between mb-2">
<p class="font-medium text-gray-900">{{ room.room }}</p>
<span class="text-sm text-gray-500">{{ room.sensor_count }} sensors</span>

View File

@@ -257,7 +257,11 @@ interface ActionParameters {
[key: string]: unknown
}
const handleActionExecute = async (sensorId: string, actionId: string, parameters: ActionParameters) => {
const handleActionExecute = async (
sensorId: string,
actionId: string,
parameters: ActionParameters,
) => {
isExecutingAction.value = true
try {
await sensorStore.executeSensorAction(sensorId, actionId)

View File

@@ -28,9 +28,11 @@
<button
@click="activeSection = section.id"
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left transition-colors"
:class="activeSection === section.id
? 'bg-blue-100 text-blue-700'
: 'text-gray-700 hover:bg-gray-100'"
:class="
activeSection === section.id
? 'bg-blue-100 text-blue-700'
: 'text-gray-700 hover:bg-gray-100'
"
>
<span class="text-lg">{{ section.icon }}</span>
<div>
@@ -46,7 +48,10 @@
<!-- Settings Content -->
<div class="lg:col-span-2 space-y-6">
<!-- Appearance Settings -->
<div v-if="activeSection === 'appearance'" class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div
v-if="activeSection === 'appearance'"
class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden"
>
<div class="p-6 border-b border-gray-100">
<h3 class="text-lg font-semibold text-gray-900">Appearance</h3>
<p class="text-gray-600 text-sm mt-1">Customize the look and feel of your dashboard</p>
@@ -61,9 +66,11 @@
:key="theme.value"
@click="settingsStore.updateSetting('theme', theme.value)"
class="relative p-4 border-2 rounded-lg cursor-pointer transition-all hover:border-blue-300"
:class="settingsStore.settings.theme === theme.value
? 'border-blue-500 bg-blue-50'
: 'border-gray-200'"
:class="
settingsStore.settings.theme === theme.value
? 'border-blue-500 bg-blue-50'
: 'border-gray-200'
"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
@@ -72,7 +79,11 @@
</div>
<div v-if="settingsStore.settings.theme === theme.value" class="text-blue-600">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
</div>
</div>
@@ -89,9 +100,11 @@
:key="mode.value"
@click="settingsStore.updateSetting('ui.navigationMode', mode.value)"
class="relative p-4 border-2 rounded-lg cursor-pointer transition-all hover:border-blue-300"
:class="settingsStore.settings.ui.navigationMode === mode.value
? 'border-blue-500 bg-blue-50'
: 'border-gray-200'"
:class="
settingsStore.settings.ui.navigationMode === mode.value
? 'border-blue-500 bg-blue-50'
: 'border-gray-200'
"
>
<div class="flex items-start justify-between">
<div class="flex items-start gap-3">
@@ -101,9 +114,16 @@
<div class="text-sm text-gray-600 mt-1">{{ mode.description }}</div>
</div>
</div>
<div v-if="settingsStore.settings.ui.navigationMode === mode.value" class="text-blue-600 mt-1">
<div
v-if="settingsStore.settings.ui.navigationMode === mode.value"
class="text-blue-600 mt-1"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
</div>
</div>
@@ -119,12 +139,21 @@
<div class="text-sm text-gray-600">Reduce spacing and padding</div>
</div>
<button
@click="settingsStore.updateSetting('ui.compactMode', !settingsStore.settings.ui.compactMode)"
@click="
settingsStore.updateSetting(
'ui.compactMode',
!settingsStore.settings.ui.compactMode,
)
"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="settingsStore.settings.ui.compactMode ? 'bg-blue-600' : 'bg-gray-200'"
>
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="settingsStore.settings.ui.compactMode ? 'translate-x-6' : 'translate-x-1'"></span>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="
settingsStore.settings.ui.compactMode ? 'translate-x-6' : 'translate-x-1'
"
></span>
</button>
</div>
@@ -134,12 +163,21 @@
<div class="text-sm text-gray-600">Enable smooth transitions</div>
</div>
<button
@click="settingsStore.updateSetting('ui.showAnimations', !settingsStore.settings.ui.showAnimations)"
@click="
settingsStore.updateSetting(
'ui.showAnimations',
!settingsStore.settings.ui.showAnimations,
)
"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="settingsStore.settings.ui.showAnimations ? 'bg-blue-600' : 'bg-gray-200'"
>
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="settingsStore.settings.ui.showAnimations ? 'translate-x-6' : 'translate-x-1'"></span>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="
settingsStore.settings.ui.showAnimations ? 'translate-x-6' : 'translate-x-1'
"
></span>
</button>
</div>
</div>
@@ -147,7 +185,10 @@
</div>
<!-- Data & Sync Settings -->
<div v-if="activeSection === 'data'" class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div
v-if="activeSection === 'data'"
class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden"
>
<div class="p-6 border-b border-gray-100">
<h3 class="text-lg font-semibold text-gray-900">Data & Synchronization</h3>
<p class="text-gray-600 text-sm mt-1">Configure data refresh and connection settings</p>
@@ -160,12 +201,19 @@
<div class="text-sm text-gray-600">Automatically refresh data periodically</div>
</div>
<button
@click="settingsStore.updateSetting('ui.autoRefresh', !settingsStore.settings.ui.autoRefresh)"
@click="
settingsStore.updateSetting(
'ui.autoRefresh',
!settingsStore.settings.ui.autoRefresh,
)
"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="settingsStore.settings.ui.autoRefresh ? 'bg-blue-600' : 'bg-gray-200'"
>
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="settingsStore.settings.ui.autoRefresh ? 'translate-x-6' : 'translate-x-1'"></span>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="settingsStore.settings.ui.autoRefresh ? 'translate-x-6' : 'translate-x-1'"
></span>
</button>
</div>
@@ -178,10 +226,17 @@
min="1"
max="60"
:value="settingsStore.settings.ui.refreshInterval"
@input="settingsStore.updateSetting('ui.refreshInterval', parseInt(($event.target as HTMLInputElement).value))"
@input="
settingsStore.updateSetting(
'ui.refreshInterval',
parseInt(($event.target as HTMLInputElement).value),
)
"
class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
/>
<div class="bg-gray-100 px-3 py-1 rounded text-sm font-medium min-w-[80px] text-center">
<div
class="bg-gray-100 px-3 py-1 rounded text-sm font-medium min-w-[80px] text-center"
>
{{ settingsStore.settings.ui.refreshInterval }}s
</div>
</div>
@@ -196,43 +251,62 @@
type="text"
placeholder="ws://localhost:8000/ws"
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
:class="{ 'border-red-300 focus:ring-red-500 focus:border-red-500': websocketUrlError }"
:class="{
'border-red-300 focus:ring-red-500 focus:border-red-500': websocketUrlError,
}"
/>
<button
@click="updateWebSocketUrl"
:disabled="!websocketUrlInput || websocketUrlInput === settingsStore.settings.websocketUrl"
:disabled="
!websocketUrlInput || websocketUrlInput === settingsStore.settings.websocketUrl
"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white rounded-lg text-sm font-medium transition-colors"
>
Update
</button>
</div>
<p v-if="websocketUrlError" class="text-red-600 text-sm mt-1">{{ websocketUrlError }}</p>
<p v-else class="text-gray-500 text-sm mt-1">Current: {{ settingsStore.settings.websocketUrl }}</p>
<p v-if="websocketUrlError" class="text-red-600 text-sm mt-1">
{{ websocketUrlError }}
</p>
<p v-else class="text-gray-500 text-sm mt-1">
Current: {{ settingsStore.settings.websocketUrl }}
</p>
</div>
<!-- Auto Connect -->
<div class="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
<div>
<div class="font-medium text-gray-900">Auto Connect</div>
<div class="text-sm text-gray-600">Automatically connect to WebSocket on app start</div>
<div class="text-sm text-gray-600">
Automatically connect to WebSocket on app start
</div>
</div>
<button
@click="settingsStore.updateSetting('autoConnect', !settingsStore.settings.autoConnect)"
@click="
settingsStore.updateSetting('autoConnect', !settingsStore.settings.autoConnect)
"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="settingsStore.settings.autoConnect ? 'bg-blue-600' : 'bg-gray-200'"
>
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="settingsStore.settings.autoConnect ? 'translate-x-6' : 'translate-x-1'"></span>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="settingsStore.settings.autoConnect ? 'translate-x-6' : 'translate-x-1'"
></span>
</button>
</div>
</div>
</div>
<!-- Notifications Settings -->
<div v-if="activeSection === 'notifications'" class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div
v-if="activeSection === 'notifications'"
class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden"
>
<div class="p-6 border-b border-gray-100">
<h3 class="text-lg font-semibold text-gray-900">Notifications</h3>
<p class="text-gray-600 text-sm mt-1">Configure how you receive alerts and notifications</p>
<p class="text-gray-600 text-sm mt-1">
Configure how you receive alerts and notifications
</p>
</div>
<div class="p-6 space-y-6">
<!-- Enable Notifications -->
@@ -242,34 +316,62 @@
<div class="text-sm text-gray-600">Receive system alerts and updates</div>
</div>
<button
@click="settingsStore.updateSetting('notifications.enabled', !settingsStore.settings.notifications.enabled)"
@click="
settingsStore.updateSetting(
'notifications.enabled',
!settingsStore.settings.notifications.enabled,
)
"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="settingsStore.settings.notifications.enabled ? 'bg-blue-600' : 'bg-gray-200'"
:class="
settingsStore.settings.notifications.enabled ? 'bg-blue-600' : 'bg-gray-200'
"
>
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="settingsStore.settings.notifications.enabled ? 'translate-x-6' : 'translate-x-1'"></span>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="
settingsStore.settings.notifications.enabled ? 'translate-x-6' : 'translate-x-1'
"
></span>
</button>
</div>
<div v-if="settingsStore.settings.notifications.enabled" class="space-y-4">
<!-- Notification Types -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
<div
class="flex items-center justify-between p-4 border border-gray-200 rounded-lg"
>
<div>
<div class="font-medium text-gray-900">Sound Alerts</div>
<div class="text-sm text-gray-600">Play sound for notifications</div>
</div>
<button
@click="settingsStore.updateSetting('notifications.sound', !settingsStore.settings.notifications.sound)"
@click="
settingsStore.updateSetting(
'notifications.sound',
!settingsStore.settings.notifications.sound,
)
"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="settingsStore.settings.notifications.sound ? 'bg-blue-600' : 'bg-gray-200'"
:class="
settingsStore.settings.notifications.sound ? 'bg-blue-600' : 'bg-gray-200'
"
>
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="settingsStore.settings.notifications.sound ? 'translate-x-6' : 'translate-x-1'"></span>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="
settingsStore.settings.notifications.sound
? 'translate-x-6'
: 'translate-x-1'
"
></span>
</button>
</div>
<div class="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
<div
class="flex items-center justify-between p-4 border border-gray-200 rounded-lg"
>
<div>
<div class="font-medium text-gray-900">Desktop Notifications</div>
<div class="text-sm text-gray-600">Show browser notifications</div>
@@ -277,10 +379,18 @@
<button
@click="toggleDesktopNotifications"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="settingsStore.settings.notifications.desktop ? 'bg-blue-600' : 'bg-gray-200'"
:class="
settingsStore.settings.notifications.desktop ? 'bg-blue-600' : 'bg-gray-200'
"
>
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="settingsStore.settings.notifications.desktop ? 'translate-x-6' : 'translate-x-1'"></span>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="
settingsStore.settings.notifications.desktop
? 'translate-x-6'
: 'translate-x-1'
"
></span>
</button>
</div>
</div>
@@ -292,12 +402,27 @@
<div class="text-sm text-gray-600">Only show high-priority notifications</div>
</div>
<button
@click="settingsStore.updateSetting('notifications.criticalOnly', !settingsStore.settings.notifications.criticalOnly)"
@click="
settingsStore.updateSetting(
'notifications.criticalOnly',
!settingsStore.settings.notifications.criticalOnly,
)
"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="settingsStore.settings.notifications.criticalOnly ? 'bg-blue-600' : 'bg-gray-200'"
:class="
settingsStore.settings.notifications.criticalOnly
? 'bg-blue-600'
: 'bg-gray-200'
"
>
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="settingsStore.settings.notifications.criticalOnly ? 'translate-x-6' : 'translate-x-1'"></span>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="
settingsStore.settings.notifications.criticalOnly
? 'translate-x-6'
: 'translate-x-1'
"
></span>
</button>
</div>
</div>
@@ -305,7 +430,10 @@
</div>
<!-- Advanced Settings -->
<div v-if="activeSection === 'advanced'" class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div
v-if="activeSection === 'advanced'"
class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden"
>
<div class="p-6 border-b border-gray-100">
<h3 class="text-lg font-semibold text-gray-900">Advanced</h3>
<p class="text-gray-600 text-sm mt-1">Developer options and advanced configuration</p>
@@ -318,12 +446,19 @@
<div class="text-sm text-gray-600">Enable debug logs and development features</div>
</div>
<button
@click="settingsStore.updateSetting('developerMode', !settingsStore.settings.developerMode)"
@click="
settingsStore.updateSetting(
'developerMode',
!settingsStore.settings.developerMode,
)
"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="settingsStore.settings.developerMode ? 'bg-blue-600' : 'bg-gray-200'"
>
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="settingsStore.settings.developerMode ? 'translate-x-6' : 'translate-x-1'"></span>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="settingsStore.settings.developerMode ? 'translate-x-6' : 'translate-x-1'"
></span>
</button>
</div>
@@ -357,7 +492,9 @@
</button>
</div>
<p v-if="importError" class="text-red-600 text-sm mt-1">{{ importError }}</p>
<p v-if="importSuccess" class="text-green-600 text-sm mt-1">Settings imported successfully!</p>
<p v-if="importSuccess" class="text-green-600 text-sm mt-1">
Settings imported successfully!
</p>
</div>
</div>
</div>
@@ -366,20 +503,24 @@
</div>
<!-- Reset Confirmation Dialog -->
<div v-if="showResetDialog" class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div
v-if="showResetDialog"
class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50"
>
<div class="bg-white rounded-xl max-w-md w-full p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-2">Reset All Settings</h3>
<p class="text-gray-600 mb-4">
Are you sure you want to reset all settings to their default values? This action cannot be undone.
Are you sure you want to reset all settings to their default values? This action cannot be
undone.
</p>
<div class="flex gap-3">
<button
<button
@click="showResetDialog = false"
class="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-50 transition-colors"
>
Cancel
</button>
<button
<button
@click="resetAllSettings"
class="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium transition-colors"
>
@@ -412,26 +553,26 @@ const settingSections = [
id: 'appearance',
name: 'Appearance',
description: 'Theme & UI',
icon: '🎨'
icon: '🎨',
},
{
id: 'data',
name: 'Data & Sync',
description: 'Connection & refresh',
icon: '🔄'
icon: '🔄',
},
{
id: 'notifications',
name: 'Notifications',
description: 'Alerts & sounds',
icon: '🔔'
icon: '🔔',
},
{
id: 'advanced',
name: 'Advanced',
description: 'Developer options',
icon: '⚙️'
}
icon: '⚙️',
},
]
// Methods
@@ -439,7 +580,7 @@ const formatTime = (date: Date) => {
return new Intl.DateTimeFormat('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
second: '2-digit',
}).format(date)
}
@@ -448,7 +589,7 @@ const updateWebSocketUrl = () => {
websocketUrlError.value = 'Please enter a valid WebSocket URL (ws:// or wss://)'
return
}
settingsStore.updateSetting('websocketUrl', websocketUrlInput.value)
websocketUrlError.value = ''
}
@@ -470,7 +611,7 @@ const exportSettings = () => {
const settingsJson = settingsStore.exportSettings()
const blob = new Blob([settingsJson], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `dashboard-settings-${new Date().toISOString().split('T')[0]}.json`
@@ -483,9 +624,9 @@ const exportSettings = () => {
const importSettings = () => {
importError.value = ''
importSuccess.value = false
const success = settingsStore.importSettings(importSettingsJson.value)
if (success) {
importSuccess.value = true
importSettingsJson.value = ''
@@ -508,4 +649,4 @@ onMounted(() => {
settingsStore.initialize()
websocketUrlInput.value = settingsStore.settings.websocketUrl
})
</script>
</script>

View File

@@ -13,4 +13,19 @@ export default defineConfig({
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
cors: true,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
secure: false,
},
'/health': {
target: 'http://localhost:8000',
changeOrigin: true,
secure: false,
},
},
},
})