From 33b2ffb40c77e60c49b6815b0d74392908f6c257 Mon Sep 17 00:00:00 2001 From: UUBulb <35923940+uubulb@users.noreply.github.com> Date: Thu, 21 Nov 2024 22:20:14 +0800 Subject: [PATCH] implement remaining features of the server page (#9) * implement remaining features of the server page * fix fm init * ? --- package-lock.json | 14 + package.json | 1 + src/api/server.ts | 6 +- src/components/fm.tsx | 332 +++++++++++++++++-- src/components/terminal.tsx | 6 +- src/components/ui/breadcrumb.tsx | 115 +++++++ src/components/xui/filepath.tsx | 102 ++++++ src/components/xui/icon-button.tsx | 38 ++- src/components/xui/virtulized-data-table.tsx | 180 ++++++++++ src/lib/fm.ts | 71 ++++ src/lib/utils.ts | 67 ++++ src/routes/cron.tsx | 7 +- src/routes/server.tsx | 33 +- src/types/{alert-rule.tsx => alert-rule.ts} | 0 src/types/api.ts | 12 + src/types/fm.ts | 37 +++ src/types/index.ts | 1 + tsconfig.app.json | 4 +- 18 files changed, 994 insertions(+), 32 deletions(-) create mode 100644 src/components/ui/breadcrumb.tsx create mode 100644 src/components/xui/filepath.tsx create mode 100644 src/components/xui/virtulized-data-table.tsx create mode 100644 src/lib/fm.ts rename src/types/{alert-rule.tsx => alert-rule.ts} (100%) create mode 100644 src/types/fm.ts diff --git a/package-lock.json b/package-lock.json index 87505ae..22abb07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "react-hook-form": "^7.53.1", "react-router-dom": "^6.27.0", "react-use-websocket": "^4.10.1", + "react-virtuoso": "^4.12.0", "sonner": "^1.6.1", "swr": "^2.2.5", "tailwind-merge": "^2.5.4", @@ -5524,6 +5525,19 @@ "integrity": "sha512-PrZbKj3BSy9kRU9otKEoMi0FOcEVh1abyYxJDzB/oL7kMBDBs+ZXhnWWed/sc679nPHAWMOn1gotoV04j5gJUw==", "license": "MIT" }, + "node_modules/react-virtuoso": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.12.0.tgz", + "integrity": "sha512-oHrKlU7xHsrnBQ89ecZoMPAK0tHnI9s1hsFW3KKg5ZGeZ5SWvbGhg/QFJFY4XETAzoCUeu+Xaxn1OUb/PGtPlA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16 || >=17 || >= 18", + "react-dom": ">=16 || >=17 || >= 18" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", diff --git a/package.json b/package.json index 5c0928e..75c422c 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "react-hook-form": "^7.53.1", "react-router-dom": "^6.27.0", "react-use-websocket": "^4.10.1", + "react-virtuoso": "^4.12.0", "sonner": "^1.6.1", "swr": "^2.2.5", "tailwind-merge": "^2.5.4", diff --git a/src/api/server.ts b/src/api/server.ts index d87381b..478e8f0 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -1,4 +1,4 @@ -import { ModelServer, ModelServerForm } from "@/types" +import { ModelServer, ModelServerForm, ModelForceUpdateResponse } from "@/types" import { fetcher, FetcherMethod } from "./api" export const updateServer = async (id: number, data: ModelServerForm): Promise => { @@ -9,6 +9,10 @@ export const deleteServer = async (id: number[]): Promise => { return fetcher(FetcherMethod.POST, '/api/v1/batch-delete/server', id); } +export const forceUpdateServer = async (id: number[]): Promise => { + return fetcher(FetcherMethod.POST, '/api/v1/force-update/server', id); +} + export const getServers = async (): Promise => { return fetcher(FetcherMethod.GET, '/api/v1/server', null); } diff --git a/src/components/fm.tsx b/src/components/fm.tsx index 0288290..bf40766 100644 --- a/src/components/fm.tsx +++ b/src/components/fm.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useRef } from "react" +import { useEffect, useState, useRef, HTMLAttributes } from "react" import { Sheet, SheetTrigger, @@ -9,18 +9,160 @@ import { } from "./xui/overlayless-sheet" import { IconButton } from "./xui/icon-button" import { createFM } from "@/api/fm" -import { ModelCreateFMResponse } from "@/types" +import { ModelCreateFMResponse, FMEntry, FMOpcode, FMIdentifier, FMWorkerData, FMWorkerOpcode } from "@/types" import useWebSocket from "react-use-websocket" import { toast } from "sonner" +import { ColumnDef } from "@tanstack/react-table" +import { Folder, File } from "lucide-react" +import { fm, formatPath, fmWorker as worker } from "@/lib/utils" +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, + AlertDialogFooter, + AlertDialogCancel, + AlertDialogAction, +} from "@/components/ui/alert-dialog" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Row, flexRender } from "@tanstack/react-table" +import { TableRow, TableCell } from "./ui/table" +import { DataTable } from "./xui/virtulized-data-table" +import { Input } from "@/components/ui/input" +import { Filepath } from "./xui/filepath" interface FMProps { wsUrl: string; } +const arraysEqual = (a: Uint8Array, b: Uint8Array) => { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + const FMComponent: React.FC = ({ wsUrl, ...props }) => { const fmRef = useRef(null); - const { sendMessage } = useWebSocket(wsUrl, { + const [dOpen, setdOpen] = useState(false); + const [uOpen, setuOpen] = useState(false); + + const columns: ColumnDef[] = [ + { + id: "type", + header: () => Type, + accessorFn: row => row.type, + cell: ({ row }) => ( + row.original.type == 0 ? : + ), + }, + { + header: () => Name, + id: "name", + accessorFn: row => row.name, + cell: ({ row }) => ( +
+ {row.original.name} +
+ ), + size: 5000, + }, + { + header: () => Action, + id: "download", + cell: ({ row }) => { + return ( + { + if (!dOpen) setdOpen(true); + downloadFile(row.original.name); + } + } /> + ) + }, + } + ] + + const tableRowComponent = (rows: Row[]) => + function getTableRow(props: HTMLAttributes) { + // @ts-expect-error data-index is a valid attribute + const index = props["data-index"]; + const row = rows[index]; + + if (!row) return null; + + return ( + { + if (row.original.type === 1) { + setPath(`${currentPath}/${row.original.name}`); + } + }} + className={row.original.type === 1 ? "cursor-pointer" : "cursor-default"} + {...props} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ); + }; + + const [fmEntires, setFMEntries] = useState([]); + + const firstChunk = useRef(true); + const handleReady = useRef(false); + const currentBasename = useRef('temp'); + + const waitForHandleReady = async () => { + while (!handleReady.current) { + await new Promise(resolve => setTimeout(resolve, 10)); + } + }; + + worker.onmessage = async (event: MessageEvent) => { + switch (event.data.type) { + case FMWorkerOpcode.Error: { + console.error('Error from worker', event.data.error); + break; + } + case FMWorkerOpcode.Progress: { + handleReady.current = true; + break; + } + case FMWorkerOpcode.Result: { + handleReady.current = false; + + if (event.data.blob && event.data.fileName) { + const url = URL.createObjectURL(event.data.blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = event.data.fileName; + anchor.click(); + URL.revokeObjectURL(url); + } + + firstChunk.current = true; + if (dOpen) setdOpen(false); + break; + } + } + } + + const { sendMessage, getWebSocket } = useWebSocket(wsUrl, { share: false, onOpen: () => { listFile(); @@ -29,48 +171,195 @@ const FMComponent: React.FC = ({ wsUrl, console.log('WebSocket connection closed:', e); }, onError: (e) => { - console.log(e); + console.error(e); toast("Websocket error", { description: "View console for details.", }) }, onMessage: async (e) => { + try { + const buf: ArrayBufferLike = e.data; + if (firstChunk.current) { + const identifier = new Uint8Array(buf, 0, 4); + if (arraysEqual(identifier, FMIdentifier.file)) { + worker.postMessage({ operation: 1, arrayBuffer: buf, fileName: currentBasename.current }); + firstChunk.current = false; + } else if (arraysEqual(identifier, FMIdentifier.fileName)) { + const { path, fmList } = await fm.parseFMList(buf); + setPath(path); + setFMEntries(fmList); + } else if (arraysEqual(identifier, FMIdentifier.error)) { + const errBytes = buf.slice(4); + const errMsg = new TextDecoder('utf-8').decode(errBytes); + throw new Error(errMsg); + } else if (arraysEqual(identifier, FMIdentifier.complete)) { + // Upload completed + if (uOpen) setuOpen(false); + listFile(); + } else { + throw new Error("Unknown identifier"); + } + } else { + await waitForHandleReady(); + worker.postMessage({ operation: 2, arrayBuffer: buf, fileName: currentBasename.current }); + } + } catch (error) { + console.error('Error processing received data:', error); + toast("FM error", { + description: "View console for details.", + }) + if (dOpen) setdOpen(false); + if (uOpen) setuOpen(false); + } } }); - const currentPath = useRef('').current; + const socket = getWebSocket(); + useEffect(() => { + if (socket && 'binaryType' in socket) + socket.binaryType = 'arraybuffer'; + }, [socket]) + + const [currentPath, setPath] = useState(''); + useEffect(() => { + listFile(); + }, [currentPath]) const listFile = () => { - const prefix = new Int8Array([0]); - const resizeMessage = new TextEncoder().encode(currentPath); + const prefix = new Int8Array([FMOpcode.List]); + const pathMsg = new TextEncoder().encode(currentPath); - const msg = new Int8Array(prefix.length + resizeMessage.length); + const msg = new Int8Array(prefix.length + pathMsg.length); msg.set(prefix); - msg.set(resizeMessage, prefix.length); + msg.set(pathMsg, prefix.length); sendMessage(msg); } - return
; + const downloadFile = (basename: string) => { + currentBasename.current = basename; + const prefix = new Int8Array([FMOpcode.Download]); + const filePathMessage = new TextEncoder().encode(`${currentPath}/${basename}`); + + const msg = new Int8Array(prefix.length + filePathMessage.length); + msg.set(prefix); + msg.set(filePathMessage, prefix.length); + + sendMessage(msg); + } + + const uploadFile = async (file: File) => { + const chunkSize = 1048576; // 1MB chunk + let offset = 0; + + // Send header + const header = fm.buildUploadHeader({ path: currentPath, file: file }); + sendMessage(header); + + // Send data chunks + while (offset < file.size) { + const chunk = file.slice(offset, offset + chunkSize); + const arrayBuffer = await fm.readFileAsArrayBuffer(chunk); + if (arrayBuffer) sendMessage(arrayBuffer); + offset += chunkSize; + } + } + + const fileInputRef = useRef(null); + + const [gotoPath, setGotoPath] = useState(''); + return ( +
+
+ + + + + + + Refresh + { + await navigator.clipboard.writeText(formatPath(currentPath)); + } + }>Copy path + + Goto + + + + + + Goto + + + { setGotoPath(e.target.value) }} /> + + Cancel + { setPath(gotoPath) }}>Confirm + + + +

