Compare commits
15 Commits
06f7537422
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a480466dd0 | ||
|
|
c3364cc422 | ||
|
|
4b4338fb91 | ||
|
|
1c7288b778 | ||
|
|
7accc66710 | ||
|
|
37ccef2f12 | ||
|
|
a94e1b06b2 | ||
|
|
9a25170b27 | ||
|
|
3ecd0ab2c4 | ||
|
|
544c1a3a4f | ||
|
|
e2cf2bc782 | ||
|
|
f96456ed29 | ||
|
|
a518665673 | ||
|
|
cb659c93bb | ||
|
|
6ee4801071 |
5
.env
5
.env
@@ -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
1
.gitattributes
vendored
@@ -1 +0,0 @@
|
||||
* text=auto eol=lf
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -18,7 +18,7 @@ coverage
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
.vscode/
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
@@ -28,3 +28,6 @@ coverage
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
.env
|
||||
CLAUDE.md
|
||||
9
.vscode/extensions.json
vendored
9
.vscode/extensions.json
vendored
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"vitest.explorer",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"EditorConfig.EditorConfig",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
15
Tasks.md
Normal file
15
Tasks.md
Normal 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
|
||||
@@ -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
|
||||
@@ -10,7 +10,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<bottom-nav />
|
||||
<!-- <app-footer /> -->
|
||||
</div>
|
||||
@@ -22,4 +21,3 @@ import AppHeader from './components/common/AppHeader.vue'
|
||||
import BottomNav from './components/common/BottomNav.vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
</script>
|
||||
|
||||
|
||||
@@ -30,7 +30,12 @@
|
||||
}
|
||||
|
||||
// Grid utilities
|
||||
@mixin grid-responsive($columns-mobile: 1, $columns-tablet: 2, $columns-desktop: 3, $gap: $spacing-md) {
|
||||
@mixin grid-responsive(
|
||||
$columns-mobile: 1,
|
||||
$columns-tablet: 2,
|
||||
$columns-desktop: 3,
|
||||
$gap: $spacing-md
|
||||
) {
|
||||
display: grid;
|
||||
gap: $gap;
|
||||
grid-template-columns: repeat($columns-mobile, 1fr);
|
||||
|
||||
@@ -66,9 +66,15 @@ $radius-2xl: 1.5rem;
|
||||
|
||||
// Shadows
|
||||
$shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
$shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
$shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
$shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
$shadow-md:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
$shadow-lg:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
$shadow-xl:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
|
||||
// Transitions
|
||||
$transition-fast: 150ms ease-in-out;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
// Typography Styles
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -40,17 +45,41 @@ p {
|
||||
}
|
||||
|
||||
// Text utilities
|
||||
.text-xs { font-size: 0.75rem; }
|
||||
.text-sm { font-size: 0.875rem; }
|
||||
.text-base { font-size: 1rem; }
|
||||
.text-lg { font-size: 1.125rem; }
|
||||
.text-xl { font-size: 1.25rem; }
|
||||
.text-2xl { font-size: 1.5rem; }
|
||||
.text-xs {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.text-sm {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.text-base {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.text-lg {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
.text-xl {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.text-2xl {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.font-medium { font-weight: 500; }
|
||||
.font-semibold { font-weight: 600; }
|
||||
.font-bold { font-weight: 700; }
|
||||
.font-medium {
|
||||
font-weight: 500;
|
||||
}
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
.font-bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.text-center { text-align: center; }
|
||||
.text-left { text-align: left; }
|
||||
.text-right { text-align: right; }
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@@ -2,14 +2,28 @@
|
||||
.grid {
|
||||
display: grid;
|
||||
|
||||
&--1 { grid-template-columns: 1fr; }
|
||||
&--2 { grid-template-columns: repeat(2, 1fr); }
|
||||
&--3 { grid-template-columns: repeat(3, 1fr); }
|
||||
&--4 { grid-template-columns: repeat(4, 1fr); }
|
||||
&--1 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
&--2 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
&--3 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
&--4 {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
&--gap-2 { gap: $spacing-sm; }
|
||||
&--gap-4 { gap: $spacing-md; }
|
||||
&--gap-6 { gap: $spacing-lg; }
|
||||
&--gap-2 {
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
&--gap-4 {
|
||||
gap: $spacing-md;
|
||||
}
|
||||
&--gap-6 {
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
|
||||
// Responsive grids
|
||||
&--responsive-simple {
|
||||
@@ -45,8 +59,16 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&--gap-1 { gap: $spacing-xs; }
|
||||
&--gap-2 { gap: $spacing-sm; }
|
||||
&--gap-3 { gap: $spacing-md; }
|
||||
&--gap-4 { gap: $spacing-lg; }
|
||||
&--gap-1 {
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
&--gap-2 {
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
&--gap-3 {
|
||||
gap: $spacing-md;
|
||||
}
|
||||
&--gap-4 {
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
}
|
||||
@@ -12,98 +12,226 @@
|
||||
}
|
||||
|
||||
// Spacing utilities
|
||||
.space-y-2 > * + * { margin-top: $spacing-sm; }
|
||||
.space-y-3 > * + * { margin-top: $spacing-md; }
|
||||
.space-y-4 > * + * { margin-top: $spacing-lg; }
|
||||
.space-y-6 > * + * { margin-top: $spacing-xl; }
|
||||
.space-y-2 > * + * {
|
||||
margin-top: $spacing-sm;
|
||||
}
|
||||
.space-y-3 > * + * {
|
||||
margin-top: $spacing-md;
|
||||
}
|
||||
.space-y-4 > * + * {
|
||||
margin-top: $spacing-lg;
|
||||
}
|
||||
.space-y-6 > * + * {
|
||||
margin-top: $spacing-xl;
|
||||
}
|
||||
|
||||
.space-x-2 > * + * { margin-left: $spacing-sm; }
|
||||
.space-x-3 > * + * { margin-left: $spacing-md; }
|
||||
.space-x-4 > * + * { margin-left: $spacing-lg; }
|
||||
.space-x-2 > * + * {
|
||||
margin-left: $spacing-sm;
|
||||
}
|
||||
.space-x-3 > * + * {
|
||||
margin-left: $spacing-md;
|
||||
}
|
||||
.space-x-4 > * + * {
|
||||
margin-left: $spacing-lg;
|
||||
}
|
||||
|
||||
// Margin utilities
|
||||
.m-0 { margin: 0; }
|
||||
.mb-1 { margin-bottom: $spacing-xs; }
|
||||
.mb-2 { margin-bottom: $spacing-sm; }
|
||||
.mb-3 { margin-bottom: $spacing-md; }
|
||||
.mb-4 { margin-bottom: $spacing-lg; }
|
||||
.m-0 {
|
||||
margin: 0;
|
||||
}
|
||||
.mb-1 {
|
||||
margin-bottom: $spacing-xs;
|
||||
}
|
||||
.mb-2 {
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
.mb-3 {
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
.mb-4 {
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
.mt-1 { margin-top: $spacing-xs; }
|
||||
.mt-2 { margin-top: $spacing-sm; }
|
||||
.mt-3 { margin-top: $spacing-md; }
|
||||
.mt-4 { margin-top: $spacing-lg; }
|
||||
.mt-1 {
|
||||
margin-top: $spacing-xs;
|
||||
}
|
||||
.mt-2 {
|
||||
margin-top: $spacing-sm;
|
||||
}
|
||||
.mt-3 {
|
||||
margin-top: $spacing-md;
|
||||
}
|
||||
.mt-4 {
|
||||
margin-top: $spacing-lg;
|
||||
}
|
||||
|
||||
// Padding utilities
|
||||
.p-0 { padding: 0; }
|
||||
.p-1 { padding: $spacing-xs; }
|
||||
.p-2 { padding: $spacing-sm; }
|
||||
.p-3 { padding: $spacing-md; }
|
||||
.p-4 { padding: $spacing-lg; }
|
||||
.p-0 {
|
||||
padding: 0;
|
||||
}
|
||||
.p-1 {
|
||||
padding: $spacing-xs;
|
||||
}
|
||||
.p-2 {
|
||||
padding: $spacing-sm;
|
||||
}
|
||||
.p-3 {
|
||||
padding: $spacing-md;
|
||||
}
|
||||
.p-4 {
|
||||
padding: $spacing-lg;
|
||||
}
|
||||
|
||||
// Width utilities
|
||||
.w-full { width: 100%; }
|
||||
.w-auto { width: auto; }
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
.w-auto {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
// Height utilities
|
||||
.h-full { height: 100%; }
|
||||
.h-auto { height: auto; }
|
||||
.h-full {
|
||||
height: 100%;
|
||||
}
|
||||
.h-auto {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
// Color utilities
|
||||
.text-primary { color: $primary; }
|
||||
.text-secondary { color: $secondary; }
|
||||
.text-success { color: $success; }
|
||||
.text-warning { color: $warning; }
|
||||
.text-danger { color: $danger; }
|
||||
.text-primary {
|
||||
color: $primary;
|
||||
}
|
||||
.text-secondary {
|
||||
color: $secondary;
|
||||
}
|
||||
.text-success {
|
||||
color: $success;
|
||||
}
|
||||
.text-warning {
|
||||
color: $warning;
|
||||
}
|
||||
.text-danger {
|
||||
color: $danger;
|
||||
}
|
||||
|
||||
.text-gray-400 { color: $gray-400; }
|
||||
.text-gray-500 { color: $gray-500; }
|
||||
.text-gray-600 { color: $gray-600; }
|
||||
.text-gray-700 { color: $gray-700; }
|
||||
.text-gray-900 { color: $gray-900; }
|
||||
.text-gray-400 {
|
||||
color: $gray-400;
|
||||
}
|
||||
.text-gray-500 {
|
||||
color: $gray-500;
|
||||
}
|
||||
.text-gray-600 {
|
||||
color: $gray-600;
|
||||
}
|
||||
.text-gray-700 {
|
||||
color: $gray-700;
|
||||
}
|
||||
.text-gray-900 {
|
||||
color: $gray-900;
|
||||
}
|
||||
|
||||
// Background utilities
|
||||
.bg-primary { background-color: $primary; }
|
||||
.bg-white { background-color: white; }
|
||||
.bg-gray-50 { background-color: $gray-50; }
|
||||
.bg-gray-100 { background-color: $gray-100; }
|
||||
.bg-primary {
|
||||
background-color: $primary;
|
||||
}
|
||||
.bg-white {
|
||||
background-color: white;
|
||||
}
|
||||
.bg-gray-50 {
|
||||
background-color: $gray-50;
|
||||
}
|
||||
.bg-gray-100 {
|
||||
background-color: $gray-100;
|
||||
}
|
||||
|
||||
// Border utilities
|
||||
.border { border: 1px solid $gray-200; }
|
||||
.border-0 { border: 0; }
|
||||
.border-gray-100 { border-color: $gray-100; }
|
||||
.border-gray-200 { border-color: $gray-200; }
|
||||
.border {
|
||||
border: 1px solid $gray-200;
|
||||
}
|
||||
.border-0 {
|
||||
border: 0;
|
||||
}
|
||||
.border-gray-100 {
|
||||
border-color: $gray-100;
|
||||
}
|
||||
.border-gray-200 {
|
||||
border-color: $gray-200;
|
||||
}
|
||||
|
||||
// Border radius utilities
|
||||
.rounded { border-radius: $radius-md; }
|
||||
.rounded-lg { border-radius: $radius-lg; }
|
||||
.rounded-xl { border-radius: $radius-xl; }
|
||||
.rounded-2xl { border-radius: $radius-2xl; }
|
||||
.rounded-full { border-radius: 9999px; }
|
||||
.rounded {
|
||||
border-radius: $radius-md;
|
||||
}
|
||||
.rounded-lg {
|
||||
border-radius: $radius-lg;
|
||||
}
|
||||
.rounded-xl {
|
||||
border-radius: $radius-xl;
|
||||
}
|
||||
.rounded-2xl {
|
||||
border-radius: $radius-2xl;
|
||||
}
|
||||
.rounded-full {
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
// Shadow utilities
|
||||
.shadow-sm { box-shadow: $shadow-sm; }
|
||||
.shadow-md { box-shadow: $shadow-md; }
|
||||
.shadow-lg { box-shadow: $shadow-lg; }
|
||||
.shadow-xl { box-shadow: $shadow-xl; }
|
||||
.shadow-sm {
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
.shadow-md {
|
||||
box-shadow: $shadow-md;
|
||||
}
|
||||
.shadow-lg {
|
||||
box-shadow: $shadow-lg;
|
||||
}
|
||||
.shadow-xl {
|
||||
box-shadow: $shadow-xl;
|
||||
}
|
||||
|
||||
// Position utilities
|
||||
.relative { position: relative; }
|
||||
.absolute { position: absolute; }
|
||||
.fixed { position: fixed; }
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
||||
.absolute {
|
||||
position: absolute;
|
||||
}
|
||||
.fixed {
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
// Z-index utilities
|
||||
.z-10 { z-index: 10; }
|
||||
.z-20 { z-index: 20; }
|
||||
.z-50 { z-index: 50; }
|
||||
.z-10 {
|
||||
z-index: 10;
|
||||
}
|
||||
.z-20 {
|
||||
z-index: 20;
|
||||
}
|
||||
.z-50 {
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
// Overflow utilities
|
||||
.overflow-hidden { overflow: hidden; }
|
||||
.overflow-y-auto { overflow-y: auto; }
|
||||
.overflow-hidden {
|
||||
overflow: hidden;
|
||||
}
|
||||
.overflow-y-auto {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
// Cursor utilities
|
||||
.cursor-pointer { cursor: pointer; }
|
||||
.cursor-not-allowed { cursor: not-allowed; }
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
.cursor-not-allowed {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
// Opacity utilities
|
||||
.opacity-50 { opacity: 0.5; }
|
||||
.opacity-75 { opacity: 0.75; }
|
||||
.opacity-50 {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.opacity-75 {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
@@ -13,11 +13,28 @@
|
||||
Building Average: {{ overallCO2.toFixed(0) }} ppm
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-12 h-12 rounded-full flex items-center justify-center" :class="getOverallStatusIconBg()">
|
||||
<svg class="w-6 h-6" :class="getOverallStatusText()" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12,2C6.5,2 2,6.5 2,12C2,17.5 6.5,22 12,22C17.5,22 22,17.5 22,12C22,6.5 17.5,2 12,2M10,16.5L6,12.5L7.5,11L10,13.5L16.5,7L18,8.5L10,16.5Z" v-if="overallStatus === 'good'"/>
|
||||
<path d="M12,2C6.5,2 2,6.5 2,12C2,17.5 6.5,22 12,22C17.5,22 22,17.5 22,12C22,6.5 17.5,2 12,2M12,7L17,12L12,17L7,12L12,7Z" v-else-if="overallStatus === 'moderate'"/>
|
||||
<path d="M12,2C6.5,2 2,6.5 2,12C2,17.5 6.5,22 12,22C17.5,22 22,17.5 22,12C22,6.5 17.5,2 12,2M12,7L17,12L12,17L7,12L12,7Z" v-else/>
|
||||
<div
|
||||
class="w-12 h-12 rounded-full flex items-center justify-center"
|
||||
:class="getOverallStatusIconBg()"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
:class="getOverallStatusText()"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M12,2C6.5,2 2,6.5 2,12C2,17.5 6.5,22 12,22C17.5,22 22,17.5 22,12C22,6.5 17.5,2 12,2M10,16.5L6,12.5L7.5,11L10,13.5L16.5,7L18,8.5L10,16.5Z"
|
||||
v-if="overallStatus === 'good'"
|
||||
/>
|
||||
<path
|
||||
d="M12,2C6.5,2 2,6.5 2,12C2,17.5 6.5,22 12,22C17.5,22 22,17.5 22,12C22,6.5 17.5,2 12,2M12,7L17,12L12,17L7,12L12,7Z"
|
||||
v-else-if="overallStatus === 'moderate'"
|
||||
/>
|
||||
<path
|
||||
d="M12,2C6.5,2 2,6.5 2,12C2,17.5 6.5,22 12,22C17.5,22 22,17.5 22,12C22,6.5 17.5,2 12,2M12,7L17,12L12,17L7,12L12,7Z"
|
||||
v-else
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@@ -29,18 +46,22 @@
|
||||
No air quality data available
|
||||
</div>
|
||||
|
||||
<div v-for="room in roomsList" :key="room.room" class="flex items-center justify-between p-2 rounded">
|
||||
<div
|
||||
v-for="room in roomsList"
|
||||
:key="room.room"
|
||||
class="flex items-center justify-between p-2 rounded"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
:class="getCO2StatusColor(room.co2.status)"
|
||||
:class="getCO2StatusColor(room.co2?.status || 'good')"
|
||||
></div>
|
||||
<span class="text-sm font-medium text-gray-900">{{ room.room }}</span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-sm text-gray-900">{{ Math.round(room.co2.current) }} ppm</div>
|
||||
<div class="text-xs" :class="getCO2TextColor(room.co2.status)">
|
||||
{{ room.co2.status.toUpperCase() }}
|
||||
<div class="text-sm text-gray-900">{{ Math.round(room.co2?.current || 0) }} ppm</div>
|
||||
<div class="text-xs" :class="getCO2TextColor(room.co2?.status || 'good')">
|
||||
{{ (room.co2?.status || 'good').toUpperCase() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -75,14 +96,17 @@ import { useRoomStore } from '@/stores/room'
|
||||
const roomStore = useRoomStore()
|
||||
|
||||
const roomsList = computed(() => {
|
||||
return Array.from(roomStore.roomsData.values()).sort((a, b) =>
|
||||
b.co2.current - a.co2.current // Sort by CO2 level descending
|
||||
)
|
||||
return Array.from(roomStore.roomsData.values())
|
||||
.filter((room) => room.co2) // Only include rooms with CO2 data
|
||||
.sort(
|
||||
(a, b) => (b.co2?.current || 0) - (a.co2?.current || 0), // Sort by CO2 level descending
|
||||
)
|
||||
})
|
||||
|
||||
const overallCO2 = computed(() => {
|
||||
if (roomsList.value.length === 0) return 0
|
||||
return roomsList.value.reduce((sum, room) => sum + room.co2.current, 0) / roomsList.value.length
|
||||
const total = roomsList.value.reduce((sum, room) => sum + (room.co2?.current || 0), 0)
|
||||
return total / roomsList.value.length
|
||||
})
|
||||
|
||||
const overallStatus = computed(() => {
|
||||
@@ -90,23 +114,27 @@ const overallStatus = computed(() => {
|
||||
})
|
||||
|
||||
const roomsWithGoodAir = computed(() => {
|
||||
return roomsList.value.filter(room => room.co2.status === 'good').length
|
||||
return roomsList.value.filter((room) => room.co2?.status === 'good').length
|
||||
})
|
||||
|
||||
const roomsNeedingAttention = computed(() => {
|
||||
return roomsList.value.filter(room => ['poor', 'critical'].includes(room.co2.status)).length
|
||||
return roomsList.value.filter(
|
||||
(room) => room.co2?.status && ['poor', 'critical'].includes(room.co2.status),
|
||||
).length
|
||||
})
|
||||
|
||||
const recommendations = computed(() => {
|
||||
const recs = []
|
||||
const criticalRooms = roomsList.value.filter(room => room.co2.status === 'critical')
|
||||
const poorRooms = roomsList.value.filter(room => room.co2.status === 'poor')
|
||||
const criticalRooms = roomsList.value.filter((room) => room.co2?.status === 'critical')
|
||||
const poorRooms = roomsList.value.filter((room) => room.co2?.status === 'poor')
|
||||
|
||||
if (criticalRooms.length > 0) {
|
||||
recs.push(`Immediate ventilation needed in ${criticalRooms[0].room}`)
|
||||
}
|
||||
if (poorRooms.length > 0) {
|
||||
recs.push(`Increase air circulation in ${poorRooms.length} room${poorRooms.length > 1 ? 's' : ''}`)
|
||||
recs.push(
|
||||
`Increase air circulation in ${poorRooms.length} room${poorRooms.length > 1 ? 's' : ''}`,
|
||||
)
|
||||
}
|
||||
if (overallCO2.value > 800) {
|
||||
recs.push('Consider adjusting HVAC settings building-wide')
|
||||
@@ -117,61 +145,91 @@ const recommendations = computed(() => {
|
||||
|
||||
const getOverallStatus = () => {
|
||||
switch (overallStatus.value) {
|
||||
case 'good': return 'Excellent Air Quality'
|
||||
case 'moderate': return 'Moderate Air Quality'
|
||||
case 'poor': return 'Poor Air Quality'
|
||||
case 'critical': return 'Critical - Action Required'
|
||||
default: return 'Unknown Status'
|
||||
case 'good':
|
||||
return 'Excellent Air Quality'
|
||||
case 'moderate':
|
||||
return 'Moderate Air Quality'
|
||||
case 'poor':
|
||||
return 'Poor Air Quality'
|
||||
case 'critical':
|
||||
return 'Critical - Action Required'
|
||||
default:
|
||||
return 'Unknown Status'
|
||||
}
|
||||
}
|
||||
|
||||
const getOverallStatusBg = () => {
|
||||
switch (overallStatus.value) {
|
||||
case 'good': return 'bg-green-50 border border-green-200'
|
||||
case 'moderate': return 'bg-yellow-50 border border-yellow-200'
|
||||
case 'poor': return 'bg-orange-50 border border-orange-200'
|
||||
case 'critical': return 'bg-red-50 border border-red-200'
|
||||
default: return 'bg-gray-50 border border-gray-200'
|
||||
case 'good':
|
||||
return 'bg-green-50 border border-green-200'
|
||||
case 'moderate':
|
||||
return 'bg-yellow-50 border border-yellow-200'
|
||||
case 'poor':
|
||||
return 'bg-orange-50 border border-orange-200'
|
||||
case 'critical':
|
||||
return 'bg-red-50 border border-red-200'
|
||||
default:
|
||||
return 'bg-gray-50 border border-gray-200'
|
||||
}
|
||||
}
|
||||
|
||||
const getOverallStatusText = () => {
|
||||
switch (overallStatus.value) {
|
||||
case 'good': return 'text-green-700'
|
||||
case 'moderate': return 'text-yellow-700'
|
||||
case 'poor': return 'text-orange-700'
|
||||
case 'critical': return 'text-red-700'
|
||||
default: return 'text-gray-700'
|
||||
case 'good':
|
||||
return 'text-green-700'
|
||||
case 'moderate':
|
||||
return 'text-yellow-700'
|
||||
case 'poor':
|
||||
return 'text-orange-700'
|
||||
case 'critical':
|
||||
return 'text-red-700'
|
||||
default:
|
||||
return 'text-gray-700'
|
||||
}
|
||||
}
|
||||
|
||||
const getOverallStatusIconBg = () => {
|
||||
switch (overallStatus.value) {
|
||||
case 'good': return 'bg-green-100'
|
||||
case 'moderate': return 'bg-yellow-100'
|
||||
case 'poor': return 'bg-orange-100'
|
||||
case 'critical': return 'bg-red-100'
|
||||
default: return 'bg-gray-100'
|
||||
case 'good':
|
||||
return 'bg-green-100'
|
||||
case 'moderate':
|
||||
return 'bg-yellow-100'
|
||||
case 'poor':
|
||||
return 'bg-orange-100'
|
||||
case 'critical':
|
||||
return 'bg-red-100'
|
||||
default:
|
||||
return 'bg-gray-100'
|
||||
}
|
||||
}
|
||||
|
||||
const getCO2StatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'good': return 'bg-green-500'
|
||||
case 'moderate': return 'bg-yellow-500'
|
||||
case 'poor': return 'bg-orange-500'
|
||||
case 'critical': return 'bg-red-500'
|
||||
default: return 'bg-gray-500'
|
||||
case 'good':
|
||||
return 'bg-green-500'
|
||||
case 'moderate':
|
||||
return 'bg-yellow-500'
|
||||
case 'poor':
|
||||
return 'bg-orange-500'
|
||||
case 'critical':
|
||||
return 'bg-red-500'
|
||||
default:
|
||||
return 'bg-gray-500'
|
||||
}
|
||||
}
|
||||
|
||||
const getCO2TextColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'good': return 'text-green-600'
|
||||
case 'moderate': return 'text-yellow-600'
|
||||
case 'poor': return 'text-orange-600'
|
||||
case 'critical': return 'text-red-600'
|
||||
default: return 'text-gray-600'
|
||||
case 'good':
|
||||
return 'text-green-600'
|
||||
case 'moderate':
|
||||
return 'text-yellow-600'
|
||||
case 'poor':
|
||||
return 'text-orange-600'
|
||||
case 'critical':
|
||||
return 'text-red-600'
|
||||
default:
|
||||
return 'text-gray-600'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -9,7 +9,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-900">{{ sensor.name }}</h3>
|
||||
<p class="text-sm text-gray-500">{{ sensor.id }}</p>
|
||||
<p class="text-sm text-gray-500">{{ sensor.sensor_id }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -17,7 +17,7 @@
|
||||
class="w-2 h-2 rounded-full transition-all duration-300"
|
||||
:class="[
|
||||
getSensorStatusColor(sensor.status),
|
||||
isRecentlyUpdated ? 'animate-pulse shadow-lg shadow-green-400/50' : ''
|
||||
isRecentlyUpdated ? 'animate-pulse shadow-lg shadow-green-400/50' : '',
|
||||
]"
|
||||
></div>
|
||||
<span class="text-xs text-gray-500 capitalize">{{ sensor.status }}</span>
|
||||
@@ -32,7 +32,9 @@
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Room Assignment</label>
|
||||
<select
|
||||
:value="sensor.room"
|
||||
@change="$emit('updateRoom', sensor.id, ($event.target as HTMLSelectElement).value)"
|
||||
@change="
|
||||
$emit('updateRoom', sensor.sensor_id, ($event.target as HTMLSelectElement).value)
|
||||
"
|
||||
class="w-full px-3 py-2 border border-gray-200 rounded-lg bg-white text-sm"
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
@@ -74,8 +76,7 @@
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-700 mb-2">Current Values</div>
|
||||
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||
<div v-for="metric in sensorValues" :key="metric.type"
|
||||
class="bg-gray-50 rounded p-2">
|
||||
<div v-for="metric in sensorValues" :key="metric.type" class="bg-gray-50 rounded p-2">
|
||||
<div class="text-gray-600 mb-1">{{ metric.label }}</div>
|
||||
<div class="font-medium text-gray-900">
|
||||
{{ metric.value }} <span class="text-gray-500">{{ metric.unit }}</span>
|
||||
@@ -100,7 +101,7 @@
|
||||
<span class="font-medium">Location:</span>
|
||||
<div>{{ sensor.metadata.location }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div v-if="sensor.lastSeen">
|
||||
<span class="font-medium">Last Seen:</span>
|
||||
<div>{{ formatTime(sensor.lastSeen) }}</div>
|
||||
</div>
|
||||
@@ -126,7 +127,7 @@
|
||||
v-for="i in 4"
|
||||
:key="i"
|
||||
class="w-1 h-2 bg-gray-200 rounded-sm"
|
||||
:class="{ 'bg-green-500': (sensor.metadata.signalStrength / 25) >= i }"
|
||||
:class="{ 'bg-green-500': sensor.metadata.signalStrength / 25 >= i }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -155,7 +156,9 @@
|
||||
<!-- No Actions State -->
|
||||
<div v-else>
|
||||
<div class="text-sm font-medium text-gray-700 mb-2">Device Actions</div>
|
||||
<div class="text-xs text-gray-500 text-center py-3 bg-gray-50 rounded border-2 border-dashed border-gray-200">
|
||||
<div
|
||||
class="text-xs text-gray-500 text-center py-3 bg-gray-50 rounded border-2 border-dashed border-gray-200"
|
||||
>
|
||||
This device is monitor-only and has no available actions
|
||||
</div>
|
||||
</div>
|
||||
@@ -166,26 +169,30 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useSensorStore } from '@/stores/sensor'
|
||||
import type { SensorDevice, SensorAction } from '@/services'
|
||||
|
||||
const props = defineProps<{
|
||||
sensor: any
|
||||
sensor: SensorDevice
|
||||
availableRooms: string[]
|
||||
isExecutingAction?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
updateRoom: [sensorId: string, newRoom: string]
|
||||
executeAction: [sensor: any, action: any]
|
||||
executeAction: [sensor: SensorDevice, action: SensorAction]
|
||||
}>()
|
||||
|
||||
const sensorStore = useSensorStore()
|
||||
|
||||
const getSensorValues = (sensor: any) => {
|
||||
const getSensorValues = (sensor: SensorDevice) => {
|
||||
const values = []
|
||||
|
||||
// Get real-time sensor reading from store
|
||||
const latestReading = sensorStore.latestReadings.get(sensor.id) || sensorStore.latestReadings.get(sensor.sensor_id)
|
||||
console.log(`[Detailed] Getting values for sensor ${sensor.id}, found reading:`, latestReading)
|
||||
const latestReading = sensorStore.latestReadings.get(sensor.sensor_id)
|
||||
console.log(
|
||||
`[Detailed] Getting values for sensor ${sensor.sensor_id}, found reading:`,
|
||||
latestReading,
|
||||
)
|
||||
console.log('[Detailed] Available readings:', Array.from(sensorStore.latestReadings.keys()))
|
||||
console.log(`[Detailed] Sensor capabilities:`, sensor.capabilities?.monitoring)
|
||||
|
||||
@@ -196,7 +203,7 @@ const getSensorValues = (sensor: any) => {
|
||||
type: 'energy',
|
||||
label: 'Energy Consumption',
|
||||
value: energyValue,
|
||||
unit: latestReading?.energy?.unit || 'kWh'
|
||||
unit: latestReading?.energy?.unit || 'kWh',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -207,19 +214,19 @@ const getSensorValues = (sensor: any) => {
|
||||
type: 'co2',
|
||||
label: 'CO2 Level',
|
||||
value: co2Value,
|
||||
unit: latestReading?.co2?.unit || 'ppm'
|
||||
unit: latestReading?.co2?.unit || 'ppm',
|
||||
})
|
||||
}
|
||||
|
||||
// Only show temperature if the sensor monitors temperature
|
||||
if (sensor.capabilities?.monitoring?.includes('temperature')) {
|
||||
const tempValue = latestReading?.temperature?.value?.toFixed(1) ||
|
||||
(Math.random() * 8 + 18).toFixed(1)
|
||||
const tempValue =
|
||||
latestReading?.temperature?.value?.toFixed(1) || (Math.random() * 8 + 18).toFixed(1)
|
||||
values.push({
|
||||
type: 'temperature',
|
||||
label: 'Temperature',
|
||||
value: tempValue,
|
||||
unit: latestReading?.temperature?.unit || '°C'
|
||||
unit: latestReading?.temperature?.unit || '°C',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -229,7 +236,7 @@ const getSensorValues = (sensor: any) => {
|
||||
type: 'humidity',
|
||||
label: 'Humidity',
|
||||
value: Math.floor(Math.random() * 40 + 30),
|
||||
unit: '%'
|
||||
unit: '%',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -239,7 +246,7 @@ const getSensorValues = (sensor: any) => {
|
||||
type: 'motion',
|
||||
label: 'Motion Status',
|
||||
value: Math.random() > 0.7 ? 'Detected' : 'Clear',
|
||||
unit: ''
|
||||
unit: '',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -251,13 +258,13 @@ const getSensorValues = (sensor: any) => {
|
||||
type: 'brightness',
|
||||
label: 'Brightness Level',
|
||||
value: Math.floor(Math.random() * 100),
|
||||
unit: '%'
|
||||
unit: '%',
|
||||
})
|
||||
values.push({
|
||||
type: 'power',
|
||||
label: 'Power Draw',
|
||||
value: Math.floor(Math.random() * 50 + 5),
|
||||
unit: 'W'
|
||||
unit: 'W',
|
||||
})
|
||||
break
|
||||
case 'hvac':
|
||||
@@ -265,13 +272,13 @@ const getSensorValues = (sensor: any) => {
|
||||
type: 'setpoint',
|
||||
label: 'Target Temperature',
|
||||
value: (Math.random() * 6 + 18).toFixed(1),
|
||||
unit: '°C'
|
||||
unit: '°C',
|
||||
})
|
||||
values.push({
|
||||
type: 'mode',
|
||||
label: 'Operating Mode',
|
||||
value: ['Heat', 'Cool', 'Auto', 'Fan'][Math.floor(Math.random() * 4)],
|
||||
unit: ''
|
||||
unit: '',
|
||||
})
|
||||
break
|
||||
case 'security':
|
||||
@@ -279,13 +286,13 @@ const getSensorValues = (sensor: any) => {
|
||||
type: 'status',
|
||||
label: 'Security Status',
|
||||
value: Math.random() > 0.8 ? 'Alert' : 'Normal',
|
||||
unit: ''
|
||||
unit: '',
|
||||
})
|
||||
values.push({
|
||||
type: 'armed',
|
||||
label: 'System Armed',
|
||||
value: Math.random() > 0.5 ? 'Yes' : 'No',
|
||||
unit: ''
|
||||
unit: '',
|
||||
})
|
||||
break
|
||||
default:
|
||||
@@ -294,7 +301,7 @@ const getSensorValues = (sensor: any) => {
|
||||
type: 'status',
|
||||
label: 'Device Status',
|
||||
value: sensor.status === 'online' ? 'Active' : 'Inactive',
|
||||
unit: ''
|
||||
unit: '',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -304,7 +311,7 @@ const getSensorValues = (sensor: any) => {
|
||||
type: 'uptime',
|
||||
label: 'Uptime',
|
||||
value: Math.floor(Math.random() * 30 + 1),
|
||||
unit: 'days'
|
||||
unit: 'days',
|
||||
})
|
||||
|
||||
return values
|
||||
@@ -315,14 +322,13 @@ const sensorValues = computed(() => getSensorValues(props.sensor))
|
||||
|
||||
// Check if sensor was recently updated for pulsing animation
|
||||
const isRecentlyUpdated = computed(() => {
|
||||
return sensorStore.recentlyUpdatedSensors.has(props.sensor.id) ||
|
||||
sensorStore.recentlyUpdatedSensors.has(props.sensor.sensor_id)
|
||||
return sensorStore.recentlyUpdatedSensors.has(props.sensor.sensor_id)
|
||||
})
|
||||
|
||||
const getDefaultTags = (sensor: any) => {
|
||||
const tags = [sensor.type]
|
||||
const getDefaultTags = (sensor: SensorDevice): string[] => {
|
||||
const tags: string[] = [sensor.type]
|
||||
|
||||
if (sensor.metadata.battery) {
|
||||
if (sensor.metadata?.battery) {
|
||||
tags.push('wireless')
|
||||
} else {
|
||||
tags.push('wired')
|
||||
@@ -345,7 +351,7 @@ const getSensorTypeIcon = (type: string) => {
|
||||
humidity: '💧',
|
||||
hvac: '❄️',
|
||||
lighting: '💡',
|
||||
security: '🔒'
|
||||
security: '🔒',
|
||||
}
|
||||
return icons[type as keyof typeof icons] || '📱'
|
||||
}
|
||||
@@ -358,17 +364,21 @@ const getSensorTypeStyle = (type: string) => {
|
||||
humidity: { bg: 'bg-blue-100', text: 'text-blue-700' },
|
||||
hvac: { bg: 'bg-cyan-100', text: 'text-cyan-700' },
|
||||
lighting: { bg: 'bg-amber-100', text: 'text-amber-700' },
|
||||
security: { bg: 'bg-purple-100', text: 'text-purple-700' }
|
||||
security: { bg: 'bg-purple-100', text: 'text-purple-700' },
|
||||
}
|
||||
return styles[type as keyof typeof styles] || { bg: 'bg-gray-100', text: 'text-gray-700' }
|
||||
}
|
||||
|
||||
const getSensorStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'online': return 'bg-green-500'
|
||||
case 'offline': return 'bg-gray-400'
|
||||
case 'error': return 'bg-red-500'
|
||||
default: return 'bg-gray-400'
|
||||
case 'online':
|
||||
return 'bg-green-500'
|
||||
case 'offline':
|
||||
return 'bg-gray-400'
|
||||
case 'error':
|
||||
return 'bg-red-500'
|
||||
default:
|
||||
return 'bg-gray-400'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="bg-white rounded-2xl shadow-sm flex flex-col justify-between h-full w-full p-4">
|
||||
<div class="bg-white rounded-2xl shadow-sm flex flex-col justify-between aspect-square p-4">
|
||||
<h6 class="text-sm font-bold text-gray-500">{{ title }}</h6>
|
||||
<div class="flex-grow flex items-center justify-start">
|
||||
<p class="text-gray-900 font-bold text-2xl">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="bg-white rounded-2xl shadow-sm flex flex-col h-full min-h-[300px]">
|
||||
<div class="p-4 h-full">
|
||||
<h6 class="text-sm font-bold text-gray-500 mb-2">{{ title }}</h6>
|
||||
<v-chart class="h-64 w-full" :option="option" autoresize />
|
||||
<div class="bg-white rounded-2xl shadow-sm flex flex-col p-4">
|
||||
<h6 class="text-sm font-bold text-gray-500 mb-2">{{ title }}</h6>
|
||||
<div class="w-full h-[400px]">
|
||||
<v-chart class="w-full h-full" :option="option" autoresize />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -13,14 +13,13 @@
|
||||
<h3 class="font-medium text-gray-900">{{ room.room }}</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- CO2 Status Indicator -->
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
:class="getCO2StatusColor(room.co2.status)"
|
||||
></div>
|
||||
<div class="w-3 h-3 rounded-full" :class="getCO2StatusColor(room.co2!.status)"></div>
|
||||
<!-- Occupancy Indicator -->
|
||||
<div class="flex items-center gap-1 text-xs text-gray-500">
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
|
||||
<path
|
||||
d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="capitalize">{{ room.occupancyEstimate }}</span>
|
||||
</div>
|
||||
@@ -32,15 +31,21 @@
|
||||
<!-- Energy -->
|
||||
<div class="bg-blue-50 rounded p-2">
|
||||
<div class="text-blue-600 font-medium">Energy</div>
|
||||
<div class="text-blue-900">{{ room.energy.current.toFixed(2) }} {{ room.energy.unit }}</div>
|
||||
<div class="text-blue-600 text-xs">Total: {{ room.energy.total.toFixed(2) }}</div>
|
||||
<div class="text-blue-900">
|
||||
{{ room.energy!.current.toFixed(2) }} {{ room.energy!.unit }}
|
||||
</div>
|
||||
<div class="text-blue-600 text-xs">Total: {{ room.energy!.total.toFixed(2) }}</div>
|
||||
</div>
|
||||
|
||||
<!-- CO2 -->
|
||||
<div class="rounded p-2" :class="getCO2BackgroundColor(room.co2.status)">
|
||||
<div class="font-medium" :class="getCO2TextColor(room.co2.status)">CO2</div>
|
||||
<div :class="getCO2TextColor(room.co2.status)">{{ Math.round(room.co2.current) }} {{ room.co2.unit }}</div>
|
||||
<div class="text-xs" :class="getCO2TextColor(room.co2.status)">{{ room.co2.status.toUpperCase() }}</div>
|
||||
<div class="rounded p-2" :class="getCO2BackgroundColor(room.co2!.status)">
|
||||
<div class="font-medium" :class="getCO2TextColor(room.co2!.status)">CO2</div>
|
||||
<div :class="getCO2TextColor(room.co2!.status)">
|
||||
{{ Math.round(room.co2!.current) }} {{ room.co2!.unit }}
|
||||
</div>
|
||||
<div class="text-xs" :class="getCO2TextColor(room.co2!.status)">
|
||||
{{ room.co2!.status.toUpperCase() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -53,7 +58,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div v-if="roomsList.length > 0" class="mt-4 pt-4 border-t border-gray-100 grid grid-cols-3 gap-4 text-center text-xs">
|
||||
<div
|
||||
v-if="roomsList.length > 0"
|
||||
class="mt-4 pt-4 border-t border-gray-100 grid grid-cols-3 gap-4 text-center text-xs"
|
||||
>
|
||||
<div>
|
||||
<div class="font-medium text-gray-900">{{ roomsList.length }}</div>
|
||||
<div class="text-gray-500">Rooms</div>
|
||||
@@ -77,47 +85,63 @@ import { useRoomStore } from '@/stores/room'
|
||||
const roomStore = useRoomStore()
|
||||
|
||||
const roomsList = computed(() => {
|
||||
return Array.from(roomStore.roomsData.values()).sort((a, b) =>
|
||||
a.room.localeCompare(b.room)
|
||||
)
|
||||
return Array.from(roomStore.roomsData.values())
|
||||
.filter((room) => room.energy && room.co2) // Only show rooms with both metrics
|
||||
.sort((a, b) => a.room.localeCompare(b.room))
|
||||
})
|
||||
|
||||
const totalEnergy = computed(() => {
|
||||
return roomsList.value.reduce((sum, room) => sum + room.energy.current, 0)
|
||||
return roomsList.value.reduce((sum, room) => sum + (room.energy?.current || 0), 0)
|
||||
})
|
||||
|
||||
const averageCO2 = computed(() => {
|
||||
if (roomsList.value.length === 0) return 0
|
||||
return roomsList.value.reduce((sum, room) => sum + room.co2.current, 0) / roomsList.value.length
|
||||
const total = roomsList.value.reduce((sum, room) => sum + (room.co2?.current || 0), 0)
|
||||
return total / roomsList.value.length
|
||||
})
|
||||
|
||||
const getCO2StatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'good': return 'bg-green-500'
|
||||
case 'moderate': return 'bg-yellow-500'
|
||||
case 'poor': return 'bg-orange-500'
|
||||
case 'critical': return 'bg-red-500'
|
||||
default: return 'bg-gray-500'
|
||||
case 'good':
|
||||
return 'bg-green-500'
|
||||
case 'moderate':
|
||||
return 'bg-yellow-500'
|
||||
case 'poor':
|
||||
return 'bg-orange-500'
|
||||
case 'critical':
|
||||
return 'bg-red-500'
|
||||
default:
|
||||
return 'bg-gray-500'
|
||||
}
|
||||
}
|
||||
|
||||
const getCO2BackgroundColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'good': return 'bg-green-50'
|
||||
case 'moderate': return 'bg-yellow-50'
|
||||
case 'poor': return 'bg-orange-50'
|
||||
case 'critical': return 'bg-red-50'
|
||||
default: return 'bg-gray-50'
|
||||
case 'good':
|
||||
return 'bg-green-50'
|
||||
case 'moderate':
|
||||
return 'bg-yellow-50'
|
||||
case 'poor':
|
||||
return 'bg-orange-50'
|
||||
case 'critical':
|
||||
return 'bg-red-50'
|
||||
default:
|
||||
return 'bg-gray-50'
|
||||
}
|
||||
}
|
||||
|
||||
const getCO2TextColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'good': return 'text-green-700'
|
||||
case 'moderate': return 'text-yellow-700'
|
||||
case 'poor': return 'text-orange-700'
|
||||
case 'critical': return 'text-red-700'
|
||||
default: return 'text-gray-700'
|
||||
case 'good':
|
||||
return 'text-green-700'
|
||||
case 'moderate':
|
||||
return 'text-yellow-700'
|
||||
case 'poor':
|
||||
return 'text-orange-700'
|
||||
case 'critical':
|
||||
return 'text-red-700'
|
||||
default:
|
||||
return 'text-gray-700'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="bg-white rounded-2xl shadow-sm p-4">
|
||||
<h6 class="text-sm font-bold text-gray-500 mb-4">Sensor Consumption</h6>
|
||||
|
||||
<h6 class="text-sm font-bold text-gray-500 mb-4">Sensor Readings</h6>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
@@ -10,13 +9,10 @@
|
||||
Sensor ID
|
||||
</th>
|
||||
<th class="text-right text-xs font-medium text-gray-500 uppercase tracking-wider py-3">
|
||||
Current
|
||||
Room
|
||||
</th>
|
||||
<th class="text-right text-xs font-medium text-gray-500 uppercase tracking-wider py-3">
|
||||
Total
|
||||
</th>
|
||||
<th class="text-right text-xs font-medium text-gray-500 uppercase tracking-wider py-3">
|
||||
Average
|
||||
Value
|
||||
</th>
|
||||
<th class="text-right text-xs font-medium text-gray-500 uppercase tracking-wider py-3">
|
||||
Last Updated
|
||||
@@ -55,9 +51,6 @@
|
||||
sensor.humidity?.unit
|
||||
}}
|
||||
</td>
|
||||
<td class="py-3 text-sm text-gray-600 text-right">
|
||||
{{ sensor.room }}
|
||||
</td>
|
||||
<td class="py-3 text-sm text-gray-500 text-right">
|
||||
{{ formatTime(sensor.timestamp) }}
|
||||
</td>
|
||||
@@ -66,7 +59,6 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Connection Status Indicator -->
|
||||
<div class="mt-4 flex items-center justify-between text-xs text-gray-500">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
|
||||
@@ -9,9 +9,7 @@
|
||||
Token expires in: {{ formatTimeUntilExpiry() }}
|
||||
</div>
|
||||
|
||||
<div v-if="authStore.error" class="auth-status__error">
|
||||
Auth Error: {{ authStore.error }}
|
||||
</div>
|
||||
<div v-if="authStore.error" class="auth-status__error">Auth Error: {{ authStore.error }}</div>
|
||||
|
||||
<button
|
||||
v-if="!authStore.isAuthenticated"
|
||||
@@ -33,13 +31,13 @@ const authStore = useAuthStore()
|
||||
const authStatusClass = computed(() => ({
|
||||
'auth-status--authenticated': authStore.isAuthenticated,
|
||||
'auth-status--error': !authStore.isAuthenticated || authStore.error,
|
||||
'auth-status--loading': authStore.isLoading
|
||||
'auth-status--loading': authStore.isLoading,
|
||||
}))
|
||||
|
||||
const statusDotClass = computed(() => ({
|
||||
'auth-status__dot--green': authStore.isAuthenticated && !authStore.error,
|
||||
'auth-status__dot--red': !authStore.isAuthenticated || authStore.error,
|
||||
'auth-status__dot--yellow': authStore.isLoading
|
||||
'auth-status__dot--yellow': authStore.isLoading,
|
||||
}))
|
||||
|
||||
const statusText = computed(() => {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
class="absolute bottom-0 left-0 right-0 bg-white md:bg-transparent border-t md:border-t-0 border-gray-200 md:shadow-none shadow-lg"
|
||||
:class="getNavigationClasses()"
|
||||
>
|
||||
<UserIcon />
|
||||
<div class="flex justify-center md:pb-4 pb-2">
|
||||
<ul
|
||||
class="flex space-x-4 md:space-x-8 md:bg-white md:rounded-lg md:shadow-md px-6 py-3 w-full md:w-auto justify-around md:justify-center"
|
||||
@@ -54,23 +55,14 @@
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
to="/ai-optimization"
|
||||
to="/models"
|
||||
class="flex flex-col items-center font-medium"
|
||||
:class="
|
||||
$route.name === 'ai-optimization'
|
||||
? 'text-purple-600'
|
||||
: 'text-gray-600 hover:text-purple-600'
|
||||
$route.name === 'models' ? 'text-purple-600' : 'text-gray-600 hover:text-purple-600'
|
||||
"
|
||||
>
|
||||
<svg class="w-6 h-6 mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-xs">AI Optimize</span>
|
||||
<IconEcosystem />
|
||||
<span class="text-xs">Models</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
@@ -126,9 +118,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { onMounted } from 'vue'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
|
||||
import UserIcon from './UserIcon.vue'
|
||||
import IconEcosystem from '../icons/IconEcosystem.vue'
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
// Compute navigation classes based on settings
|
||||
|
||||
326
src/components/common/UserIcon.vue
Normal file
326
src/components/common/UserIcon.vue
Normal 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>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
|
||||
<svg class="w-6 h-6 mb-1" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
|
||||
/>
|
||||
|
||||
@@ -39,15 +39,15 @@
|
||||
<input
|
||||
v-model.number="numericValue"
|
||||
type="range"
|
||||
:min="action.parameters.min"
|
||||
:max="action.parameters.max"
|
||||
:step="action.parameters.step"
|
||||
:min="action.parameters?.min"
|
||||
:max="action.parameters?.max"
|
||||
:step="action.parameters?.step"
|
||||
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer slider"
|
||||
/>
|
||||
<div class="flex justify-between text-sm text-gray-600">
|
||||
<span>{{ action.parameters.min }}</span>
|
||||
<span>{{ action.parameters?.min }}</span>
|
||||
<span class="font-medium">{{ numericValue }}{{ getUnit() }}</span>
|
||||
<span>{{ action.parameters.max }}</span>
|
||||
<span>{{ action.parameters?.max }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,14 +131,20 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { SensorDevice, SensorAction } from '@/services'
|
||||
|
||||
interface ActionParameters {
|
||||
value?: number | string | boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
sensor: any
|
||||
action: any
|
||||
sensor: SensorDevice
|
||||
action: SensorAction
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
execute: [sensorId: string, actionId: string, parameters: any]
|
||||
execute: [sensorId: string, actionId: string, parameters: ActionParameters]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
@@ -153,9 +159,9 @@ watch(
|
||||
(action) => {
|
||||
if (action) {
|
||||
if (action.parameters?.min !== undefined) {
|
||||
numericValue.value = action.parameters.min
|
||||
numericValue.value = action.parameters?.min ?? 0
|
||||
}
|
||||
if (action.parameters?.options?.length > 0) {
|
||||
if (action.parameters?.options && action.parameters.options.length > 0) {
|
||||
selectedOption.value = action.parameters.options[0]
|
||||
}
|
||||
toggleValue.value = false
|
||||
@@ -182,7 +188,7 @@ const getUnit = () => {
|
||||
const executeAction = async () => {
|
||||
isExecuting.value = true
|
||||
|
||||
const parameters: any = {}
|
||||
const parameters: ActionParameters = {}
|
||||
|
||||
if (props.action.type === 'adjust') {
|
||||
if (hasNumericRange.value) {
|
||||
@@ -195,7 +201,7 @@ const executeAction = async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
emit('execute', props.sensor.id, props.action.id, parameters)
|
||||
emit('execute', props.sensor.sensor_id, props.action.id, parameters)
|
||||
} catch (error) {
|
||||
console.error('Failed to execute action:', error)
|
||||
} finally {
|
||||
|
||||
@@ -10,7 +10,12 @@
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -53,16 +58,14 @@
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<div
|
||||
v-for="room in roomsWithStats"
|
||||
:key="room.name"
|
||||
class="bg-gray-50 rounded-lg p-4"
|
||||
>
|
||||
<div v-for="room in roomsWithStats" :key="room.name" class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h4 class="font-medium text-gray-900">{{ room.name }}</h4>
|
||||
<span class="text-xs px-2 py-1 bg-blue-100 text-blue-700 rounded-full font-medium">
|
||||
<span
|
||||
class="text-xs px-2 py-1 bg-blue-100 text-blue-700 rounded-full font-medium"
|
||||
>
|
||||
{{ room.sensorCount }} sensors
|
||||
</span>
|
||||
</div>
|
||||
@@ -78,14 +81,21 @@
|
||||
>
|
||||
{{ type }}
|
||||
</span>
|
||||
<span v-if="room.sensorTypes.length === 0" class="text-xs text-gray-500">None</span>
|
||||
<span v-if="room.sensorTypes.length === 0" class="text-xs text-gray-500"
|
||||
>None</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-gray-600">Energy:</span>
|
||||
<div class="font-medium" :class="room.hasMetrics ? 'text-gray-900' : 'text-gray-400'">
|
||||
{{ room.hasMetrics ? room.energyConsumption.toFixed(2) + ' kWh' : 'No data' }}
|
||||
<div
|
||||
class="font-medium"
|
||||
:class="room.hasMetrics ? 'text-gray-900' : 'text-gray-400'"
|
||||
>
|
||||
{{
|
||||
room.hasMetrics ? room.energyConsumption.toFixed(2) + ' kWh' : 'No data'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -143,14 +153,19 @@
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div v-if="roomToDelete" class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-60">
|
||||
<div
|
||||
v-if="roomToDelete"
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-60"
|
||||
>
|
||||
<div class="bg-white rounded-xl max-w-md w-full p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">Delete Room</h3>
|
||||
<p class="text-gray-600 mb-4">
|
||||
Are you sure you want to delete <strong>"{{ roomToDelete }}"</strong>?
|
||||
{{ getRoomStats(roomToDelete).sensorCount > 0
|
||||
? `This will unassign ${getRoomStats(roomToDelete).sensorCount} sensor(s).`
|
||||
: 'This action cannot be undone.' }}
|
||||
{{
|
||||
getRoomStats(roomToDelete).sensorCount > 0
|
||||
? `This will unassign ${getRoomStats(roomToDelete).sensorCount} sensor(s).`
|
||||
: 'This action cannot be undone.'
|
||||
}}
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
roomsApi,
|
||||
analyticsApi,
|
||||
healthApi,
|
||||
type SensorInfo,
|
||||
type SensorDevice,
|
||||
type SensorType,
|
||||
type SensorStatus,
|
||||
type SensorMetadata,
|
||||
type RoomInfo,
|
||||
type RoomData,
|
||||
type AnalyticsSummary,
|
||||
@@ -18,7 +21,7 @@ import {
|
||||
type HealthCheck,
|
||||
type SystemStatus,
|
||||
type DataQuery,
|
||||
type DataResponse
|
||||
type DataResponse,
|
||||
} from '@/services'
|
||||
|
||||
interface ApiState {
|
||||
@@ -30,13 +33,13 @@ export function useApi() {
|
||||
// Global API state
|
||||
const globalState = reactive<ApiState>({
|
||||
loading: false,
|
||||
error: null
|
||||
error: null,
|
||||
})
|
||||
|
||||
// Helper to handle API calls with state management
|
||||
async function handleApiCall<T>(
|
||||
apiCall: () => Promise<T>,
|
||||
localState?: { loading: boolean; error: string | null }
|
||||
localState?: { loading: boolean; error: string | null },
|
||||
): Promise<T | null> {
|
||||
const state = localState || globalState
|
||||
|
||||
@@ -58,7 +61,7 @@ export function useApi() {
|
||||
|
||||
return {
|
||||
globalState,
|
||||
handleApiCall
|
||||
handleApiCall,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,35 +69,29 @@ export function useApi() {
|
||||
export function useSensorsApi() {
|
||||
const state = reactive<ApiState>({
|
||||
loading: false,
|
||||
error: null
|
||||
error: null,
|
||||
})
|
||||
|
||||
const sensors = ref<SensorInfo[]>([])
|
||||
const currentSensor = ref<SensorInfo | null>(null)
|
||||
const sensors = ref<SensorDevice[]>([])
|
||||
const currentSensor = ref<SensorDevice | null>(null)
|
||||
const sensorData = ref<DataResponse | null>(null)
|
||||
|
||||
const { handleApiCall } = useApi()
|
||||
|
||||
const fetchSensors = async (params?: {
|
||||
room?: string
|
||||
sensor_type?: any
|
||||
status?: any
|
||||
sensor_type?: SensorType
|
||||
status?: SensorStatus
|
||||
}) => {
|
||||
const result = await handleApiCall(
|
||||
() => sensorsApi.getSensors(params),
|
||||
state
|
||||
)
|
||||
if (result) {
|
||||
sensors.value = result
|
||||
const result = await handleApiCall(() => sensorsApi.getSensors(params), state)
|
||||
if (result && result.sensors) {
|
||||
sensors.value = result.sensors
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const fetchSensor = async (sensorId: string) => {
|
||||
const result = await handleApiCall(
|
||||
() => sensorsApi.getSensor(sensorId),
|
||||
state
|
||||
)
|
||||
const result = await handleApiCall(() => sensorsApi.getSensor(sensorId), state)
|
||||
if (result) {
|
||||
currentSensor.value = result
|
||||
}
|
||||
@@ -108,12 +105,9 @@ export function useSensorsApi() {
|
||||
end_time?: number
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
},
|
||||
) => {
|
||||
const result = await handleApiCall(
|
||||
() => sensorsApi.getSensorData(sensorId, params),
|
||||
state
|
||||
)
|
||||
const result = await handleApiCall(() => sensorsApi.getSensorData(sensorId, params), state)
|
||||
if (result) {
|
||||
sensorData.value = result
|
||||
}
|
||||
@@ -121,27 +115,15 @@ export function useSensorsApi() {
|
||||
}
|
||||
|
||||
const queryData = async (query: DataQuery) => {
|
||||
return handleApiCall(
|
||||
() => sensorsApi.queryData(query),
|
||||
state
|
||||
)
|
||||
return handleApiCall(() => sensorsApi.queryData(query), state)
|
||||
}
|
||||
|
||||
const updateSensorMetadata = async (
|
||||
sensorId: string,
|
||||
metadata: Record<string, any>
|
||||
) => {
|
||||
return handleApiCall(
|
||||
() => sensorsApi.updateSensorMetadata(sensorId, metadata),
|
||||
state
|
||||
)
|
||||
const updateSensorMetadata = async (sensorId: string, metadata: SensorMetadata) => {
|
||||
return handleApiCall(() => sensorsApi.updateSensorMetadata(sensorId, metadata), state)
|
||||
}
|
||||
|
||||
const deleteSensor = async (sensorId: string) => {
|
||||
return handleApiCall(
|
||||
() => sensorsApi.deleteSensor(sensorId),
|
||||
state
|
||||
)
|
||||
return handleApiCall(() => sensorsApi.deleteSensor(sensorId), state)
|
||||
}
|
||||
|
||||
const exportData = async (params: {
|
||||
@@ -150,10 +132,7 @@ export function useSensorsApi() {
|
||||
sensor_ids?: string
|
||||
format?: 'json' | 'csv'
|
||||
}) => {
|
||||
return handleApiCall(
|
||||
() => sensorsApi.exportData(params),
|
||||
state
|
||||
)
|
||||
return handleApiCall(() => sensorsApi.exportData(params), state)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -167,7 +146,7 @@ export function useSensorsApi() {
|
||||
queryData,
|
||||
updateSensorMetadata,
|
||||
deleteSensor,
|
||||
exportData
|
||||
exportData,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +154,7 @@ export function useSensorsApi() {
|
||||
export function useRoomsApi() {
|
||||
const state = reactive<ApiState>({
|
||||
loading: false,
|
||||
error: null
|
||||
error: null,
|
||||
})
|
||||
|
||||
const rooms = ref<RoomInfo[]>([])
|
||||
@@ -184,10 +163,7 @@ export function useRoomsApi() {
|
||||
const { handleApiCall } = useApi()
|
||||
|
||||
const fetchRooms = async () => {
|
||||
const result = await handleApiCall(
|
||||
() => roomsApi.getRooms(),
|
||||
state
|
||||
)
|
||||
const result = await handleApiCall(() => roomsApi.getRooms(), state)
|
||||
if (result) {
|
||||
rooms.value = result
|
||||
}
|
||||
@@ -200,12 +176,9 @@ export function useRoomsApi() {
|
||||
start_time?: number
|
||||
end_time?: number
|
||||
limit?: number
|
||||
}
|
||||
},
|
||||
) => {
|
||||
const result = await handleApiCall(
|
||||
() => roomsApi.getRoomData(roomName, params),
|
||||
state
|
||||
)
|
||||
const result = await handleApiCall(() => roomsApi.getRoomData(roomName, params), state)
|
||||
if (result) {
|
||||
currentRoomData.value = result
|
||||
}
|
||||
@@ -217,7 +190,7 @@ export function useRoomsApi() {
|
||||
rooms,
|
||||
currentRoomData,
|
||||
fetchRooms,
|
||||
fetchRoomData
|
||||
fetchRoomData,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,7 +198,7 @@ export function useRoomsApi() {
|
||||
export function useAnalyticsApi() {
|
||||
const state = reactive<ApiState>({
|
||||
loading: false,
|
||||
error: null
|
||||
error: null,
|
||||
})
|
||||
|
||||
const summary = ref<AnalyticsSummary | null>(null)
|
||||
@@ -236,10 +209,7 @@ export function useAnalyticsApi() {
|
||||
const { handleApiCall } = useApi()
|
||||
|
||||
const fetchAnalyticsSummary = async (hours: number = 24) => {
|
||||
const result = await handleApiCall(
|
||||
() => analyticsApi.getAnalyticsSummary(hours),
|
||||
state
|
||||
)
|
||||
const result = await handleApiCall(() => analyticsApi.getAnalyticsSummary(hours), state)
|
||||
if (result) {
|
||||
summary.value = result
|
||||
}
|
||||
@@ -247,10 +217,7 @@ export function useAnalyticsApi() {
|
||||
}
|
||||
|
||||
const fetchEnergyTrends = async (hours: number = 168) => {
|
||||
const result = await handleApiCall(
|
||||
() => analyticsApi.getEnergyTrends(hours),
|
||||
state
|
||||
)
|
||||
const result = await handleApiCall(() => analyticsApi.getEnergyTrends(hours), state)
|
||||
if (result) {
|
||||
trends.value = result
|
||||
}
|
||||
@@ -258,10 +225,7 @@ export function useAnalyticsApi() {
|
||||
}
|
||||
|
||||
const fetchRoomComparison = async (hours: number = 24) => {
|
||||
const result = await handleApiCall(
|
||||
() => analyticsApi.getRoomComparison(hours),
|
||||
state
|
||||
)
|
||||
const result = await handleApiCall(() => analyticsApi.getRoomComparison(hours), state)
|
||||
if (result) {
|
||||
roomComparison.value = result
|
||||
}
|
||||
@@ -274,10 +238,7 @@ export function useAnalyticsApi() {
|
||||
hours?: number
|
||||
limit?: number
|
||||
}) => {
|
||||
const result = await handleApiCall(
|
||||
() => analyticsApi.getEvents(params),
|
||||
state
|
||||
)
|
||||
const result = await handleApiCall(() => analyticsApi.getEvents(params), state)
|
||||
if (result) {
|
||||
events.value = result.events
|
||||
}
|
||||
@@ -293,7 +254,7 @@ export function useAnalyticsApi() {
|
||||
fetchAnalyticsSummary,
|
||||
fetchEnergyTrends,
|
||||
fetchRoomComparison,
|
||||
fetchEvents
|
||||
fetchEvents,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,7 +262,7 @@ export function useAnalyticsApi() {
|
||||
export function useHealthApi() {
|
||||
const state = reactive<ApiState>({
|
||||
loading: false,
|
||||
error: null
|
||||
error: null,
|
||||
})
|
||||
|
||||
const health = ref<HealthCheck | null>(null)
|
||||
@@ -310,10 +271,7 @@ export function useHealthApi() {
|
||||
const { handleApiCall } = useApi()
|
||||
|
||||
const fetchHealth = async () => {
|
||||
const result = await handleApiCall(
|
||||
() => healthApi.getHealth(),
|
||||
state
|
||||
)
|
||||
const result = await handleApiCall(() => healthApi.getHealth(), state)
|
||||
if (result) {
|
||||
health.value = result
|
||||
}
|
||||
@@ -321,10 +279,7 @@ export function useHealthApi() {
|
||||
}
|
||||
|
||||
const fetchStatus = async () => {
|
||||
const result = await handleApiCall(
|
||||
() => healthApi.getStatus(),
|
||||
state
|
||||
)
|
||||
const result = await handleApiCall(() => healthApi.getStatus(), state)
|
||||
if (result) {
|
||||
status.value = result
|
||||
}
|
||||
@@ -336,6 +291,6 @@ export function useHealthApi() {
|
||||
health,
|
||||
status,
|
||||
fetchHealth,
|
||||
fetchStatus
|
||||
fetchStatus,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import HomeView from '../views/HomeView.vue'
|
||||
import SensorManagementView from '../views/SensorManagementView.vue'
|
||||
import AIOptimizationView from '../views/AIOptimizationView.vue'
|
||||
import ModelsView from '../views/ModelsView.vue'
|
||||
import SettingsView from '../views/SettingsView.vue'
|
||||
import AnalyticsView from '../views/AnalyticsView.vue'
|
||||
|
||||
@@ -19,9 +19,9 @@ const router = createRouter({
|
||||
component: SensorManagementView,
|
||||
},
|
||||
{
|
||||
path: '/ai-optimization',
|
||||
name: 'ai-optimization',
|
||||
component: AIOptimizationView,
|
||||
path: '/models',
|
||||
name: 'models',
|
||||
component: ModelsView,
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
// Base configuration
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'
|
||||
|
||||
// Extend Window interface for auth store
|
||||
interface WindowWithAuth extends Window {
|
||||
__AUTH_STORE__?: {
|
||||
getAuthHeader: () => Record<string, string>
|
||||
ensureAuthenticated: () => Promise<boolean>
|
||||
}
|
||||
}
|
||||
|
||||
// API Response types
|
||||
export interface ApiResponse<T = any> {
|
||||
export interface ApiResponse<T = unknown> {
|
||||
data: T
|
||||
total_count?: number
|
||||
query?: any
|
||||
query?: Record<string, unknown>
|
||||
execution_time_ms?: number
|
||||
}
|
||||
|
||||
@@ -71,7 +79,7 @@ export interface SensorReading {
|
||||
value: number
|
||||
unit: string
|
||||
}
|
||||
metadata?: Record<string, any>
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface RoomInfo {
|
||||
@@ -169,7 +177,7 @@ export interface SystemEvent {
|
||||
event_type: string
|
||||
severity: 'info' | 'warning' | 'error' | 'critical'
|
||||
message: string
|
||||
details?: Record<string, any>
|
||||
details?: Record<string, unknown>
|
||||
sensor_id?: string
|
||||
room?: string
|
||||
}
|
||||
@@ -187,6 +195,9 @@ export interface SensorDevice {
|
||||
actions: SensorAction[]
|
||||
}
|
||||
metadata: SensorMetadata
|
||||
tags?: string[]
|
||||
lastSeen?: number
|
||||
total_readings?: number
|
||||
}
|
||||
|
||||
export interface SensorAction {
|
||||
@@ -231,6 +242,7 @@ export enum SensorStatus {
|
||||
ONLINE = 'online',
|
||||
OFFLINE = 'offline',
|
||||
ERROR = 'error',
|
||||
ACTIVE = 'active',
|
||||
}
|
||||
|
||||
export interface SensorMetadata {
|
||||
@@ -242,6 +254,7 @@ export interface SensorMetadata {
|
||||
model?: string
|
||||
firmware?: string
|
||||
battery?: number
|
||||
signalStrength?: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
manufacturer?: string
|
||||
@@ -277,7 +290,7 @@ class ApiClient {
|
||||
// Dynamically get auth headers to avoid circular imports
|
||||
try {
|
||||
// Try to get from window first (for when store is exposed)
|
||||
const authStore = (window as any).__AUTH_STORE__
|
||||
const authStore = (window as WindowWithAuth).__AUTH_STORE__
|
||||
if (authStore && typeof authStore.getAuthHeader === 'function') {
|
||||
return authStore.getAuthHeader()
|
||||
}
|
||||
@@ -333,7 +346,10 @@ class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
async get<T>(endpoint: string, params?: Record<string, any>): Promise<T> {
|
||||
async get<T>(
|
||||
endpoint: string,
|
||||
params?: Record<string, string | number | boolean | string[]>,
|
||||
): Promise<T> {
|
||||
const url = new URL(`${this.baseUrl}${endpoint}`)
|
||||
|
||||
if (params) {
|
||||
@@ -366,14 +382,14 @@ class ApiClient {
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
async post<T>(endpoint: string, data?: any): Promise<T> {
|
||||
async post<T>(endpoint: string, data?: unknown): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'POST',
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
async put<T>(endpoint: string, data?: any): Promise<T> {
|
||||
async put<T>(endpoint: string, data?: unknown): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'PUT',
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
|
||||
@@ -25,7 +25,10 @@ export const authApi = {
|
||||
},
|
||||
|
||||
async saveToken(token: string): Promise<{ token: string; datetime: string; active: boolean }> {
|
||||
return apiClient.post<{ token: string; datetime: string; active: boolean }>('/api/v1/tokens/save', { token })
|
||||
return apiClient.post<{ token: string; datetime: string; active: boolean }>(
|
||||
'/api/v1/tokens/save',
|
||||
{ token },
|
||||
)
|
||||
},
|
||||
|
||||
async validateToken(token: string): Promise<TokenValidation> {
|
||||
|
||||
@@ -26,5 +26,5 @@ export type {
|
||||
RoomComparison,
|
||||
SystemEvent,
|
||||
HealthCheck,
|
||||
SystemStatus
|
||||
SystemStatus,
|
||||
} from './api'
|
||||
@@ -77,12 +77,22 @@ export const sensorsApi = {
|
||||
}): Promise<{
|
||||
data: SensorReading[]
|
||||
count: number
|
||||
export_params: any
|
||||
export_params: {
|
||||
start_time: number
|
||||
end_time: number
|
||||
sensor_ids?: string
|
||||
format?: 'json' | 'csv'
|
||||
}
|
||||
}> {
|
||||
return apiClient.get<{
|
||||
data: SensorReading[]
|
||||
count: number
|
||||
export_params: any
|
||||
export_params: {
|
||||
start_time: number
|
||||
end_time: number
|
||||
sensor_ids?: string
|
||||
format?: 'json' | 'csv'
|
||||
}
|
||||
}>('/api/v1/export', params)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -10,6 +10,13 @@ import {
|
||||
type HealthCheck,
|
||||
} from '@/services'
|
||||
|
||||
// Extend Window interface for auth store
|
||||
interface WindowWithAuth extends Window {
|
||||
__AUTH_STORE__?: {
|
||||
ensureAuthenticated: () => Promise<boolean>
|
||||
}
|
||||
}
|
||||
|
||||
export const useAnalyticsStore = defineStore('analytics', () => {
|
||||
// State
|
||||
const analyticsData = ref<{
|
||||
@@ -41,7 +48,7 @@ export const useAnalyticsStore = defineStore('analytics', () => {
|
||||
console.warn('Authentication error detected, attempting to re-authenticate...')
|
||||
|
||||
try {
|
||||
const authStore = (window as any).__AUTH_STORE__
|
||||
const authStore = (window as WindowWithAuth).__AUTH_STORE__
|
||||
if (authStore && typeof authStore.ensureAuthenticated === 'function') {
|
||||
const authSuccess = await authStore.ensureAuthenticated()
|
||||
if (authSuccess) {
|
||||
@@ -124,11 +131,7 @@ export const useAnalyticsStore = defineStore('analytics', () => {
|
||||
|
||||
// Initialize data from APIs
|
||||
async function initializeAnalyticsFromApi() {
|
||||
await Promise.allSettled([
|
||||
fetchAnalyticsSummary(),
|
||||
fetchSystemStatus(),
|
||||
fetchHealthStatus(),
|
||||
])
|
||||
await Promise.allSettled([fetchAnalyticsSummary(), fetchSystemStatus(), fetchHealthStatus()])
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -199,7 +199,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
|
||||
// Initialize on store creation (async)
|
||||
loadTokenFromStorage().catch(error => {
|
||||
loadTokenFromStorage().catch((error) => {
|
||||
console.warn('Failed to load token from storage:', error)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, reactive, watch } from 'vue'
|
||||
import { useWebSocketStore } from './websocket'
|
||||
import { useSensorStore } from './sensor'
|
||||
import { useRoomStore } from './room'
|
||||
import { useAnalyticsStore } from './analytics'
|
||||
|
||||
const MAX_HISTORY_POINTS = 48
|
||||
|
||||
/**
|
||||
* Energy Store - Simplified to only track energy consumption metrics
|
||||
* For sensor data: use useSensorStore()
|
||||
@@ -20,34 +22,66 @@ export const useEnergyStore = defineStore('energy', () => {
|
||||
const analyticsStore = useAnalyticsStore()
|
||||
|
||||
// Energy-specific state
|
||||
const currentConsumption = ref(0) // Current energy consumption in kWh
|
||||
const averageConsumption = ref(0) // Average energy consumption in kWh
|
||||
const currentConsumption = ref<number>(0) // Current energy consumption in kWh
|
||||
const averageConsumption = ref<number>(0) // Average energy consumption in kWh
|
||||
|
||||
// Computed: Current energy value from WebSocket
|
||||
const currentEnergyValue = computed(() => {
|
||||
return websocketStore.latestMessage?.energy?.value || 0
|
||||
// Track aggregated energy over time
|
||||
const energyHistory = reactive<number[]>([])
|
||||
const energyTimestamps = reactive<string[]>([])
|
||||
|
||||
// Computed: Current total energy value - sum of all sensor readings
|
||||
const currentEnergyValue = computed<number>(() => {
|
||||
// Sum energy values from all sensors' latest readings
|
||||
let totalEnergy: number = 0
|
||||
const readings = Array.from(sensorStore.latestReadings.values())
|
||||
|
||||
readings.forEach((reading) => {
|
||||
if (reading.energy?.value) {
|
||||
totalEnergy += reading.energy.value
|
||||
}
|
||||
})
|
||||
|
||||
return totalEnergy
|
||||
})
|
||||
|
||||
// Computed: Average energy usage from time series
|
||||
const averageEnergyUsage = computed(() => {
|
||||
const data = websocketStore.timeSeriesData.datasets[0].data
|
||||
if (data.length === 0) return 0
|
||||
const sum = data.reduce((acc, val) => acc + val, 0)
|
||||
return sum / data.length
|
||||
// Computed: Average energy usage from history
|
||||
const averageEnergyUsage = computed<number>(() => {
|
||||
if (energyHistory.length === 0) return 0
|
||||
const sum: number = energyHistory.reduce((acc: number, val: number) => acc + val, 0)
|
||||
return sum / energyHistory.length
|
||||
})
|
||||
|
||||
// Watch for changes in sensor readings and update history
|
||||
watch(
|
||||
() => sensorStore.latestReadings.size,
|
||||
() => {
|
||||
const currentTotal: number = currentEnergyValue.value
|
||||
const timestamp: string = new Date().toLocaleTimeString()
|
||||
|
||||
// Add to history
|
||||
energyHistory.push(currentTotal)
|
||||
energyTimestamps.push(timestamp)
|
||||
|
||||
// Keep only the latest MAX_HISTORY_POINTS
|
||||
if (energyHistory.length > MAX_HISTORY_POINTS) {
|
||||
energyHistory.shift()
|
||||
energyTimestamps.shift()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Update current consumption (called from components or watchers)
|
||||
function updateCurrentConsumption(value: number) {
|
||||
function updateCurrentConsumption(value: number): void {
|
||||
currentConsumption.value = value
|
||||
}
|
||||
|
||||
// Update average consumption (called from components or watchers)
|
||||
function updateAverageConsumption(value: number) {
|
||||
function updateAverageConsumption(value: number): void {
|
||||
averageConsumption.value = value
|
||||
}
|
||||
|
||||
// Initialize data from APIs (convenience function for AnalyticsView)
|
||||
async function initializeFromApi() {
|
||||
async function initializeFromApi(): Promise<void> {
|
||||
await Promise.allSettled([
|
||||
roomStore.loadRoomsFromAPI(),
|
||||
sensorStore.fetchApiSensors(),
|
||||
@@ -62,6 +96,8 @@ export const useEnergyStore = defineStore('energy', () => {
|
||||
averageConsumption,
|
||||
currentEnergyValue,
|
||||
averageEnergyUsage,
|
||||
energyHistory,
|
||||
energyTimestamps,
|
||||
|
||||
// Energy-specific actions
|
||||
updateCurrentConsumption,
|
||||
|
||||
@@ -3,16 +3,25 @@ import { ref, reactive } from 'vue'
|
||||
import { roomsApi, type RoomInfo as ApiRoomInfo, type SensorReading } from '@/services'
|
||||
import { useSensorStore } from './sensor'
|
||||
|
||||
// Extend Window interface for auth store
|
||||
interface WindowWithAuth extends Window {
|
||||
__AUTH_STORE__?: {
|
||||
ensureAuthenticated: () => Promise<boolean>
|
||||
}
|
||||
}
|
||||
|
||||
interface RoomMetrics {
|
||||
room: string
|
||||
sensors: string[]
|
||||
energy: {
|
||||
energySensors: string[] // Track which sensors provide energy data
|
||||
co2Sensors: string[] // Track which sensors provide CO2 data
|
||||
energy?: {
|
||||
current: number
|
||||
total: number
|
||||
average: number
|
||||
unit: string
|
||||
}
|
||||
co2: {
|
||||
co2?: {
|
||||
current: number
|
||||
average: number
|
||||
max: number
|
||||
@@ -30,16 +39,16 @@ export const useRoomStore = defineStore('room', () => {
|
||||
const apiRooms = ref<ApiRoomInfo[]>([])
|
||||
const roomsLoading = ref<boolean>(false)
|
||||
const roomsLoaded = ref<boolean>(false)
|
||||
const apiLoading = ref(false)
|
||||
const apiLoading = ref<boolean>(false)
|
||||
const apiError = ref<string | null>(null)
|
||||
|
||||
// Actions
|
||||
function updateRoomData(data: SensorReading) {
|
||||
function updateRoomData(data: SensorReading): void {
|
||||
const sensorStore = useSensorStore()
|
||||
|
||||
// Validate data structure and provide fallbacks
|
||||
if (!data.energy || !data.co2) {
|
||||
console.warn('Invalid sensor reading data, missing energy or co2 properties:', data)
|
||||
// Accept partial readings - validate that we have at least room and sensor_id
|
||||
if (!data.room || !data.sensor_id) {
|
||||
console.warn('Invalid sensor reading data, missing room or sensor_id:', data)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -50,11 +59,12 @@ export const useRoomStore = defineStore('room', () => {
|
||||
let roomMetrics = roomsData.get(data.room)
|
||||
|
||||
if (!roomMetrics) {
|
||||
// Initialize with minimal required fields - energy and co2 are optional
|
||||
roomMetrics = {
|
||||
room: data.room,
|
||||
sensors: [data.sensor_id],
|
||||
energy: { current: 0, total: 0, average: 0, unit: data.energy?.unit || 'kWh' },
|
||||
co2: { current: 0, average: 0, max: 0, status: 'good', unit: data.co2?.unit || 'ppm' },
|
||||
sensors: [],
|
||||
energySensors: [],
|
||||
co2Sensors: [],
|
||||
occupancyEstimate: 'low',
|
||||
lastUpdated: data.timestamp,
|
||||
}
|
||||
@@ -66,32 +76,78 @@ export const useRoomStore = defineStore('room', () => {
|
||||
roomMetrics.sensors.push(data.sensor_id)
|
||||
}
|
||||
|
||||
// Track which sensors provide which metrics
|
||||
if (data.energy?.value !== undefined && !roomMetrics.energySensors.includes(data.sensor_id)) {
|
||||
roomMetrics.energySensors.push(data.sensor_id)
|
||||
}
|
||||
if (data.co2?.value !== undefined && !roomMetrics.co2Sensors.includes(data.sensor_id)) {
|
||||
roomMetrics.co2Sensors.push(data.sensor_id)
|
||||
}
|
||||
|
||||
// Recalculate room metrics from all sensors in the room
|
||||
const roomSensors = Array.from(sensorStore.latestReadings.values()).filter(
|
||||
(reading) => reading.room === data.room,
|
||||
)
|
||||
|
||||
// Energy calculations
|
||||
roomMetrics.energy.current = roomSensors.reduce((sum, sensor) => sum + sensor.energy.value, 0)
|
||||
roomMetrics.energy.total += data.energy.value // Accumulate total
|
||||
roomMetrics.energy.average = roomMetrics.energy.total / roomSensors.length
|
||||
// Energy calculations - only if energy data is present in ANY sensor
|
||||
const energySensors = roomSensors.filter((sensor) => sensor.energy?.value !== undefined)
|
||||
if (energySensors.length > 0) {
|
||||
// Initialize energy object if it doesn't exist
|
||||
if (!roomMetrics.energy) {
|
||||
roomMetrics.energy = {
|
||||
current: 0,
|
||||
total: 0,
|
||||
average: 0,
|
||||
unit: data.energy?.unit || 'kWh',
|
||||
}
|
||||
}
|
||||
|
||||
// CO2 calculations
|
||||
const co2Values = roomSensors.map((sensor) => sensor.co2.value)
|
||||
roomMetrics.co2.current = co2Values.reduce((sum, val) => sum + val, 0) / co2Values.length
|
||||
roomMetrics.co2.max = Math.max(roomMetrics.co2.max, ...co2Values)
|
||||
roomMetrics.co2.average = (roomMetrics.co2.average + roomMetrics.co2.current) / 2
|
||||
roomMetrics.energy.current = energySensors.reduce(
|
||||
(sum, sensor) => sum + (sensor.energy?.value || 0),
|
||||
0,
|
||||
)
|
||||
if (data.energy?.value !== undefined) {
|
||||
roomMetrics.energy.total += data.energy.value // Accumulate total only for this reading
|
||||
}
|
||||
roomMetrics.energy.average = roomMetrics.energy.total / energySensors.length
|
||||
if (data.energy?.unit) {
|
||||
roomMetrics.energy.unit = data.energy.unit
|
||||
}
|
||||
}
|
||||
|
||||
// CO2 status classification
|
||||
if (roomMetrics.co2.current < 400) roomMetrics.co2.status = 'good'
|
||||
else if (roomMetrics.co2.current < 1000) roomMetrics.co2.status = 'moderate'
|
||||
else if (roomMetrics.co2.current < 5000) roomMetrics.co2.status = 'poor'
|
||||
else roomMetrics.co2.status = 'critical'
|
||||
// CO2 calculations - only if co2 data is present in ANY sensor
|
||||
const co2Sensors = roomSensors.filter((sensor) => sensor.co2?.value !== undefined)
|
||||
if (co2Sensors.length > 0) {
|
||||
// Initialize co2 object if it doesn't exist
|
||||
if (!roomMetrics.co2) {
|
||||
roomMetrics.co2 = {
|
||||
current: 0,
|
||||
average: 0,
|
||||
max: 0,
|
||||
status: 'good',
|
||||
unit: data.co2?.unit || 'ppm',
|
||||
}
|
||||
}
|
||||
|
||||
// Occupancy estimate based on CO2 levels
|
||||
if (roomMetrics.co2.current < 600) roomMetrics.occupancyEstimate = 'low'
|
||||
else if (roomMetrics.co2.current < 1200) roomMetrics.occupancyEstimate = 'medium'
|
||||
else roomMetrics.occupancyEstimate = 'high'
|
||||
const co2Values = co2Sensors.map((sensor) => sensor.co2?.value || 0)
|
||||
roomMetrics.co2.current = co2Values.reduce((sum, val) => sum + val, 0) / co2Values.length
|
||||
roomMetrics.co2.max = Math.max(roomMetrics.co2.max, ...co2Values)
|
||||
roomMetrics.co2.average = (roomMetrics.co2.average + roomMetrics.co2.current) / 2
|
||||
if (data.co2?.unit) {
|
||||
roomMetrics.co2.unit = data.co2.unit
|
||||
}
|
||||
|
||||
// CO2 status classification
|
||||
if (roomMetrics.co2.current < 400) roomMetrics.co2.status = 'good'
|
||||
else if (roomMetrics.co2.current < 1000) roomMetrics.co2.status = 'moderate'
|
||||
else if (roomMetrics.co2.current < 5000) roomMetrics.co2.status = 'poor'
|
||||
else roomMetrics.co2.status = 'critical'
|
||||
|
||||
// Occupancy estimate based on CO2 levels
|
||||
if (roomMetrics.co2.current < 600) roomMetrics.occupancyEstimate = 'low'
|
||||
else if (roomMetrics.co2.current < 1200) roomMetrics.occupancyEstimate = 'medium'
|
||||
else roomMetrics.occupancyEstimate = 'high'
|
||||
}
|
||||
|
||||
roomMetrics.lastUpdated = data.timestamp
|
||||
}
|
||||
@@ -169,7 +225,14 @@ export const useRoomStore = defineStore('room', () => {
|
||||
return true
|
||||
}
|
||||
|
||||
function getRoomStats(roomName: string) {
|
||||
function getRoomStats(roomName: string): {
|
||||
sensorCount: number
|
||||
sensorTypes: string[]
|
||||
hasMetrics: boolean
|
||||
energyConsumption: number
|
||||
co2Level: number
|
||||
lastUpdated: number | null
|
||||
} {
|
||||
const sensorStore = useSensorStore()
|
||||
const sensorsInRoom = sensorStore.getSensorsByRoom(roomName)
|
||||
const roomMetrics = roomsData.get(roomName)
|
||||
@@ -178,13 +241,21 @@ export const useRoomStore = defineStore('room', () => {
|
||||
sensorCount: sensorsInRoom.length,
|
||||
sensorTypes: [...new Set(sensorsInRoom.map((s) => s.type))],
|
||||
hasMetrics: !!roomMetrics,
|
||||
energyConsumption: roomMetrics?.energy.current || 0,
|
||||
co2Level: roomMetrics?.co2.current || 0,
|
||||
energyConsumption: roomMetrics?.energy?.current || 0,
|
||||
co2Level: roomMetrics?.co2?.current || 0,
|
||||
lastUpdated: roomMetrics?.lastUpdated || null,
|
||||
}
|
||||
}
|
||||
|
||||
function getAllRoomsWithStats() {
|
||||
function getAllRoomsWithStats(): Array<{
|
||||
name: string
|
||||
sensorCount: number
|
||||
sensorTypes: string[]
|
||||
hasMetrics: boolean
|
||||
energyConsumption: number
|
||||
co2Level: number
|
||||
lastUpdated: number | null
|
||||
}> {
|
||||
return availableRooms.value.map((room) => ({
|
||||
name: room,
|
||||
...getRoomStats(room),
|
||||
@@ -206,7 +277,7 @@ export const useRoomStore = defineStore('room', () => {
|
||||
console.warn('Authentication error detected, attempting to re-authenticate...')
|
||||
|
||||
try {
|
||||
const authStore = (window as any).__AUTH_STORE__
|
||||
const authStore = (window as WindowWithAuth).__AUTH_STORE__
|
||||
if (authStore && typeof authStore.ensureAuthenticated === 'function') {
|
||||
const authSuccess = await authStore.ensureAuthenticated()
|
||||
if (authSuccess) {
|
||||
@@ -241,11 +312,13 @@ export const useRoomStore = defineStore('room', () => {
|
||||
const result = await handleApiCall(() => roomsApi.getRooms())
|
||||
if (result) {
|
||||
// Handle both response formats: {rooms: [...]} or direct array [...]
|
||||
const roomsArray = Array.isArray(result) ? result : result.rooms || []
|
||||
const roomsArray = Array.isArray(result) ? result : (result as any).rooms || []
|
||||
apiRooms.value = roomsArray
|
||||
|
||||
// Update available rooms from API data
|
||||
const roomNames = roomsArray.map((room) => room.name || room.room).filter((name) => name)
|
||||
const roomNames = roomsArray
|
||||
.map((room: any) => room.name || room.room)
|
||||
.filter((name: string) => name)
|
||||
if (roomNames.length > 0) {
|
||||
availableRooms.value = [...new Set([...availableRooms.value, ...roomNames])].sort()
|
||||
}
|
||||
|
||||
@@ -6,16 +6,24 @@ import {
|
||||
type SensorDevice,
|
||||
type SensorStatus,
|
||||
type SensorReading,
|
||||
type SensorMetadata,
|
||||
} from '@/services'
|
||||
|
||||
// Extend Window interface for auth store
|
||||
interface WindowWithAuth extends Window {
|
||||
__AUTH_STORE__?: {
|
||||
ensureAuthenticated: () => Promise<boolean>
|
||||
}
|
||||
}
|
||||
|
||||
export const useSensorStore = defineStore('sensor', () => {
|
||||
// State
|
||||
const sensorDevices = reactive<Map<string, SensorDevice>>(new Map())
|
||||
const latestReadings = reactive<Map<string, SensorReading>>(new Map())
|
||||
const sensorsData = reactive<Map<string, any>>(new Map()) // Legacy support
|
||||
const sensorsData = reactive<Map<string, SensorReading>>(new Map()) // Legacy support - deprecated, use latestReadings instead
|
||||
const recentlyUpdatedSensors = reactive<Set<string>>(new Set()) // Track recently updated sensors
|
||||
const totalReadings = ref(0) // Total number of readings across all sensors
|
||||
const apiLoading = ref(false)
|
||||
const totalReadings = ref<number>(0) // Total number of readings across all sensors
|
||||
const apiLoading = ref<boolean>(false)
|
||||
const apiError = ref<string | null>(null)
|
||||
|
||||
// Computed properties
|
||||
@@ -23,12 +31,32 @@ export const useSensorStore = defineStore('sensor', () => {
|
||||
|
||||
const activeSensors = computed(() => {
|
||||
return Array.from(sensorDevices.values()).filter(
|
||||
sensor => sensor.status === 'active' || sensor.status === 'online'
|
||||
(sensor) => sensor.status === 'active' || sensor.status === 'online',
|
||||
).length
|
||||
})
|
||||
|
||||
// Aggregated CO2 metrics
|
||||
const averageCO2Level = computed<number>(() => {
|
||||
const readings = Array.from(latestReadings.values())
|
||||
const co2Readings = readings.filter((r) => r.co2?.value !== undefined)
|
||||
|
||||
if (co2Readings.length === 0) return 0
|
||||
|
||||
const totalCO2 = co2Readings.reduce((sum, r) => sum + (r.co2?.value || 0), 0)
|
||||
return totalCO2 / co2Readings.length
|
||||
})
|
||||
|
||||
const maxCO2Level = computed<number>(() => {
|
||||
const readings = Array.from(latestReadings.values())
|
||||
const co2Values = readings
|
||||
.filter((r) => r.co2?.value !== undefined)
|
||||
.map((r) => r.co2?.value || 0)
|
||||
|
||||
return co2Values.length > 0 ? Math.max(...co2Values) : 0
|
||||
})
|
||||
|
||||
// Actions
|
||||
function updateSensorRoom(sensorId: string, newRoom: string) {
|
||||
function updateSensorRoom(sensorId: string, newRoom: string): void {
|
||||
const sensor = sensorDevices.get(sensorId)
|
||||
if (sensor) {
|
||||
sensor.room = newRoom
|
||||
@@ -36,14 +64,14 @@ export const useSensorStore = defineStore('sensor', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function executeSensorAction(sensorId: string, actionId: string) {
|
||||
async function executeSensorAction(sensorId: string, actionId: string): Promise<boolean> {
|
||||
const sensor = sensorDevices.get(sensorId)
|
||||
if (!sensor) return false
|
||||
|
||||
const action = sensor.capabilities.actions.find((a) => a.id === actionId)
|
||||
if (!action) return false
|
||||
|
||||
return new Promise((resolve) => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
setTimeout(() => {
|
||||
console.log(`Action ${action.name} executed successfully on ${sensor.name}`)
|
||||
resolve(true)
|
||||
@@ -59,11 +87,11 @@ export const useSensorStore = defineStore('sensor', () => {
|
||||
return Array.from(sensorDevices.values()).filter((sensor) => sensor.type === type)
|
||||
}
|
||||
|
||||
function updateEnergySensors(data: Sensor) {
|
||||
function updateEnergySensors(data: SensorReading): void {
|
||||
console.log(data)
|
||||
}
|
||||
|
||||
function updateLatestReading(reading: SensorReading) {
|
||||
function updateLatestReading(reading: SensorReading): void {
|
||||
latestReadings.set(reading.sensor_id, reading)
|
||||
|
||||
// Increment total readings count
|
||||
@@ -93,7 +121,7 @@ export const useSensorStore = defineStore('sensor', () => {
|
||||
console.warn('Authentication error detected, attempting to re-authenticate...')
|
||||
|
||||
try {
|
||||
const authStore = (window as any).__AUTH_STORE__
|
||||
const authStore = (window as WindowWithAuth).__AUTH_STORE__
|
||||
if (authStore && typeof authStore.ensureAuthenticated === 'function') {
|
||||
const authSuccess = await authStore.ensureAuthenticated()
|
||||
if (authSuccess) {
|
||||
@@ -188,15 +216,14 @@ export const useSensorStore = defineStore('sensor', () => {
|
||||
}) {
|
||||
const result = await handleApiCall(() => sensorsApi.getSensors(params))
|
||||
if (result) {
|
||||
console.log(result)
|
||||
// Check if result has a sensors property (common API pattern)
|
||||
if (result.sensors && Array.isArray(result.sensors)) {
|
||||
let totalReadingsCount = 0
|
||||
let totalReadingsCount: number = 0
|
||||
|
||||
result.sensors.forEach((sensor) => {
|
||||
const sensorKey = sensor._id || sensor.sensor_id
|
||||
const sensorType = sensor.sensor_type || sensor.type
|
||||
const sensorName = sensor.name || ''
|
||||
const sensorKey: string = sensor.sensor_id
|
||||
const sensorType: string = sensor.sensor_type || sensor.type
|
||||
const sensorName: string = sensor.name || ''
|
||||
|
||||
// Accumulate total readings
|
||||
if (sensor.total_readings) {
|
||||
@@ -205,14 +232,12 @@ export const useSensorStore = defineStore('sensor', () => {
|
||||
|
||||
const normalizedSensor = {
|
||||
...sensor,
|
||||
id: sensorKey,
|
||||
type: sensorType,
|
||||
capabilities: {
|
||||
actions: [], // Default empty actions array
|
||||
monitoring:
|
||||
sensor.capabilities?.monitoring ||
|
||||
getDefaultMonitoringCapabilities(sensorType, sensorName),
|
||||
...sensor.capabilities,
|
||||
actions: sensor.capabilities?.actions || [],
|
||||
},
|
||||
metadata: {
|
||||
model: sensor.metadata?.model || 'Unknown',
|
||||
@@ -222,10 +247,10 @@ export const useSensorStore = defineStore('sensor', () => {
|
||||
signalStrength: sensor.metadata?.signalStrength,
|
||||
...sensor.metadata,
|
||||
},
|
||||
lastSeen: sensor.last_seen || Date.now() / 1000,
|
||||
lastSeen: sensor.lastSeen || Date.now() / 1000,
|
||||
}
|
||||
|
||||
sensorDevices.set(sensorKey, normalizedSensor)
|
||||
sensorDevices.set(sensorKey, normalizedSensor as SensorDevice)
|
||||
})
|
||||
|
||||
// Update total readings
|
||||
@@ -247,7 +272,7 @@ export const useSensorStore = defineStore('sensor', () => {
|
||||
return handleApiCall(() => sensorsApi.getSensorData(sensorId, params))
|
||||
}
|
||||
|
||||
async function updateApiSensorMetadata(sensorId: string, metadata: Record<string, any>) {
|
||||
async function updateApiSensorMetadata(sensorId: string, metadata: SensorMetadata) {
|
||||
return handleApiCall(() => sensorsApi.updateSensorMetadata(sensorId, metadata))
|
||||
}
|
||||
|
||||
@@ -277,6 +302,8 @@ export const useSensorStore = defineStore('sensor', () => {
|
||||
// Computed
|
||||
totalSensors,
|
||||
activeSensors,
|
||||
averageCO2Level,
|
||||
maxCO2Level,
|
||||
|
||||
// Actions
|
||||
updateEnergySensors,
|
||||
|
||||
@@ -33,6 +33,18 @@ interface AppSettings {
|
||||
developerMode: boolean
|
||||
}
|
||||
|
||||
// Type for setting values
|
||||
type SettingValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| Theme
|
||||
| Language
|
||||
| NavigationMode
|
||||
| UISettings
|
||||
| NotificationSettings
|
||||
| AppSettings
|
||||
|
||||
const DEFAULT_SETTINGS: AppSettings = {
|
||||
theme: 'system',
|
||||
language: 'en',
|
||||
@@ -93,8 +105,9 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
saveSettings()
|
||||
}
|
||||
|
||||
function updateSetting(path: string, value: any) {
|
||||
function updateSetting(path: string, value: SettingValue): void {
|
||||
const keys = path.split('.')
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let current: any = settings
|
||||
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
@@ -105,8 +118,9 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
saveSettings()
|
||||
}
|
||||
|
||||
function getSetting(path: string): any {
|
||||
function getSetting(path: string): SettingValue | undefined {
|
||||
const keys = path.split('.')
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let current: any = settings
|
||||
|
||||
for (const key of keys) {
|
||||
@@ -114,7 +128,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
if (current === undefined) break
|
||||
}
|
||||
|
||||
return current
|
||||
return current as SettingValue | undefined
|
||||
}
|
||||
|
||||
function exportSettings(): string {
|
||||
|
||||
@@ -5,15 +5,15 @@ import { useRoomStore } from './room'
|
||||
|
||||
const MAX_DATA_POINTS = 100
|
||||
|
||||
interface SensorReading {
|
||||
interface WebSocketReading {
|
||||
sensorId: string
|
||||
room: string
|
||||
timestamp: number
|
||||
energy: {
|
||||
energy?: {
|
||||
value: number
|
||||
unit: string
|
||||
}
|
||||
co2: {
|
||||
co2?: {
|
||||
value: number
|
||||
unit: string
|
||||
}
|
||||
@@ -24,8 +24,8 @@ interface SensorReading {
|
||||
}
|
||||
|
||||
export const useWebSocketStore = defineStore('websocket', () => {
|
||||
const isConnected = ref(false)
|
||||
const latestMessage = ref<SensorReading | null>(null)
|
||||
const isConnected = ref<boolean>(false)
|
||||
const latestMessage = ref<WebSocketReading | null>(null)
|
||||
const timeSeriesData = reactive<{
|
||||
labels: string[]
|
||||
datasets: { data: number[] }[]
|
||||
@@ -35,9 +35,9 @@ export const useWebSocketStore = defineStore('websocket', () => {
|
||||
})
|
||||
|
||||
let socket: WebSocket | null = null
|
||||
const newDataBuffer: SensorReading[] = []
|
||||
const newDataBuffer: WebSocketReading[] = []
|
||||
|
||||
function connect(url: string) {
|
||||
function connect(url: string): void {
|
||||
if (isConnected.value && socket) {
|
||||
console.log('Already connected.')
|
||||
return
|
||||
@@ -109,40 +109,40 @@ export const useWebSocketStore = defineStore('websocket', () => {
|
||||
}, 500)
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
function disconnect(): void {
|
||||
if (socket) {
|
||||
socket.close()
|
||||
}
|
||||
}
|
||||
|
||||
function processIncomingData(data: SensorReading) {
|
||||
function processIncomingData(data: WebSocketReading): void {
|
||||
// Skip non-data messages
|
||||
if ('type' in data && (data.type === 'connection_established' || data.type === 'proxy_info')) {
|
||||
if (
|
||||
('type' in data && (data as any).type === 'connection_established') ||
|
||||
(data as any).type === 'proxy_info'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const sensorStore = useSensorStore()
|
||||
const roomStore = useRoomStore()
|
||||
|
||||
// Handle new multi-metric data
|
||||
// Only update room data if we have the proper structure
|
||||
if (data.energy && data.co2 && data.room) {
|
||||
// Update individual sensor readings first
|
||||
sensorStore.updateLatestReading(data as any)
|
||||
|
||||
// Update room data if we have room information (accepts partial readings)
|
||||
if (data.room) {
|
||||
if (data.energy) {
|
||||
sensorStore.updateEnergySensors(data)
|
||||
sensorStore.updateEnergySensors(data as any)
|
||||
}
|
||||
roomStore.updateRoomData(data)
|
||||
roomStore.updateRoomData(data as any)
|
||||
}
|
||||
|
||||
// Map the sensor ID for individual sensor updates
|
||||
// const mappedSensorId = mapWebSocketSensorId(data.sensorId)
|
||||
const mappedData = { ...data, sensorId: data.sensorId, id: data.sensorId }
|
||||
sensorStore.updateLatestReading(data) // Update individual sensor readings for cards
|
||||
|
||||
// Update time series for chart if energy data is available
|
||||
if (data.energy) {
|
||||
// Update time series for chart (use energy values if available)
|
||||
const newLabel = new Date(data.timestamp * 1000).toLocaleTimeString()
|
||||
timeSeriesData.labels.push(newLabel)
|
||||
timeSeriesData.datasets[0].data.push(data.energy?.value)
|
||||
timeSeriesData.datasets[0].data.push(data.energy.value)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,220 +1,302 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<div class="px-4 py-6 mx-auto max-w-7xl sm:px-6 lg:px-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">API Dashboard</h1>
|
||||
<p class="text-gray-600 mt-2">Real-time data from backend APIs</p>
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Analytics</h1>
|
||||
<p class="text-gray-600">Manage sensors, assign rooms, and control device actions</p>
|
||||
</div>
|
||||
|
||||
<!-- API Status Section -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="p-2 bg-blue-100 rounded-lg">
|
||||
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600">System Status</p>
|
||||
<p class="text-lg font-semibold" :class="healthStatus?.status === 'healthy' ? 'text-green-600' : 'text-red-600'">
|
||||
{{ healthStatus?.status || 'Unknown' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="p-2 bg-green-100 rounded-lg">
|
||||
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600">Total Sensors</p>
|
||||
<p class="text-lg font-semibold text-gray-900">{{ sensorStore.totalSensors }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="p-2 bg-yellow-100 rounded-lg">
|
||||
<svg class="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600">Active Sensors</p>
|
||||
<p class="text-lg font-semibold text-gray-900">{{ sensorStore.activeSensors }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="p-2 bg-purple-100 rounded-lg">
|
||||
<svg class="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600">Total Readings</p>
|
||||
<p class="text-lg font-semibold text-gray-900">{{ formatNumber(sensorStore.totalReadings) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading States -->
|
||||
<div v-if="isLoading" class="bg-white rounded-lg shadow p-6 mb-8">
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<p class="ml-3 text-gray-600">Loading API data...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error States -->
|
||||
<div v-if="apiError" class="bg-red-50 border border-red-200 rounded-lg p-4 mb-8">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||
</div>
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">API Dashboard</h1>
|
||||
<p class="text-gray-600 mt-2">Real-time data from backend APIs</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="p-2 bg-blue-100 rounded-lg">
|
||||
<svg
|
||||
class="w-6 h-6 text-blue-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800">API Error</h3>
|
||||
<p class="mt-1 text-sm text-red-700">{{ apiError }}</p>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600">System Status</p>
|
||||
<p
|
||||
class="text-lg font-semibold"
|
||||
:class="healthStatus?.status === 'healthy' ? 'text-green-600' : 'text-red-600'"
|
||||
>
|
||||
{{ healthStatus?.status || 'Unknown' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Energy Metrics Summary -->
|
||||
<div class="bg-white rounded-lg shadow p-6 mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Energy Metrics</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 mb-2">Current Energy Consumption</p>
|
||||
<p class="text-2xl font-bold text-blue-600">
|
||||
{{ energyStore.currentEnergyValue.toFixed(2) }} kWh
|
||||
</p>
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="p-2 bg-green-100 rounded-lg">
|
||||
<svg
|
||||
class="w-6 h-6 text-green-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 mb-2">Average Energy Usage</p>
|
||||
<p class="text-2xl font-bold text-green-600">
|
||||
{{ energyStore.averageEnergyUsage.toFixed(2) }} kWh
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 mb-2">Total Consumption</p>
|
||||
<p class="text-2xl font-bold text-purple-600">
|
||||
{{ energyStore.currentConsumption.toFixed(2) }} kWh
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Cumulative</p>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600">Total Sensors</p>
|
||||
<p class="text-lg font-semibold text-gray-900">{{ sensorStore.totalSensors }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sensors Section -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-semibold text-gray-900">API Sensors</h2>
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="p-2 bg-yellow-100 rounded-lg">
|
||||
<svg
|
||||
class="w-6 h-6 text-yellow-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div v-if="apiSensors.length === 0" class="text-center text-gray-500 py-8">
|
||||
No sensors found from API
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="sensor in apiSensors" :key="sensor.sensor_id"
|
||||
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<p class="font-medium text-gray-900">{{ sensor.sensor_id }}</p>
|
||||
<p class="text-sm text-gray-500">{{ sensor.room || 'No room assigned' }}</p>
|
||||
<p class="text-xs text-gray-400">{{ sensor.sensor_type }} • {{ sensor.total_readings }} readings</p>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span :class="[
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600">Active Sensors</p>
|
||||
<p class="text-lg font-semibold text-gray-900">{{ sensorStore.activeSensors }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="p-2 bg-purple-100 rounded-lg">
|
||||
<svg
|
||||
class="w-6 h-6 text-purple-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600">Total Readings</p>
|
||||
<p class="text-lg font-semibold text-gray-900">
|
||||
{{ formatNumber(sensorStore.totalReadings) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading States -->
|
||||
<div v-if="isLoading" class="bg-white rounded-lg shadow p-6 mb-8">
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<p class="ml-3 text-gray-600">Loading API data...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error States -->
|
||||
<div v-if="apiError" class="bg-red-50 border border-red-200 rounded-lg p-4 mb-8">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800">API Error</h3>
|
||||
<p class="mt-1 text-sm text-red-700">{{ apiError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Energy Metrics Summary -->
|
||||
<div class="bg-white rounded-lg shadow p-6 mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Energy Metrics</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 mb-2">Current Energy Consumption</p>
|
||||
<p class="text-2xl font-bold text-blue-600">
|
||||
{{ energyStore.currentEnergyValue.toFixed(2) }} kWh
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 mb-2">Average Energy Usage</p>
|
||||
<p class="text-2xl font-bold text-green-600">
|
||||
{{ energyStore.averageEnergyUsage.toFixed(2) }} kWh
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 mb-2">Total Consumption</p>
|
||||
<p class="text-2xl font-bold text-purple-600">
|
||||
{{ energyStore.currentConsumption.toFixed(2) }} kWh
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Cumulative</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sensors Section -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-semibold text-gray-900">API Sensors</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div v-if="apiSensors.length === 0" class="text-center text-gray-500 py-8">
|
||||
No sensors found from API
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="sensor in apiSensors"
|
||||
:key="sensor.sensor_id"
|
||||
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900">{{ sensor.sensor_id }}</p>
|
||||
<p class="text-sm text-gray-500">{{ sensor.room || 'No room assigned' }}</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
{{ sensor.sensor_type }} • {{ sensor.total_readings }} readings
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||||
sensor.status === 'online' ? 'bg-green-100 text-green-800' :
|
||||
sensor.status === 'offline' ? 'bg-red-100 text-red-800' :
|
||||
'bg-yellow-100 text-yellow-800'
|
||||
]">
|
||||
{{ sensor.status }}
|
||||
sensor.status === 'online'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: sensor.status === 'offline'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: 'bg-yellow-100 text-yellow-800',
|
||||
]"
|
||||
>
|
||||
{{ sensor.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rooms Section -->
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-semibold text-gray-900">API Rooms</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div v-if="apiRooms.length === 0" class="text-center text-gray-500 py-8">
|
||||
No rooms found from API
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="room in apiRooms" :key="room.room" class="p-3 bg-gray-50 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="font-medium text-gray-900">{{ room.room }}</p>
|
||||
<span class="text-sm text-gray-500">{{ room.sensor_count }} sensors</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 space-y-1">
|
||||
<p v-if="room.sensor_types">Types: {{ room.sensor_types.join(', ') }}</p>
|
||||
<div v-if="room.latest_metrics">
|
||||
<span v-if="room.latest_metrics.energy" class="mr-4">
|
||||
Energy: {{ room.latest_metrics.energy.current }}
|
||||
{{ room.latest_metrics.energy.unit }}
|
||||
</span>
|
||||
<span v-if="room.latest_metrics.co2">
|
||||
CO2: {{ room.latest_metrics.co2.current }} {{ room.latest_metrics.co2.unit }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rooms Section -->
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-semibold text-gray-900">API Rooms</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div v-if="apiRooms.length === 0" class="text-center text-gray-500 py-8">
|
||||
No rooms found from API
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="room in apiRooms" :key="room.name || room.room"
|
||||
class="p-3 bg-gray-50 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="font-medium text-gray-900">{{ room.name || room.room }}</p>
|
||||
<span class="text-sm text-gray-500">{{ room.sensor_count }} sensors</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 space-y-1">
|
||||
<p v-if="room.sensor_types">Types: {{ room.sensor_types.join(', ') }}</p>
|
||||
<div v-if="room.latest_metrics">
|
||||
<span v-if="room.latest_metrics.energy" class="mr-4">
|
||||
Energy: {{ room.latest_metrics.energy.current }} {{ room.latest_metrics.energy.unit }}
|
||||
</span>
|
||||
<span v-if="room.latest_metrics.co2">
|
||||
CO2: {{ room.latest_metrics.co2.current }} {{ room.latest_metrics.co2.unit }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-else class="text-gray-400 italic">No metrics available</p>
|
||||
</div>
|
||||
<p v-else class="text-gray-400 italic">No metrics available</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">API Actions</h2>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button @click="refreshAllData"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
:disabled="isLoading">
|
||||
<svg v-if="!isLoading" class="-ml-1 mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
<div v-else class="animate-spin -ml-1 mr-2 h-4 w-4 border-2 border-white border-t-transparent rounded-full"></div>
|
||||
Refresh All Data
|
||||
</button>
|
||||
<!-- Action Buttons -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">API Actions</h2>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button
|
||||
@click="refreshAllData"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<svg
|
||||
v-if="!isLoading"
|
||||
class="-ml-1 mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
></path>
|
||||
</svg>
|
||||
<div
|
||||
v-else
|
||||
class="animate-spin -ml-1 mr-2 h-4 w-4 border-2 border-white border-t-transparent rounded-full"
|
||||
></div>
|
||||
Refresh All Data
|
||||
</button>
|
||||
|
||||
<button @click="fetchSensorsOnly"
|
||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
:disabled="isLoading">
|
||||
Fetch Sensors
|
||||
</button>
|
||||
<button
|
||||
@click="fetchSensorsOnly"
|
||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
Fetch Sensors
|
||||
</button>
|
||||
|
||||
<button @click="fetchRoomsOnly"
|
||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
:disabled="isLoading">
|
||||
Fetch Rooms
|
||||
</button>
|
||||
<button
|
||||
@click="fetchRoomsOnly"
|
||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
Fetch Rooms
|
||||
</button>
|
||||
|
||||
<button @click="fetchAnalyticsOnly"
|
||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
:disabled="isLoading">
|
||||
Fetch Analytics
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@click="fetchAnalyticsOnly"
|
||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
Fetch Analytics
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -240,10 +322,10 @@ const healthStatus = computed(() => analyticsStore.healthStatus)
|
||||
|
||||
// Combined loading and error states
|
||||
const isLoading = computed(
|
||||
() => sensorStore.apiLoading || roomStore.apiLoading || analyticsStore.apiLoading
|
||||
() => sensorStore.apiLoading || roomStore.apiLoading || analyticsStore.apiLoading,
|
||||
)
|
||||
const apiError = computed(
|
||||
() => sensorStore.apiError || roomStore.apiError || analyticsStore.apiError
|
||||
() => sensorStore.apiError || roomStore.apiError || analyticsStore.apiError,
|
||||
)
|
||||
|
||||
// Helper functions
|
||||
|
||||
@@ -1,59 +1,30 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Filter Controls Row -->
|
||||
<!--div class="flex flex-col sm:flex-row gap-4 mb-6">
|
||||
<select class="px-4 py-2 border border-gray-200 rounded-lg bg-white">
|
||||
<option>Timeframe: All-time</option>
|
||||
</select>
|
||||
<select class="px-4 py-2 border border-gray-200 rounded-lg bg-white">
|
||||
<option>People: All</option>
|
||||
</select>
|
||||
<select class="px-4 py-2 border border-gray-200 rounded-lg bg-white">
|
||||
<option>Topic: All</option>
|
||||
</select>
|
||||
</div-->
|
||||
|
||||
<!-- Top Metric Cards Row -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 min-h-96">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-4 gap-y-3">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 pb-20">
|
||||
<div class="space-y-6">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
<MetricCard title="Current Energy" :content="currentEnergyValue" details="kWh" />
|
||||
<MetricCard title="Average Usage" :content="averageEnergyUsage" details="kWh" />
|
||||
<MetricCard
|
||||
title="Connection Status"
|
||||
:content="websocketStore.isConnected ? 'Connected' : 'Disconnected'"
|
||||
/>
|
||||
<MetricCard title="Average Usage" :content="averageEnergyUsage" details="kWh" />
|
||||
<GraphMetricCard
|
||||
title="Real-time Energy"
|
||||
:content="currentEnergyValue"
|
||||
details="kWh"
|
||||
:trend-data="websocketStore.timeSeriesData.datasets[0].data.slice(-8)"
|
||||
trend-direction="neutral"
|
||||
/>
|
||||
<GraphMetricCard
|
||||
title="Current Knowledge"
|
||||
content="86%"
|
||||
:trend-data="[203, 78, 80, 82, 142, 85, 85, 86]"
|
||||
trend-direction="down"
|
||||
/>
|
||||
<GraphMetricCard
|
||||
title="Knowledge Gain"
|
||||
content="+34%"
|
||||
:trend-data="[20, 25, 28, 30, 32, 33, 34, 34]"
|
||||
:trend-data="energyStore.energyHistory.slice(-8)"
|
||||
trend-direction="neutral"
|
||||
/>
|
||||
<GraphMetricCard title="Average CO2" :content="averageCO2" details="ppm" />
|
||||
<GraphMetricCard title="Max CO2" :content="maxCO2" details="ppm" />
|
||||
</div>
|
||||
<div>
|
||||
<RealtimeEnergyChartCard title="Month" />
|
||||
</div>
|
||||
<SensorConsumptionTable />
|
||||
</div>
|
||||
|
||||
<!-- Charts and Knowledge Cards Row -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<SensorConsumptionTable />
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<RoomMetricsCard />
|
||||
<AirQualityCard />
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
<RealtimeEnergyChartCard title="Month" />
|
||||
<RoomMetricsCard />
|
||||
<AirQualityCard />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -66,23 +37,30 @@ import SensorConsumptionTable from '@/components/cards/SensorConsumptionTable.vu
|
||||
import RoomMetricsCard from '@/components/cards/RoomMetricsCard.vue'
|
||||
import AirQualityCard from '@/components/cards/AirQualityCard.vue'
|
||||
import { useEnergyStore } from '@/stores/energy'
|
||||
import { useSensorStore } from '@/stores/sensor'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useWebSocketStore } from '@/stores/websocket'
|
||||
|
||||
const energyStore = useEnergyStore()
|
||||
const sensorStore = useSensorStore()
|
||||
const websocketStore = useWebSocketStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
const currentEnergyValue = computed(() => {
|
||||
return websocketStore.latestMessage?.energy?.value.toFixed(2) || '0.00'
|
||||
return energyStore.currentEnergyValue.toFixed(2)
|
||||
})
|
||||
|
||||
const averageEnergyUsage = computed(() => {
|
||||
const data = websocketStore.timeSeriesData.datasets[0].data
|
||||
if (data.length === 0) return '0.00'
|
||||
const sum = data.reduce((acc, val) => acc + val, 0)
|
||||
return (sum / data.length).toFixed(2)
|
||||
return energyStore.averageEnergyUsage.toFixed(2)
|
||||
})
|
||||
|
||||
const averageCO2 = computed(() => {
|
||||
return Math.round(sensorStore.averageCO2Level)
|
||||
})
|
||||
|
||||
const maxCO2 = computed(() => {
|
||||
return Math.round(sensorStore.maxCO2Level)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">AI Optimization</h1>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Models</h1>
|
||||
<p class="text-gray-600">
|
||||
Leverage artificial intelligence to optimize energy consumption and building operations
|
||||
</p>
|
||||
@@ -184,7 +184,7 @@
|
||||
|
||||
<!-- Action Modal -->
|
||||
<ActionModal
|
||||
v-if="showActionModal"
|
||||
v-if="showActionModal && selectedSensor && selectedAction"
|
||||
:sensor="selectedSensor"
|
||||
:action="selectedAction"
|
||||
@execute="handleActionExecute"
|
||||
@@ -201,6 +201,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useSensorStore } from '@/stores/sensor'
|
||||
import { useRoomStore } from '@/stores/room'
|
||||
import { useWebSocketStore } from '@/stores/websocket'
|
||||
import type { SensorDevice, SensorAction } from '@/services'
|
||||
import ActionModal from '@/components/modals/ActionModal.vue'
|
||||
import RoomManagementModal from '@/components/modals/RoomManagementModal.vue'
|
||||
import SimpleSensorCard from '@/components/cards/SimpleSensorCard.vue'
|
||||
@@ -217,8 +218,8 @@ const selectedType = ref('')
|
||||
const selectedStatus = ref('')
|
||||
|
||||
const showActionModal = ref(false)
|
||||
const selectedSensor = ref<any>(null)
|
||||
const selectedAction = ref<any>(null)
|
||||
const selectedSensor = ref<SensorDevice | null>(null)
|
||||
const selectedAction = ref<SensorAction | null>(null)
|
||||
const isExecutingAction = ref(false)
|
||||
|
||||
const showRoomManagementModal = ref(false)
|
||||
@@ -241,17 +242,26 @@ const updateRoom = (sensorId: string, newRoom: string) => {
|
||||
sensorStore.updateSensorRoom(sensorId, newRoom)
|
||||
}
|
||||
|
||||
const executeAction = (sensor: any, action: any) => {
|
||||
const executeAction = (sensor: SensorDevice, action: SensorAction) => {
|
||||
if (action.parameters) {
|
||||
selectedSensor.value = sensor
|
||||
selectedAction.value = action
|
||||
showActionModal.value = true
|
||||
} else {
|
||||
handleActionExecute(sensor.id, action.id, {})
|
||||
handleActionExecute(sensor.sensor_id, action.id, {})
|
||||
}
|
||||
}
|
||||
|
||||
const handleActionExecute = async (sensorId: string, actionId: string, parameters: any) => {
|
||||
interface ActionParameters {
|
||||
value?: number | string | boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const handleActionExecute = async (
|
||||
sensorId: string,
|
||||
actionId: string,
|
||||
parameters: ActionParameters,
|
||||
) => {
|
||||
isExecutingAction.value = true
|
||||
try {
|
||||
await sensorStore.executeSensorAction(sensorId, actionId)
|
||||
|
||||
@@ -28,9 +28,11 @@
|
||||
<button
|
||||
@click="activeSection = section.id"
|
||||
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left transition-colors"
|
||||
:class="activeSection === section.id
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'text-gray-700 hover:bg-gray-100'"
|
||||
:class="
|
||||
activeSection === section.id
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
"
|
||||
>
|
||||
<span class="text-lg">{{ section.icon }}</span>
|
||||
<div>
|
||||
@@ -46,7 +48,10 @@
|
||||
<!-- Settings Content -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Appearance Settings -->
|
||||
<div v-if="activeSection === 'appearance'" class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div
|
||||
v-if="activeSection === 'appearance'"
|
||||
class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden"
|
||||
>
|
||||
<div class="p-6 border-b border-gray-100">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Appearance</h3>
|
||||
<p class="text-gray-600 text-sm mt-1">Customize the look and feel of your dashboard</p>
|
||||
@@ -61,9 +66,11 @@
|
||||
:key="theme.value"
|
||||
@click="settingsStore.updateSetting('theme', theme.value)"
|
||||
class="relative p-4 border-2 rounded-lg cursor-pointer transition-all hover:border-blue-300"
|
||||
:class="settingsStore.settings.theme === theme.value
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200'"
|
||||
:class="
|
||||
settingsStore.settings.theme === theme.value
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200'
|
||||
"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -72,7 +79,11 @@
|
||||
</div>
|
||||
<div v-if="settingsStore.settings.theme === theme.value" class="text-blue-600">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@@ -89,9 +100,11 @@
|
||||
:key="mode.value"
|
||||
@click="settingsStore.updateSetting('ui.navigationMode', mode.value)"
|
||||
class="relative p-4 border-2 rounded-lg cursor-pointer transition-all hover:border-blue-300"
|
||||
:class="settingsStore.settings.ui.navigationMode === mode.value
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200'"
|
||||
:class="
|
||||
settingsStore.settings.ui.navigationMode === mode.value
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200'
|
||||
"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-start gap-3">
|
||||
@@ -101,9 +114,16 @@
|
||||
<div class="text-sm text-gray-600 mt-1">{{ mode.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="settingsStore.settings.ui.navigationMode === mode.value" class="text-blue-600 mt-1">
|
||||
<div
|
||||
v-if="settingsStore.settings.ui.navigationMode === mode.value"
|
||||
class="text-blue-600 mt-1"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,12 +139,21 @@
|
||||
<div class="text-sm text-gray-600">Reduce spacing and padding</div>
|
||||
</div>
|
||||
<button
|
||||
@click="settingsStore.updateSetting('ui.compactMode', !settingsStore.settings.ui.compactMode)"
|
||||
@click="
|
||||
settingsStore.updateSetting(
|
||||
'ui.compactMode',
|
||||
!settingsStore.settings.ui.compactMode,
|
||||
)
|
||||
"
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
:class="settingsStore.settings.ui.compactMode ? 'bg-blue-600' : 'bg-gray-200'"
|
||||
>
|
||||
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||
:class="settingsStore.settings.ui.compactMode ? 'translate-x-6' : 'translate-x-1'"></span>
|
||||
<span
|
||||
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||
:class="
|
||||
settingsStore.settings.ui.compactMode ? 'translate-x-6' : 'translate-x-1'
|
||||
"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -134,12 +163,21 @@
|
||||
<div class="text-sm text-gray-600">Enable smooth transitions</div>
|
||||
</div>
|
||||
<button
|
||||
@click="settingsStore.updateSetting('ui.showAnimations', !settingsStore.settings.ui.showAnimations)"
|
||||
@click="
|
||||
settingsStore.updateSetting(
|
||||
'ui.showAnimations',
|
||||
!settingsStore.settings.ui.showAnimations,
|
||||
)
|
||||
"
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
:class="settingsStore.settings.ui.showAnimations ? 'bg-blue-600' : 'bg-gray-200'"
|
||||
>
|
||||
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||
:class="settingsStore.settings.ui.showAnimations ? 'translate-x-6' : 'translate-x-1'"></span>
|
||||
<span
|
||||
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||
:class="
|
||||
settingsStore.settings.ui.showAnimations ? 'translate-x-6' : 'translate-x-1'
|
||||
"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,7 +185,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Data & Sync Settings -->
|
||||
<div v-if="activeSection === 'data'" class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div
|
||||
v-if="activeSection === 'data'"
|
||||
class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden"
|
||||
>
|
||||
<div class="p-6 border-b border-gray-100">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Data & Synchronization</h3>
|
||||
<p class="text-gray-600 text-sm mt-1">Configure data refresh and connection settings</p>
|
||||
@@ -160,12 +201,19 @@
|
||||
<div class="text-sm text-gray-600">Automatically refresh data periodically</div>
|
||||
</div>
|
||||
<button
|
||||
@click="settingsStore.updateSetting('ui.autoRefresh', !settingsStore.settings.ui.autoRefresh)"
|
||||
@click="
|
||||
settingsStore.updateSetting(
|
||||
'ui.autoRefresh',
|
||||
!settingsStore.settings.ui.autoRefresh,
|
||||
)
|
||||
"
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
:class="settingsStore.settings.ui.autoRefresh ? 'bg-blue-600' : 'bg-gray-200'"
|
||||
>
|
||||
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||
:class="settingsStore.settings.ui.autoRefresh ? 'translate-x-6' : 'translate-x-1'"></span>
|
||||
<span
|
||||
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||
:class="settingsStore.settings.ui.autoRefresh ? 'translate-x-6' : 'translate-x-1'"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -178,10 +226,17 @@
|
||||
min="1"
|
||||
max="60"
|
||||
:value="settingsStore.settings.ui.refreshInterval"
|
||||
@input="settingsStore.updateSetting('ui.refreshInterval', parseInt(($event.target as HTMLInputElement).value))"
|
||||
@input="
|
||||
settingsStore.updateSetting(
|
||||
'ui.refreshInterval',
|
||||
parseInt(($event.target as HTMLInputElement).value),
|
||||
)
|
||||
"
|
||||
class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
<div class="bg-gray-100 px-3 py-1 rounded text-sm font-medium min-w-[80px] text-center">
|
||||
<div
|
||||
class="bg-gray-100 px-3 py-1 rounded text-sm font-medium min-w-[80px] text-center"
|
||||
>
|
||||
{{ settingsStore.settings.ui.refreshInterval }}s
|
||||
</div>
|
||||
</div>
|
||||
@@ -196,43 +251,62 @@
|
||||
type="text"
|
||||
placeholder="ws://localhost:8000/ws"
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
:class="{ 'border-red-300 focus:ring-red-500 focus:border-red-500': websocketUrlError }"
|
||||
:class="{
|
||||
'border-red-300 focus:ring-red-500 focus:border-red-500': websocketUrlError,
|
||||
}"
|
||||
/>
|
||||
<button
|
||||
@click="updateWebSocketUrl"
|
||||
:disabled="!websocketUrlInput || websocketUrlInput === settingsStore.settings.websocketUrl"
|
||||
:disabled="
|
||||
!websocketUrlInput || websocketUrlInput === settingsStore.settings.websocketUrl
|
||||
"
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="websocketUrlError" class="text-red-600 text-sm mt-1">{{ websocketUrlError }}</p>
|
||||
<p v-else class="text-gray-500 text-sm mt-1">Current: {{ settingsStore.settings.websocketUrl }}</p>
|
||||
<p v-if="websocketUrlError" class="text-red-600 text-sm mt-1">
|
||||
{{ websocketUrlError }}
|
||||
</p>
|
||||
<p v-else class="text-gray-500 text-sm mt-1">
|
||||
Current: {{ settingsStore.settings.websocketUrl }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Auto Connect -->
|
||||
<div class="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
|
||||
<div>
|
||||
<div class="font-medium text-gray-900">Auto Connect</div>
|
||||
<div class="text-sm text-gray-600">Automatically connect to WebSocket on app start</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
Automatically connect to WebSocket on app start
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="settingsStore.updateSetting('autoConnect', !settingsStore.settings.autoConnect)"
|
||||
@click="
|
||||
settingsStore.updateSetting('autoConnect', !settingsStore.settings.autoConnect)
|
||||
"
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
:class="settingsStore.settings.autoConnect ? 'bg-blue-600' : 'bg-gray-200'"
|
||||
>
|
||||
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||
:class="settingsStore.settings.autoConnect ? 'translate-x-6' : 'translate-x-1'"></span>
|
||||
<span
|
||||
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||
:class="settingsStore.settings.autoConnect ? 'translate-x-6' : 'translate-x-1'"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notifications Settings -->
|
||||
<div v-if="activeSection === 'notifications'" class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div
|
||||
v-if="activeSection === 'notifications'"
|
||||
class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden"
|
||||
>
|
||||
<div class="p-6 border-b border-gray-100">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Notifications</h3>
|
||||
<p class="text-gray-600 text-sm mt-1">Configure how you receive alerts and notifications</p>
|
||||
<p class="text-gray-600 text-sm mt-1">
|
||||
Configure how you receive alerts and notifications
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Enable Notifications -->
|
||||
@@ -242,34 +316,62 @@
|
||||
<div class="text-sm text-gray-600">Receive system alerts and updates</div>
|
||||
</div>
|
||||
<button
|
||||
@click="settingsStore.updateSetting('notifications.enabled', !settingsStore.settings.notifications.enabled)"
|
||||
@click="
|
||||
settingsStore.updateSetting(
|
||||
'notifications.enabled',
|
||||
!settingsStore.settings.notifications.enabled,
|
||||
)
|
||||
"
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
:class="settingsStore.settings.notifications.enabled ? 'bg-blue-600' : 'bg-gray-200'"
|
||||
:class="
|
||||
settingsStore.settings.notifications.enabled ? 'bg-blue-600' : 'bg-gray-200'
|
||||
"
|
||||
>
|
||||
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||
:class="settingsStore.settings.notifications.enabled ? 'translate-x-6' : 'translate-x-1'"></span>
|
||||
<span
|
||||
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||
:class="
|
||||
settingsStore.settings.notifications.enabled ? 'translate-x-6' : 'translate-x-1'
|
||||
"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="settingsStore.settings.notifications.enabled" class="space-y-4">
|
||||
<!-- Notification Types -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
|
||||
<div
|
||||
class="flex items-center justify-between p-4 border border-gray-200 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<div class="font-medium text-gray-900">Sound Alerts</div>
|
||||
<div class="text-sm text-gray-600">Play sound for notifications</div>
|
||||
</div>
|
||||
<button
|
||||
@click="settingsStore.updateSetting('notifications.sound', !settingsStore.settings.notifications.sound)"
|
||||
@click="
|
||||
settingsStore.updateSetting(
|
||||
'notifications.sound',
|
||||
!settingsStore.settings.notifications.sound,
|
||||
)
|
||||
"
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
:class="settingsStore.settings.notifications.sound ? 'bg-blue-600' : 'bg-gray-200'"
|
||||
:class="
|
||||
settingsStore.settings.notifications.sound ? 'bg-blue-600' : 'bg-gray-200'
|
||||
"
|
||||
>
|
||||
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||
:class="settingsStore.settings.notifications.sound ? 'translate-x-6' : 'translate-x-1'"></span>
|
||||
<span
|
||||
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||
:class="
|
||||
settingsStore.settings.notifications.sound
|
||||
? 'translate-x-6'
|
||||
: 'translate-x-1'
|
||||
"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
|
||||
<div
|
||||
class="flex items-center justify-between p-4 border border-gray-200 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<div class="font-medium text-gray-900">Desktop Notifications</div>
|
||||
<div class="text-sm text-gray-600">Show browser notifications</div>
|
||||
@@ -277,10 +379,18 @@
|
||||
<button
|
||||
@click="toggleDesktopNotifications"
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
:class="settingsStore.settings.notifications.desktop ? 'bg-blue-600' : 'bg-gray-200'"
|
||||
:class="
|
||||
settingsStore.settings.notifications.desktop ? 'bg-blue-600' : 'bg-gray-200'
|
||||
"
|
||||
>
|
||||
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||
:class="settingsStore.settings.notifications.desktop ? 'translate-x-6' : 'translate-x-1'"></span>
|
||||
<span
|
||||
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||
:class="
|
||||
settingsStore.settings.notifications.desktop
|
||||
? 'translate-x-6'
|
||||
: 'translate-x-1'
|
||||
"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -292,12 +402,27 @@
|
||||
<div class="text-sm text-gray-600">Only show high-priority notifications</div>
|
||||
</div>
|
||||
<button
|
||||
@click="settingsStore.updateSetting('notifications.criticalOnly', !settingsStore.settings.notifications.criticalOnly)"
|
||||
@click="
|
||||
settingsStore.updateSetting(
|
||||
'notifications.criticalOnly',
|
||||
!settingsStore.settings.notifications.criticalOnly,
|
||||
)
|
||||
"
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
:class="settingsStore.settings.notifications.criticalOnly ? 'bg-blue-600' : 'bg-gray-200'"
|
||||
:class="
|
||||
settingsStore.settings.notifications.criticalOnly
|
||||
? 'bg-blue-600'
|
||||
: 'bg-gray-200'
|
||||
"
|
||||
>
|
||||
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||
:class="settingsStore.settings.notifications.criticalOnly ? 'translate-x-6' : 'translate-x-1'"></span>
|
||||
<span
|
||||
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||
:class="
|
||||
settingsStore.settings.notifications.criticalOnly
|
||||
? 'translate-x-6'
|
||||
: 'translate-x-1'
|
||||
"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -305,7 +430,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Advanced Settings -->
|
||||
<div v-if="activeSection === 'advanced'" class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div
|
||||
v-if="activeSection === 'advanced'"
|
||||
class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden"
|
||||
>
|
||||
<div class="p-6 border-b border-gray-100">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Advanced</h3>
|
||||
<p class="text-gray-600 text-sm mt-1">Developer options and advanced configuration</p>
|
||||
@@ -318,12 +446,19 @@
|
||||
<div class="text-sm text-gray-600">Enable debug logs and development features</div>
|
||||
</div>
|
||||
<button
|
||||
@click="settingsStore.updateSetting('developerMode', !settingsStore.settings.developerMode)"
|
||||
@click="
|
||||
settingsStore.updateSetting(
|
||||
'developerMode',
|
||||
!settingsStore.settings.developerMode,
|
||||
)
|
||||
"
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
:class="settingsStore.settings.developerMode ? 'bg-blue-600' : 'bg-gray-200'"
|
||||
>
|
||||
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||
:class="settingsStore.settings.developerMode ? 'translate-x-6' : 'translate-x-1'"></span>
|
||||
<span
|
||||
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||
:class="settingsStore.settings.developerMode ? 'translate-x-6' : 'translate-x-1'"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -357,7 +492,9 @@
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="importError" class="text-red-600 text-sm mt-1">{{ importError }}</p>
|
||||
<p v-if="importSuccess" class="text-green-600 text-sm mt-1">Settings imported successfully!</p>
|
||||
<p v-if="importSuccess" class="text-green-600 text-sm mt-1">
|
||||
Settings imported successfully!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -366,11 +503,15 @@
|
||||
</div>
|
||||
|
||||
<!-- Reset Confirmation Dialog -->
|
||||
<div v-if="showResetDialog" class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||
<div
|
||||
v-if="showResetDialog"
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50"
|
||||
>
|
||||
<div class="bg-white rounded-xl max-w-md w-full p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">Reset All Settings</h3>
|
||||
<p class="text-gray-600 mb-4">
|
||||
Are you sure you want to reset all settings to their default values? This action cannot be undone.
|
||||
Are you sure you want to reset all settings to their default values? This action cannot be
|
||||
undone.
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
@@ -412,26 +553,26 @@ const settingSections = [
|
||||
id: 'appearance',
|
||||
name: 'Appearance',
|
||||
description: 'Theme & UI',
|
||||
icon: '🎨'
|
||||
icon: '🎨',
|
||||
},
|
||||
{
|
||||
id: 'data',
|
||||
name: 'Data & Sync',
|
||||
description: 'Connection & refresh',
|
||||
icon: '🔄'
|
||||
icon: '🔄',
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
name: 'Notifications',
|
||||
description: 'Alerts & sounds',
|
||||
icon: '🔔'
|
||||
icon: '🔔',
|
||||
},
|
||||
{
|
||||
id: 'advanced',
|
||||
name: 'Advanced',
|
||||
description: 'Developer options',
|
||||
icon: '⚙️'
|
||||
}
|
||||
icon: '⚙️',
|
||||
},
|
||||
]
|
||||
|
||||
// Methods
|
||||
@@ -439,7 +580,7 @@ const formatTime = (date: Date) => {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
second: '2-digit',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,4 +13,19 @@ export default defineConfig({
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
cors: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/health': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user