ostool-server 0.7.2

Server for managing development boards, serial sessions, and TFTP artifacts
import { useRef, useState } from "react";
import { api } from "@/api/client";
import { useList } from "@/api/events";
import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogContent,
  DialogTitle,
  DialogDescription,
  DialogFooter,
} from "@/components/ui/dialog";
import { FieldGroup } from "@/components/ui/field";
import {
  DataTable,
  Row,
  Cell,
  ConfirmAction,
  TextField,
  Notice,
  useAction,
} from "@/components/forms";
import type { DtbFileResponse } from "@/types/api";
export function validateFile(file: File | null) {
  if (file && file.size > 10 * 1024 * 1024)
    throw new Error("DTB 文件大小不能超过 10 MiB");
}
export function DtbUpload({
  onUploaded,
}: {
  onUploaded?: (name: string) => void;
}) {
  const [open, setOpen] = useState(false);
  const [name, setName] = useState("");
  const [file, setFile] = useState<File | null>(null);
  const task = useAction();
  return (
    <>
      <Button type="button" variant="outline" onClick={() => setOpen(true)}>
        上传 DTB
      </Button>
      <Dialog
        open={open}
        onOpenChange={(v) => {
          if (!task.pending) setOpen(v);
        }}
      >
        <DialogContent>
          <DialogTitle>上传 DTB</DialogTitle>
          <DialogDescription>单个文件最大 10 MiB。</DialogDescription>
          <FieldGroup>
            <TextField
              label="文件"
              type="file"
              accept=".dtb,application/octet-stream"
              onChange={(e) => {
                const f = e.target.files?.[0] ?? null;
                setFile(f);
                if (f) setName(f.name);
              }}
            />
            <TextField label="文件名" value={name} onValue={setName} />
          </FieldGroup>
          {task.error && <Notice>{task.error}</Notice>}
          <DialogFooter>
            <Button
              type="button"
              variant="outline"
              onClick={() => setOpen(false)}
              disabled={task.pending}
            >
              取消
            </Button>
            <Button
              type="button"
              disabled={task.pending}
              onClick={() =>
                void task.run(async () => {
                  if (!file) throw new Error("请选择 DTB 文件");
                  validateFile(file);
                  const result = await api.createDtb(
                    name.trim() || file.name,
                    file,
                  );
                  onUploaded?.(result.name);
                  setOpen(false);
                  setFile(null);
                  setName("");
                }, "DTB 已上传")
              }
            >
              {task.pending ? "上传中…" : "上传"}
            </Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>
    </>
  );
}
export default function Dtbs() {
  const dtbs = useList("dtbs");
  const [editing, setEditing] = useState<DtbFileResponse | null>(null);
  return (
    <>
      <header className="page-header">
        <h1>DTB</h1>
        <DtbUpload />
      </header>
      <p className="page-description">
        管理板卡启动使用的设备树文件,单个文件最大 10 MiB。
      </p>
      <DataTable
        headers={["名称", "大小", "更新时间", "TFTP 路径模板", "操作"]}
        empty={!dtbs.length}
      >
        {dtbs.map((d) => (
          <Row key={d.name}>
            <Cell className="mono">{d.name}</Cell>
            <Cell>{formatSize(d.size)}</Cell>
            <Cell>{new Date(d.updated_at).toLocaleString()}</Cell>
            <Cell className="mono">{d.relative_tftp_path_template}</Cell>
            <Cell>
              <div className="actions">
                <Button size="sm" variant="ghost" onClick={() => setEditing(d)}>
                  修改
                </Button>
                <ConfirmAction
                  label="删除"
                  description={`删除 DTB ${d.name}?已被板卡引用的文件不能删除。`}
                  action={() => api.deleteDtb(d.name)}
                />
              </div>
            </Cell>
          </Row>
        ))}
      </DataTable>
      {editing && (
        <EditDtb
          key={editing.name}
          dtb={editing}
          close={() => setEditing(null)}
        />
      )}
    </>
  );
}
function EditDtb({ dtb, close }: { dtb: DtbFileResponse; close: () => void }) {
  const [name, setName] = useState(dtb.name);
  const file = useRef<File | null>(null);
  const task = useAction();
  return (
    <Dialog
      open
      onOpenChange={(v) => {
        if (!v && !task.pending) close();
      }}
    >
      <DialogContent>
        <DialogTitle>修改 DTB</DialogTitle>
        <DialogDescription>
          可重命名或替换文件,单个文件最大 10 MiB。
        </DialogDescription>
        <FieldGroup>
          <TextField label="文件名" value={name} onValue={setName} />
          <TextField
            label="替换文件"
            type="file"
            accept=".dtb,application/octet-stream"
            onChange={(e) => {
              file.current = e.target.files?.[0] ?? null;
              if (file.current) setName(file.current.name);
            }}
          />
        </FieldGroup>
        {task.error && <Notice>{task.error}</Notice>}
        <DialogFooter>
          <Button variant="outline" onClick={close} disabled={task.pending}>
            取消
          </Button>
          <Button
            disabled={task.pending}
            onClick={() =>
              void task.run(async () => {
                validateFile(file.current);
                if (!name.trim()) throw new Error("文件名不能为空");
                if (name === dtb.name && !file.current)
                  throw new Error("请修改文件名或选择替换文件");
                await api.updateDtb(dtb.name, name, file.current);
                close();
              }, "DTB 已更新")
            }
          >
            保存修改
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}
function formatSize(n: number) {
  return n < 1024
    ? `${n} B`
    : n < 1024 * 1024
      ? `${(n / 1024).toFixed(1)} KiB`
      : `${(n / 1024 / 1024).toFixed(1)} MiB`;
}