Pseudo File Manager

+
+ { + const files = e.target.files; + if (files && files.length > 0) { + if (!uOpen) setuOpen(true); + await uploadFile(files[0]); + } + } + } /> + { + if (fileInputRef.current) fileInputRef.current.click(); + } + } /> +
+
+ + + + + Downloading... + + + + + + + + Uploading... + + + + + +
+ ); } export const FMCard = ({ id }: { id?: string }) => { const [open, setOpen] = useState(false); const [fm, setFM] = useState(null); + const [init, setInit] = useState(false); const fetchFM = async () => { - if (id && !fm) { + if (id) { try { + setInit(false); const createdFM = await createFM(id); setFM(createdFM); } catch (e) { toast("FM API Error", { description: "View console for details.", }) - console.log("fetch error", e); + console.error("fetch error", e); return; } + setInit(true); } } @@ -79,13 +368,18 @@ export const FMCard = ({ id }: { id?: string }) => { - - - Pseudo File Manager - - -
- + +
+ + + + + {fm?.session_id && init + ? + + : +

The server does not exist, or have not been connected yet.

+ }
diff --git a/src/components/terminal.tsx b/src/components/terminal.tsx index 39bafc8..319da29 100644 --- a/src/components/terminal.tsx +++ b/src/components/terminal.tsx @@ -40,7 +40,7 @@ const XtermComponent: React.FC = ({ w setClose(true); }, onError: (e) => { - console.log(e); + console.error(e); toast("Websocket error", { description: "View console for details.", }) @@ -88,7 +88,7 @@ const XtermComponent: React.FC = ({ w await sleep(1500); doResize(); } catch (error) { - console.log('resize error', error); + console.error('resize error', error); } finally { sendResize.current = false; } @@ -134,7 +134,7 @@ export const TerminalPage = () => { toast("Terminal API Error", { description: "View console for details.", }) - console.log("fetch error", e); + console.error("fetch error", e); return; } } diff --git a/src/components/ui/breadcrumb.tsx b/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..60e6c96 --- /dev/null +++ b/src/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) =>