Compare commits

..

15 Commits

Author SHA1 Message Date
rafaeldpsilva
a480466dd0 format 2025-12-20 00:18:45 +00:00
rafaeldpsilva
c3364cc422 format 2025-12-20 00:17:21 +00:00
rafaeldpsilva
4b4338fb91 update 2025-12-20 00:15:44 +00:00
rafaeldpsilva
1c7288b778 update 2025-12-20 00:14:43 +00:00
rafaeldpsilva
7accc66710 update 2025-12-20 00:13:33 +00:00
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
51 changed files with 1850 additions and 878 deletions

5
.env
View File

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

1
.gitattributes vendored
View File

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

5
.gitignore vendored
View File

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

View File

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

15
Tasks.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<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>
<div class="flex-grow flex items-center justify-start">
<p class="text-gray-900 font-bold text-2xl">

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
<template>
<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">
<table class="min-w-full">
<thead>
@@ -10,13 +9,10 @@
Sensor ID
</th>
<th class="text-right text-xs font-medium text-gray-500 uppercase tracking-wider py-3">
Current
Room
</th>
<th class="text-right text-xs font-medium text-gray-500 uppercase tracking-wider py-3">
Total
</th>
<th class="text-right text-xs font-medium text-gray-500 uppercase tracking-wider py-3">
Average
Value
</th>
<th class="text-right text-xs font-medium text-gray-500 uppercase tracking-wider py-3">
Last Updated
@@ -55,9 +51,6 @@
sensor.humidity?.unit
}}
</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">
{{ formatTime(sensor.timestamp) }}
</td>
@@ -66,7 +59,6 @@
</table>
</div>
<!-- Connection Status Indicator -->
<div class="mt-4 flex items-center justify-between text-xs text-gray-500">
<div class="flex items-center gap-2">
<div

View File

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

View File

@@ -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="getNavigationClasses()"
>
<UserIcon />
<div class="flex justify-center md:pb-4 pb-2">
<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"
@@ -54,23 +55,14 @@
</li>
<li>
<router-link
to="/ai-optimization"
to="/models"
class="flex flex-col items-center font-medium"
:class="
$route.name === 'ai-optimization'
? 'text-purple-600'
: 'text-gray-600 hover:text-purple-600'
$route.name === 'models' ? '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">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
<span class="text-xs">AI Optimize</span>
<IconEcosystem />
<span class="text-xs">Models</span>
</router-link>
</li>
<li>
@@ -126,9 +118,10 @@
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { onMounted } from 'vue'
import { useSettingsStore } from '@/stores/settings'
import UserIcon from './UserIcon.vue'
import IconEcosystem from '../icons/IconEcosystem.vue'
const settingsStore = useSettingsStore()
// 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>
<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
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

