|
1 | 1 | import { BarChart } from "@mantine/charts"; |
2 | | -import { Button, Divider, Group, Text } from "@mantine/core"; |
3 | | -import { IconCloudDownload, IconTableShare } from "@tabler/icons-react"; |
4 | | -import { sql } from "drizzle-orm"; |
| 2 | +import { Button, Divider, Group, Paper, Text, ThemeIcon } from "@mantine/core"; |
| 3 | +import { |
| 4 | + IconCircleDashedCheck, |
| 5 | + IconCircleDashedX, |
| 6 | + IconCloudDownload, |
| 7 | + IconHeartBroken, |
| 8 | + IconTableShare, |
| 9 | + IconWifi, |
| 10 | + IconWifiOff, |
| 11 | +} from "@tabler/icons-react"; |
| 12 | +import { asc, gt, sql } from "drizzle-orm"; |
5 | 13 | import { href, Link } from "react-router"; |
| 14 | +import * as schema from "../../database/schema.d"; |
6 | 15 | import type { Route } from "./+types/index"; |
7 | 16 |
|
8 | 17 | export const handle = { |
@@ -42,21 +51,61 @@ export async function loader({ context }: Route.LoaderArgs) { |
42 | 51 | GROUP BY tp.period_index |
43 | 52 | ORDER BY tp.period_index; |
44 | 53 | `); |
| 54 | + const heartbeats = await context.db |
| 55 | + .select() |
| 56 | + .from(schema.Heartbeats) |
| 57 | + .where( |
| 58 | + gt( |
| 59 | + schema.Heartbeats.hourStartTimestamp, |
| 60 | + sql`(unixepoch() - 24 * 60 * 60)` |
| 61 | + ) |
| 62 | + ) |
| 63 | + .orderBy(asc(schema.Heartbeats.hourStartTimestamp)); |
45 | 64 | const weatherStationHealthData: { |
46 | 65 | time: string; |
47 | 66 | disregarded: number; |
48 | 67 | observed: number; |
49 | | - }[] = weatherStationHealthQuery.map((row: any) => { |
50 | | - const start = new Date(row.period_time * 1000); |
51 | | - return { |
52 | | - time: `${start.getHours()}:${start |
53 | | - .getMinutes() |
54 | | - .toString() |
55 | | - .padStart(2, "0")}`, |
56 | | - observed: row.observed || 0, |
57 | | - disregarded: row.disregarded || 0, |
58 | | - }; |
59 | | - }); |
| 68 | + }[] = weatherStationHealthQuery |
| 69 | + .filter((row: any) => row.period_index !== -48) // Skip the first period, it is always empty as it contains data from more than 24 hours ago |
| 70 | + .map((row: any) => { |
| 71 | + const endOfPeriod = new Date(row.period_time * 1000); |
| 72 | + const startOfPeriod = new Date(endOfPeriod.getTime() - 30 * 60 * 1000); // Add 30 minutes to start |
| 73 | + const minutesSinceStartOfPeriod = Math.round( |
| 74 | + (new Date().getTime() - startOfPeriod.getTime()) / 60000 |
| 75 | + ); |
| 76 | + const heartbeat = heartbeats.find((heartbeat) => { |
| 77 | + // Match if the weatherStationPeriod's hour matches the heartbeat's hourStartTimestamp |
| 78 | + return ( |
| 79 | + heartbeat.hourStartTimestamp.getHours() === |
| 80 | + startOfPeriod.getHours() && |
| 81 | + heartbeat.hourStartTimestamp.getDate() === startOfPeriod.getDate() |
| 82 | + ); |
| 83 | + }); |
| 84 | + |
| 85 | + let uptime = 0; // Uptime as a percentage (i.e. 100% = 55 pings) |
| 86 | + let expectedPings = 60; // Expected number of pings in the period, if the period is the current one (ie less than an hour, we should expect fewer pings). This is because it's the previous 30 minutes. |
| 87 | + if (minutesSinceStartOfPeriod < 60) |
| 88 | + expectedPings = expectedPings * (minutesSinceStartOfPeriod / 60); |
| 89 | + if (heartbeat) uptime = (heartbeat.pingCount / expectedPings) * 100; |
| 90 | + if (uptime > 100) uptime = 100; |
| 91 | + uptime = Math.round(uptime); |
| 92 | + |
| 93 | + return { |
| 94 | + time: `${startOfPeriod.getHours()}:${startOfPeriod |
| 95 | + .getMinutes() |
| 96 | + .toString() |
| 97 | + .padStart(2, "0")}`, |
| 98 | + observed: row.observed || 0, |
| 99 | + disregarded: row.disregarded || 0, |
| 100 | + endOfPeriod: `${endOfPeriod.getHours()}:${endOfPeriod |
| 101 | + .getMinutes() |
| 102 | + .toString() |
| 103 | + .padStart(2, "0")}`, |
| 104 | + pingCount: heartbeat?.pingCount ?? 0, |
| 105 | + uptime, |
| 106 | + expectedPings, |
| 107 | + }; |
| 108 | + }); |
60 | 109 |
|
61 | 110 | const averageProcessingTimeLastHourQuery = await context.db.get(sql` |
62 | 111 | SELECT AVG(created_at - timestamp) AS avg_difference_of_last_5_observations_within_last_hour |
@@ -85,6 +134,72 @@ export async function loader({ context }: Route.LoaderArgs) { |
85 | 134 | } |
86 | 135 |
|
87 | 136 | export default function Page({ actionData, loaderData }: Route.ComponentProps) { |
| 137 | + const ChartTooltip = ({ |
| 138 | + label, |
| 139 | + payload, |
| 140 | + }: { |
| 141 | + label: string; |
| 142 | + payload: Record<string, any>[] | undefined; |
| 143 | + }) => { |
| 144 | + if (!payload || payload.length === 0) return null; |
| 145 | + const payloadData = payload[0]["payload"]; |
| 146 | + const startOfPeriod = new Date(`1970-01-01T${payloadData.time}:00`); |
| 147 | + |
| 148 | + return ( |
| 149 | + <Paper px="md" py="sm" withBorder shadow="md" radius="md"> |
| 150 | + <Text fw={500} mb={5}> |
| 151 | + {label} to {payloadData.endOfPeriod} UTC |
| 152 | + </Text> |
| 153 | + |
| 154 | + <Group> |
| 155 | + <ThemeIcon variant="white" color="green.4" radius="md"> |
| 156 | + <IconCircleDashedCheck style={{ width: "70%", height: "70%" }} /> |
| 157 | + </ThemeIcon> |
| 158 | + <Text fz="sm"> |
| 159 | + {payloadData.observed} observation |
| 160 | + {payloadData.observed === 1 ? "" : "s"} successfully recorded |
| 161 | + </Text> |
| 162 | + </Group> |
| 163 | + {payloadData.disregarded > 0 && ( |
| 164 | + <Group> |
| 165 | + <ThemeIcon variant="white" color="red.5" radius="md"> |
| 166 | + <IconCircleDashedX style={{ width: "70%", height: "70%" }} /> |
| 167 | + </ThemeIcon> |
| 168 | + <Text fz="sm"> |
| 169 | + {payloadData.disregarded} observation |
| 170 | + {payloadData.disregarded === 1 ? "" : "s"} failed validation check |
| 171 | + </Text> |
| 172 | + </Group> |
| 173 | + )} |
| 174 | + {payloadData.uptime >= 90 ? ( |
| 175 | + <Group> |
| 176 | + <ThemeIcon variant="white" color="black" radius="md"> |
| 177 | + <IconWifi style={{ width: "70%", height: "70%" }} /> |
| 178 | + </ThemeIcon> |
| 179 | + <Text fz="sm">No connectivity issues experienced</Text> |
| 180 | + </Group> |
| 181 | + ) : ( |
| 182 | + <> |
| 183 | + <Group> |
| 184 | + <ThemeIcon variant="white" color="black" radius="md"> |
| 185 | + <IconWifiOff style={{ width: "70%", height: "70%" }} /> |
| 186 | + </ThemeIcon> |
| 187 | + <Text fz="sm">Connected {payloadData.uptime}% of the time</Text> |
| 188 | + </Group> |
| 189 | + <Group> |
| 190 | + <ThemeIcon variant="white" color="pink.4" radius="md"> |
| 191 | + <IconHeartBroken style={{ width: "70%", height: "70%" }} /> |
| 192 | + </ThemeIcon> |
| 193 | + <Text fz="sm"> |
| 194 | + Received {payloadData.pingCount} of {payloadData.expectedPings}{" "} |
| 195 | + expected heartbeats |
| 196 | + </Text> |
| 197 | + </Group> |
| 198 | + </> |
| 199 | + )} |
| 200 | + </Paper> |
| 201 | + ); |
| 202 | + }; |
88 | 203 | return ( |
89 | 204 | <> |
90 | 205 | <Text my={"sm"}> |
@@ -194,6 +309,11 @@ export default function Page({ actionData, loaderData }: Route.ComponentProps) { |
194 | 309 | legendProps={{ verticalAlign: "bottom" }} |
195 | 310 | tickLine="none" |
196 | 311 | gridAxis="none" |
| 312 | + tooltipProps={{ |
| 313 | + content: ({ label, payload }) => ( |
| 314 | + <ChartTooltip label={label} payload={payload} /> |
| 315 | + ), |
| 316 | + }} |
197 | 317 | series={[ |
198 | 318 | { |
199 | 319 | name: "observed", |
|
0 commit comments