Skip to content

Commit

Permalink
Output mqtt status in settings page. Add icons to sections
Browse files Browse the repository at this point in the history
  • Loading branch information
sidoh committed Oct 26, 2024
1 parent a5bffbc commit 58312f5
Show file tree
Hide file tree
Showing 10 changed files with 213 additions and 104 deletions.
28 changes: 8 additions & 20 deletions web2/api/api-zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,14 @@ const About = z
})
.partial()
.passthrough(),
mqtt: z
.object({
configured: z.boolean(),
connected: z.boolean(),
status: z.string(),
})
.partial()
.passthrough(),
})
.partial()
.passthrough();
Expand Down Expand Up @@ -824,26 +832,6 @@ const endpoints = makeApi([
type: "Body",
schema: z.array(UpdateBatch),
},
{
name: "blockOnQueue",
type: "Query",
schema: z
.boolean()
.describe(
"If true, response will block on update packets being sent before returning"
)
.optional(),
},
{
name: "fmt",
type: "Query",
schema: z
.literal("normalized")
.describe(
"If set to `normalized`, the response will be in normalized format."
)
.optional(),
},
],
response: BooleanResponse,
},
Expand Down
6 changes: 6 additions & 0 deletions web2/components/ui/spinner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import * as React from "react";
import { Loader } from "lucide-react";

export const Spinner: React.FC<{ className?: string }> = ({ className }) => (
<Loader size={16} className={`animate-spin ${className}`} />
);
71 changes: 60 additions & 11 deletions web2/lib/settings.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,74 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from "react";
import React, {
createContext,
useContext,
useState,
useEffect,
ReactNode,
useCallback,
} from "react";
import { z } from "zod";
import { api, schemas } from "@/api";

type Settings = z.infer<typeof schemas.Settings>;
type About = z.infer<typeof schemas.About>;

interface SettingsContextType {
settings: Settings | null;
isLoading: boolean;
settings: Settings | null;

about: About | null;
isLoadingAbout: boolean;
updateSettings: (newSettings: Partial<Settings>) => void;
theme: "light" | "dark";
toggleTheme: () => void;
reloadAbout: () => void;
}

const SettingsContext = createContext<SettingsContextType | null>(null);

export const SettingsProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
export const SettingsProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const [settings, setSettings] = useState<Settings | null>(null);
const [about, setAbout] = useState<About | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingAbout, setIsLoadingAbout] = useState(true);
const [theme, setTheme] = useState<"light" | "dark">("light");

