feat: implement Fury Dashboard with sensor data visualization and API integration

This commit is contained in:
2026-04-12 22:38:10 +08:00
parent e1c523aa99
commit 9bc593c9a7
7 changed files with 786 additions and 17 deletions
+8
View File
@@ -111,6 +111,14 @@ VITE_API_URL=https://api.my-domain.example.com
Then, when you run the frontend, it will use that URL as the base URL for the API.
The home dashboard also reads public sensor data from `https://api.furyhawk.lol`. Those requests are routed through the frontend at `/fury-api` in both Vite dev mode and nginx so the browser does not hit the remote origin directly.
Example proxied route:
```text
/fury-api/temperature/search?limit=12
```
## Code Structure
The frontend code is structured as follows:
+9
View File
@@ -1,6 +1,15 @@
server {
listen 80;
location /fury-api/ {
proxy_pass https://api.furyhawk.lol/;
proxy_http_version 1.1;
proxy_ssl_server_name on;
proxy_set_header Host api.furyhawk.lol;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
root /usr/share/nginx/html;
index index.html index.htm;
@@ -0,0 +1,347 @@
import {
Badge,
Box,
Button,
Card,
Container,
Flex,
Heading,
HStack,
SimpleGrid,
Skeleton,
Table,
Text,
VStack,
} from "@chakra-ui/react"
import { useQuery } from "@tanstack/react-query"
import useAuth from "@/hooks/useAuth"
import {
type FuryDashboardData,
type FuryMetricKey,
fetchFuryDashboard,
} from "./furyDashboardApi"
import SensorTrendChart from "./SensorTrendChart"
const metricAccent: Record<FuryMetricKey, string> = {
humidity: "teal.500",
pressure: "orange.500",
temperature: "red.500",
}
const formatRelativeTime = (isoDate: string) => {
const deltaInMinutes = Math.max(
0,
Math.round((Date.now() - new Date(isoDate).getTime()) / 60_000),
)
if (deltaInMinutes < 1) {
return "just now"
}
if (deltaInMinutes < 60) {
return `${deltaInMinutes} min ago`
}
const hours = Math.floor(deltaInMinutes / 60)
const minutes = deltaInMinutes % 60
if (hours < 24) {
return minutes === 0 ? `${hours} hr ago` : `${hours} hr ${minutes} min ago`
}
const days = Math.floor(hours / 24)
return `${days} day${days === 1 ? "" : "s"} ago`
}
const formatTimestamp = (isoDate: string) => {
return new Intl.DateTimeFormat(undefined, {
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
month: "short",
}).format(new Date(isoDate))
}
const formatValue = (value: number | null, unit: string) => {
if (value === null || Number.isNaN(value)) {
return "--"
}
const digits = unit === "hPa" ? 2 : 1
return `${value.toFixed(digits)}${unit}`
}
const formatDelta = (current: number | undefined, previous: number | undefined, unit: string) => {
if (current === undefined || previous === undefined) {
return "No previous sample"
}
const delta = current - previous
const sign = delta > 0 ? "+" : ""
const digits = unit === "hPa" ? 2 : 1
if (delta === 0) {
return `Unchanged from last sample`
}
return `${sign}${delta.toFixed(digits)}${unit} vs last sample`
}
const buildComfortSummary = (dashboard: FuryDashboardData) => {
const temperature = dashboard.metrics.temperature.latest?.value
const humidity = dashboard.metrics.humidity.latest?.value
const pressure = dashboard.metrics.pressure.latest?.value
if (
temperature === undefined ||
humidity === undefined ||
pressure === undefined
) {
return "Waiting for enough sensor data to describe the current room state."
}
if (temperature >= 30 || humidity >= 75) {
return "The room is running warm and humid right now. Ventilation or cooling would help."
}
if (temperature <= 24 && humidity <= 60) {
return "Conditions look cool and dry."
}
if (pressure >= 1008) {
return "Pressure is relatively steady while the room stays within a typical comfort band."
}
return "Conditions are stable, with moderate temperature and humidity."
}
function DashboardLoading() {
return (
<Container maxW="full" py={8}>
<VStack align="stretch" gap={6}>
<Card.Root>
<Card.Body gap={4}>
<Skeleton height="8" width="20rem" />
<Skeleton height="4" width="28rem" />
<Skeleton height="4" width="16rem" />
</Card.Body>
</Card.Root>
<SimpleGrid columns={{ base: 1, lg: 3 }} gap={4}>
{Array.from({ length: 3 }).map((_, index) => (
<Card.Root key={`metric-skeleton-${index}`}>
<Card.Body gap={3}>
<Skeleton height="4" width="8rem" />
<Skeleton height="10" width="10rem" />
<Skeleton height="4" width="12rem" />
<Skeleton height="24" />
</Card.Body>
</Card.Root>
))}
</SimpleGrid>
</VStack>
</Container>
)
}
function FuryDashboard() {
const { user: currentUser } = useAuth()
const { data, error, isLoading, refetch, isFetching } = useQuery({
queryKey: ["fury-dashboard"],
queryFn: fetchFuryDashboard,
refetchInterval: 60_000,
retry: 1,
})
if (isLoading) {
return <DashboardLoading />
}
if (error || !data) {
return (
<Container maxW="full" py={8}>
<Card.Root>
<Card.Body gap={4}>
<Heading size="lg">Environment Dashboard</Heading>
<Text color="red.500">
Sensor data could not be loaded from the Furyhawk API proxy.
</Text>
<Button alignSelf="flex-start" onClick={() => refetch()}>
Retry
</Button>
</Card.Body>
</Card.Root>
</Container>
)
}
const latestSampleTimes = Object.values(data.metrics)
.map((metric) => metric.latest?.updateTime)
.filter((value): value is string => Boolean(value))
.sort((left, right) =>
new Date(right).getTime() - new Date(left).getTime(),
)
const newestSampleTime = latestSampleTimes[0] ?? data.generatedAt
const welcomeName = currentUser?.full_name || currentUser?.email || "there"
return (
<Container maxW="full" py={8}>
<VStack align="stretch" gap={6}>
<Card.Root overflow="hidden">
<Card.Body
bgGradient="linear(to-r, gray.900, gray.700)"
color="white"
gap={4}
p={{ base: 5, md: 7 }}
>
<Flex
align={{ base: "flex-start", md: "center" }}
direction={{ base: "column", md: "row" }}
gap={4}
justify="space-between"
>
<Box>
<Text fontSize="sm" textTransform="uppercase" opacity={0.8}>
Hi, {welcomeName}
</Text>
<Heading size="xl" mt={1}>
Environment Dashboard
</Heading>
<Text mt={2} maxW="3xl" opacity={0.85}>
Live temperature, humidity, and pressure samples proxied from
api.furyhawk.lol.
</Text>
</Box>
<HStack flexWrap="wrap" gap={3}>
<Badge colorScheme="green">60s refresh</Badge>
<Badge colorScheme="blue">
Latest sample {formatRelativeTime(newestSampleTime)}
</Badge>
{isFetching ? <Badge colorScheme="orange">Refreshing</Badge> : null}
</HStack>
</Flex>
<Text maxW="4xl">{buildComfortSummary(data)}</Text>
</Card.Body>
</Card.Root>
<SimpleGrid columns={{ base: 1, lg: 3 }} gap={4}>
{Object.values(data.metrics).map((metric) => (
<Card.Root key={metric.key}>
<Card.Body gap={5}>
<Flex justify="space-between" gap={4}>
<Box>
<Text color="gray.500" fontSize="sm" textTransform="uppercase">
{metric.label}
</Text>
<Heading size="2xl" mt={2} color={metricAccent[metric.key]}>
{metric.latest?.rawValue ?? "--"}
</Heading>
</Box>
<Badge alignSelf="flex-start" colorScheme="gray">
{metric.summary.count} pts / 24h
</Badge>
</Flex>
<Box>
<Text fontSize="sm" color="gray.500">
{metric.description}
</Text>
<Text fontSize="sm" mt={1}>
{formatDelta(
metric.latest?.value,
metric.previous?.value,
metric.unit,
)}
</Text>
<Text fontSize="sm" color="gray.500" mt={1}>
Updated {metric.latest ? formatRelativeTime(metric.latest.updateTime) : "--"}
</Text>
</Box>
<SimpleGrid columns={3} gap={3}>
<Box bg="gray.50" borderRadius="md" p={3}>
<Text color="gray.500" fontSize="xs" textTransform="uppercase">
Average
</Text>
<Text fontWeight="semibold" mt={1}>
{formatValue(metric.summary.average, metric.unit)}
</Text>
</Box>
<Box bg="gray.50" borderRadius="md" p={3}>
<Text color="gray.500" fontSize="xs" textTransform="uppercase">
Low
</Text>
<Text fontWeight="semibold" mt={1}>
{formatValue(metric.summary.min, metric.unit)}
</Text>
</Box>
<Box bg="gray.50" borderRadius="md" p={3}>
<Text color="gray.500" fontSize="xs" textTransform="uppercase">
High
</Text>
<Text fontWeight="semibold" mt={1}>
{formatValue(metric.summary.max, metric.unit)}
</Text>
</Box>
</SimpleGrid>
<SensorTrendChart
color={metricAccent[metric.key]}
readings={metric.readings}
title={`${metric.label} Trend`}
unit={metric.unit}
/>
</Card.Body>
</Card.Root>
))}
</SimpleGrid>
<Card.Root>
<Card.Body gap={4}>
<Flex
align={{ base: "flex-start", md: "center" }}
direction={{ base: "column", md: "row" }}
gap={3}
justify="space-between"
>
<Box>
<Heading size="md">Recent Samples</Heading>
<Text color="gray.500" mt={1}>
Latest synchronized sensor snapshots from the external telemetry API.
</Text>
</Box>
<Text color="gray.500" fontSize="sm">
Last fetch {formatTimestamp(data.generatedAt)}
</Text>
</Flex>
<Table.Root size={{ base: "sm", md: "md" }}>
<Table.Header>
<Table.Row>
<Table.ColumnHeader w="14rem">Timestamp</Table.ColumnHeader>
<Table.ColumnHeader>Temperature</Table.ColumnHeader>
<Table.ColumnHeader>Humidity</Table.ColumnHeader>
<Table.ColumnHeader>Pressure</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{data.recentSamples.map((sample) => (
<Table.Row key={sample.updateTime}>
<Table.Cell>{formatTimestamp(sample.updateTime)}</Table.Cell>
<Table.Cell>{sample.temperature ?? "--"}</Table.Cell>
<Table.Cell>{sample.humidity ?? "--"}</Table.Cell>
<Table.Cell>{sample.pressure ?? "--"}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</Card.Body>
</Card.Root>
</VStack>
</Container>
)
}
export default FuryDashboard
@@ -0,0 +1,215 @@
import { Box, Flex, Text } from "@chakra-ui/react"
import type { FuryReading } from "./furyDashboardApi"
const DAY_IN_MS = 24 * 60 * 60 * 1000
const CHART_WIDTH = 640
const CHART_HEIGHT = 220
const CHART_PADDING_X = 18
const CHART_PADDING_Y = 18
const MAX_POINTS = 72
interface SensorTrendChartProps {
color: string
readings: FuryReading[]
title: string
unit: string
}
const formatAxisValue = (value: number, unit: string) => {
const digits = unit === "hPa" ? 1 : 0
return `${value.toFixed(digits)}${unit}`
}
const formatTimeLabel = (isoDate: string) => {
return new Intl.DateTimeFormat(undefined, {
hour: "2-digit",
minute: "2-digit",
}).format(new Date(isoDate))
}
const getLastDayReadings = (readings: FuryReading[]) => {
const since = Date.now() - DAY_IN_MS
return readings
.filter((reading) => new Date(reading.updateTime).getTime() >= since)
.sort(
(left, right) =>
new Date(left.updateTime).getTime() - new Date(right.updateTime).getTime(),
)
}
const downsampleReadings = (readings: FuryReading[]) => {
if (readings.length <= MAX_POINTS) {
return readings
}
const step = Math.ceil(readings.length / MAX_POINTS)
const sampled = readings.filter((_, index) => index % step === 0)
const lastReading = readings[readings.length - 1]
if (sampled[sampled.length - 1]?.id !== lastReading?.id && lastReading) {
sampled.push(lastReading)
}
return sampled
}
function SensorTrendChart({ color, readings, title, unit }: SensorTrendChartProps) {
const dayReadings = downsampleReadings(getLastDayReadings(readings))
if (dayReadings.length < 2) {
return (
<Box
bg="gray.50"
borderRadius="lg"
minH="220px"
p={4}
borderWidth="1px"
borderColor="gray.100"
>
<Text fontWeight="medium">{title}</Text>
<Text color="gray.500" fontSize="sm" mt={2}>
Not enough samples yet to draw a 24-hour chart.
</Text>
</Box>
)
}
const values = dayReadings.map((reading) => reading.value)
const minValue = Math.min(...values)
const maxValue = Math.max(...values)
const valueRange = maxValue - minValue || 1
const innerWidth = CHART_WIDTH - CHART_PADDING_X * 2
const innerHeight = CHART_HEIGHT - CHART_PADDING_Y * 2
const points = dayReadings.map((reading, index) => {
const x =
CHART_PADDING_X +
(index / Math.max(dayReadings.length - 1, 1)) * innerWidth
const normalizedValue = (reading.value - minValue) / valueRange
const y = CHART_HEIGHT - CHART_PADDING_Y - normalizedValue * innerHeight
return {
reading,
x,
y,
}
})
const linePoints = points.map((point) => `${point.x},${point.y}`).join(" ")
const areaPath = [
`M ${points[0]?.x} ${CHART_HEIGHT - CHART_PADDING_Y}`,
...points.map((point) => `L ${point.x} ${point.y}`),
`L ${points[points.length - 1]?.x} ${CHART_HEIGHT - CHART_PADDING_Y}`,
"Z",
].join(" ")
const midIndex = Math.floor(points.length / 2)
const axisLevels = [maxValue, (maxValue + minValue) / 2, minValue]
return (
<Box>
<Flex justify="space-between" align="flex-start" gap={3} mb={3}>
<Box>
<Text fontWeight="medium">{title}</Text>
<Text color="gray.500" fontSize="sm">
Last 24 hours, sampled every few minutes
</Text>
</Box>
<Box textAlign="right">
<Text fontSize="xs" color="gray.500" textTransform="uppercase">
Range
</Text>
<Text fontSize="sm" fontWeight="medium">
{formatAxisValue(minValue, unit)} to {formatAxisValue(maxValue, unit)}
</Text>
</Box>
</Flex>
<Box
bg="gray.50"
borderRadius="lg"
borderWidth="1px"
borderColor="gray.100"
overflow="hidden"
p={3}
>
<svg
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
width="100%"
height="220"
role="img"
aria-label={`${title} trend for the last 24 hours`}
preserveAspectRatio="none"
>
<defs>
<linearGradient id={`trend-fill-${title}`} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity="0.28" />
<stop offset="100%" stopColor={color} stopOpacity="0.03" />
</linearGradient>
</defs>
{axisLevels.map((level) => {
const normalizedValue = (level - minValue) / valueRange
const y = CHART_HEIGHT - CHART_PADDING_Y - normalizedValue * innerHeight
return (
<g key={`${title}-${level}`}>
<line
x1={CHART_PADDING_X}
x2={CHART_WIDTH - CHART_PADDING_X}
y1={y}
y2={y}
stroke="rgba(148, 163, 184, 0.28)"
strokeDasharray="4 6"
/>
<text
x={CHART_WIDTH - CHART_PADDING_X}
y={y - 6}
textAnchor="end"
fill="#64748b"
fontSize="12"
>
{formatAxisValue(level, unit)}
</text>
</g>
)
})}
<path d={areaPath} fill={`url(#trend-fill-${title})`} />
<polyline
fill="none"
points={linePoints}
stroke={color}
strokeLinejoin="round"
strokeLinecap="round"
strokeWidth="3"
/>
{points.map((point, index) => {
const isEdge = index === 0 || index === midIndex || index === points.length - 1
if (!isEdge) {
return null
}
return (
<g key={`${point.reading.id}-${index}`}>
<circle cx={point.x} cy={point.y} fill={color} r="4" />
<circle cx={point.x} cy={point.y} fill="white" r="2" />
</g>
)
})}
</svg>
<Flex justify="space-between" mt={2} color="gray.500" fontSize="xs">
<Text>{formatTimeLabel(dayReadings[0].updateTime)}</Text>
<Text>{formatTimeLabel(dayReadings[midIndex].updateTime)}</Text>
<Text>{formatTimeLabel(dayReadings[dayReadings.length - 1].updateTime)}</Text>
</Flex>
</Box>
</Box>
)
}
export default SensorTrendChart
@@ -0,0 +1,196 @@
import axios from "axios"
const furyApi = axios.create({
baseURL: "/fury-api",
timeout: 15_000,
})
const SENSOR_LIMIT = 288
const DAY_IN_MS = 24 * 60 * 60 * 1000
const metricConfig = {
humidity: {
description: "Indoor moisture trend",
field: "humidity",
label: "Humidity",
path: "/humidity/search",
unit: "%",
},
pressure: {
description: "Barometric pressure trend",
field: "pressure",
label: "Pressure",
path: "/pressure/search",
unit: "hPa",
},
temperature: {
description: "Ambient temperature trend",
field: "temperature",
label: "Temperature",
path: "/temperature/search",
unit: "C",
},
} as const
export type FuryMetricKey = keyof typeof metricConfig
type MetricField = (typeof metricConfig)[FuryMetricKey]["field"]
interface FuryApiItem {
id: number
humidity?: string
pressure?: string
temperature?: string
update_time: string
}
export interface FuryReading {
id: number
rawValue: string
updateTime: string
value: number
}
interface FurySummary {
average: number | null
count: number
max: number | null
min: number | null
}
export interface FuryMetricSnapshot {
description: string
key: FuryMetricKey
label: string
latest: FuryReading | null
previous: FuryReading | null
readings: FuryReading[]
summary: FurySummary
unit: string
}
export interface FuryRecentSample {
humidity: string | null
pressure: string | null
temperature: string | null
updateTime: string
}
export interface FuryDashboardData {
generatedAt: string
metrics: Record<FuryMetricKey, FuryMetricSnapshot>
recentSamples: FuryRecentSample[]
}
const parseMeasurement = (rawValue: string) => {
const match = rawValue.match(/-?\d+(?:\.\d+)?/)
return match ? Number(match[0]) : Number.NaN
}
const calculateSummary = (readings: FuryReading[]) => {
const since = Date.now() - DAY_IN_MS
const recentValues = readings
.filter((reading) => new Date(reading.updateTime).getTime() >= since)
.map((reading) => reading.value)
.filter((value) => Number.isFinite(value))
if (recentValues.length === 0) {
return {
average: null,
count: 0,
max: null,
min: null,
}
}
const total = recentValues.reduce((sum, value) => sum + value, 0)
return {
average: total / recentValues.length,
count: recentValues.length,
max: Math.max(...recentValues),
min: Math.min(...recentValues),
}
}
const toReadings = (items: FuryApiItem[], field: MetricField) => {
return items
.map((item) => {
const rawValue = item[field]
if (!rawValue) {
return null
}
return {
id: item.id,
rawValue,
updateTime: item.update_time,
value: parseMeasurement(rawValue),
}
})
.filter((item): item is FuryReading => item !== null)
}
const fetchMetric = async <TMetricKey extends FuryMetricKey>(
metric: TMetricKey,
limit = SENSOR_LIMIT,
): Promise<FuryMetricSnapshot> => {
const config = metricConfig[metric]
const response = await furyApi.get<FuryApiItem[]>(config.path, {
params: { limit },
})
const readings = toReadings(response.data, config.field)
return {
description: config.description,
key: metric,
label: config.label,
latest: readings[0] ?? null,
previous: readings[1] ?? null,
readings,
summary: calculateSummary(readings),
unit: config.unit,
}
}
const buildRecentSamples = (
temperature: FuryReading[],
humidity: FuryReading[],
pressure: FuryReading[],
) => {
const sampleCount = Math.min(temperature.length, humidity.length, pressure.length, 8)
return Array.from({ length: sampleCount }, (_, index) => ({
humidity: humidity[index]?.rawValue ?? null,
pressure: pressure[index]?.rawValue ?? null,
temperature: temperature[index]?.rawValue ?? null,
updateTime:
temperature[index]?.updateTime ||
humidity[index]?.updateTime ||
pressure[index]?.updateTime ||
new Date().toISOString(),
}))
}
export const fetchFuryDashboard = async (): Promise<FuryDashboardData> => {
const [temperature, humidity, pressure] = await Promise.all([
fetchMetric("temperature"),
fetchMetric("humidity"),
fetchMetric("pressure"),
])
return {
generatedAt: new Date().toISOString(),
metrics: {
humidity,
pressure,
temperature,
},
recentSamples: buildRecentSamples(
temperature.readings,
humidity.readings,
pressure.readings,
),
}
}
+2 -16
View File
@@ -1,25 +1,11 @@
import { Box, Container, Text } from "@chakra-ui/react"
import { createFileRoute } from "@tanstack/react-router"
import useAuth from "@/hooks/useAuth"
import FuryDashboard from "@/components/dashboard/FuryDashboard"
export const Route = createFileRoute("/_layout/")({
component: Dashboard,
})
function Dashboard() {
const { user: currentUser } = useAuth()
return (
<>
<Container maxW="full">
<Box pt={12} m={4}>
<Text fontSize="2xl" truncate maxW="sm">
Hi, {currentUser?.full_name || currentUser?.email} 👋🏼
</Text>
<Text>Welcome back, nice to see you again!</Text>
</Box>
</Container>
</>
)
return <FuryDashboard />
}
+9 -1
View File
@@ -22,5 +22,13 @@ export default defineConfig({
"localhost:5175",
"dev.furyhawk.lol",
],
proxy: {
"/fury-api": {
target: "https://api.furyhawk.lol",
changeOrigin: true,
secure: true,
rewrite: (path) => path.replace(/^\/fury-api/, ""),
},
},
},
});
})