Initial commit: Tau Acuvim IoT monitoring system
Complete IoT monitoring platform for Acuvim II power meters via ESP32. Firmware (Phases 1-7): - ESP32-WROVER-B (TTGO T-Call v1.4) with RS485 Modbus RTU - WiFi STA+AP concurrent mode with GSM/GPRS failover - Transport abstraction layer with 4 priority modes - MQTT protocol with 20 commands, LWT, QoS, exponential backoff - SD card offline buffering with JSONL rotation and non-blocking drain - OTA firmware updates with dual partition rollback protection - Watchdog timer, crash loop detection, Acuvim health monitoring - Captive portal provisioning with AP mode Console backend (Phase 8): - .NET 10 minimal API with PostgreSQL + EF Core - JWT authentication, SignalR real-time updates - MQTTnet 5.x bridge service with health monitoring - Device, telemetry, firmware, alert, group management - Rate limiting, security headers, Swagger/OpenAPI Frontend (Phase 9): - React 18 + TypeScript + Vite with Ant Design 5 - ECharts telemetry visualization, TanStack Query - SignalR live updates, device management UI - Dashboard, fleet management, firmware deployment Testing & Production (Phase 10): - 28 firmware unit tests (Modbus, JSON, config, version) - 23 xUnit backend tests (device, telemetry, command, alert) - Docker Compose with nginx, TLS MQTT, PostgreSQL - Production deployment, commissioning, and troubleshooting docs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
84a0668c54
40
.gitignore
vendored
Normal file
40
.gitignore
vendored
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# .NET
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
*.cache
|
||||||
|
*.dll
|
||||||
|
*.exe
|
||||||
|
*.pdb
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# PlatformIO
|
||||||
|
.pio/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
Thumbs.db
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
console/frontend/dist/
|
||||||
|
|
||||||
|
# NuGet
|
||||||
|
*.nupkg
|
||||||
|
|
||||||
|
# Mosquitto passwords (production secrets)
|
||||||
|
mqtt-passwords/
|
||||||
|
mqtt-certs/
|
||||||
4
console/.env.example
Normal file
4
console/.env.example
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
POSTGRES_PASSWORD=change-this-strong-password
|
||||||
|
MQTT_PASSWORD=change-this-mqtt-password
|
||||||
|
JWT_SECRET=change-this-minimum-32-character-secret-key!!
|
||||||
|
CONSOLE_URL=https://console.yourdomain.com
|
||||||
27
console/Dockerfile
Normal file
27
console/Dockerfile
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
FROM node:22 AS frontend
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY frontend/package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
COPY src/Tau.Acuvim.Console/Tau.Acuvim.Console.csproj ./Tau.Acuvim.Console/
|
||||||
|
RUN dotnet restore Tau.Acuvim.Console/Tau.Acuvim.Console.csproj
|
||||||
|
|
||||||
|
COPY src/Tau.Acuvim.Console/ ./Tau.Acuvim.Console/
|
||||||
|
RUN dotnet publish Tau.Acuvim.Console/Tau.Acuvim.Console.csproj -c Release -o /app/publish
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app/publish .
|
||||||
|
COPY --from=frontend /app/frontend/dist ./wwwroot/
|
||||||
|
|
||||||
|
RUN mkdir -p /app/firmware
|
||||||
|
|
||||||
|
ENV ASPNETCORE_URLS=http://+:5000
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
ENTRYPOINT ["dotnet", "Tau.Acuvim.Console.dll"]
|
||||||
69
console/docker-compose.prod.yml
Normal file
69
console/docker-compose.prod.yml
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
services:
|
||||||
|
console:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
environment:
|
||||||
|
- ASPNETCORE_ENVIRONMENT=Production
|
||||||
|
- ConnectionStrings__DefaultConnection=Host=db;Database=acuvim;Username=acuvim;Password=${POSTGRES_PASSWORD}
|
||||||
|
- Mqtt__Broker=mqtt
|
||||||
|
- Mqtt__Port=8883
|
||||||
|
- Mqtt__Username=console
|
||||||
|
- Mqtt__Password=${MQTT_PASSWORD}
|
||||||
|
- Mqtt__UseTls=true
|
||||||
|
- Firmware__BaseUrl=${CONSOLE_URL}
|
||||||
|
- Jwt__Secret=${JWT_SECRET}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
mqtt:
|
||||||
|
condition: service_started
|
||||||
|
volumes:
|
||||||
|
- firmware-data:/app/firmware
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=acuvim
|
||||||
|
- POSTGRES_USER=acuvim
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U acuvim"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
mqtt:
|
||||||
|
image: eclipse-mosquitto:2
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8883:8883"
|
||||||
|
volumes:
|
||||||
|
- mosquitto-data:/mosquitto/data
|
||||||
|
- mosquitto-config:/mosquitto/config
|
||||||
|
- ./mosquitto-prod.conf:/mosquitto/config/mosquitto.conf
|
||||||
|
- ./mqtt-certs:/mosquitto/certs
|
||||||
|
- ./mqtt-passwords:/mosquitto/passwords
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "443:443"
|
||||||
|
- "80:80"
|
||||||
|
volumes:
|
||||||
|
- ./nginx.conf:/etc/nginx/conf.d/default.conf
|
||||||
|
- ssl-certs:/etc/letsencrypt
|
||||||
|
depends_on:
|
||||||
|
- console
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
|
mosquitto-data:
|
||||||
|
mosquitto-config:
|
||||||
|
firmware-data:
|
||||||
|
ssl-certs:
|
||||||
47
console/docker-compose.yml
Normal file
47
console/docker-compose.yml
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
services:
|
||||||
|
console:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
environment:
|
||||||
|
- ConnectionStrings__DefaultConnection=Host=db;Database=acuvim;Username=acuvim;Password=secret
|
||||||
|
- Mqtt__Broker=mqtt
|
||||||
|
- Mqtt__Port=1883
|
||||||
|
- Firmware__BaseUrl=http://localhost:5000
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
mqtt:
|
||||||
|
condition: service_started
|
||||||
|
volumes:
|
||||||
|
- firmware-data:/app/firmware
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=acuvim
|
||||||
|
- POSTGRES_USER=acuvim
|
||||||
|
- POSTGRES_PASSWORD=secret
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U acuvim"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
mqtt:
|
||||||
|
image: eclipse-mosquitto:2
|
||||||
|
ports:
|
||||||
|
- "1883:1883"
|
||||||
|
- "9001:9001"
|
||||||
|
volumes:
|
||||||
|
- mosquitto-data:/mosquitto/data
|
||||||
|
- ./mosquitto.conf:/mosquitto/config/mosquitto.conf
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
|
mosquitto-data:
|
||||||
|
firmware-data:
|
||||||
12
console/frontend/index.html
Normal file
12
console/frontend/index.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Tau Acuvim Console</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3496
console/frontend/package-lock.json
generated
Normal file
3496
console/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
console/frontend/package.json
Normal file
31
console/frontend/package.json
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "tau-acuvim-console",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^5.6.1",
|
||||||
|
"@microsoft/signalr": "^8.0.7",
|
||||||
|
"@tanstack/react-query": "^5.62.0",
|
||||||
|
"antd": "^5.22.0",
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
|
"echarts": "^5.5.1",
|
||||||
|
"echarts-for-react": "^3.0.2",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.28.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "~5.6.2",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
57
console/frontend/src/App.tsx
Normal file
57
console/frontend/src/App.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { ConfigProvider, theme, App as AntApp } from 'antd';
|
||||||
|
import { AuthContext, useAuthProvider } from './hooks/useAuth';
|
||||||
|
import { useSignalR } from './hooks/useSignalR';
|
||||||
|
import AppLayout from './components/layout/AppLayout';
|
||||||
|
import LoginPage from './pages/LoginPage';
|
||||||
|
import DashboardPage from './pages/DashboardPage';
|
||||||
|
import DeviceListPage from './pages/DeviceListPage';
|
||||||
|
import DeviceDetailPage from './pages/DeviceDetailPage';
|
||||||
|
import FirmwarePage from './pages/FirmwarePage';
|
||||||
|
import AlertsPage from './pages/AlertsPage';
|
||||||
|
import GroupsPage from './pages/GroupsPage';
|
||||||
|
import SettingsPage from './pages/SettingsPage';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: 1, staleTime: 10_000 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
function AppRoutes() {
|
||||||
|
const auth = useAuthProvider();
|
||||||
|
useSignalR(auth.isAuthenticated);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={auth}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route element={
|
||||||
|
auth.isAuthenticated ? <AppLayout /> : <Navigate to="/login" replace />
|
||||||
|
}>
|
||||||
|
<Route path="/" element={<DashboardPage />} />
|
||||||
|
<Route path="/devices" element={<DeviceListPage />} />
|
||||||
|
<Route path="/devices/:deviceId" element={<DeviceDetailPage />} />
|
||||||
|
<Route path="/firmware" element={<FirmwarePage />} />
|
||||||
|
<Route path="/alerts" element={<AlertsPage />} />
|
||||||
|
<Route path="/groups" element={<GroupsPage />} />
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<ConfigProvider theme={{ algorithm: theme.defaultAlgorithm }}>
|
||||||
|
<AntApp>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AppRoutes />
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AntApp>
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
console/frontend/src/api/alerts.ts
Normal file
13
console/frontend/src/api/alerts.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import client from './client';
|
||||||
|
import type { Alert } from '../types/alert';
|
||||||
|
|
||||||
|
export const alertsApi = {
|
||||||
|
list: (params?: { severity?: string; acknowledged?: boolean; page?: number; pageSize?: number }) =>
|
||||||
|
client.get<Alert[]>('/alerts', { params }).then((r) => r.data),
|
||||||
|
|
||||||
|
byDevice: (deviceId: string) =>
|
||||||
|
client.get<Alert[]>(`/alerts/device/${deviceId}`).then((r) => r.data),
|
||||||
|
|
||||||
|
acknowledge: (id: number) =>
|
||||||
|
client.post(`/alerts/${id}/acknowledge`).then((r) => r.data),
|
||||||
|
};
|
||||||
20
console/frontend/src/api/auth.ts
Normal file
20
console/frontend/src/api/auth.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import client from './client';
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
token: string;
|
||||||
|
expiresAt: string;
|
||||||
|
user: { id: string; username: string; email: string; role: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
login: (data: LoginRequest) =>
|
||||||
|
client.post<AuthResponse>('/auth/login', data).then((r) => r.data),
|
||||||
|
|
||||||
|
me: () =>
|
||||||
|
client.get<{ id: string; username: string; email: string; role: string }>('/auth/me').then((r) => r.data),
|
||||||
|
};
|
||||||
34
console/frontend/src/api/client.ts
Normal file
34
console/frontend/src/api/client.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { API_BASE } from '../utils/constants';
|
||||||
|
|
||||||
|
const client = axios.create({ baseURL: API_BASE });
|
||||||
|
|
||||||
|
let tokenRef: string | null = null;
|
||||||
|
|
||||||
|
export function setToken(token: string | null) {
|
||||||
|
tokenRef = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToken(): string | null {
|
||||||
|
return tokenRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
client.interceptors.request.use((config) => {
|
||||||
|
if (tokenRef) {
|
||||||
|
config.headers.Authorization = `Bearer ${tokenRef}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
client.interceptors.response.use(
|
||||||
|
(res) => res,
|
||||||
|
(err) => {
|
||||||
|
if (err.response?.status === 401) {
|
||||||
|
tokenRef = null;
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(err);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default client;
|
||||||
39
console/frontend/src/api/devices.ts
Normal file
39
console/frontend/src/api/devices.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import client from './client';
|
||||||
|
import type { DeviceListItem, DeviceDetail, UpdateDeviceRequest, CommandResponse, DashboardSummary, DeviceGroup } from '../types/device';
|
||||||
|
|
||||||
|
export const devicesApi = {
|
||||||
|
list: (params?: { status?: string; search?: string; page?: number; pageSize?: number }) =>
|
||||||
|
client.get<DeviceListItem[]>('/devices', { params }).then((r) => r.data),
|
||||||
|
|
||||||
|
get: (deviceId: string) =>
|
||||||
|
client.get<DeviceDetail>(`/devices/${deviceId}`).then((r) => r.data),
|
||||||
|
|
||||||
|
update: (deviceId: string, data: UpdateDeviceRequest) =>
|
||||||
|
client.put(`/devices/${deviceId}`, data).then((r) => r.data),
|
||||||
|
|
||||||
|
delete: (deviceId: string) =>
|
||||||
|
client.delete(`/devices/${deviceId}`),
|
||||||
|
|
||||||
|
sendCommand: (deviceId: string, command: string, params?: Record<string, unknown>) =>
|
||||||
|
client.post<CommandResponse>(`/devices/${deviceId}/command`, { command, params }).then((r) => r.data),
|
||||||
|
|
||||||
|
getCommands: (deviceId: string) =>
|
||||||
|
client.get<CommandResponse[]>(`/devices/${deviceId}/commands`).then((r) => r.data),
|
||||||
|
|
||||||
|
dashboard: () =>
|
||||||
|
client.get<DashboardSummary>('/dashboard').then((r) => r.data),
|
||||||
|
|
||||||
|
groups: {
|
||||||
|
list: () =>
|
||||||
|
client.get<DeviceGroup[]>('/groups').then((r) => r.data),
|
||||||
|
|
||||||
|
create: (data: { name: string; description?: string }) =>
|
||||||
|
client.post<DeviceGroup>('/groups', data).then((r) => r.data),
|
||||||
|
|
||||||
|
update: (id: string, data: { name: string; description?: string }) =>
|
||||||
|
client.put(`/groups/${id}`, data).then((r) => r.data),
|
||||||
|
|
||||||
|
delete: (id: string) =>
|
||||||
|
client.delete(`/groups/${id}`),
|
||||||
|
},
|
||||||
|
};
|
||||||
18
console/frontend/src/api/firmware.ts
Normal file
18
console/frontend/src/api/firmware.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import client from './client';
|
||||||
|
import type { FirmwareVersion } from '../types/firmware';
|
||||||
|
|
||||||
|
export const firmwareApi = {
|
||||||
|
list: () =>
|
||||||
|
client.get<FirmwareVersion[]>('/firmware').then((r) => r.data),
|
||||||
|
|
||||||
|
upload: (formData: FormData) =>
|
||||||
|
client.post<FirmwareVersion>('/firmware', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
}).then((r) => r.data),
|
||||||
|
|
||||||
|
delete: (version: string) =>
|
||||||
|
client.delete(`/firmware/${version}`),
|
||||||
|
|
||||||
|
deploy: (version: string, deviceIds: string[]) =>
|
||||||
|
client.post('/firmware/deploy', { version, deviceIds }).then((r) => r.data),
|
||||||
|
};
|
||||||
10
console/frontend/src/api/telemetry.ts
Normal file
10
console/frontend/src/api/telemetry.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import client from './client';
|
||||||
|
import type { TelemetryRecord } from '../types/telemetry';
|
||||||
|
|
||||||
|
export const telemetryApi = {
|
||||||
|
latest: (deviceId: string) =>
|
||||||
|
client.get<TelemetryRecord>(`/telemetry/${deviceId}/latest`).then((r) => r.data),
|
||||||
|
|
||||||
|
history: (deviceId: string, params: { from: string; to: string; page?: number; pageSize?: number }) =>
|
||||||
|
client.get<TelemetryRecord[]>(`/telemetry/${deviceId}/history`, { params }).then((r) => r.data),
|
||||||
|
};
|
||||||
94
console/frontend/src/components/alerts/AlertTable.tsx
Normal file
94
console/frontend/src/components/alerts/AlertTable.tsx
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import { Table, Tag, Button, message, Space, Select, Input } from 'antd';
|
||||||
|
import { CheckOutlined, SearchOutlined } from '@ant-design/icons';
|
||||||
|
import type { Alert } from '../../types/alert';
|
||||||
|
import { timeAgo } from '../../utils/formatters';
|
||||||
|
import { SEVERITY_COLORS } from '../../utils/constants';
|
||||||
|
import { alertsApi } from '../../api/alerts';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
alerts: Alert[];
|
||||||
|
loading: boolean;
|
||||||
|
filters?: boolean;
|
||||||
|
severity?: string;
|
||||||
|
search?: string;
|
||||||
|
onSeverityChange?: (s: string | undefined) => void;
|
||||||
|
onSearchChange?: (s: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AlertTable({ alerts, loading, filters, severity, search, onSeverityChange, onSearchChange }: Props) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const handleAck = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await alertsApi.acknowledge(id);
|
||||||
|
message.success('Alert acknowledged');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['alerts'] });
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to acknowledge alert');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: 'Severity',
|
||||||
|
dataIndex: 'severity',
|
||||||
|
key: 'severity',
|
||||||
|
width: 90,
|
||||||
|
render: (s: string) => <Tag color={SEVERITY_COLORS[s]}>{s}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Device',
|
||||||
|
dataIndex: 'deviceId',
|
||||||
|
key: 'deviceId',
|
||||||
|
render: (id: string) => <span style={{ fontFamily: 'monospace', fontSize: 12 }}>{id}</span>,
|
||||||
|
},
|
||||||
|
{ title: 'Type', dataIndex: 'alertType', key: 'alertType' },
|
||||||
|
{ title: 'Message', dataIndex: 'message', key: 'message', ellipsis: true },
|
||||||
|
{
|
||||||
|
title: 'Value',
|
||||||
|
key: 'value',
|
||||||
|
render: (_: unknown, r: Alert) => r.value != null ? `${r.value.toFixed(1)} / ${r.threshold?.toFixed(1) ?? '--'}` : '--',
|
||||||
|
},
|
||||||
|
{ title: 'Time', dataIndex: 'createdAt', key: 'createdAt', render: (d: string) => timeAgo(d) },
|
||||||
|
{
|
||||||
|
title: 'Ack',
|
||||||
|
key: 'ack',
|
||||||
|
width: 80,
|
||||||
|
render: (_: unknown, r: Alert) =>
|
||||||
|
r.acknowledged
|
||||||
|
? <Tag color="green">Yes</Tag>
|
||||||
|
: <Button size="small" icon={<CheckOutlined />} onClick={() => handleAck(r.id)} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{filters && (
|
||||||
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
|
<Select
|
||||||
|
placeholder="Severity"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 140 }}
|
||||||
|
value={severity}
|
||||||
|
onChange={onSeverityChange}
|
||||||
|
options={[
|
||||||
|
{ label: 'Critical', value: 'critical' },
|
||||||
|
{ label: 'Warning', value: 'warning' },
|
||||||
|
{ label: 'Info', value: 'info' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Search alerts"
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => onSearchChange?.(e.target.value)}
|
||||||
|
style={{ width: 250 }}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
<Table dataSource={alerts} columns={columns} rowKey="id" loading={loading} size="middle" pagination={{ pageSize: 20 }} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
console/frontend/src/components/dashboard/DeviceMap.tsx
Normal file
38
console/frontend/src/components/dashboard/DeviceMap.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { Card, Row, Col, Typography, theme } from 'antd';
|
||||||
|
import type { DeviceListItem } from '../../types/device';
|
||||||
|
import { STATUS_COLORS } from '../../utils/constants';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
devices: DeviceListItem[];
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeviceMap({ devices, loading }: Props) {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title="Device Status Grid" size="small" loading={loading}>
|
||||||
|
<Row gutter={[8, 8]}>
|
||||||
|
{devices.map((d) => (
|
||||||
|
<Col key={d.id} xs={8} sm={6} md={4} lg={3}>
|
||||||
|
<div style={{
|
||||||
|
padding: 8,
|
||||||
|
borderRadius: token.borderRadius,
|
||||||
|
border: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 10, height: 10, borderRadius: '50%',
|
||||||
|
backgroundColor: STATUS_COLORS[d.status] ?? '#d9d9d9',
|
||||||
|
display: 'inline-block', marginBottom: 4,
|
||||||
|
}} />
|
||||||
|
<Typography.Text style={{ display: 'block', fontSize: 11, fontFamily: 'monospace' }} ellipsis>
|
||||||
|
{d.name ?? d.deviceId.slice(-6)}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
console/frontend/src/components/dashboard/FleetSummary.tsx
Normal file
44
console/frontend/src/components/dashboard/FleetSummary.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { Row, Col, Card, Statistic } from 'antd';
|
||||||
|
import {
|
||||||
|
ApiOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
WarningOutlined,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
AlertOutlined,
|
||||||
|
BarChartOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import type { DashboardSummary } from '../../types/device';
|
||||||
|
import { STATUS_COLORS } from '../../utils/constants';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: DashboardSummary | undefined;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FleetSummary({ data, loading }: Props) {
|
||||||
|
const items = [
|
||||||
|
{ title: 'Total Devices', value: data?.totalDevices, icon: <ApiOutlined />, color: '#1677ff' },
|
||||||
|
{ title: 'Online', value: data?.onlineDevices, icon: <CheckCircleOutlined />, color: STATUS_COLORS['online'] },
|
||||||
|
{ title: 'Degraded', value: data?.degradedDevices, icon: <WarningOutlined />, color: STATUS_COLORS['degraded'] },
|
||||||
|
{ title: 'Offline', value: data?.offlineDevices, icon: <CloseCircleOutlined />, color: STATUS_COLORS['offline'] },
|
||||||
|
{ title: 'Active Alerts', value: data?.activeAlerts, icon: <AlertOutlined />, color: '#fa541c' },
|
||||||
|
{ title: 'Telemetry Today', value: data?.telemetryToday, icon: <BarChartOutlined />, color: '#722ed1' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<Col xs={12} sm={8} lg={4} key={item.title}>
|
||||||
|
<Card loading={loading} size="small">
|
||||||
|
<Statistic
|
||||||
|
title={item.title}
|
||||||
|
value={item.value ?? 0}
|
||||||
|
prefix={item.icon}
|
||||||
|
valueStyle={{ color: item.color }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
console/frontend/src/components/dashboard/RecentAlerts.tsx
Normal file
32
console/frontend/src/components/dashboard/RecentAlerts.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { Card, List, Tag, Typography } from 'antd';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { alertsApi } from '../../api/alerts';
|
||||||
|
import { SEVERITY_COLORS } from '../../utils/constants';
|
||||||
|
import { timeAgo } from '../../utils/formatters';
|
||||||
|
|
||||||
|
export default function RecentAlerts() {
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['alerts', 'recent'],
|
||||||
|
queryFn: () => alertsApi.list({ page: 1, pageSize: 8 }),
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title="Recent Alerts" size="small">
|
||||||
|
<List
|
||||||
|
loading={isLoading}
|
||||||
|
dataSource={data ?? []}
|
||||||
|
renderItem={(item) => (
|
||||||
|
<List.Item>
|
||||||
|
<List.Item.Meta
|
||||||
|
avatar={<Tag color={SEVERITY_COLORS[item.severity]}>{item.severity.charAt(0).toUpperCase()}</Tag>}
|
||||||
|
title={<Typography.Text>{item.alertType} - <Typography.Text code style={{ fontSize: 11 }}>{item.deviceId}</Typography.Text></Typography.Text>}
|
||||||
|
description={<Typography.Text type="secondary">{item.message} · {timeAgo(item.createdAt)}</Typography.Text>}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
locale={{ emptyText: 'No recent alerts' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
console/frontend/src/components/devices/CommandModal.tsx
Normal file
58
console/frontend/src/components/devices/CommandModal.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { Modal, Form, Select, Input, message } from 'antd';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { devicesApi } from '../../api/devices';
|
||||||
|
|
||||||
|
const COMMANDS = [
|
||||||
|
'ping', 'get_config', 'get_status', 'get_telemetry', 'restart',
|
||||||
|
'factory_reset', 'wifi_scan', 'wifi_set', 'wifi_disconnect',
|
||||||
|
'mqtt_set', 'gsm_set', 'transport_priority', 'sleep_set',
|
||||||
|
'modbus_set', 'console_set', 'ota_update', 'ota_check',
|
||||||
|
'set_heartbeat', 'set_alerts',
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
deviceId: string;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CommandModal({ deviceId, open, onClose }: Props) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
let params: Record<string, unknown> | undefined;
|
||||||
|
if (values.params) {
|
||||||
|
try { params = JSON.parse(values.params); } catch { message.error('Invalid JSON params'); return; }
|
||||||
|
}
|
||||||
|
await devicesApi.sendCommand(deviceId, values.command, params);
|
||||||
|
message.success('Command sent');
|
||||||
|
form.resetFields();
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to send command');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="Send Command" open={open} onOk={handleSend} onCancel={onClose} confirmLoading={loading} okText="Send">
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="command" label="Command" rules={[{ required: true }]}>
|
||||||
|
<Select
|
||||||
|
options={COMMANDS.map((c) => ({ label: c, value: c }))}
|
||||||
|
placeholder="Select command"
|
||||||
|
showSearch
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="params" label="Parameters (JSON)">
|
||||||
|
<Input.TextArea rows={4} placeholder='{"key": "value"}' />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
console/frontend/src/components/devices/DeviceCard.tsx
Normal file
45
console/frontend/src/components/devices/DeviceCard.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { Card, Descriptions, Tag, Space, Button } from 'antd';
|
||||||
|
import { ReloadOutlined, WifiOutlined, CodeOutlined } from '@ant-design/icons';
|
||||||
|
import type { DeviceDetail } from '../../types/device';
|
||||||
|
import DeviceStatusBadge from './DeviceStatusBadge';
|
||||||
|
import { timeAgo } from '../../utils/formatters';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
device: DeviceDetail;
|
||||||
|
onRestart: () => void;
|
||||||
|
onWifiScan: () => void;
|
||||||
|
onCommand: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeviceCard({ device, onRestart, onWifiScan, onCommand }: Props) {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title="Device Info"
|
||||||
|
extra={<DeviceStatusBadge status={device.status} />}
|
||||||
|
>
|
||||||
|
<Descriptions column={{ xs: 1, sm: 2 }} size="small">
|
||||||
|
<Descriptions.Item label="Device ID">
|
||||||
|
<span style={{ fontFamily: 'monospace' }}>{device.deviceId}</span>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Name">{device.name ?? '--'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Hardware">{device.hardware ?? '--'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Firmware">{device.firmwareVersion ?? '--'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="MAC">{device.macAddress ?? '--'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="IMEI">{device.imei ?? '--'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Connection">
|
||||||
|
{device.connectionType ? <Tag>{device.connectionType}</Tag> : '--'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Signal">{device.signalStrength != null ? `${device.signalStrength} dBm` : '--'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="IP">{device.ipAddress ?? '--'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Boot Count">{device.bootCount}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Last Heartbeat">{timeAgo(device.lastHeartbeat)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Last Telemetry">{timeAgo(device.lastTelemetry)}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
<Space style={{ marginTop: 16 }}>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={onRestart}>Restart</Button>
|
||||||
|
<Button icon={<WifiOutlined />} onClick={onWifiScan}>WiFi Scan</Button>
|
||||||
|
<Button icon={<CodeOutlined />} onClick={onCommand}>Send Command</Button>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
console/frontend/src/components/devices/DeviceConfigForm.tsx
Normal file
94
console/frontend/src/components/devices/DeviceConfigForm.tsx
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import { Card, Form, Input, InputNumber, Switch, Select, Button, Row, Col, message, Alert } from 'antd';
|
||||||
|
import { devicesApi } from '../../api/devices';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
deviceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeviceConfigForm({ deviceId }: Props) {
|
||||||
|
const sendConfig = async (command: string, values: Record<string, unknown>) => {
|
||||||
|
try {
|
||||||
|
await devicesApi.sendCommand(deviceId, command, values);
|
||||||
|
message.success(`${command} sent to device`);
|
||||||
|
} catch {
|
||||||
|
message.error(`Failed to send ${command}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Alert message="Changes are pushed to the device immediately via MQTT. The device will apply settings and reconnect as needed." type="warning" showIcon style={{ marginBottom: 24 }} />
|
||||||
|
<Row gutter={[24, 24]}>
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card title="WiFi Configuration" size="small">
|
||||||
|
<Form layout="vertical" onFinish={(v) => sendConfig('wifi_set', v)}>
|
||||||
|
<Form.Item name="ssid" label="SSID"><Input /></Form.Item>
|
||||||
|
<Form.Item name="password" label="Password"><Input.Password /></Form.Item>
|
||||||
|
<Form.Item name="enabled" label="Enabled" valuePropName="checked"><Switch /></Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit">Save</Button>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card title="MQTT Configuration" size="small">
|
||||||
|
<Form layout="vertical" onFinish={(v) => sendConfig('mqtt_set', v)}>
|
||||||
|
<Form.Item name="broker" label="Broker"><Input /></Form.Item>
|
||||||
|
<Form.Item name="port" label="Port"><InputNumber min={1} max={65535} style={{ width: '100%' }} /></Form.Item>
|
||||||
|
<Form.Item name="username" label="Username"><Input /></Form.Item>
|
||||||
|
<Form.Item name="password" label="Password"><Input.Password /></Form.Item>
|
||||||
|
<Form.Item name="topic" label="Topic Prefix"><Input /></Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit">Save</Button>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card title="GSM Configuration" size="small">
|
||||||
|
<Form layout="vertical" onFinish={(v) => sendConfig('gsm_set', v)}>
|
||||||
|
<Form.Item name="apn" label="APN"><Input /></Form.Item>
|
||||||
|
<Form.Item name="enabled" label="Enabled" valuePropName="checked"><Switch /></Form.Item>
|
||||||
|
<Form.Item name="priority" label="Transport Priority">
|
||||||
|
<Select options={[
|
||||||
|
{ label: 'WiFi First', value: 'wifi_first' },
|
||||||
|
{ label: 'GSM First', value: 'gsm_first' },
|
||||||
|
{ label: 'WiFi Only', value: 'wifi_only' },
|
||||||
|
{ label: 'GSM Only', value: 'gsm_only' },
|
||||||
|
]} />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit">Save</Button>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card title="Modbus Configuration" size="small">
|
||||||
|
<Form layout="vertical" onFinish={(v) => sendConfig('modbus_set', v)}>
|
||||||
|
<Form.Item name="slave_address" label="Slave Address"><InputNumber min={1} max={247} style={{ width: '100%' }} /></Form.Item>
|
||||||
|
<Form.Item name="baud_rate" label="Baud Rate">
|
||||||
|
<Select options={[9600, 19200, 38400, 57600, 115200].map((r) => ({ label: String(r), value: r }))} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="poll_interval" label="Poll Interval (sec)"><InputNumber min={1} max={3600} style={{ width: '100%' }} /></Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit">Save</Button>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card title="Sleep Configuration" size="small">
|
||||||
|
<Form layout="vertical" onFinish={(v) => sendConfig('sleep_set', v)}>
|
||||||
|
<Form.Item name="enabled" label="Enabled" valuePropName="checked"><Switch /></Form.Item>
|
||||||
|
<Form.Item name="sleep_minutes" label="Sleep Duration (min)"><InputNumber min={1} max={1440} style={{ width: '100%' }} /></Form.Item>
|
||||||
|
<Form.Item name="wake_seconds" label="Wake Duration (sec)"><InputNumber min={30} max={3600} style={{ width: '100%' }} /></Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit">Save</Button>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card title="Console URL" size="small">
|
||||||
|
<Form layout="vertical" onFinish={(v) => sendConfig('console_set', v)}>
|
||||||
|
<Form.Item name="url" label="Console URL"><Input placeholder="https://console.example.com" /></Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit">Save</Button>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
import { Badge } from 'antd';
|
||||||
|
import { STATUS_COLORS } from '../../utils/constants';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
status: string;
|
||||||
|
text?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeviceStatusBadge({ status, text = true }: Props) {
|
||||||
|
const color = STATUS_COLORS[status] ?? '#d9d9d9';
|
||||||
|
return <Badge color={color} text={text ? status.charAt(0).toUpperCase() + status.slice(1) : undefined} />;
|
||||||
|
}
|
||||||
101
console/frontend/src/components/devices/DeviceTable.tsx
Normal file
101
console/frontend/src/components/devices/DeviceTable.tsx
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { Table, Input, Select, Space } from 'antd';
|
||||||
|
import { SearchOutlined } from '@ant-design/icons';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import type { DeviceListItem } from '../../types/device';
|
||||||
|
import DeviceStatusBadge from './DeviceStatusBadge';
|
||||||
|
import { timeAgo } from '../../utils/formatters';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
devices: DeviceListItem[];
|
||||||
|
loading: boolean;
|
||||||
|
status: string | undefined;
|
||||||
|
search: string;
|
||||||
|
onStatusChange: (status: string | undefined) => void;
|
||||||
|
onSearchChange: (search: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeviceTable({ devices, loading, status, search, onStatusChange, onSearchChange }: Props) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: 'Status',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
width: 100,
|
||||||
|
render: (s: string) => <DeviceStatusBadge status={s} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Device ID',
|
||||||
|
dataIndex: 'deviceId',
|
||||||
|
key: 'deviceId',
|
||||||
|
render: (id: string) => <span style={{ fontFamily: 'monospace' }}>{id}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Name',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
render: (n: string | null) => n ?? '--',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Firmware',
|
||||||
|
dataIndex: 'firmwareVersion',
|
||||||
|
key: 'firmwareVersion',
|
||||||
|
render: (v: string | null) => v ?? '--',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Connection',
|
||||||
|
dataIndex: 'connectionType',
|
||||||
|
key: 'connectionType',
|
||||||
|
render: (c: string | null) => c ?? '--',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Group',
|
||||||
|
dataIndex: 'groupName',
|
||||||
|
key: 'groupName',
|
||||||
|
render: (g: string | null) => g ?? '--',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Last Seen',
|
||||||
|
dataIndex: 'lastHeartbeat',
|
||||||
|
key: 'lastHeartbeat',
|
||||||
|
render: (d: string | null) => timeAgo(d),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
|
<Select
|
||||||
|
placeholder="Filter by status"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 160 }}
|
||||||
|
value={status}
|
||||||
|
onChange={onStatusChange}
|
||||||
|
options={[
|
||||||
|
{ label: 'Online', value: 'online' },
|
||||||
|
{ label: 'Degraded', value: 'degraded' },
|
||||||
|
{ label: 'Offline', value: 'offline' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Search devices"
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
style={{ width: 250 }}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
<Table
|
||||||
|
dataSource={devices}
|
||||||
|
columns={columns}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
onRow={(record) => ({ onClick: () => navigate(`/devices/${record.deviceId}`), style: { cursor: 'pointer' } })}
|
||||||
|
pagination={{ pageSize: 20 }}
|
||||||
|
size="middle"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
console/frontend/src/components/devices/WifiScanModal.tsx
Normal file
51
console/frontend/src/components/devices/WifiScanModal.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { Modal, Table, message, Spin } from 'antd';
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { devicesApi } from '../../api/devices';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
deviceId: string;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WifiScanModal({ deviceId, open, onClose }: Props) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
|
||||||
|
const triggerScan = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setSent(false);
|
||||||
|
try {
|
||||||
|
await devicesApi.sendCommand(deviceId, 'wifi_scan');
|
||||||
|
setSent(true);
|
||||||
|
message.info('WiFi scan command sent. Check device commands for results.');
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to send WiFi scan command');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [deviceId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) triggerScan();
|
||||||
|
}, [open, triggerScan]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="WiFi Scan" open={open} onCancel={onClose} footer={null}>
|
||||||
|
{loading && <Spin tip="Sending scan command..." />}
|
||||||
|
{sent && !loading && (
|
||||||
|
<Table
|
||||||
|
dataSource={[]}
|
||||||
|
columns={[
|
||||||
|
{ title: 'SSID', dataIndex: 'ssid', key: 'ssid' },
|
||||||
|
{ title: 'RSSI', dataIndex: 'rssi', key: 'rssi' },
|
||||||
|
{ title: 'Security', dataIndex: 'security', key: 'security' },
|
||||||
|
]}
|
||||||
|
locale={{ emptyText: 'Scan command sent. Results will appear in the Commands tab when the device responds.' }}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
console/frontend/src/components/firmware/DeployModal.tsx
Normal file
85
console/frontend/src/components/firmware/DeployModal.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { Modal, Radio, Select, Checkbox, Button, message, Space, Typography } from 'antd';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useDeviceList, useGroups } from '../../hooks/useDevices';
|
||||||
|
import { firmwareApi } from '../../api/firmware';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
version: string;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeployModal({ version, open, onClose }: Props) {
|
||||||
|
const [mode, setMode] = useState<'all' | 'group' | 'select'>('select');
|
||||||
|
const [selectedGroup, setSelectedGroup] = useState<string>();
|
||||||
|
const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const { data: devices } = useDeviceList({ status: 'online' });
|
||||||
|
const { data: groups } = useGroups();
|
||||||
|
|
||||||
|
const handleDeploy = async () => {
|
||||||
|
let deviceIds: string[];
|
||||||
|
if (mode === 'all') {
|
||||||
|
deviceIds = devices?.map((d) => d.deviceId) ?? [];
|
||||||
|
} else if (mode === 'group') {
|
||||||
|
deviceIds = devices?.filter((d) => d.groupName === groups?.find((g) => g.id === selectedGroup)?.name).map((d) => d.deviceId) ?? [];
|
||||||
|
} else {
|
||||||
|
deviceIds = selectedDevices;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deviceIds.length === 0) { message.warning('No devices selected'); return; }
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await firmwareApi.deploy(version, deviceIds);
|
||||||
|
message.success(`Deploy started for ${deviceIds.length} device(s)`);
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
message.error('Deploy failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={`Deploy ${version}`} open={open} onCancel={onClose} footer={[
|
||||||
|
<Button key="cancel" onClick={onClose}>Cancel</Button>,
|
||||||
|
<Button key="deploy" type="primary" loading={loading} onClick={handleDeploy}>
|
||||||
|
Deploy to {mode === 'all' ? (devices?.length ?? 0) : mode === 'group' ? '...' : selectedDevices.length} device(s)
|
||||||
|
</Button>,
|
||||||
|
]}>
|
||||||
|
<Radio.Group value={mode} onChange={(e) => setMode(e.target.value)} style={{ marginBottom: 16 }}>
|
||||||
|
<Space direction="vertical">
|
||||||
|
<Radio value="all">All online devices ({devices?.length ?? 0})</Radio>
|
||||||
|
<Radio value="group">By group</Radio>
|
||||||
|
<Radio value="select">Select devices</Radio>
|
||||||
|
</Space>
|
||||||
|
</Radio.Group>
|
||||||
|
|
||||||
|
{mode === 'group' && (
|
||||||
|
<Select
|
||||||
|
style={{ width: '100%', marginBottom: 16 }}
|
||||||
|
placeholder="Select group"
|
||||||
|
value={selectedGroup}
|
||||||
|
onChange={setSelectedGroup}
|
||||||
|
options={groups?.map((g) => ({ label: g.name, value: g.id }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === 'select' && (
|
||||||
|
<Checkbox.Group
|
||||||
|
value={selectedDevices}
|
||||||
|
onChange={(vals) => setSelectedDevices(vals as string[])}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: 8 }}
|
||||||
|
>
|
||||||
|
{devices?.map((d) => (
|
||||||
|
<Checkbox key={d.deviceId} value={d.deviceId}>
|
||||||
|
<Typography.Text code>{d.deviceId}</Typography.Text> {d.name ?? ''} <Typography.Text type="secondary">({d.firmwareVersion ?? 'unknown'})</Typography.Text>
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</Checkbox.Group>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
console/frontend/src/components/firmware/FirmwareTable.tsx
Normal file
58
console/frontend/src/components/firmware/FirmwareTable.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { Table, Tag, Button, Popconfirm, message } from 'antd';
|
||||||
|
import { DeleteOutlined, RocketOutlined } from '@ant-design/icons';
|
||||||
|
import type { FirmwareVersion } from '../../types/firmware';
|
||||||
|
import { formatDate, formatBytes } from '../../utils/formatters';
|
||||||
|
import { firmwareApi } from '../../api/firmware';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: FirmwareVersion[];
|
||||||
|
loading: boolean;
|
||||||
|
onDeploy: (version: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FirmwareTable({ data, loading, onDeploy }: Props) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const handleDelete = async (version: string) => {
|
||||||
|
try {
|
||||||
|
await firmwareApi.delete(version);
|
||||||
|
message.success('Firmware deleted');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['firmware'] });
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to delete firmware');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: 'Version', dataIndex: 'version', key: 'version', render: (v: string) => <Tag>{v}</Tag> },
|
||||||
|
{ title: 'Size', dataIndex: 'fileSize', key: 'fileSize', render: (s: number) => formatBytes(s) },
|
||||||
|
{ title: 'Date', dataIndex: 'createdAt', key: 'createdAt', render: (d: string) => formatDate(d) },
|
||||||
|
{ title: 'Hardware', dataIndex: 'hardware', key: 'hardware', render: (h: string | null) => h ?? 'Any' },
|
||||||
|
{
|
||||||
|
title: 'Status',
|
||||||
|
key: 'status',
|
||||||
|
render: (_: unknown, r: FirmwareVersion) => (
|
||||||
|
<>
|
||||||
|
{r.active && <Tag color="green">Active</Tag>}
|
||||||
|
{r.mandatory && <Tag color="red">Mandatory</Tag>}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: 'Notes', dataIndex: 'releaseNotes', key: 'releaseNotes', ellipsis: true, render: (n: string | null) => n ?? '--' },
|
||||||
|
{
|
||||||
|
title: 'Actions',
|
||||||
|
key: 'actions',
|
||||||
|
render: (_: unknown, r: FirmwareVersion) => (
|
||||||
|
<>
|
||||||
|
<Button type="link" icon={<RocketOutlined />} onClick={() => onDeploy(r.version)}>Deploy</Button>
|
||||||
|
<Popconfirm title="Delete this firmware?" onConfirm={() => handleDelete(r.version)}>
|
||||||
|
<Button type="link" danger icon={<DeleteOutlined />} />
|
||||||
|
</Popconfirm>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return <Table dataSource={data} columns={columns} rowKey="id" loading={loading} size="middle" pagination={false} />;
|
||||||
|
}
|
||||||
74
console/frontend/src/components/firmware/FirmwareUpload.tsx
Normal file
74
console/frontend/src/components/firmware/FirmwareUpload.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { Modal, Form, Input, Switch, Upload, Button, message } from 'antd';
|
||||||
|
import { UploadOutlined } from '@ant-design/icons';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import type { UploadFile } from 'antd/es/upload';
|
||||||
|
import { firmwareApi } from '../../api/firmware';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FirmwareUpload({ open, onClose }: Props) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const handleUpload = async () => {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
const file = fileList[0]?.originFileObj;
|
||||||
|
if (!file) { message.error('Select a firmware file'); return; }
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('version', values.version);
|
||||||
|
if (values.releaseNotes) formData.append('releaseNotes', values.releaseNotes);
|
||||||
|
if (values.hardware) formData.append('hardware', values.hardware);
|
||||||
|
formData.append('mandatory', String(values.mandatory ?? false));
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await firmwareApi.upload(formData);
|
||||||
|
message.success('Firmware uploaded');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['firmware'] });
|
||||||
|
form.resetFields();
|
||||||
|
setFileList([]);
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
message.error('Upload failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="Upload Firmware" open={open} onOk={handleUpload} onCancel={onClose} confirmLoading={loading} okText="Upload">
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="version" label="Version" rules={[{ required: true, message: 'Enter version (e.g. 1.2.0)' }]}>
|
||||||
|
<Input placeholder="1.2.0" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="Firmware File" required>
|
||||||
|
<Upload
|
||||||
|
fileList={fileList}
|
||||||
|
beforeUpload={() => false}
|
||||||
|
onChange={({ fileList: fl }) => setFileList(fl.slice(-1))}
|
||||||
|
accept=".bin"
|
||||||
|
>
|
||||||
|
<Button icon={<UploadOutlined />}>Select .bin file</Button>
|
||||||
|
</Upload>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="releaseNotes" label="Release Notes">
|
||||||
|
<Input.TextArea rows={3} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="hardware" label="Hardware (optional)">
|
||||||
|
<Input placeholder="TTGO T-Call v1.4" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="mandatory" label="Mandatory" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
console/frontend/src/components/layout/AppLayout.tsx
Normal file
78
console/frontend/src/components/layout/AppLayout.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Layout, Menu, Button, Dropdown, theme } from 'antd';
|
||||||
|
import {
|
||||||
|
DashboardOutlined,
|
||||||
|
ApiOutlined,
|
||||||
|
CloudUploadOutlined,
|
||||||
|
AlertOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
MenuFoldOutlined,
|
||||||
|
MenuUnfoldOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
LogoutOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../../hooks/useAuth';
|
||||||
|
|
||||||
|
const { Sider, Header, Content } = Layout;
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ key: '/', icon: <DashboardOutlined />, label: 'Dashboard' },
|
||||||
|
{ key: '/devices', icon: <ApiOutlined />, label: 'Devices' },
|
||||||
|
{ key: '/firmware', icon: <CloudUploadOutlined />, label: 'Firmware' },
|
||||||
|
{ key: '/alerts', icon: <AlertOutlined />, label: 'Alerts' },
|
||||||
|
{ key: '/groups', icon: <TeamOutlined />, label: 'Groups' },
|
||||||
|
{ key: '/settings', icon: <SettingOutlined />, label: 'Settings' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AppLayout() {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const { token: themeToken } = theme.useToken();
|
||||||
|
|
||||||
|
const selectedKey = menuItems.find((m) => location.pathname === m.key || (m.key !== '/' && location.pathname.startsWith(m.key)))?.key ?? '/';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout style={{ minHeight: '100vh' }}>
|
||||||
|
<Sider trigger={null} collapsible collapsed={collapsed} breakpoint="lg" onBreakpoint={(broken) => setCollapsed(broken)}>
|
||||||
|
<div style={{ height: 48, margin: 16, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontWeight: 700, fontSize: collapsed ? 14 : 16, whiteSpace: 'nowrap', overflow: 'hidden' }}>
|
||||||
|
{collapsed ? 'TAC' : 'Tau Acuvim Console'}
|
||||||
|
</div>
|
||||||
|
<Menu
|
||||||
|
theme="dark"
|
||||||
|
mode="inline"
|
||||||
|
selectedKeys={[selectedKey]}
|
||||||
|
items={menuItems}
|
||||||
|
onClick={({ key }) => navigate(key)}
|
||||||
|
/>
|
||||||
|
</Sider>
|
||||||
|
<Layout>
|
||||||
|
<Header style={{ padding: '0 24px', background: themeToken.colorBgContainer, display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderBottom: `1px solid ${themeToken.colorBorderSecondary}` }}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
/>
|
||||||
|
<Dropdown
|
||||||
|
menu={{
|
||||||
|
items: [
|
||||||
|
{ key: 'user', label: user?.username ?? 'User', icon: <UserOutlined />, disabled: true },
|
||||||
|
{ type: 'divider' },
|
||||||
|
{ key: 'logout', label: 'Logout', icon: <LogoutOutlined />, danger: true },
|
||||||
|
],
|
||||||
|
onClick: ({ key }) => { if (key === 'logout') { logout(); navigate('/login'); } },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button type="text" icon={<UserOutlined />}>{user?.username}</Button>
|
||||||
|
</Dropdown>
|
||||||
|
</Header>
|
||||||
|
<Content style={{ margin: 24 }}>
|
||||||
|
<Outlet />
|
||||||
|
</Content>
|
||||||
|
</Layout>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
console/frontend/src/components/telemetry/LiveReadings.tsx
Normal file
59
console/frontend/src/components/telemetry/LiveReadings.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { Card, Row, Col, Statistic, Badge, Divider } from 'antd';
|
||||||
|
import { ThunderboltOutlined } from '@ant-design/icons';
|
||||||
|
import type { TelemetryData } from '../../types/telemetry';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: TelemetryData | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LiveReadings({ data }: Props) {
|
||||||
|
if (!data) return <Card loading />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title={<><ThunderboltOutlined /> Live Readings</>} extra={<Badge status="processing" text="LIVE" />}>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Divider orientation="left" plain>Phase Voltages</Divider>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={8}><Statistic title="Va" value={data.v?.a} precision={1} suffix="V" /></Col>
|
||||||
|
<Col span={8}><Statistic title="Vb" value={data.v?.b} precision={1} suffix="V" /></Col>
|
||||||
|
<Col span={8}><Statistic title="Vc" value={data.v?.c} precision={1} suffix="V" /></Col>
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Divider orientation="left" plain>Phase Currents</Divider>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={8}><Statistic title="Ia" value={data.i?.a} precision={2} suffix="A" /></Col>
|
||||||
|
<Col span={8}><Statistic title="Ib" value={data.i?.b} precision={2} suffix="A" /></Col>
|
||||||
|
<Col span={8}><Statistic title="Ic" value={data.i?.c} precision={2} suffix="A" /></Col>
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Divider orientation="left" plain>Power</Divider>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={8}><Statistic title="Active" value={data.p?.act} precision={1} suffix="kW" /></Col>
|
||||||
|
<Col span={8}><Statistic title="Reactive" value={data.p?.react} precision={1} suffix="kVAR" /></Col>
|
||||||
|
<Col span={8}><Statistic title="Apparent" value={data.p?.app} precision={1} suffix="kVA" /></Col>
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Divider orientation="left" plain>Energy & Frequency</Divider>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={8}><Statistic title="Import" value={data.e?.imp} precision={1} suffix="kWh" /></Col>
|
||||||
|
<Col span={8}><Statistic title="Export" value={data.e?.exp} precision={1} suffix="kWh" /></Col>
|
||||||
|
<Col span={8}><Statistic title="Freq" value={data.f} precision={2} suffix="Hz" /></Col>
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24}>
|
||||||
|
<Divider orientation="left" plain>Power Factor</Divider>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={6}><Statistic title="PF Total" value={data.pf?.total} precision={3} /></Col>
|
||||||
|
<Col span={6}><Statistic title="PF A" value={data.pf?.a} precision={3} /></Col>
|
||||||
|
<Col span={6}><Statistic title="PF B" value={data.pf?.b} precision={3} /></Col>
|
||||||
|
<Col span={6}><Statistic title="PF C" value={data.pf?.c} precision={3} /></Col>
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
console/frontend/src/components/telemetry/TelemetryChart.tsx
Normal file
41
console/frontend/src/components/telemetry/TelemetryChart.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import ReactECharts from 'echarts-for-react';
|
||||||
|
import { Card } from 'antd';
|
||||||
|
import type { TelemetryRecord } from '../../types/telemetry';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
records: TelemetryRecord[];
|
||||||
|
series: { name: string; extract: (data: Record<string, unknown>) => number | undefined }[];
|
||||||
|
yAxisLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TelemetryChart({ title, records, series, yAxisLabel }: Props) {
|
||||||
|
const sorted = [...records].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
||||||
|
|
||||||
|
const option = {
|
||||||
|
tooltip: { trigger: 'axis' as const },
|
||||||
|
legend: { data: series.map((s) => s.name), bottom: 0 },
|
||||||
|
grid: { left: 60, right: 20, top: 20, bottom: 40 },
|
||||||
|
xAxis: {
|
||||||
|
type: 'time' as const,
|
||||||
|
data: sorted.map((r) => r.timestamp),
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value' as const,
|
||||||
|
name: yAxisLabel,
|
||||||
|
},
|
||||||
|
series: series.map((s) => ({
|
||||||
|
name: s.name,
|
||||||
|
type: 'line' as const,
|
||||||
|
smooth: true,
|
||||||
|
showSymbol: false,
|
||||||
|
data: sorted.map((r) => [r.timestamp, s.extract(r.data as Record<string, unknown>)]),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title={title} size="small" style={{ marginBottom: 16 }}>
|
||||||
|
<ReactECharts option={option} style={{ height: 250 }} />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
console/frontend/src/hooks/useAuth.ts
Normal file
38
console/frontend/src/hooks/useAuth.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { createContext, useContext, useCallback, useState } from 'react';
|
||||||
|
import { authApi, type AuthResponse } from '../api/auth';
|
||||||
|
import { setToken } from '../api/client';
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: AuthResponse['user'] | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthContext = createContext<AuthState>({
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
login: async () => {},
|
||||||
|
logout: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
return useContext(AuthContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuthProvider(): AuthState {
|
||||||
|
const [user, setUser] = useState<AuthResponse['user'] | null>(null);
|
||||||
|
|
||||||
|
const login = useCallback(async (username: string, password: string) => {
|
||||||
|
const res = await authApi.login({ username, password });
|
||||||
|
setToken(res.token);
|
||||||
|
setUser(res.user);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
setToken(null);
|
||||||
|
setUser(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { user, isAuthenticated: !!user, login, logout };
|
||||||
|
}
|
||||||
43
console/frontend/src/hooks/useDevices.ts
Normal file
43
console/frontend/src/hooks/useDevices.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { devicesApi } from '../api/devices';
|
||||||
|
|
||||||
|
export function useDeviceList(params?: { status?: string; search?: string; page?: number; pageSize?: number }) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['devices', params],
|
||||||
|
queryFn: () => devicesApi.list(params),
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDevice(deviceId: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['device', deviceId],
|
||||||
|
queryFn: () => devicesApi.get(deviceId!),
|
||||||
|
enabled: !!deviceId,
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDashboard() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['dashboard'],
|
||||||
|
queryFn: () => devicesApi.dashboard(),
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeviceCommands(deviceId: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['commands', deviceId],
|
||||||
|
queryFn: () => devicesApi.getCommands(deviceId!),
|
||||||
|
enabled: !!deviceId,
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGroups() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['groups'],
|
||||||
|
queryFn: () => devicesApi.groups.list(),
|
||||||
|
});
|
||||||
|
}
|
||||||
64
console/frontend/src/hooks/useSignalR.ts
Normal file
64
console/frontend/src/hooks/useSignalR.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { HubConnectionBuilder, HubConnection, LogLevel } from '@microsoft/signalr';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { notification } from 'antd';
|
||||||
|
import { getToken } from '../api/client';
|
||||||
|
import { HUB_URL } from '../utils/constants';
|
||||||
|
|
||||||
|
export function useSignalR(enabled: boolean) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const connectionRef = useRef<HubConnection | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
const connection = new HubConnectionBuilder()
|
||||||
|
.withUrl(HUB_URL, { accessTokenFactory: () => getToken() ?? '' })
|
||||||
|
.withAutomaticReconnect()
|
||||||
|
.configureLogging(LogLevel.Warning)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
connectionRef.current = connection;
|
||||||
|
|
||||||
|
connection.on('DeviceStatusChanged', (_deviceId: string) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['devices'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.on('TelemetryReceived', (deviceId: string, data: string) => {
|
||||||
|
try {
|
||||||
|
queryClient.setQueryData(['telemetry', deviceId, 'latest'], JSON.parse(data));
|
||||||
|
} catch { /* ignore parse errors */ }
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.on('HeartbeatReceived', (deviceId: string) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['device', deviceId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['devices'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.on('AlertCreated', (_deviceId: string, alertJson: string) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['alerts'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||||
|
try {
|
||||||
|
const alert = JSON.parse(alertJson);
|
||||||
|
notification.warning({ message: 'New Alert', description: alert.message ?? alert.type });
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.on('CommandCompleted', (deviceId: string) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['commands', deviceId] });
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.on('DeviceRegistered', () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['devices'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.start().catch((err) => console.error('SignalR connection error:', err));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
connection.stop();
|
||||||
|
};
|
||||||
|
}, [enabled, queryClient]);
|
||||||
|
}
|
||||||
25
console/frontend/src/hooks/useTelemetry.ts
Normal file
25
console/frontend/src/hooks/useTelemetry.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { telemetryApi } from '../api/telemetry';
|
||||||
|
|
||||||
|
export function useLatestTelemetry(deviceId: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['telemetry', deviceId, 'latest'],
|
||||||
|
queryFn: () => telemetryApi.latest(deviceId!),
|
||||||
|
enabled: !!deviceId,
|
||||||
|
refetchInterval: 5_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTelemetryHistory(
|
||||||
|
deviceId: string | undefined,
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
page = 1,
|
||||||
|
pageSize = 500,
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['telemetry', deviceId, 'history', from, to, page],
|
||||||
|
queryFn: () => telemetryApi.history(deviceId!, { from, to, page, pageSize }),
|
||||||
|
enabled: !!deviceId && !!from && !!to,
|
||||||
|
});
|
||||||
|
}
|
||||||
9
console/frontend/src/main.tsx
Normal file
9
console/frontend/src/main.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
35
console/frontend/src/pages/AlertsPage.tsx
Normal file
35
console/frontend/src/pages/AlertsPage.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { Typography } from 'antd';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { alertsApi } from '../api/alerts';
|
||||||
|
import AlertTable from '../components/alerts/AlertTable';
|
||||||
|
|
||||||
|
export default function AlertsPage() {
|
||||||
|
const [severity, setSeverity] = useState<string | undefined>();
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['alerts', severity],
|
||||||
|
queryFn: () => alertsApi.list({ severity, pageSize: 100 }),
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filtered = search
|
||||||
|
? data?.filter((a) => a.deviceId.includes(search) || a.alertType.includes(search) || a.message.includes(search))
|
||||||
|
: data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Typography.Title level={4}>Alerts</Typography.Title>
|
||||||
|
<AlertTable
|
||||||
|
alerts={filtered ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
filters
|
||||||
|
severity={severity}
|
||||||
|
search={search}
|
||||||
|
onSeverityChange={setSeverity}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
console/frontend/src/pages/DashboardPage.tsx
Normal file
25
console/frontend/src/pages/DashboardPage.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { Typography, Row, Col } from 'antd';
|
||||||
|
import FleetSummary from '../components/dashboard/FleetSummary';
|
||||||
|
import RecentAlerts from '../components/dashboard/RecentAlerts';
|
||||||
|
import DeviceMap from '../components/dashboard/DeviceMap';
|
||||||
|
import { useDashboard, useDeviceList } from '../hooks/useDevices';
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const { data: summary, isLoading: summaryLoading } = useDashboard();
|
||||||
|
const { data: devices, isLoading: devicesLoading } = useDeviceList();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Typography.Title level={4}>Fleet Overview</Typography.Title>
|
||||||
|
<FleetSummary data={summary} loading={summaryLoading} />
|
||||||
|
<Row gutter={[16, 16]} style={{ marginTop: 24 }}>
|
||||||
|
<Col xs={24} lg={10}>
|
||||||
|
<RecentAlerts />
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={14}>
|
||||||
|
<DeviceMap devices={devices ?? []} loading={devicesLoading} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
console/frontend/src/pages/DeviceDetailPage.tsx
Normal file
151
console/frontend/src/pages/DeviceDetailPage.tsx
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import { Typography, Tabs, Spin, Row, Col, Table, Tag, message, Button } from 'antd';
|
||||||
|
import { ArrowLeftOutlined } from '@ant-design/icons';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { useDevice, useDeviceCommands } from '../hooks/useDevices';
|
||||||
|
import { useLatestTelemetry, useTelemetryHistory } from '../hooks/useTelemetry';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { alertsApi } from '../api/alerts';
|
||||||
|
import { devicesApi } from '../api/devices';
|
||||||
|
import DeviceCard from '../components/devices/DeviceCard';
|
||||||
|
import LiveReadings from '../components/telemetry/LiveReadings';
|
||||||
|
import TelemetryChart from '../components/telemetry/TelemetryChart';
|
||||||
|
import DeviceConfigForm from '../components/devices/DeviceConfigForm';
|
||||||
|
import AlertTable from '../components/alerts/AlertTable';
|
||||||
|
import CommandModal from '../components/devices/CommandModal';
|
||||||
|
import WifiScanModal from '../components/devices/WifiScanModal';
|
||||||
|
import { timeAgo } from '../utils/formatters';
|
||||||
|
|
||||||
|
export default function DeviceDetailPage() {
|
||||||
|
const { deviceId } = useParams<{ deviceId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [showCommand, setShowCommand] = useState(false);
|
||||||
|
const [showWifiScan, setShowWifiScan] = useState(false);
|
||||||
|
const [timeRange] = useState('24h');
|
||||||
|
|
||||||
|
const { data: device, isLoading } = useDevice(deviceId);
|
||||||
|
const { data: telemetry } = useLatestTelemetry(deviceId);
|
||||||
|
|
||||||
|
const { from, to } = useMemo(() => {
|
||||||
|
const now = dayjs();
|
||||||
|
const hours = timeRange === '7d' ? 168 : timeRange === '1h' ? 1 : 24;
|
||||||
|
return { from: now.subtract(hours, 'hour').toISOString(), to: now.toISOString() };
|
||||||
|
}, [timeRange]);
|
||||||
|
|
||||||
|
const { data: history } = useTelemetryHistory(deviceId, from, to);
|
||||||
|
const { data: alerts, isLoading: alertsLoading } = useQuery({
|
||||||
|
queryKey: ['alerts', 'device', deviceId],
|
||||||
|
queryFn: () => alertsApi.byDevice(deviceId!),
|
||||||
|
enabled: !!deviceId,
|
||||||
|
});
|
||||||
|
const { data: commands, isLoading: commandsLoading } = useDeviceCommands(deviceId);
|
||||||
|
|
||||||
|
if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
|
||||||
|
if (!device) return <Typography.Text type="danger">Device not found</Typography.Text>;
|
||||||
|
|
||||||
|
const handleRestart = async () => {
|
||||||
|
try {
|
||||||
|
await devicesApi.sendCommand(device.deviceId, 'restart');
|
||||||
|
message.success('Restart command sent');
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to send restart');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const voltageSeries = [
|
||||||
|
{ name: 'Va', extract: (d: Record<string, unknown>) => (d['v'] as Record<string, number> | undefined)?.a },
|
||||||
|
{ name: 'Vb', extract: (d: Record<string, unknown>) => (d['v'] as Record<string, number> | undefined)?.b },
|
||||||
|
{ name: 'Vc', extract: (d: Record<string, unknown>) => (d['v'] as Record<string, number> | undefined)?.c },
|
||||||
|
];
|
||||||
|
|
||||||
|
const powerSeries = [
|
||||||
|
{ name: 'Active (kW)', extract: (d: Record<string, unknown>) => (d['p'] as Record<string, number> | undefined)?.act },
|
||||||
|
{ name: 'Reactive (kVAR)', extract: (d: Record<string, unknown>) => (d['p'] as Record<string, number> | undefined)?.react },
|
||||||
|
{ name: 'Apparent (kVA)', extract: (d: Record<string, unknown>) => (d['p'] as Record<string, number> | undefined)?.app },
|
||||||
|
];
|
||||||
|
|
||||||
|
const pfFreqSeries = [
|
||||||
|
{ name: 'PF', extract: (d: Record<string, unknown>) => (d['pf'] as Record<string, number> | undefined)?.total },
|
||||||
|
{ name: 'Freq (Hz)', extract: (d: Record<string, unknown>) => d['f'] as number | undefined },
|
||||||
|
];
|
||||||
|
|
||||||
|
const commandColumns = [
|
||||||
|
{ title: 'Command', dataIndex: 'commandName', key: 'commandName' },
|
||||||
|
{ title: 'Status', dataIndex: 'status', key: 'status', render: (s: string) => <Tag color={s === 'completed' ? 'green' : s === 'sent' ? 'blue' : 'default'}>{s}</Tag> },
|
||||||
|
{ title: 'Sent', dataIndex: 'sentAt', key: 'sentAt', render: (d: string | null) => timeAgo(d) },
|
||||||
|
{ title: 'Completed', dataIndex: 'completedAt', key: 'completedAt', render: (d: string | null) => timeAgo(d) },
|
||||||
|
{
|
||||||
|
title: 'Response',
|
||||||
|
dataIndex: 'response',
|
||||||
|
key: 'response',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (r: Record<string, unknown> | null) => r ? JSON.stringify(r) : '--',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button type="link" icon={<ArrowLeftOutlined />} onClick={() => navigate('/devices')} style={{ padding: 0, marginBottom: 8 }}>
|
||||||
|
Devices
|
||||||
|
</Button>
|
||||||
|
<Typography.Title level={4}>{device.name ?? device.deviceId}</Typography.Title>
|
||||||
|
|
||||||
|
<Tabs defaultActiveKey="overview" items={[
|
||||||
|
{
|
||||||
|
key: 'overview',
|
||||||
|
label: 'Overview',
|
||||||
|
children: (
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col xs={24} lg={24}>
|
||||||
|
<DeviceCard device={device} onRestart={handleRestart} onWifiScan={() => setShowWifiScan(true)} onCommand={() => setShowCommand(true)} />
|
||||||
|
</Col>
|
||||||
|
<Col xs={24}>
|
||||||
|
<LiveReadings data={telemetry?.data} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'telemetry',
|
||||||
|
label: 'Telemetry',
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<TelemetryChart title="Phase Voltages (V)" records={history ?? []} series={voltageSeries} yAxisLabel="V" />
|
||||||
|
<TelemetryChart title="Active Power (kW)" records={history ?? []} series={powerSeries} yAxisLabel="kW" />
|
||||||
|
<TelemetryChart title="Power Factor & Frequency" records={history ?? []} series={pfFreqSeries} />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'config',
|
||||||
|
label: 'Config',
|
||||||
|
children: <DeviceConfigForm deviceId={device.deviceId} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'alerts',
|
||||||
|
label: 'Alerts',
|
||||||
|
children: <AlertTable alerts={alerts ?? []} loading={alertsLoading} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'commands',
|
||||||
|
label: 'Commands',
|
||||||
|
children: <Table dataSource={commands ?? []} columns={commandColumns} rowKey="id" loading={commandsLoading} size="middle" pagination={{ pageSize: 20 }} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ota',
|
||||||
|
label: 'OTA',
|
||||||
|
children: (
|
||||||
|
<Typography.Paragraph>
|
||||||
|
Current firmware: <Typography.Text strong>{device.firmwareVersion ?? 'unknown'}</Typography.Text>.
|
||||||
|
Use the Firmware page to deploy updates to this device.
|
||||||
|
</Typography.Paragraph>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]} />
|
||||||
|
|
||||||
|
<CommandModal deviceId={device.deviceId} open={showCommand} onClose={() => setShowCommand(false)} />
|
||||||
|
<WifiScanModal deviceId={device.deviceId} open={showWifiScan} onClose={() => setShowWifiScan(false)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
console/frontend/src/pages/DeviceListPage.tsx
Normal file
25
console/frontend/src/pages/DeviceListPage.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { Typography } from 'antd';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import DeviceTable from '../components/devices/DeviceTable';
|
||||||
|
import { useDeviceList } from '../hooks/useDevices';
|
||||||
|
|
||||||
|
export default function DeviceListPage() {
|
||||||
|
const [status, setStatus] = useState<string | undefined>();
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
const { data, isLoading } = useDeviceList({ status, search: search || undefined });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Typography.Title level={4}>Devices</Typography.Title>
|
||||||
|
<DeviceTable
|
||||||
|
devices={data ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
status={status}
|
||||||
|
search={search}
|
||||||
|
onStatusChange={setStatus}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
console/frontend/src/pages/FirmwarePage.tsx
Normal file
30
console/frontend/src/pages/FirmwarePage.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { Typography, Button, Space } from 'antd';
|
||||||
|
import { CloudUploadOutlined } from '@ant-design/icons';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { firmwareApi } from '../api/firmware';
|
||||||
|
import FirmwareTable from '../components/firmware/FirmwareTable';
|
||||||
|
import FirmwareUpload from '../components/firmware/FirmwareUpload';
|
||||||
|
import DeployModal from '../components/firmware/DeployModal';
|
||||||
|
|
||||||
|
export default function FirmwarePage() {
|
||||||
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
|
const [deployVersion, setDeployVersion] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['firmware'],
|
||||||
|
queryFn: () => firmwareApi.list(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Space style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>Firmware Management</Typography.Title>
|
||||||
|
<Button type="primary" icon={<CloudUploadOutlined />} onClick={() => setShowUpload(true)}>Upload New</Button>
|
||||||
|
</Space>
|
||||||
|
<FirmwareTable data={data ?? []} loading={isLoading} onDeploy={setDeployVersion} />
|
||||||
|
<FirmwareUpload open={showUpload} onClose={() => setShowUpload(false)} />
|
||||||
|
{deployVersion && <DeployModal version={deployVersion} open={!!deployVersion} onClose={() => setDeployVersion(null)} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
console/frontend/src/pages/GroupsPage.tsx
Normal file
84
console/frontend/src/pages/GroupsPage.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { Typography, Table, Button, Modal, Form, Input, message, Popconfirm, Space } from 'antd';
|
||||||
|
import { PlusOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useGroups } from '../hooks/useDevices';
|
||||||
|
import { devicesApi } from '../api/devices';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type { DeviceGroup } from '../types/device';
|
||||||
|
import { formatDate } from '../utils/formatters';
|
||||||
|
|
||||||
|
export default function GroupsPage() {
|
||||||
|
const { data, isLoading } = useGroups();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<DeviceGroup | null>(null);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
await devicesApi.groups.update(editing.id, values);
|
||||||
|
message.success('Group updated');
|
||||||
|
} else {
|
||||||
|
await devicesApi.groups.create(values);
|
||||||
|
message.success('Group created');
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['groups'] });
|
||||||
|
setShowModal(false);
|
||||||
|
setEditing(null);
|
||||||
|
form.resetFields();
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to save group');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await devicesApi.groups.delete(id);
|
||||||
|
message.success('Group deleted');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['groups'] });
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to delete group');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: 'Name', dataIndex: 'name', key: 'name' },
|
||||||
|
{ title: 'Description', dataIndex: 'description', key: 'description', render: (d: string | null) => d ?? '--' },
|
||||||
|
{ title: 'Created', dataIndex: 'createdAt', key: 'createdAt', render: (d: string) => formatDate(d) },
|
||||||
|
{
|
||||||
|
title: 'Actions',
|
||||||
|
key: 'actions',
|
||||||
|
render: (_: unknown, r: DeviceGroup) => (
|
||||||
|
<Space>
|
||||||
|
<Button type="link" icon={<EditOutlined />} onClick={() => { setEditing(r); form.setFieldsValue(r); setShowModal(true); }} />
|
||||||
|
<Popconfirm title="Delete this group?" onConfirm={() => handleDelete(r.id)}>
|
||||||
|
<Button type="link" danger icon={<DeleteOutlined />} />
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Space style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>Groups</Typography.Title>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => { setEditing(null); form.resetFields(); setShowModal(true); }}>Add Group</Button>
|
||||||
|
</Space>
|
||||||
|
<Table dataSource={data ?? []} columns={columns} rowKey="id" loading={isLoading} pagination={false} />
|
||||||
|
<Modal
|
||||||
|
title={editing ? 'Edit Group' : 'Create Group'}
|
||||||
|
open={showModal}
|
||||||
|
onOk={handleSave}
|
||||||
|
onCancel={() => { setShowModal(false); setEditing(null); }}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="name" label="Name" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item name="description" label="Description"><Input.TextArea rows={3} /></Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
console/frontend/src/pages/LoginPage.tsx
Normal file
45
console/frontend/src/pages/LoginPage.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { Form, Input, Button, Card, Typography, message, theme } from 'antd';
|
||||||
|
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { login } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
const handleLogin = async (values: { username: string; password: string }) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await login(values.username, values.password);
|
||||||
|
navigate('/');
|
||||||
|
} catch {
|
||||||
|
message.error('Invalid credentials');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: token.colorBgLayout }}>
|
||||||
|
<Card style={{ width: 400 }}>
|
||||||
|
<Typography.Title level={3} style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||||
|
Tau Acuvim Console
|
||||||
|
</Typography.Title>
|
||||||
|
<Form onFinish={handleLogin} layout="vertical" autoComplete="off">
|
||||||
|
<Form.Item name="username" rules={[{ required: true, message: 'Enter username' }]}>
|
||||||
|
<Input prefix={<UserOutlined />} placeholder="Username" size="large" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="password" rules={[{ required: true, message: 'Enter password' }]}>
|
||||||
|
<Input.Password prefix={<LockOutlined />} placeholder="Password" size="large" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading} block size="large">Login</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
console/frontend/src/pages/SettingsPage.tsx
Normal file
19
console/frontend/src/pages/SettingsPage.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { Typography, Card, Descriptions } from 'antd';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Typography.Title level={4}>Settings</Typography.Title>
|
||||||
|
<Card title="Current User" size="small" style={{ maxWidth: 500 }}>
|
||||||
|
<Descriptions column={1} size="small">
|
||||||
|
<Descriptions.Item label="Username">{user?.username ?? '--'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Email">{user?.email ?? '--'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Role">{user?.role ?? '--'}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
console/frontend/src/types/alert.ts
Normal file
15
console/frontend/src/types/alert.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
export interface Alert {
|
||||||
|
id: number;
|
||||||
|
deviceId: string;
|
||||||
|
alertType: string;
|
||||||
|
severity: string;
|
||||||
|
message: string;
|
||||||
|
value: number | null;
|
||||||
|
threshold: number | null;
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
acknowledged: boolean;
|
||||||
|
acknowledgedBy: string | null;
|
||||||
|
acknowledgedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
resolvedAt: string | null;
|
||||||
|
}
|
||||||
76
console/frontend/src/types/device.ts
Normal file
76
console/frontend/src/types/device.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
export interface DeviceListItem {
|
||||||
|
id: string;
|
||||||
|
deviceId: string;
|
||||||
|
name: string | null;
|
||||||
|
status: string;
|
||||||
|
firmwareVersion: string | null;
|
||||||
|
connectionType: string | null;
|
||||||
|
lastHeartbeat: string | null;
|
||||||
|
lastTelemetry: string | null;
|
||||||
|
groupName: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceDetail {
|
||||||
|
id: string;
|
||||||
|
deviceId: string;
|
||||||
|
name: string | null;
|
||||||
|
description: string | null;
|
||||||
|
macAddress: string | null;
|
||||||
|
imei: string | null;
|
||||||
|
hardware: string | null;
|
||||||
|
firmwareVersion: string | null;
|
||||||
|
capabilities: Record<string, unknown> | null;
|
||||||
|
status: string;
|
||||||
|
lastHeartbeat: string | null;
|
||||||
|
lastTelemetry: string | null;
|
||||||
|
connectionType: string | null;
|
||||||
|
signalStrength: number | null;
|
||||||
|
ipAddress: string | null;
|
||||||
|
bootCount: number;
|
||||||
|
tags: Record<string, unknown> | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateDeviceRequest {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
groupId?: string;
|
||||||
|
tags?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendCommandRequest {
|
||||||
|
command: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandResponse {
|
||||||
|
id: string;
|
||||||
|
deviceId: string;
|
||||||
|
requestId: string;
|
||||||
|
commandName: string;
|
||||||
|
params: Record<string, unknown> | null;
|
||||||
|
status: string;
|
||||||
|
response: Record<string, unknown> | null;
|
||||||
|
createdBy: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
sentAt: string | null;
|
||||||
|
completedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardSummary {
|
||||||
|
totalDevices: number;
|
||||||
|
onlineDevices: number;
|
||||||
|
degradedDevices: number;
|
||||||
|
offlineDevices: number;
|
||||||
|
activeAlerts: number;
|
||||||
|
telemetryToday: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceGroup {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
deviceCount?: number;
|
||||||
|
}
|
||||||
23
console/frontend/src/types/firmware.ts
Normal file
23
console/frontend/src/types/firmware.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
export interface FirmwareVersion {
|
||||||
|
id: string;
|
||||||
|
version: string;
|
||||||
|
filename: string;
|
||||||
|
fileSize: number;
|
||||||
|
checksum: string;
|
||||||
|
releaseNotes: string | null;
|
||||||
|
hardware: string | null;
|
||||||
|
mandatory: boolean;
|
||||||
|
active: boolean;
|
||||||
|
uploadedBy: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirmwareCheckResponse {
|
||||||
|
updateAvailable: boolean;
|
||||||
|
version?: string;
|
||||||
|
url?: string;
|
||||||
|
size?: number;
|
||||||
|
checksum?: string;
|
||||||
|
releaseNotes?: string;
|
||||||
|
mandatory?: boolean;
|
||||||
|
}
|
||||||
22
console/frontend/src/types/telemetry.ts
Normal file
22
console/frontend/src/types/telemetry.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
export interface TelemetryRecord {
|
||||||
|
id: number;
|
||||||
|
deviceId: string;
|
||||||
|
timestamp: string;
|
||||||
|
data: TelemetryData;
|
||||||
|
source: string | null;
|
||||||
|
connection: string | null;
|
||||||
|
receivedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TelemetryData {
|
||||||
|
ts?: number;
|
||||||
|
dev?: string;
|
||||||
|
conn?: string;
|
||||||
|
v?: { a: number; b: number; c: number };
|
||||||
|
i?: { a: number; b: number; c: number };
|
||||||
|
p?: { act: number; react: number; app: number };
|
||||||
|
e?: { imp: number; exp: number };
|
||||||
|
pf?: { a: number; b: number; c: number; total: number };
|
||||||
|
f?: number;
|
||||||
|
thd?: { v: { a: number; b: number; c: number }; i: { a: number; b: number; c: number } };
|
||||||
|
}
|
||||||
16
console/frontend/src/utils/constants.ts
Normal file
16
console/frontend/src/utils/constants.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
export const API_BASE = '/api';
|
||||||
|
export const HUB_URL = '/hubs/devices';
|
||||||
|
|
||||||
|
export const STATUS_COLORS: Record<string, string> = {
|
||||||
|
online: '#52c41a',
|
||||||
|
degraded: '#faad14',
|
||||||
|
offline: '#ff4d4f',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SEVERITY_COLORS: Record<string, string> = {
|
||||||
|
critical: '#ff4d4f',
|
||||||
|
warning: '#faad14',
|
||||||
|
info: '#1677ff',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PAGE_SIZE = 20;
|
||||||
45
console/frontend/src/utils/formatters.ts
Normal file
45
console/frontend/src/utils/formatters.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
|
export function timeAgo(date: string | null | undefined): string {
|
||||||
|
if (!date) return '--';
|
||||||
|
return dayjs(date).fromNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(date: string | null | undefined): string {
|
||||||
|
if (!date) return '--';
|
||||||
|
return dayjs(date).format('MMM D, YYYY HH:mm');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatUptime(seconds: number): string {
|
||||||
|
const d = Math.floor(seconds / 86400);
|
||||||
|
const h = Math.floor((seconds % 86400) / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
if (d > 0) return `${d}d ${h}h ${m}m`;
|
||||||
|
if (h > 0) return `${h}h ${m}m`;
|
||||||
|
return `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / 1048576).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPower(kw: number | null | undefined): string {
|
||||||
|
if (kw == null) return '--';
|
||||||
|
if (Math.abs(kw) >= 1000) return `${(kw / 1000).toFixed(1)} MW`;
|
||||||
|
return `${kw.toFixed(1)} kW`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatVoltage(v: number | null | undefined): string {
|
||||||
|
if (v == null) return '--';
|
||||||
|
return `${v.toFixed(1)} V`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCurrent(a: number | null | undefined): string {
|
||||||
|
if (a == null) return '--';
|
||||||
|
return `${a.toFixed(2)} A`;
|
||||||
|
}
|
||||||
1
console/frontend/src/vite-env.d.ts
vendored
Normal file
1
console/frontend/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
25
console/frontend/tsconfig.json
Normal file
25
console/frontend/tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
23
console/frontend/vite.config.ts
Normal file
23
console/frontend/vite.config.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:5000',
|
||||||
|
'/hubs': {
|
||||||
|
target: 'http://localhost:5000',
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
'/swagger': 'http://localhost:5000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
13
console/mosquitto-acl.conf
Normal file
13
console/mosquitto-acl.conf
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# Console has full access
|
||||||
|
user console
|
||||||
|
topic readwrite acuvim/#
|
||||||
|
topic readwrite devices/#
|
||||||
|
|
||||||
|
# Per-device access: devices can only access their own topics
|
||||||
|
# Add entries like:
|
||||||
|
# user ACV-AABBCCDDEEFF
|
||||||
|
# topic readwrite acuvim/ACV-AABBCCDDEEFF/#
|
||||||
|
# topic write devices/register
|
||||||
|
|
||||||
|
pattern readwrite acuvim/%u/#
|
||||||
|
pattern write devices/register
|
||||||
27
console/mosquitto-prod.conf
Normal file
27
console/mosquitto-prod.conf
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
per_listener_settings true
|
||||||
|
|
||||||
|
# TLS listener
|
||||||
|
listener 8883
|
||||||
|
cafile /mosquitto/certs/ca.crt
|
||||||
|
certfile /mosquitto/certs/server.crt
|
||||||
|
keyfile /mosquitto/certs/server.key
|
||||||
|
require_certificate false
|
||||||
|
tls_version tlsv1.2
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
allow_anonymous false
|
||||||
|
password_file /mosquitto/passwords/passwd
|
||||||
|
|
||||||
|
# ACL
|
||||||
|
acl_file /mosquitto/config/acl
|
||||||
|
|
||||||
|
# Persistence
|
||||||
|
persistence true
|
||||||
|
persistence_location /mosquitto/data/
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log_dest stdout
|
||||||
|
log_type error
|
||||||
|
log_type warning
|
||||||
|
log_type notice
|
||||||
|
connection_messages true
|
||||||
5
console/mosquitto.conf
Normal file
5
console/mosquitto.conf
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
listener 1883
|
||||||
|
allow_anonymous true
|
||||||
|
|
||||||
|
listener 9001
|
||||||
|
protocol websockets
|
||||||
38
console/nginx.conf
Normal file
38
console/nginx.conf
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/console/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/console/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
ssl_prefer_server_ciphers on;
|
||||||
|
|
||||||
|
client_max_body_size 50M;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://console:5000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /hubs {
|
||||||
|
proxy_pass http://console:5000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 86400;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
console/src/Tau.Acuvim.Console/DTOs/AlertDtos.cs
Normal file
6
console/src/Tau.Acuvim.Console/DTOs/AlertDtos.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace Tau.Acuvim.Console.DTOs;
|
||||||
|
|
||||||
|
public record AlertResponse(
|
||||||
|
long Id, string DeviceId, string AlertType, string Severity,
|
||||||
|
string Message, double? Value, double? Threshold,
|
||||||
|
bool Acknowledged, DateTime CreatedAt, DateTime? ResolvedAt);
|
||||||
6
console/src/Tau.Acuvim.Console/DTOs/AuthDtos.cs
Normal file
6
console/src/Tau.Acuvim.Console/DTOs/AuthDtos.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace Tau.Acuvim.Console.DTOs;
|
||||||
|
|
||||||
|
public record LoginRequest(string Username, string Password);
|
||||||
|
public record RegisterRequest(string Username, string Email, string Password, string Role = "Viewer");
|
||||||
|
public record AuthResponse(string Token, string Username, string Role, DateTime Expiry);
|
||||||
|
public record UserInfo(Guid Id, string Username, string Email, string Role);
|
||||||
29
console/src/Tau.Acuvim.Console/DTOs/DeviceDtos.cs
Normal file
29
console/src/Tau.Acuvim.Console/DTOs/DeviceDtos.cs
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Tau.Acuvim.Console.DTOs;
|
||||||
|
|
||||||
|
public record DeviceListResponse(
|
||||||
|
Guid Id, string DeviceId, string? Name, string Status,
|
||||||
|
string? FirmwareVersion, string? ConnectionType,
|
||||||
|
DateTime? LastHeartbeat, DateTime? LastTelemetry,
|
||||||
|
string? GroupName, DateTime CreatedAt);
|
||||||
|
|
||||||
|
public record DeviceDetailResponse(
|
||||||
|
Guid Id, string DeviceId, string? Name, string? Description,
|
||||||
|
string? MacAddress, string? Imei, string? Hardware,
|
||||||
|
string? FirmwareVersion, JsonDocument? Capabilities,
|
||||||
|
string Status, DateTime? LastHeartbeat, DateTime? LastTelemetry,
|
||||||
|
string? ConnectionType, int? SignalStrength, string? IpAddress,
|
||||||
|
int BootCount, JsonDocument? Tags, DateTime CreatedAt);
|
||||||
|
|
||||||
|
public record UpdateDeviceRequest(string? Name, string? Description, Guid? GroupId, JsonDocument? Tags);
|
||||||
|
|
||||||
|
public record SendCommandRequest(string Command, JsonDocument? Params);
|
||||||
|
|
||||||
|
public record CommandResponse(
|
||||||
|
Guid Id, string RequestId, string CommandName, string Status,
|
||||||
|
JsonDocument? Response, DateTime CreatedAt, DateTime? CompletedAt);
|
||||||
|
|
||||||
|
public record DashboardSummary(
|
||||||
|
int TotalDevices, int OnlineDevices, int OfflineDevices,
|
||||||
|
int DegradedDevices, int ActiveAlerts, int TotalTelemetryToday);
|
||||||
12
console/src/Tau.Acuvim.Console/DTOs/FirmwareDtos.cs
Normal file
12
console/src/Tau.Acuvim.Console/DTOs/FirmwareDtos.cs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
namespace Tau.Acuvim.Console.DTOs;
|
||||||
|
|
||||||
|
public record FirmwareListResponse(
|
||||||
|
Guid Id, string Version, string Filename, int FileSize,
|
||||||
|
string? ReleaseNotes, bool Mandatory, bool Active, DateTime CreatedAt);
|
||||||
|
|
||||||
|
public record FirmwareCheckResponse(
|
||||||
|
bool UpdateAvailable, string? Version = null, string? Url = null,
|
||||||
|
int? Size = null, string? Checksum = null,
|
||||||
|
string? ReleaseNotes = null, bool Mandatory = false);
|
||||||
|
|
||||||
|
public record FirmwareDeployRequest(string Version, List<string> DeviceIds);
|
||||||
86
console/src/Tau.Acuvim.Console/Data/AppDbContext.cs
Normal file
86
console/src/Tau.Acuvim.Console/Data/AppDbContext.cs
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Tau.Acuvim.Console.Models;
|
||||||
|
|
||||||
|
namespace Tau.Acuvim.Console.Data;
|
||||||
|
|
||||||
|
public class AppDbContext : DbContext
|
||||||
|
{
|
||||||
|
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||||||
|
|
||||||
|
public DbSet<Device> Devices => Set<Device>();
|
||||||
|
public DbSet<TelemetryRecord> TelemetryRecords => Set<TelemetryRecord>();
|
||||||
|
public DbSet<Alert> Alerts => Set<Alert>();
|
||||||
|
public DbSet<Command> Commands => Set<Command>();
|
||||||
|
public DbSet<FirmwareVersion> FirmwareVersions => Set<FirmwareVersion>();
|
||||||
|
public DbSet<DeviceGroup> DeviceGroups => Set<DeviceGroup>();
|
||||||
|
public DbSet<User> Users => Set<User>();
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<Device>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("devices");
|
||||||
|
e.HasKey(d => d.Id);
|
||||||
|
e.HasIndex(d => d.DeviceId).IsUnique();
|
||||||
|
e.HasIndex(d => d.Status);
|
||||||
|
e.Property(d => d.Capabilities).HasColumnType("jsonb");
|
||||||
|
e.Property(d => d.Tags).HasColumnType("jsonb");
|
||||||
|
e.HasOne(d => d.Group).WithMany(g => g.Devices).HasForeignKey(d => d.GroupId);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<TelemetryRecord>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("telemetry_records");
|
||||||
|
e.HasKey(t => t.Id);
|
||||||
|
e.HasIndex(t => new { t.DeviceId, t.Timestamp }).IsDescending(false, true);
|
||||||
|
e.HasIndex(t => t.Timestamp).IsDescending();
|
||||||
|
e.Property(t => t.Data).HasColumnType("jsonb");
|
||||||
|
e.HasOne(t => t.Device).WithMany(d => d.TelemetryRecords)
|
||||||
|
.HasForeignKey(t => t.DeviceId).HasPrincipalKey(d => d.DeviceId);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Alert>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("alerts");
|
||||||
|
e.HasKey(a => a.Id);
|
||||||
|
e.HasIndex(a => new { a.DeviceId, a.CreatedAt }).IsDescending(false, true);
|
||||||
|
e.Property(a => a.Metadata).HasColumnType("jsonb");
|
||||||
|
e.HasOne(a => a.Device).WithMany(d => d.Alerts)
|
||||||
|
.HasForeignKey(a => a.DeviceId).HasPrincipalKey(d => d.DeviceId);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Command>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("commands");
|
||||||
|
e.HasKey(c => c.Id);
|
||||||
|
e.HasIndex(c => c.RequestId).IsUnique();
|
||||||
|
e.HasIndex(c => new { c.DeviceId, c.CreatedAt }).IsDescending(false, true);
|
||||||
|
e.Property(c => c.Params).HasColumnType("jsonb");
|
||||||
|
e.Property(c => c.Response).HasColumnType("jsonb");
|
||||||
|
e.HasOne(c => c.Device).WithMany(d => d.Commands)
|
||||||
|
.HasForeignKey(c => c.DeviceId).HasPrincipalKey(d => d.DeviceId);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<FirmwareVersion>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("firmware_versions");
|
||||||
|
e.HasKey(f => f.Id);
|
||||||
|
e.HasIndex(f => f.Version).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<DeviceGroup>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("device_groups");
|
||||||
|
e.HasKey(g => g.Id);
|
||||||
|
e.HasIndex(g => g.Name).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<User>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("users");
|
||||||
|
e.HasKey(u => u.Id);
|
||||||
|
e.HasIndex(u => u.Username).IsUnique();
|
||||||
|
e.HasIndex(u => u.Email).IsUnique();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
38
console/src/Tau.Acuvim.Console/Endpoints/AlertEndpoints.cs
Normal file
38
console/src/Tau.Acuvim.Console/Endpoints/AlertEndpoints.cs
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Tau.Acuvim.Console.DTOs;
|
||||||
|
using Tau.Acuvim.Console.Services;
|
||||||
|
|
||||||
|
namespace Tau.Acuvim.Console.Endpoints;
|
||||||
|
|
||||||
|
public static class AlertEndpoints
|
||||||
|
{
|
||||||
|
public static void MapAlertEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api").RequireAuthorization();
|
||||||
|
|
||||||
|
group.MapGet("/alerts", async (AlertService svc,
|
||||||
|
string? deviceId, string? severity, bool? acknowledged,
|
||||||
|
int page = 1, int pageSize = 50) =>
|
||||||
|
{
|
||||||
|
var alerts = await svc.GetAllAsync(deviceId, severity, acknowledged, page, pageSize);
|
||||||
|
return Results.Ok(alerts.Select(a => new AlertResponse(
|
||||||
|
a.Id, a.DeviceId, a.AlertType, a.Severity, a.Message,
|
||||||
|
a.Value, a.Threshold, a.Acknowledged, a.CreatedAt, a.ResolvedAt)));
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapGet("/devices/{deviceId}/alerts", async (string deviceId, AlertService svc,
|
||||||
|
int page = 1, int pageSize = 50) =>
|
||||||
|
{
|
||||||
|
var alerts = await svc.GetByDeviceAsync(deviceId, page, pageSize);
|
||||||
|
return Results.Ok(alerts.Select(a => new AlertResponse(
|
||||||
|
a.Id, a.DeviceId, a.AlertType, a.Severity, a.Message,
|
||||||
|
a.Value, a.Threshold, a.Acknowledged, a.CreatedAt, a.ResolvedAt)));
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPut("/alerts/{id}/acknowledge", async (long id, AlertService svc, HttpContext ctx) =>
|
||||||
|
{
|
||||||
|
var user = ctx.User.FindFirst(ClaimTypes.Name)?.Value ?? "unknown";
|
||||||
|
return await svc.AcknowledgeAsync(id, user) ? Results.Ok() : Results.NotFound();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
37
console/src/Tau.Acuvim.Console/Endpoints/AuthEndpoints.cs
Normal file
37
console/src/Tau.Acuvim.Console/Endpoints/AuthEndpoints.cs
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
using Tau.Acuvim.Console.DTOs;
|
||||||
|
using Tau.Acuvim.Console.Services;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace Tau.Acuvim.Console.Endpoints;
|
||||||
|
|
||||||
|
public static class AuthEndpoints
|
||||||
|
{
|
||||||
|
public static void MapAuthEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/auth");
|
||||||
|
|
||||||
|
group.MapPost("/login", async (LoginRequest req, AuthService svc) =>
|
||||||
|
{
|
||||||
|
var result = await svc.LoginAsync(req);
|
||||||
|
return result is not null
|
||||||
|
? Results.Ok(result)
|
||||||
|
: Results.Unauthorized();
|
||||||
|
}).RequireRateLimiting("auth");
|
||||||
|
|
||||||
|
group.MapPost("/register", async (RegisterRequest req, AuthService svc) =>
|
||||||
|
{
|
||||||
|
var result = await svc.RegisterAsync(req);
|
||||||
|
return result is not null
|
||||||
|
? Results.Ok(result)
|
||||||
|
: Results.Conflict(new { message = "Username or email already exists" });
|
||||||
|
}).RequireAuthorization(p => p.RequireRole("Admin"));
|
||||||
|
|
||||||
|
group.MapGet("/me", async (HttpContext ctx, AuthService svc) =>
|
||||||
|
{
|
||||||
|
var username = ctx.User.FindFirst(ClaimTypes.Name)?.Value;
|
||||||
|
if (username == null) return Results.Unauthorized();
|
||||||
|
var user = await svc.GetUserAsync(username);
|
||||||
|
return user is not null ? Results.Ok(user) : Results.NotFound();
|
||||||
|
}).RequireAuthorization();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Tau.Acuvim.Console.Data;
|
||||||
|
using Tau.Acuvim.Console.DTOs;
|
||||||
|
using Tau.Acuvim.Console.Services;
|
||||||
|
|
||||||
|
namespace Tau.Acuvim.Console.Endpoints;
|
||||||
|
|
||||||
|
public static class DashboardEndpoints
|
||||||
|
{
|
||||||
|
public static void MapDashboardEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/dashboard").RequireAuthorization();
|
||||||
|
|
||||||
|
group.MapGet("/summary", async (AppDbContext db, AlertService alertSvc, TelemetryService telSvc) =>
|
||||||
|
{
|
||||||
|
var total = await db.Devices.CountAsync();
|
||||||
|
var online = await db.Devices.CountAsync(d => d.Status == "online");
|
||||||
|
var offline = await db.Devices.CountAsync(d => d.Status == "offline");
|
||||||
|
var degraded = await db.Devices.CountAsync(d => d.Status == "degraded");
|
||||||
|
var activeAlerts = await alertSvc.GetActiveCountAsync();
|
||||||
|
var telemetryToday = await telSvc.GetCountTodayAsync();
|
||||||
|
|
||||||
|
return Results.Ok(new DashboardSummary(
|
||||||
|
total, online, offline, degraded, activeAlerts, telemetryToday));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
54
console/src/Tau.Acuvim.Console/Endpoints/DeviceEndpoints.cs
Normal file
54
console/src/Tau.Acuvim.Console/Endpoints/DeviceEndpoints.cs
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Tau.Acuvim.Console.DTOs;
|
||||||
|
using Tau.Acuvim.Console.Services;
|
||||||
|
|
||||||
|
namespace Tau.Acuvim.Console.Endpoints;
|
||||||
|
|
||||||
|
public static class DeviceEndpoints
|
||||||
|
{
|
||||||
|
public static void MapDeviceEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/devices").RequireAuthorization();
|
||||||
|
|
||||||
|
group.MapGet("/", async (DeviceService svc,
|
||||||
|
string? status, string? search, int page = 1, int pageSize = 50) =>
|
||||||
|
Results.Ok(await svc.GetAllAsync(status, search, page, pageSize)));
|
||||||
|
|
||||||
|
group.MapGet("/{deviceId}", async (string deviceId, DeviceService svc) =>
|
||||||
|
{
|
||||||
|
var device = await svc.GetByDeviceIdAsync(deviceId);
|
||||||
|
return device is not null ? Results.Ok(device) : Results.NotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPut("/{deviceId}", async (string deviceId, UpdateDeviceRequest req, DeviceService svc) =>
|
||||||
|
{
|
||||||
|
var device = await svc.UpdateAsync(deviceId, req);
|
||||||
|
return device is not null ? Results.Ok(device) : Results.NotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapDelete("/{deviceId}", async (string deviceId, DeviceService svc) =>
|
||||||
|
await svc.DeleteAsync(deviceId) ? Results.NoContent() : Results.NotFound());
|
||||||
|
|
||||||
|
group.MapPost("/{deviceId}/command", async (string deviceId, SendCommandRequest req,
|
||||||
|
CommandService cmdSvc, MqttBridgeService mqtt, HttpContext ctx) =>
|
||||||
|
{
|
||||||
|
var user = ctx.User.FindFirst(ClaimTypes.Name)?.Value;
|
||||||
|
var cmd = await cmdSvc.CreateAsync(deviceId, req.Command, req.Params, user);
|
||||||
|
var payload = cmdSvc.BuildCommandPayload(cmd);
|
||||||
|
await mqtt.PublishCommandAsync(deviceId, payload);
|
||||||
|
await cmdSvc.MarkSentAsync(cmd.RequestId);
|
||||||
|
return Results.Ok(new CommandResponse(cmd.Id, cmd.RequestId, cmd.CommandName,
|
||||||
|
cmd.Status, cmd.Response, cmd.CreatedAt, cmd.CompletedAt));
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapGet("/{deviceId}/commands", async (string deviceId, CommandService svc,
|
||||||
|
int page = 1, int pageSize = 20) =>
|
||||||
|
{
|
||||||
|
var commands = await svc.GetByDeviceAsync(deviceId, page, pageSize);
|
||||||
|
return Results.Ok(commands.Select(c => new CommandResponse(
|
||||||
|
c.Id, c.RequestId, c.CommandName, c.Status,
|
||||||
|
c.Response, c.CreatedAt, c.CompletedAt)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,83 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Tau.Acuvim.Console.Services;
|
||||||
|
|
||||||
|
namespace Tau.Acuvim.Console.Endpoints;
|
||||||
|
|
||||||
|
public static class FirmwareEndpoints
|
||||||
|
{
|
||||||
|
public static void MapFirmwareEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/firmware");
|
||||||
|
|
||||||
|
group.MapGet("/", async (FirmwareService svc) =>
|
||||||
|
Results.Ok(await svc.GetAllAsync()))
|
||||||
|
.RequireAuthorization();
|
||||||
|
|
||||||
|
group.MapPost("/", async (HttpRequest req, FirmwareService svc) =>
|
||||||
|
{
|
||||||
|
var form = await req.ReadFormAsync();
|
||||||
|
var file = form.Files.GetFile("firmware");
|
||||||
|
if (file == null)
|
||||||
|
return Results.BadRequest(new { message = "No firmware file provided" });
|
||||||
|
|
||||||
|
var version = form["version"].ToString();
|
||||||
|
if (string.IsNullOrEmpty(version))
|
||||||
|
return Results.BadRequest(new { message = "Version required" });
|
||||||
|
|
||||||
|
var releaseNotes = form["release_notes"].ToString();
|
||||||
|
var hardware = form["hardware"].ToString();
|
||||||
|
var mandatory = form["mandatory"].ToString() == "true";
|
||||||
|
var user = req.HttpContext.User.FindFirst(ClaimTypes.Name)?.Value;
|
||||||
|
|
||||||
|
var fw = await svc.UploadAsync(file, version, releaseNotes,
|
||||||
|
string.IsNullOrEmpty(hardware) ? null : hardware, mandatory, user);
|
||||||
|
return Results.Created($"/api/firmware/{fw.Version}", fw);
|
||||||
|
}).RequireAuthorization().DisableAntiforgery();
|
||||||
|
|
||||||
|
group.MapGet("/{version}", async (string version, FirmwareService svc) =>
|
||||||
|
{
|
||||||
|
var fw = await svc.GetByVersionAsync(version);
|
||||||
|
return fw is not null ? Results.Ok(fw) : Results.NotFound();
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
group.MapDelete("/{version}", async (string version, FirmwareService svc) =>
|
||||||
|
await svc.DeleteAsync(version) ? Results.NoContent() : Results.NotFound())
|
||||||
|
.RequireAuthorization();
|
||||||
|
|
||||||
|
// Device-facing endpoints (allow device_id query param as auth)
|
||||||
|
group.MapGet("/check", async (string device_id, string current_version,
|
||||||
|
string? hardware, FirmwareService svc) =>
|
||||||
|
Results.Ok(await svc.CheckAsync(device_id, current_version, hardware)))
|
||||||
|
.AllowAnonymous();
|
||||||
|
|
||||||
|
group.MapGet("/download/{version}", async (string version, FirmwareService svc) =>
|
||||||
|
{
|
||||||
|
var path = await svc.GetFilePathAsync(version);
|
||||||
|
if (path == null || !File.Exists(path))
|
||||||
|
return Results.NotFound();
|
||||||
|
return Results.File(path, "application/octet-stream", Path.GetFileName(path));
|
||||||
|
}).AllowAnonymous();
|
||||||
|
|
||||||
|
group.MapPost("/deploy", async (DTOs.FirmwareDeployRequest req,
|
||||||
|
FirmwareService fwSvc, CommandService cmdSvc, MqttBridgeService mqtt) =>
|
||||||
|
{
|
||||||
|
var fw = await fwSvc.GetByVersionAsync(req.Version);
|
||||||
|
if (fw == null)
|
||||||
|
return Results.NotFound(new { message = "Firmware version not found" });
|
||||||
|
|
||||||
|
var results = new List<object>();
|
||||||
|
foreach (var deviceId in req.DeviceIds)
|
||||||
|
{
|
||||||
|
var parameters = System.Text.Json.JsonDocument.Parse(
|
||||||
|
$"{{\"url\":\"{fw.FilePath}\",\"version\":\"{fw.Version}\",\"checksum\":\"{fw.Checksum}\"}}");
|
||||||
|
var cmd = await cmdSvc.CreateAsync(deviceId, "ota_update", parameters, "deploy");
|
||||||
|
var payload = cmdSvc.BuildCommandPayload(cmd);
|
||||||
|
await mqtt.PublishCommandAsync(deviceId, payload);
|
||||||
|
await cmdSvc.MarkSentAsync(cmd.RequestId);
|
||||||
|
results.Add(new { deviceId, requestId = cmd.RequestId });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(new { deployed = results.Count, devices = results });
|
||||||
|
}).RequireAuthorization();
|
||||||
|
}
|
||||||
|
}
|
||||||
45
console/src/Tau.Acuvim.Console/Endpoints/GroupEndpoints.cs
Normal file
45
console/src/Tau.Acuvim.Console/Endpoints/GroupEndpoints.cs
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Tau.Acuvim.Console.Data;
|
||||||
|
using Tau.Acuvim.Console.Models;
|
||||||
|
|
||||||
|
namespace Tau.Acuvim.Console.Endpoints;
|
||||||
|
|
||||||
|
public static class GroupEndpoints
|
||||||
|
{
|
||||||
|
public static void MapGroupEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/groups").RequireAuthorization();
|
||||||
|
|
||||||
|
group.MapGet("/", async (AppDbContext db) =>
|
||||||
|
Results.Ok(await db.DeviceGroups
|
||||||
|
.Select(g => new { g.Id, g.Name, g.Description, DeviceCount = g.Devices.Count, g.CreatedAt })
|
||||||
|
.ToListAsync()));
|
||||||
|
|
||||||
|
group.MapPost("/", async (DeviceGroup req, AppDbContext db) =>
|
||||||
|
{
|
||||||
|
req.Id = Guid.NewGuid();
|
||||||
|
db.DeviceGroups.Add(req);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return Results.Created($"/api/groups/{req.Id}", req);
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPut("/{id}", async (Guid id, DeviceGroup req, AppDbContext db) =>
|
||||||
|
{
|
||||||
|
var existing = await db.DeviceGroups.FindAsync(id);
|
||||||
|
if (existing == null) return Results.NotFound();
|
||||||
|
existing.Name = req.Name;
|
||||||
|
existing.Description = req.Description;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return Results.Ok(existing);
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapDelete("/{id}", async (Guid id, AppDbContext db) =>
|
||||||
|
{
|
||||||
|
var existing = await db.DeviceGroups.FindAsync(id);
|
||||||
|
if (existing == null) return Results.NotFound();
|
||||||
|
db.DeviceGroups.Remove(existing);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return Results.NoContent();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
using Tau.Acuvim.Console.Services;
|
||||||
|
|
||||||
|
namespace Tau.Acuvim.Console.Endpoints;
|
||||||
|
|
||||||
|
public static class TelemetryEndpoints
|
||||||
|
{
|
||||||
|
public static void MapTelemetryEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/devices/{deviceId}/telemetry").RequireAuthorization();
|
||||||
|
|
||||||
|
group.MapGet("/", async (string deviceId, TelemetryService svc) =>
|
||||||
|
{
|
||||||
|
var latest = await svc.GetLatestAsync(deviceId);
|
||||||
|
return latest is not null ? Results.Ok(latest) : Results.NotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapGet("/history", async (string deviceId, TelemetryService svc,
|
||||||
|
DateTime? from, DateTime? to, int page = 1, int pageSize = 100) =>
|
||||||
|
{
|
||||||
|
var fromDate = from ?? DateTime.UtcNow.AddDays(-1);
|
||||||
|
var toDate = to ?? DateTime.UtcNow;
|
||||||
|
var records = await svc.GetHistoryAsync(deviceId, fromDate, toDate, page, pageSize);
|
||||||
|
return Results.Ok(records);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
16
console/src/Tau.Acuvim.Console/Hubs/DeviceHub.cs
Normal file
16
console/src/Tau.Acuvim.Console/Hubs/DeviceHub.cs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
|
namespace Tau.Acuvim.Console.Hubs;
|
||||||
|
|
||||||
|
public class DeviceHub : Hub
|
||||||
|
{
|
||||||
|
public override async Task OnConnectedAsync()
|
||||||
|
{
|
||||||
|
await base.OnConnectedAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||||
|
{
|
||||||
|
await base.OnDisconnectedAsync(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
431
console/src/Tau.Acuvim.Console/Migrations/20260516163509_InitialCreate.Designer.cs
generated
Normal file
431
console/src/Tau.Acuvim.Console/Migrations/20260516163509_InitialCreate.Designer.cs
generated
Normal file
@ -0,0 +1,431 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using Tau.Acuvim.Console.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Tau.Acuvim.Console.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260516163509_InitialCreate")]
|
||||||
|
partial class InitialCreate
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.8")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tau.Acuvim.Console.Models.Alert", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<bool>("Acknowledged")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("AcknowledgedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("AcknowledgedBy")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("AlertType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<JsonDocument>("Metadata")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ResolvedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Severity")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<double?>("Threshold")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b.Property<double?>("Value")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("DeviceId", "CreatedAt")
|
||||||
|
.IsDescending(false, true);
|
||||||
|
|
||||||
|
b.ToTable("alerts", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tau.Acuvim.Console.Models.Command", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("CommandName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("CompletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<JsonDocument>("Params")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<string>("RequestId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<JsonDocument>("Response")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("SentAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("RequestId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("DeviceId", "CreatedAt")
|
||||||
|
.IsDescending(false, true);
|
||||||
|
|
||||||
|
b.ToTable("commands", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tau.Acuvim.Console.Models.Device", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("BootCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<JsonDocument>("Capabilities")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<string>("ConnectionType")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("FirmwareVersion")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid?>("GroupId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Hardware")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Iccid")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Imei")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("IpAddress")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastHeartbeat")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastTelemetry")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("MacAddress")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int?>("SignalStrength")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<JsonDocument>("Tags")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("DeviceId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("GroupId");
|
||||||
|
|
||||||
|
b.HasIndex("Status");
|
||||||
|
|
||||||
|
b.ToTable("devices", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tau.Acuvim.Console.Models.DeviceGroup", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("device_groups", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tau.Acuvim.Console.Models.FirmwareVersion", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<bool>("Active")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Checksum")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("FilePath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("FileSize")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Filename")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Hardware")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("Mandatory")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("ReleaseNotes")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("UploadedBy")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Version")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Version")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("firmware_versions", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tau.Acuvim.Console.Models.TelemetryRecord", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Connection")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<JsonDocument>("Data")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ReceivedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Source")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Timestamp")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Timestamp")
|
||||||
|
.IsDescending();
|
||||||
|
|
||||||
|
b.HasIndex("DeviceId", "Timestamp")
|
||||||
|
.IsDescending(false, true);
|
||||||
|
|
||||||
|
b.ToTable("telemetry_records", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tau.Acuvim.Console.Models.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastLoginAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Role")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Email")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("Username")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("users", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tau.Acuvim.Console.Models.Alert", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Tau.Acuvim.Console.Models.Device", "Device")
|
||||||
|
.WithMany("Alerts")
|
||||||
|
.HasForeignKey("DeviceId")
|
||||||
|
.HasPrincipalKey("DeviceId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Device");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tau.Acuvim.Console.Models.Command", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Tau.Acuvim.Console.Models.Device", "Device")
|
||||||
|
.WithMany("Commands")
|
||||||
|
.HasForeignKey("DeviceId")
|
||||||
|
.HasPrincipalKey("DeviceId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Device");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tau.Acuvim.Console.Models.Device", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Tau.Acuvim.Console.Models.DeviceGroup", "Group")
|
||||||
|
.WithMany("Devices")
|
||||||
|
.HasForeignKey("GroupId");
|
||||||
|
|
||||||
|
b.Navigation("Group");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tau.Acuvim.Console.Models.TelemetryRecord", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Tau.Acuvim.Console.Models.Device", "Device")
|
||||||
|
.WithMany("TelemetryRecords")
|
||||||
|
.HasForeignKey("DeviceId")
|
||||||
|
.HasPrincipalKey("DeviceId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Device");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tau.Acuvim.Console.Models.Device", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Alerts");
|
||||||
|
|
||||||
|
b.Navigation("Commands");
|
||||||
|
|
||||||
|
b.Navigation("TelemetryRecords");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tau.Acuvim.Console.Models.DeviceGroup", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Devices");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,283 @@
|
|||||||
|
using System;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Tau.Acuvim.Console.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class InitialCreate : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "device_groups",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Description = table.Column<string>(type: "text", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_device_groups", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "firmware_versions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Version = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Filename = table.Column<string>(type: "text", nullable: false),
|
||||||
|
FilePath = table.Column<string>(type: "text", nullable: false),
|
||||||
|
FileSize = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Checksum = table.Column<string>(type: "text", nullable: false),
|
||||||
|
ReleaseNotes = table.Column<string>(type: "text", nullable: true),
|
||||||
|
Hardware = table.Column<string>(type: "text", nullable: true),
|
||||||
|
Mandatory = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
Active = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
UploadedBy = table.Column<string>(type: "text", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_firmware_versions", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "users",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Username = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Email = table.Column<string>(type: "text", nullable: false),
|
||||||
|
PasswordHash = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Role = table.Column<string>(type: "text", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
LastLoginAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_users", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "devices",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
DeviceId = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "text", nullable: true),
|
||||||
|
Description = table.Column<string>(type: "text", nullable: true),
|
||||||
|
MacAddress = table.Column<string>(type: "text", nullable: true),
|
||||||
|
Imei = table.Column<string>(type: "text", nullable: true),
|
||||||
|
Iccid = table.Column<string>(type: "text", nullable: true),
|
||||||
|
Hardware = table.Column<string>(type: "text", nullable: true),
|
||||||
|
FirmwareVersion = table.Column<string>(type: "text", nullable: true),
|
||||||
|
Capabilities = table.Column<JsonDocument>(type: "jsonb", nullable: true),
|
||||||
|
Status = table.Column<string>(type: "text", nullable: false),
|
||||||
|
LastHeartbeat = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
LastTelemetry = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
ConnectionType = table.Column<string>(type: "text", nullable: true),
|
||||||
|
SignalStrength = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
IpAddress = table.Column<string>(type: "text", nullable: true),
|
||||||
|
BootCount = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
GroupId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
Tags = table.Column<JsonDocument>(type: "jsonb", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_devices", x => x.Id);
|
||||||
|
table.UniqueConstraint("AK_devices_DeviceId", x => x.DeviceId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_devices_device_groups_GroupId",
|
||||||
|
column: x => x.GroupId,
|
||||||
|
principalTable: "device_groups",
|
||||||
|
principalColumn: "Id");
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "alerts",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
DeviceId = table.Column<string>(type: "text", nullable: false),
|
||||||
|
AlertType = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Severity = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Message = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Value = table.Column<double>(type: "double precision", nullable: true),
|
||||||
|
Threshold = table.Column<double>(type: "double precision", nullable: true),
|
||||||
|
Metadata = table.Column<JsonDocument>(type: "jsonb", nullable: true),
|
||||||
|
Acknowledged = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
AcknowledgedBy = table.Column<string>(type: "text", nullable: true),
|
||||||
|
AcknowledgedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
ResolvedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_alerts", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_alerts_devices_DeviceId",
|
||||||
|
column: x => x.DeviceId,
|
||||||
|
principalTable: "devices",
|
||||||
|
principalColumn: "DeviceId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "commands",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
DeviceId = table.Column<string>(type: "text", nullable: false),
|
||||||
|
RequestId = table.Column<string>(type: "text", nullable: false),
|
||||||
|
CommandName = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Params = table.Column<JsonDocument>(type: "jsonb", nullable: true),
|
||||||
|
Status = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Response = table.Column<JsonDocument>(type: "jsonb", nullable: true),
|
||||||
|
CreatedBy = table.Column<string>(type: "text", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
SentAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
CompletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_commands", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_commands_devices_DeviceId",
|
||||||
|
column: x => x.DeviceId,
|
||||||
|
principalTable: "devices",
|
||||||
|
principalColumn: "DeviceId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "telemetry_records",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
DeviceId = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Timestamp = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
Data = table.Column<JsonDocument>(type: "jsonb", nullable: false),
|
||||||
|
Source = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Connection = table.Column<string>(type: "text", nullable: true),
|
||||||
|
ReceivedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_telemetry_records", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_telemetry_records_devices_DeviceId",
|
||||||
|
column: x => x.DeviceId,
|
||||||
|
principalTable: "devices",
|
||||||
|
principalColumn: "DeviceId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_alerts_DeviceId_CreatedAt",
|
||||||
|
table: "alerts",
|
||||||
|
columns: new[] { "DeviceId", "CreatedAt" },
|
||||||
|
descending: new[] { false, true });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_commands_DeviceId_CreatedAt",
|
||||||
|
table: "commands",
|
||||||
|
columns: new[] { "DeviceId", "CreatedAt" },
|
||||||
|
descending: new[] { false, true });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_commands_RequestId",
|
||||||
|
table: "commands",
|
||||||
|
column: "RequestId",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_device_groups_Name",
|
||||||
|
table: "device_groups",
|
||||||
|
column: "Name",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_devices_DeviceId",
|
||||||
|
table: "devices",
|
||||||
|
column: "DeviceId",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_devices_GroupId",
|
||||||
|
table: "devices",
|
||||||
|
column: "GroupId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_devices_Status",
|
||||||
|
table: "devices",
|
||||||
|
column: "Status");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_firmware_versions_Version",
|
||||||
|
table: "firmware_versions",
|
||||||
|
column: "Version",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_telemetry_records_DeviceId_Timestamp",
|
||||||
|
table: "telemetry_records",
|
||||||
|
columns: new[] { "DeviceId", "Timestamp" },
|
||||||
|
descending: new[] { false, true });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_telemetry_records_Timestamp",
|
||||||
|
table: "telemetry_records",
|
||||||
|
column: "Timestamp",
|
||||||
|
descending: new bool[0]);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_users_Email",
|
||||||
|
table: "users",
|
||||||
|
column: "Email",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_users_Username",
|
||||||
|
table: "users",
|
||||||
|
column: "Username",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "alerts");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "commands");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "firmware_versions");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "telemetry_records");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "users");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "devices");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "device_groups");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,428 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using Tau.Acuvim.Console.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Tau.Acuvim.Console.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
partial class AppDbContextModelSnapshot : ModelSnapshot
|
||||||
|
{
|
||||||
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.8")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tau.Acuvim.Console.Models.Alert", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<bool>("Acknowledged")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("AcknowledgedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("AcknowledgedBy")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("AlertType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<JsonDocument>("Metadata")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ResolvedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Severity")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<double?>("Threshold")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b.Property<double?>("Value")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("DeviceId", "CreatedAt")
|
||||||
|
.IsDescending(false, true);
|
||||||
|
|
||||||
|
b.ToTable("alerts", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tau.Acuvim.Console.Models.Command", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("CommandName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("CompletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<JsonDocument>("Params")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<string>("RequestId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<JsonDocument>("Response")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("SentAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("RequestId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("DeviceId", "CreatedAt")
|
||||||
|
.IsDescending(false, true);
|
||||||
|
|
||||||
|
b.ToTable("commands", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tau.Acuvim.Console.Models.Device", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("BootCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<JsonDocument>("Capabilities")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<string>("ConnectionType")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("FirmwareVersion")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid?>("GroupId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Hardware")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Iccid")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Imei")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("IpAddress")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastHeartbeat")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastTelemetry")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("MacAddress")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int?>("SignalStrength")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<JsonDocument>("Tags")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("DeviceId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("GroupId");
|
||||||
|
|
||||||
|
b.HasIndex("Status");
|
||||||
|
|
||||||
|
b.ToTable("devices", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tau.Acuvim.Console.Models.DeviceGroup", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("device_groups", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tau.Acuvim.Console.Models.FirmwareVersion", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<bool>("Active")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Checksum")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("FilePath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("FileSize")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Filename")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Hardware")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("Mandatory")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("ReleaseNotes")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("UploadedBy")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Version")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Version")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("firmware_versions", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tau.Acuvim.Console.Models.TelemetryRecord", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Connection")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<JsonDocument>("Data")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ReceivedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Source")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Timestamp")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Timestamp")
|
||||||
|
.IsDescending();
|
||||||
|
|
||||||
|
b.HasIndex("DeviceId", "Timestamp")
|
||||||
|
.IsDescending(false, true);
|
||||||
|
|
||||||
|
b.ToTable("telemetry_records", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tau.Acuvim.Console.Models.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastLoginAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Role")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Email")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("Username")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("users", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tau.Acuvim.Console.Models.Alert", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Tau.Acuvim.Console.Models.Device", "Device")
|
||||||
|
.WithMany("Alerts")
|
||||||
|
.HasForeignKey("DeviceId")
|
||||||
|
.HasPrincipalKey("DeviceId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Device");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tau.Acuvim.Console.Models.Command", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Tau.Acuvim.Console.Models.Device", "Device")
|
||||||
|
.WithMany("Commands")
|
||||||
|
.HasForeignKey("DeviceId")
|
||||||
|
.HasPrincipalKey("DeviceId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Device");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tau.Acuvim.Console.Models.Device", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Tau.Acuvim.Console.Models.DeviceGroup", "Group")
|
||||||
|
.WithMany("Devices")
|
||||||
|
.HasForeignKey("GroupId");
|
||||||
|
|
||||||
|
b.Navigation("Group");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tau.Acuvim.Console.Models.TelemetryRecord", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Tau.Acuvim.Console.Models.Device", "Device")
|
||||||
|
.WithMany("TelemetryRecords")
|
||||||
|
.HasForeignKey("DeviceId")
|
||||||
|
.HasPrincipalKey("DeviceId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Device");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tau.Acuvim.Console.Models.Device", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Alerts");
|
||||||
|
|
||||||
|
b.Navigation("Commands");
|
||||||
|
|
||||||
|
b.Navigation("TelemetryRecords");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tau.Acuvim.Console.Models.DeviceGroup", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Devices");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
console/src/Tau.Acuvim.Console/Models/Alert.cs
Normal file
22
console/src/Tau.Acuvim.Console/Models/Alert.cs
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Tau.Acuvim.Console.Models;
|
||||||
|
|
||||||
|
public class Alert
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public string DeviceId { get; set; } = string.Empty;
|
||||||
|
public string AlertType { get; set; } = string.Empty;
|
||||||
|
public string Severity { get; set; } = string.Empty;
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
public double? Value { get; set; }
|
||||||
|
public double? Threshold { get; set; }
|
||||||
|
public JsonDocument? Metadata { get; set; }
|
||||||
|
public bool Acknowledged { get; set; }
|
||||||
|
public string? AcknowledgedBy { get; set; }
|
||||||
|
public DateTime? AcknowledgedAt { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime? ResolvedAt { get; set; }
|
||||||
|
|
||||||
|
public Device? Device { get; set; }
|
||||||
|
}
|
||||||
20
console/src/Tau.Acuvim.Console/Models/Command.cs
Normal file
20
console/src/Tau.Acuvim.Console/Models/Command.cs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Tau.Acuvim.Console.Models;
|
||||||
|
|
||||||
|
public class Command
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string DeviceId { get; set; } = string.Empty;
|
||||||
|
public string RequestId { get; set; } = string.Empty;
|
||||||
|
public string CommandName { get; set; } = string.Empty;
|
||||||
|
public JsonDocument? Params { get; set; }
|
||||||
|
public string Status { get; set; } = "pending";
|
||||||
|
public JsonDocument? Response { get; set; }
|
||||||
|
public string? CreatedBy { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime? SentAt { get; set; }
|
||||||
|
public DateTime? CompletedAt { get; set; }
|
||||||
|
|
||||||
|
public Device? Device { get; set; }
|
||||||
|
}
|
||||||
33
console/src/Tau.Acuvim.Console/Models/Device.cs
Normal file
33
console/src/Tau.Acuvim.Console/Models/Device.cs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Tau.Acuvim.Console.Models;
|
||||||
|
|
||||||
|
public class Device
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string DeviceId { get; set; } = string.Empty;
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string? MacAddress { get; set; }
|
||||||
|
public string? Imei { get; set; }
|
||||||
|
public string? Iccid { get; set; }
|
||||||
|
public string? Hardware { get; set; }
|
||||||
|
public string? FirmwareVersion { get; set; }
|
||||||
|
public JsonDocument? Capabilities { get; set; }
|
||||||
|
public string Status { get; set; } = "offline";
|
||||||
|
public DateTime? LastHeartbeat { get; set; }
|
||||||
|
public DateTime? LastTelemetry { get; set; }
|
||||||
|
public string? ConnectionType { get; set; }
|
||||||
|
public int? SignalStrength { get; set; }
|
||||||
|
public string? IpAddress { get; set; }
|
||||||
|
public int BootCount { get; set; }
|
||||||
|
public Guid? GroupId { get; set; }
|
||||||
|
public JsonDocument? Tags { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public DeviceGroup? Group { get; set; }
|
||||||
|
public ICollection<TelemetryRecord> TelemetryRecords { get; set; } = [];
|
||||||
|
public ICollection<Alert> Alerts { get; set; } = [];
|
||||||
|
public ICollection<Command> Commands { get; set; } = [];
|
||||||
|
}
|
||||||
11
console/src/Tau.Acuvim.Console/Models/DeviceGroup.cs
Normal file
11
console/src/Tau.Acuvim.Console/Models/DeviceGroup.cs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
namespace Tau.Acuvim.Console.Models;
|
||||||
|
|
||||||
|
public class DeviceGroup
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public ICollection<Device> Devices { get; set; } = [];
|
||||||
|
}
|
||||||
17
console/src/Tau.Acuvim.Console/Models/FirmwareVersion.cs
Normal file
17
console/src/Tau.Acuvim.Console/Models/FirmwareVersion.cs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
namespace Tau.Acuvim.Console.Models;
|
||||||
|
|
||||||
|
public class FirmwareVersion
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string Version { get; set; } = string.Empty;
|
||||||
|
public string Filename { get; set; } = string.Empty;
|
||||||
|
public string FilePath { get; set; } = string.Empty;
|
||||||
|
public int FileSize { get; set; }
|
||||||
|
public string Checksum { get; set; } = string.Empty;
|
||||||
|
public string? ReleaseNotes { get; set; }
|
||||||
|
public string? Hardware { get; set; }
|
||||||
|
public bool Mandatory { get; set; }
|
||||||
|
public bool Active { get; set; } = true;
|
||||||
|
public string? UploadedBy { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
16
console/src/Tau.Acuvim.Console/Models/TelemetryRecord.cs
Normal file
16
console/src/Tau.Acuvim.Console/Models/TelemetryRecord.cs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Tau.Acuvim.Console.Models;
|
||||||
|
|
||||||
|
public class TelemetryRecord
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public string DeviceId { get; set; } = string.Empty;
|
||||||
|
public DateTime Timestamp { get; set; }
|
||||||
|
public JsonDocument Data { get; set; } = null!;
|
||||||
|
public string Source { get; set; } = "live";
|
||||||
|
public string? Connection { get; set; }
|
||||||
|
public DateTime ReceivedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public Device? Device { get; set; }
|
||||||
|
}
|
||||||
12
console/src/Tau.Acuvim.Console/Models/User.cs
Normal file
12
console/src/Tau.Acuvim.Console/Models/User.cs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
namespace Tau.Acuvim.Console.Models;
|
||||||
|
|
||||||
|
public class User
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
public string PasswordHash { get; set; } = string.Empty;
|
||||||
|
public string Role { get; set; } = "Viewer";
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime? LastLoginAt { get; set; }
|
||||||
|
}
|
||||||
209
console/src/Tau.Acuvim.Console/Program.cs
Normal file
209
console/src/Tau.Acuvim.Console/Program.cs
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using Microsoft.OpenApi;
|
||||||
|
using Serilog;
|
||||||
|
using Tau.Acuvim.Console.Data;
|
||||||
|
using Tau.Acuvim.Console.Endpoints;
|
||||||
|
using Tau.Acuvim.Console.Hubs;
|
||||||
|
using Tau.Acuvim.Console.Services;
|
||||||
|
|
||||||
|
Log.Logger = new LoggerConfiguration()
|
||||||
|
.WriteTo.Console()
|
||||||
|
.CreateBootstrapLogger();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
builder.Host.UseSerilog((ctx, lc) => lc
|
||||||
|
.ReadFrom.Configuration(ctx.Configuration)
|
||||||
|
.WriteTo.Console());
|
||||||
|
|
||||||
|
// Database
|
||||||
|
builder.Services.AddDbContext<AppDbContext>(options =>
|
||||||
|
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||||
|
|
||||||
|
// Authentication
|
||||||
|
var jwtSecret = builder.Configuration["Jwt:Secret"] ?? "default-dev-secret-key-min-32-chars!!";
|
||||||
|
var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "Tau.Acuvim.Console";
|
||||||
|
|
||||||
|
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
|
.AddJwtBearer(options =>
|
||||||
|
{
|
||||||
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ValidateIssuerSigningKey = true,
|
||||||
|
ValidIssuer = jwtIssuer,
|
||||||
|
ValidAudience = jwtIssuer,
|
||||||
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret))
|
||||||
|
};
|
||||||
|
|
||||||
|
// Support SignalR token via query string
|
||||||
|
options.Events = new JwtBearerEvents
|
||||||
|
{
|
||||||
|
OnMessageReceived = context =>
|
||||||
|
{
|
||||||
|
var accessToken = context.Request.Query["access_token"];
|
||||||
|
var path = context.HttpContext.Request.Path;
|
||||||
|
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
|
||||||
|
{
|
||||||
|
context.Token = accessToken;
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddAuthorization();
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
builder.Services.AddRateLimiter(options =>
|
||||||
|
{
|
||||||
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
options.AddFixedWindowLimiter("auth", o =>
|
||||||
|
{
|
||||||
|
o.Window = TimeSpan.FromMinutes(1);
|
||||||
|
o.PermitLimit = 10;
|
||||||
|
o.QueueLimit = 0;
|
||||||
|
});
|
||||||
|
options.AddFixedWindowLimiter("api", o =>
|
||||||
|
{
|
||||||
|
o.Window = TimeSpan.FromMinutes(1);
|
||||||
|
o.PermitLimit = 300;
|
||||||
|
o.QueueLimit = 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Health checks
|
||||||
|
builder.Services.AddHealthChecks()
|
||||||
|
.AddNpgSql(builder.Configuration.GetConnectionString("DefaultConnection")!);
|
||||||
|
|
||||||
|
// Services
|
||||||
|
builder.Services.AddScoped<DeviceService>();
|
||||||
|
builder.Services.AddScoped<TelemetryService>();
|
||||||
|
builder.Services.AddScoped<CommandService>();
|
||||||
|
builder.Services.AddScoped<AlertService>();
|
||||||
|
builder.Services.AddScoped<AuthService>();
|
||||||
|
builder.Services.AddScoped<FirmwareService>();
|
||||||
|
builder.Services.AddSingleton<MqttBridgeService>();
|
||||||
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<MqttBridgeService>());
|
||||||
|
builder.Services.AddSingleton<HealthMonitorService>();
|
||||||
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<HealthMonitorService>());
|
||||||
|
|
||||||
|
// SignalR
|
||||||
|
builder.Services.AddSignalR();
|
||||||
|
|
||||||
|
// Swagger
|
||||||
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
|
builder.Services.AddSwaggerGen(c =>
|
||||||
|
{
|
||||||
|
c.SwaggerDoc("v1", new() { Title = "Tau Acuvim Console API", Version = "v1" });
|
||||||
|
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
||||||
|
{
|
||||||
|
Description = "JWT Authorization header using the Bearer scheme",
|
||||||
|
Name = "Authorization",
|
||||||
|
In = ParameterLocation.Header,
|
||||||
|
Type = SecuritySchemeType.Http,
|
||||||
|
Scheme = "bearer"
|
||||||
|
});
|
||||||
|
c.AddSecurityRequirement(doc =>
|
||||||
|
{
|
||||||
|
var requirement = new OpenApiSecurityRequirement();
|
||||||
|
requirement.Add(new OpenApiSecuritySchemeReference("Bearer", doc), new List<string>());
|
||||||
|
return requirement;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// CORS
|
||||||
|
builder.Services.AddCors(options =>
|
||||||
|
{
|
||||||
|
options.AddDefaultPolicy(policy =>
|
||||||
|
{
|
||||||
|
policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader();
|
||||||
|
});
|
||||||
|
options.AddPolicy("SignalR", policy =>
|
||||||
|
{
|
||||||
|
policy.SetIsOriginAllowed(_ => true).AllowAnyMethod().AllowAnyHeader().AllowCredentials();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// Migrate database
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
await db.Database.MigrateAsync();
|
||||||
|
|
||||||
|
var auth = scope.ServiceProvider.GetRequiredService<AuthService>();
|
||||||
|
await auth.SeedAdminAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.UseSerilogRequestLogging();
|
||||||
|
app.UseRateLimiter();
|
||||||
|
app.UseCors();
|
||||||
|
|
||||||
|
// Security headers
|
||||||
|
app.Use(async (context, next) =>
|
||||||
|
{
|
||||||
|
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
|
||||||
|
context.Response.Headers["X-Frame-Options"] = "DENY";
|
||||||
|
context.Response.Headers["X-XSS-Protection"] = "0";
|
||||||
|
context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
|
||||||
|
if (!context.Request.Path.StartsWithSegments("/swagger"))
|
||||||
|
{
|
||||||
|
context.Response.Headers["Content-Security-Policy"] =
|
||||||
|
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:;";
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseHsts();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseSwagger();
|
||||||
|
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Tau Acuvim Console API"));
|
||||||
|
|
||||||
|
app.UseDefaultFiles();
|
||||||
|
app.UseStaticFiles();
|
||||||
|
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
// Endpoints
|
||||||
|
app.MapDeviceEndpoints();
|
||||||
|
app.MapTelemetryEndpoints();
|
||||||
|
app.MapFirmwareEndpoints();
|
||||||
|
app.MapAlertEndpoints();
|
||||||
|
app.MapAuthEndpoints();
|
||||||
|
app.MapDashboardEndpoints();
|
||||||
|
app.MapGroupEndpoints();
|
||||||
|
|
||||||
|
// SignalR
|
||||||
|
app.MapHub<DeviceHub>("/hubs/devices").RequireCors("SignalR");
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.MapHealthChecks("/health").AllowAnonymous();
|
||||||
|
|
||||||
|
// SPA fallback — serve index.html for non-API, non-file routes
|
||||||
|
app.MapFallbackToFile("index.html");
|
||||||
|
|
||||||
|
app.Run();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Fatal(ex, "Application terminated unexpectedly");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Log.CloseAndFlush();
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "http://localhost:5295",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
96
console/src/Tau.Acuvim.Console/Services/AlertService.cs
Normal file
96
console/src/Tau.Acuvim.Console/Services/AlertService.cs
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Tau.Acuvim.Console.Data;
|
||||||
|
using Tau.Acuvim.Console.Models;
|
||||||
|
|
||||||
|
namespace Tau.Acuvim.Console.Services;
|
||||||
|
|
||||||
|
public class AlertService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly ILogger<AlertService> _logger;
|
||||||
|
|
||||||
|
public AlertService(AppDbContext db, ILogger<AlertService> logger)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ProcessAlertAsync(string deviceId, string payload)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(payload);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
var alert = new Alert
|
||||||
|
{
|
||||||
|
DeviceId = deviceId,
|
||||||
|
AlertType = root.GetProperty("alert").GetString() ?? "unknown",
|
||||||
|
Severity = root.GetProperty("severity").GetString() ?? "info",
|
||||||
|
Message = root.GetProperty("message").GetString() ?? "",
|
||||||
|
Value = root.TryGetProperty("value", out var v) ? v.GetDouble() : null,
|
||||||
|
Threshold = root.TryGetProperty("threshold", out var t) ? t.GetDouble() : null,
|
||||||
|
Metadata = JsonDocument.Parse(payload)
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.Alerts.Add(alert);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogWarning("Alert {Type} from {DeviceId}: {Message}",
|
||||||
|
alert.AlertType, deviceId, alert.Message);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to process alert from {DeviceId}", deviceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<Alert>> GetAllAsync(string? deviceId, string? severity,
|
||||||
|
bool? acknowledged, int page, int pageSize)
|
||||||
|
{
|
||||||
|
var query = _db.Alerts.AsQueryable();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(deviceId))
|
||||||
|
query = query.Where(a => a.DeviceId == deviceId);
|
||||||
|
if (!string.IsNullOrEmpty(severity))
|
||||||
|
query = query.Where(a => a.Severity == severity);
|
||||||
|
if (acknowledged.HasValue)
|
||||||
|
query = query.Where(a => a.Acknowledged == acknowledged.Value);
|
||||||
|
|
||||||
|
return await query
|
||||||
|
.OrderByDescending(a => a.CreatedAt)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<Alert>> GetByDeviceAsync(string deviceId, int page, int pageSize)
|
||||||
|
{
|
||||||
|
return await _db.Alerts
|
||||||
|
.Where(a => a.DeviceId == deviceId)
|
||||||
|
.OrderByDescending(a => a.CreatedAt)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> AcknowledgeAsync(long id, string acknowledgedBy)
|
||||||
|
{
|
||||||
|
var alert = await _db.Alerts.FindAsync(id);
|
||||||
|
if (alert == null) return false;
|
||||||
|
|
||||||
|
alert.Acknowledged = true;
|
||||||
|
alert.AcknowledgedBy = acknowledgedBy;
|
||||||
|
alert.AcknowledgedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> GetActiveCountAsync()
|
||||||
|
{
|
||||||
|
return await _db.Alerts
|
||||||
|
.Where(a => !a.Acknowledged && a.ResolvedAt == null)
|
||||||
|
.CountAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
119
console/src/Tau.Acuvim.Console/Services/AuthService.cs
Normal file
119
console/src/Tau.Acuvim.Console/Services/AuthService.cs
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using Tau.Acuvim.Console.Data;
|
||||||
|
using Tau.Acuvim.Console.DTOs;
|
||||||
|
using Tau.Acuvim.Console.Models;
|
||||||
|
|
||||||
|
namespace Tau.Acuvim.Console.Services;
|
||||||
|
|
||||||
|
public class AuthService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly IConfiguration _config;
|
||||||
|
private readonly ILogger<AuthService> _logger;
|
||||||
|
|
||||||
|
public AuthService(AppDbContext db, IConfiguration config, ILogger<AuthService> logger)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_config = config;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AuthResponse?> LoginAsync(LoginRequest request)
|
||||||
|
{
|
||||||
|
var user = await _db.Users.FirstOrDefaultAsync(u => u.Username == request.Username);
|
||||||
|
if (user == null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Failed login attempt for {Username}", request.Username);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
user.LastLoginAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return GenerateToken(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AuthResponse?> RegisterAsync(RegisterRequest request)
|
||||||
|
{
|
||||||
|
if (await _db.Users.AnyAsync(u => u.Username == request.Username))
|
||||||
|
return null;
|
||||||
|
if (await _db.Users.AnyAsync(u => u.Email == request.Email))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Username = request.Username,
|
||||||
|
Email = request.Email,
|
||||||
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password),
|
||||||
|
Role = request.Role
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.Users.Add(user);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("User {Username} registered with role {Role}", user.Username, user.Role);
|
||||||
|
return GenerateToken(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UserInfo?> GetUserAsync(string username)
|
||||||
|
{
|
||||||
|
var user = await _db.Users.FirstOrDefaultAsync(u => u.Username == username);
|
||||||
|
if (user == null) return null;
|
||||||
|
return new UserInfo(user.Id, user.Username, user.Email, user.Role);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SeedAdminAsync()
|
||||||
|
{
|
||||||
|
if (await _db.Users.AnyAsync()) return;
|
||||||
|
|
||||||
|
var admin = new User
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Username = "admin",
|
||||||
|
Email = "admin@localhost",
|
||||||
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword("admin"),
|
||||||
|
Role = "Admin"
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.Users.Add(admin);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
_logger.LogInformation("Default admin user created (username: admin, password: admin)");
|
||||||
|
}
|
||||||
|
|
||||||
|
private AuthResponse GenerateToken(User user)
|
||||||
|
{
|
||||||
|
var secret = _config["Jwt:Secret"] ?? "default-dev-secret-key-min-32-chars!!";
|
||||||
|
var issuer = _config["Jwt:Issuer"] ?? "Tau.Acuvim.Console";
|
||||||
|
var expiryHours = _config.GetValue("Jwt:ExpiryHours", 24);
|
||||||
|
|
||||||
|
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));
|
||||||
|
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||||
|
var expiry = DateTime.UtcNow.AddHours(expiryHours);
|
||||||
|
|
||||||
|
var claims = new[]
|
||||||
|
{
|
||||||
|
new Claim(ClaimTypes.Name, user.Username),
|
||||||
|
new Claim(ClaimTypes.Email, user.Email),
|
||||||
|
new Claim(ClaimTypes.Role, user.Role),
|
||||||
|
new Claim("sub", user.Id.ToString())
|
||||||
|
};
|
||||||
|
|
||||||
|
var token = new JwtSecurityToken(
|
||||||
|
issuer: issuer,
|
||||||
|
audience: issuer,
|
||||||
|
claims: claims,
|
||||||
|
expires: expiry,
|
||||||
|
signingCredentials: creds);
|
||||||
|
|
||||||
|
return new AuthResponse(
|
||||||
|
Token: new JwtSecurityTokenHandler().WriteToken(token),
|
||||||
|
Username: user.Username,
|
||||||
|
Role: user.Role,
|
||||||
|
Expiry: expiry);
|
||||||
|
}
|
||||||
|
}
|
||||||
132
console/src/Tau.Acuvim.Console/Services/CommandService.cs
Normal file
132
console/src/Tau.Acuvim.Console/Services/CommandService.cs
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Tau.Acuvim.Console.Data;
|
||||||
|
using Tau.Acuvim.Console.Models;
|
||||||
|
|
||||||
|
namespace Tau.Acuvim.Console.Services;
|
||||||
|
|
||||||
|
public class CommandService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly ILogger<CommandService> _logger;
|
||||||
|
|
||||||
|
public CommandService(AppDbContext db, ILogger<CommandService> logger)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Command> CreateAsync(string deviceId, string commandName,
|
||||||
|
JsonDocument? parameters, string? createdBy)
|
||||||
|
{
|
||||||
|
var cmd = new Command
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
DeviceId = deviceId,
|
||||||
|
RequestId = $"cmd-{Guid.NewGuid():N}",
|
||||||
|
CommandName = commandName,
|
||||||
|
Params = parameters,
|
||||||
|
Status = "pending",
|
||||||
|
CreatedBy = createdBy
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.Commands.Add(cmd);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("Command {RequestId} ({Command}) created for {DeviceId}",
|
||||||
|
cmd.RequestId, commandName, deviceId);
|
||||||
|
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MarkSentAsync(string requestId)
|
||||||
|
{
|
||||||
|
var cmd = await _db.Commands.FirstOrDefaultAsync(c => c.RequestId == requestId);
|
||||||
|
if (cmd == null) return;
|
||||||
|
|
||||||
|
cmd.Status = "sent";
|
||||||
|
cmd.SentAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ProcessResponseAsync(string deviceId, string payload)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(payload);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
var requestId = root.GetProperty("request_id").GetString();
|
||||||
|
if (string.IsNullOrEmpty(requestId)) return;
|
||||||
|
|
||||||
|
var cmd = await _db.Commands.FirstOrDefaultAsync(c => c.RequestId == requestId);
|
||||||
|
if (cmd == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Response for unknown request {RequestId}", requestId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var status = root.TryGetProperty("status", out var statusProp)
|
||||||
|
? statusProp.GetString() ?? "success" : "success";
|
||||||
|
|
||||||
|
cmd.Status = status;
|
||||||
|
cmd.Response = JsonDocument.Parse(payload);
|
||||||
|
cmd.CompletedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("Command {RequestId} completed: {Status}", requestId, status);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to process command response from {DeviceId}", deviceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<Command>> GetByDeviceAsync(string deviceId, int page, int pageSize)
|
||||||
|
{
|
||||||
|
return await _db.Commands
|
||||||
|
.Where(c => c.DeviceId == deviceId)
|
||||||
|
.OrderByDescending(c => c.CreatedAt)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> TimeoutStalCommandsAsync(int timeoutSeconds = 60)
|
||||||
|
{
|
||||||
|
var cutoff = DateTime.UtcNow.AddSeconds(-timeoutSeconds);
|
||||||
|
var stale = await _db.Commands
|
||||||
|
.Where(c => c.Status == "sent" && c.SentAt < cutoff)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var cmd in stale)
|
||||||
|
{
|
||||||
|
cmd.Status = "timeout";
|
||||||
|
cmd.CompletedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stale.Count > 0)
|
||||||
|
{
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
_logger.LogWarning("Timed out {Count} commands", stale.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stale.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string BuildCommandPayload(Command cmd)
|
||||||
|
{
|
||||||
|
var payload = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["cmd"] = cmd.CommandName,
|
||||||
|
["request_id"] = cmd.RequestId
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cmd.Params != null)
|
||||||
|
{
|
||||||
|
payload["params"] = cmd.Params;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonSerializer.Serialize(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
157
console/src/Tau.Acuvim.Console/Services/DeviceService.cs
Normal file
157
console/src/Tau.Acuvim.Console/Services/DeviceService.cs
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Tau.Acuvim.Console.Data;
|
||||||
|
using Tau.Acuvim.Console.DTOs;
|
||||||
|
using Tau.Acuvim.Console.Models;
|
||||||
|
|
||||||
|
namespace Tau.Acuvim.Console.Services;
|
||||||
|
|
||||||
|
public class DeviceService(AppDbContext db, ILogger<DeviceService> logger)
|
||||||
|
{
|
||||||
|
public async Task<List<DeviceListResponse>> GetAllAsync(string? status, string? search, int page, int pageSize)
|
||||||
|
{
|
||||||
|
var query = db.Devices.Include(d => d.Group).AsQueryable();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(status))
|
||||||
|
query = query.Where(d => d.Status == status);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
|
query = query.Where(d =>
|
||||||
|
(d.Name != null && d.Name.Contains(search)) ||
|
||||||
|
d.DeviceId.Contains(search));
|
||||||
|
|
||||||
|
return await query
|
||||||
|
.OrderByDescending(d => d.CreatedAt)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.Select(d => new DeviceListResponse(
|
||||||
|
d.Id, d.DeviceId, d.Name, d.Status,
|
||||||
|
d.FirmwareVersion, d.ConnectionType,
|
||||||
|
d.LastHeartbeat, d.LastTelemetry,
|
||||||
|
d.Group != null ? d.Group.Name : null,
|
||||||
|
d.CreatedAt))
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DeviceDetailResponse?> GetByDeviceIdAsync(string deviceId)
|
||||||
|
{
|
||||||
|
return await db.Devices
|
||||||
|
.Where(d => d.DeviceId == deviceId)
|
||||||
|
.Select(d => new DeviceDetailResponse(
|
||||||
|
d.Id, d.DeviceId, d.Name, d.Description,
|
||||||
|
d.MacAddress, d.Imei, d.Hardware,
|
||||||
|
d.FirmwareVersion, d.Capabilities,
|
||||||
|
d.Status, d.LastHeartbeat, d.LastTelemetry,
|
||||||
|
d.ConnectionType, d.SignalStrength, d.IpAddress,
|
||||||
|
d.BootCount, d.Tags, d.CreatedAt))
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Device> RegisterAsync(string payload)
|
||||||
|
{
|
||||||
|
using var json = JsonDocument.Parse(payload);
|
||||||
|
var root = json.RootElement;
|
||||||
|
|
||||||
|
var deviceId = root.GetProperty("device_id").GetString()!;
|
||||||
|
|
||||||
|
var device = await db.Devices.FirstOrDefaultAsync(d => d.DeviceId == deviceId);
|
||||||
|
|
||||||
|
if (device is null)
|
||||||
|
{
|
||||||
|
device = new Device { Id = Guid.NewGuid(), DeviceId = deviceId };
|
||||||
|
db.Devices.Add(device);
|
||||||
|
logger.LogInformation("Registering new device {DeviceId}", deviceId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logger.LogInformation("Updating registration for device {DeviceId}", deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root.TryGetProperty("mac", out var mac))
|
||||||
|
device.MacAddress = mac.GetString();
|
||||||
|
|
||||||
|
if (root.TryGetProperty("firmware", out var firmware))
|
||||||
|
device.FirmwareVersion = firmware.GetString();
|
||||||
|
|
||||||
|
if (root.TryGetProperty("hardware", out var hardware))
|
||||||
|
device.Hardware = hardware.GetString();
|
||||||
|
|
||||||
|
if (root.TryGetProperty("imei", out var imei))
|
||||||
|
device.Imei = imei.GetString();
|
||||||
|
|
||||||
|
if (root.TryGetProperty("capabilities", out var capabilities))
|
||||||
|
device.Capabilities = JsonDocument.Parse(capabilities.GetRawText());
|
||||||
|
|
||||||
|
if (root.TryGetProperty("ts", out var ts))
|
||||||
|
device.LastHeartbeat = DateTimeOffset.FromUnixTimeSeconds(ts.GetInt64()).UtcDateTime;
|
||||||
|
|
||||||
|
device.Status = "online";
|
||||||
|
device.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateConnectionStatusAsync(string deviceId, string payload)
|
||||||
|
{
|
||||||
|
var device = await db.Devices.FirstOrDefaultAsync(d => d.DeviceId == deviceId);
|
||||||
|
|
||||||
|
if (device is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Status update for unknown device {DeviceId}", deviceId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var json = JsonDocument.Parse(payload);
|
||||||
|
var root = json.RootElement;
|
||||||
|
|
||||||
|
if (root.TryGetProperty("status", out var status))
|
||||||
|
device.Status = status.GetString() ?? "offline";
|
||||||
|
|
||||||
|
if (root.TryGetProperty("ts", out var ts))
|
||||||
|
device.LastHeartbeat = DateTimeOffset.FromUnixTimeSeconds(ts.GetInt64()).UtcDateTime;
|
||||||
|
|
||||||
|
device.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
logger.LogInformation("Device {DeviceId} status updated to {Status}", deviceId, device.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Device?> UpdateAsync(string deviceId, UpdateDeviceRequest request)
|
||||||
|
{
|
||||||
|
var device = await db.Devices.FirstOrDefaultAsync(d => d.DeviceId == deviceId);
|
||||||
|
|
||||||
|
if (device is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Update requested for unknown device {DeviceId}", deviceId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
device.Name = request.Name;
|
||||||
|
device.Description = request.Description;
|
||||||
|
device.GroupId = request.GroupId;
|
||||||
|
device.Tags = request.Tags;
|
||||||
|
device.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
logger.LogInformation("Device {DeviceId} metadata updated", deviceId);
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteAsync(string deviceId)
|
||||||
|
{
|
||||||
|
var device = await db.Devices.FirstOrDefaultAsync(d => d.DeviceId == deviceId);
|
||||||
|
|
||||||
|
if (device is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Delete requested for unknown device {DeviceId}", deviceId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.Devices.Remove(device);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
logger.LogInformation("Device {DeviceId} deleted", deviceId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
135
console/src/Tau.Acuvim.Console/Services/FirmwareService.cs
Normal file
135
console/src/Tau.Acuvim.Console/Services/FirmwareService.cs
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Tau.Acuvim.Console.Data;
|
||||||
|
using Tau.Acuvim.Console.DTOs;
|
||||||
|
using Tau.Acuvim.Console.Models;
|
||||||
|
|
||||||
|
namespace Tau.Acuvim.Console.Services;
|
||||||
|
|
||||||
|
public class FirmwareService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly ILogger<FirmwareService> _logger;
|
||||||
|
private readonly string _storagePath;
|
||||||
|
private readonly string _baseUrl;
|
||||||
|
|
||||||
|
public FirmwareService(AppDbContext db, ILogger<FirmwareService> logger,
|
||||||
|
IConfiguration config)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_logger = logger;
|
||||||
|
_storagePath = config["Firmware:StoragePath"] ?? "./firmware";
|
||||||
|
_baseUrl = config["Firmware:BaseUrl"] ?? "http://localhost:5000";
|
||||||
|
Directory.CreateDirectory(_storagePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<FirmwareVersion> UploadAsync(IFormFile file, string version,
|
||||||
|
string? releaseNotes, string? hardware, bool mandatory, string? uploadedBy)
|
||||||
|
{
|
||||||
|
var versionDir = Path.Combine(_storagePath, version);
|
||||||
|
Directory.CreateDirectory(versionDir);
|
||||||
|
|
||||||
|
var filePath = Path.Combine(versionDir, file.FileName);
|
||||||
|
|
||||||
|
await using var stream = new FileStream(filePath, FileMode.Create);
|
||||||
|
await file.CopyToAsync(stream);
|
||||||
|
stream.Position = 0;
|
||||||
|
|
||||||
|
var checksum = Convert.ToHexStringLower(await SHA256.HashDataAsync(stream));
|
||||||
|
|
||||||
|
var fw = new FirmwareVersion
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Version = version,
|
||||||
|
Filename = file.FileName,
|
||||||
|
FilePath = filePath,
|
||||||
|
FileSize = (int)file.Length,
|
||||||
|
Checksum = checksum,
|
||||||
|
ReleaseNotes = releaseNotes,
|
||||||
|
Hardware = hardware,
|
||||||
|
Mandatory = mandatory,
|
||||||
|
UploadedBy = uploadedBy
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.FirmwareVersions.Add(fw);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("Firmware {Version} uploaded ({Size} bytes)", version, file.Length);
|
||||||
|
return fw;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<FirmwareVersion>> GetAllAsync()
|
||||||
|
{
|
||||||
|
return await _db.FirmwareVersions
|
||||||
|
.OrderByDescending(f => f.CreatedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<FirmwareVersion?> GetByVersionAsync(string version)
|
||||||
|
{
|
||||||
|
return await _db.FirmwareVersions
|
||||||
|
.FirstOrDefaultAsync(f => f.Version == version);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteAsync(string version)
|
||||||
|
{
|
||||||
|
var fw = await _db.FirmwareVersions.FirstOrDefaultAsync(f => f.Version == version);
|
||||||
|
if (fw == null) return false;
|
||||||
|
|
||||||
|
if (File.Exists(fw.FilePath))
|
||||||
|
File.Delete(fw.FilePath);
|
||||||
|
|
||||||
|
_db.FirmwareVersions.Remove(fw);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<FirmwareCheckResponse> CheckAsync(string deviceId,
|
||||||
|
string currentVersion, string? hardware)
|
||||||
|
{
|
||||||
|
var query = _db.FirmwareVersions.Where(f => f.Active);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(hardware))
|
||||||
|
query = query.Where(f => f.Hardware == null || f.Hardware == hardware);
|
||||||
|
|
||||||
|
var latest = await query
|
||||||
|
.OrderByDescending(f => f.CreatedAt)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
if (latest != null && IsNewer(latest.Version, currentVersion))
|
||||||
|
{
|
||||||
|
return new FirmwareCheckResponse(
|
||||||
|
UpdateAvailable: true,
|
||||||
|
Version: latest.Version,
|
||||||
|
Url: $"{_baseUrl}/api/firmware/download/{latest.Version}",
|
||||||
|
Size: latest.FileSize,
|
||||||
|
Checksum: latest.Checksum,
|
||||||
|
ReleaseNotes: latest.ReleaseNotes,
|
||||||
|
Mandatory: latest.Mandatory
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new FirmwareCheckResponse(UpdateAvailable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> GetFilePathAsync(string version)
|
||||||
|
{
|
||||||
|
var fw = await _db.FirmwareVersions.FirstOrDefaultAsync(f => f.Version == version);
|
||||||
|
return fw?.FilePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsNewer(string available, string current)
|
||||||
|
{
|
||||||
|
var avParts = available.Split('.').Select(p => int.TryParse(p, out var n) ? n : 0).ToArray();
|
||||||
|
var curParts = current.Split('.').Select(p => int.TryParse(p, out var n) ? n : 0).ToArray();
|
||||||
|
|
||||||
|
for (int i = 0; i < Math.Max(avParts.Length, curParts.Length); i++)
|
||||||
|
{
|
||||||
|
var a = i < avParts.Length ? avParts[i] : 0;
|
||||||
|
var c = i < curParts.Length ? curParts[i] : 0;
|
||||||
|
if (a != c) return a > c;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
149
console/src/Tau.Acuvim.Console/Services/HealthMonitorService.cs
Normal file
149
console/src/Tau.Acuvim.Console/Services/HealthMonitorService.cs
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Tau.Acuvim.Console.Data;
|
||||||
|
using Tau.Acuvim.Console.Hubs;
|
||||||
|
|
||||||
|
namespace Tau.Acuvim.Console.Services;
|
||||||
|
|
||||||
|
public class HealthMonitorService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly ILogger<HealthMonitorService> _logger;
|
||||||
|
private readonly IHubContext<DeviceHub> _hub;
|
||||||
|
private readonly int _checkIntervalSeconds;
|
||||||
|
private readonly int _degradedMultiplier;
|
||||||
|
private readonly int _offlineMultiplier;
|
||||||
|
|
||||||
|
public HealthMonitorService(IServiceProvider services,
|
||||||
|
ILogger<HealthMonitorService> logger,
|
||||||
|
IHubContext<DeviceHub> hub,
|
||||||
|
IConfiguration config)
|
||||||
|
{
|
||||||
|
_services = services;
|
||||||
|
_logger = logger;
|
||||||
|
_hub = hub;
|
||||||
|
_checkIntervalSeconds = config.GetValue("HealthMonitor:CheckIntervalSeconds", 30);
|
||||||
|
_degradedMultiplier = config.GetValue("HealthMonitor:DegradedThresholdMultiplier", 3);
|
||||||
|
_offlineMultiplier = config.GetValue("HealthMonitor:OfflineThresholdMultiplier", 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Health monitor started (interval: {Interval}s)", _checkIntervalSeconds);
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await CheckDeviceHealthAsync();
|
||||||
|
await TimeoutStaleCommandsAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Health monitor check failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(_checkIntervalSeconds * 1000, stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ProcessHeartbeatAsync(string deviceId, string payload)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
|
var device = await db.Devices.FirstOrDefaultAsync(d => d.DeviceId == deviceId);
|
||||||
|
if (device == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Heartbeat from unknown device {DeviceId}", deviceId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(payload);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
device.Status = "online";
|
||||||
|
device.LastHeartbeat = DateTime.UtcNow;
|
||||||
|
device.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
if (root.TryGetProperty("fw", out var fw))
|
||||||
|
device.FirmwareVersion = fw.GetString();
|
||||||
|
if (root.TryGetProperty("boot", out var boot))
|
||||||
|
device.BootCount = boot.GetInt32();
|
||||||
|
|
||||||
|
if (root.TryGetProperty("conn", out var conn))
|
||||||
|
{
|
||||||
|
if (conn.TryGetProperty("type", out var connType))
|
||||||
|
device.ConnectionType = connType.GetString();
|
||||||
|
if (conn.TryGetProperty("wifi", out var wifi) &&
|
||||||
|
wifi.TryGetProperty("ip", out var ip))
|
||||||
|
device.IpAddress = ip.GetString();
|
||||||
|
if (conn.TryGetProperty("wifi", out var wifi2) &&
|
||||||
|
wifi2.TryGetProperty("rssi", out var rssi))
|
||||||
|
device.SignalStrength = rssi.GetInt32();
|
||||||
|
if (conn.TryGetProperty("gsm", out var gsm) &&
|
||||||
|
device.ConnectionType == "gsm" &&
|
||||||
|
gsm.TryGetProperty("signal", out var gsmSignal))
|
||||||
|
device.SignalStrength = gsmSignal.GetInt32();
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await _hub.Clients.All.SendAsync("HeartbeatReceived", deviceId, payload);
|
||||||
|
|
||||||
|
_logger.LogDebug("Heartbeat processed for {DeviceId}", deviceId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to process heartbeat from {DeviceId}", deviceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CheckDeviceHealthAsync()
|
||||||
|
{
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
|
var devices = await db.Devices
|
||||||
|
.Where(d => d.Status != "offline" && d.LastHeartbeat != null)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var device in devices)
|
||||||
|
{
|
||||||
|
var elapsed = DateTime.UtcNow - device.LastHeartbeat!.Value;
|
||||||
|
var heartbeatInterval = TimeSpan.FromSeconds(60);
|
||||||
|
|
||||||
|
string? newStatus = null;
|
||||||
|
if (elapsed > heartbeatInterval * _offlineMultiplier && device.Status != "offline")
|
||||||
|
{
|
||||||
|
newStatus = "offline";
|
||||||
|
}
|
||||||
|
else if (elapsed > heartbeatInterval * _degradedMultiplier && device.Status == "online")
|
||||||
|
{
|
||||||
|
newStatus = "degraded";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newStatus != null)
|
||||||
|
{
|
||||||
|
device.Status = newStatus;
|
||||||
|
device.UpdatedAt = DateTime.UtcNow;
|
||||||
|
_logger.LogWarning("Device {DeviceId} marked {Status} (last heartbeat: {Elapsed}s ago)",
|
||||||
|
device.DeviceId, newStatus, (int)elapsed.TotalSeconds);
|
||||||
|
|
||||||
|
await _hub.Clients.All.SendAsync("DeviceStatusChanged", device.DeviceId, newStatus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TimeoutStaleCommandsAsync()
|
||||||
|
{
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var cmdService = scope.ServiceProvider.GetRequiredService<CommandService>();
|
||||||
|
await cmdService.TimeoutStalCommandsAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
194
console/src/Tau.Acuvim.Console/Services/MqttBridgeService.cs
Normal file
194
console/src/Tau.Acuvim.Console/Services/MqttBridgeService.cs
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
using System.Buffers;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using MQTTnet;
|
||||||
|
using MQTTnet.Packets;
|
||||||
|
using MQTTnet.Protocol;
|
||||||
|
using Tau.Acuvim.Console.Hubs;
|
||||||
|
|
||||||
|
namespace Tau.Acuvim.Console.Services;
|
||||||
|
|
||||||
|
public class MqttBridgeService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly ILogger<MqttBridgeService> _logger;
|
||||||
|
private readonly IHubContext<DeviceHub> _hub;
|
||||||
|
private readonly IConfiguration _config;
|
||||||
|
private IMqttClient? _client;
|
||||||
|
private readonly string _topicPrefix;
|
||||||
|
|
||||||
|
public MqttBridgeService(IServiceProvider services,
|
||||||
|
ILogger<MqttBridgeService> logger,
|
||||||
|
IHubContext<DeviceHub> hub,
|
||||||
|
IConfiguration config)
|
||||||
|
{
|
||||||
|
_services = services;
|
||||||
|
_logger = logger;
|
||||||
|
_hub = hub;
|
||||||
|
_config = config;
|
||||||
|
_topicPrefix = config["Mqtt:TopicPrefix"] ?? "acuvim";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ConnectAndSubscribeAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "MQTT connection error, retrying in 5s");
|
||||||
|
await Task.Delay(5000, stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_client?.IsConnected == true)
|
||||||
|
{
|
||||||
|
await _client.DisconnectAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ConnectAndSubscribeAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var factory = new MqttClientFactory();
|
||||||
|
_client = factory.CreateMqttClient();
|
||||||
|
|
||||||
|
var broker = _config["Mqtt:Broker"] ?? "localhost";
|
||||||
|
var port = _config.GetValue("Mqtt:Port", 1883);
|
||||||
|
var clientId = _config["Mqtt:ClientId"] ?? "acuvim-console";
|
||||||
|
var username = _config["Mqtt:Username"];
|
||||||
|
var password = _config["Mqtt:Password"];
|
||||||
|
|
||||||
|
var optionsBuilder = new MqttClientOptionsBuilder()
|
||||||
|
.WithTcpServer(broker, port)
|
||||||
|
.WithClientId(clientId)
|
||||||
|
.WithCleanSession(true);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(username))
|
||||||
|
optionsBuilder.WithCredentials(username, password);
|
||||||
|
|
||||||
|
_client.ApplicationMessageReceivedAsync += HandleMessageAsync;
|
||||||
|
|
||||||
|
_client.DisconnectedAsync += e =>
|
||||||
|
{
|
||||||
|
if (!ct.IsCancellationRequested)
|
||||||
|
_logger.LogWarning("MQTT disconnected: {Reason}", e.Reason);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
|
_logger.LogInformation("Connecting to MQTT broker {Broker}:{Port}", broker, port);
|
||||||
|
await _client.ConnectAsync(optionsBuilder.Build(), ct);
|
||||||
|
_logger.LogInformation("MQTT connected");
|
||||||
|
|
||||||
|
var subscribeOptions = new MqttClientSubscribeOptionsBuilder()
|
||||||
|
.WithTopicFilter($"{_topicPrefix}/+/telemetry", MqttQualityOfServiceLevel.AtLeastOnce)
|
||||||
|
.WithTopicFilter($"{_topicPrefix}/+/heartbeat", MqttQualityOfServiceLevel.AtLeastOnce)
|
||||||
|
.WithTopicFilter($"{_topicPrefix}/+/resp", MqttQualityOfServiceLevel.AtLeastOnce)
|
||||||
|
.WithTopicFilter($"{_topicPrefix}/+/status", MqttQualityOfServiceLevel.AtLeastOnce)
|
||||||
|
.WithTopicFilter($"{_topicPrefix}/+/alerts", MqttQualityOfServiceLevel.AtLeastOnce)
|
||||||
|
.WithTopicFilter("devices/register", MqttQualityOfServiceLevel.AtLeastOnce)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
await _client.SubscribeAsync(subscribeOptions, ct);
|
||||||
|
_logger.LogInformation("Subscribed to device topics");
|
||||||
|
|
||||||
|
var tcs = new TaskCompletionSource();
|
||||||
|
ct.Register(() => tcs.TrySetResult());
|
||||||
|
await tcs.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleMessageAsync(MqttApplicationMessageReceivedEventArgs e)
|
||||||
|
{
|
||||||
|
var topic = e.ApplicationMessage.Topic;
|
||||||
|
var payload = Encoding.UTF8.GetString(e.ApplicationMessage.Payload.ToArray());
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (topic == "devices/register")
|
||||||
|
{
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var deviceService = scope.ServiceProvider.GetRequiredService<DeviceService>();
|
||||||
|
var device = await deviceService.RegisterAsync(payload);
|
||||||
|
await _hub.Clients.All.SendAsync("DeviceRegistered", device.DeviceId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = topic.Split('/');
|
||||||
|
if (parts.Length < 3) return;
|
||||||
|
|
||||||
|
var deviceId = parts[1];
|
||||||
|
var type = parts[2];
|
||||||
|
|
||||||
|
switch (type)
|
||||||
|
{
|
||||||
|
case "telemetry":
|
||||||
|
{
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var svc = scope.ServiceProvider.GetRequiredService<TelemetryService>();
|
||||||
|
await svc.IngestAsync(deviceId, payload);
|
||||||
|
await _hub.Clients.All.SendAsync("TelemetryReceived", deviceId, payload);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "heartbeat":
|
||||||
|
{
|
||||||
|
var monitor = _services.GetRequiredService<HealthMonitorService>();
|
||||||
|
await monitor.ProcessHeartbeatAsync(deviceId, payload);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "resp":
|
||||||
|
{
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var svc = scope.ServiceProvider.GetRequiredService<CommandService>();
|
||||||
|
await svc.ProcessResponseAsync(deviceId, payload);
|
||||||
|
await _hub.Clients.All.SendAsync("CommandCompleted", deviceId, payload);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "status":
|
||||||
|
{
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var svc = scope.ServiceProvider.GetRequiredService<DeviceService>();
|
||||||
|
await svc.UpdateConnectionStatusAsync(deviceId, payload);
|
||||||
|
await _hub.Clients.All.SendAsync("DeviceStatusChanged", deviceId, payload);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "alerts":
|
||||||
|
{
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var svc = scope.ServiceProvider.GetRequiredService<AlertService>();
|
||||||
|
await svc.ProcessAlertAsync(deviceId, payload);
|
||||||
|
await _hub.Clients.All.SendAsync("AlertCreated", deviceId, payload);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error handling MQTT message on {Topic}", topic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task PublishCommandAsync(string deviceId, string payload)
|
||||||
|
{
|
||||||
|
if (_client?.IsConnected != true)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Cannot publish command: MQTT not connected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var topic = $"{_topicPrefix}/{deviceId}/cmd";
|
||||||
|
var message = new MqttApplicationMessageBuilder()
|
||||||
|
.WithTopic(topic)
|
||||||
|
.WithPayload(payload)
|
||||||
|
.WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
await _client.PublishAsync(message);
|
||||||
|
_logger.LogInformation("Published command to {Topic}", topic);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
console/src/Tau.Acuvim.Console/Services/TelemetryService.cs
Normal file
99
console/src/Tau.Acuvim.Console/Services/TelemetryService.cs
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Tau.Acuvim.Console.Data;
|
||||||
|
using Tau.Acuvim.Console.Models;
|
||||||
|
|
||||||
|
namespace Tau.Acuvim.Console.Services;
|
||||||
|
|
||||||
|
public class TelemetryService(AppDbContext db, ILogger<TelemetryService> logger)
|
||||||
|
{
|
||||||
|
public async Task IngestAsync(string deviceId, string payload)
|
||||||
|
{
|
||||||
|
JsonDocument doc;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
doc = JsonDocument.Parse(payload);
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Invalid JSON telemetry payload from device {DeviceId}", deviceId);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
var timestamp = root.TryGetProperty("ts", out var tsElement) && tsElement.TryGetInt64(out var epoch)
|
||||||
|
? DateTimeOffset.FromUnixTimeSeconds(epoch).UtcDateTime
|
||||||
|
: DateTime.UtcNow;
|
||||||
|
|
||||||
|
string? connection = root.TryGetProperty("conn", out var connElement)
|
||||||
|
? connElement.GetString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
var record = new TelemetryRecord
|
||||||
|
{
|
||||||
|
DeviceId = deviceId,
|
||||||
|
Timestamp = timestamp,
|
||||||
|
Data = doc,
|
||||||
|
Source = "live",
|
||||||
|
Connection = connection,
|
||||||
|
ReceivedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
db.TelemetryRecords.Add(record);
|
||||||
|
|
||||||
|
var device = await db.Devices.FirstOrDefaultAsync(d => d.DeviceId == deviceId);
|
||||||
|
if (device is not null)
|
||||||
|
{
|
||||||
|
device.LastTelemetry = DateTime.UtcNow;
|
||||||
|
device.UpdatedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logger.LogWarning("Telemetry received for unknown device {DeviceId}", deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
logger.LogDebug("Ingested telemetry for device {DeviceId} at {Timestamp}", deviceId, timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TelemetryRecord?> GetLatestAsync(string deviceId)
|
||||||
|
{
|
||||||
|
return await db.TelemetryRecords
|
||||||
|
.Where(t => t.DeviceId == deviceId)
|
||||||
|
.OrderByDescending(t => t.Timestamp)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<TelemetryRecord>> GetHistoryAsync(
|
||||||
|
string deviceId, DateTime from, DateTime to, int page, int pageSize)
|
||||||
|
{
|
||||||
|
return await db.TelemetryRecords
|
||||||
|
.Where(t => t.DeviceId == deviceId && t.Timestamp >= from && t.Timestamp <= to)
|
||||||
|
.OrderByDescending(t => t.Timestamp)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> GetCountTodayAsync()
|
||||||
|
{
|
||||||
|
var todayUtc = DateTime.UtcNow.Date;
|
||||||
|
return await db.TelemetryRecords
|
||||||
|
.CountAsync(t => t.ReceivedAt >= todayUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> CleanupOldRecordsAsync(int retentionDays)
|
||||||
|
{
|
||||||
|
var cutoff = DateTime.UtcNow.AddDays(-retentionDays);
|
||||||
|
var count = await db.TelemetryRecords
|
||||||
|
.Where(t => t.ReceivedAt < cutoff)
|
||||||
|
.ExecuteDeleteAsync();
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Cleaned up {Count} telemetry records older than {RetentionDays} days", count, retentionDays);
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
console/src/Tau.Acuvim.Console/Tau.Acuvim.Console.csproj
Normal file
25
console/src/Tau.Acuvim.Console/Tau.Acuvim.Console.csproj
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="9.0.0" />
|
||||||
|
<PackageReference Include="BCrypt.Net-Next" Version="4.2.0" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.2" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="MQTTnet" Version="5.0.1.1416" />
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
||||||
|
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
6
console/src/Tau.Acuvim.Console/Tau.Acuvim.Console.http
Normal file
6
console/src/Tau.Acuvim.Console/Tau.Acuvim.Console.http
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
@Tau.Acuvim.Console_HostAddress = http://localhost:5295
|
||||||
|
|
||||||
|
GET {{Tau.Acuvim.Console_HostAddress}}/weatherforecast/
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
###
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
console/src/Tau.Acuvim.Console/appsettings.Production.json
Normal file
43
console/src/Tau.Acuvim.Console/appsettings.Production.json
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": "Host=db;Database=acuvim;Username=acuvim;Password=${POSTGRES_PASSWORD}"
|
||||||
|
},
|
||||||
|
"Mqtt": {
|
||||||
|
"Broker": "mqtt",
|
||||||
|
"Port": 8883,
|
||||||
|
"Username": "console",
|
||||||
|
"Password": "${MQTT_PASSWORD}",
|
||||||
|
"TopicPrefix": "acuvim",
|
||||||
|
"ClientId": "acuvim-console",
|
||||||
|
"UseTls": true
|
||||||
|
},
|
||||||
|
"Firmware": {
|
||||||
|
"StoragePath": "/app/firmware",
|
||||||
|
"BaseUrl": "${CONSOLE_URL}"
|
||||||
|
},
|
||||||
|
"Jwt": {
|
||||||
|
"Secret": "${JWT_SECRET}",
|
||||||
|
"Issuer": "Tau.Acuvim.Console",
|
||||||
|
"ExpiryHours": 8
|
||||||
|
},
|
||||||
|
"HealthMonitor": {
|
||||||
|
"CheckIntervalSeconds": 30,
|
||||||
|
"DegradedThresholdMultiplier": 3,
|
||||||
|
"OfflineThresholdMultiplier": 5
|
||||||
|
},
|
||||||
|
"Telemetry": {
|
||||||
|
"RetentionDays": 365,
|
||||||
|
"CleanupIntervalHours": 24
|
||||||
|
},
|
||||||
|
"Serilog": {
|
||||||
|
"MinimumLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Override": {
|
||||||
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"Microsoft.EntityFrameworkCore": "Warning",
|
||||||
|
"System.Net.Http": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
41
console/src/Tau.Acuvim.Console/appsettings.json
Normal file
41
console/src/Tau.Acuvim.Console/appsettings.json
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": "Host=localhost;Database=acuvim;Username=acuvim;Password=secret"
|
||||||
|
},
|
||||||
|
"Mqtt": {
|
||||||
|
"Broker": "localhost",
|
||||||
|
"Port": 1883,
|
||||||
|
"Username": "",
|
||||||
|
"Password": "",
|
||||||
|
"TopicPrefix": "acuvim",
|
||||||
|
"ClientId": "acuvim-console"
|
||||||
|
},
|
||||||
|
"Firmware": {
|
||||||
|
"StoragePath": "./firmware",
|
||||||
|
"BaseUrl": "http://localhost:5000"
|
||||||
|
},
|
||||||
|
"Jwt": {
|
||||||
|
"Secret": "tau-acuvim-dev-secret-key-min-32-chars!!",
|
||||||
|
"Issuer": "Tau.Acuvim.Console",
|
||||||
|
"ExpiryHours": 24
|
||||||
|
},
|
||||||
|
"HealthMonitor": {
|
||||||
|
"CheckIntervalSeconds": 30,
|
||||||
|
"DegradedThresholdMultiplier": 3,
|
||||||
|
"OfflineThresholdMultiplier": 5
|
||||||
|
},
|
||||||
|
"Telemetry": {
|
||||||
|
"RetentionDays": 365,
|
||||||
|
"CleanupIntervalHours": 24
|
||||||
|
},
|
||||||
|
"Serilog": {
|
||||||
|
"MinimumLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Override": {
|
||||||
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"Microsoft.EntityFrameworkCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
119
console/tests/Tau.Acuvim.Console.Tests/AlertServiceTests.cs
Normal file
119
console/tests/Tau.Acuvim.Console.Tests/AlertServiceTests.cs
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Tau.Acuvim.Console.Models;
|
||||||
|
using Tau.Acuvim.Console.Services;
|
||||||
|
|
||||||
|
namespace Tau.Acuvim.Console.Tests;
|
||||||
|
|
||||||
|
public class AlertServiceTests
|
||||||
|
{
|
||||||
|
private AlertService CreateService(Data.AppDbContext db)
|
||||||
|
=> new(db, TestHelpers.CreateLogger<AlertService>());
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProcessAlertAsync_CreatesAlert()
|
||||||
|
{
|
||||||
|
using var db = TestHelpers.CreateDbContext();
|
||||||
|
var svc = CreateService(db);
|
||||||
|
|
||||||
|
var payload = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
alert = "overvoltage",
|
||||||
|
severity = "critical",
|
||||||
|
message = "Voltage exceeded 250V",
|
||||||
|
value = 255.3,
|
||||||
|
threshold = 250.0
|
||||||
|
});
|
||||||
|
|
||||||
|
await svc.ProcessAlertAsync("DEV-A1", payload);
|
||||||
|
|
||||||
|
Assert.Single(db.Alerts);
|
||||||
|
var alert = db.Alerts.First();
|
||||||
|
Assert.Equal("DEV-A1", alert.DeviceId);
|
||||||
|
Assert.Equal("overvoltage", alert.AlertType);
|
||||||
|
Assert.Equal("critical", alert.Severity);
|
||||||
|
Assert.Equal("Voltage exceeded 250V", alert.Message);
|
||||||
|
Assert.Equal(255.3, alert.Value);
|
||||||
|
Assert.Equal(250.0, alert.Threshold);
|
||||||
|
Assert.False(alert.Acknowledged);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAllAsync_FiltersBySeverity()
|
||||||
|
{
|
||||||
|
using var db = TestHelpers.CreateDbContext();
|
||||||
|
var svc = CreateService(db);
|
||||||
|
|
||||||
|
db.Alerts.AddRange(
|
||||||
|
new Alert { DeviceId = "D1", AlertType = "temp", Severity = "critical", Message = "hot" },
|
||||||
|
new Alert { DeviceId = "D1", AlertType = "voltage", Severity = "warning", Message = "low" },
|
||||||
|
new Alert { DeviceId = "D2", AlertType = "temp", Severity = "critical", Message = "hot2" }
|
||||||
|
);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await svc.GetAllAsync(
|
||||||
|
deviceId: null, severity: "critical", acknowledged: null, page: 1, pageSize: 10);
|
||||||
|
|
||||||
|
Assert.Equal(2, result.Count);
|
||||||
|
Assert.All(result, a => Assert.Equal("critical", a.Severity));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AcknowledgeAsync_MarksAlertAcknowledged()
|
||||||
|
{
|
||||||
|
using var db = TestHelpers.CreateDbContext();
|
||||||
|
var svc = CreateService(db);
|
||||||
|
|
||||||
|
var alert = new Alert
|
||||||
|
{
|
||||||
|
DeviceId = "D1",
|
||||||
|
AlertType = "temp",
|
||||||
|
Severity = "warning",
|
||||||
|
Message = "test"
|
||||||
|
};
|
||||||
|
db.Alerts.Add(alert);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await svc.AcknowledgeAsync(alert.Id, "operator1");
|
||||||
|
|
||||||
|
Assert.True(result);
|
||||||
|
var updated = db.Alerts.First(a => a.Id == alert.Id);
|
||||||
|
Assert.True(updated.Acknowledged);
|
||||||
|
Assert.Equal("operator1", updated.AcknowledgedBy);
|
||||||
|
Assert.NotNull(updated.AcknowledgedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AcknowledgeAsync_ReturnsFalse_WhenNotFound()
|
||||||
|
{
|
||||||
|
using var db = TestHelpers.CreateDbContext();
|
||||||
|
var svc = CreateService(db);
|
||||||
|
|
||||||
|
var result = await svc.AcknowledgeAsync(999, "operator1");
|
||||||
|
|
||||||
|
Assert.False(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetActiveCountAsync_CountsUnacknowledged()
|
||||||
|
{
|
||||||
|
using var db = TestHelpers.CreateDbContext();
|
||||||
|
var svc = CreateService(db);
|
||||||
|
|
||||||
|
db.Alerts.AddRange(
|
||||||
|
new Alert { DeviceId = "D1", AlertType = "a", Severity = "info", Message = "m1",
|
||||||
|
Acknowledged = false, ResolvedAt = null },
|
||||||
|
new Alert { DeviceId = "D1", AlertType = "b", Severity = "info", Message = "m2",
|
||||||
|
Acknowledged = true, AcknowledgedBy = "op" },
|
||||||
|
new Alert { DeviceId = "D2", AlertType = "c", Severity = "info", Message = "m3",
|
||||||
|
Acknowledged = false, ResolvedAt = DateTime.UtcNow },
|
||||||
|
new Alert { DeviceId = "D2", AlertType = "d", Severity = "info", Message = "m4",
|
||||||
|
Acknowledged = false, ResolvedAt = null }
|
||||||
|
);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var count = await svc.GetActiveCountAsync();
|
||||||
|
|
||||||
|
// Only alerts where Acknowledged == false AND ResolvedAt == null
|
||||||
|
Assert.Equal(2, count);
|
||||||
|
}
|
||||||
|
}
|
||||||
143
console/tests/Tau.Acuvim.Console.Tests/CommandServiceTests.cs
Normal file
143
console/tests/Tau.Acuvim.Console.Tests/CommandServiceTests.cs
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Tau.Acuvim.Console.Models;
|
||||||
|
using Tau.Acuvim.Console.Services;
|
||||||
|
|
||||||
|
namespace Tau.Acuvim.Console.Tests;
|
||||||
|
|
||||||
|
public class CommandServiceTests
|
||||||
|
{
|
||||||
|
private CommandService CreateService(Data.AppDbContext db)
|
||||||
|
=> new(db, TestHelpers.CreateLogger<CommandService>());
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_CreatesCommand_WithPendingStatus()
|
||||||
|
{
|
||||||
|
using var db = TestHelpers.CreateDbContext();
|
||||||
|
var svc = CreateService(db);
|
||||||
|
|
||||||
|
var cmd = await svc.CreateAsync("DEV-C1", "reboot", null, "admin");
|
||||||
|
|
||||||
|
Assert.Equal("DEV-C1", cmd.DeviceId);
|
||||||
|
Assert.Equal("reboot", cmd.CommandName);
|
||||||
|
Assert.Equal("pending", cmd.Status);
|
||||||
|
Assert.Equal("admin", cmd.CreatedBy);
|
||||||
|
Assert.StartsWith("cmd-", cmd.RequestId);
|
||||||
|
Assert.Single(db.Commands);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MarkSentAsync_UpdatesStatus()
|
||||||
|
{
|
||||||
|
using var db = TestHelpers.CreateDbContext();
|
||||||
|
var svc = CreateService(db);
|
||||||
|
|
||||||
|
var cmd = await svc.CreateAsync("DEV-C2", "reset", null, "admin");
|
||||||
|
|
||||||
|
await svc.MarkSentAsync(cmd.RequestId);
|
||||||
|
|
||||||
|
var updated = db.Commands.First(c => c.RequestId == cmd.RequestId);
|
||||||
|
Assert.Equal("sent", updated.Status);
|
||||||
|
Assert.NotNull(updated.SentAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProcessResponseAsync_CompletesCommand()
|
||||||
|
{
|
||||||
|
using var db = TestHelpers.CreateDbContext();
|
||||||
|
var svc = CreateService(db);
|
||||||
|
|
||||||
|
var cmd = await svc.CreateAsync("DEV-C3", "ping", null, "admin");
|
||||||
|
await svc.MarkSentAsync(cmd.RequestId);
|
||||||
|
|
||||||
|
var responsePayload = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
request_id = cmd.RequestId,
|
||||||
|
status = "success",
|
||||||
|
data = "pong"
|
||||||
|
});
|
||||||
|
|
||||||
|
await svc.ProcessResponseAsync("DEV-C3", responsePayload);
|
||||||
|
|
||||||
|
var updated = db.Commands.First(c => c.RequestId == cmd.RequestId);
|
||||||
|
Assert.Equal("success", updated.Status);
|
||||||
|
Assert.NotNull(updated.Response);
|
||||||
|
Assert.NotNull(updated.CompletedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProcessResponseAsync_IgnoresUnknownRequestId()
|
||||||
|
{
|
||||||
|
using var db = TestHelpers.CreateDbContext();
|
||||||
|
var svc = CreateService(db);
|
||||||
|
|
||||||
|
var responsePayload = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
request_id = "cmd-unknown",
|
||||||
|
status = "success"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
await svc.ProcessResponseAsync("DEV-C4", responsePayload);
|
||||||
|
|
||||||
|
Assert.Empty(db.Commands);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BuildCommandPayload_IncludesCommandAndRequestId()
|
||||||
|
{
|
||||||
|
using var db = TestHelpers.CreateDbContext();
|
||||||
|
var svc = CreateService(db);
|
||||||
|
|
||||||
|
var cmd = await svc.CreateAsync("DEV-C5", "firmware_update", null, "admin");
|
||||||
|
|
||||||
|
var json = svc.BuildCommandPayload(cmd);
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
Assert.Equal("firmware_update", root.GetProperty("cmd").GetString());
|
||||||
|
Assert.Equal(cmd.RequestId, root.GetProperty("request_id").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TimeoutStalCommandsAsync_TimesOutOldCommands()
|
||||||
|
{
|
||||||
|
using var db = TestHelpers.CreateDbContext();
|
||||||
|
var svc = CreateService(db);
|
||||||
|
|
||||||
|
// Create a command and mark it as sent with an old SentAt time
|
||||||
|
var cmd = new Command
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
DeviceId = "DEV-C6",
|
||||||
|
RequestId = $"cmd-{Guid.NewGuid():N}",
|
||||||
|
CommandName = "check",
|
||||||
|
Status = "sent",
|
||||||
|
SentAt = DateTime.UtcNow.AddSeconds(-120)
|
||||||
|
};
|
||||||
|
db.Commands.Add(cmd);
|
||||||
|
|
||||||
|
// Also add a recent sent command that should NOT be timed out
|
||||||
|
var recentCmd = new Command
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
DeviceId = "DEV-C6",
|
||||||
|
RequestId = $"cmd-{Guid.NewGuid():N}",
|
||||||
|
CommandName = "check2",
|
||||||
|
Status = "sent",
|
||||||
|
SentAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
db.Commands.Add(recentCmd);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var count = await svc.TimeoutStalCommandsAsync(timeoutSeconds: 60);
|
||||||
|
|
||||||
|
Assert.Equal(1, count);
|
||||||
|
|
||||||
|
var timedOut = db.Commands.First(c => c.RequestId == cmd.RequestId);
|
||||||
|
Assert.Equal("timeout", timedOut.Status);
|
||||||
|
Assert.NotNull(timedOut.CompletedAt);
|
||||||
|
|
||||||
|
var stillSent = db.Commands.First(c => c.RequestId == recentCmd.RequestId);
|
||||||
|
Assert.Equal("sent", stillSent.Status);
|
||||||
|
}
|
||||||
|
}
|
||||||
129
console/tests/Tau.Acuvim.Console.Tests/DeviceServiceTests.cs
Normal file
129
console/tests/Tau.Acuvim.Console.Tests/DeviceServiceTests.cs
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Tau.Acuvim.Console.DTOs;
|
||||||
|
using Tau.Acuvim.Console.Models;
|
||||||
|
using Tau.Acuvim.Console.Services;
|
||||||
|
|
||||||
|
namespace Tau.Acuvim.Console.Tests;
|
||||||
|
|
||||||
|
public class DeviceServiceTests
|
||||||
|
{
|
||||||
|
private DeviceService CreateService(Data.AppDbContext db)
|
||||||
|
=> new(db, TestHelpers.CreateLogger<DeviceService>());
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RegisterAsync_CreatesNewDevice()
|
||||||
|
{
|
||||||
|
using var db = TestHelpers.CreateDbContext();
|
||||||
|
var svc = CreateService(db);
|
||||||
|
|
||||||
|
var payload = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
device_id = "DEV-001",
|
||||||
|
mac = "AA:BB:CC:DD:EE:FF",
|
||||||
|
firmware = "1.0.0",
|
||||||
|
hardware = "rev-A",
|
||||||
|
imei = "123456789012345",
|
||||||
|
ts = 1700000000L
|
||||||
|
});
|
||||||
|
|
||||||
|
var device = await svc.RegisterAsync(payload);
|
||||||
|
|
||||||
|
Assert.Equal("DEV-001", device.DeviceId);
|
||||||
|
Assert.Equal("AA:BB:CC:DD:EE:FF", device.MacAddress);
|
||||||
|
Assert.Equal("1.0.0", device.FirmwareVersion);
|
||||||
|
Assert.Equal("rev-A", device.Hardware);
|
||||||
|
Assert.Equal("123456789012345", device.Imei);
|
||||||
|
Assert.Equal("online", device.Status);
|
||||||
|
Assert.Single(db.Devices);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RegisterAsync_UpdatesExistingDevice()
|
||||||
|
{
|
||||||
|
using var db = TestHelpers.CreateDbContext();
|
||||||
|
var svc = CreateService(db);
|
||||||
|
|
||||||
|
var payload1 = JsonSerializer.Serialize(new { device_id = "DEV-001", firmware = "1.0.0" });
|
||||||
|
await svc.RegisterAsync(payload1);
|
||||||
|
|
||||||
|
var payload2 = JsonSerializer.Serialize(new { device_id = "DEV-001", firmware = "2.0.0" });
|
||||||
|
var device = await svc.RegisterAsync(payload2);
|
||||||
|
|
||||||
|
Assert.Equal("2.0.0", device.FirmwareVersion);
|
||||||
|
Assert.Single(db.Devices);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAllAsync_FiltersByStatus()
|
||||||
|
{
|
||||||
|
using var db = TestHelpers.CreateDbContext();
|
||||||
|
var svc = CreateService(db);
|
||||||
|
|
||||||
|
db.Devices.AddRange(
|
||||||
|
new Device { Id = Guid.NewGuid(), DeviceId = "D1", Status = "online" },
|
||||||
|
new Device { Id = Guid.NewGuid(), DeviceId = "D2", Status = "offline" },
|
||||||
|
new Device { Id = Guid.NewGuid(), DeviceId = "D3", Status = "online" }
|
||||||
|
);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await svc.GetAllAsync(status: "online", search: null, page: 1, pageSize: 10);
|
||||||
|
|
||||||
|
Assert.Equal(2, result.Count);
|
||||||
|
Assert.All(result, r => Assert.Equal("online", r.Status));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAllAsync_SearchesByName()
|
||||||
|
{
|
||||||
|
using var db = TestHelpers.CreateDbContext();
|
||||||
|
var svc = CreateService(db);
|
||||||
|
|
||||||
|
db.Devices.AddRange(
|
||||||
|
new Device { Id = Guid.NewGuid(), DeviceId = "D1", Name = "Sensor Alpha" },
|
||||||
|
new Device { Id = Guid.NewGuid(), DeviceId = "D2", Name = "Sensor Beta" },
|
||||||
|
new Device { Id = Guid.NewGuid(), DeviceId = "D3", Name = "Motor Gamma" }
|
||||||
|
);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await svc.GetAllAsync(status: null, search: "Sensor", page: 1, pageSize: 10);
|
||||||
|
|
||||||
|
Assert.Equal(2, result.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByDeviceIdAsync_ReturnsNull_WhenNotFound()
|
||||||
|
{
|
||||||
|
using var db = TestHelpers.CreateDbContext();
|
||||||
|
var svc = CreateService(db);
|
||||||
|
|
||||||
|
var result = await svc.GetByDeviceIdAsync("NONEXISTENT");
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteAsync_RemovesDevice()
|
||||||
|
{
|
||||||
|
using var db = TestHelpers.CreateDbContext();
|
||||||
|
var svc = CreateService(db);
|
||||||
|
|
||||||
|
db.Devices.Add(new Device { Id = Guid.NewGuid(), DeviceId = "DEV-DEL" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await svc.DeleteAsync("DEV-DEL");
|
||||||
|
|
||||||
|
Assert.True(result);
|
||||||
|
Assert.Empty(db.Devices);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteAsync_ReturnsFalse_WhenNotFound()
|
||||||
|
{
|
||||||
|
using var db = TestHelpers.CreateDbContext();
|
||||||
|
var svc = CreateService(db);
|
||||||
|
|
||||||
|
var result = await svc.DeleteAsync("NONEXISTENT");
|
||||||
|
|
||||||
|
Assert.False(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\Tau.Acuvim.Console\Tau.Acuvim.Console.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
122
console/tests/Tau.Acuvim.Console.Tests/TelemetryServiceTests.cs
Normal file
122
console/tests/Tau.Acuvim.Console.Tests/TelemetryServiceTests.cs
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Tau.Acuvim.Console.Models;
|
||||||
|
using Tau.Acuvim.Console.Services;
|
||||||
|
|
||||||
|
namespace Tau.Acuvim.Console.Tests;
|
||||||
|
|
||||||
|
public class TelemetryServiceTests
|
||||||
|
{
|
||||||
|
private TelemetryService CreateService(Data.AppDbContext db)
|
||||||
|
=> new(db, TestHelpers.CreateLogger<TelemetryService>());
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task IngestAsync_CreatesTelemetryRecord()
|
||||||
|
{
|
||||||
|
using var db = TestHelpers.CreateDbContext();
|
||||||
|
var svc = CreateService(db);
|
||||||
|
|
||||||
|
db.Devices.Add(new Device { Id = Guid.NewGuid(), DeviceId = "DEV-T1" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var payload = JsonSerializer.Serialize(new { ts = 1700000000L, conn = "wifi", voltage = 3.3 });
|
||||||
|
await svc.IngestAsync("DEV-T1", payload);
|
||||||
|
|
||||||
|
Assert.Single(db.TelemetryRecords);
|
||||||
|
var record = db.TelemetryRecords.First();
|
||||||
|
Assert.Equal("DEV-T1", record.DeviceId);
|
||||||
|
Assert.Equal("live", record.Source);
|
||||||
|
Assert.Equal("wifi", record.Connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task IngestAsync_UpdatesDeviceLastTelemetry()
|
||||||
|
{
|
||||||
|
using var db = TestHelpers.CreateDbContext();
|
||||||
|
var svc = CreateService(db);
|
||||||
|
|
||||||
|
var device = new Device { Id = Guid.NewGuid(), DeviceId = "DEV-T2", LastTelemetry = null };
|
||||||
|
db.Devices.Add(device);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var payload = JsonSerializer.Serialize(new { ts = 1700000000L });
|
||||||
|
await svc.IngestAsync("DEV-T2", payload);
|
||||||
|
|
||||||
|
var updated = db.Devices.First(d => d.DeviceId == "DEV-T2");
|
||||||
|
Assert.NotNull(updated.LastTelemetry);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task IngestAsync_ThrowsOnInvalidJson()
|
||||||
|
{
|
||||||
|
using var db = TestHelpers.CreateDbContext();
|
||||||
|
var svc = CreateService(db);
|
||||||
|
|
||||||
|
await Assert.ThrowsAnyAsync<JsonException>(() => svc.IngestAsync("DEV-X", "not-json!!!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetLatestAsync_ReturnsLatestRecord()
|
||||||
|
{
|
||||||
|
using var db = TestHelpers.CreateDbContext();
|
||||||
|
var svc = CreateService(db);
|
||||||
|
|
||||||
|
db.TelemetryRecords.AddRange(
|
||||||
|
new TelemetryRecord
|
||||||
|
{
|
||||||
|
DeviceId = "DEV-T3",
|
||||||
|
Timestamp = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||||
|
Data = JsonDocument.Parse("{\"v\":1}"),
|
||||||
|
ReceivedAt = DateTime.UtcNow
|
||||||
|
},
|
||||||
|
new TelemetryRecord
|
||||||
|
{
|
||||||
|
DeviceId = "DEV-T3",
|
||||||
|
Timestamp = new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||||
|
Data = JsonDocument.Parse("{\"v\":2}"),
|
||||||
|
ReceivedAt = DateTime.UtcNow
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var latest = await svc.GetLatestAsync("DEV-T3");
|
||||||
|
|
||||||
|
Assert.NotNull(latest);
|
||||||
|
Assert.Equal(new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc), latest.Timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetCountTodayAsync_CountsRecordsFromToday()
|
||||||
|
{
|
||||||
|
using var db = TestHelpers.CreateDbContext();
|
||||||
|
var svc = CreateService(db);
|
||||||
|
|
||||||
|
db.TelemetryRecords.AddRange(
|
||||||
|
new TelemetryRecord
|
||||||
|
{
|
||||||
|
DeviceId = "DEV-T4",
|
||||||
|
Timestamp = DateTime.UtcNow,
|
||||||
|
Data = JsonDocument.Parse("{}"),
|
||||||
|
ReceivedAt = DateTime.UtcNow
|
||||||
|
},
|
||||||
|
new TelemetryRecord
|
||||||
|
{
|
||||||
|
DeviceId = "DEV-T4",
|
||||||
|
Timestamp = DateTime.UtcNow,
|
||||||
|
Data = JsonDocument.Parse("{}"),
|
||||||
|
ReceivedAt = DateTime.UtcNow
|
||||||
|
},
|
||||||
|
new TelemetryRecord
|
||||||
|
{
|
||||||
|
DeviceId = "DEV-T4",
|
||||||
|
Timestamp = DateTime.UtcNow.AddDays(-2),
|
||||||
|
Data = JsonDocument.Parse("{}"),
|
||||||
|
ReceivedAt = DateTime.UtcNow.AddDays(-2)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var count = await svc.GetCountTodayAsync();
|
||||||
|
|
||||||
|
Assert.Equal(2, count);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user