Integrate sensorsApi and roomsApi services into energy store. Add API state, loading/error handling, and async functions for fetching sensor and room data. Update room loading logic to fetch from API. Expose new API functions for analytics and health endpoints. Update SensorManagementView to use localhost WebSocket for real-time updates.
377 lines
12 KiB
Vue
377 lines
12 KiB
Vue
<template>
|
|
<div class="space-y-6">
|
|
<!-- 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">Sensor & IoT Management</h1>
|
|
<p class="text-gray-600">Manage sensors, assign rooms, and control device actions</p>
|
|
</div>
|
|
<div class="mt-4 sm:mt-0">
|
|
<div class="flex items-center gap-2 text-sm text-gray-600">
|
|
<div
|
|
class="w-3 h-3 rounded-full"
|
|
:class="energyStore.isConnected ? 'bg-green-500' : 'bg-red-500'"
|
|
></div>
|
|
<span>{{ energyStore.isConnected ? 'Connected' : 'Disconnected' }}</span>
|
|
<span class="mx-2">•</span>
|
|
<span>{{ sensorList.length }} sensors</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filters and View Toggle -->
|
|
<div class="flex flex-col lg:flex-row gap-4">
|
|
<!-- Filters -->
|
|
<div class="flex flex-col sm:flex-row gap-4 flex-1">
|
|
<div class="flex gap-2">
|
|
<select
|
|
v-model="selectedRoom"
|
|
class="px-4 py-2 border border-gray-200 rounded-lg bg-white flex-1"
|
|
>
|
|
<option value="">All Rooms</option>
|
|
<option v-for="room in energyStore.availableRooms" :key="room" :value="room">
|
|
{{ room }}
|
|
</option>
|
|
</select>
|
|
<button
|
|
@click="showRoomManagementModal = true"
|
|
class="px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors flex items-center gap-1"
|
|
title="Manage Rooms"
|
|
>
|
|
<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="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
|
/>
|
|
</svg>
|
|
<span class="hidden sm:inline">Rooms</span>
|
|
</button>
|
|
</div>
|
|
|
|
<select v-model="selectedType" class="px-4 py-2 border border-gray-200 rounded-lg bg-white">
|
|
<option value="">All Types</option>
|
|
<option value="energy">Energy</option>
|
|
<option value="co2">CO2</option>
|
|
<option value="temperature">Temperature</option>
|
|
<option value="hvac">HVAC</option>
|
|
<option value="lighting">Lighting</option>
|
|
<option value="security">Security</option>
|
|
</select>
|
|
|
|
<select
|
|
v-model="selectedStatus"
|
|
class="px-4 py-2 border border-gray-200 rounded-lg bg-white"
|
|
>
|
|
<option value="">All Status</option>
|
|
<option value="online">Online</option>
|
|
<option value="offline">Offline</option>
|
|
<option value="error">Error</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- View Toggle -->
|
|
<div class="flex items-center gap-2 bg-gray-100 rounded-lg p-1">
|
|
<button
|
|
@click="viewMode = 'simple'"
|
|
class="px-3 py-1.5 rounded text-sm font-medium transition-colors"
|
|
:class="
|
|
viewMode === 'simple'
|
|
? 'bg-white text-gray-900 shadow-sm'
|
|
: 'text-gray-600 hover:text-gray-900'
|
|
"
|
|
>
|
|
<div class="flex items-center gap-1">
|
|
<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="M4 6h16M4 10h16M4 14h16M4 18h16"
|
|
/>
|
|
</svg>
|
|
Simple
|
|
</div>
|
|
</button>
|
|
<button
|
|
@click="viewMode = 'detailed'"
|
|
class="px-3 py-1.5 rounded text-sm font-medium transition-colors"
|
|
:class="
|
|
viewMode === 'detailed'
|
|
? 'bg-white text-gray-900 shadow-sm'
|
|
: 'text-gray-600 hover:text-gray-900'
|
|
"
|
|
>
|
|
<div class="flex items-center gap-1">
|
|
<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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
|
/>
|
|
</svg>
|
|
Detailed
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Real-time Room Metrics Summary -->
|
|
<div
|
|
v-if="Object.keys(roomMetricsSummary).length > 0"
|
|
class="bg-white rounded-xl shadow-sm border border-gray-100 p-4 mb-6"
|
|
>
|
|
<h2 class="text-lg font-semibold text-gray-900 mb-4">Room-based Real-time Metrics</h2>
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
<div
|
|
v-for="(metrics, room) in roomMetricsSummary"
|
|
:key="room"
|
|
class="bg-gray-50 rounded-lg p-3"
|
|
>
|
|
<div class="text-sm font-medium text-gray-900 mb-2">{{ room }}</div>
|
|
<div class="space-y-1 text-xs">
|
|
<div class="flex justify-between">
|
|
<span class="text-gray-600">Energy:</span>
|
|
<span class="font-medium">{{ metrics.energy.toFixed(2) }} kWh</span>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<span class="text-gray-600">CO2:</span>
|
|
<span class="font-medium" :class="getCO2StatusColor(metrics.co2)">
|
|
{{ Math.round(metrics.co2) }} ppm
|
|
</span>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<span class="text-gray-600">Sensors:</span>
|
|
<span class="font-medium">{{ metrics.sensorCount }}</span>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<span class="text-gray-600">Occupancy:</span>
|
|
<span class="font-medium capitalize" :class="getOccupancyColor(metrics.occupancy)">
|
|
{{ metrics.occupancy }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sensors Grid -->
|
|
<div
|
|
class="grid gap-4"
|
|
:class="
|
|
viewMode === 'simple'
|
|
? 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'
|
|
: 'grid-cols-1 lg:grid-cols-2 xl:grid-cols-3'
|
|
"
|
|
>
|
|
<!-- Simple Cards -->
|
|
<template v-if="viewMode === 'simple'">
|
|
<SimpleSensorCard
|
|
v-for="sensor in filteredSensors"
|
|
:key="sensor.id"
|
|
:sensor="sensor"
|
|
:is-executing-action="isExecutingAction"
|
|
@execute-action="executeAction"
|
|
@show-more="showSensorDetails"
|
|
/>
|
|
</template>
|
|
|
|
<!-- Detailed Cards -->
|
|
<template v-else>
|
|
<DetailedSensorCard
|
|
v-for="sensor in filteredSensors"
|
|
:key="sensor.id"
|
|
:sensor="sensor"
|
|
:available-rooms="energyStore.availableRooms"
|
|
:is-executing-action="isExecutingAction"
|
|
@update-room="updateRoom"
|
|
@execute-action="executeAction"
|
|
/>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Empty State -->
|
|
<div v-if="filteredSensors.length === 0" class="text-center py-12">
|
|
<div class="text-gray-400 text-6xl mb-4">🔍</div>
|
|
<h3 class="text-lg font-medium text-gray-900 mb-2">No sensors found</h3>
|
|
<p class="text-gray-600">Try adjusting your filters or check if sensors are connected.</p>
|
|
</div>
|
|
|
|
<!-- Action Modal -->
|
|
<ActionModal
|
|
v-if="showActionModal"
|
|
:sensor="selectedSensor"
|
|
:action="selectedAction"
|
|
@execute="handleActionExecute"
|
|
@close="closeActionModal"
|
|
/>
|
|
|
|
<!-- Room Management Modal -->
|
|
<RoomManagementModal v-if="showRoomManagementModal" @close="showRoomManagementModal = false" />
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|
import { useEnergyStore } from '@/stores/energy'
|
|
import ActionModal from '@/components/modals/ActionModal.vue'
|
|
import RoomManagementModal from '@/components/modals/RoomManagementModal.vue'
|
|
import SimpleSensorCard from '@/components/cards/SimpleSensorCard.vue'
|
|
import DetailedSensorCard from '@/components/cards/DetailedSensorCard.vue'
|
|
|
|
const energyStore = useEnergyStore()
|
|
|
|
// View mode
|
|
const viewMode = ref<'simple' | 'detailed'>('simple')
|
|
|
|
// Filters
|
|
const selectedRoom = ref('')
|
|
const selectedType = ref('')
|
|
const selectedStatus = ref('')
|
|
|
|
// Action modal
|
|
const showActionModal = ref(false)
|
|
const selectedSensor = ref<any>(null)
|
|
const selectedAction = ref<any>(null)
|
|
const isExecutingAction = ref(false)
|
|
|
|
// Room management modal
|
|
const showRoomManagementModal = ref(false)
|
|
|
|
const sensorList = computed(() => {
|
|
return Array.from(energyStore.sensorDevices.values()).sort((a, b) => a.name.localeCompare(b.name))
|
|
})
|
|
|
|
const filteredSensors = computed(() => {
|
|
return sensorList.value.filter((sensor) => {
|
|
const matchesRoom = !selectedRoom.value || sensor.room === selectedRoom.value
|
|
const matchesType = !selectedType.value || sensor.type === selectedType.value
|
|
const matchesStatus = !selectedStatus.value || sensor.status === selectedStatus.value
|
|
|
|
return matchesRoom && matchesType && matchesStatus
|
|
})
|
|
})
|
|
|
|
// Real-time room metrics aggregation
|
|
const roomMetricsSummary = computed(() => {
|
|
const summary: Record<string, any> = {}
|
|
|
|
// Process room data from energy store
|
|
Array.from(energyStore.roomsData.values()).forEach((roomData) => {
|
|
if (roomData.room && roomData.sensors.length > 0) {
|
|
summary[roomData.room] = {
|
|
energy: roomData.energy.current || 0,
|
|
co2: roomData.co2.current || 0,
|
|
sensorCount: roomData.sensors.length,
|
|
occupancy: roomData.occupancyEstimate || 'low',
|
|
lastUpdated: roomData.lastUpdated,
|
|
}
|
|
}
|
|
})
|
|
|
|
// Fallback: Aggregate from individual sensor readings
|
|
if (Object.keys(summary).length === 0) {
|
|
const readings = Array.from(energyStore.latestReadings.values())
|
|
readings.forEach((reading) => {
|
|
if (!reading.room) return
|
|
|
|
if (!summary[reading.room]) {
|
|
summary[reading.room] = {
|
|
energy: 0,
|
|
co2: 0,
|
|
temperature: 0,
|
|
sensorCount: 0,
|
|
occupancy: 'low',
|
|
}
|
|
}
|
|
|
|
summary[reading.room].energy += reading.energy?.value || 0
|
|
summary[reading.room].co2 += reading.co2?.value || 0
|
|
summary[reading.room].sensorCount += 1
|
|
|
|
// Simple occupancy estimate based on CO2
|
|
const avgCo2 = summary[reading.room].co2 / summary[reading.room].sensorCount
|
|
if (avgCo2 > 1200) summary[reading.room].occupancy = 'high'
|
|
else if (avgCo2 > 600) summary[reading.room].occupancy = 'medium'
|
|
else summary[reading.room].occupancy = 'low'
|
|
})
|
|
}
|
|
|
|
return summary
|
|
})
|
|
|
|
const updateRoom = (sensorId: string, newRoom: string) => {
|
|
energyStore.updateSensorRoom(sensorId, newRoom)
|
|
}
|
|
|
|
const executeAction = (sensor: any, action: any) => {
|
|
if (action.parameters) {
|
|
// Show modal for actions with parameters
|
|
selectedSensor.value = sensor
|
|
selectedAction.value = action
|
|
showActionModal.value = true
|
|
} else {
|
|
// Execute simple actions directly
|
|
handleActionExecute(sensor.id, action.id, {})
|
|
}
|
|
}
|
|
|
|
const handleActionExecute = async (sensorId: string, actionId: string, parameters: any) => {
|
|
isExecutingAction.value = true
|
|
try {
|
|
await energyStore.executeSensorAction(sensorId, actionId, parameters)
|
|
} catch (error) {
|
|
console.error('Action execution failed:', error)
|
|
} finally {
|
|
isExecutingAction.value = false
|
|
closeActionModal()
|
|
}
|
|
}
|
|
|
|
const closeActionModal = () => {
|
|
showActionModal.value = false
|
|
selectedSensor.value = null
|
|
selectedAction.value = null
|
|
}
|
|
|
|
const showSensorDetails = () => {
|
|
// Switch to detailed view when user wants to see more actions
|
|
viewMode.value = 'detailed'
|
|
}
|
|
|
|
// Helper functions for styling
|
|
const getCO2StatusColor = (co2Level: number) => {
|
|
if (co2Level < 400) return 'text-green-600'
|
|
if (co2Level < 1000) return 'text-yellow-600'
|
|
if (co2Level < 5000) return 'text-orange-600'
|
|
return 'text-red-600'
|
|
}
|
|
|
|
const getOccupancyColor = (occupancy: string) => {
|
|
switch (occupancy) {
|
|
case 'low':
|
|
return 'text-green-600'
|
|
case 'medium':
|
|
return 'text-yellow-600'
|
|
case 'high':
|
|
return 'text-red-600'
|
|
default:
|
|
return 'text-gray-600'
|
|
}
|
|
}
|
|
|
|
// WebSocket connection for real-time updates
|
|
onMounted(() => {
|
|
if (!energyStore.isConnected) {
|
|
energyStore.connect('ws://localhost:8000/ws')
|
|
}
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
// Keep the connection alive for other components
|
|
// energyStore.disconnect()
|
|
})
|
|
</script>
|