diff --git a/frontend/README.md b/frontend/README.md index bbb73cb..5eba284 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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: diff --git a/frontend/nginx.conf b/frontend/nginx.conf index ba4d9aa..b11efee 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -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; diff --git a/frontend/src/components/dashboard/FuryDashboard.tsx b/frontend/src/components/dashboard/FuryDashboard.tsx new file mode 100644 index 0000000..630af41 --- /dev/null +++ b/frontend/src/components/dashboard/FuryDashboard.tsx @@ -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 = { + 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 ( + + + + + + + + + + + {Array.from({ length: 3 }).map((_, index) => ( + + + + + + + + + ))} + + + + ) +} + +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 + } + + if (error || !data) { + return ( + + + + Environment Dashboard + + Sensor data could not be loaded from the Furyhawk API proxy. + + + + + + ) + } + + 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 ( + + + + + + + + Hi, {welcomeName} + + + Environment Dashboard + + + Live temperature, humidity, and pressure samples proxied from + api.furyhawk.lol. + + + + 60s refresh + + Latest sample {formatRelativeTime(newestSampleTime)} + + {isFetching ? Refreshing : null} + + + + {buildComfortSummary(data)} + + + + + {Object.values(data.metrics).map((metric) => ( + + + + + + {metric.label} + + + {metric.latest?.rawValue ?? "--"} + + + + {metric.summary.count} pts / 24h + + + + + + {metric.description} + + + {formatDelta( + metric.latest?.value, + metric.previous?.value, + metric.unit, + )} + + + Updated {metric.latest ? formatRelativeTime(metric.latest.updateTime) : "--"} + + + + + + + Average + + + {formatValue(metric.summary.average, metric.unit)} + + + + + Low + + + {formatValue(metric.summary.min, metric.unit)} + + + + + High + + + {formatValue(metric.summary.max, metric.unit)} + + + + + + + + ))} + + + + + + + Recent Samples + + Latest synchronized sensor snapshots from the external telemetry API. + + + + Last fetch {formatTimestamp(data.generatedAt)} + + + + + + + Timestamp + Temperature + Humidity + Pressure + + + + {data.recentSamples.map((sample) => ( + + {formatTimestamp(sample.updateTime)} + {sample.temperature ?? "--"} + {sample.humidity ?? "--"} + {sample.pressure ?? "--"} + + ))} + + + + + + + ) +} + +export default FuryDashboard \ No newline at end of file diff --git a/frontend/src/components/dashboard/SensorTrendChart.tsx b/frontend/src/components/dashboard/SensorTrendChart.tsx new file mode 100644 index 0000000..4897b55 --- /dev/null +++ b/frontend/src/components/dashboard/SensorTrendChart.tsx @@ -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 ( + + {title} + + Not enough samples yet to draw a 24-hour chart. + + + ) + } + + 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 ( + + + + {title} + + Last 24 hours, sampled every few minutes + + + + + Range + + + {formatAxisValue(minValue, unit)} to {formatAxisValue(maxValue, unit)} + + + + + + + + + + + + + + {axisLevels.map((level) => { + const normalizedValue = (level - minValue) / valueRange + const y = CHART_HEIGHT - CHART_PADDING_Y - normalizedValue * innerHeight + + return ( + + + + {formatAxisValue(level, unit)} + + + ) + })} + + + + + {points.map((point, index) => { + const isEdge = index === 0 || index === midIndex || index === points.length - 1 + + if (!isEdge) { + return null + } + + return ( + + + + + ) + })} + + + + {formatTimeLabel(dayReadings[0].updateTime)} + {formatTimeLabel(dayReadings[midIndex].updateTime)} + {formatTimeLabel(dayReadings[dayReadings.length - 1].updateTime)} + + + + ) +} + +export default SensorTrendChart \ No newline at end of file diff --git a/frontend/src/components/dashboard/furyDashboardApi.ts b/frontend/src/components/dashboard/furyDashboardApi.ts new file mode 100644 index 0000000..8c79820 --- /dev/null +++ b/frontend/src/components/dashboard/furyDashboardApi.ts @@ -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 + 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 ( + metric: TMetricKey, + limit = SENSOR_LIMIT, +): Promise => { + const config = metricConfig[metric] + const response = await furyApi.get(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 => { + 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, + ), + } +} \ No newline at end of file diff --git a/frontend/src/routes/_layout/index.tsx b/frontend/src/routes/_layout/index.tsx index 0313854..0c08bb7 100644 --- a/frontend/src/routes/_layout/index.tsx +++ b/frontend/src/routes/_layout/index.tsx @@ -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 ( - <> - - - - Hi, {currentUser?.full_name || currentUser?.email} 👋🏼 - - Welcome back, nice to see you again! - - - - ) + return } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 811b069..b3db6da 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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/, ""), + }, + }, }, -}); +})