Skip to content

Commit b07fb85

Browse files
committed
Display uptime for the Balena device
1 parent ca217d6 commit b07fb85

File tree

1 file changed

+134
-14
lines changed

1 file changed

+134
-14
lines changed

website/app/routes/index.tsx

Lines changed: 134 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
11
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";
513
import { href, Link } from "react-router";
14+
import * as schema from "../../database/schema.d";
615
import type { Route } from "./+types/index";
716

817
export const handle = {
@@ -42,21 +51,61 @@ export async function loader({ context }: Route.LoaderArgs) {
4251
GROUP BY tp.period_index
4352
ORDER BY tp.period_index;
4453
`);
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));
4564
const weatherStationHealthData: {
4665
time: string;
4766
disregarded: number;
4867
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+
});
60109

61110
const averageProcessingTimeLastHourQuery = await context.db.get(sql`
62111
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) {
85134
}
86135

87136
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+
};
88203
return (
89204
<>
90205
<Text my={"sm"}>
@@ -194,6 +309,11 @@ export default function Page({ actionData, loaderData }: Route.ComponentProps) {
194309
legendProps={{ verticalAlign: "bottom" }}
195310
tickLine="none"
196311
gridAxis="none"
312+
tooltipProps={{
313+
content: ({ label, payload }) => (
314+
<ChartTooltip label={label} payload={payload} />
315+
),
316+
}}
197317
series={[
198318
{
199319
name: "observed",

0 commit comments

Comments
 (0)