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/ /cypress/screenshots/
# Editor directories and files # Editor directories and files
.vscode/* .vscode/
!.vscode/extensions.json !.vscode/extensions.json
.idea .idea
*.suo *.suo
@@ -28,3 +28,6 @@ coverage
*.sw? *.sw?
*.tsbuildinfo *.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>
</div> </div>
</main> </main>
<bottom-nav /> <bottom-nav />
<!-- <app-footer /> --> <!-- <app-footer /> -->
</div> </div>
@@ -22,4 +21,3 @@ import AppHeader from './components/common/AppHeader.vue'
import BottomNav from './components/common/BottomNav.vue' import BottomNav from './components/common/BottomNav.vue'
import { RouterView } from 'vue-router' import { RouterView } from 'vue-router'
</script> </script>

View File

@@ -30,7 +30,12 @@
} }
// Grid utilities // 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; display: grid;
gap: $gap; gap: $gap;
grid-template-columns: repeat($columns-mobile, 1fr); grid-template-columns: repeat($columns-mobile, 1fr);

View File

@@ -66,9 +66,15 @@ $radius-2xl: 1.5rem;
// Shadows // Shadows
$shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); $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-md:
$shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); 0 4px 6px -1px rgba(0, 0, 0, 0.1),
$shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); 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 // Transitions
$transition-fast: 150ms ease-in-out; $transition-fast: 150ms ease-in-out;

View File

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

View File

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

View File

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

View File

@@ -13,11 +13,28 @@
Building Average: {{ overallCO2.toFixed(0) }} ppm Building Average: {{ overallCO2.toFixed(0) }} ppm
</div> </div>
</div> </div>
<div class="w-12 h-12 rounded-full flex items-center justify-center" :class="getOverallStatusIconBg()"> <div
<svg class="w-6 h-6" :class="getOverallStatusText()" fill="currentColor" viewBox="0 0 24 24"> class="w-12 h-12 rounded-full flex items-center justify-center"
<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'"/> :class="getOverallStatusIconBg()"
<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
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> </svg>
</div> </div>
</div> </div>
@@ -29,18 +46,22 @@
No air quality data available No air quality data available
</div> </div>
<div v-for="room in roomsList" :key="room.room" class="flex items-center justify-between p-2 rounded"> <div
v-for="room in roomsList"
:key="room.room"
class="flex items-center justify-between p-2 rounded"
>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div <div
class="w-3 h-3 rounded-full" class="w-3 h-3 rounded-full"
:class="getCO2StatusColor(room.co2.status)" :class="getCO2StatusColor(room.co2?.status || 'good')"
></div> ></div>
<span class="text-sm font-medium text-gray-900">{{ room.room }}</span> <span class="text-sm font-medium text-gray-900">{{ room.room }}</span>
</div> </div>
<div class="text-right"> <div class="text-right">
<div class="text-sm text-gray-900">{{ Math.round(room.co2.current) }} ppm</div> <div class="text-sm text-gray-900">{{ Math.round(room.co2?.current || 0) }} ppm</div>
<div class="text-xs" :class="getCO2TextColor(room.co2.status)"> <div class="text-xs" :class="getCO2TextColor(room.co2?.status || 'good')">
{{ room.co2.status.toUpperCase() }} {{ (room.co2?.status || 'good').toUpperCase() }}
</div> </div>
</div> </div>
</div> </div>
@@ -75,14 +96,17 @@ import { useRoomStore } from '@/stores/room'
const roomStore = useRoomStore() const roomStore = useRoomStore()
const roomsList = computed(() => { const roomsList = computed(() => {
return Array.from(roomStore.roomsData.values()).sort((a, b) => return Array.from(roomStore.roomsData.values())
b.co2.current - a.co2.current // Sort by CO2 level descending .filter((room) => room.co2) // Only include rooms with CO2 data
) .sort(
(a, b) => (b.co2?.current || 0) - (a.co2?.current || 0), // Sort by CO2 level descending
)
}) })
const overallCO2 = computed(() => { const overallCO2 = computed(() => {
if (roomsList.value.length === 0) return 0 if (roomsList.value.length === 0) return 0
return roomsList.value.reduce((sum, room) => sum + room.co2.current, 0) / roomsList.value.length const total = roomsList.value.reduce((sum, room) => sum + (room.co2?.current || 0), 0)
return total / roomsList.value.length
}) })
const overallStatus = computed(() => { const overallStatus = computed(() => {
@@ -90,23 +114,27 @@ const overallStatus = computed(() => {
}) })
const roomsWithGoodAir = computed(() => { const roomsWithGoodAir = computed(() => {
return roomsList.value.filter(room => room.co2.status === 'good').length return roomsList.value.filter((room) => room.co2?.status === 'good').length
}) })
const roomsNeedingAttention = computed(() => { const roomsNeedingAttention = computed(() => {
return roomsList.value.filter(room => ['poor', 'critical'].includes(room.co2.status)).length return roomsList.value.filter(
(room) => room.co2?.status && ['poor', 'critical'].includes(room.co2.status),
).length
}) })
const recommendations = computed(() => { const recommendations = computed(() => {
const recs = [] const recs = []
const criticalRooms = roomsList.value.filter(room => room.co2.status === 'critical') const criticalRooms = roomsList.value.filter((room) => room.co2?.status === 'critical')
const poorRooms = roomsList.value.filter(room => room.co2.status === 'poor') const poorRooms = roomsList.value.filter((room) => room.co2?.status === 'poor')
if (criticalRooms.length > 0) { if (criticalRooms.length > 0) {
recs.push(`Immediate ventilation needed in ${criticalRooms[0].room}`) recs.push(`Immediate ventilation needed in ${criticalRooms[0].room}`)
} }
if (poorRooms.length > 0) { 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) { if (overallCO2.value > 800) {
recs.push('Consider adjusting HVAC settings building-wide') recs.push('Consider adjusting HVAC settings building-wide')
@@ -117,61 +145,91 @@ const recommendations = computed(() => {
const getOverallStatus = () => { const getOverallStatus = () => {
switch (overallStatus.value) { switch (overallStatus.value) {
case 'good': return 'Excellent Air Quality' case 'good':
case 'moderate': return 'Moderate Air Quality' return 'Excellent Air Quality'
case 'poor': return 'Poor Air Quality' case 'moderate':
case 'critical': return 'Critical - Action Required' return 'Moderate Air Quality'
default: return 'Unknown Status' case 'poor':
return 'Poor Air Quality'
case 'critical':
return 'Critical - Action Required'
default:
return 'Unknown Status'
} }
} }
const getOverallStatusBg = () => { const getOverallStatusBg = () => {
switch (overallStatus.value) { switch (overallStatus.value) {
case 'good': return 'bg-green-50 border border-green-200' case 'good':
case 'moderate': return 'bg-yellow-50 border border-yellow-200' return 'bg-green-50 border border-green-200'
case 'poor': return 'bg-orange-50 border border-orange-200' case 'moderate':
case 'critical': return 'bg-red-50 border border-red-200' return 'bg-yellow-50 border border-yellow-200'
default: return 'bg-gray-50 border border-gray-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 = () => { const getOverallStatusText = () => {
switch (overallStatus.value) { switch (overallStatus.value) {
case 'good': return 'text-green-700' case 'good':
case 'moderate': return 'text-yellow-700' return 'text-green-700'
case 'poor': return 'text-orange-700' case 'moderate':
case 'critical': return 'text-red-700' return 'text-yellow-700'
default: return 'text-gray-700' case 'poor':
return 'text-orange-700'
case 'critical':
return 'text-red-700'
default:
return 'text-gray-700'
} }
} }
const getOverallStatusIconBg = () => { const getOverallStatusIconBg = () => {
switch (overallStatus.value) { switch (overallStatus.value) {
case 'good': return 'bg-green-100' case 'good':
case 'moderate': return 'bg-yellow-100' return 'bg-green-100'
case 'poor': return 'bg-orange-100' case 'moderate':
case 'critical': return 'bg-red-100' return 'bg-yellow-100'
default: return 'bg-gray-100' case 'poor':
return 'bg-orange-100'
case 'critical':
return 'bg-red-100'
default:
return 'bg-gray-100'
} }
} }
const getCO2StatusColor = (status: string) => { const getCO2StatusColor = (status: string) => {
switch (status) { switch (status) {
case 'good': return 'bg-green-500' case 'good':
case 'moderate': return 'bg-yellow-500' return 'bg-green-500'
case 'poor': return 'bg-orange-500' case 'moderate':
case 'critical': return 'bg-red-500' return 'bg-yellow-500'
default: return 'bg-gray-500' case 'poor':
return 'bg-orange-500'
case 'critical':
return 'bg-red-500'
default:
return 'bg-gray-500'
} }
} }
const getCO2TextColor = (status: string) => { const getCO2TextColor = (status: string) => {
switch (status) { switch (status) {
case 'good': return 'text-green-600' case 'good':
case 'moderate': return 'text-yellow-600' return 'text-green-600'
case 'poor': return 'text-orange-600' case 'moderate':
case 'critical': return 'text-red-600' return 'text-yellow-600'
default: return 'text-gray-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>
<div> <div>
<h3 class="font-medium text-gray-900">{{ sensor.name }}</h3> <h3 class="font-medium text-gray-900">{{ sensor.name }}</h3>
<p class="text-sm text-gray-500">{{ sensor.id }}</p> <p class="text-sm text-gray-500">{{ sensor.sensor_id }}</p>
</div> </div>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -17,7 +17,7 @@
class="w-2 h-2 rounded-full transition-all duration-300" class="w-2 h-2 rounded-full transition-all duration-300"
:class="[ :class="[
getSensorStatusColor(sensor.status), getSensorStatusColor(sensor.status),
isRecentlyUpdated ? 'animate-pulse shadow-lg shadow-green-400/50' : '' isRecentlyUpdated ? 'animate-pulse shadow-lg shadow-green-400/50' : '',
]" ]"
></div> ></div>
<span class="text-xs text-gray-500 capitalize">{{ sensor.status }}</span> <span class="text-xs text-gray-500 capitalize">{{ sensor.status }}</span>
@@ -32,7 +32,9 @@
<label class="block text-sm font-medium text-gray-700 mb-2">Room Assignment</label> <label class="block text-sm font-medium text-gray-700 mb-2">Room Assignment</label>
<select <select
:value="sensor.room" :value="sensor.room"
@change="$emit('updateRoom', sensor.id, ($event.target as HTMLSelectElement).value)" @change="
$emit('updateRoom', sensor.sensor_id, ($event.target as HTMLSelectElement).value)
"
class="w-full px-3 py-2 border border-gray-200 rounded-lg bg-white text-sm" class="w-full px-3 py-2 border border-gray-200 rounded-lg bg-white text-sm"
> >
<option value="">Unassigned</option> <option value="">Unassigned</option>
@@ -74,8 +76,7 @@
<div> <div>
<div class="text-sm font-medium text-gray-700 mb-2">Current Values</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 class="grid grid-cols-2 gap-2 text-xs">
<div v-for="metric in sensorValues" :key="metric.type" <div v-for="metric in sensorValues" :key="metric.type" class="bg-gray-50 rounded p-2">
class="bg-gray-50 rounded p-2">
<div class="text-gray-600 mb-1">{{ metric.label }}</div> <div class="text-gray-600 mb-1">{{ metric.label }}</div>
<div class="font-medium text-gray-900"> <div class="font-medium text-gray-900">
{{ metric.value }} <span class="text-gray-500">{{ metric.unit }}</span> {{ metric.value }} <span class="text-gray-500">{{ metric.unit }}</span>
@@ -100,7 +101,7 @@
<span class="font-medium">Location:</span> <span class="font-medium">Location:</span>
<div>{{ sensor.metadata.location }}</div> <div>{{ sensor.metadata.location }}</div>
</div> </div>
<div> <div v-if="sensor.lastSeen">
<span class="font-medium">Last Seen:</span> <span class="font-medium">Last Seen:</span>
<div>{{ formatTime(sensor.lastSeen) }}</div> <div>{{ formatTime(sensor.lastSeen) }}</div>
</div> </div>
@@ -126,7 +127,7 @@
v-for="i in 4" v-for="i in 4"
:key="i" :key="i"
class="w-1 h-2 bg-gray-200 rounded-sm" 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> </div>
</div> </div>
@@ -155,7 +156,9 @@
<!-- No Actions State --> <!-- No Actions State -->
<div v-else> <div v-else>
<div class="text-sm font-medium text-gray-700 mb-2">Device Actions</div> <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 This device is monitor-only and has no available actions
</div> </div>
</div> </div>
@@ -166,26 +169,30 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useSensorStore } from '@/stores/sensor' import { useSensorStore } from '@/stores/sensor'
import type { SensorDevice, SensorAction } from '@/services'
const props = defineProps<{ const props = defineProps<{
sensor: any sensor: SensorDevice
availableRooms: string[] availableRooms: string[]
isExecutingAction?: boolean isExecutingAction?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
updateRoom: [sensorId: string, newRoom: string] updateRoom: [sensorId: string, newRoom: string]
executeAction: [sensor: any, action: any] executeAction: [sensor: SensorDevice, action: SensorAction]
}>() }>()
const sensorStore = useSensorStore() const sensorStore = useSensorStore()
const getSensorValues = (sensor: any) => { const getSensorValues = (sensor: SensorDevice) => {
const values = [] const values = []
// Get real-time sensor reading from store // Get real-time sensor reading from store
const latestReading = sensorStore.latestReadings.get(sensor.id) || sensorStore.latestReadings.get(sensor.sensor_id) const latestReading = sensorStore.latestReadings.get(sensor.sensor_id)
console.log(`[Detailed] Getting values for sensor ${sensor.id}, found reading:`, latestReading) console.log(
`[Detailed] Getting values for sensor ${sensor.sensor_id}, found reading:`,
latestReading,
)
console.log('[Detailed] Available readings:', Array.from(sensorStore.latestReadings.keys())) console.log('[Detailed] Available readings:', Array.from(sensorStore.latestReadings.keys()))
console.log(`[Detailed] Sensor capabilities:`, sensor.capabilities?.monitoring) console.log(`[Detailed] Sensor capabilities:`, sensor.capabilities?.monitoring)
@@ -196,7 +203,7 @@ const getSensorValues = (sensor: any) => {
type: 'energy', type: 'energy',
label: 'Energy Consumption', label: 'Energy Consumption',
value: energyValue, value: energyValue,
unit: latestReading?.energy?.unit || 'kWh' unit: latestReading?.energy?.unit || 'kWh',
}) })
} }
@@ -207,19 +214,19 @@ const getSensorValues = (sensor: any) => {
type: 'co2', type: 'co2',
label: 'CO2 Level', label: 'CO2 Level',
value: co2Value, value: co2Value,
unit: latestReading?.co2?.unit || 'ppm' unit: latestReading?.co2?.unit || 'ppm',
}) })
} }
// Only show temperature if the sensor monitors temperature // Only show temperature if the sensor monitors temperature
if (sensor.capabilities?.monitoring?.includes('temperature')) { if (sensor.capabilities?.monitoring?.includes('temperature')) {
const tempValue = latestReading?.temperature?.value?.toFixed(1) || const tempValue =
(Math.random() * 8 + 18).toFixed(1) latestReading?.temperature?.value?.toFixed(1) || (Math.random() * 8 + 18).toFixed(1)
values.push({ values.push({
type: 'temperature', type: 'temperature',
label: 'Temperature', label: 'Temperature',
value: tempValue, value: tempValue,
unit: latestReading?.temperature?.unit || '°C' unit: latestReading?.temperature?.unit || '°C',
}) })
} }
@@ -229,7 +236,7 @@ const getSensorValues = (sensor: any) => {
type: 'humidity', type: 'humidity',
label: 'Humidity', label: 'Humidity',
value: Math.floor(Math.random() * 40 + 30), value: Math.floor(Math.random() * 40 + 30),
unit: '%' unit: '%',
}) })
} }
@@ -239,7 +246,7 @@ const getSensorValues = (sensor: any) => {
type: 'motion', type: 'motion',
label: 'Motion Status', label: 'Motion Status',
value: Math.random() > 0.7 ? 'Detected' : 'Clear', value: Math.random() > 0.7 ? 'Detected' : 'Clear',
unit: '' unit: '',
}) })
} }
@@ -251,13 +258,13 @@ const getSensorValues = (sensor: any) => {
type: 'brightness', type: 'brightness',
label: 'Brightness Level', label: 'Brightness Level',
value: Math.floor(Math.random() * 100), value: Math.floor(Math.random() * 100),
unit: '%' unit: '%',
}) })
values.push({ values.push({
type: 'power', type: 'power',
label: 'Power Draw', label: 'Power Draw',
value: Math.floor(Math.random() * 50 + 5), value: Math.floor(Math.random() * 50 + 5),
unit: 'W' unit: 'W',
}) })
break break
case 'hvac': case 'hvac':
@@ -265,13 +272,13 @@ const getSensorValues = (sensor: any) => {
type: 'setpoint', type: 'setpoint',
label: 'Target Temperature', label: 'Target Temperature',
value: (Math.random() * 6 + 18).toFixed(1), value: (Math.random() * 6 + 18).toFixed(1),
unit: '°C' unit: '°C',
}) })
values.push({ values.push({
type: 'mode', type: 'mode',
label: 'Operating Mode', label: 'Operating Mode',
value: ['Heat', 'Cool', 'Auto', 'Fan'][Math.floor(Math.random() * 4)], value: ['Heat', 'Cool', 'Auto', 'Fan'][Math.floor(Math.random() * 4)],
unit: '' unit: '',
}) })
break break
case 'security': case 'security':
@@ -279,13 +286,13 @@ const getSensorValues = (sensor: any) => {
type: 'status', type: 'status',
label: 'Security Status', label: 'Security Status',
value: Math.random() > 0.8 ? 'Alert' : 'Normal', value: Math.random() > 0.8 ? 'Alert' : 'Normal',
unit: '' unit: '',
}) })
values.push({ values.push({
type: 'armed', type: 'armed',
label: 'System Armed', label: 'System Armed',
value: Math.random() > 0.5 ? 'Yes' : 'No', value: Math.random() > 0.5 ? 'Yes' : 'No',
unit: '' unit: '',
}) })
break break
default: default:
@@ -294,7 +301,7 @@ const getSensorValues = (sensor: any) => {
type: 'status', type: 'status',
label: 'Device Status', label: 'Device Status',
value: sensor.status === 'online' ? 'Active' : 'Inactive', value: sensor.status === 'online' ? 'Active' : 'Inactive',
unit: '' unit: '',
}) })
} }
} }
@@ -304,7 +311,7 @@ const getSensorValues = (sensor: any) => {
type: 'uptime', type: 'uptime',
label: 'Uptime', label: 'Uptime',
value: Math.floor(Math.random() * 30 + 1), value: Math.floor(Math.random() * 30 + 1),
unit: 'days' unit: 'days',
}) })
return values return values
@@ -315,14 +322,13 @@ const sensorValues = computed(() => getSensorValues(props.sensor))
// Check if sensor was recently updated for pulsing animation // Check if sensor was recently updated for pulsing animation
const isRecentlyUpdated = computed(() => { const isRecentlyUpdated = computed(() => {
return sensorStore.recentlyUpdatedSensors.has(props.sensor.id) || return sensorStore.recentlyUpdatedSensors.has(props.sensor.sensor_id)
sensorStore.recentlyUpdatedSensors.has(props.sensor.sensor_id)
}) })
const getDefaultTags = (sensor: any) => { const getDefaultTags = (sensor: SensorDevice): string[] => {
const tags = [sensor.type] const tags: string[] = [sensor.type]
if (sensor.metadata.battery) { if (sensor.metadata?.battery) {
tags.push('wireless') tags.push('wireless')
} else { } else {
tags.push('wired') tags.push('wired')
@@ -345,7 +351,7 @@ const getSensorTypeIcon = (type: string) => {
humidity: '💧', humidity: '💧',
hvac: '❄️', hvac: '❄️',
lighting: '💡', lighting: '💡',
security: '🔒' security: '🔒',
} }
return icons[type as keyof typeof icons] || '📱' return icons[type as keyof typeof icons] || '📱'
} }
@@ -358,17 +364,21 @@ const getSensorTypeStyle = (type: string) => {
humidity: { bg: 'bg-blue-100', text: 'text-blue-700' }, humidity: { bg: 'bg-blue-100', text: 'text-blue-700' },
hvac: { bg: 'bg-cyan-100', text: 'text-cyan-700' }, hvac: { bg: 'bg-cyan-100', text: 'text-cyan-700' },
lighting: { bg: 'bg-amber-100', text: 'text-amber-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' } return styles[type as keyof typeof styles] || { bg: 'bg-gray-100', text: 'text-gray-700' }
} }
const getSensorStatusColor = (status: string) => { const getSensorStatusColor = (status: string) => {
switch (status) { switch (status) {
case 'online': return 'bg-green-500' case 'online':
case 'offline': return 'bg-gray-400' return 'bg-green-500'
case 'error': return 'bg-red-500' case 'offline':
default: return 'bg-gray-400' return 'bg-gray-400'
case 'error':
return 'bg-red-500'
default:
return 'bg-gray-400'
} }
} }

View File

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

View File

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

View File

@@ -13,14 +13,13 @@
<h3 class="font-medium text-gray-900">{{ room.room }}</h3> <h3 class="font-medium text-gray-900">{{ room.room }}</h3>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<!-- CO2 Status Indicator --> <!-- CO2 Status Indicator -->
<div <div class="w-3 h-3 rounded-full" :class="getCO2StatusColor(room.co2!.status)"></div>
class="w-3 h-3 rounded-full"
:class="getCO2StatusColor(room.co2.status)"
></div>
<!-- Occupancy Indicator --> <!-- Occupancy Indicator -->
<div class="flex items-center gap-1 text-xs text-gray-500"> <div class="flex items-center gap-1 text-xs text-gray-500">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"> <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> </svg>
<span class="capitalize">{{ room.occupancyEstimate }}</span> <span class="capitalize">{{ room.occupancyEstimate }}</span>
</div> </div>
@@ -32,15 +31,21 @@
<!-- Energy --> <!-- Energy -->
<div class="bg-blue-50 rounded p-2"> <div class="bg-blue-50 rounded p-2">
<div class="text-blue-600 font-medium">Energy</div> <div class="text-blue-600 font-medium">Energy</div>
<div class="text-blue-900">{{ room.energy.current.toFixed(2) }} {{ room.energy.unit }}</div> <div class="text-blue-900">
<div class="text-blue-600 text-xs">Total: {{ room.energy.total.toFixed(2) }}</div> {{ room.energy!.current.toFixed(2) }} {{ room.energy!.unit }}
</div>
<div class="text-blue-600 text-xs">Total: {{ room.energy!.total.toFixed(2) }}</div>
</div> </div>
<!-- CO2 --> <!-- CO2 -->
<div class="rounded p-2" :class="getCO2BackgroundColor(room.co2.status)"> <div class="rounded p-2" :class="getCO2BackgroundColor(room.co2!.status)">
<div class="font-medium" :class="getCO2TextColor(room.co2.status)">CO2</div> <div class="font-medium" :class="getCO2TextColor(room.co2!.status)">CO2</div>
<div :class="getCO2TextColor(room.co2.status)">{{ Math.round(room.co2.current) }} {{ room.co2.unit }}</div> <div :class="getCO2TextColor(room.co2!.status)">
<div class="text-xs" :class="getCO2TextColor(room.co2.status)">{{ room.co2.status.toUpperCase() }}</div> {{ 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>
</div> </div>
@@ -53,7 +58,10 @@
</div> </div>
<!-- Summary Stats --> <!-- 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>
<div class="font-medium text-gray-900">{{ roomsList.length }}</div> <div class="font-medium text-gray-900">{{ roomsList.length }}</div>
<div class="text-gray-500">Rooms</div> <div class="text-gray-500">Rooms</div>
@@ -77,47 +85,63 @@ import { useRoomStore } from '@/stores/room'
const roomStore = useRoomStore() const roomStore = useRoomStore()
const roomsList = computed(() => { const roomsList = computed(() => {
return Array.from(roomStore.roomsData.values()).sort((a, b) => return Array.from(roomStore.roomsData.values())
a.room.localeCompare(b.room) .filter((room) => room.energy && room.co2) // Only show rooms with both metrics
) .sort((a, b) => a.room.localeCompare(b.room))
}) })
const totalEnergy = computed(() => { const totalEnergy = computed(() => {
return roomsList.value.reduce((sum, room) => sum + room.energy.current, 0) return roomsList.value.reduce((sum, room) => sum + (room.energy?.current || 0), 0)
}) })
const averageCO2 = computed(() => { const averageCO2 = computed(() => {
if (roomsList.value.length === 0) return 0 if (roomsList.value.length === 0) return 0
return roomsList.value.reduce((sum, room) => sum + room.co2.current, 0) / roomsList.value.length const total = roomsList.value.reduce((sum, room) => sum + (room.co2?.current || 0), 0)
return total / roomsList.value.length
}) })
const getCO2StatusColor = (status: string) => { const getCO2StatusColor = (status: string) => {
switch (status) { switch (status) {
case 'good': return 'bg-green-500' case 'good':
case 'moderate': return 'bg-yellow-500' return 'bg-green-500'
case 'poor': return 'bg-orange-500' case 'moderate':
case 'critical': return 'bg-red-500' return 'bg-yellow-500'
default: return 'bg-gray-500' case 'poor':
return 'bg-orange-500'
case 'critical':
return 'bg-red-500'
default:
return 'bg-gray-500'
} }
} }
const getCO2BackgroundColor = (status: string) => { const getCO2BackgroundColor = (status: string) => {
switch (status) { switch (status) {
case 'good': return 'bg-green-50' case 'good':
case 'moderate': return 'bg-yellow-50' return 'bg-green-50'
case 'poor': return 'bg-orange-50' case 'moderate':
case 'critical': return 'bg-red-50' return 'bg-yellow-50'
default: return 'bg-gray-50' case 'poor':
return 'bg-orange-50'
case 'critical':
return 'bg-red-50'
default:
return 'bg-gray-50'
} }
} }
const getCO2TextColor = (status: string) => { const getCO2TextColor = (status: string) => {
switch (status) { switch (status) {
case 'good': return 'text-green-700' case 'good':
case 'moderate': return 'text-yellow-700' return 'text-green-700'
case 'poor': return 'text-orange-700' case 'moderate':
case 'critical': return 'text-red-700' return 'text-yellow-700'
default: return 'text-gray-700' case 'poor':
return 'text-orange-700'
case 'critical':
return 'text-red-700'
default:
return 'text-gray-700'
} }
} }

View File

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

View File

@@ -9,9 +9,7 @@
Token expires in: {{ formatTimeUntilExpiry() }} Token expires in: {{ formatTimeUntilExpiry() }}
</div> </div>
<div v-if="authStore.error" class="auth-status__error"> <div v-if="authStore.error" class="auth-status__error">Auth Error: {{ authStore.error }}</div>
Auth Error: {{ authStore.error }}
</div>
<button <button
v-if="!authStore.isAuthenticated" v-if="!authStore.isAuthenticated"
@@ -33,13 +31,13 @@ const authStore = useAuthStore()
const authStatusClass = computed(() => ({ const authStatusClass = computed(() => ({
'auth-status--authenticated': authStore.isAuthenticated, 'auth-status--authenticated': authStore.isAuthenticated,
'auth-status--error': !authStore.isAuthenticated || authStore.error, 'auth-status--error': !authStore.isAuthenticated || authStore.error,
'auth-status--loading': authStore.isLoading 'auth-status--loading': authStore.isLoading,
})) }))
const statusDotClass = computed(() => ({ const statusDotClass = computed(() => ({
'auth-status__dot--green': authStore.isAuthenticated && !authStore.error, 'auth-status__dot--green': authStore.isAuthenticated && !authStore.error,
'auth-status__dot--red': !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(() => { const statusText = computed(() => {

View File

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

View File

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

View File

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

View File

@@ -39,15 +39,15 @@
<input <input
v-model.number="numericValue" v-model.number="numericValue"
type="range" type="range"
:min="action.parameters.min" :min="action.parameters?.min"
:max="action.parameters.max" :max="action.parameters?.max"
:step="action.parameters.step" :step="action.parameters?.step"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer slider" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer slider"
/> />
<div class="flex justify-between text-sm text-gray-600"> <div class="flex justify-between text-sm text-gray-600">
<span>{{ action.parameters.min }}</span> <span>{{ action.parameters?.min }}</span>
<span class="font-medium">{{ numericValue }}{{ getUnit() }}</span> <span class="font-medium">{{ numericValue }}{{ getUnit() }}</span>
<span>{{ action.parameters.max }}</span> <span>{{ action.parameters?.max }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -131,14 +131,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import type { SensorDevice, SensorAction } from '@/services'
interface ActionParameters {
value?: number | string | boolean
[key: string]: unknown
}
const props = defineProps<{ const props = defineProps<{
sensor: any sensor: SensorDevice
action: any action: SensorAction
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
execute: [sensorId: string, actionId: string, parameters: any] execute: [sensorId: string, actionId: string, parameters: ActionParameters]
close: [] close: []
}>() }>()
@@ -153,9 +159,9 @@ watch(
(action) => { (action) => {
if (action) { if (action) {
if (action.parameters?.min !== undefined) { if (action.parameters?.min !== undefined) {
numericValue.value = action.parameters.min numericValue.value = action.parameters?.min ?? 0
} }
if (action.parameters?.options?.length > 0) { if (action.parameters?.options && action.parameters.options.length > 0) {
selectedOption.value = action.parameters.options[0] selectedOption.value = action.parameters.options[0]
} }
toggleValue.value = false toggleValue.value = false
@@ -182,7 +188,7 @@ const getUnit = () => {
const executeAction = async () => { const executeAction = async () => {
isExecuting.value = true isExecuting.value = true
const parameters: any = {} const parameters: ActionParameters = {}
if (props.action.type === 'adjust') { if (props.action.type === 'adjust') {
if (hasNumericRange.value) { if (hasNumericRange.value) {
@@ -195,7 +201,7 @@ const executeAction = async () => {
} }
try { try {
emit('execute', props.sensor.id, props.action.id, parameters) emit('execute', props.sensor.sensor_id, props.action.id, parameters)
} catch (error) { } catch (error) {
console.error('Failed to execute action:', error) console.error('Failed to execute action:', error)
} finally { } finally {

View File

@@ -10,7 +10,12 @@
class="text-gray-400 hover:text-gray-600 transition-colors" 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"> <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> </svg>
</button> </button>
</div> </div>
@@ -53,16 +58,14 @@
</div> </div>
<div class="grid grid-cols-1 gap-3"> <div class="grid grid-cols-1 gap-3">
<div <div v-for="room in roomsWithStats" :key="room.name" class="bg-gray-50 rounded-lg p-4">
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 items-center justify-between">
<div class="flex-1"> <div class="flex-1">
<div class="flex items-center gap-3 mb-2"> <div class="flex items-center gap-3 mb-2">
<h4 class="font-medium text-gray-900">{{ room.name }}</h4> <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 {{ room.sensorCount }} sensors
</span> </span>
</div> </div>
@@ -78,14 +81,21 @@
> >
{{ type }} {{ type }}
</span> </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> </div>
<div> <div>
<span class="text-gray-600">Energy:</span> <span class="text-gray-600">Energy:</span>
<div class="font-medium" :class="room.hasMetrics ? 'text-gray-900' : 'text-gray-400'"> <div
{{ room.hasMetrics ? room.energyConsumption.toFixed(2) + ' kWh' : 'No data' }} class="font-medium"
:class="room.hasMetrics ? 'text-gray-900' : 'text-gray-400'"
>
{{
room.hasMetrics ? room.energyConsumption.toFixed(2) + ' kWh' : 'No data'
}}
</div> </div>
</div> </div>
@@ -143,14 +153,19 @@
</div> </div>
<!-- Delete Confirmation Modal --> <!-- 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"> <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> <h3 class="text-lg font-semibold text-gray-900 mb-2">Delete Room</h3>
<p class="text-gray-600 mb-4"> <p class="text-gray-600 mb-4">
Are you sure you want to delete <strong>"{{ roomToDelete }}"</strong>? Are you sure you want to delete <strong>"{{ roomToDelete }}"</strong>?
{{ getRoomStats(roomToDelete).sensorCount > 0 {{
? `This will unassign ${getRoomStats(roomToDelete).sensorCount} sensor(s).` getRoomStats(roomToDelete).sensorCount > 0
: 'This action cannot be undone.' }} ? `This will unassign ${getRoomStats(roomToDelete).sensorCount} sensor(s).`
: 'This action cannot be undone.'
}}
</p> </p>
<div class="flex gap-3"> <div class="flex gap-3">
<button <button

View File

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

View File

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

View File

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

View File

@@ -25,7 +25,10 @@ export const authApi = {
}, },
async saveToken(token: string): Promise<{ token: string; datetime: string; active: boolean }> { 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> { async validateToken(token: string): Promise<TokenValidation> {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,220 +1,302 @@
<template> <template>
<div class="min-h-screen bg-gray-50"> <div class="space-y-6">
<div class="px-4 py-6 mx-auto max-w-7xl sm:px-6 lg:px-8"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
<div class="mb-8"> <div>
<h1 class="text-3xl font-bold text-gray-900">API Dashboard</h1> <h1 class="text-2xl font-bold text-gray-900">Analytics</h1>
<p class="text-gray-600 mt-2">Real-time data from backend APIs</p> <p class="text-gray-600">Manage sensors, assign rooms, and control device actions</p>
</div> </div>
</div>
<!-- API Status Section --> <div class="mb-8">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8"> <h1 class="text-3xl font-bold text-gray-900">API Dashboard</h1>
<div class="bg-white rounded-lg shadow p-6"> <p class="text-gray-600 mt-2">Real-time data from backend APIs</p>
<div class="flex items-center"> </div>
<div class="p-2 bg-blue-100 rounded-lg"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <div class="bg-white rounded-lg shadow p-6">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path> <div class="flex items-center">
</svg> <div class="p-2 bg-blue-100 rounded-lg">
</div> <svg
<div class="ml-4"> class="w-6 h-6 text-blue-600"
<p class="text-sm font-medium text-gray-600">System Status</p> fill="none"
<p class="text-lg font-semibold" :class="healthStatus?.status === 'healthy' ? 'text-green-600' : 'text-red-600'"> stroke="currentColor"
{{ healthStatus?.status || 'Unknown' }} viewBox="0 0 24 24"
</p> >
</div> <path
</div> stroke-linecap="round"
</div> stroke-linejoin="round"
stroke-width="2"
<div class="bg-white rounded-lg shadow p-6"> d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
<div class="flex items-center"> ></path>
<div class="p-2 bg-green-100 rounded-lg">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">Total Sensors</p>
<p class="text-lg font-semibold text-gray-900">{{ sensorStore.totalSensors }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center">
<div class="p-2 bg-yellow-100 rounded-lg">
<svg class="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">Active Sensors</p>
<p class="text-lg font-semibold text-gray-900">{{ sensorStore.activeSensors }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center">
<div class="p-2 bg-purple-100 rounded-lg">
<svg class="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">Total Readings</p>
<p class="text-lg font-semibold text-gray-900">{{ formatNumber(sensorStore.totalReadings) }}</p>
</div>
</div>
</div>
</div>
<!-- Loading States -->
<div v-if="isLoading" class="bg-white rounded-lg shadow p-6 mb-8">
<div class="flex items-center justify-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p class="ml-3 text-gray-600">Loading API data...</p>
</div>
</div>
<!-- Error States -->
<div v-if="apiError" class="bg-red-50 border border-red-200 rounded-lg p-4 mb-8">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg> </svg>
</div> </div>
<div class="ml-3"> <div class="ml-4">
<h3 class="text-sm font-medium text-red-800">API Error</h3> <p class="text-sm font-medium text-gray-600">System Status</p>
<p class="mt-1 text-sm text-red-700">{{ apiError }}</p> <p
class="text-lg font-semibold"
:class="healthStatus?.status === 'healthy' ? 'text-green-600' : 'text-red-600'"
>
{{ healthStatus?.status || 'Unknown' }}
</p>
</div> </div>
</div> </div>
</div> </div>
<!-- Energy Metrics Summary --> <div class="bg-white rounded-lg shadow p-6">
<div class="bg-white rounded-lg shadow p-6 mb-8"> <div class="flex items-center">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Energy Metrics</h2> <div class="p-2 bg-green-100 rounded-lg">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6"> <svg
<div> class="w-6 h-6 text-green-600"
<p class="text-sm font-medium text-gray-600 mb-2">Current Energy Consumption</p> fill="none"
<p class="text-2xl font-bold text-blue-600"> stroke="currentColor"
{{ energyStore.currentEnergyValue.toFixed(2) }} kWh viewBox="0 0 24 24"
</p> >
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
></path>
</svg>
</div> </div>
<div> <div class="ml-4">
<p class="text-sm font-medium text-gray-600 mb-2">Average Energy Usage</p> <p class="text-sm font-medium text-gray-600">Total Sensors</p>
<p class="text-2xl font-bold text-green-600"> <p class="text-lg font-semibold text-gray-900">{{ sensorStore.totalSensors }}</p>
{{ energyStore.averageEnergyUsage.toFixed(2) }} kWh
</p>
</div>
<div>
<p class="text-sm font-medium text-gray-600 mb-2">Total Consumption</p>
<p class="text-2xl font-bold text-purple-600">
{{ energyStore.currentConsumption.toFixed(2) }} kWh
</p>
<p class="text-xs text-gray-500 mt-1">Cumulative</p>
</div> </div>
</div> </div>
</div> </div>
<!-- Sensors Section --> <div class="bg-white rounded-lg shadow p-6">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8"> <div class="flex items-center">
<div class="bg-white rounded-lg shadow"> <div class="p-2 bg-yellow-100 rounded-lg">
<div class="px-6 py-4 border-b border-gray-200"> <svg
<h2 class="text-lg font-semibold text-gray-900">API Sensors</h2> class="w-6 h-6 text-yellow-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
</div> </div>
<div class="p-6"> <div class="ml-4">
<div v-if="apiSensors.length === 0" class="text-center text-gray-500 py-8"> <p class="text-sm font-medium text-gray-600">Active Sensors</p>
No sensors found from API <p class="text-lg font-semibold text-gray-900">{{ sensorStore.activeSensors }}</p>
</div> </div>
<div v-else class="space-y-3"> </div>
<div v-for="sensor in apiSensors" :key="sensor.sensor_id" </div>
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div> <div class="bg-white rounded-lg shadow p-6">
<p class="font-medium text-gray-900">{{ sensor.sensor_id }}</p> <div class="flex items-center">
<p class="text-sm text-gray-500">{{ sensor.room || 'No room assigned' }}</p> <div class="p-2 bg-purple-100 rounded-lg">
<p class="text-xs text-gray-400">{{ sensor.sensor_type }} {{ sensor.total_readings }} readings</p> <svg
</div> class="w-6 h-6 text-purple-600"
<div class="flex items-center"> fill="none"
<span :class="[ stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
></path>
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">Total Readings</p>
<p class="text-lg font-semibold text-gray-900">
{{ formatNumber(sensorStore.totalReadings) }}
</p>
</div>
</div>
</div>
</div>
<!-- Loading States -->
<div v-if="isLoading" class="bg-white rounded-lg shadow p-6 mb-8">
<div class="flex items-center justify-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p class="ml-3 text-gray-600">Loading API data...</p>
</div>
</div>
<!-- Error States -->
<div v-if="apiError" class="bg-red-50 border border-red-200 rounded-lg p-4 mb-8">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">API Error</h3>
<p class="mt-1 text-sm text-red-700">{{ apiError }}</p>
</div>
</div>
</div>
<!-- Energy Metrics Summary -->
<div class="bg-white rounded-lg shadow p-6 mb-8">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Energy Metrics</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<p class="text-sm font-medium text-gray-600 mb-2">Current Energy Consumption</p>
<p class="text-2xl font-bold text-blue-600">
{{ energyStore.currentEnergyValue.toFixed(2) }} kWh
</p>
</div>
<div>
<p class="text-sm font-medium text-gray-600 mb-2">Average Energy Usage</p>
<p class="text-2xl font-bold text-green-600">
{{ energyStore.averageEnergyUsage.toFixed(2) }} kWh
</p>
</div>
<div>
<p class="text-sm font-medium text-gray-600 mb-2">Total Consumption</p>
<p class="text-2xl font-bold text-purple-600">
{{ energyStore.currentConsumption.toFixed(2) }} kWh
</p>
<p class="text-xs text-gray-500 mt-1">Cumulative</p>
</div>
</div>
</div>
<!-- Sensors Section -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
<div class="bg-white rounded-lg shadow">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">API Sensors</h2>
</div>
<div class="p-6">
<div v-if="apiSensors.length === 0" class="text-center text-gray-500 py-8">
No sensors found from API
</div>
<div v-else class="space-y-3">
<div
v-for="sensor in apiSensors"
:key="sensor.sensor_id"
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div>
<p class="font-medium text-gray-900">{{ sensor.sensor_id }}</p>
<p class="text-sm text-gray-500">{{ sensor.room || 'No room assigned' }}</p>
<p class="text-xs text-gray-400">
{{ sensor.sensor_type }} {{ sensor.total_readings }} readings
</p>
</div>
<div class="flex items-center">
<span
:class="[
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium', 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
sensor.status === 'online' ? 'bg-green-100 text-green-800' : sensor.status === 'online'
sensor.status === 'offline' ? 'bg-red-100 text-red-800' : ? 'bg-green-100 text-green-800'
'bg-yellow-100 text-yellow-800' : sensor.status === 'offline'
]"> ? 'bg-red-100 text-red-800'
{{ sensor.status }} : 'bg-yellow-100 text-yellow-800',
]"
>
{{ sensor.status }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- Rooms Section -->
<div class="bg-white rounded-lg shadow">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">API Rooms</h2>
</div>
<div class="p-6">
<div v-if="apiRooms.length === 0" class="text-center text-gray-500 py-8">
No rooms found from API
</div>
<div v-else class="space-y-3">
<div v-for="room in apiRooms" :key="room.room" class="p-3 bg-gray-50 rounded-lg">
<div class="flex items-center justify-between mb-2">
<p class="font-medium text-gray-900">{{ room.room }}</p>
<span class="text-sm text-gray-500">{{ room.sensor_count }} sensors</span>
</div>
<div class="text-xs text-gray-600 space-y-1">
<p v-if="room.sensor_types">Types: {{ room.sensor_types.join(', ') }}</p>
<div v-if="room.latest_metrics">
<span v-if="room.latest_metrics.energy" class="mr-4">
Energy: {{ room.latest_metrics.energy.current }}
{{ room.latest_metrics.energy.unit }}
</span>
<span v-if="room.latest_metrics.co2">
CO2: {{ room.latest_metrics.co2.current }} {{ room.latest_metrics.co2.unit }}
</span> </span>
</div> </div>
</div> <p v-else class="text-gray-400 italic">No metrics available</p>
</div>
</div>
</div>
<!-- Rooms Section -->
<div class="bg-white rounded-lg shadow">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">API Rooms</h2>
</div>
<div class="p-6">
<div v-if="apiRooms.length === 0" class="text-center text-gray-500 py-8">
No rooms found from API
</div>
<div v-else class="space-y-3">
<div v-for="room in apiRooms" :key="room.name || room.room"
class="p-3 bg-gray-50 rounded-lg">
<div class="flex items-center justify-between mb-2">
<p class="font-medium text-gray-900">{{ room.name || room.room }}</p>
<span class="text-sm text-gray-500">{{ room.sensor_count }} sensors</span>
</div>
<div class="text-xs text-gray-600 space-y-1">
<p v-if="room.sensor_types">Types: {{ room.sensor_types.join(', ') }}</p>
<div v-if="room.latest_metrics">
<span v-if="room.latest_metrics.energy" class="mr-4">
Energy: {{ room.latest_metrics.energy.current }} {{ room.latest_metrics.energy.unit }}
</span>
<span v-if="room.latest_metrics.co2">
CO2: {{ room.latest_metrics.co2.current }} {{ room.latest_metrics.co2.unit }}
</span>
</div>
<p v-else class="text-gray-400 italic">No metrics available</p>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="bg-white rounded-lg shadow p-6"> <div class="bg-white rounded-lg shadow p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">API Actions</h2> <h2 class="text-lg font-semibold text-gray-900 mb-4">API Actions</h2>
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
<button @click="refreshAllData" <button
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" @click="refreshAllData"
:disabled="isLoading"> class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
<svg v-if="!isLoading" class="-ml-1 mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> :disabled="isLoading"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path> >
</svg> <svg
<div v-else class="animate-spin -ml-1 mr-2 h-4 w-4 border-2 border-white border-t-transparent rounded-full"></div> v-if="!isLoading"
Refresh All Data class="-ml-1 mr-2 h-4 w-4"
</button> fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
></path>
</svg>
<div
v-else
class="animate-spin -ml-1 mr-2 h-4 w-4 border-2 border-white border-t-transparent rounded-full"
></div>
Refresh All Data
</button>
<button @click="fetchSensorsOnly" <button
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" @click="fetchSensorsOnly"
:disabled="isLoading"> class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
Fetch Sensors :disabled="isLoading"
</button> >
Fetch Sensors
</button>
<button @click="fetchRoomsOnly" <button
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" @click="fetchRoomsOnly"
:disabled="isLoading"> class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
Fetch Rooms :disabled="isLoading"
</button> >
Fetch Rooms
</button>
<button @click="fetchAnalyticsOnly" <button
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" @click="fetchAnalyticsOnly"
:disabled="isLoading"> class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
Fetch Analytics :disabled="isLoading"
</button> >
</div> Fetch Analytics
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -240,10 +322,10 @@ const healthStatus = computed(() => analyticsStore.healthStatus)
// Combined loading and error states // Combined loading and error states
const isLoading = computed( const isLoading = computed(
() => sensorStore.apiLoading || roomStore.apiLoading || analyticsStore.apiLoading () => sensorStore.apiLoading || roomStore.apiLoading || analyticsStore.apiLoading,
) )
const apiError = computed( const apiError = computed(
() => sensorStore.apiError || roomStore.apiError || analyticsStore.apiError () => sensorStore.apiError || roomStore.apiError || analyticsStore.apiError,
) )
// Helper functions // Helper functions

View File

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

View File

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

View File

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

View File

@@ -28,9 +28,11 @@
<button <button
@click="activeSection = section.id" @click="activeSection = section.id"
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left transition-colors" class="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left transition-colors"
:class="activeSection === section.id :class="
? 'bg-blue-100 text-blue-700' activeSection === section.id
: 'text-gray-700 hover:bg-gray-100'" ? 'bg-blue-100 text-blue-700'
: 'text-gray-700 hover:bg-gray-100'
"
> >
<span class="text-lg">{{ section.icon }}</span> <span class="text-lg">{{ section.icon }}</span>
<div> <div>
@@ -46,7 +48,10 @@
<!-- Settings Content --> <!-- Settings Content -->
<div class="lg:col-span-2 space-y-6"> <div class="lg:col-span-2 space-y-6">
<!-- Appearance Settings --> <!-- 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"> <div class="p-6 border-b border-gray-100">
<h3 class="text-lg font-semibold text-gray-900">Appearance</h3> <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> <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" :key="theme.value"
@click="settingsStore.updateSetting('theme', 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="relative p-4 border-2 rounded-lg cursor-pointer transition-all hover:border-blue-300"
:class="settingsStore.settings.theme === theme.value :class="
? 'border-blue-500 bg-blue-50' settingsStore.settings.theme === theme.value
: 'border-gray-200'" ? 'border-blue-500 bg-blue-50'
: 'border-gray-200'
"
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -72,7 +79,11 @@
</div> </div>
<div v-if="settingsStore.settings.theme === theme.value" class="text-blue-600"> <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"> <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> </svg>
</div> </div>
</div> </div>
@@ -89,9 +100,11 @@
:key="mode.value" :key="mode.value"
@click="settingsStore.updateSetting('ui.navigationMode', 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="relative p-4 border-2 rounded-lg cursor-pointer transition-all hover:border-blue-300"
:class="settingsStore.settings.ui.navigationMode === mode.value :class="
? 'border-blue-500 bg-blue-50' settingsStore.settings.ui.navigationMode === mode.value
: 'border-gray-200'" ? 'border-blue-500 bg-blue-50'
: 'border-gray-200'
"
> >
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
@@ -101,9 +114,16 @@
<div class="text-sm text-gray-600 mt-1">{{ mode.description }}</div> <div class="text-sm text-gray-600 mt-1">{{ mode.description }}</div>
</div> </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"> <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> </svg>
</div> </div>
</div> </div>
@@ -119,12 +139,21 @@
<div class="text-sm text-gray-600">Reduce spacing and padding</div> <div class="text-sm text-gray-600">Reduce spacing and padding</div>
</div> </div>
<button <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="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="settingsStore.settings.ui.compactMode ? 'bg-blue-600' : 'bg-gray-200'" :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" <span
:class="settingsStore.settings.ui.compactMode ? 'translate-x-6' : 'translate-x-1'"></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> </button>
</div> </div>
@@ -134,12 +163,21 @@
<div class="text-sm text-gray-600">Enable smooth transitions</div> <div class="text-sm text-gray-600">Enable smooth transitions</div>
</div> </div>
<button <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="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="settingsStore.settings.ui.showAnimations ? 'bg-blue-600' : 'bg-gray-200'" :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" <span
:class="settingsStore.settings.ui.showAnimations ? 'translate-x-6' : 'translate-x-1'"></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> </button>
</div> </div>
</div> </div>
@@ -147,7 +185,10 @@
</div> </div>
<!-- Data & Sync Settings --> <!-- 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"> <div class="p-6 border-b border-gray-100">
<h3 class="text-lg font-semibold text-gray-900">Data & Synchronization</h3> <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> <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 class="text-sm text-gray-600">Automatically refresh data periodically</div>
</div> </div>
<button <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="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="settingsStore.settings.ui.autoRefresh ? 'bg-blue-600' : 'bg-gray-200'" :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" <span
:class="settingsStore.settings.ui.autoRefresh ? 'translate-x-6' : 'translate-x-1'"></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> </button>
</div> </div>
@@ -178,10 +226,17 @@
min="1" min="1"
max="60" max="60"
:value="settingsStore.settings.ui.refreshInterval" :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" 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 {{ settingsStore.settings.ui.refreshInterval }}s
</div> </div>
</div> </div>
@@ -196,43 +251,62 @@
type="text" type="text"
placeholder="ws://localhost:8000/ws" 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="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 <button
@click="updateWebSocketUrl" @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" 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 Update
</button> </button>
</div> </div>
<p v-if="websocketUrlError" class="text-red-600 text-sm mt-1">{{ websocketUrlError }}</p> <p v-if="websocketUrlError" class="text-red-600 text-sm mt-1">
<p v-else class="text-gray-500 text-sm mt-1">Current: {{ settingsStore.settings.websocketUrl }}</p> {{ websocketUrlError }}
</p>
<p v-else class="text-gray-500 text-sm mt-1">
Current: {{ settingsStore.settings.websocketUrl }}
</p>
</div> </div>
<!-- Auto Connect --> <!-- Auto Connect -->
<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>
<div class="font-medium text-gray-900">Auto Connect</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> </div>
<button <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="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="settingsStore.settings.autoConnect ? 'bg-blue-600' : 'bg-gray-200'" :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" <span
:class="settingsStore.settings.autoConnect ? 'translate-x-6' : 'translate-x-1'"></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> </button>
</div> </div>
</div> </div>
</div> </div>
<!-- Notifications Settings --> <!-- 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"> <div class="p-6 border-b border-gray-100">
<h3 class="text-lg font-semibold text-gray-900">Notifications</h3> <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>
<div class="p-6 space-y-6"> <div class="p-6 space-y-6">
<!-- Enable Notifications --> <!-- Enable Notifications -->
@@ -242,34 +316,62 @@
<div class="text-sm text-gray-600">Receive system alerts and updates</div> <div class="text-sm text-gray-600">Receive system alerts and updates</div>
</div> </div>
<button <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="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" <span
:class="settingsStore.settings.notifications.enabled ? 'translate-x-6' : 'translate-x-1'"></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> </button>
</div> </div>
<div v-if="settingsStore.settings.notifications.enabled" class="space-y-4"> <div v-if="settingsStore.settings.notifications.enabled" class="space-y-4">
<!-- Notification Types --> <!-- Notification Types -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <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>
<div class="font-medium text-gray-900">Sound Alerts</div> <div class="font-medium text-gray-900">Sound Alerts</div>
<div class="text-sm text-gray-600">Play sound for notifications</div> <div class="text-sm text-gray-600">Play sound for notifications</div>
</div> </div>
<button <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="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" <span
:class="settingsStore.settings.notifications.sound ? 'translate-x-6' : 'translate-x-1'"></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> </button>
</div> </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>
<div class="font-medium text-gray-900">Desktop Notifications</div> <div class="font-medium text-gray-900">Desktop Notifications</div>
<div class="text-sm text-gray-600">Show browser notifications</div> <div class="text-sm text-gray-600">Show browser notifications</div>
@@ -277,10 +379,18 @@
<button <button
@click="toggleDesktopNotifications" @click="toggleDesktopNotifications"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors" 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" <span
:class="settingsStore.settings.notifications.desktop ? 'translate-x-6' : 'translate-x-1'"></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> </button>
</div> </div>
</div> </div>
@@ -292,12 +402,27 @@
<div class="text-sm text-gray-600">Only show high-priority notifications</div> <div class="text-sm text-gray-600">Only show high-priority notifications</div>
</div> </div>
<button <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="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" <span
:class="settingsStore.settings.notifications.criticalOnly ? 'translate-x-6' : 'translate-x-1'"></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> </button>
</div> </div>
</div> </div>
@@ -305,7 +430,10 @@
</div> </div>
<!-- Advanced Settings --> <!-- 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"> <div class="p-6 border-b border-gray-100">
<h3 class="text-lg font-semibold text-gray-900">Advanced</h3> <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> <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 class="text-sm text-gray-600">Enable debug logs and development features</div>
</div> </div>
<button <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="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="settingsStore.settings.developerMode ? 'bg-blue-600' : 'bg-gray-200'" :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" <span
:class="settingsStore.settings.developerMode ? 'translate-x-6' : 'translate-x-1'"></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> </button>
</div> </div>
@@ -357,7 +492,9 @@
</button> </button>
</div> </div>
<p v-if="importError" class="text-red-600 text-sm mt-1">{{ importError }}</p> <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> </div>
</div> </div>
@@ -366,11 +503,15 @@
</div> </div>
<!-- Reset Confirmation Dialog --> <!-- 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"> <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> <h3 class="text-lg font-semibold text-gray-900 mb-2">Reset All Settings</h3>
<p class="text-gray-600 mb-4"> <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> </p>
<div class="flex gap-3"> <div class="flex gap-3">
<button <button
@@ -412,26 +553,26 @@ const settingSections = [
id: 'appearance', id: 'appearance',
name: 'Appearance', name: 'Appearance',
description: 'Theme & UI', description: 'Theme & UI',
icon: '🎨' icon: '🎨',
}, },
{ {
id: 'data', id: 'data',
name: 'Data & Sync', name: 'Data & Sync',
description: 'Connection & refresh', description: 'Connection & refresh',
icon: '🔄' icon: '🔄',
}, },
{ {
id: 'notifications', id: 'notifications',
name: 'Notifications', name: 'Notifications',
description: 'Alerts & sounds', description: 'Alerts & sounds',
icon: '🔔' icon: '🔔',
}, },
{ {
id: 'advanced', id: 'advanced',
name: 'Advanced', name: 'Advanced',
description: 'Developer options', description: 'Developer options',
icon: '⚙️' icon: '⚙️',
} },
] ]
// Methods // Methods
@@ -439,7 +580,7 @@ const formatTime = (date: Date) => {
return new Intl.DateTimeFormat('en-US', { return new Intl.DateTimeFormat('en-US', {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
second: '2-digit' second: '2-digit',
}).format(date) }).format(date)
} }

View File

@@ -13,4 +13,19 @@ export default defineConfig({
'@': fileURLToPath(new URL('./src', import.meta.url)), '@': 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,
},
},
},
}) })