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:
Renier Forster 2026-05-16 19:05:32 +02:00
commit 84a0668c54
154 changed files with 24029 additions and 0 deletions

40
.gitignore vendored Normal file
View 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
View 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
View 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"]

View 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:

View 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:

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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>
);
}

View 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),
};

View 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),
};

View 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;

View 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}`),
},
};

View 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),
};

View 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),
};

View 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 }} />
</>
);
}

View 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>
);
}

View 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>
);
}

View 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} &middot; {timeAgo(item.createdAt)}</Typography.Text>}
/>
</List.Item>
)}
locale={{ emptyText: 'No recent alerts' }}
/>
</Card>
);
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View File

@ -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} />;
}

View 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"
/>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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} />;
}

View 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>
);
}

View 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>
);
}

View 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 &amp; 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>
);
}

View 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>
);
}

View 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 };
}

View 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(),
});
}

View 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]);
}

View 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,
});
}

View 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>,
);

View 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}
/>
</>
);
}

View 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>
</>
);
}

View 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)} />
</>
);
}

View 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}
/>
</>
);
}

View 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)} />}
</>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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;
}

View 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;
}

View 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;
}

View 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 } };
}

View 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;

View 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
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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"]
}

View 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',
},
},
});

View 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

View 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
View File

@ -0,0 +1,5 @@
listener 1883
allow_anonymous true
listener 9001
protocol websockets

38
console/nginx.conf Normal file
View 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;
}
}

View 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);

View 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);

View 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);

View 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);

View 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();
});
}
}

View 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();
});
}
}

View 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();
}
}

View File

@ -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));
});
}
}

View 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)));
});
}
}

View File

@ -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();
}
}

View 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();
});
}
}

View File

@ -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);
});
}
}

View 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);
}
}

View 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
}
}
}

View File

@ -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");
}
}
}

View File

@ -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
}
}
}

View 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; }
}

View 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; }
}

View 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; } = [];
}

View 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; } = [];
}

View 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;
}

View 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; }
}

View 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; }
}

View 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();
}

View File

@ -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"
}
}
}
}

View 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();
}
}

View 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);
}
}

View 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);
}
}

View 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;
}
}

View 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;
}
}

View 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();
}
}

View 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);
}
}

View 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;
}
}

View 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>

View File

@ -0,0 +1,6 @@
@Tau.Acuvim.Console_HostAddress = http://localhost:5295
GET {{Tau.Acuvim.Console_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View 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": "*"
}

View 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": "*"
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View File

@ -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>

View 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