diff --git a/src/app/applications/page.tsx b/src/app/applications/page.tsx index 8fa0c73..2b71156 100644 --- a/src/app/applications/page.tsx +++ b/src/app/applications/page.tsx @@ -13,26 +13,17 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { MoreVertical, Plus, Rocket } from "lucide-react"; import InstallApplicationDialog from "@/components/dialogs/InstallApplication"; -import { useEffect, useState } from "react"; import { getApplications, removeApp } from "@/lib/dockship/application"; import RemoveDialog from "@/components/dialogs/Remove"; -import { Application } from "@/lib/types"; import DeployAppDialog from "@/components/dialogs/DeployApp"; +import { useAsyncInterval } from "@/hooks/use-async"; export default function ApplicationsPage() { - const [applications, setApplications] = useState([]); - - useEffect(() => { - const fetch = async () => { - const apps = await getApplications(); - setApplications(apps); - }; - - fetch(); - - const iid = setInterval(fetch, 3000); - return () => clearInterval(iid); - }, []); + const { value: applications, setValue: setApplications } = useAsyncInterval( + [], + getApplications, + 3000 + ); const remove = async (appId: string) => { await removeApp(appId); diff --git a/src/app/deployments/page.tsx b/src/app/deployments/page.tsx index 70c3922..bc93051 100644 --- a/src/app/deployments/page.tsx +++ b/src/app/deployments/page.tsx @@ -11,6 +11,7 @@ import { getDeployments } from "@/lib/dockship/deploy"; import { getApplications } from "@/lib/dockship/application"; import { cn } from "@/lib/utils"; import DeployAppDialog from "@/components/dialogs/DeployApp"; +import { useAsync } from "@/hooks/use-async"; const statusDot = { running: "bg-chart-2", @@ -19,36 +20,13 @@ const statusDot = { } as const; export default function DeploymentsPage() { - const [applications, setApplications] = useState([]); - const [deployments, setDeployments] = useState([]); - const [loading, setLoading] = useState(true); + const { value: applications } = useAsync([], getApplications); - useEffect(() => { - let mounted = true; - - async function fetchData() { - try { - const [apps, deps] = await Promise.all([ - getApplications(), - getDeployments(), - ]); - - if (!mounted) return; - - setApplications(apps); - setDeployments(deps.filter((d) => d !== null)); - } catch (err) { - console.error("Failed to load deployments:", err); - } finally { - if (mounted) setLoading(false); - } - } - - fetchData(); - return () => { - mounted = false; - }; - }, []); + const { value: deployments, loading } = useAsync( + [], + async () => (await getDeployments()).filter((d) => d !== null), + [applications] + ); const appNameByContainer = useMemo(() => { const map = new Map(); diff --git a/src/app/environment/page.tsx b/src/app/environment/page.tsx index 2db907e..b709700 100644 --- a/src/app/environment/page.tsx +++ b/src/app/environment/page.tsx @@ -16,22 +16,16 @@ import { DropdownMenuItem, } from "@/components/ui/dropdown-menu"; import { ChevronDown } from "lucide-react"; +import { useAsync } from "@/hooks/use-async"; export default function EnvironmentPage() { - const [selectedApp, setSelectedApp] = useState(""); - const [showSecrets, setShowSecrets] = useState>({}); - const [envGroups, setEnvGroups] = useState>({}); const [isSaving, setIsSaving] = useState(false); - - useEffect(() => { - const fetch = async () => { - const env = await getEnv(); - setEnvGroups(env); - const vars = Object.keys(env); - if (vars.length > 0) setSelectedApp(vars[0]); - }; - fetch(); - }, []); + const [showSecrets, setShowSecrets] = useState>({}); + const { value: envGroups, setValue: setEnvGroups } = useAsync({}, getEnv); + const { value: selectedApp, setValue: setSelectedApp } = useAsync( + "", + async () => Object.keys(envGroups)[0] || "" + ); const handleSave = async () => { setIsSaving(true); diff --git a/src/app/logs/page.tsx b/src/app/logs/page.tsx index 9509bc0..d240717 100644 --- a/src/app/logs/page.tsx +++ b/src/app/logs/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { DashboardLayout } from "@/components/dashboard/DashboardLayout"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -9,25 +9,18 @@ import { Pause, Play, Download, Filter, RefreshCw } from "lucide-react"; import { cn } from "@/lib/utils"; import { Log } from "@/lib/types"; import { getAllContainerLogs } from "@/lib/dockship/docker"; +import { useAsyncInterval } from "@/hooks/use-async"; export default function LogsPage() { const [isPaused, setIsPaused] = useState(false); const [filter, setFilter] = useState<"all" | "info" | "warn" | "error">( "all" ); - const [logs, setLogs] = useState([]); - - useEffect(() => { - const fetch = async () => { - if (!isPaused) setLogs(await getAllContainerLogs()); - }; - - const iid = setInterval(fetch, 1000); - - fetch(); - - return () => clearInterval(iid); - }, []); + const { value: logs, setValue: setLogs } = useAsyncInterval( + [], + async () => (isPaused ? ([] as Log[]) : getAllContainerLogs()), + 100 + ); const filteredLogs = logs.filter((log) => filter === "all" ? true : log.level === filter diff --git a/src/app/monitoring/page.tsx b/src/app/monitoring/page.tsx index 74b3a30..5a86310 100644 --- a/src/app/monitoring/page.tsx +++ b/src/app/monitoring/page.tsx @@ -13,6 +13,7 @@ import { getLiveNodes, getNodes } from "@/lib/dockship/node"; import { average } from "@/lib/format"; import { getContainerStats, getDocker } from "@/lib/dockship/docker"; import { SimpleStats } from "@/lib/types"; +import { useAsync, useAsyncInterval } from "@/hooks/use-async"; const cpuChartConfig = { cpu: { label: "CPU Usage", color: "var(--chart-1)" }, @@ -29,73 +30,74 @@ const networkChartConfig = { export default function MonitoringPage() { const [data, setData] = useState< - { - time: string; - cpu: number; - memory: number; - in: number; - out: number; - }[] + { time: string; cpu: number; memory: number; in: number; out: number }[] >([]); const isMobile = useIsMobile(); const [liveData, setLiveData] = useState<{ cpuUsage: number; ramUsage: number; }>(); - const [containerStats, SetContainerStats] = useState< + const [containerStats, setContainerStats] = useState< (SimpleStats & { name: string })[] >([]); - useEffect(() => { - const fetchNodes = async () => { + // Live nodes polling every 3 seconds + const { value: liveNodes } = useAsyncInterval( + { cpuUsage: 0, ramUsage: 0 }, + async () => { const nodes = await getLiveNodes(); - setLiveData({ + return { cpuUsage: average(nodes, (n) => n.liveData?.cpu.usage || 0), ramUsage: average(nodes, (n) => n.liveData?.memory.usage || 0), - }); - }; - fetchNodes(); - const interval = setInterval(fetchNodes, 3000); - return () => clearInterval(interval); - }, []); + }; + }, + 3000, + [] + ); + // Update liveData state useEffect(() => { - async function fetchMetrics() { - setData(await getMetrics()); - } + setLiveData(liveNodes); + }, [liveNodes]); - fetchMetrics(); - - const i = setInterval(fetchMetrics, 30000); - - return () => clearInterval(i); - }, []); + // Metrics polling every 30 seconds + const { value: metrics } = useAsyncInterval( + [] as typeof data, + getMetrics, + 30000, + [] + ); + // Update data state useEffect(() => { - async function fetchContainerMetrics() { + setData(metrics); + }, [metrics]); + + // Container stats fetched once + const { value: containers } = useAsync( + [] as typeof containerStats, + async () => { const nodes = await getNodes(); const dockerNodes = await getDocker(nodes); - SetContainerStats( - ( - await Promise.all( - dockerNodes.flatMap((node) => - node.containers.map(async (c) => { - const stats = await getContainerStats(c.Id); - - if (stats === undefined) return undefined; - - return { ...stats, name: c.Names[0] }; - }) - ) + return ( + await Promise.all( + dockerNodes.flatMap((node) => + node.containers.map(async (c) => { + const stats = await getContainerStats(c.Id); + if (!stats) return undefined; + return { ...stats, name: c.Names[0] }; + }) ) - ).filter((s): s is SimpleStats & { name: string } => s !== undefined) - ); + ) + ).filter((s): s is SimpleStats & { name: string } => s !== undefined); + }, + [] + ); - console.log(containerStats); - } - fetchContainerMetrics(); - }, []); + useEffect(() => { + setContainerStats(containers); + }, [containers]); return ( ([]); - const [dockerNodes, setDockerNodes] = useState([]); + const { value: nodes, setValue: setNodes } = useAsyncInterval( + [], + getLiveNodes, + 3000 + ); - useEffect(() => { - const fetchNodes = async () => { - const nodes = await getLiveNodes(); - setNodes(nodes); - }; - fetchNodes(); - const interval = setInterval(fetchNodes, 3000); - return () => clearInterval(interval); - }, []); - - useEffect(() => { - const fetchDocker = async () => { - setDockerNodes(await getDocker(nodes)); - }; - fetchDocker(); - }, [nodes]); + const { value: dockerNodes } = useAsync([], async () => getDocker(nodes), [ + nodes, + ]); return ( diff --git a/src/app/overview/page.tsx b/src/app/overview/page.tsx index 868b291..56510c3 100644 --- a/src/app/overview/page.tsx +++ b/src/app/overview/page.tsx @@ -29,36 +29,29 @@ import { getDocker } from "@/lib/dockship/docker"; import { getApplications } from "@/lib/dockship/application"; import { cn } from "@/lib/utils"; import { getDeployments } from "@/lib/dockship/deploy"; +import { useAsync, useAsyncInterval } from "@/hooks/use-async"; export default function OverviewPage() { - const [nodes, setNodes] = useState<(NodeLiveData & Node)[]>([]); - const [dockerNodes, setDockerNodes] = useState([]); - const [apps, setApplications] = useState([]); - const [deployments, setDeployments] = useState([]); - - useEffect(() => { - const fetchNodes = async () => { - const nodes = await getLiveNodes(); - setNodes(nodes); - setDockerNodes(await getDocker(nodes)); - setApplications(await getApplications()); - setDeployments((await getDeployments()).filter((d) => d !== null)); - }; - fetchNodes(); - const interval = setInterval(fetchNodes, 3000); - - return () => clearInterval(interval); - }, []); + const { value: nodes } = useAsyncInterval([], getLiveNodes, 3000); + const { value: dockerNodes } = useAsync([], async () => getDocker(nodes), [ + nodes, + ]); + const { value: applications } = useAsyncInterval([], getApplications, 3000); + const { value: deployments } = useAsyncInterval( + [], + async () => (await getDeployments()).filter((d) => d !== null), + 3000 + ); const appNameByContainer = useMemo(() => { const map = new Map(); - for (const app of apps) { + for (const app of applications) { for (const container of app.deployments) { map.set(container, app.name); } } return map; - }, [apps]); + }, [applications]); return ( @@ -66,9 +59,9 @@ export default function OverviewPage() {
app.deployments.length > 0).length + applications.filter((app) => app.deployments.length > 0).length } Deployed`} icon={} /> @@ -118,12 +111,12 @@ export default function OverviewPage() {
{/* Rows */} - {apps.slice(-5).length === 0 ? ( + {applications.slice(-5).length === 0 ? (
No applications yet
) : ( - apps.slice(-5).map((app) => ( + applications.slice(-5).map((app) => (
([]); - const [apps, setApps] = useState([]); - + const { value: nodes } = useAsync([], getLiveNodes); + const { value: applications } = useAsync([], getApplications); const [name, setName] = useState(""); const [app, setApp] = useState(defaultApp || ""); const [node, setNode] = useState(""); - useEffect(() => { - const fetchNodes = async () => { - setNodeList(await getLiveNodes()); - setApps(await getApplications()); - }; - fetchNodes(); - }, []); - const deploy = async () => { console.log(name, app, node); await deployApp(name, app, node); @@ -75,7 +66,7 @@ export default function DeployAppDialog({ ({ + data={applications.map((app) => ({ label: app.name, value: app.id, }))} @@ -91,7 +82,7 @@ export default function DeployAppDialog({ node.liveData) .map((node) => ({ label: node.name, value: node.node_id }))} placeholder="Select Node..." diff --git a/src/components/dialogs/InstallApplication.tsx b/src/components/dialogs/InstallApplication.tsx index 47b242c..9abb5c9 100644 --- a/src/components/dialogs/InstallApplication.tsx +++ b/src/components/dialogs/InstallApplication.tsx @@ -17,24 +17,18 @@ import { getLiveNodes } from "@/lib/dockship/node"; import { Combobox } from "../ui/combobox"; import { Node, NodeLiveData } from "@/lib/types"; import { installApp } from "@/lib/dockship/application"; +import { useAsync } from "@/hooks/use-async"; export default function InstallApplicationDialog({ children, }: { children: ReactNode; }) { - const [nodeList, setNodeList] = useState<(NodeLiveData & Node)[]>([]); + const { value: nodes } = useAsync([], getLiveNodes); const [name, setName] = useState(""); const [repo, setRepo] = useState(""); const [node, setNode] = useState(""); - useEffect(() => { - const fetchNodes = async () => { - setNodeList(await getLiveNodes()); - }; - fetchNodes(); - }, []); - const deploy = async () => { console.log(await installApp(name, repo, node)); }; @@ -80,7 +74,7 @@ export default function InstallApplicationDialog({ node.liveData) .map((node) => ({ label: node.name, value: node.node_id }))} placeholder="Select Node..." diff --git a/src/hooks/use-async.ts b/src/hooks/use-async.ts new file mode 100644 index 0000000..65ac907 --- /dev/null +++ b/src/hooks/use-async.ts @@ -0,0 +1,68 @@ +import { useState, useEffect } from "react"; + +export function useAsync( + defaultValue: T, + asyncFunction: () => Promise, + dependencies: any[] = [] +) { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [value, setValue] = useState(defaultValue); + + useEffect(() => { + let isMounted = true; + setLoading(true); + asyncFunction() + .then((result) => { + if (isMounted) setValue(result); + }) + .catch((err) => { + if (isMounted) setError(err); + }) + .finally(() => { + if (isMounted) setLoading(false); + }); + + return () => { + isMounted = false; + }; + }, dependencies); + + return { loading, error, value, setValue }; +} + +export function useAsyncInterval( + defaultValue: T, + asyncFunction: () => Promise, + interval: number, + dependencies: any[] = [] +) { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [value, setValue] = useState(defaultValue); + + useEffect(() => { + let isMounted = true; + + const iid = setInterval(() => { + setLoading(true); + asyncFunction() + .then((result) => { + if (isMounted) setValue(result); + }) + .catch((err) => { + if (isMounted) setError(err); + }) + .finally(() => { + if (isMounted) setLoading(false); + }); + }, interval); + + return () => { + isMounted = false; + clearInterval(iid); + }; + }, dependencies); + + return { loading, error, value, setValue }; +}