Skip to content

Commit 7852d6d

Browse files
committed
Show offline state instead of spinner when backend is unreachable
1 parent 85a2c45 commit 7852d6d

7 files changed

Lines changed: 130 additions & 34 deletions

File tree

‎frontend/src/App.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,16 +105,21 @@ const App = () => {
105105
useEffect(() => {
106106
const syncData = () => {
107107
fetch(api("/api/hue"))
108-
.then((r) => r.json())
108+
.then((r) => {
109+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
110+
return r.json();
111+
})
109112
.then((d: Response) => {
110113
setData(d);
111114
setError(false);
112115
})
113116
.catch(() => setError(true));
114117
};
115118

119+
syncData();
116120
const es = new EventSource(api("/api/hue/events"));
117121
es.onopen = syncData;
122+
es.onerror = () => setError(true);
118123
es.onmessage = (e: MessageEvent) => {
119124
const event = JSON.parse(e.data as string) as HueLiveEvent;
120125
setData((prev) => (prev ? applyEvent(prev, event) : prev));

‎frontend/src/api.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,18 @@ const API_BASE = import.meta.env.VITE_API_URL ?? "";
33
export const api = (path: string) => `${API_BASE}${path}`;
44

55
export const fetcher = (url: string) => fetch(url).then((res) => res.json());
6+
7+
export class HttpError extends Error {
8+
status: number;
9+
constructor(status: number, message?: string) {
10+
super(message ?? `HTTP ${status}`);
11+
this.name = "HttpError";
12+
this.status = status;
13+
}
14+
}
15+
16+
export const jsonFetcher = async (url: string) => {
17+
const res = await fetch(url);
18+
if (!res.ok) throw new HttpError(res.status);
19+
return res.json();
20+
};

‎frontend/src/components/Box.tsx‎

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,9 @@ type BoxProps = {
88
className?: string;
99
drawer?: React.ReactElement;
1010
loading?: boolean;
11-
error?: boolean;
1211
} & React.HTMLAttributes<HTMLDivElement>;
1312

14-
const Box: React.FC<BoxProps> = ({
15-
className,
16-
children,
17-
drawer,
18-
loading,
19-
error,
20-
}) => {
13+
const Box: React.FC<BoxProps> = ({ className, children, drawer, loading }) => {
2114
const [collapsed, setCollapsed] = useState(true);
2215
const theme = useTheme();
2316

@@ -36,21 +29,6 @@ const Box: React.FC<BoxProps> = ({
3629
);
3730
}
3831

39-
if (error) {
40-
return (
41-
<div
42-
className={className}
43-
css={{
44-
display: "flex",
45-
justifyContent: "center",
46-
color: theme.colors.error,
47-
}}
48-
>
49-
<Icon>error</Icon>
50-
</div>
51-
);
52-
}
53-
5432
const expandable = drawer !== undefined;
5533

5634
return (

‎frontend/src/components/FmiWeatherBox.tsx‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { type LucideProps } from "lucide-react";
55
import { createElement, memo, useState } from "react";
66
import useSWR from "swr";
77

8-
import { api, fetcher } from "../api";
8+
import { api, jsonFetcher } from "../api";
99
import useLocationSettings from "../hooks/useLocationSettings";
1010
import useScreenshotMode from "../hooks/useScreenshotMode";
1111
import { PvForecast } from "../types/pv";
@@ -19,6 +19,7 @@ import Arrow from "./Arrow";
1919
import Box from "./Box";
2020
import Icon from "./Icon";
2121
import LocationForm from "./LocationForm";
22+
import OfflineState from "./OfflineState";
2223
import RaindropIcon from "./RaindropIcon";
2324
import WeatherChart from "./WeatherChart";
2425

@@ -47,9 +48,10 @@ const WeatherBox: React.FC<WeatherBoxProps> = ({ className }) => {
4748
? api(`/api/weather/fmi?lat=${location.lat}&lon=${location.lon}`)
4849
: null;
4950

50-
const { data } = useSWR<WeatherData>(weatherUrl, fetcher, {
51+
const { data, error } = useSWR<WeatherData>(weatherUrl, jsonFetcher, {
5152
refreshInterval: 3600000,
5253
refreshWhenHidden: true,
54+
shouldRetryOnError: false,
5355
});
5456

5557
const { data: pvForecast } = useSWR<PvForecast>(
@@ -88,6 +90,14 @@ const WeatherBox: React.FC<WeatherBoxProps> = ({ className }) => {
8890
);
8991
}
9092

93+
if (error) {
94+
return (
95+
<Box className={className}>
96+
<OfflineState label="säätiedot" />
97+
</Box>
98+
);
99+
}
100+
91101
if (!(data && current && today)) {
92102
return null;
93103
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { useTheme } from "@emotion/react";
2+
import { formatDistanceToNow } from "date-fns";
3+
import { fi } from "date-fns/locale/fi";
4+
import { FC } from "react";
5+
6+
import Icon from "./Icon";
7+
8+
type OfflineStateProps = {
9+
label: string;
10+
lastSeen?: Date | null;
11+
};
12+
13+
const OfflineState: FC<OfflineStateProps> = ({ label, lastSeen }) => {
14+
const theme = useTheme();
15+
return (
16+
<div
17+
css={{
18+
display: "flex",
19+
flexDirection: "column",
20+
alignItems: "center",
21+
gap: 8,
22+
color: theme.colors.text.muted,
23+
padding: "0.5em 0",
24+
}}
25+
>
26+
<Icon size={32}>cloud_off</Icon>
27+
<div
28+
css={{
29+
...theme.typography.label,
30+
color: theme.colors.text.muted,
31+
letterSpacing: "0.04em",
32+
textTransform: "lowercase",
33+
}}
34+
>
35+
{label}
36+
</div>
37+
{lastSeen && (
38+
<div
39+
css={{
40+
...theme.typography.caption,
41+
color: theme.colors.text.light,
42+
}}
43+
>
44+
päivitetty{" "}
45+
{formatDistanceToNow(lastSeen, { locale: fi, addSuffix: true })}
46+
</div>
47+
)}
48+
</div>
49+
);
50+
};
51+
52+
export default OfflineState;

‎frontend/src/components/SolisBox.tsx‎

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import { useTheme } from "@emotion/react";
22
import { memo } from "react";
33
import useSWR from "swr";
44

5-
import { api, fetcher } from "../api";
5+
import { api, HttpError, jsonFetcher } from "../api";
66
import { SolisData } from "../types/solis";
77
import Box, { DrawerRow } from "./Box";
88
import Icon from "./Icon";
9+
import OfflineState from "./OfflineState";
910

1011
type SolisBoxProps = {
1112
className?: string;
@@ -14,13 +15,30 @@ type SolisBoxProps = {
1415
const SolisBox: React.FC<SolisBoxProps> = ({ className }) => {
1516
const theme = useTheme();
1617

17-
const { data, error } = useSWR<SolisData>(api("/api/solis"), fetcher, {
18-
refreshInterval: 300_000,
19-
refreshWhenHidden: true,
20-
shouldRetryOnError: false,
21-
});
18+
const { data, error } = useSWR<SolisData, HttpError>(
19+
api("/api/solis"),
20+
jsonFetcher,
21+
{
22+
refreshInterval: 300_000,
23+
refreshWhenHidden: true,
24+
shouldRetryOnError: false,
25+
},
26+
);
27+
28+
// Not configured: hide entirely.
29+
if (error?.status === 503) return null;
30+
31+
const offline = !!error || data?.status === 2;
32+
if (offline) {
33+
const lastSeen = data?.updated_at ? new Date(data.updated_at) : null;
34+
return (
35+
<Box className={className}>
36+
<OfflineState label="aurinkopaneelit" lastSeen={lastSeen} />
37+
</Box>
38+
);
39+
}
2240

23-
if (!data || error) return null;
41+
if (!data) return null;
2442

2543
const isAlarm = data.status === 3;
2644
const displayPower = data.power.toFixed(1);

‎frontend/src/components/TemperatureBox.tsx‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { mq } from "../mq";
88
import { Sensor } from "../types/hue";
99
import Box, { DrawerRow } from "./Box";
1010
import Icon from "./Icon";
11+
import OfflineState from "./OfflineState";
1112
import TrendIndicator from "./TrendIndicator";
1213

1314
type TemperatureBoxProps = {
@@ -43,6 +44,24 @@ const TemperatureBox: React.FC<TemperatureBoxProps> = ({
4344

4445
const sensorWithLowBattery = sensors.find(isBatteryLow);
4546

47+
if (error) {
48+
return (
49+
<Box
50+
className={classNames(className)}
51+
css={{
52+
position: "relative",
53+
display: "flex",
54+
flexDirection: "column",
55+
[mq[0]]: {
56+
width: "100%",
57+
},
58+
}}
59+
>
60+
<OfflineState label={typeof title === "string" ? title : "anturit"} />
61+
</Box>
62+
);
63+
}
64+
4665
return (
4766
<Box
4867
className={classNames(className)}
@@ -55,7 +74,6 @@ const TemperatureBox: React.FC<TemperatureBoxProps> = ({
5574
},
5675
}}
5776
loading={sensors.length === 0}
58-
error={error}
5977
drawer={
6078
<div
6179
css={{

0 commit comments

Comments
 (0)