Add computer settings pages and restore workspace pages
This commit is contained in:
@@ -1,30 +0,0 @@
|
||||
import AddComputerForm from "@/src/layouts/bots/(partials)/add-computer-form";
|
||||
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
|
||||
import type { ComputerWithStatus } from "@/src/types";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* Open while set.
|
||||
*/
|
||||
computer?: ComputerWithStatus;
|
||||
onClose: () => void;
|
||||
onUpdated: (params: { computer: ComputerWithStatus; msg: string }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Edits a computer in a modal, with the same form as creating one.
|
||||
*/
|
||||
export default function ComputerEditModal({ computer, onClose, onUpdated }: Props) {
|
||||
return (
|
||||
<AddModalShell
|
||||
open={Boolean(computer)}
|
||||
setOpen={(open) => !open && onClose()}
|
||||
title={`Edit ${computer?.name || "the computer"}`}
|
||||
description="New CPUs or memory restart it with them. Its files stay."
|
||||
>
|
||||
{computer ? (
|
||||
<AddComputerForm key={computer.id} computer={computer} onCancel={onClose} onUpdated={onUpdated} />
|
||||
) : null}
|
||||
</AddModalShell>
|
||||
);
|
||||
}
|
||||
@@ -9,36 +9,54 @@ type Props = {
|
||||
computer: ComputerWithStatus;
|
||||
is_super_admin: boolean;
|
||||
busy?: boolean;
|
||||
onEdit: () => void;
|
||||
onRetry: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One computer: its name, state and resources, then (super admins) Edit,
|
||||
* Retry when its setup failed, and Delete (never the main one).
|
||||
* One computer: its name, state and resources. The title opens its
|
||||
* settings page; super admins can retry a failed setup and delete a
|
||||
* sandbox.
|
||||
*/
|
||||
export default function ComputerSettingsRow({ computer, is_super_admin, busy, onEdit, onRetry, onDelete }: Props) {
|
||||
export default function ComputerSettingsRow({
|
||||
computer,
|
||||
is_super_admin,
|
||||
busy,
|
||||
onRetry,
|
||||
onDelete,
|
||||
}: Props) {
|
||||
const name = computer.name || `Computer ${computer.id}`;
|
||||
const busy_here = busy || computer.status == "creating";
|
||||
|
||||
return (
|
||||
<SettingsListRow
|
||||
avatar={<ComputerIconTile />}
|
||||
href={`/admin/settings/computers/${computer.id}`}
|
||||
title={name}
|
||||
badge={computer.kind == "main" ? "Main" : undefined}
|
||||
subtitle={[computerStatusLabel({ computer }), computerResourcesLabel({ computer }), computer.description]
|
||||
subtitle={[
|
||||
computerStatusLabel({ computer }),
|
||||
computerResourcesLabel({ computer }),
|
||||
computer.description,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
>
|
||||
{is_super_admin ? (
|
||||
<SettingsRowAction title={`Edit ${name}`} label="Edit" disabled={busy_here} onClick={onEdit} />
|
||||
) : null}
|
||||
{is_super_admin && computer.status == "failed" ? (
|
||||
<SettingsRowAction title={`Set ${name} up again`} label="Retry" disabled={busy} onClick={onRetry} />
|
||||
<SettingsRowAction
|
||||
title={`Set ${name} up again`}
|
||||
label="Retry"
|
||||
disabled={busy}
|
||||
onClick={onRetry}
|
||||
/>
|
||||
) : null}
|
||||
{is_super_admin && computer.kind != "main" ? (
|
||||
<SettingsRowAction title={`Delete ${name}; its files are kept`} label="Delete" disabled={busy} onClick={onDelete} />
|
||||
<SettingsRowAction
|
||||
title={`Delete ${name}; its files are kept`}
|
||||
label="Delete"
|
||||
disabled={busy_here}
|
||||
onClick={onDelete}
|
||||
/>
|
||||
) : null}
|
||||
</SettingsListRow>
|
||||
);
|
||||
|
||||
@@ -10,34 +10,48 @@ import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
|
||||
import SettingsGroup from "../../(partials)/settings-group";
|
||||
import SettingsList from "../../(partials)/settings-list";
|
||||
import SettingsStatus from "../../(partials)/settings-status";
|
||||
import ComputerEditModal from "../(partials)/computer-edit-modal";
|
||||
import ComputerSettingsRow from "../(partials)/computer-settings-row";
|
||||
|
||||
type Status = { error?: boolean; success?: boolean; msg?: string };
|
||||
|
||||
/**
|
||||
* Every computer the user has, main first, kept live: its state and
|
||||
* resources. Super admins create, edit (new resources restart it, its
|
||||
* files stay), retry and delete them.
|
||||
* resources. A row opens that computer's settings page; super admins
|
||||
* create, retry and delete them from here too.
|
||||
*/
|
||||
export default function ComputersSettingsSection() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const { openForm } = useContext(AddFlowContext);
|
||||
const { computers, setComputers } = useLiveComputers({ computers: pageProps?.computers || [], include_new: true });
|
||||
const [editing, setEditing] = useState<ComputerWithStatus>();
|
||||
const { computers, setComputers } = useLiveComputers({
|
||||
computers: pageProps?.computers || [],
|
||||
include_new: true,
|
||||
});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [status, setStatus] = useState<Status>();
|
||||
|
||||
const is_super_admin = isPassThroughUser({ user_types: pageProps?.user_types });
|
||||
const is_super_admin = isPassThroughUser({
|
||||
user_types: pageProps?.user_types,
|
||||
});
|
||||
|
||||
const merge = (fresh: ComputerWithStatus) =>
|
||||
setComputers((prev) => prev.map((c) => (c.id == fresh.id ? { ...c, ...fresh } : c)));
|
||||
setComputers((prev) =>
|
||||
prev.map((c) => (c.id == fresh.id ? { ...c, ...fresh } : c)),
|
||||
);
|
||||
|
||||
const run = async ({ path, computer_id }: { path: string; computer_id: number }) => {
|
||||
const run = async ({
|
||||
path,
|
||||
computer_id,
|
||||
}: {
|
||||
path: string;
|
||||
computer_id: number;
|
||||
}) => {
|
||||
setBusy(true);
|
||||
setStatus(undefined);
|
||||
|
||||
const res = await postAdminApi<ComputerWithStatus>({ path, body: { computer_id } });
|
||||
const res = await postAdminApi<ComputerWithStatus>({
|
||||
path,
|
||||
body: { computer_id },
|
||||
});
|
||||
|
||||
setBusy(false);
|
||||
|
||||
@@ -64,12 +78,24 @@ export default function ComputersSettingsSection() {
|
||||
computer={computer}
|
||||
is_super_admin={is_super_admin}
|
||||
busy={busy}
|
||||
onEdit={() => setEditing(computer)}
|
||||
onRetry={() => run({ path: "computers/retry-computer", computer_id: Number(computer.id) })}
|
||||
onRetry={() =>
|
||||
run({
|
||||
path: "computers/retry-computer",
|
||||
computer_id: Number(computer.id),
|
||||
})
|
||||
}
|
||||
onDelete={() => {
|
||||
if (!window.confirm(`Delete ${computer.name || "this computer"}? Its container goes; its files stay on disk.`)) return;
|
||||
if (
|
||||
!window.confirm(
|
||||
`Delete ${computer.name || "this computer"}? Its container goes; its files stay on disk.`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
run({ path: "computers/delete-computer", computer_id: Number(computer.id) });
|
||||
run({
|
||||
path: "computers/delete-computer",
|
||||
computer_id: Number(computer.id),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
@@ -89,15 +115,6 @@ export default function ComputersSettingsSection() {
|
||||
</Button>
|
||||
) : null}
|
||||
<SettingsStatus status={status} />
|
||||
<ComputerEditModal
|
||||
computer={editing}
|
||||
onClose={() => setEditing(undefined)}
|
||||
onUpdated={({ computer, msg }) => {
|
||||
merge(computer);
|
||||
setEditing(undefined);
|
||||
setStatus({ success: true, msg });
|
||||
}}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import SettingsListRow from "../../../(partials)/settings-list-row";
|
||||
import SettingsRowAction from "../../../(partials)/settings-row-action";
|
||||
|
||||
type Props = {
|
||||
bot: BUN_SQLITE_COBBLE_BOTS;
|
||||
/**
|
||||
* The bot's settings page, when the actor may open it.
|
||||
*/
|
||||
href?: string;
|
||||
busy?: boolean;
|
||||
onRemove?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One bot with direct access to the computer.
|
||||
*/
|
||||
export default function ComputerBotRow({
|
||||
bot,
|
||||
href,
|
||||
busy,
|
||||
onRemove,
|
||||
}: Props) {
|
||||
const name = bot.name || `Bot ${bot.id}`;
|
||||
|
||||
return (
|
||||
<SettingsListRow
|
||||
avatar={
|
||||
<BotAvatar
|
||||
image_media_id={bot.image_media_id}
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
face={(bot.face || "idle") as BotFace}
|
||||
/>
|
||||
}
|
||||
href={href}
|
||||
title={name}
|
||||
subtitle={
|
||||
bot.kind == "coordinator"
|
||||
? "Coordinates your crew and can use every computer"
|
||||
: bot.role || "Worker bot"
|
||||
}
|
||||
>
|
||||
{onRemove ? (
|
||||
<SettingsRowAction
|
||||
title={`Take ${name}'s access away`}
|
||||
label="Remove"
|
||||
disabled={busy}
|
||||
onClick={onRemove}
|
||||
/>
|
||||
) : null}
|
||||
</SettingsListRow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useState } from "react";
|
||||
import InlineAddSelect from "@/src/components/general/inline-add-select";
|
||||
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||
import type { BotWithLastMessage, ComputerWithStatus } from "@/src/types";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import isLockedComputerAccess from "@/src/utils/is-locked-computer-access";
|
||||
import SettingsGroup from "../../../(partials)/settings-group";
|
||||
import SettingsList from "../../../(partials)/settings-list";
|
||||
import SettingsStatus from "../../../(partials)/settings-status";
|
||||
import ComputerBotRow from "./computer-bot-row";
|
||||
|
||||
type Props = {
|
||||
computer: ComputerWithStatus;
|
||||
/**
|
||||
* Every bot the user has, for adding one.
|
||||
*/
|
||||
bots: BotWithLastMessage[];
|
||||
/**
|
||||
* Bots with direct access now.
|
||||
*/
|
||||
computer_bots: BUN_SQLITE_COBBLE_BOTS[];
|
||||
is_super_admin: boolean;
|
||||
};
|
||||
|
||||
type Status = { error?: boolean; success?: boolean; msg?: string };
|
||||
|
||||
/**
|
||||
* Bots with direct access to the computer. The coordinator can use every
|
||||
* computer; in a workspace, every bot there uses the workspace's
|
||||
* computers instead.
|
||||
*/
|
||||
export default function ComputerBotsGroup({
|
||||
computer,
|
||||
bots,
|
||||
computer_bots: initial,
|
||||
is_super_admin,
|
||||
}: Props) {
|
||||
const [computer_bots, setComputerBots] = useState(initial);
|
||||
const [busy_id, setBusyId] = useState<number | "add">();
|
||||
const [status, setStatus] = useState<Status>();
|
||||
|
||||
const addable = bots.filter(
|
||||
(bot) =>
|
||||
bot.kind != "coordinator" &&
|
||||
!computer_bots.some((member) => member.id == bot.id),
|
||||
);
|
||||
|
||||
const change = async ({
|
||||
path,
|
||||
bot_id,
|
||||
}: {
|
||||
path: "add-computer" | "remove-computer";
|
||||
bot_id: number;
|
||||
}) => {
|
||||
setBusyId(path == "add-computer" ? "add" : bot_id);
|
||||
setStatus(undefined);
|
||||
|
||||
const res = await postAdminApi({
|
||||
path: `bots/${path}`,
|
||||
body: { bot_id, computer_id: computer.id },
|
||||
});
|
||||
|
||||
setBusyId(undefined);
|
||||
|
||||
if (!res.success) {
|
||||
setStatus({ error: true, msg: res.msg || "That didn't work." });
|
||||
return;
|
||||
}
|
||||
|
||||
if (path == "add-computer") {
|
||||
const bot = bots.find((item) => Number(item.id) == bot_id);
|
||||
|
||||
if (bot) {
|
||||
setComputerBots((prev) => [...prev, bot]);
|
||||
}
|
||||
} else {
|
||||
setComputerBots((prev) =>
|
||||
prev.filter((bot) => Number(bot.id) != bot_id),
|
||||
);
|
||||
}
|
||||
|
||||
setStatus({ success: true, msg: res.msg || "Saved." });
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
id="bots"
|
||||
title="Bots"
|
||||
description="Bots given direct access. The coordinator can use every computer, and in a workspace, every bot there uses the workspace's computers."
|
||||
>
|
||||
<SettingsList
|
||||
is_empty={!computer_bots.length}
|
||||
empty="No direct bot access yet."
|
||||
>
|
||||
{computer_bots.map((bot) => {
|
||||
const locked = isLockedComputerAccess({
|
||||
bot_kind: bot.kind,
|
||||
computer_kind: computer.kind,
|
||||
});
|
||||
const href =
|
||||
bot.kind == "coordinator" && !is_super_admin
|
||||
? undefined
|
||||
: `/admin/settings/bots/${bot.id}`;
|
||||
|
||||
return (
|
||||
<ComputerBotRow
|
||||
key={bot.id}
|
||||
bot={bot}
|
||||
href={href}
|
||||
busy={busy_id == bot.id}
|
||||
onRemove={
|
||||
locked
|
||||
? undefined
|
||||
: () =>
|
||||
change({
|
||||
path: "remove-computer",
|
||||
bot_id: Number(bot.id),
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SettingsList>
|
||||
<InlineAddSelect
|
||||
id="computer-add-bot"
|
||||
noun="bot"
|
||||
options={addable.map((bot) => ({
|
||||
value: String(bot.id),
|
||||
title: bot.name || `Bot ${bot.id}`,
|
||||
}))}
|
||||
loading={busy_id == "add"}
|
||||
onAdd={({ value }) =>
|
||||
change({ path: "add-computer", bot_id: Number(value) })
|
||||
}
|
||||
/>
|
||||
<SettingsStatus status={status} />
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import { useState } from "react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import type { ComputerWithStatus } from "@/src/types";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import SettingsGroup from "../../../(partials)/settings-group";
|
||||
import SettingsStatus from "../../../(partials)/settings-status";
|
||||
|
||||
type Props = {
|
||||
computer: ComputerWithStatus;
|
||||
onUpdated: (computer: ComputerWithStatus) => void;
|
||||
};
|
||||
|
||||
type Status = { error?: boolean; success?: boolean; msg?: string };
|
||||
|
||||
/**
|
||||
* Setting a failed computer up again, or deleting a sandbox. Its files
|
||||
* stay on disk either way.
|
||||
*/
|
||||
export default function ComputerDangerGroup({
|
||||
computer,
|
||||
onUpdated,
|
||||
}: Props) {
|
||||
const [busy, setBusy] = useState<"retry" | "delete">();
|
||||
const [status, setStatus] = useState<Status>();
|
||||
const name = computer.name || "this computer";
|
||||
const can_retry = computer.status == "failed";
|
||||
const can_delete = computer.kind != "main";
|
||||
|
||||
const retry = async () => {
|
||||
setBusy("retry");
|
||||
setStatus(undefined);
|
||||
|
||||
const res = await postAdminApi<ComputerWithStatus>({
|
||||
path: "computers/retry-computer",
|
||||
body: { computer_id: computer.id },
|
||||
});
|
||||
|
||||
setBusy(undefined);
|
||||
|
||||
if (!res.success) {
|
||||
setStatus({ error: true, msg: res.msg || "Couldn't set it up again." });
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.singleRes) onUpdated(res.singleRes);
|
||||
setStatus({ success: true, msg: res.msg || "Setting it up again." });
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Delete ${name}? Its container goes; its files stay on disk.`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
setBusy("delete");
|
||||
setStatus(undefined);
|
||||
|
||||
const res = await postAdminApi({
|
||||
path: "computers/delete-computer",
|
||||
body: { computer_id: computer.id },
|
||||
});
|
||||
|
||||
if (res.success) {
|
||||
window.location.href = "/admin/settings/computers";
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(undefined);
|
||||
setStatus({ error: true, msg: res.msg || "Couldn't delete it." });
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
id="danger"
|
||||
title="Danger zone"
|
||||
description="Set a failed computer up again, or delete a sandbox. Its files stay on disk."
|
||||
>
|
||||
{can_retry || can_delete ? (
|
||||
<Row className="w-full gap-2! flex-wrap items-center">
|
||||
{can_retry ? (
|
||||
<Button
|
||||
title={`Set ${name} up again`}
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
size="small"
|
||||
loading={busy == "retry"}
|
||||
disabled={Boolean(busy)}
|
||||
onClick={retry}
|
||||
>
|
||||
Retry setup
|
||||
</Button>
|
||||
) : null}
|
||||
{can_delete ? (
|
||||
<Button
|
||||
title={`Delete ${name}`}
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
size="small"
|
||||
loading={busy == "delete"}
|
||||
disabled={Boolean(busy)}
|
||||
onClick={remove}
|
||||
>
|
||||
Delete computer
|
||||
</Button>
|
||||
) : null}
|
||||
</Row>
|
||||
) : (
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
Nothing to do here. The main computer stays.
|
||||
</Span>
|
||||
)}
|
||||
<SettingsStatus status={status} />
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { useState } from "react";
|
||||
import type { ComputerWithStatus } from "@/src/types";
|
||||
import AddComputerForm from "@/src/layouts/bots/(partials)/add-computer-form";
|
||||
import SettingsGroup from "../../../(partials)/settings-group";
|
||||
import SettingsStatus from "../../../(partials)/settings-status";
|
||||
|
||||
type Props = {
|
||||
computer: ComputerWithStatus;
|
||||
onUpdated: (computer: ComputerWithStatus) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The computer's name, what it's for, and its CPUs and memory, edited in
|
||||
* place. New resources restart it; its files stay.
|
||||
*/
|
||||
export default function ComputerDetailsGroup({
|
||||
computer,
|
||||
onUpdated,
|
||||
}: Props) {
|
||||
const [status, setStatus] = useState<{ success?: boolean; msg?: string }>();
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
id="details"
|
||||
title="Details"
|
||||
description="New CPUs or memory restart it with them. Its files stay."
|
||||
>
|
||||
<AddComputerForm
|
||||
computer={computer}
|
||||
onUpdated={({ computer: saved, msg }) => {
|
||||
onUpdated(saved);
|
||||
setStatus({ success: true, msg });
|
||||
}}
|
||||
/>
|
||||
<SettingsStatus status={status} />
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import ComputerIconTile from "@/src/components/general/computer-icon-tile";
|
||||
import type { ComputerWithStatus } from "@/src/types";
|
||||
import computerResourcesLabel from "@/src/utils/computer-resources-label";
|
||||
import computerStatusLabel from "@/src/utils/computer-status-label";
|
||||
import computerViewUrl from "@/src/utils/computer-view-url";
|
||||
import SettingsGroup from "../../../(partials)/settings-group";
|
||||
|
||||
type Props = {
|
||||
computer: ComputerWithStatus;
|
||||
};
|
||||
|
||||
/**
|
||||
* The computer at a glance: its state, resources and what it's for, and a
|
||||
* way to open it in the browser.
|
||||
*/
|
||||
export default function ComputerOverviewGroup({ computer }: Props) {
|
||||
const name = computer.name || `Computer ${computer.id}`;
|
||||
const ready = computer.status == "running";
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
id="overview"
|
||||
title="Overview"
|
||||
description="What this computer is, and its container's state."
|
||||
>
|
||||
<Row className="w-full gap-4! flex-nowrap items-center">
|
||||
<ComputerIconTile />
|
||||
<Stack className="grow min-w-0 gap-0!">
|
||||
<Row className="gap-2! flex-nowrap items-center">
|
||||
<Span className="text-sm font-bold leading-5 text-ink truncate">
|
||||
{name}
|
||||
</Span>
|
||||
{computer.kind == "main" ? (
|
||||
<Span className="shrink-0 text-[11px] font-bold leading-4 text-ink-secondary">
|
||||
Main
|
||||
</Span>
|
||||
) : null}
|
||||
</Row>
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary truncate w-full">
|
||||
{[
|
||||
computerStatusLabel({ computer }),
|
||||
computerResourcesLabel({ computer }),
|
||||
computer.description,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</Span>
|
||||
</Stack>
|
||||
<Button
|
||||
title={
|
||||
ready
|
||||
? `Open ${name} in the browser`
|
||||
: `${name} isn't running yet`
|
||||
}
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
size="small"
|
||||
href={
|
||||
ready
|
||||
? computerViewUrl({
|
||||
computer_id: Number(computer.id),
|
||||
mode: "browser",
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
target="_blank"
|
||||
disabled={!ready}
|
||||
beforeIcon={<ExternalLink size={14} aria-hidden />}
|
||||
className="shrink-0"
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
</Row>
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
|
||||
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||
import SettingsListRow from "../../../(partials)/settings-list-row";
|
||||
import SettingsRowAction from "../../../(partials)/settings-row-action";
|
||||
|
||||
type Props = {
|
||||
workspace: BUN_SQLITE_COBBLE_WORKSPACES;
|
||||
busy?: boolean;
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One workspace linked to the computer, where every bot in it can use the
|
||||
* computer.
|
||||
*/
|
||||
export default function ComputerWorkspaceRow({
|
||||
workspace,
|
||||
busy,
|
||||
onRemove,
|
||||
}: Props) {
|
||||
const name = workspace.name || `Workspace ${workspace.id}`;
|
||||
|
||||
return (
|
||||
<SettingsListRow
|
||||
avatar={
|
||||
<WorkspaceAvatar
|
||||
image_media_id={workspace.image_media_id}
|
||||
size="sm"
|
||||
/>
|
||||
}
|
||||
href={`/admin/settings/workspaces/${workspace.id}`}
|
||||
title={name}
|
||||
subtitle={workspace.description || "Every bot here can use this computer"}
|
||||
>
|
||||
<SettingsRowAction
|
||||
title={`Unlink ${name} from this computer`}
|
||||
label="Remove"
|
||||
disabled={busy}
|
||||
onClick={onRemove}
|
||||
/>
|
||||
</SettingsListRow>
|
||||
);
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
import { useState } from "react";
|
||||
import InlineAddSelect from "@/src/components/general/inline-add-select";
|
||||
import type {
|
||||
BUN_SQLITE_COBBLE_WORKSPACES,
|
||||
} from "@/db/types/db";
|
||||
import type { ComputerWithStatus, WorkspaceWithActivity } from "@/src/types";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import SettingsGroup from "../../../(partials)/settings-group";
|
||||
import SettingsList from "../../../(partials)/settings-list";
|
||||
import SettingsStatus from "../../../(partials)/settings-status";
|
||||
import ComputerWorkspaceRow from "./computer-workspace-row";
|
||||
|
||||
type Props = {
|
||||
computer: ComputerWithStatus;
|
||||
/**
|
||||
* Every workspace the user has.
|
||||
*/
|
||||
workspaces: WorkspaceWithActivity[];
|
||||
/**
|
||||
* Workspaces linked now.
|
||||
*/
|
||||
computer_workspaces: BUN_SQLITE_COBBLE_WORKSPACES[];
|
||||
};
|
||||
|
||||
type Status = { error?: boolean; success?: boolean; msg?: string };
|
||||
|
||||
/**
|
||||
* Workspaces linked to the computer. Every bot in one can use it, and its
|
||||
* default computer comes first.
|
||||
*/
|
||||
export default function ComputerWorkspacesGroup({
|
||||
computer,
|
||||
workspaces,
|
||||
computer_workspaces: initial,
|
||||
}: Props) {
|
||||
const [computer_workspaces, setComputerWorkspaces] = useState(initial);
|
||||
const [busy_id, setBusyId] = useState<number | "add">();
|
||||
const [status, setStatus] = useState<Status>();
|
||||
|
||||
const addable = workspaces.filter(
|
||||
(workspace) =>
|
||||
!computer_workspaces.some(
|
||||
(linked) => linked.id == workspace.id,
|
||||
),
|
||||
);
|
||||
|
||||
const change = async ({
|
||||
path,
|
||||
workspace_id,
|
||||
}: {
|
||||
path: "add-computer" | "remove-computer";
|
||||
workspace_id: number;
|
||||
}) => {
|
||||
setBusyId(path == "add-computer" ? "add" : workspace_id);
|
||||
setStatus(undefined);
|
||||
|
||||
const res = await postAdminApi({
|
||||
path: `workspaces/${path}`,
|
||||
body: { workspace_id, computer_id: computer.id },
|
||||
});
|
||||
|
||||
setBusyId(undefined);
|
||||
|
||||
if (!res.success) {
|
||||
setStatus({ error: true, msg: res.msg || "That didn't work." });
|
||||
return;
|
||||
}
|
||||
|
||||
if (path == "add-computer") {
|
||||
const workspace = workspaces.find(
|
||||
(item) => Number(item.id) == workspace_id,
|
||||
);
|
||||
|
||||
if (workspace) {
|
||||
setComputerWorkspaces((prev) => [...prev, workspace]);
|
||||
}
|
||||
} else {
|
||||
setComputerWorkspaces((prev) =>
|
||||
prev.filter(
|
||||
(workspace) => Number(workspace.id) != workspace_id,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
setStatus({ success: true, msg: res.msg || "Saved." });
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
id="workspaces"
|
||||
title="Workspaces"
|
||||
description="Every bot in these workspaces can use this computer. Its default computer comes first."
|
||||
>
|
||||
<SettingsList
|
||||
is_empty={!computer_workspaces.length}
|
||||
empty="Not linked to a workspace yet."
|
||||
>
|
||||
{computer_workspaces.map((workspace) => (
|
||||
<ComputerWorkspaceRow
|
||||
key={workspace.id}
|
||||
workspace={workspace}
|
||||
busy={busy_id == workspace.id}
|
||||
onRemove={() =>
|
||||
change({
|
||||
path: "remove-computer",
|
||||
workspace_id: Number(workspace.id),
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SettingsList>
|
||||
<InlineAddSelect
|
||||
id="computer-add-workspace"
|
||||
noun="workspace"
|
||||
options={addable.map((workspace) => ({
|
||||
value: String(workspace.id),
|
||||
title: workspace.name || `Workspace ${workspace.id}`,
|
||||
}))}
|
||||
loading={busy_id == "add"}
|
||||
onAdd={({ value }) =>
|
||||
change({
|
||||
path: "add-computer",
|
||||
workspace_id: Number(value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<SettingsStatus status={status} />
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import { useContext } from "react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import useLiveComputers from "@/src/hooks/use-live-computers";
|
||||
import type { ComputerWithStatus } from "@/src/types";
|
||||
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
|
||||
import SettingsShell from "../../../(partials)/settings-shell";
|
||||
import ComputerBotsGroup from "../(partials)/computer-bots-group";
|
||||
import ComputerDangerGroup from "../(partials)/computer-danger-group";
|
||||
import ComputerDetailsGroup from "../(partials)/computer-details-group";
|
||||
import ComputerOverviewGroup from "../(partials)/computer-overview-group";
|
||||
import ComputerWorkspacesGroup from "../(partials)/computer-workspaces-group";
|
||||
|
||||
/**
|
||||
* One computer's settings: its state and resources, direct bot access,
|
||||
* linked workspaces, and retrying or deleting it. Super admins edit and
|
||||
* manage the danger zone; admins see the rest.
|
||||
*/
|
||||
export default function ComputerSettingsSection() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const initial = pageProps?.computer;
|
||||
const { computers, setComputers } = useLiveComputers({
|
||||
computers: initial ? [initial] : [],
|
||||
});
|
||||
|
||||
if (!initial?.id) return null;
|
||||
|
||||
const computer =
|
||||
computers.find((item) => item.id == initial.id) || initial;
|
||||
const name = computer.name || `Computer ${computer.id}`;
|
||||
const is_super_admin = isPassThroughUser({
|
||||
user_types: pageProps?.user_types,
|
||||
});
|
||||
|
||||
const update = (next: ComputerWithStatus) =>
|
||||
setComputers((prev) =>
|
||||
prev.some((item) => item.id == next.id)
|
||||
? prev.map((item) => (item.id == next.id ? { ...item, ...next } : item))
|
||||
: [...prev, next],
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsShell
|
||||
title={name}
|
||||
description={
|
||||
computer.description ||
|
||||
"Its container, the bots that reach it, and its workspaces."
|
||||
}
|
||||
breadcrumb_title={name}
|
||||
>
|
||||
<ComputerOverviewGroup computer={computer} />
|
||||
{is_super_admin ? (
|
||||
<ComputerDetailsGroup
|
||||
computer={computer}
|
||||
onUpdated={update}
|
||||
/>
|
||||
) : null}
|
||||
<ComputerBotsGroup
|
||||
computer={computer}
|
||||
bots={pageProps?.bots || []}
|
||||
computer_bots={pageProps?.computer_bots || []}
|
||||
is_super_admin={is_super_admin}
|
||||
/>
|
||||
<ComputerWorkspacesGroup
|
||||
computer={computer}
|
||||
workspaces={pageProps?.workspaces || []}
|
||||
computer_workspaces={pageProps?.computer_workspaces || []}
|
||||
/>
|
||||
{is_super_admin ? (
|
||||
<ComputerDangerGroup computer={computer} onUpdated={update} />
|
||||
) : null}
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { PagePropsType, PageQueryObject } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
|
||||
import grabComputerAccess from "@/src/functions/backend/db/computers/grab-computer-access";
|
||||
import grabUserComputer from "@/src/functions/backend/db/computers/grab-user-computer";
|
||||
import grabUserComputers from "@/src/functions/backend/db/computers/grab-user-computers";
|
||||
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||
import grabCrewSettingsServerProps from "../../(functions)/grab-crew-settings-server-props";
|
||||
|
||||
/**
|
||||
* One computer's settings: its state and resources, direct bot access,
|
||||
* linked workspaces, and retrying or deleting it. For super admins and
|
||||
* admins; only super admins edit, retry and delete.
|
||||
*/
|
||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
const settings = await grabCrewSettingsServerProps(params);
|
||||
|
||||
if (!settings.props?.person?.id) return settings;
|
||||
|
||||
const query: PageQueryObject = params.query;
|
||||
const user_id = settings.props.person.id;
|
||||
|
||||
const allowed = await grabUserComputer({
|
||||
computer_id: String(query.computer_id),
|
||||
user_id,
|
||||
});
|
||||
|
||||
if (!allowed?.id) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/admin/settings/computers",
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const [
|
||||
computer,
|
||||
bots,
|
||||
workspaces,
|
||||
access,
|
||||
] = await Promise.all([
|
||||
grabUserComputers({ computer_id: allowed.id }).then(
|
||||
(rows) => rows[0] || allowed,
|
||||
),
|
||||
grabBots({ user_id }),
|
||||
grabUserWorkspaces({ user_id }),
|
||||
grabComputerAccess({ computer_id: allowed.id }),
|
||||
]);
|
||||
|
||||
return {
|
||||
props: {
|
||||
...settings.props,
|
||||
computer,
|
||||
bots,
|
||||
workspaces,
|
||||
computer_bots: access.bots,
|
||||
computer_workspaces: access.workspaces,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import ComputerSettingsSection from "./(sections)/computer-settings-section";
|
||||
import settingsRecordPageMeta from "../../(functions)/settings-record-page-meta";
|
||||
|
||||
export default function AdminSettingsComputerPage() {
|
||||
return <ComputerSettingsSection />;
|
||||
}
|
||||
|
||||
export const meta = settingsRecordPageMeta({ kind: "computers" });
|
||||
Reference in New Issue
Block a user