useEffect(() => {
api.getSettings().then((fetchedSettings) => {
setSettings(fetchedSettings);
setIsLoading(false);
});
const reloadAbout = useCallback(() => {
setIsLoadingAbout(true);
api
.getAbout()
.then(setAbout)
.finally(() => setIsLoadingAbout(false));
}, []);

useEffect(() => {
// Initialize theme from localStorage or system preference
const savedTheme = localStorage.getItem("theme");
const prefersDarkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
const initialTheme = savedTheme ? savedTheme : (prefersDarkMode ? "dark" : "light");
const prefersDarkMode = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches;
const initialTheme = savedTheme
? savedTheme
: prefersDarkMode
? "dark"
: "light";

setTheme(initialTheme as "light" | "dark");

const fetchData = async () => {
const [settings, about] = await Promise.all([
api.getSettings(),
api.getAbout(),
]);
setSettings(settings);
setAbout(about);
setIsLoading(false);
setIsLoadingAbout(false);
};

fetchData();
}, []);

const updateSettings = (newSettings: Partial<Settings>) => {
Expand All @@ -54,7 +92,18 @@ export const SettingsProvider: React.FC<{ children: ReactNode }> = ({ children }
}, [theme]);

return (
<SettingsContext.Provider value={{ settings, updateSettings, isLoading, theme, toggleTheme }}>
<SettingsContext.Provider
value={{
settings,
about,
updateSettings,
isLoading,
isLoadingAbout,
theme,
toggleTheme,
reloadAbout,
}}
>
{children}
</SettingsContext.Provider>
);
Expand Down
40 changes: 28 additions & 12 deletions web2/src/pages/settings/form-components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ export type SettingsKey = keyof typeof schemas.Settings.shape;
export const DynamicFormControl: React.FC<{
field: keyof typeof schemas.Settings.shape;
fieldType?: "text" | "number" | "password";
}> = ({ field, fieldType: fieldTypeOverride }) => {
onChange?: (field: SettingsKey, value: any) => void;
}> = ({ field, fieldType: fieldTypeOverride, onChange }) => {
const form = useFormContext<Settings>();
const fieldSchema = schemas.Settings.shape[field];
const fieldType = extractSchemaType(fieldSchema);
Expand All @@ -62,15 +63,18 @@ export const DynamicFormControl: React.FC<{
type={inputType}
{...formField}
value={formField.value as string | number | undefined}
onChange={(e) =>
inputType === "number"
? formField.onChange(
Number.isNaN(e.target.valueAsNumber)
? e.target.value
: e.target.valueAsNumber
)
: formField.onChange(e.target.value)
}
onChange={(e) => {
onChange?.(field, e.target.value);
if (inputType === "number") {
formField.onChange(
Number.isNaN(e.target.valueAsNumber)
? e.target.value
: e.target.valueAsNumber
);
} else {
formField.onChange(e.target.value);
}
}}
/>
)}
/>
Expand Down Expand Up @@ -228,7 +232,8 @@ export const FormFields: React.FC<{
fields: SettingsKey[];
fieldNames?: Partial<Record<SettingsKey, string>>;
fieldTypes?: Partial<Record<SettingsKey, "text" | "number" | "password">>;
}> = ({ fields, fieldNames, fieldTypes }) => {
onChange?: (field: SettingsKey, value: any) => void;
}> = ({ fields, fieldNames, fieldTypes, onChange }) => {
const form = useFormContext<Settings>();

return (
Expand All @@ -247,6 +252,7 @@ export const FormFields: React.FC<{
<DynamicFormControl
field={field}
fieldType={fieldTypes?.[field]}
onChange={onChange}
/>
</StandardFormField>
)}
Expand All @@ -264,7 +270,16 @@ export const FieldSection: React.FC<{
fieldNames?: Partial<Record<SettingsKey, string>>;
fieldTypes?: Partial<Record<SettingsKey, "text" | "number" | "password">>;
children?: React.ReactNode;
}> = ({ title, description, fields, fieldNames, fieldTypes, children }) => (
onChange?: (field: SettingsKey, value: any) => void;
}> = ({
title,
description,
fields,
fieldNames,
fieldTypes,
children,
onChange,
}) => (
<div>
{title && <h2 className="text-2xl font-bold">{title}</h2>}
{description && <p className="text-sm text-gray-500">{description}</p>}
Expand All @@ -273,6 +288,7 @@ export const FieldSection: React.FC<{
fields={fields}
fieldNames={fieldNames}
fieldTypes={fieldTypes}
onChange={onChange}
/>
{children}
</div>
Expand Down
6 changes: 3 additions & 3 deletions web2/src/pages/settings/section-hardware.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
export const HardwareSettings: React.FC<NavChildProps<"hardware">> = () => (
<FieldSections>
<FieldSection
title="Radio Pins"
title="⚙️ Radio Pins"
fields={["ce_pin", "csn_pin", "reset_pin"]}
fieldNames={{
ce_pin: "Chip Enable (CE) Pin",
Expand All @@ -19,7 +19,7 @@ export const HardwareSettings: React.FC<NavChildProps<"hardware">> = () => (
}}
/>
<FieldSection
title="LED"
title="💡 LED"
fields={[
"led_pin",
"led_mode_operating",
Expand All @@ -38,4 +38,4 @@ export const HardwareSettings: React.FC<NavChildProps<"hardware">> = () => (
}}
/>
</FieldSections>
);
);
71 changes: 67 additions & 4 deletions web2/src/pages/settings/section-mqtt.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as React from "react";
import { NavChildProps } from "@/components/ui/sidebar-pill-nav";
import { FieldSection, FieldSections } from "./form-components";
import { FieldValues, useFormContext } from "react-hook-form";
import { FieldValues, useFormContext, useWatch } from "react-hook-form";
import { useState, useEffect } from "react";
import {
FormField,
Expand All @@ -13,6 +13,11 @@ import {
import SimpleSelect from "@/components/ui/select-box";
import { schemas } from "@/api";
import { z } from "zod";
import { useSettings } from "@/lib/settings";
import { Button } from "@/components/ui/button";
import { CheckCircle, XCircle } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";

type Settings = z.infer<typeof schemas.Settings>;
type SettingsKey = keyof typeof schemas.Settings.shape;
Expand Down Expand Up @@ -135,15 +140,73 @@ const TopicFieldsSelector: React.FC<{}> = ({}) => {
);
};

export const MQTTSettings: React.FC<NavChildProps<"mqtt">> = () => (
<FieldSections>
export const MQTTConnectionSection: React.FC<{}> = () => {
const { about, reloadAbout, isLoadingAbout } = useSettings();
const [mqttServer, mqttUsername, mqttPassword] = useWatch({
name: ["mqtt_server", "mqtt_username", "mqtt_password"],
});
const [hasChanged, setHasChanged] = useState(false);

useEffect(() => {
let timeoutId: NodeJS.Timeout;
setHasChanged(true);

const debouncedReload = () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
if (mqttServer) {
reloadAbout();
setHasChanged(false);
}
}, 2000);
};

debouncedReload();

return () => clearTimeout(timeoutId);
}, [mqttServer, mqttUsername, mqttPassword]);

const mqttStatus = about?.mqtt?.status ?? "Unknown";
const isConnected = about?.mqtt?.connected;
const isLoading = isLoadingAbout || hasChanged;

return (
<FieldSection
title="MQTT Connection"
fields={["mqtt_server", "mqtt_username", "mqtt_password"]}
fieldTypes={{
mqtt_password: "password",
}}
/>
>
{about?.mqtt?.configured && (
<div className="mt-4 flex items-center">
<span className="font-medium mr-2">MQTT Status:</span>{" "}
{isLoading ? (
<div className="flex items-center">
<Spinner className="mr-2" />
<Skeleton className="h-6 w-20 border-gray-400" />
</div>
) : (
<div className="flex items-center">
<span className="mx-2">
{isConnected ? (
<CheckCircle size={16} className="text-green-500" />
) : (
<XCircle size={16} className="text-red-500" />
)}
</span>
<code>{mqttStatus}</code>
</div>
)}
</div>
)}
</FieldSection>
);
};

export const MQTTSettings: React.FC<NavChildProps<"mqtt">> = () => (
<FieldSections>
<MQTTConnectionSection />
<FieldSection title="MQTT Topics" fields={[]}>
<TopicFieldsSelector />
</FieldSection>
Expand Down
4 changes: 2 additions & 2 deletions web2/src/pages/settings/section-network.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ import { FieldSection, FieldSections } from "./form-components";
export const NetworkSettings: React.FC<NavChildProps<"network">> = () => (
<FieldSections>
<FieldSection
title="Security"
title="🔒 Security"
fields={["admin_username", "admin_password"]}
fieldTypes={{
admin_password: "password",
}}
/>
<FieldSection
title="WiFi"
title="📶 WiFi"
fields={[
"hostname",
"wifi_static_ip",
Expand Down
6 changes: 3 additions & 3 deletions web2/src/pages/settings/section-radio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { FieldSection, FieldSections } from "./form-components";
export const RadioSettings: React.FC<NavChildProps<"radio">> = () => (
<FieldSections>
<FieldSection
title="Device"
title="📻 Device"
fields={[
"radio_interface_type",
"rf24_power_level",
Expand All @@ -14,11 +14,11 @@ export const RadioSettings: React.FC<NavChildProps<"radio">> = () => (
]}
/>
<FieldSection
title="Repeats"
title="🔁 Repeats"
fields={["packet_repeats", "packet_repeats_per_loop", "listen_repeats"]}
/>
<FieldSection
title="Throttling"
title="⏱️ Throttling"
fields={[
"packet_repeat_throttle_sensitivity",
"packet_repeat_throttle_threshold",
Expand Down
Loading

0 comments on commit 58312f5

Please sign in to comment.