Compare commits

..

10 Commits

Author SHA1 Message Date
rafaeldpsilva
37ccef2f12 update 2025-12-19 23:54:43 +00:00
rafaeldpsilva
a94e1b06b2 update 2025-12-19 23:43:39 +00:00
rafaeldpsilva
9a25170b27 Refactor dashboard layout and update sensor table columns
- Make metric cards and charts more compact and consistent - Change
SensorConsumptionTable columns: show Room and Value, remove
Current/Total/Average - Update headings and layout for AnalyticsView and
HomeView - Improve responsiveness and spacing for cards and sections
2025-10-03 15:06:34 +01:00
rafaeldpsilva
3ecd0ab2c4 Refactor UserIcon to use ref for outside click detection 2025-10-03 11:20:23 +01:00
rafaeldpsilva
544c1a3a4f Add user icon with dropdown menu to BottomNav 2025-10-03 11:16:50 +01:00
rafaeldpsilva
e2cf2bc782 Support partial sensor readings and improve room metrics aggregation
- Allow room and card components to handle rooms with missing energy or
CO2 data - Update RoomMetrics type to make energy and co2 fields
optional - Track which sensors provide energy or CO2 data per room -
Aggregate room metrics only from available data (partial readings) -
Update AirQualityCard and RoomMetricsCard to safely access optional
fields - Set MAX_HISTORY_POINTS to 48 in energy store - Improve
robustness of API room fetching and data mapping - Update CLAUDE.md with
new partial reading support and data flow details
2025-10-03 10:51:48 +01:00
rafaeldpsilva
f96456ed29 Refactor sensor ID usage and types, add CO2 metrics, update docs
- Standardize on `sensor.sensor_id` throughout components and stores -
Add average and max CO2 metrics to sensor store and HomeView - Improve
type safety for sensors, actions, and API calls - Update AGENTS.md with
repository guidelines - Refine settings store types and utility
functions - Add WindowWithAuth interface for auth store access - Minor
bug fixes and code cleanup
2025-10-01 14:04:25 +01:00
rafaeldpsilva
a518665673 Remove console.log statements from sensor store and AnalyticsView 2025-10-01 12:58:28 +01:00
rafaeldpsilva
cb659c93bb Refactor HomeView to use energyStore for energy data Refactor HomeView
to use energyStore for energy data
2025-10-01 12:57:09 +01:00
rafaeldpsilva
6ee4801071 Format and indent AnalyticsView.vue for improved readability 2025-10-01 12:56:56 +01:00
27 changed files with 1238 additions and 465 deletions

19
AGENTS.md Normal file
View File

@@ -0,0 +1,19 @@
# 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 Normal file
View File

@@ -0,0 +1,189 @@
# 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)

View File

@@ -10,7 +10,6 @@
</div> </div>
</div> </div>
</main> </main>
<bottom-nav /> <bottom-nav />
<!-- <app-footer /> --> <!-- <app-footer /> -->
</div> </div>
@@ -22,4 +21,3 @@ import AppHeader from './components/common/AppHeader.vue'
import BottomNav from './components/common/BottomNav.vue' import BottomNav from './components/common/BottomNav.vue'
import { RouterView } from 'vue-router' import { RouterView } from 'vue-router'
</script> </script>

View File

