Add computer settings pages and restore workspace pages

This commit is contained in:
2026-10-19 13:30:02 +01:00
parent 7667f2bc2f
commit 7b1ccfeb62
36 changed files with 1082 additions and 131 deletions
@@ -7,7 +7,7 @@ import { Monitor } from "lucide-react";
import type { BotWithLastMessage } from "@/src/types";
import { BotComputersContext } from "../(data)/bot-computers-context";
import BotComputerOption from "./bot-computer-option";
import HeaderIconButton from "./header-icon-button";
import HeaderIconButton from "@/src/components/general/header-icon-button";
type Props = {
bot: BotWithLastMessage;
@@ -10,7 +10,7 @@ import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import BotHarnessForm from "@/src/pages/admin/settings/(partials)/bot-harness-form";
import HeaderIconButton from "./header-icon-button";
import HeaderIconButton from "@/src/components/general/header-icon-button";
type Props = {
bot: BotWithLastMessage;
@@ -11,7 +11,7 @@ import type { BotWithLastMessage } from "@/src/types";
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
import useBotSessions from "../(hooks)/use-bot-sessions";
import BotSessionRow from "./bot-session-row";
import HeaderIconButton from "./header-icon-button";
import HeaderIconButton from "@/src/components/general/header-icon-button";
type Props = {
bot: BotWithLastMessage;
@@ -14,7 +14,7 @@ import BotModelsModal from "./bot-models-modal";
import BotProfileModal from "./bot-profile-modal";
import BotSessionsModal from "./bot-sessions-modal";
import NewSessionButton from "./new-session-button";
import HeaderIconButton from "./header-icon-button";
import HeaderIconButton from "@/src/components/general/header-icon-button";
import HeaderMetaItem from "./header-meta-item";
type Props = {
@@ -1,50 +0,0 @@
import Button from "@moduletrace/twui/layout/Button";
import type { LucideIcon } from "lucide-react";
type Props = {
icon: LucideIcon;
/**
* Says what the button does; it has no visible label.
*/
title: string;
disabled?: boolean;
loading?: boolean;
/**
* For a button that acts on its own; a modal's target leaves it out.
*/
onClick?: () => void;
/**
* For a button that opens a page.
*/
href?: string;
};
/**
* A ghost icon button on the right of the conversation header, usually a
* modal's target.
*/
export default function HeaderIconButton({
icon: Icon,
title,
disabled,
loading,
onClick,
href,
}: Props) {
return (
<Button
title={title}
aria-label={title}
type="button"
variant="ghost"
color="text"
disabled={disabled}
loading={loading}
onClick={onClick}
href={href}
className="shrink-0 p-2! text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken rounded-default transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink"
>
<Icon size={18} aria-hidden />
</Button>
);
}
@@ -1,7 +1,7 @@
import { useState } from "react";
import { MessageSquarePlus } from "lucide-react";
import postSessionApi from "../(functions)/post-session-api";
import HeaderIconButton from "./header-icon-button";
import HeaderIconButton from "@/src/components/general/header-icon-button";
type Props = {
bot_id: number;
@@ -37,6 +37,17 @@ export default function settingsRecordPageMeta({
};
}
if (kind == "computers") {
const computer = props?.computer;
return {
title: `${computer?.name || "Computer"} settings | ${SiteData["SiteName"]}`,
description:
computer?.description ||
`Manage this computer on ${SiteData["SiteName"]}.`,
};
}
const person = props?.target_person;
const name =
[person?.first_name, person?.last_name].filter(Boolean).join(" ") ||
@@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import H1 from "@moduletrace/twui/layout/H1";
@@ -6,13 +7,21 @@ import Span from "@moduletrace/twui/layout/Span";
type Props = {
title: string;
description: string;
/**
* Controls at the end of the header, e.g. a settings link.
*/
action?: ReactNode;
};
/**
* Same 64px header as a conversation, so moving between bots and settings
* doesn't shift the page.
*/
export default function SettingsPaneHeader({ title, description }: Props) {
export default function SettingsPaneHeader({
title,
description,
action,
}: Props) {
return (
<Row className="h-16 shrink-0 gap-3! flex-nowrap items-center px-6 border-0 border-b border-solid border-hairline">
<Stack className="min-w-0 gap-0!">
@@ -23,6 +32,11 @@ export default function SettingsPaneHeader({ title, description }: Props) {
{description}
</Span>
</Stack>
{action ? (
<Row className="ml-auto shrink-0 gap-1! flex-nowrap justify-end">
{action}
</Row>
) : null}
</Row>
);
}
@@ -16,6 +16,10 @@ type Props = {
* A nested record page's name, for its breadcrumb trail.
*/
breadcrumb_title?: string;
/**
* Controls at the end of the pane header, e.g. a settings link.
*/
action?: ReactNode;
};
/**
@@ -28,10 +32,15 @@ export default function SettingsShell({
children,
wide,
breadcrumb_title,
action,
}: Props) {
return (
<Stack className="grow min-w-0 h-full gap-0! items-stretch">
<SettingsPaneHeader title={title} description={description} />
<SettingsPaneHeader
title={title}
description={description}
action={action}
/>
<Stack className="grow min-h-0 overflow-y-auto gap-0! items-stretch">
<Stack
className={twMerge(
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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" });
@@ -1,5 +1,9 @@
import { useContext, useState } from "react";
import EmptyContent from "@moduletrace/twui/elements/EmptyContent";
import { Settings } from "lucide-react";
import HeaderIconButton from "@/src/components/general/header-icon-button";
import { AppContext } from "@/src/pages/__root";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import SettingsSectionHeading from "../../../(partials)/settings-section-heading";
import SettingsShell from "../../../(partials)/settings-shell";
import WorkspaceBotsGroup from "../(partials)/workspace-bots-group";
@@ -9,11 +13,19 @@ import WorkspaceDetailsGroup from "../(partials)/workspace-details-group";
import WorkspaceImageGroup from "../(partials)/workspace-image-group";
import WorkspaceModels from "../(partials)/workspace-models";
type Props = {
/**
* On the workspace's own page: show the link into its settings.
*/
show_settings_link?: boolean;
};
/**
* A workspace's settings: its picture, context, bots, computers and
* models, then deleting it. Super admins and admins only.
* A workspace's page: its picture, context, bots, computers and models,
* then deleting it. Super admins and admins manage it; a member with no
* bots sees an empty note.
*/
export default function WorkspaceOverview() {
export default function WorkspaceOverview({ show_settings_link }: Props) {
const { pageProps } = useContext(AppContext);
const [workspace, setWorkspace] = useState(pageProps?.workspace);
@@ -22,6 +34,20 @@ export default function WorkspaceOverview() {
const workspace_id = Number(workspace.id);
const name = workspace.name || "Workspace";
const harnesses = pageProps?.workspace_harness_overview?.harnesses;
const can_manage = canManageCrew({
user_types: pageProps?.user_types,
});
if (!can_manage) {
return (
<SettingsShell
title={name}
description={workspace.description || "A workspace."}
>
<EmptyContent title="No bots here yet. An admin adds them." />
</SettingsShell>
);
}
return (
<SettingsShell
@@ -30,7 +56,16 @@ export default function WorkspaceOverview() {
workspace.description ||
"Context, bots, computers and models for this workspace."
}
breadcrumb_title={name}
breadcrumb_title={show_settings_link ? undefined : name}
action={
show_settings_link ? (
<HeaderIconButton
icon={Settings}
title="Workspace settings"
href={`/admin/settings/workspaces/${workspace_id}`}
/>
) : undefined
}
>
<WorkspaceImageGroup workspace={workspace} />
<WorkspaceDetailsGroup workspace={workspace} onSaved={setWorkspace} />
@@ -1,15 +1,19 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabUserWorkspace from "@/src/functions/backend/db/workspaces/grab-user-workspace";
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
import grabUserComputers from "@/src/functions/backend/db/computers/grab-user-computers";
import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview";
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
import grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots";
import grabWorkspaceComputers from "@/src/functions/backend/db/workspaces/grab-workspace-computers";
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
import type { PagePropsType, PageQueryObject } from "@/src/types";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import type { PagePropsType, PageQueryObject } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
/**
* The workspace's old landing page. Management moved to
* `/admin/settings/workspaces/<id>`: managers go there, members go to the
* workspace's first bot. No page renders.
* A workspace's page: its details, crew, computers and models, with the
* rail showing its crew. Only super admins and admins manage it; a member
* opens its first bot instead. Its settings live under Settings.
*/
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const query: PageQueryObject = params.query;
@@ -19,37 +23,68 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
return { redirect: { destination: "/auth/login" } };
}
const workspace = await grabUserWorkspace({
workspace_id: String(query.workspace_id),
user_id: user.id,
});
const workspaces = await grabUserWorkspaces({ user_id: user.id });
const workspace = workspaces.find(
(item) => String(item.id) == String(query.workspace_id),
);
if (!workspace?.id) {
return { redirect: { destination: AdminHomePath, permanent: false } };
return { redirect: { destination: AdminHomePath } };
}
const workspace_id = Number(workspace.id);
if (canManageCrew({ user_types })) {
/**
* Members don't manage it: they go straight to its first bot.
*/
if (!canManageCrew({ user_types })) {
const workspace_bots = await grabWorkspaceBots({
user_id: user.id,
workspace_id,
});
if (workspace_bots[0]?.id) {
return {
redirect: {
destination: `/admin/workspaces/${workspace_id}/bots/${workspace_bots[0].id}`,
},
};
}
return {
redirect: {
destination: `/admin/settings/workspaces/${workspace_id}`,
permanent: false,
props: {
workspaces,
workspace,
bots: workspace_bots,
workspace_bots,
},
};
}
const workspace_bots = await grabWorkspaceBots({
user_id: user.id,
workspace_id,
});
const [
workspace_bots,
all_bots,
workspace_computers,
computers,
workspace_harness_overview,
] = await Promise.all([
grabWorkspaceBots({ user_id: user.id, workspace_id }),
grabBots({ user_id: user.id }),
grabWorkspaceComputers({ workspace_id }),
grabUserComputers({ user_id: user.id }),
grabBotHarnessOverview({ workspace_id }).catch(() => null),
]);
return {
redirect: {
destination: workspace_bots[0]?.id
? `/admin/workspaces/${workspace_id}/bots/${workspace_bots[0].id}`
: AdminHomePath,
permanent: false,
props: {
workspaces,
workspace,
bots: workspace_bots,
workspace_bots,
all_bots,
workspace_computers,
computers,
workspace_harness_overview,
},
};
};
@@ -1,15 +1,18 @@
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
import type { PagePropsType } from "@/src/types";
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
import { SiteData } from "@/src/data/site-data";
import WorkspaceOverview from "@/src/pages/admin/settings/workspaces/[workspace_id]/(sections)/workspace-overview";
/**
* Never rendered: `index.server.ts` always redirects. Workspace settings
* live at `/admin/settings/workspaces/<id>`.
*/
export default function WorkspacePage() {
return null;
return <WorkspaceOverview show_settings_link />;
}
export const meta: BunextPageModuleMeta = {
title: `Workspaces | ${SiteData["SiteName"]}`,
description: `Your workspaces.`,
export const meta: BunextPageModuleMetaFn = async (params) => {
const workspace = (params.serverRes?.props as PagePropsType | undefined)
?.workspace;
return {
title: `${workspace?.name || "Workspace"} | ${SiteData["SiteName"]}`,
description: workspace?.description || `A workspace.`,
};
};
+5 -11
View File
@@ -2,15 +2,13 @@ import userAuth from "@/src/functions/backend/auth/user-auth";
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
import type { PagePropsType } from "@/src/types";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
/**
* Opens the user's first workspace: its settings page for managers, its
* first bot for members. The bots when they have no workspaces.
* Opens the user's first workspace, or the bots when they have none.
*/
export const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
const { user, user_types } = await userAuth({ req });
const { user } = await userAuth({ req });
if (!user?.id) {
return { redirect: { destination: "/auth/login" } };
@@ -18,15 +16,11 @@ export const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
const first = (await grabUserWorkspaces({ user_id: user.id }))[0];
if (!first?.id) {
return { redirect: { destination: AdminHomePath } };
}
return {
redirect: {
destination: canManageCrew({ user_types })
? `/admin/settings/workspaces/${first.id}`
: `/admin/workspaces/${first.id}`,
destination: first?.id
? `/admin/workspaces/${first.id}`
: AdminHomePath,
},
};
};