@@ -39,15 +39,15 @@
<input
v-model.number="numericValue"
type="range"
:min="action.parameters.min"
:max="action.parameters.max"
:step="action.parameters.step"
:min="action.parameters?.min"
:max="action.parameters?.max"
:step="action.parameters?.step"
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">
<span>{{ action.parameters.min }}</span>
<span>{{ action.parameters?.min }}</span>
<span class="font-medium">{{ numericValue }}{{ getUnit() }}</span>
<span>{{ action.parameters.max }}</span>
<span>{{ action.parameters?.max }}</span>
</div>
</div>
</div>
@@ -131,14 +131,20 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { SensorDevice, SensorAction } from '@/services'
interface ActionParameters {
value?: number | string | boolean
[key: string]: unknown
}
const props = defineProps<{
sensor: any
action: any
sensor: SensorDevice
action: SensorAction
}>()
const emit = defineEmits<{
execute: [sensorId: string, actionId: string, parameters: any]
execute: [sensorId: string, actionId: string, parameters: ActionParameters]
close: []
}>()
@@ -153,9 +159,9 @@ watch(
(action) => {
if (action) {
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]
}
toggleValue.value = false
@@ -182,7 +188,7 @@ const getUnit = () => {
const executeAction = async () => {
isExecuting.value = true
const parameters: any = {}
const parameters: ActionParameters = {}
if (props.action.type === 'adjust') {
if (hasNumericRange.value) {
@@ -195,7 +201,7 @@ const executeAction = async () => {
}
try {
emit('execute', props.sensor.id, props.action.id, parameters)
emit('execute', props.sensor.sensor_id, props.action.id, parameters)
} catch (error) {
console.error('Failed to execute action:', error)
} finally {

View File

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

View File

@@ -3,12 +3,15 @@
* Provides reactive API state management
*/
import { ref, reactive } from 'vue'
import {
sensorsApi,
roomsApi,
analyticsApi,
import {
sensorsApi,
roomsApi,
analyticsApi,
healthApi,
type SensorInfo,
type SensorDevice,
type SensorType,
type SensorStatus,
type SensorMetadata,
type RoomInfo,
type RoomData,
type AnalyticsSummary,
@@ -18,7 +21,7 @@ import {
type HealthCheck,
type SystemStatus,
type DataQuery,
type DataResponse
type DataResponse,
} from '@/services'
interface ApiState {
@@ -30,16 +33,16 @@ export function useApi() {
// Global API state
const globalState = reactive<ApiState>({
loading: false,
error: null
error: null,
})
// Helper to handle API calls with state management
async function handleApiCall<T>(
apiCall: () => Promise<T>,
localState?: { loading: boolean; error: string | null }
localState?: { loading: boolean; error: string | null },
): Promise<T | null> {
const state = localState || globalState
state.loading = true
state.error = null
@@ -58,7 +61,7 @@ export function useApi() {
return {
globalState,
handleApiCall
handleApiCall,
}
}
@@ -66,35 +69,29 @@ export function useApi() {
export function useSensorsApi() {
const state = reactive<ApiState>({
loading: false,
error: null
error: null,
})
const sensors = ref<SensorInfo[]>([])
const currentSensor = ref<SensorInfo | null>(null)
const sensors = ref<SensorDevice[]>([])
const currentSensor = ref<SensorDevice | null>(null)
const sensorData = ref<DataResponse | null>(null)
const { handleApiCall } = useApi()
const fetchSensors = async (params?: {
room?: string
sensor_type?: any
status?: any
sensor_type?: SensorType
status?: SensorStatus
}) => {
const result = await handleApiCall(
() => sensorsApi.getSensors(params),
state
)
if (result) {
sensors.value = result
const result = await handleApiCall(() => sensorsApi.getSensors(params), state)
if (result && result.sensors) {
sensors.value = result.sensors
}
return result
}
const fetchSensor = async (sensorId: string) => {
const result = await handleApiCall(
() => sensorsApi.getSensor(sensorId),
state
)
const result = await handleApiCall(() => sensorsApi.getSensor(sensorId), state)
if (result) {
currentSensor.value = result
}
@@ -108,12 +105,9 @@ export function useSensorsApi() {
end_time?: number
limit?: number
offset?: number
}
},
) => {
const result = await handleApiCall(
() => sensorsApi.getSensorData(sensorId, params),
state
)
const result = await handleApiCall(() => sensorsApi.getSensorData(sensorId, params), state)
if (result) {
sensorData.value = result
}
@@ -121,27 +115,15 @@ export function useSensorsApi() {
}
const queryData = async (query: DataQuery) => {
return handleApiCall(
() => sensorsApi.queryData(query),
state
)
return handleApiCall(() => sensorsApi.queryData(query), state)
}
const updateSensorMetadata = async (
sensorId: string,
metadata: Record<string, any>
) => {
return handleApiCall(
() => sensorsApi.updateSensorMetadata(sensorId, metadata),
state
)
const updateSensorMetadata = async (sensorId: string, metadata: SensorMetadata) => {
return handleApiCall(() => sensorsApi.updateSensorMetadata(sensorId, metadata), state)
}
const deleteSensor = async (sensorId: string) => {
return handleApiCall(
() => sensorsApi.deleteSensor(sensorId),
state
)
return handleApiCall(() => sensorsApi.deleteSensor(sensorId), state)
}
const exportData = async (params: {
@@ -150,10 +132,7 @@ export function useSensorsApi() {
sensor_ids?: string
format?: 'json' | 'csv'
}) => {
return handleApiCall(
() => sensorsApi.exportData(params),
state
)
return handleApiCall(() => sensorsApi.exportData(params), state)
}
return {
@@ -167,7 +146,7 @@ export function useSensorsApi() {
queryData,
updateSensorMetadata,
deleteSensor,
exportData
exportData,
}
}
@@ -175,7 +154,7 @@ export function useSensorsApi() {
export function useRoomsApi() {
const state = reactive<ApiState>({
loading: false,
error: null
error: null,
})
const rooms = ref<RoomInfo[]>([])
@@ -184,10 +163,7 @@ export function useRoomsApi() {
const { handleApiCall } = useApi()
const fetchRooms = async () => {
const result = await handleApiCall(
() => roomsApi.getRooms(),
state
)
const result = await handleApiCall(() => roomsApi.getRooms(), state)
if (result) {
rooms.value = result
}
@@ -200,12 +176,9 @@ export function useRoomsApi() {
start_time?: number
end_time?: number
limit?: number
}
},
) => {
const result = await handleApiCall(
() => roomsApi.getRoomData(roomName, params),
state
)
const result = await handleApiCall(() => roomsApi.getRoomData(roomName, params), state)
if (result) {
currentRoomData.value = result
}
@@ -217,7 +190,7 @@ export function useRoomsApi() {
rooms,
currentRoomData,
fetchRooms,
fetchRoomData
fetchRoomData,
}
}
@@ -225,7 +198,7 @@ export function useRoomsApi() {
export function useAnalyticsApi() {
const state = reactive<ApiState>({
loading: false,
error: null
error: null,
})
const summary = ref<AnalyticsSummary | null>(null)
@@ -236,10 +209,7 @@ export function useAnalyticsApi() {
const { handleApiCall } = useApi()
const fetchAnalyticsSummary = async (hours: number = 24) => {
const result = await handleApiCall(
() => analyticsApi.getAnalyticsSummary(hours),
state
)
const result = await handleApiCall(() => analyticsApi.getAnalyticsSummary(hours), state)
if (result) {
summary.value = result
}
@@ -247,10 +217,7 @@ export function useAnalyticsApi() {
}
const fetchEnergyTrends = async (hours: number = 168) => {
const result = await handleApiCall(
() => analyticsApi.getEnergyTrends(hours),
state
)
const result = await handleApiCall(() => analyticsApi.getEnergyTrends(hours), state)
if (result) {
trends.value = result
}
@@ -258,10 +225,7 @@ export function useAnalyticsApi() {
}
const fetchRoomComparison = async (hours: number = 24) => {
const result = await handleApiCall(
() => analyticsApi.getRoomComparison(hours),
state
)
const result = await handleApiCall(() => analyticsApi.getRoomComparison(hours), state)
if (result) {
roomComparison.value = result
}
@@ -274,10 +238,7 @@ export function useAnalyticsApi() {
hours?: number
limit?: number
}) => {
const result = await handleApiCall(
() => analyticsApi.getEvents(params),
state
)
const result = await handleApiCall(() => analyticsApi.getEvents(params), state)
if (result) {
events.value = result.events
}
@@ -293,7 +254,7 @@ export function useAnalyticsApi() {
fetchAnalyticsSummary,
fetchEnergyTrends,
fetchRoomComparison,
fetchEvents
fetchEvents,
}
}
@@ -301,7 +262,7 @@ export function useAnalyticsApi() {
export function useHealthApi() {
const state = reactive<ApiState>({
loading: false,
error: null
error: null,
})
const health = ref<HealthCheck | null>(null)
@@ -310,10 +271,7 @@ export function useHealthApi() {
const { handleApiCall } = useApi()
const fetchHealth = async () => {
const result = await handleApiCall(
() => healthApi.getHealth(),
state
)
const result = await handleApiCall(() => healthApi.getHealth(), state)
if (result) {
health.value = result
}
@@ -321,10 +279,7 @@ export function useHealthApi() {
}
const fetchStatus = async () => {
const result = await handleApiCall(
() => healthApi.getStatus(),
state
)
const result = await handleApiCall(() => healthApi.getStatus(), state)
if (result) {
status.value = result
}
@@ -336,6 +291,6 @@ export function useHealthApi() {
health,
status,
fetchHealth,
fetchStatus
fetchStatus,
}
}
}

View File

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

View File

@@ -1,11 +1,19 @@
// Base configuration
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
export interface ApiResponse<T = any> {
export interface ApiResponse<T = unknown> {
data: T
total_count?: number
query?: any
query?: Record<string, unknown>
execution_time_ms?: number
}
@@ -71,7 +79,7 @@ export interface SensorReading {
value: number
unit: string
}
metadata?: Record<string, any>
metadata?: Record<string, unknown>
}
export interface RoomInfo {
@@ -169,7 +177,7 @@ export interface SystemEvent {
event_type: string
severity: 'info' | 'warning' | 'error' | 'critical'
message: string
details?: Record<string, any>
details?: Record<string, unknown>
sensor_id?: string
room?: string
}
@@ -187,6 +195,9 @@ export interface SensorDevice {
actions: SensorAction[]
}
metadata: SensorMetadata
tags?: string[]
lastSeen?: number
total_readings?: number
}
export interface SensorAction {
@@ -231,6 +242,7 @@ export enum SensorStatus {
ONLINE = 'online',
OFFLINE = 'offline',
ERROR = 'error',
ACTIVE = 'active',
}
export interface SensorMetadata {
@@ -242,6 +254,7 @@ export interface SensorMetadata {
model?: string
firmware?: string
battery?: number
signalStrength?: number
created_at?: string
updated_at?: string
manufacturer?: string
@@ -277,7 +290,7 @@ class ApiClient {
// Dynamically get auth headers to avoid circular imports
try {
// 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') {
return authStore.getAuthHeader()
}
@@ -333,7 +346,10 @@ 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}`)
if (params) {
@@ -366,14 +382,14 @@ class ApiClient {
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, {
method: 'POST',
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, {
method: 'PUT',
body: data ? JSON.stringify(data) : undefined,

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,13 @@ import {
type HealthCheck,
} from '@/services'
// Extend Window interface for auth store
interface WindowWithAuth extends Window {
__AUTH_STORE__?: {
ensureAuthenticated: () => Promise<boolean>
}
}
export const useAnalyticsStore = defineStore('analytics', () => {
// State
const analyticsData = ref<{
@@ -41,7 +48,7 @@ export const useAnalyticsStore = defineStore('analytics', () => {
console.warn('Authentication error detected, attempting to re-authenticate...')
try {
const authStore = (window as any).__AUTH_STORE__
const authStore = (window as WindowWithAuth).__AUTH_STORE__
if (authStore && typeof authStore.ensureAuthenticated === 'function') {
const authSuccess = await authStore.ensureAuthenticated()
if (authSuccess) {
@@ -124,11 +131,7 @@ export const useAnalyticsStore = defineStore('analytics', () => {
// Initialize data from APIs
async function initializeAnalyticsFromApi() {
await Promise.allSettled([
fetchAnalyticsSummary(),
fetchSystemStatus(),
fetchHealthStatus(),
])
await Promise.allSettled([fetchAnalyticsSummary(), fetchSystemStatus(), fetchHealthStatus()])
}
return {
@@ -148,4 +151,4 @@ export const useAnalyticsStore = defineStore('analytics', () => {
fetchHealthStatus,
initializeAnalyticsFromApi,
}
})
})

View File

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

View File

@@ -1,10 +1,12 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { ref, computed, reactive, watch } from 'vue'
import { useWebSocketStore } from './websocket'
import { useSensorStore } from './sensor'
import { useRoomStore } from './room'
import { useAnalyticsStore } from './analytics'
const MAX_HISTORY_POINTS = 48
/**
* Energy Store - Simplified to only track energy consumption metrics
* For sensor data: use useSensorStore()
@@ -20,34 +22,66 @@ export const useEnergyStore = defineStore('energy', () => {
const analyticsStore = useAnalyticsStore()
// Energy-specific state
const currentConsumption = ref(0) // Current energy consumption in kWh
const averageConsumption = ref(0) // Average energy consumption in kWh
const currentConsumption = ref<number>(0) // Current energy consumption in kWh
const averageConsumption = ref<number>(0) // Average energy consumption in kWh
// Computed: Current energy value from WebSocket
const currentEnergyValue = computed(() => {
return websocketStore.latestMessage?.energy?.value || 0
// Track aggregated energy over time
const energyHistory = reactive<number[]>([])
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
const averageEnergyUsage = computed(() => {
const data = websocketStore.timeSeriesData.datasets[0].data
if (data.length === 0) return 0
const sum = data.reduce((acc, val) => acc + val, 0)
return sum / data.length
// Computed: Average energy usage from history
const averageEnergyUsage = computed<number>(() => {
if (energyHistory.length === 0) return 0
const sum: number = energyHistory.reduce((acc: number, val: number) => acc + val, 0)
return sum / energyHistory.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)
function updateCurrentConsumption(value: number) {
function updateCurrentConsumption(value: number): void {
currentConsumption.value = value
}
// Update average consumption (called from components or watchers)
function updateAverageConsumption(value: number) {
function updateAverageConsumption(value: number): void {
averageConsumption.value = value
}
// Initialize data from APIs (convenience function for AnalyticsView)
async function initializeFromApi() {
async function initializeFromApi(): Promise<void> {
await Promise.allSettled([
roomStore.loadRoomsFromAPI(),
sensorStore.fetchApiSensors(),
@@ -62,6 +96,8 @@ export const useEnergyStore = defineStore('energy', () => {
averageConsumption,
currentEnergyValue,
averageEnergyUsage,
energyHistory,
energyTimestamps,
// Energy-specific actions
updateCurrentConsumption,

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,15 +5,15 @@ import { useRoomStore } from './room'
const MAX_DATA_POINTS = 100
interface SensorReading {
interface WebSocketReading {
sensorId: string
room: string
timestamp: number
energy: {
energy?: {
value: number
unit: string
}
co2: {
co2?: {
value: number
unit: string
}
@@ -24,8 +24,8 @@ interface SensorReading {
}
export const useWebSocketStore = defineStore('websocket', () => {
const isConnected = ref(false)
const latestMessage = ref<SensorReading | null>(null)
const isConnected = ref<boolean>(false)
const latestMessage = ref<WebSocketReading | null>(null)
const timeSeriesData = reactive<{
labels: string[]
datasets: { data: number[] }[]
@@ -35,9 +35,9 @@ export const useWebSocketStore = defineStore('websocket', () => {
})
let socket: WebSocket | null = null
const newDataBuffer: SensorReading[] = []
const newDataBuffer: WebSocketReading[] = []
function connect(url: string) {
function connect(url: string): void {
if (isConnected.value && socket) {
console.log('Already connected.')
return
@@ -109,40 +109,40 @@ export const useWebSocketStore = defineStore('websocket', () => {
}, 500)
}
function disconnect() {
function disconnect(): void {
if (socket) {
socket.close()
}
}
function processIncomingData(data: SensorReading) {
function processIncomingData(data: WebSocketReading): void {
// 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
}
const sensorStore = useSensorStore()
const roomStore = useRoomStore()
// Handle new multi-metric data
// Only update room data if we have the proper structure
if (data.energy && data.co2 && data.room) {
// Update individual sensor readings first
sensorStore.updateLatestReading(data as any)
// Update room data if we have room information (accepts partial readings)
if (data.room) {
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
// const mappedSensorId = mapWebSocketSensorId(data.sensorId)
const mappedData = { ...data, sensorId: data.sensorId, id: data.sensorId }
sensorStore.updateLatestReading(data) // Update individual sensor readings for cards
// Update time series for chart if energy data is available
if (data.energy) {
// Update time series for chart (use energy values if available)
const newLabel = new Date(data.timestamp * 1000).toLocaleTimeString()
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,302 @@
<template>
<div class="min-h-screen bg-gray-50">
<div class="px-4 py-6 mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">API Dashboard</h1>
<p class="text-gray-600 mt-2">Real-time data from backend APIs</p>
<div class="space-y-6">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">Analytics</h1>
<p class="text-gray-600">Manage sensors, assign rooms, and control device actions</p>
</div>
<!-- API Status Section -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center">
<div class="p-2 bg-blue-100 rounded-lg">
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">System Status</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 class="bg-white rounded-lg shadow p-6">
<div class="flex items-center">
<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" />
</div>
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">API Dashboard</h1>
<p class="text-gray-600 mt-2">Real-time data from backend APIs</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center">
<div class="p-2 bg-blue-100 rounded-lg">
<svg
class="w-6 h-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<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>
</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 class="ml-4">
<p class="text-sm font-medium text-gray-600">System Status</p>
<p
class="text-lg font-semibold"
:class="healthStatus?.status === 'healthy' ? 'text-green-600' : 'text-red-600'"
>
{{ healthStatus?.status || 'Unknown' }}
</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 class="bg-white rounded-lg shadow p-6">
<div class="flex items-center">
<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>
<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 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>
<!-- 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 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="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="[
<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>
</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',
sensor.status === 'online' ? 'bg-green-100 text-green-800' :
sensor.status === 'offline' ? 'bg-red-100 text-red-800' :
'bg-yellow-100 text-yellow-800'
]">
{{ sensor.status }}
sensor.status === 'online'
? 'bg-green-100 text-green-800'
: sensor.status === 'offline'
? 'bg-red-100 text-red-800'
: '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>
</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.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>
<p v-else class="text-gray-400 italic">No metrics available</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">API Actions</h2>
<div class="flex flex-wrap gap-3">
<button @click="refreshAllData"
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"
:disabled="isLoading">
<svg v-if="!isLoading" class="-ml-1 mr-2 h-4 w-4" 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>
<!-- Action Buttons -->
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">API Actions</h2>
<div class="flex flex-wrap gap-3">
<button
@click="refreshAllData"
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"
:disabled="isLoading"
>
<svg
v-if="!isLoading"
class="-ml-1 mr-2 h-4 w-4"
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"
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"
:disabled="isLoading">
Fetch Sensors
</button>
<button
@click="fetchSensorsOnly"
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"
:disabled="isLoading"
>
Fetch Sensors
</button>
<button @click="fetchRoomsOnly"
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"
:disabled="isLoading">
Fetch Rooms
</button>
<button
@click="fetchRoomsOnly"
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"
:disabled="isLoading"
>
Fetch Rooms
</button>
<button @click="fetchAnalyticsOnly"
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"
:disabled="isLoading">
Fetch Analytics
</button>
</div>
<button
@click="fetchAnalyticsOnly"
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"
:disabled="isLoading"
>
Fetch Analytics
</button>
</div>
</div>
</div>
@@ -240,10 +322,10 @@ const healthStatus = computed(() => analyticsStore.healthStatus)
// Combined loading and error states
const isLoading = computed(
() => sensorStore.apiLoading || roomStore.apiLoading || analyticsStore.apiLoading
() => sensorStore.apiLoading || roomStore.apiLoading || analyticsStore.apiLoading,
)
const apiError = computed(
() => sensorStore.apiError || roomStore.apiError || analyticsStore.apiError
() => sensorStore.apiError || roomStore.apiError || analyticsStore.apiError,
)
// Helper functions
@@ -279,4 +361,4 @@ const fetchAnalyticsOnly = async () => {
onMounted(async () => {
await refreshAllData()
})
</script>
</script>

View File

@@ -1,59 +1,30 @@
<template>
<div class="space-y-6">
<!-- Filter Controls Row -->
<!--div class="flex flex-col sm:flex-row gap-4 mb-6">
<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">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 pb-20">
<div class="space-y-6">
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4">
<MetricCard title="Current Energy" :content="currentEnergyValue" details="kWh" />
<MetricCard title="Average Usage" :content="averageEnergyUsage" details="kWh" />
<MetricCard
title="Connection Status"
:content="websocketStore.isConnected ? 'Connected' : 'Disconnected'"
/>
<MetricCard title="Average Usage" :content="averageEnergyUsage" details="kWh" />
<GraphMetricCard
title="Real-time Energy"
:content="currentEnergyValue"
details="kWh"
:trend-data="websocketStore.timeSeriesData.datasets[0].data.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-data="energyStore.energyHistory.slice(-8)"
trend-direction="neutral"
/>
<GraphMetricCard title="Average CO2" :content="averageCO2" details="ppm" />
<GraphMetricCard title="Max CO2" :content="maxCO2" details="ppm" />
</div>
<div>
<RealtimeEnergyChartCard title="Month" />
</div>
<SensorConsumptionTable />
</div>
<!-- Charts and Knowledge Cards Row -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<SensorConsumptionTable />
<div class="grid grid-cols-1 gap-4">
<RoomMetricsCard />
<AirQualityCard />
</div>
<div class="space-y-6">
<RealtimeEnergyChartCard title="Month" />
<RoomMetricsCard />
<AirQualityCard />
</div>
</div>
</template>
@@ -66,23 +37,30 @@ import SensorConsumptionTable from '@/components/cards/SensorConsumptionTable.vu
import RoomMetricsCard from '@/components/cards/RoomMetricsCard.vue'
import AirQualityCard from '@/components/cards/AirQualityCard.vue'
import { useEnergyStore } from '@/stores/energy'
import { useSensorStore } from '@/stores/sensor'
import { useSettingsStore } from '@/stores/settings'
import { computed, onMounted, onUnmounted } from 'vue'
import { useWebSocketStore } from '@/stores/websocket'
const energyStore = useEnergyStore()
const sensorStore = useSensorStore()
const websocketStore = useWebSocketStore()
const settingsStore = useSettingsStore()
const currentEnergyValue = computed(() => {
return websocketStore.latestMessage?.energy?.value.toFixed(2) || '0.00'
return energyStore.currentEnergyValue.toFixed(2)
})
const averageEnergyUsage = computed(() => {
const data = websocketStore.timeSeriesData.datasets[0].data
if (data.length === 0) return '0.00'
const sum = data.reduce((acc, val) => acc + val, 0)
return (sum / data.length).toFixed(2)
return energyStore.averageEnergyUsage.toFixed(2)
})
const averageCO2 = computed(() => {
return Math.round(sensorStore.averageCO2Level)
})
const maxCO2 = computed(() => {
return Math.round(sensorStore.maxCO2Level)
})
onMounted(() => {

View File

@@ -3,7 +3,7 @@
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
<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">
Leverage artificial intelligence to optimize energy consumption and building operations
</p>

View File

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

View File

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

View File

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