diff --git a/src/pages/admin/bots/[bot_id]/(partials)/header-icon-button.tsx b/src/components/general/header-icon-button.tsx similarity index 100% rename from src/pages/admin/bots/[bot_id]/(partials)/header-icon-button.tsx rename to src/components/general/header-icon-button.tsx diff --git a/src/dict/settings-record-sections-dict.ts b/src/dict/settings-record-sections-dict.ts index c72af8f..14eff17 100644 --- a/src/dict/settings-record-sections-dict.ts +++ b/src/dict/settings-record-sections-dict.ts @@ -28,6 +28,7 @@ export const SettingsRecordBack: Record< bots: { title: "All bots", href: "/admin/settings/bots" }, workspaces: { title: "All workspaces", href: "/admin/settings/workspaces" }, users: { title: "All users", href: "/admin/settings/users" }, + computers: { title: "All computers", href: "/admin/settings/computers" }, }; export const SettingsRecordSections: Record< @@ -55,4 +56,11 @@ export const SettingsRecordSections: Record< { title: "Edit", url: "#edit", Icon: Pencil }, { title: "Danger zone", url: "#danger", Icon: CircleAlert }, ], + computers: [ + { title: "Overview", url: "#overview", Icon: Info }, + { title: "Details", url: "#details", Icon: Pencil }, + { title: "Bots", url: "#bots", Icon: Bot }, + { title: "Workspaces", url: "#workspaces", Icon: Layers }, + { title: "Danger zone", url: "#danger", Icon: CircleAlert }, + ], }; diff --git a/src/functions/backend/db/computers/grab-computer-access.ts b/src/functions/backend/db/computers/grab-computer-access.ts new file mode 100644 index 0000000..d707ec7 --- /dev/null +++ b/src/functions/backend/db/computers/grab-computer-access.ts @@ -0,0 +1,45 @@ +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { + BUN_SQLITE_COBBLE_BOTS, + BUN_SQLITE_COBBLE_WORKSPACES, +} from "@/db/types/db"; + +type Params = { + computer_id: number | string; +}; + +export type ComputerAccess = { + bots: BUN_SQLITE_COBBLE_BOTS[]; + workspaces: BUN_SQLITE_COBBLE_WORKSPACES[]; +}; + +/** + * Who reaches a computer: the bots with direct access and the workspaces + * linked to it. The caller has already checked the user sees the + * computer. + */ +export default async function grabComputerAccess({ + computer_id, +}: Params): Promise { + const [bots, workspaces] = await Promise.all([ + BunSQLite.sql({ + sql: `SELECT b.* FROM bots b + JOIN bot_computers bc ON bc.bot_id = b.id + WHERE bc.computer_id = ? + ORDER BY b.kind = 'coordinator' DESC, b.name COLLATE NOCASE ASC, b.id ASC`, + values: [computer_id], + }), + BunSQLite.sql({ + sql: `SELECT w.* FROM workspaces w + JOIN workspace_computers wc ON wc.workspace_id = w.id + WHERE wc.computer_id = ? AND w.archived = 0 + ORDER BY w.name COLLATE NOCASE ASC, w.id ASC`, + values: [computer_id], + }), + ]); + + return { + bots: bots.payload || [], + workspaces: workspaces.payload || [], + }; +} diff --git a/src/layouts/bots/(partials)/add-computer-form.tsx b/src/layouts/bots/(partials)/add-computer-form.tsx index eda61ef..30c0716 100644 --- a/src/layouts/bots/(partials)/add-computer-form.tsx +++ b/src/layouts/bots/(partials)/add-computer-form.tsx @@ -14,7 +14,10 @@ type Props = { * Edits this computer; creates one when not given. */ computer?: ComputerWithStatus; - onCancel: () => void; + /** + * Left out when the form is embedded in a settings page. + */ + onCancel?: () => void; /** * Gets the new computer (still being set up) and the server's note. */ diff --git a/src/layouts/bots/(partials)/add-flow.tsx b/src/layouts/bots/(partials)/add-flow.tsx index 57df182..bae4b6f 100644 --- a/src/layouts/bots/(partials)/add-flow.tsx +++ b/src/layouts/bots/(partials)/add-flow.tsx @@ -122,7 +122,10 @@ export default function AddFlow({ children }: Props) { const pathname = pageProps?.url?.pathname; if ( - (linked && pathname == `/admin/settings/workspaces/${workspace?.id}`) || + (linked && + (pathname == `/admin/workspaces/${workspace?.id}` || + pathname == + `/admin/settings/workspaces/${workspace?.id}`)) || pathname == SettingsComputersPath ) { window.location.reload(); diff --git a/src/layouts/bots/(partials)/add-workspace-form.tsx b/src/layouts/bots/(partials)/add-workspace-form.tsx index 39a5ee7..8bd3ea2 100644 --- a/src/layouts/bots/(partials)/add-workspace-form.tsx +++ b/src/layouts/bots/(partials)/add-workspace-form.tsx @@ -49,7 +49,7 @@ export default function AddWorkspaceForm({ onCancel }: Props) { await setProfileImage({ target: "workspace", id: workspace_id, data_url: image.data_url }).catch(() => null); } - window.location.href = `/admin/settings/workspaces/${workspace_id}`; + window.location.href = `/admin/workspaces/${workspace_id}`; }; return ( diff --git a/src/layouts/bots/(partials)/rail-settings-aside.tsx b/src/layouts/bots/(partials)/rail-settings-aside.tsx index 05198c4..579b8d5 100644 --- a/src/layouts/bots/(partials)/rail-settings-aside.tsx +++ b/src/layouts/bots/(partials)/rail-settings-aside.tsx @@ -1,6 +1,7 @@ import { useContext, type ReactNode } from "react"; import Divider from "@moduletrace/twui/layout/Divider"; import BotAvatar from "@/src/components/general/bot-avatar"; +import ComputerIconTile from "@/src/components/general/computer-icon-tile"; import UserAvatar from "@/src/components/general/user-avatar"; import WorkspaceAvatar from "@/src/components/general/workspace-avatar"; import { UserTypes } from "@/src/dict/user-types-dict"; @@ -10,8 +11,11 @@ import { SettingsRecordSections, } from "@/src/dict/settings-record-sections-dict"; import { AppContext } from "@/src/pages/__root"; +import computerResourcesLabel from "@/src/utils/computer-resources-label"; +import computerStatusLabel from "@/src/utils/computer-status-label"; import parseSettingsRecordPath from "@/src/utils/parse-settings-record-path"; import parseUserTypes from "@/src/utils/parse-user-types"; +import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user"; import RailBackLink from "./rail-back-link"; import RailSettingsLink from "./rail-settings-link"; import RailSettingsList from "./rail-settings-list"; @@ -35,6 +39,9 @@ export default function RailSettingsAside({ pathname }: Props) { } const back = SettingsRecordBack[record.kind]; + const is_super_admin = isPassThroughUser({ + user_types: pageProps?.user_types, + }); const sections = SettingsRecordSections[record.kind].filter((section) => { if ( record.kind == "bots" && @@ -44,6 +51,14 @@ export default function RailSettingsAside({ pathname }: Props) { return false; } + if ( + record.kind == "computers" && + !is_super_admin && + (section.url == "#details" || section.url == "#danger") + ) { + return false; + } + if (section.url == "#models") { return record.kind == "bots" ? Boolean(pageProps?.bot_harness_overview?.harnesses?.length) @@ -89,7 +104,7 @@ export default function RailSettingsAside({ pathname }: Props) { avatar = ( ); - } else { + } else if (record.kind == "users") { const person = pageProps?.target_person; if (!person?.id) return ; @@ -108,6 +123,17 @@ export default function RailSettingsAside({ pathname }: Props) { `User ${person.id}`; subtitle = types || person.email || "User"; avatar = ; + } else { + const computer = pageProps?.computer; + + if (!computer?.id) return ; + + title = computer.name || `Computer ${computer.id}`; + subtitle = [ + computerStatusLabel({ computer }), + computerResourcesLabel({ computer }), + ].join(" · "); + avatar = ; } return ( diff --git a/src/layouts/bots/(partials)/workspace-row.tsx b/src/layouts/bots/(partials)/workspace-row.tsx index 52da35a..43e62c8 100644 --- a/src/layouts/bots/(partials)/workspace-row.tsx +++ b/src/layouts/bots/(partials)/workspace-row.tsx @@ -1,12 +1,9 @@ -import { useContext } from "react"; import Link from "@moduletrace/twui/layout/Link"; import Span from "@moduletrace/twui/layout/Span"; import Stack from "@moduletrace/twui/layout/Stack"; import WorkspaceAvatar from "@/src/components/general/workspace-avatar"; import { twMerge } from "tailwind-merge"; -import { AppContext } from "@/src/pages/__root"; import type { WorkspaceWithActivity } from "@/src/types"; -import canManageCrew from "@/src/utils/rbac/can-manage-crew"; type Props = { workspace: WorkspaceWithActivity; @@ -18,14 +15,9 @@ type Props = { * hue, so unread is said in words beside the description. */ export default function WorkspaceRow({ workspace, selected }: Props) { - const { pageProps } = useContext(AppContext); - const href = canManageCrew({ user_types: pageProps?.user_types }) - ? `/admin/settings/workspaces/${workspace.id}` - : `/admin/workspaces/${workspace.id}`; - return ( @@ -23,6 +32,11 @@ export default function SettingsPaneHeader({ title, description }: Props) { {description} + {action ? ( + + {action} + + ) : null} ); } diff --git a/src/pages/admin/settings/(partials)/settings-shell.tsx b/src/pages/admin/settings/(partials)/settings-shell.tsx index 1f62ca8..22c29b6 100644 --- a/src/pages/admin/settings/(partials)/settings-shell.tsx +++ b/src/pages/admin/settings/(partials)/settings-shell.tsx @@ -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 ( - + 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 ( - !open && onClose()} - title={`Edit ${computer?.name || "the computer"}`} - description="New CPUs or memory restart it with them. Its files stay." - > - {computer ? ( - - ) : null} - - ); -} diff --git a/src/pages/admin/settings/computers/(partials)/computer-settings-row.tsx b/src/pages/admin/settings/computers/(partials)/computer-settings-row.tsx index fae0f92..127e00a 100644 --- a/src/pages/admin/settings/computers/(partials)/computer-settings-row.tsx +++ b/src/pages/admin/settings/computers/(partials)/computer-settings-row.tsx @@ -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 ( } + 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 ? ( - - ) : null} {is_super_admin && computer.status == "failed" ? ( - + ) : null} {is_super_admin && computer.kind != "main" ? ( - + ) : null} ); diff --git a/src/pages/admin/settings/computers/(sections)/computers-settings-section.tsx b/src/pages/admin/settings/computers/(sections)/computers-settings-section.tsx index 7fce403..3d547e7 100644 --- a/src/pages/admin/settings/computers/(sections)/computers-settings-section.tsx +++ b/src/pages/admin/settings/computers/(sections)/computers-settings-section.tsx @@ -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(); + const { computers, setComputers } = useLiveComputers({ + computers: pageProps?.computers || [], + include_new: true, + }); const [busy, setBusy] = useState(false); const [status, setStatus] = useState(); - 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({ path, body: { computer_id } }); + const res = await postAdminApi({ + 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() { ) : null} - setEditing(undefined)} - onUpdated={({ computer, msg }) => { - merge(computer); - setEditing(undefined); - setStatus({ success: true, msg }); - }} - /> ); } diff --git a/src/pages/admin/settings/computers/[computer_id]/(partials)/computer-bot-row.tsx b/src/pages/admin/settings/computers/[computer_id]/(partials)/computer-bot-row.tsx new file mode 100644 index 0000000..3b0ce42 --- /dev/null +++ b/src/pages/admin/settings/computers/[computer_id]/(partials)/computer-bot-row.tsx @@ -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 ( + + } + href={href} + title={name} + subtitle={ + bot.kind == "coordinator" + ? "Coordinates your crew and can use every computer" + : bot.role || "Worker bot" + } + > + {onRemove ? ( + + ) : null} + + ); +} diff --git a/src/pages/admin/settings/computers/[computer_id]/(partials)/computer-bots-group.tsx b/src/pages/admin/settings/computers/[computer_id]/(partials)/computer-bots-group.tsx new file mode 100644 index 0000000..384d543 --- /dev/null +++ b/src/pages/admin/settings/computers/[computer_id]/(partials)/computer-bots-group.tsx @@ -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(); + const [status, setStatus] = useState(); + + 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 ( + + + {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 ( + + change({ + path: "remove-computer", + bot_id: Number(bot.id), + }) + } + /> + ); + })} + + ({ + value: String(bot.id), + title: bot.name || `Bot ${bot.id}`, + }))} + loading={busy_id == "add"} + onAdd={({ value }) => + change({ path: "add-computer", bot_id: Number(value) }) + } + /> + + + ); +} diff --git a/src/pages/admin/settings/computers/[computer_id]/(partials)/computer-danger-group.tsx b/src/pages/admin/settings/computers/[computer_id]/(partials)/computer-danger-group.tsx new file mode 100644 index 0000000..5df79c6 --- /dev/null +++ b/src/pages/admin/settings/computers/[computer_id]/(partials)/computer-danger-group.tsx @@ -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(); + 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({ + 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 ( + + {can_retry || can_delete ? ( + + {can_retry ? ( + + ) : null} + {can_delete ? ( + + ) : null} + + ) : ( + + Nothing to do here. The main computer stays. + + )} + + + ); +} diff --git a/src/pages/admin/settings/computers/[computer_id]/(partials)/computer-details-group.tsx b/src/pages/admin/settings/computers/[computer_id]/(partials)/computer-details-group.tsx new file mode 100644 index 0000000..1d0821b --- /dev/null +++ b/src/pages/admin/settings/computers/[computer_id]/(partials)/computer-details-group.tsx @@ -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 ( + + { + onUpdated(saved); + setStatus({ success: true, msg }); + }} + /> + + + ); +} diff --git a/src/pages/admin/settings/computers/[computer_id]/(partials)/computer-overview-group.tsx b/src/pages/admin/settings/computers/[computer_id]/(partials)/computer-overview-group.tsx new file mode 100644 index 0000000..736a42b --- /dev/null +++ b/src/pages/admin/settings/computers/[computer_id]/(partials)/computer-overview-group.tsx @@ -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 ( + + + + + + + {name} + + {computer.kind == "main" ? ( + + Main + + ) : null} + + + {[ + computerStatusLabel({ computer }), + computerResourcesLabel({ computer }), + computer.description, + ] + .filter(Boolean) + .join(" · ")} + + + + + + ); +} diff --git a/src/pages/admin/settings/computers/[computer_id]/(partials)/computer-workspace-row.tsx b/src/pages/admin/settings/computers/[computer_id]/(partials)/computer-workspace-row.tsx new file mode 100644 index 0000000..3b61794 --- /dev/null +++ b/src/pages/admin/settings/computers/[computer_id]/(partials)/computer-workspace-row.tsx @@ -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 ( + + } + href={`/admin/settings/workspaces/${workspace.id}`} + title={name} + subtitle={workspace.description || "Every bot here can use this computer"} + > + + + ); +} diff --git a/src/pages/admin/settings/computers/[computer_id]/(partials)/computer-workspaces-group.tsx b/src/pages/admin/settings/computers/[computer_id]/(partials)/computer-workspaces-group.tsx new file mode 100644 index 0000000..129bc13 --- /dev/null +++ b/src/pages/admin/settings/computers/[computer_id]/(partials)/computer-workspaces-group.tsx @@ -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(); + const [status, setStatus] = useState(); + + 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 ( + + + {computer_workspaces.map((workspace) => ( + + change({ + path: "remove-computer", + workspace_id: Number(workspace.id), + }) + } + /> + ))} + + ({ + value: String(workspace.id), + title: workspace.name || `Workspace ${workspace.id}`, + }))} + loading={busy_id == "add"} + onAdd={({ value }) => + change({ + path: "add-computer", + workspace_id: Number(value), + }) + } + /> + + + ); +} diff --git a/src/pages/admin/settings/computers/[computer_id]/(sections)/computer-settings-section.tsx b/src/pages/admin/settings/computers/[computer_id]/(sections)/computer-settings-section.tsx new file mode 100644 index 0000000..adee61e --- /dev/null +++ b/src/pages/admin/settings/computers/[computer_id]/(sections)/computer-settings-section.tsx @@ -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 ( + + + {is_super_admin ? ( + + ) : null} + + + {is_super_admin ? ( + + ) : null} + + ); +} diff --git a/src/pages/admin/settings/computers/[computer_id]/index.server.ts b/src/pages/admin/settings/computers/[computer_id]/index.server.ts new file mode 100644 index 0000000..0497107 --- /dev/null +++ b/src/pages/admin/settings/computers/[computer_id]/index.server.ts @@ -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 = 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, + }, + }; +}; diff --git a/src/pages/admin/settings/computers/[computer_id]/index.tsx b/src/pages/admin/settings/computers/[computer_id]/index.tsx new file mode 100644 index 0000000..efcc663 --- /dev/null +++ b/src/pages/admin/settings/computers/[computer_id]/index.tsx @@ -0,0 +1,8 @@ +import ComputerSettingsSection from "./(sections)/computer-settings-section"; +import settingsRecordPageMeta from "../../(functions)/settings-record-page-meta"; + +export default function AdminSettingsComputerPage() { + return ; +} + +export const meta = settingsRecordPageMeta({ kind: "computers" }); diff --git a/src/pages/admin/settings/workspaces/[workspace_id]/(sections)/workspace-overview.tsx b/src/pages/admin/settings/workspaces/[workspace_id]/(sections)/workspace-overview.tsx index 7d6d555..aaadc3f 100644 --- a/src/pages/admin/settings/workspaces/[workspace_id]/(sections)/workspace-overview.tsx +++ b/src/pages/admin/settings/workspaces/[workspace_id]/(sections)/workspace-overview.tsx @@ -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 ( + + + + ); + } return ( + ) : undefined + } > diff --git a/src/pages/admin/workspaces/[workspace_id]/index.server.ts b/src/pages/admin/workspaces/[workspace_id]/index.server.ts index f0aa56e..0aaa255 100644 --- a/src/pages/admin/workspaces/[workspace_id]/index.server.ts +++ b/src/pages/admin/workspaces/[workspace_id]/index.server.ts @@ -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/`: 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 = async (params) => { const query: PageQueryObject = params.query; @@ -19,37 +23,68 @@ export const server: BunextPageServerFn = 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, }, }; }; diff --git a/src/pages/admin/workspaces/[workspace_id]/index.tsx b/src/pages/admin/workspaces/[workspace_id]/index.tsx index 23b4878..36e7d69 100644 --- a/src/pages/admin/workspaces/[workspace_id]/index.tsx +++ b/src/pages/admin/workspaces/[workspace_id]/index.tsx @@ -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/`. - */ export default function WorkspacePage() { - return null; + return ; } -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.`, + }; }; diff --git a/src/pages/admin/workspaces/index.server.ts b/src/pages/admin/workspaces/index.server.ts index a86a343..ba1a139 100644 --- a/src/pages/admin/workspaces/index.server.ts +++ b/src/pages/admin/workspaces/index.server.ts @@ -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 = 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 = 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, }, }; }; diff --git a/src/types/index.ts b/src/types/index.ts index 370815e..c8a5f84 100755 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -392,6 +392,12 @@ export type PagePropsType = { all_bots?: BotWithLastMessage[] | null; workspace_computers?: ComputerWithStatus[] | null; computers?: ComputerWithStatus[] | null; + /** + * The computer a settings page manages, with its access. + */ + computer?: ComputerWithStatus | null; + computer_bots?: BUN_SQLITE_COBBLE_BOTS[] | null; + computer_workspaces?: BUN_SQLITE_COBBLE_WORKSPACES[] | null; workspace_harness_overview?: BotHarnessOverviewPayload | null; /** * The workspaces a bot works in, with what it has for each alone. @@ -815,6 +821,7 @@ export type PageQueryObject = { user_id?: string; bot_id?: string; workspace_id?: string; + computer_id?: string; event_id?: string; user_type?: UserType; }; diff --git a/src/utils/parse-settings-record-path.test.ts b/src/utils/parse-settings-record-path.test.ts index fd3e106..8c3b75a 100644 --- a/src/utils/parse-settings-record-path.test.ts +++ b/src/utils/parse-settings-record-path.test.ts @@ -14,6 +14,11 @@ describe("parseSettingsRecordPath", () => { expect( parseSettingsRecordPath({ pathname: "/admin/settings/users/8" }), ).toEqual({ kind: "users", id: "8" }); + expect( + parseSettingsRecordPath({ + pathname: "/admin/settings/computers/2", + }), + ).toEqual({ kind: "computers", id: "2" }); }); test("ignores settings list pages and other paths", () => { diff --git a/src/utils/parse-settings-record-path.ts b/src/utils/parse-settings-record-path.ts index 91c8449..c40757a 100644 --- a/src/utils/parse-settings-record-path.ts +++ b/src/utils/parse-settings-record-path.ts @@ -1,4 +1,8 @@ -export type SettingsRecordKind = "bots" | "workspaces" | "users"; +export type SettingsRecordKind = + | "bots" + | "workspaces" + | "users" + | "computers"; export type SettingsRecordPath = { kind: SettingsRecordKind; @@ -21,7 +25,9 @@ export default function parseSettingsRecordPath({ const match = pathname .replace(/\/$/, "") - .match(/^\/admin\/settings\/(bots|workspaces|users)\/([^/]+)$/); + .match( + /^\/admin\/settings\/(bots|workspaces|users|computers)\/([^/]+)$/, + ); if (!match) return null;