diff --git a/web2/components/ui/alert.tsx b/web2/components/ui/alert.tsx new file mode 100644 index 00000000..41fa7e05 --- /dev/null +++ b/web2/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/web2/package-lock.json b/web2/package-lock.json index 265a7939..ed15f068 100644 --- a/web2/package-lock.json +++ b/web2/package-lock.json @@ -4708,6 +4708,16 @@ "node": ">=6" } }, + "node_modules/make-dir/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -4997,19 +5007,6 @@ "node": "*" } }, - "node_modules/nodemon/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -5962,12 +5959,16 @@ } }, "node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "optional": true, + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", "bin": { - "semver": "bin/semver" + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/send": { @@ -6125,19 +6126,6 @@ "node": ">=10" } }, - "node_modules/simple-update-notifier/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", diff --git a/web2/src/pages/settings/section-state.tsx b/web2/src/pages/settings/section-state.tsx index 59d4b15d..9783eced 100644 --- a/web2/src/pages/settings/section-state.tsx +++ b/web2/src/pages/settings/section-state.tsx @@ -2,24 +2,20 @@ import * as React from "react"; import { useState, useEffect } from "react"; import { NavChildProps } from "@/components/ui/sidebar-pill-nav"; import { - DynamicFormControl, - FieldSection, - FieldSections, - StandardFormField, + FieldSection, + FieldSections } from "./form-components"; -import SelectBox from "@/components/ui/select-box"; import Settings from "."; import { z } from "zod"; import { schemas } from "@/api"; import { useFormContext } from "react-hook-form"; import { - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, } from "@/components/ui/form"; -import Select from "react-select"; import SimpleSelect from "@/components/ui/select-box"; type Settings = z.infer; diff --git a/web2/src/pages/settings/section-system.tsx b/web2/src/pages/settings/section-system.tsx index 26bd2af1..c95d05fb 100644 --- a/web2/src/pages/settings/section-system.tsx +++ b/web2/src/pages/settings/section-system.tsx @@ -2,9 +2,28 @@ import * as React from "react"; import { NavChildProps } from "@/components/ui/sidebar-pill-nav"; import { FieldSection, FieldSections } from "./form-components"; import { Button } from "@/components/ui/button"; -import { api } from "@/api"; +import { api, schemas } from "@/api"; import { useToast } from "@/hooks/use-toast"; import { Input } from "@/components/ui/input"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { AlertTriangle } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Skeleton } from "@/components/ui/skeleton"; + +type SystemInfo = Awaited>; + +function compareVersions(v1: string, v2: string): number { + const v1Parts = v1.split(".").map(Number); + const v2Parts = v2.split(".").map(Number); + + for (let i = 0; i < Math.max(v1Parts.length, v2Parts.length); i++) { + const v1Part = v1Parts[i] || 0; + const v2Part = v2Parts[i] || 0; + if (v1Part > v2Part) return 1; + if (v1Part < v2Part) return -1; + } + return 0; +} const ActionSection: React.FC = () => { const { toast } = useToast(); @@ -117,7 +136,7 @@ const BackupsSection: React.FC = () => {
-

Restore Backup

+

Restore Backup

{ type="submit" disabled={!backupFile} onClick={handleUploadBackup} + variant="secondary" > Upload Backup @@ -142,13 +162,275 @@ const BackupsSection: React.FC = () => { ); }; -export const SystemSettings: React.FC> = () => ( - - - - - - - - -); +const FirmwareSection: React.FC<{ currentVersion: string | null, variant: string | null }> = ({ + currentVersion, + variant, +}) => { + const { toast } = useToast(); + const [firmwareFile, setFirmwareFile] = React.useState(null); + const [isChecking, setIsChecking] = React.useState(false); + const [latestVersionInfo, setLatestVersionInfo] = React.useState<{ + version: string; + url: string; + body: string; + download_links: { + name: string; + url: string; + }[], + release_date: string; + } | null>(null); + + const handleFileChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + setFirmwareFile(file || null); + }; + + const handleUploadFirmware = async () => { + // TODO: Implement firmware upload logic + toast({ + title: "Update started", + description: "Do not turn off the device until the update is complete.", + variant: "default", + }); + + api + .postFirmware({ file: firmwareFile }) + .then(() => { + toast({ + title: "Success", + description: "The update is complete. The device will restart.", + variant: "default", + }); + }) + .catch((error) => { + toast({ + title: "Error uploading firmware", + description: error.message, + variant: "destructive", + }); + }); + }; + + const checkLatestVersion = async () => { + setIsChecking(true); + try { + const response = await fetch( + "https://api.github.com/repos/sidoh/esp8266_milight_hub/releases/latest" + ); + const data = await response.json(); + setLatestVersionInfo({ + version: data.tag_name, + url: data.html_url, + body: data.body, + download_links: data.assets.map((asset: any) => ({ + name: asset.name, + url: asset.browser_download_url, + })), + release_date: data.published_at, + }); + } catch (error) { + toast({ + title: "Error checking latest version", + description: "Failed to fetch the latest version from GitHub.", + variant: "destructive", + }); + } finally { + setIsChecking(false); + } + }; + + const isNewVersionAvailable = React.useMemo(() => { + if (!currentVersion || !latestVersionInfo) return false; + return compareVersions(latestVersionInfo.version, currentVersion) > 0; + }, [currentVersion, latestVersionInfo]); + + const getDownloadLink = React.useMemo(() => { + if (!latestVersionInfo || !variant) return null; + return latestVersionInfo.download_links.find(link => link.name.toLowerCase().includes(variant.toLowerCase())); + }, [latestVersionInfo, variant]); + + console.log(variant, latestVersionInfo) + + return ( +
+ + + Warning + + Always create a backup before updating firmware! + + + +
+

Upload Firmware

+ +
+ + +
+ +
+ + {!latestVersionInfo && ( +
+

Check for Updates

+
+ +
+
+ )} + + {latestVersionInfo && ( +
+

Latest Version Information

+
+ {isNewVersionAvailable && ( +

+ A new version is available! +

+ )} +

+ Version: {latestVersionInfo.version} +

+

+ Release Date:{" "} + {new Date(latestVersionInfo.release_date).toLocaleString()} +

+

+ Release Notes: +

+
+            {latestVersionInfo.body}
+          
+
+ + {getDownloadLink && ( + + )} +
+
+ )} +
+ ); +}; + +const SystemInfoSection: React.FC<{ + systemInfo: SystemInfo | null; + isLoading: boolean; +}> = ({ systemInfo, isLoading }) => { + if (isLoading) { + return ( +
+ + + + +
+ ); + } + + return systemInfo ? ( +
+
+ Firmware: {systemInfo?.firmware} +
+
+ Version: {systemInfo?.version} +
+
+ IP Address: {systemInfo?.ip_address} +
+
+ Variant: {systemInfo?.variant} +
+
+ Free Heap: {systemInfo?.free_heap} bytes +
+
+ Arduino Version: {systemInfo?.arduino_version} +
+
+ Last Reset Reason: {systemInfo?.reset_reason} +
+
+ Dropped Packets: {systemInfo?.queue_stats?.dropped_packets} +
+
+ ) : ( + <> + ); +}; + +export const SystemSettings: React.FC> = () => { + const [systemInfo, setSystemInfo] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const { toast } = useToast(); + + useEffect(() => { + const fetchSystemInfo = async () => { + try { + const response = await api.getAbout(); + setSystemInfo(response); + } catch (error) { + console.error("Failed to fetch system info:", error); + toast({ + title: "Error fetching system info", + description: "Failed to load system information.", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }; + + fetchSystemInfo(); + }, []); + + return ( + + + + + + + + + + + + + + + ); +};