@@ -31,16 +31,16 @@
<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="flex items-center gap-2">
<div <div
class="w-3 h-3 rounded-full" class="w-3 h-3 rounded-full"
:class="getCO2StatusColor(room.co2.status)" :class="getCO2StatusColor(room.co2?.status || 'good')"
></div> ></div>
<span class="text-sm font-medium text-gray-900">{{ room.room }}</span> <span class="text-sm font-medium text-gray-900">{{ room.room }}</span>
</div> </div>
<div class="text-right"> <div class="text-right">
<div class="text-sm text-gray-900">{{ Math.round(room.co2.current) }} ppm</div> <div class="text-sm text-gray-900">{{ Math.round(room.co2?.current || 0) }} ppm</div>
<div class="text-xs" :class="getCO2TextColor(room.co2.status)"> <div class="text-xs" :class="getCO2TextColor(room.co2?.status || 'good')">
{{ room.co2.status.toUpperCase() }} {{ (room.co2?.status || 'good').toUpperCase() }}
</div> </div>
</div> </div>
</div> </div>
@@ -75,14 +75,17 @@ import { useRoomStore } from '@/stores/room'
const roomStore = useRoomStore() const roomStore = useRoomStore()
const roomsList = computed(() => { const roomsList = computed(() => {
return Array.from(roomStore.roomsData.values()).sort((a, b) => return Array.from(roomStore.roomsData.values())
b.co2.current - a.co2.current // 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
)
}) })
const overallCO2 = computed(() => { const overallCO2 = computed(() => {
if (roomsList.value.length === 0) return 0 if (roomsList.value.length === 0) return 0
return roomsList.value.reduce((sum, room) => sum + room.co2.current, 0) / roomsList.value.length const total = roomsList.value.reduce((sum, room) => sum + (room.co2?.current || 0), 0)
return total / roomsList.value.length
}) })
const overallStatus = computed(() => { const overallStatus = computed(() => {
@@ -90,18 +93,18 @@ const overallStatus = computed(() => {
}) })
const roomsWithGoodAir = 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(() => { const roomsNeedingAttention = computed(() => {
return roomsList.value.filter(room => ['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 recommendations = computed(() => {
const recs = [] const recs = []
const criticalRooms = roomsList.value.filter(room => room.co2.status === 'critical') const criticalRooms = roomsList.value.filter(room => room.co2?.status === 'critical')
const poorRooms = roomsList.value.filter(room => room.co2.status === 'poor') const poorRooms = roomsList.value.filter(room => room.co2?.status === 'poor')
if (criticalRooms.length > 0) { if (criticalRooms.length > 0) {
recs.push(`Immediate ventilation needed in ${criticalRooms[0].room}`) recs.push(`Immediate ventilation needed in ${criticalRooms[0].room}`)
} }
@@ -111,7 +114,7 @@ const recommendations = computed(() => {
if (overallCO2.value > 800) { if (overallCO2.value > 800) {
recs.push('Consider adjusting HVAC settings building-wide') recs.push('Consider adjusting HVAC settings building-wide')
} }
return recs.slice(0, 3) // Max 3 recommendations return recs.slice(0, 3) // Max 3 recommendations
}) })

View File

@@ -9,7 +9,7 @@
</div> </div>
<div> <div>
<h3 class="font-medium text-gray-900">{{ sensor.name }}</h3> <h3 class="font-medium text-gray-900">{{ sensor.name }}</h3>
<p class="text-sm text-gray-500">{{ sensor.id }}</p> <p class="text-sm text-gray-500">{{ sensor.sensor_id }}</p>
</div> </div>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -30,9 +30,9 @@
<!-- Room Assignment --> <!-- Room Assignment -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Room Assignment</label> <label class="block text-sm font-medium text-gray-700 mb-2">Room Assignment</label>
<select <select
:value="sensor.room" :value="sensor.room"
@change="$emit('updateRoom', 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" class="w-full px-3 py-2 border border-gray-200 rounded-lg bg-white text-sm"
> >
<option value="">Unassigned</option> <option value="">Unassigned</option>
@@ -100,7 +100,7 @@
<span class="font-medium">Location:</span> <span class="font-medium">Location:</span>
<div>{{ sensor.metadata.location }}</div> <div>{{ sensor.metadata.location }}</div>
</div> </div>
<div> <div v-if="sensor.lastSeen">
<span class="font-medium">Last Seen:</span> <span class="font-medium">Last Seen:</span>
<div>{{ formatTime(sensor.lastSeen) }}</div> <div>{{ formatTime(sensor.lastSeen) }}</div>
</div> </div>
@@ -166,26 +166,27 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useSensorStore } from '@/stores/sensor' import { useSensorStore } from '@/stores/sensor'
import type { SensorDevice, SensorAction } from '@/services'
const props = defineProps<{ const props = defineProps<{
sensor: any sensor: SensorDevice
availableRooms: string[] availableRooms: string[]
isExecutingAction?: boolean isExecutingAction?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
updateRoom: [sensorId: string, newRoom: string] updateRoom: [sensorId: string, newRoom: string]
executeAction: [sensor: any, action: any] executeAction: [sensor: SensorDevice, action: SensorAction]
}>() }>()
const sensorStore = useSensorStore() const sensorStore = useSensorStore()
const getSensorValues = (sensor: any) => { const getSensorValues = (sensor: SensorDevice) => {
const values = [] const values = []
// Get real-time sensor reading from store // Get real-time sensor reading from store
const latestReading = sensorStore.latestReadings.get(sensor.id) || sensorStore.latestReadings.get(sensor.sensor_id) const latestReading = sensorStore.latestReadings.get(sensor.sensor_id)
console.log(`[Detailed] Getting values for 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] Available readings:', Array.from(sensorStore.latestReadings.keys()))
console.log(`[Detailed] Sensor capabilities:`, sensor.capabilities?.monitoring) console.log(`[Detailed] Sensor capabilities:`, sensor.capabilities?.monitoring)
@@ -315,25 +316,24 @@ const sensorValues = computed(() => getSensorValues(props.sensor))
// Check if sensor was recently updated for pulsing animation // Check if sensor was recently updated for pulsing animation
const isRecentlyUpdated = computed(() => { const isRecentlyUpdated = computed(() => {
return sensorStore.recentlyUpdatedSensors.has(props.sensor.id) || return sensorStore.recentlyUpdatedSensors.has(props.sensor.sensor_id)
sensorStore.recentlyUpdatedSensors.has(props.sensor.sensor_id)
}) })
const getDefaultTags = (sensor: any) => { const getDefaultTags = (sensor: SensorDevice): string[] => {
const tags = [sensor.type] const tags: string[] = [sensor.type]
if (sensor.metadata.battery) { if (sensor.metadata?.battery) {
tags.push('wireless') tags.push('wireless')
} else { } else {
tags.push('wired') tags.push('wired')
} }
if (sensor.capabilities.actions.length > 0) { if (sensor.capabilities.actions.length > 0) {
tags.push('controllable') tags.push('controllable')
} else { } else {
tags.push('monitor-only') tags.push('monitor-only')
} }
return tags return tags
} }

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="bg-white rounded-2xl shadow-sm flex flex-col justify-between h-full w-full p-4"> <div class="bg-white rounded-2xl shadow-sm flex flex-col justify-between aspect-square p-4">
<h6 class="text-sm font-bold text-gray-500">{{ title }}</h6> <h6 class="text-sm font-bold text-gray-500">{{ title }}</h6>
<div class="flex-grow flex items-center justify-start"> <div class="flex-grow flex items-center justify-start">
<p class="text-gray-900 font-bold text-2xl"> <p class="text-gray-900 font-bold text-2xl">

View File

@@ -1,8 +1,8 @@
<template> <template>
<div class="bg-white rounded-2xl shadow-sm flex flex-col h-full min-h-[300px]"> <div class="bg-white rounded-2xl shadow-sm flex flex-col p-4">
<div class="p-4 h-full"> <h6 class="text-sm font-bold text-gray-500 mb-2">{{ title }}</h6>
<h6 class="text-sm font-bold text-gray-500 mb-2">{{ title }}</h6> <div class="w-full h-[400px]">
<v-chart class="h-64 w-full" :option="option" autoresize /> <v-chart class="w-full h-full" :option="option" autoresize />
</div> </div>
</div> </div>
</template> </template>

View File

@@ -13,9 +13,9 @@
<h3 class="font-medium text-gray-900">{{ room.room }}</h3> <h3 class="font-medium text-gray-900">{{ room.room }}</h3>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<!-- CO2 Status Indicator --> <!-- CO2 Status Indicator -->
<div <div
class="w-3 h-3 rounded-full" class="w-3 h-3 rounded-full"
:class="getCO2StatusColor(room.co2.status)" :class="getCO2StatusColor(room.co2!.status)"
></div> ></div>
<!-- Occupancy Indicator --> <!-- Occupancy Indicator -->
<div class="flex items-center gap-1 text-xs text-gray-500"> <div class="flex items-center gap-1 text-xs text-gray-500">
@@ -32,15 +32,15 @@
<!-- Energy --> <!-- Energy -->
<div class="bg-blue-50 rounded p-2"> <div class="bg-blue-50 rounded p-2">
<div class="text-blue-600 font-medium">Energy</div> <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 class="text-blue-600 text-xs">Total: {{ room.energy!.total.toFixed(2) }}</div>
</div> </div>
<!-- CO2 --> <!-- CO2 -->
<div class="rounded p-2" :class="getCO2BackgroundColor(room.co2.status)"> <div class="rounded p-2" :class="getCO2BackgroundColor(room.co2!.status)">
<div class="font-medium" :class="getCO2TextColor(room.co2.status)">CO2</div> <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="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="text-xs" :class="getCO2TextColor(room.co2!.status)">{{ room.co2!.status.toUpperCase() }}</div>
</div> </div>
</div> </div>
@@ -77,18 +77,19 @@ import { useRoomStore } from '@/stores/room'
const roomStore = useRoomStore() const roomStore = useRoomStore()
const roomsList = computed(() => { const roomsList = computed(() => {
return Array.from(roomStore.roomsData.values()).sort((a, b) => return Array.from(roomStore.roomsData.values())
a.room.localeCompare(b.room) .filter(room => room.energy && room.co2) // Only show rooms with both metrics
) .sort((a, b) => a.room.localeCompare(b.room))
}) })
const totalEnergy = computed(() => { const totalEnergy = computed(() => {
return roomsList.value.reduce((sum, room) => sum + room.energy.current, 0) return roomsList.value.reduce((sum, room) => sum + (room.energy?.current || 0), 0)
}) })
const averageCO2 = computed(() => { const averageCO2 = computed(() => {
if (roomsList.value.length === 0) return 0 if (roomsList.value.length === 0) return 0
return roomsList.value.reduce((sum, room) => sum + room.co2.current, 0) / roomsList.value.length const total = roomsList.value.reduce((sum, room) => sum + (room.co2?.current || 0), 0)
return total / roomsList.value.length
}) })
const getCO2StatusColor = (status: string) => { const getCO2StatusColor = (status: string) => {

View File

@@ -1,7 +1,6 @@
<template> <template>
<div class="bg-white rounded-2xl shadow-sm p-4"> <div class="bg-white rounded-2xl shadow-sm p-4">
<h6 class="text-sm font-bold text-gray-500 mb-4">Sensor Consumption</h6> <h6 class="text-sm font-bold text-gray-500 mb-4">Sensor Readings</h6>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-full"> <table class="min-w-full">
<thead> <thead>
@@ -10,13 +9,10 @@
Sensor ID Sensor ID
</th> </th>
<th class="text-right text-xs font-medium text-gray-500 uppercase tracking-wider py-3"> <th class="text-right text-xs font-medium text-gray-500 uppercase tracking-wider py-3">
Current Room
</th> </th>
<th class="text-right text-xs font-medium text-gray-500 uppercase tracking-wider py-3"> <th class="text-right text-xs font-medium text-gray-500 uppercase tracking-wider py-3">
Total Value
</th>
<th class="text-right text-xs font-medium text-gray-500 uppercase tracking-wider py-3">
Average
</th> </th>
<th class="text-right text-xs font-medium text-gray-500 uppercase tracking-wider py-3"> <th class="text-right text-xs font-medium text-gray-500 uppercase tracking-wider py-3">
Last Updated Last Updated
@@ -55,9 +51,6 @@
sensor.humidity?.unit sensor.humidity?.unit
}} }}
</td> </td>
<td class="py-3 text-sm text-gray-600 text-right">
{{ sensor.room }}
</td>
<td class="py-3 text-sm text-gray-500 text-right"> <td class="py-3 text-sm text-gray-500 text-right">
{{ formatTime(sensor.timestamp) }} {{ formatTime(sensor.timestamp) }}
</td> </td>
@@ -66,7 +59,6 @@
</table> </table>
</div> </div>
<!-- Connection Status Indicator -->
<div class="mt-4 flex items-center justify-between text-xs text-gray-500"> <div class="mt-4 flex items-center justify-between text-xs text-gray-500">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div <div

View File

@@ -13,6 +13,7 @@
class="absolute bottom-0 left-0 right-0 bg-white md:bg-transparent border-t md:border-t-0 border-gray-200 md:shadow-none shadow-lg" class="absolute bottom-0 left-0 right-0 bg-white md:bg-transparent border-t md:border-t-0 border-gray-200 md:shadow-none shadow-lg"
:class="getNavigationClasses()" :class="getNavigationClasses()"
> >
<UserIcon />
<div class="flex justify-center md:pb-4 pb-2"> <div class="flex justify-center md:pb-4 pb-2">
<ul <ul
class="flex space-x-4 md:space-x-8 md:bg-white md:rounded-lg md:shadow-md px-6 py-3 w-full md:w-auto justify-around md:justify-center" class="flex space-x-4 md:space-x-8 md:bg-white md:rounded-lg md:shadow-md px-6 py-3 w-full md:w-auto justify-around md:justify-center"
@@ -54,23 +55,14 @@
</li> </li>
<li> <li>
<router-link <router-link
to="/ai-optimization" to="/models"
class="flex flex-col items-center font-medium" class="flex flex-col items-center font-medium"
:class=" :class="
$route.name === 'ai-optimization' $route.name === 'models' ? 'text-purple-600' : 'text-gray-600 hover:text-purple-600'
? 'text-purple-600'
: 'text-gray-600 hover:text-purple-600'
" "
> >
<svg class="w-6 h-6 mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <IconEcosystem />
<path <span class="text-xs">Models</span>
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
<span class="text-xs">AI Optimize</span>
</router-link> </router-link>
</li> </li>
<li> <li>
@@ -126,9 +118,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted } from 'vue' import { onMounted } from 'vue'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
import UserIcon from './UserIcon.vue'
import IconEcosystem from '../icons/IconEcosystem.vue'
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
// Compute navigation classes based on settings // Compute navigation classes based on settings

View File

@@ -0,0 +1,326 @@
<template>
<!-- User Icon Container -->
<div
v-if="settingsStore.settings.ui.navigationMode !== 'hidden'"
class="absolute bottom-0 left-0 h-16 group md:h-16"
ref="userIconContainer"
>
<!-- Invisible hover trigger area for desktop (only for hover mode) -->
<div
v-if="settingsStore.settings.ui.navigationMode === 'hover'"
class="absolute inset-0 hidden md:block"
></div>
<div :class="getNavigationClasses()">
<div class="flex md:pb-4 pb-2 pl-4">
<!-- User Avatar Button -->
<button
@click="toggleMenu"
class="relative bg-white rounded-full shadow-md hover:shadow-lg transition-shadow duration-200 w-12 h-12 flex items-center justify-center focus:outline-none focus:ring-2 focus:ring-blue-500"
:class="{ 'ring-2 ring-blue-400': isMenuOpen }"
>
<!-- User Initials or Icon -->
<div v-if="authStore.isAuthenticated" class="flex items-center justify-center">
<span class="text-sm font-semibold text-gray-700">{{ userInitials }}</span>
</div>
<div v-else class="flex items-center justify-center">
<svg class="w-6 h-6 text-gray-400" 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"
/>
</svg>
</div>
<!-- Status Indicator -->
<div
class="absolute bottom-0 right-0 w-3 h-3 rounded-full border-2 border-white"
:class="statusIndicatorColor"
></div>
</button>
<!-- Dropdown Menu -->
<transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<div
v-if="isMenuOpen"
class="absolute bottom-16 left-4 w-64 bg-white rounded-lg shadow-xl border border-gray-200 py-2 z-50"
@click.stop
>
<!-- User Info Section -->
<div class="px-4 py-3 border-b border-gray-100">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center">
<span class="text-lg font-semibold text-blue-600">{{ userInitials }}</span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">{{ userName }}</p>
<p class="text-xs text-gray-500">{{ authStatus }}</p>
</div>
</div>
</div>
<!-- Token Info (if authenticated) -->
<div v-if="authStore.isAuthenticated" class="px-4 py-3 border-b border-gray-100">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500">Token Expires:</span>
<span class="font-medium" :class="expiryTextColor">{{ tokenExpiryText }}</span>
</div>
<div class="mt-1 w-full bg-gray-200 rounded-full h-1.5">
<div
class="h-1.5 rounded-full transition-all duration-300"
:class="expiryBarColor"
:style="{ width: tokenExpiryPercentage + '%' }"
></div>
</div>
</div>
<!-- Menu Items -->
<div class="py-1">
<button
v-if="!authStore.isAuthenticated"
@click="handleLogin"
class="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center space-x-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"
/>
</svg>
<span>Login / Generate Token</span>
</button>
<button
@click="handleProfile"
class="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center space-x-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
<span>Profile</span>
</button>
<button
v-if="authStore.isAuthenticated"
@click="handleTokenInfo"
class="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center space-x-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
/>
</svg>
<span>Token Details</span>
</button>
<button
@click="handleSettings"
class="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center space-x-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<span>Settings</span>
</button>
<div v-if="authStore.isAuthenticated" class="border-t border-gray-100 mt-1 pt-1">
<button
@click="handleLogout"
class="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center space-x-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
<span>Logout</span>
</button>
</div>
</div>
</div>
</transition>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useSettingsStore } from '@/stores/settings'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const settingsStore = useSettingsStore()
const authStore = useAuthStore()
const isMenuOpen = ref(false)
const userIconContainer = ref<HTMLElement | null>(null)
// Compute navigation classes based on settings (same as BottomNav)
const getNavigationClasses = () => {
const mode = settingsStore.settings.ui.navigationMode
if (mode === 'always') {
return 'transform-none md:transform-none'
} else if (mode === 'hover') {
return 'transform-none md:transform md:translate-y-full md:group-hover:translate-y-0 md:transition-transform md:duration-300 md:ease-in-out'
}
return 'transform-none md:transform md:translate-y-full md:group-hover:translate-y-0 md:transition-transform md:duration-300 md:ease-in-out'
}
// User display info
const userName = computed(() => {
if (authStore.isAuthenticated) {
return 'Dashboard User'
}
return 'Guest'
})
const userInitials = computed(() => {
const name = userName.value
const parts = name.split(' ')
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase()
}
return name.substring(0, 2).toUpperCase()
})
const authStatus = computed(() => {
if (authStore.isAuthenticated) {
return 'Authenticated'
}
return 'Not authenticated'
})
const statusIndicatorColor = computed(() => {
if (authStore.isAuthenticated) {
return 'bg-green-500'
}
return 'bg-gray-400'
})
// Token expiry info
const tokenExpiryText = computed(() => {
if (!authStore.timeUntilExpiry) return 'N/A'
const { hours, minutes } = authStore.timeUntilExpiry
if (hours > 0) {
return `${hours}h ${minutes}m`
}
return `${minutes}m`
})
const tokenExpiryPercentage = computed(() => {
if (!authStore.timeUntilExpiry) return 0
const { milliseconds } = authStore.timeUntilExpiry
const totalMs = 24 * 60 * 60 * 1000 // 24 hours in milliseconds
return Math.max(0, Math.min(100, (milliseconds / totalMs) * 100))
})
const expiryTextColor = computed(() => {
const percentage = tokenExpiryPercentage.value
if (percentage > 50) return 'text-green-600'
if (percentage > 25) return 'text-yellow-600'
return 'text-red-600'
})
const expiryBarColor = computed(() => {
const percentage = tokenExpiryPercentage.value
if (percentage > 50) return 'bg-green-500'
if (percentage > 25) return 'bg-yellow-500'
return 'bg-red-500'
})
// Menu handlers
const toggleMenu = () => {
isMenuOpen.value = !isMenuOpen.value
}
const closeMenu = () => {
isMenuOpen.value = false
}
const handleLogin = async () => {
const success = await authStore.generateToken()
if (success) {
console.log('Token generated successfully')
}
closeMenu()
}
const handleProfile = () => {
console.log('Navigate to profile')
closeMenu()
}
const handleTokenInfo = () => {
console.log('Show token details:', {
token: authStore.token,
expiry: authStore.tokenExpiry,
resources: authStore.tokenResources,
})
closeMenu()
}
const handleSettings = () => {
router.push('/settings')
closeMenu()
}
const handleLogout = () => {
authStore.clearToken()
console.log('User logged out')
closeMenu()
}
// Close menu when clicking outside
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement
// Check if click is outside the entire user icon container
if (isMenuOpen.value && userIconContainer.value && !userIconContainer.value.contains(target)) {
closeMenu()
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
// Initialize settings store if needed
if (!settingsStore.lastSaved) {
settingsStore.initialize()
}
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>

View File

@@ -1,5 +1,5 @@
<template> <template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor"> <svg class="w-6 h-6 mb-1" fill="currentColor" viewBox="0 0 24 24">
<path <path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z" d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/> />

View File

@@ -4,9 +4,7 @@
<div class="absolute inset-0 bg-black/50" @click="$emit('close')"></div> <div class="absolute inset-0 bg-black/50" @click="$emit('close')"></div>
<!-- Modal --> <!-- Modal -->
<div <div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto">
class="relative bg-white rounded-2xl shadow-xl max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto"
>
<!-- Header --> <!-- Header -->
<div class="p-6 border-b border-gray-100"> <div class="p-6 border-b border-gray-100">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@@ -39,15 +37,15 @@
<input <input
v-model.number="numericValue" v-model.number="numericValue"
type="range" type="range"
:min="action.parameters.min" :min="action.parameters?.min"
:max="action.parameters.max" :max="action.parameters?.max"
:step="action.parameters.step" :step="action.parameters?.step"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer slider" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer slider"
/> />
<div class="flex justify-between text-sm text-gray-600"> <div class="flex justify-between text-sm text-gray-600">
<span>{{ action.parameters.min }}</span> <span>{{ action.parameters?.min }}</span>
<span class="font-medium">{{ numericValue }}{{ getUnit() }}</span> <span class="font-medium">{{ numericValue }}{{ getUnit() }}</span>
<span>{{ action.parameters.max }}</span> <span>{{ action.parameters?.max }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -131,14 +129,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import type { SensorDevice, SensorAction } from '@/services'
interface ActionParameters {
value?: number | string | boolean
[key: string]: unknown
}
const props = defineProps<{ const props = defineProps<{
sensor: any sensor: SensorDevice
action: any action: SensorAction
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
execute: [sensorId: string, actionId: string, parameters: any] execute: [sensorId: string, actionId: string, parameters: ActionParameters]
close: [] close: []
}>() }>()
@@ -153,9 +157,9 @@ watch(
(action) => { (action) => {
if (action) { if (action) {
if (action.parameters?.min !== undefined) { if (action.parameters?.min !== undefined) {
numericValue.value = action.parameters.min numericValue.value = action.parameters?.min ?? 0
} }
if (action.parameters?.options?.length > 0) { if (action.parameters?.options && action.parameters.options.length > 0) {
selectedOption.value = action.parameters.options[0] selectedOption.value = action.parameters.options[0]
} }
toggleValue.value = false toggleValue.value = false
@@ -182,7 +186,7 @@ const getUnit = () => {
const executeAction = async () => { const executeAction = async () => {
isExecuting.value = true isExecuting.value = true
const parameters: any = {} const parameters: ActionParameters = {}
if (props.action.type === 'adjust') { if (props.action.type === 'adjust') {
if (hasNumericRange.value) { if (hasNumericRange.value) {
@@ -195,7 +199,7 @@ const executeAction = async () => {
} }
try { try {
emit('execute', props.sensor.id, props.action.id, parameters) emit('execute', props.sensor.sensor_id, props.action.id, parameters)
} catch (error) { } catch (error) {
console.error('Failed to execute action:', error) console.error('Failed to execute action:', error)
} finally { } finally {

View File

@@ -3,12 +3,15 @@
* Provides reactive API state management * Provides reactive API state management
*/ */
import { ref, reactive } from 'vue' import { ref, reactive } from 'vue'
import { import {
sensorsApi, sensorsApi,
roomsApi, roomsApi,
analyticsApi, analyticsApi,
healthApi, healthApi,
type SensorInfo, type SensorDevice,
type SensorType,
type SensorStatus,
type SensorMetadata,
type RoomInfo, type RoomInfo,
type RoomData, type RoomData,
type AnalyticsSummary, type AnalyticsSummary,
@@ -69,23 +72,23 @@ export function useSensorsApi() {
error: null error: null
}) })
const sensors = ref<SensorInfo[]>([]) const sensors = ref<SensorDevice[]>([])
const currentSensor = ref<SensorInfo | null>(null) const currentSensor = ref<SensorDevice | null>(null)
const sensorData = ref<DataResponse | null>(null) const sensorData = ref<DataResponse | null>(null)
const { handleApiCall } = useApi() const { handleApiCall } = useApi()
const fetchSensors = async (params?: { const fetchSensors = async (params?: {
room?: string room?: string
sensor_type?: any sensor_type?: SensorType
status?: any status?: SensorStatus
}) => { }) => {
const result = await handleApiCall( const result = await handleApiCall(
() => sensorsApi.getSensors(params), () => sensorsApi.getSensors(params),
state state
) )
if (result) { if (result && result.sensors) {
sensors.value = result sensors.value = result.sensors
} }
return result return result
} }
@@ -129,7 +132,7 @@ export function useSensorsApi() {
const updateSensorMetadata = async ( const updateSensorMetadata = async (
sensorId: string, sensorId: string,
metadata: Record<string, any> metadata: SensorMetadata
) => { ) => {
return handleApiCall( return handleApiCall(
() => sensorsApi.updateSensorMetadata(sensorId, metadata), () => sensorsApi.updateSensorMetadata(sensorId, metadata),

View File

@@ -1,7 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue' import HomeView from '../views/HomeView.vue'
import SensorManagementView from '../views/SensorManagementView.vue' import SensorManagementView from '../views/SensorManagementView.vue'
import AIOptimizationView from '../views/AIOptimizationView.vue' import ModelsView from '../views/ModelsView.vue'
import SettingsView from '../views/SettingsView.vue' import SettingsView from '../views/SettingsView.vue'
import AnalyticsView from '../views/AnalyticsView.vue' import AnalyticsView from '../views/AnalyticsView.vue'
@@ -19,9 +19,9 @@ const router = createRouter({
component: SensorManagementView, component: SensorManagementView,
}, },
{ {
path: '/ai-optimization', path: '/models',
name: 'ai-optimization', name: 'models',
component: AIOptimizationView, component: ModelsView,
}, },
{ {
path: '/settings', path: '/settings',

View File

@@ -1,11 +1,19 @@
// Base configuration // Base configuration
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000' const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'
// Extend Window interface for auth store
interface WindowWithAuth extends Window {
__AUTH_STORE__?: {
getAuthHeader: () => Record<string, string>
ensureAuthenticated: () => Promise<boolean>
}
}
// API Response types // API Response types
export interface ApiResponse<T = any> { export interface ApiResponse<T = unknown> {
data: T data: T
total_count?: number total_count?: number
query?: any query?: Record<string, unknown>
execution_time_ms?: number execution_time_ms?: number
} }
@@ -71,7 +79,7 @@ export interface SensorReading {
value: number value: number
unit: string unit: string
} }
metadata?: Record<string, any> metadata?: Record<string, unknown>
} }
export interface RoomInfo { export interface RoomInfo {
@@ -169,7 +177,7 @@ export interface SystemEvent {
event_type: string event_type: string
severity: 'info' | 'warning' | 'error' | 'critical' severity: 'info' | 'warning' | 'error' | 'critical'
message: string message: string
details?: Record<string, any> details?: Record<string, unknown>
sensor_id?: string sensor_id?: string
room?: string room?: string
} }
@@ -187,6 +195,9 @@ export interface SensorDevice {
actions: SensorAction[] actions: SensorAction[]
} }
metadata: SensorMetadata metadata: SensorMetadata
tags?: string[]
lastSeen?: number
total_readings?: number
} }
export interface SensorAction { export interface SensorAction {
@@ -231,6 +242,7 @@ export enum SensorStatus {
ONLINE = 'online', ONLINE = 'online',
OFFLINE = 'offline', OFFLINE = 'offline',
ERROR = 'error', ERROR = 'error',
ACTIVE = 'active',
} }
export interface SensorMetadata { export interface SensorMetadata {
@@ -242,6 +254,7 @@ export interface SensorMetadata {
model?: string model?: string
firmware?: string firmware?: string
battery?: number battery?: number
signalStrength?: number
created_at?: string created_at?: string
updated_at?: string updated_at?: string
manufacturer?: string manufacturer?: string
@@ -277,7 +290,7 @@ class ApiClient {
// Dynamically get auth headers to avoid circular imports // Dynamically get auth headers to avoid circular imports
try { try {
// Try to get from window first (for when store is exposed) // Try to get from window first (for when store is exposed)
const authStore = (window as any).__AUTH_STORE__ const authStore = (window as WindowWithAuth).__AUTH_STORE__
if (authStore && typeof authStore.getAuthHeader === 'function') { if (authStore && typeof authStore.getAuthHeader === 'function') {
return authStore.getAuthHeader() return authStore.getAuthHeader()
} }
@@ -333,7 +346,7 @@ class ApiClient {
} }
} }
async get<T>(endpoint: string, params?: Record<string, any>): Promise<T> { async get<T>(endpoint: string, params?: Record<string, string | number | boolean | string[]>): Promise<T> {
const url = new URL(`${this.baseUrl}${endpoint}`) const url = new URL(`${this.baseUrl}${endpoint}`)
if (params) { if (params) {
@@ -366,14 +379,14 @@ class ApiClient {
return await response.json() return await response.json()
} }
async post<T>(endpoint: string, data?: any): Promise<T> { async post<T>(endpoint: string, data?: unknown): Promise<T> {
return this.request<T>(endpoint, { return this.request<T>(endpoint, {
method: 'POST', method: 'POST',
body: data ? JSON.stringify(data) : undefined, body: data ? JSON.stringify(data) : undefined,
}) })
} }
async put<T>(endpoint: string, data?: any): Promise<T> { async put<T>(endpoint: string, data?: unknown): Promise<T> {
return this.request<T>(endpoint, { return this.request<T>(endpoint, {
method: 'PUT', method: 'PUT',
body: data ? JSON.stringify(data) : undefined, body: data ? JSON.stringify(data) : undefined,

View File

@@ -77,12 +77,22 @@ export const sensorsApi = {
}): Promise<{ }): Promise<{
data: SensorReading[] data: SensorReading[]
count: number count: number
export_params: any export_params: {
start_time: number
end_time: number
sensor_ids?: string
format?: 'json' | 'csv'
}
}> { }> {
return apiClient.get<{ return apiClient.get<{
data: SensorReading[] data: SensorReading[]
count: number count: number
export_params: any export_params: {
start_time: number
end_time: number
sensor_ids?: string
format?: 'json' | 'csv'
}
}>('/api/v1/export', params) }>('/api/v1/export', params)
}, },
} }

View File

@@ -10,6 +10,13 @@ import {
type HealthCheck, type HealthCheck,
} from '@/services' } from '@/services'
// Extend Window interface for auth store
interface WindowWithAuth extends Window {
__AUTH_STORE__?: {
ensureAuthenticated: () => Promise<boolean>
}
}
export const useAnalyticsStore = defineStore('analytics', () => { export const useAnalyticsStore = defineStore('analytics', () => {
// State // State
const analyticsData = ref<{ const analyticsData = ref<{
@@ -41,7 +48,7 @@ export const useAnalyticsStore = defineStore('analytics', () => {
console.warn('Authentication error detected, attempting to re-authenticate...') console.warn('Authentication error detected, attempting to re-authenticate...')
try { try {
const authStore = (window as any).__AUTH_STORE__ const authStore = (window as WindowWithAuth).__AUTH_STORE__
if (authStore && typeof authStore.ensureAuthenticated === 'function') { if (authStore && typeof authStore.ensureAuthenticated === 'function') {
const authSuccess = await authStore.ensureAuthenticated() const authSuccess = await authStore.ensureAuthenticated()
if (authSuccess) { if (authSuccess) {

View File

@@ -1,10 +1,12 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed, reactive, watch } from 'vue'
import { useWebSocketStore } from './websocket' import { useWebSocketStore } from './websocket'
import { useSensorStore } from './sensor' import { useSensorStore } from './sensor'
import { useRoomStore } from './room' import { useRoomStore } from './room'
import { useAnalyticsStore } from './analytics' import { useAnalyticsStore } from './analytics'
const MAX_HISTORY_POINTS = 48
/** /**
* Energy Store - Simplified to only track energy consumption metrics * Energy Store - Simplified to only track energy consumption metrics
* For sensor data: use useSensorStore() * For sensor data: use useSensorStore()
@@ -20,34 +22,66 @@ export const useEnergyStore = defineStore('energy', () => {
const analyticsStore = useAnalyticsStore() const analyticsStore = useAnalyticsStore()
// Energy-specific state // Energy-specific state
const currentConsumption = ref(0) // Current energy consumption in kWh const currentConsumption = ref<number>(0) // Current energy consumption in kWh
const averageConsumption = ref(0) // Average energy consumption in kWh const averageConsumption = ref<number>(0) // Average energy consumption in kWh
// Computed: Current energy value from WebSocket // Track aggregated energy over time
const currentEnergyValue = computed(() => { const energyHistory = reactive<number[]>([])
return websocketStore.latestMessage?.energy?.value || 0 const energyTimestamps = reactive<string[]>([])
// Computed: Current total energy value - sum of all sensor readings
const currentEnergyValue = computed<number>(() => {
// Sum energy values from all sensors' latest readings
let totalEnergy: number = 0
const readings = Array.from(sensorStore.latestReadings.values())
readings.forEach((reading) => {
if (reading.energy?.value) {
totalEnergy += reading.energy.value
}
})
return totalEnergy
}) })
// Computed: Average energy usage from time series // Computed: Average energy usage from history
const averageEnergyUsage = computed(() => { const averageEnergyUsage = computed<number>(() => {
const data = websocketStore.timeSeriesData.datasets[0].data if (energyHistory.length === 0) return 0
if (data.length === 0) return 0 const sum: number = energyHistory.reduce((acc: number, val: number) => acc + val, 0)
const sum = data.reduce((acc, val) => acc + val, 0) return sum / energyHistory.length
return sum / data.length
}) })
// Watch for changes in sensor readings and update history
watch(
() => sensorStore.latestReadings.size,
() => {
const currentTotal: number = currentEnergyValue.value
const timestamp: string = new Date().toLocaleTimeString()
// Add to history
energyHistory.push(currentTotal)
energyTimestamps.push(timestamp)
// Keep only the latest MAX_HISTORY_POINTS
if (energyHistory.length > MAX_HISTORY_POINTS) {
energyHistory.shift()
energyTimestamps.shift()
}
},
)
// Update current consumption (called from components or watchers) // Update current consumption (called from components or watchers)
function updateCurrentConsumption(value: number) { function updateCurrentConsumption(value: number): void {
currentConsumption.value = value currentConsumption.value = value
} }
// Update average consumption (called from components or watchers) // Update average consumption (called from components or watchers)
function updateAverageConsumption(value: number) { function updateAverageConsumption(value: number): void {
averageConsumption.value = value averageConsumption.value = value
} }
// Initialize data from APIs (convenience function for AnalyticsView) // Initialize data from APIs (convenience function for AnalyticsView)
async function initializeFromApi() { async function initializeFromApi(): Promise<void> {
await Promise.allSettled([ await Promise.allSettled([
roomStore.loadRoomsFromAPI(), roomStore.loadRoomsFromAPI(),
sensorStore.fetchApiSensors(), sensorStore.fetchApiSensors(),
@@ -62,6 +96,8 @@ export const useEnergyStore = defineStore('energy', () => {
averageConsumption, averageConsumption,
currentEnergyValue, currentEnergyValue,
averageEnergyUsage, averageEnergyUsage,
energyHistory,
energyTimestamps,
// Energy-specific actions // Energy-specific actions
updateCurrentConsumption, updateCurrentConsumption,

View File

@@ -3,16 +3,25 @@ import { ref, reactive } from 'vue'
import { roomsApi, type RoomInfo as ApiRoomInfo, type SensorReading } from '@/services' import { roomsApi, type RoomInfo as ApiRoomInfo, type SensorReading } from '@/services'
import { useSensorStore } from './sensor' import { useSensorStore } from './sensor'
// Extend Window interface for auth store
interface WindowWithAuth extends Window {
__AUTH_STORE__?: {
ensureAuthenticated: () => Promise<boolean>
}
}
interface RoomMetrics { interface RoomMetrics {
room: string room: string
sensors: string[] sensors: string[]
energy: { energySensors: string[] // Track which sensors provide energy data
co2Sensors: string[] // Track which sensors provide CO2 data
energy?: {
current: number current: number
total: number total: number
average: number average: number
unit: string unit: string
} }
co2: { co2?: {
current: number current: number
average: number average: number
max: number max: number
@@ -30,16 +39,16 @@ export const useRoomStore = defineStore('room', () => {
const apiRooms = ref<ApiRoomInfo[]>([]) const apiRooms = ref<ApiRoomInfo[]>([])
const roomsLoading = ref<boolean>(false) const roomsLoading = ref<boolean>(false)
const roomsLoaded = ref<boolean>(false) const roomsLoaded = ref<boolean>(false)
const apiLoading = ref(false) const apiLoading = ref<boolean>(false)
const apiError = ref<string | null>(null) const apiError = ref<string | null>(null)
// Actions // Actions
function updateRoomData(data: SensorReading) { function updateRoomData(data: SensorReading): void {
const sensorStore = useSensorStore() const sensorStore = useSensorStore()
// Validate data structure and provide fallbacks // Accept partial readings - validate that we have at least room and sensor_id
if (!data.energy || !data.co2) { if (!data.room || !data.sensor_id) {
console.warn('Invalid sensor reading data, missing energy or co2 properties:', data) console.warn('Invalid sensor reading data, missing room or sensor_id:', data)
return return
} }
@@ -50,11 +59,12 @@ export const useRoomStore = defineStore('room', () => {
let roomMetrics = roomsData.get(data.room) let roomMetrics = roomsData.get(data.room)
if (!roomMetrics) { if (!roomMetrics) {
// Initialize with minimal required fields - energy and co2 are optional
roomMetrics = { roomMetrics = {
room: data.room, room: data.room,
sensors: [data.sensor_id], sensors: [],
energy: { current: 0, total: 0, average: 0, unit: data.energy?.unit || 'kWh' }, energySensors: [],
co2: { current: 0, average: 0, max: 0, status: 'good', unit: data.co2?.unit || 'ppm' }, co2Sensors: [],
occupancyEstimate: 'low', occupancyEstimate: 'low',
lastUpdated: data.timestamp, lastUpdated: data.timestamp,
} }
@@ -66,32 +76,78 @@ export const useRoomStore = defineStore('room', () => {
roomMetrics.sensors.push(data.sensor_id) roomMetrics.sensors.push(data.sensor_id)
} }
// Track which sensors provide which metrics
if (data.energy?.value !== undefined && !roomMetrics.energySensors.includes(data.sensor_id)) {
roomMetrics.energySensors.push(data.sensor_id)
}
if (data.co2?.value !== undefined && !roomMetrics.co2Sensors.includes(data.sensor_id)) {
roomMetrics.co2Sensors.push(data.sensor_id)
}
// Recalculate room metrics from all sensors in the room // Recalculate room metrics from all sensors in the room
const roomSensors = Array.from(sensorStore.latestReadings.values()).filter( const roomSensors = Array.from(sensorStore.latestReadings.values()).filter(
(reading) => reading.room === data.room, (reading) => reading.room === data.room,
) )
// Energy calculations // Energy calculations - only if energy data is present in ANY sensor
roomMetrics.energy.current = roomSensors.reduce((sum, sensor) => sum + sensor.energy.value, 0) const energySensors = roomSensors.filter((sensor) => sensor.energy?.value !== undefined)
roomMetrics.energy.total += data.energy.value // Accumulate total if (energySensors.length > 0) {
roomMetrics.energy.average = roomMetrics.energy.total / roomSensors.length // Initialize energy object if it doesn't exist
if (!roomMetrics.energy) {
roomMetrics.energy = {
current: 0,
total: 0,
average: 0,
unit: data.energy?.unit || 'kWh',
}
}
// CO2 calculations roomMetrics.energy.current = energySensors.reduce(
const co2Values = roomSensors.map((sensor) => sensor.co2.value) (sum, sensor) => sum + (sensor.energy?.value || 0),
roomMetrics.co2.current = co2Values.reduce((sum, val) => sum + val, 0) / co2Values.length 0,
roomMetrics.co2.max = Math.max(roomMetrics.co2.max, ...co2Values) )
roomMetrics.co2.average = (roomMetrics.co2.average + roomMetrics.co2.current) / 2 if (data.energy?.value !== undefined) {
roomMetrics.energy.total += data.energy.value // Accumulate total only for this reading
}
roomMetrics.energy.average = roomMetrics.energy.total / energySensors.length
if (data.energy?.unit) {
roomMetrics.energy.unit = data.energy.unit
}
}
// CO2 status classification // CO2 calculations - only if co2 data is present in ANY sensor
if (roomMetrics.co2.current < 400) roomMetrics.co2.status = 'good' const co2Sensors = roomSensors.filter((sensor) => sensor.co2?.value !== undefined)
else if (roomMetrics.co2.current < 1000) roomMetrics.co2.status = 'moderate' if (co2Sensors.length > 0) {
else if (roomMetrics.co2.current < 5000) roomMetrics.co2.status = 'poor' // Initialize co2 object if it doesn't exist
else roomMetrics.co2.status = 'critical' if (!roomMetrics.co2) {
roomMetrics.co2 = {
current: 0,
average: 0,
max: 0,
status: 'good',
unit: data.co2?.unit || 'ppm',
}
}
// Occupancy estimate based on CO2 levels const co2Values = co2Sensors.map((sensor) => sensor.co2?.value || 0)
if (roomMetrics.co2.current < 600) roomMetrics.occupancyEstimate = 'low' roomMetrics.co2.current = co2Values.reduce((sum, val) => sum + val, 0) / co2Values.length
else if (roomMetrics.co2.current < 1200) roomMetrics.occupancyEstimate = 'medium' roomMetrics.co2.max = Math.max(roomMetrics.co2.max, ...co2Values)
else roomMetrics.occupancyEstimate = 'high' roomMetrics.co2.average = (roomMetrics.co2.average + roomMetrics.co2.current) / 2
if (data.co2?.unit) {
roomMetrics.co2.unit = data.co2.unit
}
// CO2 status classification
if (roomMetrics.co2.current < 400) roomMetrics.co2.status = 'good'
else if (roomMetrics.co2.current < 1000) roomMetrics.co2.status = 'moderate'
else if (roomMetrics.co2.current < 5000) roomMetrics.co2.status = 'poor'
else roomMetrics.co2.status = 'critical'
// Occupancy estimate based on CO2 levels
if (roomMetrics.co2.current < 600) roomMetrics.occupancyEstimate = 'low'
else if (roomMetrics.co2.current < 1200) roomMetrics.occupancyEstimate = 'medium'
else roomMetrics.occupancyEstimate = 'high'
}
roomMetrics.lastUpdated = data.timestamp roomMetrics.lastUpdated = data.timestamp
} }
@@ -169,7 +225,14 @@ export const useRoomStore = defineStore('room', () => {
return true return true
} }
function getRoomStats(roomName: string) { function getRoomStats(roomName: string): {
sensorCount: number
sensorTypes: string[]
hasMetrics: boolean
energyConsumption: number
co2Level: number
lastUpdated: number | null
} {
const sensorStore = useSensorStore() const sensorStore = useSensorStore()
const sensorsInRoom = sensorStore.getSensorsByRoom(roomName) const sensorsInRoom = sensorStore.getSensorsByRoom(roomName)
const roomMetrics = roomsData.get(roomName) const roomMetrics = roomsData.get(roomName)
@@ -178,13 +241,21 @@ export const useRoomStore = defineStore('room', () => {
sensorCount: sensorsInRoom.length, sensorCount: sensorsInRoom.length,
sensorTypes: [...new Set(sensorsInRoom.map((s) => s.type))], sensorTypes: [...new Set(sensorsInRoom.map((s) => s.type))],
hasMetrics: !!roomMetrics, hasMetrics: !!roomMetrics,
energyConsumption: roomMetrics?.energy.current || 0, energyConsumption: roomMetrics?.energy?.current || 0,
co2Level: roomMetrics?.co2.current || 0, co2Level: roomMetrics?.co2?.current || 0,
lastUpdated: roomMetrics?.lastUpdated || null, lastUpdated: roomMetrics?.lastUpdated || null,
} }
} }
function getAllRoomsWithStats() { function getAllRoomsWithStats(): Array<{
name: string
sensorCount: number
sensorTypes: string[]
hasMetrics: boolean
energyConsumption: number
co2Level: number
lastUpdated: number | null
}> {
return availableRooms.value.map((room) => ({ return availableRooms.value.map((room) => ({
name: room, name: room,
...getRoomStats(room), ...getRoomStats(room),
@@ -206,7 +277,7 @@ export const useRoomStore = defineStore('room', () => {
console.warn('Authentication error detected, attempting to re-authenticate...') console.warn('Authentication error detected, attempting to re-authenticate...')
try { try {
const authStore = (window as any).__AUTH_STORE__ const authStore = (window as WindowWithAuth).__AUTH_STORE__
if (authStore && typeof authStore.ensureAuthenticated === 'function') { if (authStore && typeof authStore.ensureAuthenticated === 'function') {
const authSuccess = await authStore.ensureAuthenticated() const authSuccess = await authStore.ensureAuthenticated()
if (authSuccess) { if (authSuccess) {
@@ -241,11 +312,11 @@ export const useRoomStore = defineStore('room', () => {
const result = await handleApiCall(() => roomsApi.getRooms()) const result = await handleApiCall(() => roomsApi.getRooms())
if (result) { if (result) {
// Handle both response formats: {rooms: [...]} or direct array [...] // Handle both response formats: {rooms: [...]} or direct array [...]
const roomsArray = Array.isArray(result) ? result : result.rooms || [] const roomsArray = Array.isArray(result) ? result : (result as any).rooms || []
apiRooms.value = roomsArray apiRooms.value = roomsArray
// Update available rooms from API data // Update available rooms from API data
const roomNames = roomsArray.map((room) => room.name || room.room).filter((name) => name) const roomNames = roomsArray.map((room: any) => room.name || room.room).filter((name: string) => name)
if (roomNames.length > 0) { if (roomNames.length > 0) {
availableRooms.value = [...new Set([...availableRooms.value, ...roomNames])].sort() availableRooms.value = [...new Set([...availableRooms.value, ...roomNames])].sort()
} }

View File

@@ -6,16 +6,24 @@ import {
type SensorDevice, type SensorDevice,
type SensorStatus, type SensorStatus,
type SensorReading, type SensorReading,
type SensorMetadata,
} from '@/services' } from '@/services'
// Extend Window interface for auth store
interface WindowWithAuth extends Window {
__AUTH_STORE__?: {
ensureAuthenticated: () => Promise<boolean>
}
}
export const useSensorStore = defineStore('sensor', () => { export const useSensorStore = defineStore('sensor', () => {
// State // State
const sensorDevices = reactive<Map<string, SensorDevice>>(new Map()) const sensorDevices = reactive<Map<string, SensorDevice>>(new Map())
const latestReadings = reactive<Map<string, SensorReading>>(new Map()) const latestReadings = reactive<Map<string, SensorReading>>(new Map())
const sensorsData = reactive<Map<string, any>>(new Map()) // Legacy support const sensorsData = reactive<Map<string, SensorReading>>(new Map()) // Legacy support - deprecated, use latestReadings instead
const recentlyUpdatedSensors = reactive<Set<string>>(new Set()) // Track recently updated sensors const recentlyUpdatedSensors = reactive<Set<string>>(new Set()) // Track recently updated sensors
const totalReadings = ref(0) // Total number of readings across all sensors const totalReadings = ref<number>(0) // Total number of readings across all sensors
const apiLoading = ref(false) const apiLoading = ref<boolean>(false)
const apiError = ref<string | null>(null) const apiError = ref<string | null>(null)
// Computed properties // Computed properties
@@ -23,12 +31,32 @@ export const useSensorStore = defineStore('sensor', () => {
const activeSensors = computed(() => { const activeSensors = computed(() => {
return Array.from(sensorDevices.values()).filter( return Array.from(sensorDevices.values()).filter(
sensor => sensor.status === 'active' || sensor.status === 'online' (sensor) => sensor.status === 'active' || sensor.status === 'online',
).length ).length
}) })
// Aggregated CO2 metrics
const averageCO2Level = computed<number>(() => {
const readings = Array.from(latestReadings.values())
const co2Readings = readings.filter(r => r.co2?.value !== undefined)
if (co2Readings.length === 0) return 0
const totalCO2 = co2Readings.reduce((sum, r) => sum + (r.co2?.value || 0), 0)
return totalCO2 / co2Readings.length
})
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)
return co2Values.length > 0 ? Math.max(...co2Values) : 0
})
// Actions // Actions
function updateSensorRoom(sensorId: string, newRoom: string) { function updateSensorRoom(sensorId: string, newRoom: string): void {
const sensor = sensorDevices.get(sensorId) const sensor = sensorDevices.get(sensorId)
if (sensor) { if (sensor) {
sensor.room = newRoom sensor.room = newRoom
@@ -36,14 +64,14 @@ export const useSensorStore = defineStore('sensor', () => {
} }
} }
async function executeSensorAction(sensorId: string, actionId: string) { async function executeSensorAction(sensorId: string, actionId: string): Promise<boolean> {
const sensor = sensorDevices.get(sensorId) const sensor = sensorDevices.get(sensorId)
if (!sensor) return false if (!sensor) return false
const action = sensor.capabilities.actions.find((a) => a.id === actionId) const action = sensor.capabilities.actions.find((a) => a.id === actionId)
if (!action) return false if (!action) return false
return new Promise((resolve) => { return new Promise<boolean>((resolve) => {
setTimeout(() => { setTimeout(() => {
console.log(`Action ${action.name} executed successfully on ${sensor.name}`) console.log(`Action ${action.name} executed successfully on ${sensor.name}`)
resolve(true) resolve(true)
@@ -59,11 +87,11 @@ export const useSensorStore = defineStore('sensor', () => {
return Array.from(sensorDevices.values()).filter((sensor) => sensor.type === type) return Array.from(sensorDevices.values()).filter((sensor) => sensor.type === type)
} }
function updateEnergySensors(data: Sensor) { function updateEnergySensors(data: SensorReading): void {
console.log(data) console.log(data)
} }
function updateLatestReading(reading: SensorReading) { function updateLatestReading(reading: SensorReading): void {
latestReadings.set(reading.sensor_id, reading) latestReadings.set(reading.sensor_id, reading)
// Increment total readings count // Increment total readings count
@@ -93,7 +121,7 @@ export const useSensorStore = defineStore('sensor', () => {
console.warn('Authentication error detected, attempting to re-authenticate...') console.warn('Authentication error detected, attempting to re-authenticate...')
try { try {
const authStore = (window as any).__AUTH_STORE__ const authStore = (window as WindowWithAuth).__AUTH_STORE__
if (authStore && typeof authStore.ensureAuthenticated === 'function') { if (authStore && typeof authStore.ensureAuthenticated === 'function') {
const authSuccess = await authStore.ensureAuthenticated() const authSuccess = await authStore.ensureAuthenticated()
if (authSuccess) { if (authSuccess) {
@@ -188,15 +216,14 @@ export const useSensorStore = defineStore('sensor', () => {
}) { }) {
const result = await handleApiCall(() => sensorsApi.getSensors(params)) const result = await handleApiCall(() => sensorsApi.getSensors(params))
if (result) { if (result) {
console.log(result)
// Check if result has a sensors property (common API pattern) // Check if result has a sensors property (common API pattern)
if (result.sensors && Array.isArray(result.sensors)) { if (result.sensors && Array.isArray(result.sensors)) {
let totalReadingsCount = 0 let totalReadingsCount: number = 0
result.sensors.forEach((sensor) => { result.sensors.forEach((sensor) => {
const sensorKey = sensor._id || sensor.sensor_id const sensorKey: string = sensor.sensor_id
const sensorType = sensor.sensor_type || sensor.type const sensorType: string = sensor.sensor_type || sensor.type
const sensorName = sensor.name || '' const sensorName: string = sensor.name || ''
// Accumulate total readings // Accumulate total readings
if (sensor.total_readings) { if (sensor.total_readings) {
@@ -205,14 +232,12 @@ export const useSensorStore = defineStore('sensor', () => {
const normalizedSensor = { const normalizedSensor = {
...sensor, ...sensor,
id: sensorKey,
type: sensorType, type: sensorType,
capabilities: { capabilities: {
actions: [], // Default empty actions array
monitoring: monitoring:
sensor.capabilities?.monitoring || sensor.capabilities?.monitoring ||
getDefaultMonitoringCapabilities(sensorType, sensorName), getDefaultMonitoringCapabilities(sensorType, sensorName),
...sensor.capabilities, actions: sensor.capabilities?.actions || [],
}, },
metadata: { metadata: {
model: sensor.metadata?.model || 'Unknown', model: sensor.metadata?.model || 'Unknown',
@@ -222,10 +247,10 @@ export const useSensorStore = defineStore('sensor', () => {
signalStrength: sensor.metadata?.signalStrength, signalStrength: sensor.metadata?.signalStrength,
...sensor.metadata, ...sensor.metadata,
}, },
lastSeen: sensor.last_seen || Date.now() / 1000, lastSeen: sensor.lastSeen || Date.now() / 1000,
} }
sensorDevices.set(sensorKey, normalizedSensor) sensorDevices.set(sensorKey, normalizedSensor as SensorDevice)
}) })
// Update total readings // Update total readings
@@ -247,7 +272,7 @@ export const useSensorStore = defineStore('sensor', () => {
return handleApiCall(() => sensorsApi.getSensorData(sensorId, params)) return handleApiCall(() => sensorsApi.getSensorData(sensorId, params))
} }
async function updateApiSensorMetadata(sensorId: string, metadata: Record<string, any>) { async function updateApiSensorMetadata(sensorId: string, metadata: SensorMetadata) {
return handleApiCall(() => sensorsApi.updateSensorMetadata(sensorId, metadata)) return handleApiCall(() => sensorsApi.updateSensorMetadata(sensorId, metadata))
} }
@@ -277,6 +302,8 @@ export const useSensorStore = defineStore('sensor', () => {
// Computed // Computed
totalSensors, totalSensors,
activeSensors, activeSensors,
averageCO2Level,
maxCO2Level,
// Actions // Actions
updateEnergySensors, updateEnergySensors,

View File

@@ -33,6 +33,18 @@ interface AppSettings {
developerMode: boolean developerMode: boolean
} }
// Type for setting values
type SettingValue =
| string
| number
| boolean
| Theme
| Language
| NavigationMode
| UISettings
| NotificationSettings
| AppSettings
const DEFAULT_SETTINGS: AppSettings = { const DEFAULT_SETTINGS: AppSettings = {
theme: 'system', theme: 'system',
language: 'en', language: 'en',
@@ -93,8 +105,9 @@ export const useSettingsStore = defineStore('settings', () => {
saveSettings() saveSettings()
} }
function updateSetting(path: string, value: any) { function updateSetting(path: string, value: SettingValue): void {
const keys = path.split('.') const keys = path.split('.')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let current: any = settings let current: any = settings
for (let i = 0; i < keys.length - 1; i++) { for (let i = 0; i < keys.length - 1; i++) {
@@ -105,8 +118,9 @@ export const useSettingsStore = defineStore('settings', () => {
saveSettings() saveSettings()
} }
function getSetting(path: string): any { function getSetting(path: string): SettingValue | undefined {
const keys = path.split('.') const keys = path.split('.')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let current: any = settings let current: any = settings
for (const key of keys) { for (const key of keys) {
@@ -114,7 +128,7 @@ export const useSettingsStore = defineStore('settings', () => {
if (current === undefined) break if (current === undefined) break
} }
return current return current as SettingValue | undefined
} }
function exportSettings(): string { function exportSettings(): string {

View File

@@ -5,15 +5,15 @@ import { useRoomStore } from './room'
const MAX_DATA_POINTS = 100 const MAX_DATA_POINTS = 100
interface SensorReading { interface WebSocketReading {
sensorId: string sensorId: string
room: string room: string
timestamp: number timestamp: number
energy: { energy?: {
value: number value: number
unit: string unit: string
} }
co2: { co2?: {
value: number value: number
unit: string unit: string
} }
@@ -24,8 +24,8 @@ interface SensorReading {
} }
export const useWebSocketStore = defineStore('websocket', () => { export const useWebSocketStore = defineStore('websocket', () => {
const isConnected = ref(false) const isConnected = ref<boolean>(false)
const latestMessage = ref<SensorReading | null>(null) const latestMessage = ref<WebSocketReading | null>(null)
const timeSeriesData = reactive<{ const timeSeriesData = reactive<{
labels: string[] labels: string[]
datasets: { data: number[] }[] datasets: { data: number[] }[]
@@ -35,9 +35,9 @@ export const useWebSocketStore = defineStore('websocket', () => {
}) })
let socket: WebSocket | null = null let socket: WebSocket | null = null
const newDataBuffer: SensorReading[] = [] const newDataBuffer: WebSocketReading[] = []
function connect(url: string) { function connect(url: string): void {
if (isConnected.value && socket) { if (isConnected.value && socket) {
console.log('Already connected.') console.log('Already connected.')
return return
@@ -109,40 +109,37 @@ export const useWebSocketStore = defineStore('websocket', () => {
}, 500) }, 500)
} }
function disconnect() { function disconnect(): void {
if (socket) { if (socket) {
socket.close() socket.close()
} }
} }
function processIncomingData(data: SensorReading) { function processIncomingData(data: WebSocketReading): void {
// Skip non-data messages // Skip non-data messages
if ('type' in data && (data.type === 'connection_established' || data.type === 'proxy_info')) { if ('type' in data && (data as any).type === 'connection_established' || (data as any).type === 'proxy_info') {
return return
} }
const sensorStore = useSensorStore() const sensorStore = useSensorStore()
const roomStore = useRoomStore() const roomStore = useRoomStore()
// Handle new multi-metric data // Update individual sensor readings first
// Only update room data if we have the proper structure sensorStore.updateLatestReading(data as any)
if (data.energy && data.co2 && data.room) {
// Update room data if we have room information (accepts partial readings)
if (data.room) {
if (data.energy) { if (data.energy) {
sensorStore.updateEnergySensors(data) sensorStore.updateEnergySensors(data as any)
} }
roomStore.updateRoomData(data) roomStore.updateRoomData(data as any)
} }
// Map the sensor ID for individual sensor updates // Update time series for chart if energy data is available
// const mappedSensorId = mapWebSocketSensorId(data.sensorId)
const mappedData = { ...data, sensorId: data.sensorId, id: data.sensorId }
sensorStore.updateLatestReading(data) // Update individual sensor readings for cards
if (data.energy) { if (data.energy) {
// Update time series for chart (use energy values if available)
const newLabel = new Date(data.timestamp * 1000).toLocaleTimeString() const newLabel = new Date(data.timestamp * 1000).toLocaleTimeString()
timeSeriesData.labels.push(newLabel) timeSeriesData.labels.push(newLabel)
timeSeriesData.datasets[0].data.push(data.energy?.value) timeSeriesData.datasets[0].data.push(data.energy.value)
} }
} }

View File

@@ -1,220 +1,306 @@
<template> <template>
<div class="min-h-screen bg-gray-50"> <div class="space-y-6">
<div class="px-4 py-6 mx-auto max-w-7xl sm:px-6 lg:px-8"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
<div class="mb-8"> <div>
<h1 class="text-3xl font-bold text-gray-900">API Dashboard</h1> <h1 class="text-2xl font-bold text-gray-900">Analytics</h1>
<p class="text-gray-600 mt-2">Real-time data from backend APIs</p> <p class="text-gray-600">Manage sensors, assign rooms, and control device actions</p>
</div> </div>
</div>
<!-- API Status Section --> <div class="mb-8">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8"> <h1 class="text-3xl font-bold text-gray-900">API Dashboard</h1>
<div class="bg-white rounded-lg shadow p-6"> <p class="text-gray-600 mt-2">Real-time data from backend APIs</p>
<div class="flex items-center"> </div>
<div class="p-2 bg-blue-100 rounded-lg"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <div class="bg-white rounded-lg shadow p-6">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path> <div class="flex items-center">
</svg> <div class="p-2 bg-blue-100 rounded-lg">
</div> <svg
<div class="ml-4"> class="w-6 h-6 text-blue-600"
<p class="text-sm font-medium text-gray-600">System Status</p> fill="none"
<p class="text-lg font-semibold" :class="healthStatus?.status === 'healthy' ? 'text-green-600' : 'text-red-600'"> stroke="currentColor"
{{ healthStatus?.status || 'Unknown' }} viewBox="0 0 24 24"
</p> >
</div> <path
</div> stroke-linecap="round"
</div> stroke-linejoin="round"
stroke-width="2"
<div class="bg-white rounded-lg shadow p-6"> d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
<div class="flex items-center"> ></path>
<div class="p-2 bg-green-100 rounded-lg">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">Total Sensors</p>
<p class="text-lg font-semibold text-gray-900">{{ sensorStore.totalSensors }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center">
<div class="p-2 bg-yellow-100 rounded-lg">
<svg class="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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"></path>
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">Active Sensors</p>
<p class="text-lg font-semibold text-gray-900">{{ sensorStore.activeSensors }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center">
<div class="p-2 bg-purple-100 rounded-lg">
<svg class="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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"></path>
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">Total Readings</p>
<p class="text-lg font-semibold text-gray-900">{{ formatNumber(sensorStore.totalReadings) }}</p>
</div>
</div>
</div>
</div>
<!-- Loading States -->
<div v-if="isLoading" class="bg-white rounded-lg shadow p-6 mb-8">
<div class="flex items-center justify-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p class="ml-3 text-gray-600">Loading API data...</p>
</div>
</div>
<!-- Error States -->
<div v-if="apiError" class="bg-red-50 border border-red-200 rounded-lg p-4 mb-8">
<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> </svg>
</div> </div>
<div class="ml-3"> <div class="ml-4">
<h3 class="text-sm font-medium text-red-800">API Error</h3> <p class="text-sm font-medium text-gray-600">System Status</p>
<p class="mt-1 text-sm text-red-700">{{ apiError }}</p> <p
class="text-lg font-semibold"
:class="healthStatus?.status === 'healthy' ? 'text-green-600' : 'text-red-600'"
>
{{ healthStatus?.status || 'Unknown' }}
</p>
</div> </div>
</div> </div>
</div> </div>
<!-- Energy Metrics Summary --> <div class="bg-white rounded-lg shadow p-6">
<div class="bg-white rounded-lg shadow p-6 mb-8"> <div class="flex items-center">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Energy Metrics</h2> <div class="p-2 bg-green-100 rounded-lg">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6"> <svg
<div> class="w-6 h-6 text-green-600"
<p class="text-sm font-medium text-gray-600 mb-2">Current Energy Consumption</p> fill="none"
<p class="text-2xl font-bold text-blue-600"> stroke="currentColor"
{{ energyStore.currentEnergyValue.toFixed(2) }} kWh viewBox="0 0 24 24"
</p> >
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
></path>
</svg>
</div> </div>
<div> <div class="ml-4">
<p class="text-sm font-medium text-gray-600 mb-2">Average Energy Usage</p> <p class="text-sm font-medium text-gray-600">Total Sensors</p>
<p class="text-2xl font-bold text-green-600"> <p class="text-lg font-semibold text-gray-900">{{ sensorStore.totalSensors }}</p>
{{ energyStore.averageEnergyUsage.toFixed(2) }} kWh
</p>
</div>
<div>
<p class="text-sm font-medium text-gray-600 mb-2">Total Consumption</p>
<p class="text-2xl font-bold text-purple-600">
{{ energyStore.currentConsumption.toFixed(2) }} kWh
</p>
<p class="text-xs text-gray-500 mt-1">Cumulative</p>
</div> </div>
</div> </div>
</div> </div>
<!-- Sensors Section --> <div class="bg-white rounded-lg shadow p-6">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8"> <div class="flex items-center">
<div class="bg-white rounded-lg shadow"> <div class="p-2 bg-yellow-100 rounded-lg">
<div class="px-6 py-4 border-b border-gray-200"> <svg
<h2 class="text-lg font-semibold text-gray-900">API Sensors</h2> class="w-6 h-6 text-yellow-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<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"
></path>
</svg>
</div> </div>
<div class="p-6"> <div class="ml-4">
<div v-if="apiSensors.length === 0" class="text-center text-gray-500 py-8"> <p class="text-sm font-medium text-gray-600">Active Sensors</p>
No sensors found from API <p class="text-lg font-semibold text-gray-900">{{ sensorStore.activeSensors }}</p>
</div> </div>
<div v-else class="space-y-3"> </div>
<div v-for="sensor in apiSensors" :key="sensor.sensor_id" </div>
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div> <div class="bg-white rounded-lg shadow p-6">
<p class="font-medium text-gray-900">{{ sensor.sensor_id }}</p> <div class="flex items-center">
<p class="text-sm text-gray-500">{{ sensor.room || 'No room assigned' }}</p> <div class="p-2 bg-purple-100 rounded-lg">
<p class="text-xs text-gray-400">{{ sensor.sensor_type }} {{ sensor.total_readings }} readings</p> <svg
</div> class="w-6 h-6 text-purple-600"
<div class="flex items-center"> fill="none"
<span :class="[ stroke="currentColor"
viewBox="0 0 24 24"
>
<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"
></path>
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">Total Readings</p>
<p class="text-lg font-semibold text-gray-900">
{{ formatNumber(sensorStore.totalReadings) }}
</p>
</div>
</div>
</div>
</div>
<!-- Loading States -->
<div v-if="isLoading" class="bg-white rounded-lg shadow p-6 mb-8">
<div class="flex items-center justify-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p class="ml-3 text-gray-600">Loading API data...</p>
</div>
</div>
<!-- Error States -->
<div v-if="apiError" class="bg-red-50 border border-red-200 rounded-lg p-4 mb-8">
<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">API Error</h3>
<p class="mt-1 text-sm text-red-700">{{ apiError }}</p>
</div>
</div>
</div>
<!-- Energy Metrics Summary -->
<div class="bg-white rounded-lg shadow p-6 mb-8">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Energy Metrics</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<p class="text-sm font-medium text-gray-600 mb-2">Current Energy Consumption</p>
<p class="text-2xl font-bold text-blue-600">
{{ energyStore.currentEnergyValue.toFixed(2) }} kWh
</p>
</div>
<div>
<p class="text-sm font-medium text-gray-600 mb-2">Average Energy Usage</p>
<p class="text-2xl font-bold text-green-600">
{{ energyStore.averageEnergyUsage.toFixed(2) }} kWh
</p>
</div>
<div>
<p class="text-sm font-medium text-gray-600 mb-2">Total Consumption</p>
<p class="text-2xl font-bold text-purple-600">
{{ energyStore.currentConsumption.toFixed(2) }} kWh
</p>
<p class="text-xs text-gray-500 mt-1">Cumulative</p>
</div>
</div>
</div>
<!-- Sensors Section -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
<div class="bg-white rounded-lg shadow">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">API Sensors</h2>
</div>
<div class="p-6">
<div v-if="apiSensors.length === 0" class="text-center text-gray-500 py-8">
No sensors found from API
</div>
<div v-else class="space-y-3">
<div
v-for="sensor in apiSensors"
:key="sensor.sensor_id"
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div>
<p class="font-medium text-gray-900">{{ sensor.sensor_id }}</p>
<p class="text-sm text-gray-500">{{ sensor.room || 'No room assigned' }}</p>
<p class="text-xs text-gray-400">
{{ sensor.sensor_type }} {{ sensor.total_readings }} readings
</p>
</div>
<div class="flex items-center">
<span
:class="[
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium', 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
sensor.status === 'online' ? 'bg-green-100 text-green-800' : sensor.status === 'online'
sensor.status === 'offline' ? 'bg-red-100 text-red-800' : ? 'bg-green-100 text-green-800'
'bg-yellow-100 text-yellow-800' : sensor.status === 'offline'
]"> ? 'bg-red-100 text-red-800'
{{ sensor.status }} : 'bg-yellow-100 text-yellow-800',
]"
>
{{ sensor.status }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- Rooms Section -->
<div class="bg-white rounded-lg shadow">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">API Rooms</h2>
</div>
<div class="p-6">
<div v-if="apiRooms.length === 0" class="text-center text-gray-500 py-8">
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 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>
</div>
<div class="text-xs text-gray-600 space-y-1">
<p v-if="room.sensor_types">Types: {{ room.sensor_types.join(', ') }}</p>
<div v-if="room.latest_metrics">
<span v-if="room.latest_metrics.energy" class="mr-4">
Energy: {{ room.latest_metrics.energy.current }}
{{ room.latest_metrics.energy.unit }}
</span>
<span v-if="room.latest_metrics.co2">
CO2: {{ room.latest_metrics.co2.current }} {{ room.latest_metrics.co2.unit }}
</span> </span>
</div> </div>
</div> <p v-else class="text-gray-400 italic">No metrics available</p>
</div>
</div>
</div>
<!-- Rooms Section -->
<div class="bg-white rounded-lg shadow">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">API Rooms</h2>
</div>
<div class="p-6">
<div v-if="apiRooms.length === 0" class="text-center text-gray-500 py-8">
No rooms found from API
</div>
<div v-else class="space-y-3">
<div v-for="room in apiRooms" :key="room.name || 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.name || room.room }}</p>
<span class="text-sm text-gray-500">{{ room.sensor_count }} sensors</span>
</div>
<div class="text-xs text-gray-600 space-y-1">
<p v-if="room.sensor_types">Types: {{ room.sensor_types.join(', ') }}</p>
<div v-if="room.latest_metrics">
<span v-if="room.latest_metrics.energy" class="mr-4">
Energy: {{ room.latest_metrics.energy.current }} {{ room.latest_metrics.energy.unit }}
</span>
<span v-if="room.latest_metrics.co2">
CO2: {{ room.latest_metrics.co2.current }} {{ room.latest_metrics.co2.unit }}
</span>
</div>
<p v-else class="text-gray-400 italic">No metrics available</p>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="bg-white rounded-lg shadow p-6"> <div class="bg-white rounded-lg shadow p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">API Actions</h2> <h2 class="text-lg font-semibold text-gray-900 mb-4">API Actions</h2>
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
<button @click="refreshAllData" <button
class="inline-flex items-center px-4 py-2 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" @click="refreshAllData"
:disabled="isLoading"> class="inline-flex items-center px-4 py-2 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"
<svg v-if="!isLoading" class="-ml-1 mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> :disabled="isLoading"
<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"></path> >
</svg> <svg
<div v-else class="animate-spin -ml-1 mr-2 h-4 w-4 border-2 border-white border-t-transparent rounded-full"></div> v-if="!isLoading"
Refresh All Data class="-ml-1 mr-2 h-4 w-4"
</button> fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<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"
></path>
</svg>
<div
v-else
class="animate-spin -ml-1 mr-2 h-4 w-4 border-2 border-white border-t-transparent rounded-full"
></div>
Refresh All Data
</button>
<button @click="fetchSensorsOnly" <button
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" @click="fetchSensorsOnly"
:disabled="isLoading"> class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
Fetch Sensors :disabled="isLoading"
</button> >
Fetch Sensors
</button>
<button @click="fetchRoomsOnly" <button
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" @click="fetchRoomsOnly"
:disabled="isLoading"> class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
Fetch Rooms :disabled="isLoading"
</button> >
Fetch Rooms
</button>
<button @click="fetchAnalyticsOnly" <button
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" @click="fetchAnalyticsOnly"
:disabled="isLoading"> class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
Fetch Analytics :disabled="isLoading"
</button> >
</div> Fetch Analytics
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -240,10 +326,10 @@ const healthStatus = computed(() => analyticsStore.healthStatus)
// Combined loading and error states // Combined loading and error states
const isLoading = computed( const isLoading = computed(
() => sensorStore.apiLoading || roomStore.apiLoading || analyticsStore.apiLoading () => sensorStore.apiLoading || roomStore.apiLoading || analyticsStore.apiLoading,
) )
const apiError = computed( const apiError = computed(
() => sensorStore.apiError || roomStore.apiError || analyticsStore.apiError () => sensorStore.apiError || roomStore.apiError || analyticsStore.apiError,
) )
// Helper functions // Helper functions
@@ -279,4 +365,4 @@ const fetchAnalyticsOnly = async () => {
onMounted(async () => { onMounted(async () => {
await refreshAllData() await refreshAllData()
}) })
</script> </script>

View File

@@ -1,59 +1,30 @@
<template> <template>
<div class="space-y-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 pb-20">
<!-- Filter Controls Row --> <div class="space-y-6">
<!--div class="flex flex-col sm:flex-row gap-4 mb-6"> <div class="grid grid-cols-2 sm:grid-cols-3 gap-4">
<select class="px-4 py-2 border border-gray-200 rounded-lg bg-white">
<option>Timeframe: All-time</option>
</select>
<select class="px-4 py-2 border border-gray-200 rounded-lg bg-white">
<option>People: All</option>
</select>
<select class="px-4 py-2 border border-gray-200 rounded-lg bg-white">
<option>Topic: All</option>
</select>
</div-->
<!-- Top Metric Cards Row -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 min-h-96">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-4 gap-y-3">
<MetricCard title="Current Energy" :content="currentEnergyValue" details="kWh" /> <MetricCard title="Current Energy" :content="currentEnergyValue" details="kWh" />
<MetricCard title="Average Usage" :content="averageEnergyUsage" details="kWh" />
<MetricCard <MetricCard
title="Connection Status" title="Connection Status"
:content="websocketStore.isConnected ? 'Connected' : 'Disconnected'" :content="websocketStore.isConnected ? 'Connected' : 'Disconnected'"
/> />
<MetricCard title="Average Usage" :content="averageEnergyUsage" details="kWh" />
<GraphMetricCard <GraphMetricCard
title="Real-time Energy" title="Real-time Energy"
:content="currentEnergyValue" :content="currentEnergyValue"
details="kWh" details="kWh"
:trend-data="websocketStore.timeSeriesData.datasets[0].data.slice(-8)" :trend-data="energyStore.energyHistory.slice(-8)"
trend-direction="neutral"
/>
<GraphMetricCard
title="Current Knowledge"
content="86%"
:trend-data="[203, 78, 80, 82, 142, 85, 85, 86]"
trend-direction="down"
/>
<GraphMetricCard
title="Knowledge Gain"
content="+34%"
:trend-data="[20, 25, 28, 30, 32, 33, 34, 34]"
trend-direction="neutral" trend-direction="neutral"
/> />
<GraphMetricCard title="Average CO2" :content="averageCO2" details="ppm" />
<GraphMetricCard title="Max CO2" :content="maxCO2" details="ppm" />
</div> </div>
<div> <SensorConsumptionTable />
<RealtimeEnergyChartCard title="Month" />
</div>
</div> </div>
<!-- Charts and Knowledge Cards Row --> <div class="space-y-6">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4"> <RealtimeEnergyChartCard title="Month" />
<SensorConsumptionTable /> <RoomMetricsCard />
<div class="grid grid-cols-1 gap-4"> <AirQualityCard />
<RoomMetricsCard />
<AirQualityCard />
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -66,23 +37,30 @@ import SensorConsumptionTable from '@/components/cards/SensorConsumptionTable.vu
import RoomMetricsCard from '@/components/cards/RoomMetricsCard.vue' import RoomMetricsCard from '@/components/cards/RoomMetricsCard.vue'
import AirQualityCard from '@/components/cards/AirQualityCard.vue' import AirQualityCard from '@/components/cards/AirQualityCard.vue'
import { useEnergyStore } from '@/stores/energy' import { useEnergyStore } from '@/stores/energy'
import { useSensorStore } from '@/stores/sensor'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
import { computed, onMounted, onUnmounted } from 'vue' import { computed, onMounted, onUnmounted } from 'vue'
import { useWebSocketStore } from '@/stores/websocket' import { useWebSocketStore } from '@/stores/websocket'
const energyStore = useEnergyStore() const energyStore = useEnergyStore()
const sensorStore = useSensorStore()
const websocketStore = useWebSocketStore() const websocketStore = useWebSocketStore()
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
const currentEnergyValue = computed(() => { const currentEnergyValue = computed(() => {
return websocketStore.latestMessage?.energy?.value.toFixed(2) || '0.00' return energyStore.currentEnergyValue.toFixed(2)
}) })
const averageEnergyUsage = computed(() => { const averageEnergyUsage = computed(() => {
const data = websocketStore.timeSeriesData.datasets[0].data return energyStore.averageEnergyUsage.toFixed(2)
if (data.length === 0) return '0.00' })
const sum = data.reduce((acc, val) => acc + val, 0)
return (sum / data.length).toFixed(2) const averageCO2 = computed(() => {
return Math.round(sensorStore.averageCO2Level)
})
const maxCO2 = computed(() => {
return Math.round(sensorStore.maxCO2Level)
}) })
onMounted(() => { onMounted(() => {

View File

@@ -3,7 +3,7 @@
<!-- Header --> <!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h1 class="text-2xl font-bold text-gray-900">AI Optimization</h1> <h1 class="text-2xl font-bold text-gray-900">Models</h1>
<p class="text-gray-600"> <p class="text-gray-600">
Leverage artificial intelligence to optimize energy consumption and building operations Leverage artificial intelligence to optimize energy consumption and building operations
</p> </p>

View File

@@ -184,7 +184,7 @@
<!-- Action Modal --> <!-- Action Modal -->
<ActionModal <ActionModal
v-if="showActionModal" v-if="showActionModal && selectedSensor && selectedAction"
:sensor="selectedSensor" :sensor="selectedSensor"
:action="selectedAction" :action="selectedAction"
@execute="handleActionExecute" @execute="handleActionExecute"
@@ -201,6 +201,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useSensorStore } from '@/stores/sensor' import { useSensorStore } from '@/stores/sensor'
import { useRoomStore } from '@/stores/room' import { useRoomStore } from '@/stores/room'
import { useWebSocketStore } from '@/stores/websocket' import { useWebSocketStore } from '@/stores/websocket'
import type { SensorDevice, SensorAction } from '@/services'
import ActionModal from '@/components/modals/ActionModal.vue' import ActionModal from '@/components/modals/ActionModal.vue'
import RoomManagementModal from '@/components/modals/RoomManagementModal.vue' import RoomManagementModal from '@/components/modals/RoomManagementModal.vue'
import SimpleSensorCard from '@/components/cards/SimpleSensorCard.vue' import SimpleSensorCard from '@/components/cards/SimpleSensorCard.vue'
@@ -217,8 +218,8 @@ const selectedType = ref('')
const selectedStatus = ref('') const selectedStatus = ref('')
const showActionModal = ref(false) const showActionModal = ref(false)
const selectedSensor = ref<any>(null) const selectedSensor = ref<SensorDevice | null>(null)
const selectedAction = ref<any>(null) const selectedAction = ref<SensorAction | null>(null)
const isExecutingAction = ref(false) const isExecutingAction = ref(false)
const showRoomManagementModal = ref(false) const showRoomManagementModal = ref(false)
@@ -241,17 +242,22 @@ const updateRoom = (sensorId: string, newRoom: string) => {
sensorStore.updateSensorRoom(sensorId, newRoom) sensorStore.updateSensorRoom(sensorId, newRoom)
} }
const executeAction = (sensor: any, action: any) => { const executeAction = (sensor: SensorDevice, action: SensorAction) => {
if (action.parameters) { if (action.parameters) {
selectedSensor.value = sensor selectedSensor.value = sensor
selectedAction.value = action selectedAction.value = action
showActionModal.value = true showActionModal.value = true
} else { } else {
handleActionExecute(sensor.id, action.id, {}) handleActionExecute(sensor.sensor_id, action.id, {})
} }
} }
const handleActionExecute = async (sensorId: string, actionId: string, parameters: any) => { interface ActionParameters {
value?: number | string | boolean
[key: string]: unknown
}
const handleActionExecute = async (sensorId: string, actionId: string, parameters: ActionParameters) => {
isExecutingAction.value = true isExecutingAction.value = true
try { try {
await sensorStore.executeSensorAction(sensorId, actionId) await sensorStore.executeSensorAction(sensorId, actionId)