feat(admin): moved server build/update to full app
All checks were successful
Build and Push LST Docker Image / docker (push) Successful in 2m27s

This commit is contained in:
2026-04-21 07:36:04 -05:00
parent b832d7aa1e
commit cb00addee9
49 changed files with 15551 additions and 36 deletions

View File

@@ -19,6 +19,8 @@
"better-auth": "^1.5.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"lucide-react": "^0.577.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
@@ -6016,6 +6018,25 @@
"node": ">= 12"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/date-fns-tz": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz",
"integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==",
"license": "MIT",
"peerDependencies": {
"date-fns": "^3.0.0 || ^4.0.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",

View File

@@ -34,7 +34,9 @@
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"tw-animate-css": "^1.4.0",
"zod": "^4.3.6"
"zod": "^4.3.6",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0"
},
"devDependencies": {
"@eslint/js": "^9.36.0",

View File

@@ -1,5 +1,5 @@
import { Link } from "@tanstack/react-router";
import { Bell, Logs, Settings } from "lucide-react";
import { Bell, Logs, Server, Settings } from "lucide-react";
import {
SidebarGroup,
@@ -40,6 +40,14 @@ export default function AdminSidebar({ session }: any) {
module: "admin",
active: true,
},
{
title: "Servers",
url: "/admin/servers",
icon: Server,
role: ["systemAdmin", "admin"],
module: "admin",
active: true,
},
{
title: "Logs",
url: "/admin/logs",

View File

@@ -1,22 +1,55 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import socket from "@/lib/socket.io";
export function useSocketRoom<T>(roomId: string) {
type RoomUpdatePayload<T> = {
roomId: string;
payloads: T[];
};
type RoomErrorPayload = {
roomId?: string;
message?: string;
};
export function useSocketRoom<T>(
roomId: string,
getKey?: (item: T) => string | number,
) {
const [data, setData] = useState<T[]>([]);
const [info, setInfo] = useState(
"No data yet — join the room to start receiving",
);
const clearRoom = useCallback(
(id?: string | number) => {
if (id !== undefined && getKey) {
setData((prev) => prev.filter((item) => getKey(item) !== id));
setInfo(`Removed item ${id}`);
return;
}
setData([]);
setInfo("Room data cleared");
},
[getKey],
);
useEffect(() => {
function handleConnect() {
socket.emit("join-room", roomId);
setInfo(`Joined room: ${roomId}`);
}
function handleUpdate(payload: any) {
function handleUpdate(payload: RoomUpdatePayload<T>) {
// protects against other room updates hitting this hook
if (payload.roomId !== roomId) return;
setData((prev) => [...payload.payloads, ...prev]);
setInfo("");
}
function handleError(err: any) {
function handleError(err: RoomErrorPayload) {
if (err.roomId && err.roomId !== roomId) return;
setInfo(err.message ?? "Room error");
}
@@ -31,6 +64,7 @@ export function useSocketRoom<T>(roomId: string) {
// If already connected, join immediately
if (socket.connected) {
socket.emit("join-room", roomId);
setInfo(`Joined room: ${roomId}`);
}
return () => {
@@ -42,5 +76,5 @@ export function useSocketRoom<T>(roomId: string) {
};
}, [roomId]);
return { data, info };
return { data, info, clearRoom };
}

View File

@@ -0,0 +1,22 @@
import { keepPreviousData, queryOptions } from "@tanstack/react-query";
import axios from "axios";
export function servers() {
return queryOptions({
queryKey: ["servers"],
queryFn: () => fetch(),
staleTime: 5000,
refetchOnWindowFocus: true,
placeholderData: keepPreviousData,
});
}
const fetch = async () => {
if (window.location.hostname === "localhost") {
await new Promise((res) => setTimeout(res, 1500));
}
const { data } = await axios.get("/lst/api/servers");
return data.data;
};

View File

@@ -105,6 +105,7 @@ export default function LstTable({
</TableBody>
</Table>
<ScrollBar orientation="horizontal" />
<ScrollBar orientation="vertical" />
</ScrollArea>
<div className="flex items-center justify-end space-x-2 py-4">
<Button

View File

@@ -14,6 +14,7 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as DocsIndexRouteImport } from './routes/docs/index'
import { Route as DocsSplatRouteImport } from './routes/docs/$'
import { Route as AdminSettingsRouteImport } from './routes/admin/settings'
import { Route as AdminServersRouteImport } from './routes/admin/servers'
import { Route as AdminNotificationsRouteImport } from './routes/admin/notifications'
import { Route as AdminLogsRouteImport } from './routes/admin/logs'
import { Route as authLoginRouteImport } from './routes/(auth)/login'
@@ -46,6 +47,11 @@ const AdminSettingsRoute = AdminSettingsRouteImport.update({
path: '/admin/settings',
getParentRoute: () => rootRouteImport,
} as any)
const AdminServersRoute = AdminServersRouteImport.update({
id: '/admin/servers',
path: '/admin/servers',
getParentRoute: () => rootRouteImport,
} as any)
const AdminNotificationsRoute = AdminNotificationsRouteImport.update({
id: '/admin/notifications',
path: '/admin/notifications',
@@ -83,6 +89,7 @@ export interface FileRoutesByFullPath {
'/login': typeof authLoginRoute
'/admin/logs': typeof AdminLogsRoute
'/admin/notifications': typeof AdminNotificationsRoute
'/admin/servers': typeof AdminServersRoute
'/admin/settings': typeof AdminSettingsRoute
'/docs/$': typeof DocsSplatRoute
'/docs/': typeof DocsIndexRoute
@@ -96,6 +103,7 @@ export interface FileRoutesByTo {
'/login': typeof authLoginRoute
'/admin/logs': typeof AdminLogsRoute
'/admin/notifications': typeof AdminNotificationsRoute
'/admin/servers': typeof AdminServersRoute
'/admin/settings': typeof AdminSettingsRoute
'/docs/$': typeof DocsSplatRoute
'/docs': typeof DocsIndexRoute
@@ -110,6 +118,7 @@ export interface FileRoutesById {
'/(auth)/login': typeof authLoginRoute
'/admin/logs': typeof AdminLogsRoute
'/admin/notifications': typeof AdminNotificationsRoute
'/admin/servers': typeof AdminServersRoute
'/admin/settings': typeof AdminSettingsRoute
'/docs/$': typeof DocsSplatRoute
'/docs/': typeof DocsIndexRoute
@@ -125,6 +134,7 @@ export interface FileRouteTypes {
| '/login'
| '/admin/logs'
| '/admin/notifications'
| '/admin/servers'
| '/admin/settings'
| '/docs/$'
| '/docs/'
@@ -138,6 +148,7 @@ export interface FileRouteTypes {
| '/login'
| '/admin/logs'
| '/admin/notifications'
| '/admin/servers'
| '/admin/settings'
| '/docs/$'
| '/docs'
@@ -151,6 +162,7 @@ export interface FileRouteTypes {
| '/(auth)/login'
| '/admin/logs'
| '/admin/notifications'
| '/admin/servers'
| '/admin/settings'
| '/docs/$'
| '/docs/'
@@ -165,6 +177,7 @@ export interface RootRouteChildren {
authLoginRoute: typeof authLoginRoute
AdminLogsRoute: typeof AdminLogsRoute
AdminNotificationsRoute: typeof AdminNotificationsRoute
AdminServersRoute: typeof AdminServersRoute
AdminSettingsRoute: typeof AdminSettingsRoute
DocsSplatRoute: typeof DocsSplatRoute
DocsIndexRoute: typeof DocsIndexRoute
@@ -210,6 +223,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AdminSettingsRouteImport
parentRoute: typeof rootRouteImport
}
'/admin/servers': {
id: '/admin/servers'
path: '/admin/servers'
fullPath: '/admin/servers'
preLoaderRoute: typeof AdminServersRouteImport
parentRoute: typeof rootRouteImport
}
'/admin/notifications': {
id: '/admin/notifications'
path: '/admin/notifications'
@@ -261,6 +281,7 @@ const rootRouteChildren: RootRouteChildren = {
authLoginRoute: authLoginRoute,
AdminLogsRoute: AdminLogsRoute,
AdminNotificationsRoute: AdminNotificationsRoute,
AdminServersRoute: AdminServersRoute,
AdminSettingsRoute: AdminSettingsRoute,
DocsSplatRoute: DocsSplatRoute,
DocsIndexRoute: DocsIndexRoute,

View File

@@ -0,0 +1,245 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { createColumnHelper } from "@tanstack/react-table";
import axios from "axios";
import { format } from "date-fns-tz";
import { CircleFadingArrowUp, Trash } from "lucide-react";
import { Suspense, useState } from "react";
import { toast } from "sonner";
import { Button } from "../../components/ui/button";
import { Spinner } from "../../components/ui/spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "../../components/ui/tooltip";
import { useSocketRoom } from "../../hooks/socket.io.hook";
import { authClient } from "../../lib/auth-client";
import { servers } from "../../lib/queries/servers";
import LstTable from "../../lib/tableStuff/LstTable";
import SearchableHeader from "../../lib/tableStuff/SearchableHeader";
import SkellyTable from "../../lib/tableStuff/SkellyTable";
export const Route = createFileRoute("/admin/servers")({
beforeLoad: async ({ location }) => {
const { data: session } = await authClient.getSession();
const allowedRole = ["systemAdmin", "admin"];
if (!session?.user) {
throw redirect({
to: "/",
search: {
redirect: location.href,
},
});
}
if (!allowedRole.includes(session.user.role as string)) {
throw redirect({
to: "/",
});
}
return { user: session.user };
},
component: RouteComponent,
});
const ServerTable = () => {
const { data, refetch } = useSuspenseQuery(servers());
const columnHelper = createColumnHelper<any>();
const columns = [
columnHelper.accessor("name", {
header: ({ column }) => (
<SearchableHeader column={column} title="Name" searchable={true} />
),
filterFn: "includesString",
cell: (i) => i.getValue(),
}),
columnHelper.accessor("greatPlainsPlantCode", {
header: ({ column }) => (
<SearchableHeader column={column} title="GP Code" />
),
cell: (i) => <span>{i.getValue().toUpperCase()}</span>,
}),
columnHelper.accessor("server", {
header: ({ column }) => (
<SearchableHeader column={column} title="server" />
),
cell: (i) => <span>{i.getValue().toUpperCase()}</span>,
}),
columnHelper.accessor("idAddress", {
header: ({ column }) => (
<SearchableHeader column={column} title="IP Address" />
),
cell: (i) => <span>{i.getValue()}</span>,
}),
columnHelper.accessor("lastUpdated", {
header: ({ column }) => (
<SearchableHeader column={column} title="Last Update" />
),
cell: (i) => <span>{format(i.getValue(), "M/d/yyyy HH:mm")}</span>,
}),
columnHelper.accessor("buildNumber", {
header: ({ column }) => (
<SearchableHeader column={column} title="Build" />
),
cell: (i) => <span>{i.getValue()}</span>,
}),
columnHelper.accessor("update", {
header: ({ column }) => (
<SearchableHeader column={column} title="Update" searchable={false} />
),
filterFn: "includesString",
cell: (i) => {
// biome-ignore lint: just removing the lint for now to get this going will maybe fix later
const [activeToggle, setActiveToggle] = useState(false);
const onToggle = async () => {
setActiveToggle(true);
toast.success(
`${i.row.original.name} just started the upgrade monitor logs for errors.`,
);
try {
const res = await axios.post(
`/lst/api/admin/build/updateServer`,
{
server: i.row.original.server,
destination: i.row.original.serverLoc,
token: i.row.original.plantToken,
},
{ withCredentials: true },
);
if (res.data.success) {
toast.success(
`${i.row.original.name} has completed its upgrade.`,
);
refetch();
setActiveToggle(false);
}
} catch (error) {
setActiveToggle(false);
console.error(error);
}
};
return (
<div>
<div className="flex items-center space-x-2">
<Button
variant="ghost"
disabled={activeToggle}
onClick={() => onToggle()}
>
{activeToggle ? (
<span>
<Spinner />
</span>
) : (
<span>
<CircleFadingArrowUp />
</span>
)}
</Button>
</div>
</div>
);
},
}),
];
return <LstTable data={data} columns={columns} />;
};
function RouteComponent() {
const { data: logs = [], clearRoom } = useSocketRoom<any>("admin:build");
const columnHelper = createColumnHelper<any>();
const logColumns = [
columnHelper.accessor("timestamp", {
header: ({ column }) => (
<SearchableHeader column={column} title="Time" searchable={false} />
),
filterFn: "includesString",
cell: (i) => format(i.getValue(), "M/d/yyyy HH:mm"),
}),
columnHelper.accessor("message", {
header: ({ column }) => (
<SearchableHeader column={column} title="Message" />
),
cell: (i) => (
<Tooltip>
<TooltipTrigger>
{i.getValue()?.length > 250 ? (
<span>{i.getValue().slice(0, 250)}...</span>
) : (
<span>{i.getValue()}</span>
)}
</TooltipTrigger>
<TooltipContent>{i.getValue()}</TooltipContent>
</Tooltip>
),
}),
columnHelper.accessor("clearLog", {
header: ({ column }) => (
<SearchableHeader column={column} title="Clear" />
),
cell: ({ row }) => {
const x = row.original;
return (
<Button
size="icon"
variant={"destructive"}
onClick={() => clearRoom(x.timestamp)}
>
<Trash />
</Button>
);
},
}),
];
const triggerBuild = async () => {
try {
const res = await axios.post(
`/lst/api/admin/build/release`,
{
withCredentials: true,
},
);
if (res.data.success) {
toast.success(res.data.message);
}
if (!res.data.success) {
toast.error(res.data.message);
}
} catch (err) {
console.log(err);
//toast.error(err?.message);
}
};
//console.log(logs);
return (
<div className="flex flex-col gap-1">
<div className="flex gap-1 justify-end">
<Button onClick={triggerBuild}>Trigger Build</Button>
<Button onClick={() => clearRoom()}>Clear Logs</Button>
</div>
<div className="flex gap-1 w-full">
<div className="w-full">
<Suspense fallback={<SkellyTable />}>
<ServerTable />
</Suspense>
</div>
<div className="w-1/2">
<LstTable data={logs} columns={logColumns} />
</div>
</div>
</div>
);
}