Add computer settings pages and restore workspace pages
This commit is contained in:
@@ -28,6 +28,7 @@ export const SettingsRecordBack: Record<
|
|||||||
bots: { title: "All bots", href: "/admin/settings/bots" },
|
bots: { title: "All bots", href: "/admin/settings/bots" },
|
||||||
workspaces: { title: "All workspaces", href: "/admin/settings/workspaces" },
|
workspaces: { title: "All workspaces", href: "/admin/settings/workspaces" },
|
||||||
users: { title: "All users", href: "/admin/settings/users" },
|
users: { title: "All users", href: "/admin/settings/users" },
|
||||||
|
computers: { title: "All computers", href: "/admin/settings/computers" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SettingsRecordSections: Record<
|
export const SettingsRecordSections: Record<
|
||||||
@@ -55,4 +56,11 @@ export const SettingsRecordSections: Record<
|
|||||||
{ title: "Edit", url: "#edit", Icon: Pencil },
|
{ title: "Edit", url: "#edit", Icon: Pencil },
|
||||||
{ title: "Danger zone", url: "#danger", Icon: CircleAlert },
|
{ 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 },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<ComputerAccess> {
|
||||||
|
const [bots, workspaces] = await Promise.all([
|
||||||
|
BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
|
||||||
|
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<BUN_SQLITE_COBBLE_WORKSPACES>({
|
||||||
|
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 || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -14,7 +14,10 @@ type Props = {
|
|||||||
* Edits this computer; creates one when not given.
|
* Edits this computer; creates one when not given.
|
||||||
*/
|
*/
|
||||||
computer?: ComputerWithStatus;
|
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.
|
* Gets the new computer (still being set up) and the server's note.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -122,7 +122,10 @@ export default function AddFlow({ children }: Props) {
|
|||||||
const pathname = pageProps?.url?.pathname;
|
const pathname = pageProps?.url?.pathname;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(linked && pathname == `/admin/settings/workspaces/${workspace?.id}`) ||
|
(linked &&
|
||||||
|
(pathname == `/admin/workspaces/${workspace?.id}` ||
|
||||||
|
pathname ==
|
||||||
|
`/admin/settings/workspaces/${workspace?.id}`)) ||
|
||||||
pathname == SettingsComputersPath
|
pathname == SettingsComputersPath
|
||||||
) {
|
) {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export default function AddWorkspaceForm({ onCancel }: Props) {
|
|||||||
await setProfileImage({ target: "workspace", id: workspace_id, data_url: image.data_url }).catch(() => null);
|
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 (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useContext, type ReactNode } from "react";
|
import { useContext, type ReactNode } from "react";
|
||||||
import Divider from "@moduletrace/twui/layout/Divider";
|
import Divider from "@moduletrace/twui/layout/Divider";
|
||||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
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 UserAvatar from "@/src/components/general/user-avatar";
|
||||||
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
|
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
|
||||||
import { UserTypes } from "@/src/dict/user-types-dict";
|
import { UserTypes } from "@/src/dict/user-types-dict";
|
||||||
@@ -10,8 +11,11 @@ import {
|
|||||||
SettingsRecordSections,
|
SettingsRecordSections,
|
||||||
} from "@/src/dict/settings-record-sections-dict";
|
} from "@/src/dict/settings-record-sections-dict";
|
||||||
import { AppContext } from "@/src/pages/__root";
|
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 parseSettingsRecordPath from "@/src/utils/parse-settings-record-path";
|
||||||
import parseUserTypes from "@/src/utils/parse-user-types";
|
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 RailBackLink from "./rail-back-link";
|
||||||
import RailSettingsLink from "./rail-settings-link";
|
import RailSettingsLink from "./rail-settings-link";
|
||||||
import RailSettingsList from "./rail-settings-list";
|
import RailSettingsList from "./rail-settings-list";
|
||||||
@@ -35,6 +39,9 @@ export default function RailSettingsAside({ pathname }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const back = SettingsRecordBack[record.kind];
|
const back = SettingsRecordBack[record.kind];
|
||||||
|
const is_super_admin = isPassThroughUser({
|
||||||
|
user_types: pageProps?.user_types,
|
||||||
|
});
|
||||||
const sections = SettingsRecordSections[record.kind].filter((section) => {
|
const sections = SettingsRecordSections[record.kind].filter((section) => {
|
||||||
if (
|
if (
|
||||||
record.kind == "bots" &&
|
record.kind == "bots" &&
|
||||||
@@ -44,6 +51,14 @@ export default function RailSettingsAside({ pathname }: Props) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
record.kind == "computers" &&
|
||||||
|
!is_super_admin &&
|
||||||
|
(section.url == "#details" || section.url == "#danger")
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (section.url == "#models") {
|
if (section.url == "#models") {
|
||||||
return record.kind == "bots"
|
return record.kind == "bots"
|
||||||
? Boolean(pageProps?.bot_harness_overview?.harnesses?.length)
|
? Boolean(pageProps?.bot_harness_overview?.harnesses?.length)
|
||||||
@@ -89,7 +104,7 @@ export default function RailSettingsAside({ pathname }: Props) {
|
|||||||
avatar = (
|
avatar = (
|
||||||
<WorkspaceAvatar image_media_id={workspace.image_media_id} />
|
<WorkspaceAvatar image_media_id={workspace.image_media_id} />
|
||||||
);
|
);
|
||||||
} else {
|
} else if (record.kind == "users") {
|
||||||
const person = pageProps?.target_person;
|
const person = pageProps?.target_person;
|
||||||
|
|
||||||
if (!person?.id) return <RailSettingsList pathname={pathname} />;
|
if (!person?.id) return <RailSettingsList pathname={pathname} />;
|
||||||
@@ -108,6 +123,17 @@ export default function RailSettingsAside({ pathname }: Props) {
|
|||||||
`User ${person.id}`;
|
`User ${person.id}`;
|
||||||
subtitle = types || person.email || "User";
|
subtitle = types || person.email || "User";
|
||||||
avatar = <UserAvatar person={person} size="md" />;
|
avatar = <UserAvatar person={person} size="md" />;
|
||||||
|
} else {
|
||||||
|
const computer = pageProps?.computer;
|
||||||
|
|
||||||
|
if (!computer?.id) return <RailSettingsList pathname={pathname} />;
|
||||||
|
|
||||||
|
title = computer.name || `Computer ${computer.id}`;
|
||||||
|
subtitle = [
|
||||||
|
computerStatusLabel({ computer }),
|
||||||
|
computerResourcesLabel({ computer }),
|
||||||
|
].join(" · ");
|
||||||
|
avatar = <ComputerIconTile />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { useContext } from "react";
|
|
||||||
import Link from "@moduletrace/twui/layout/Link";
|
import Link from "@moduletrace/twui/layout/Link";
|
||||||
import Span from "@moduletrace/twui/layout/Span";
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
import Stack from "@moduletrace/twui/layout/Stack";
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
|
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { AppContext } from "@/src/pages/__root";
|
|
||||||
import type { WorkspaceWithActivity } from "@/src/types";
|
import type { WorkspaceWithActivity } from "@/src/types";
|
||||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
workspace: WorkspaceWithActivity;
|
workspace: WorkspaceWithActivity;
|
||||||
@@ -18,14 +15,9 @@ type Props = {
|
|||||||
* hue, so unread is said in words beside the description.
|
* hue, so unread is said in words beside the description.
|
||||||
*/
|
*/
|
||||||
export default function WorkspaceRow({ workspace, selected }: Props) {
|
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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={`/admin/workspaces/${workspace.id}`}
|
||||||
aria-current={selected ? "page" : undefined}
|
aria-current={selected ? "page" : undefined}
|
||||||
title={workspace.name}
|
title={workspace.name}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Monitor } from "lucide-react";
|
|||||||
import type { BotWithLastMessage } from "@/src/types";
|
import type { BotWithLastMessage } from "@/src/types";
|
||||||
import { BotComputersContext } from "../(data)/bot-computers-context";
|
import { BotComputersContext } from "../(data)/bot-computers-context";
|
||||||
import BotComputerOption from "./bot-computer-option";
|
import BotComputerOption from "./bot-computer-option";
|
||||||
import HeaderIconButton from "./header-icon-button";
|
import HeaderIconButton from "@/src/components/general/header-icon-button";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
bot: BotWithLastMessage;
|
bot: BotWithLastMessage;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
|
|||||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||||
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
|
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
|
||||||
import BotHarnessForm from "@/src/pages/admin/settings/(partials)/bot-harness-form";
|
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 = {
|
type Props = {
|
||||||
bot: BotWithLastMessage;
|
bot: BotWithLastMessage;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type { BotWithLastMessage } from "@/src/types";
|
|||||||
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
|
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
|
||||||
import useBotSessions from "../(hooks)/use-bot-sessions";
|
import useBotSessions from "../(hooks)/use-bot-sessions";
|
||||||
import BotSessionRow from "./bot-session-row";
|
import BotSessionRow from "./bot-session-row";
|
||||||
import HeaderIconButton from "./header-icon-button";
|
import HeaderIconButton from "@/src/components/general/header-icon-button";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
bot: BotWithLastMessage;
|
bot: BotWithLastMessage;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import BotModelsModal from "./bot-models-modal";
|
|||||||
import BotProfileModal from "./bot-profile-modal";
|
import BotProfileModal from "./bot-profile-modal";
|
||||||
import BotSessionsModal from "./bot-sessions-modal";
|
import BotSessionsModal from "./bot-sessions-modal";
|
||||||
import NewSessionButton from "./new-session-button";
|
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";
|
import HeaderMetaItem from "./header-meta-item";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { MessageSquarePlus } from "lucide-react";
|
import { MessageSquarePlus } from "lucide-react";
|
||||||
import postSessionApi from "../(functions)/post-session-api";
|
import postSessionApi from "../(functions)/post-session-api";
|
||||||
import HeaderIconButton from "./header-icon-button";
|
import HeaderIconButton from "@/src/components/general/header-icon-button";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
bot_id: number;
|
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 person = props?.target_person;
|
||||||
const name =
|
const name =
|
||||||
[person?.first_name, person?.last_name].filter(Boolean).join(" ") ||
|
[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 Row from "@moduletrace/twui/layout/Row";
|
||||||
import Stack from "@moduletrace/twui/layout/Stack";
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
import H1 from "@moduletrace/twui/layout/H1";
|
import H1 from "@moduletrace/twui/layout/H1";
|
||||||
@@ -6,13 +7,21 @@ import Span from "@moduletrace/twui/layout/Span";
|
|||||||
type Props = {
|
type Props = {
|
||||||
title: string;
|
title: string;
|
||||||
description: 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
|
* Same 64px header as a conversation, so moving between bots and settings
|
||||||
* doesn't shift the page.
|
* doesn't shift the page.
|
||||||
*/
|
*/
|
||||||
export default function SettingsPaneHeader({ title, description }: Props) {
|
export default function SettingsPaneHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<Row className="h-16 shrink-0 gap-3! flex-nowrap items-center px-6 border-0 border-b border-solid border-hairline">
|
<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!">
|
<Stack className="min-w-0 gap-0!">
|
||||||
@@ -23,6 +32,11 @@ export default function SettingsPaneHeader({ title, description }: Props) {
|
|||||||
{description}
|
{description}
|
||||||
</Span>
|
</Span>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
{action ? (
|
||||||
|
<Row className="ml-auto shrink-0 gap-1! flex-nowrap justify-end">
|
||||||
|
{action}
|
||||||
|
</Row>
|
||||||
|
) : null}
|
||||||
</Row>
|
</Row>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ type Props = {
|
|||||||
* A nested record page's name, for its breadcrumb trail.
|
* A nested record page's name, for its breadcrumb trail.
|
||||||
*/
|
*/
|
||||||
breadcrumb_title?: string;
|
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,
|
children,
|
||||||
wide,
|
wide,
|
||||||
breadcrumb_title,
|
breadcrumb_title,
|
||||||
|
action,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<Stack className="grow min-w-0 h-full gap-0! items-stretch">
|
<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="grow min-h-0 overflow-y-auto gap-0! items-stretch">
|
||||||
<Stack
|
<Stack
|
||||||
className={twMerge(
|
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;
|
computer: ComputerWithStatus;
|
||||||
is_super_admin: boolean;
|
is_super_admin: boolean;
|
||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
onEdit: () => void;
|
|
||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One computer: its name, state and resources, then (super admins) Edit,
|
* One computer: its name, state and resources. The title opens its
|
||||||
* Retry when its setup failed, and Delete (never the main one).
|
* 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 name = computer.name || `Computer ${computer.id}`;
|
||||||
const busy_here = busy || computer.status == "creating";
|
const busy_here = busy || computer.status == "creating";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsListRow
|
<SettingsListRow
|
||||||
avatar={<ComputerIconTile />}
|
avatar={<ComputerIconTile />}
|
||||||
|
href={`/admin/settings/computers/${computer.id}`}
|
||||||
title={name}
|
title={name}
|
||||||
badge={computer.kind == "main" ? "Main" : undefined}
|
badge={computer.kind == "main" ? "Main" : undefined}
|
||||||
subtitle={[computerStatusLabel({ computer }), computerResourcesLabel({ computer }), computer.description]
|
subtitle={[
|
||||||
|
computerStatusLabel({ computer }),
|
||||||
|
computerResourcesLabel({ computer }),
|
||||||
|
computer.description,
|
||||||
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" · ")}
|
.join(" · ")}
|
||||||
>
|
>
|
||||||
{is_super_admin ? (
|
|
||||||
<SettingsRowAction title={`Edit ${name}`} label="Edit" disabled={busy_here} onClick={onEdit} />
|
|
||||||
) : null}
|
|
||||||
{is_super_admin && computer.status == "failed" ? (
|
{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}
|
) : null}
|
||||||
{is_super_admin && computer.kind != "main" ? (
|
{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}
|
) : null}
|
||||||
</SettingsListRow>
|
</SettingsListRow>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,34 +10,48 @@ import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
|
|||||||
import SettingsGroup from "../../(partials)/settings-group";
|
import SettingsGroup from "../../(partials)/settings-group";
|
||||||
import SettingsList from "../../(partials)/settings-list";
|
import SettingsList from "../../(partials)/settings-list";
|
||||||
import SettingsStatus from "../../(partials)/settings-status";
|
import SettingsStatus from "../../(partials)/settings-status";
|
||||||
import ComputerEditModal from "../(partials)/computer-edit-modal";
|
|
||||||
import ComputerSettingsRow from "../(partials)/computer-settings-row";
|
import ComputerSettingsRow from "../(partials)/computer-settings-row";
|
||||||
|
|
||||||
type Status = { error?: boolean; success?: boolean; msg?: string };
|
type Status = { error?: boolean; success?: boolean; msg?: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every computer the user has, main first, kept live: its state and
|
* Every computer the user has, main first, kept live: its state and
|
||||||
* resources. Super admins create, edit (new resources restart it, its
|
* resources. A row opens that computer's settings page; super admins
|
||||||
* files stay), retry and delete them.
|
* create, retry and delete them from here too.
|
||||||
*/
|
*/
|
||||||
export default function ComputersSettingsSection() {
|
export default function ComputersSettingsSection() {
|
||||||
const { pageProps } = useContext(AppContext);
|
const { pageProps } = useContext(AppContext);
|
||||||
const { openForm } = useContext(AddFlowContext);
|
const { openForm } = useContext(AddFlowContext);
|
||||||
const { computers, setComputers } = useLiveComputers({ computers: pageProps?.computers || [], include_new: true });
|
const { computers, setComputers } = useLiveComputers({
|
||||||
const [editing, setEditing] = useState<ComputerWithStatus>();
|
computers: pageProps?.computers || [],
|
||||||
|
include_new: true,
|
||||||
|
});
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [status, setStatus] = useState<Status>();
|
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) =>
|
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);
|
setBusy(true);
|
||||||
setStatus(undefined);
|
setStatus(undefined);
|
||||||
|
|
||||||
const res = await postAdminApi<ComputerWithStatus>({ path, body: { computer_id } });
|
const res = await postAdminApi<ComputerWithStatus>({
|
||||||
|
path,
|
||||||
|
body: { computer_id },
|
||||||
|
});
|
||||||
|
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
|
||||||
@@ -64,12 +78,24 @@ export default function ComputersSettingsSection() {
|
|||||||
computer={computer}
|
computer={computer}
|
||||||
is_super_admin={is_super_admin}
|
is_super_admin={is_super_admin}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
onEdit={() => setEditing(computer)}
|
onRetry={() =>
|
||||||
onRetry={() => run({ path: "computers/retry-computer", computer_id: Number(computer.id) })}
|
run({
|
||||||
|
path: "computers/retry-computer",
|
||||||
|
computer_id: Number(computer.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
onDelete={() => {
|
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>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<SettingsStatus status={status} />
|
<SettingsStatus status={status} />
|
||||||
<ComputerEditModal
|
|
||||||
computer={editing}
|
|
||||||
onClose={() => setEditing(undefined)}
|
|
||||||
onUpdated={({ computer, msg }) => {
|
|
||||||
merge(computer);
|
|
||||||
setEditing(undefined);
|
|
||||||
setStatus({ success: true, msg });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</SettingsGroup>
|
</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" });
|
||||||
+39
-4
@@ -1,5 +1,9 @@
|
|||||||
import { useContext, useState } from "react";
|
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 { AppContext } from "@/src/pages/__root";
|
||||||
|
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||||
import SettingsSectionHeading from "../../../(partials)/settings-section-heading";
|
import SettingsSectionHeading from "../../../(partials)/settings-section-heading";
|
||||||
import SettingsShell from "../../../(partials)/settings-shell";
|
import SettingsShell from "../../../(partials)/settings-shell";
|
||||||
import WorkspaceBotsGroup from "../(partials)/workspace-bots-group";
|
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 WorkspaceImageGroup from "../(partials)/workspace-image-group";
|
||||||
import WorkspaceModels from "../(partials)/workspace-models";
|
import WorkspaceModels from "../(partials)/workspace-models";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
/**
|
/**
|
||||||
* A workspace's settings: its picture, context, bots, computers and
|
* On the workspace's own page: show the link into its settings.
|
||||||
* models, then deleting it. Super admins and admins only.
|
|
||||||
*/
|
*/
|
||||||
export default function WorkspaceOverview() {
|
show_settings_link?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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({ show_settings_link }: Props) {
|
||||||
const { pageProps } = useContext(AppContext);
|
const { pageProps } = useContext(AppContext);
|
||||||
const [workspace, setWorkspace] = useState(pageProps?.workspace);
|
const [workspace, setWorkspace] = useState(pageProps?.workspace);
|
||||||
|
|
||||||
@@ -22,6 +34,20 @@ export default function WorkspaceOverview() {
|
|||||||
const workspace_id = Number(workspace.id);
|
const workspace_id = Number(workspace.id);
|
||||||
const name = workspace.name || "Workspace";
|
const name = workspace.name || "Workspace";
|
||||||
const harnesses = pageProps?.workspace_harness_overview?.harnesses;
|
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 (
|
return (
|
||||||
<SettingsShell
|
<SettingsShell
|
||||||
@@ -30,7 +56,16 @@ export default function WorkspaceOverview() {
|
|||||||
workspace.description ||
|
workspace.description ||
|
||||||
"Context, bots, computers and models for this workspace."
|
"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} />
|
<WorkspaceImageGroup workspace={workspace} />
|
||||||
<WorkspaceDetailsGroup workspace={workspace} onSaved={setWorkspace} />
|
<WorkspaceDetailsGroup workspace={workspace} onSaved={setWorkspace} />
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
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 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 { AdminHomePath } from "@/src/dict/admin-sections-dict";
|
||||||
import type { PagePropsType, PageQueryObject } from "@/src/types";
|
|
||||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||||
|
import type { PagePropsType, PageQueryObject } from "@/src/types";
|
||||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The workspace's old landing page. Management moved to
|
* A workspace's page: its details, crew, computers and models, with the
|
||||||
* `/admin/settings/workspaces/<id>`: managers go there, members go to the
|
* rail showing its crew. Only super admins and admins manage it; a member
|
||||||
* workspace's first bot. No page renders.
|
* opens its first bot instead. Its settings live under Settings.
|
||||||
*/
|
*/
|
||||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||||
const query: PageQueryObject = params.query;
|
const query: PageQueryObject = params.query;
|
||||||
@@ -19,37 +23,68 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
|||||||
return { redirect: { destination: "/auth/login" } };
|
return { redirect: { destination: "/auth/login" } };
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspace = await grabUserWorkspace({
|
const workspaces = await grabUserWorkspaces({ user_id: user.id });
|
||||||
workspace_id: String(query.workspace_id),
|
const workspace = workspaces.find(
|
||||||
user_id: user.id,
|
(item) => String(item.id) == String(query.workspace_id),
|
||||||
});
|
);
|
||||||
|
|
||||||
if (!workspace?.id) {
|
if (!workspace?.id) {
|
||||||
return { redirect: { destination: AdminHomePath, permanent: false } };
|
return { redirect: { destination: AdminHomePath } };
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspace_id = Number(workspace.id);
|
const workspace_id = Number(workspace.id);
|
||||||
|
|
||||||
if (canManageCrew({ user_types })) {
|
/**
|
||||||
return {
|
* Members don't manage it: they go straight to its first bot.
|
||||||
redirect: {
|
*/
|
||||||
destination: `/admin/settings/workspaces/${workspace_id}`,
|
if (!canManageCrew({ user_types })) {
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspace_bots = await grabWorkspaceBots({
|
const workspace_bots = await grabWorkspaceBots({
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (workspace_bots[0]?.id) {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
destination: workspace_bots[0]?.id
|
destination: `/admin/workspaces/${workspace_id}/bots/${workspace_bots[0].id}`,
|
||||||
? `/admin/workspaces/${workspace_id}/bots/${workspace_bots[0].id}`
|
},
|
||||||
: AdminHomePath,
|
};
|
||||||
permanent: false,
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
workspaces,
|
||||||
|
workspace,
|
||||||
|
bots: workspace_bots,
|
||||||
|
workspace_bots,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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 { 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() {
|
export default function WorkspacePage() {
|
||||||
return null;
|
return <WorkspaceOverview show_settings_link />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const meta: BunextPageModuleMeta = {
|
export const meta: BunextPageModuleMetaFn = async (params) => {
|
||||||
title: `Workspaces | ${SiteData["SiteName"]}`,
|
const workspace = (params.serverRes?.props as PagePropsType | undefined)
|
||||||
description: `Your workspaces.`,
|
?.workspace;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${workspace?.name || "Workspace"} | ${SiteData["SiteName"]}`,
|
||||||
|
description: workspace?.description || `A workspace.`,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||||
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
|
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
|
||||||
import type { PagePropsType } from "@/src/types";
|
import type { PagePropsType } from "@/src/types";
|
||||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
|
||||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens the user's first workspace: its settings page for managers, its
|
* Opens the user's first workspace, or the bots when they have none.
|
||||||
* first bot for members. The bots when they have no workspaces.
|
|
||||||
*/
|
*/
|
||||||
export const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
|
export const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
|
||||||
const { user, user_types } = await userAuth({ req });
|
const { user } = await userAuth({ req });
|
||||||
|
|
||||||
if (!user?.id) {
|
if (!user?.id) {
|
||||||
return { redirect: { destination: "/auth/login" } };
|
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];
|
const first = (await grabUserWorkspaces({ user_id: user.id }))[0];
|
||||||
|
|
||||||
if (!first?.id) {
|
|
||||||
return { redirect: { destination: AdminHomePath } };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
destination: canManageCrew({ user_types })
|
destination: first?.id
|
||||||
? `/admin/settings/workspaces/${first.id}`
|
? `/admin/workspaces/${first.id}`
|
||||||
: `/admin/workspaces/${first.id}`,
|
: AdminHomePath,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -392,6 +392,12 @@ export type PagePropsType = {
|
|||||||
all_bots?: BotWithLastMessage[] | null;
|
all_bots?: BotWithLastMessage[] | null;
|
||||||
workspace_computers?: ComputerWithStatus[] | null;
|
workspace_computers?: ComputerWithStatus[] | null;
|
||||||
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;
|
workspace_harness_overview?: BotHarnessOverviewPayload | null;
|
||||||
/**
|
/**
|
||||||
* The workspaces a bot works in, with what it has for each alone.
|
* The workspaces a bot works in, with what it has for each alone.
|
||||||
@@ -815,6 +821,7 @@ export type PageQueryObject = {
|
|||||||
user_id?: string;
|
user_id?: string;
|
||||||
bot_id?: string;
|
bot_id?: string;
|
||||||
workspace_id?: string;
|
workspace_id?: string;
|
||||||
|
computer_id?: string;
|
||||||
event_id?: string;
|
event_id?: string;
|
||||||
user_type?: UserType;
|
user_type?: UserType;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ describe("parseSettingsRecordPath", () => {
|
|||||||
expect(
|
expect(
|
||||||
parseSettingsRecordPath({ pathname: "/admin/settings/users/8" }),
|
parseSettingsRecordPath({ pathname: "/admin/settings/users/8" }),
|
||||||
).toEqual({ kind: "users", id: "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", () => {
|
test("ignores settings list pages and other paths", () => {
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
export type SettingsRecordKind = "bots" | "workspaces" | "users";
|
export type SettingsRecordKind =
|
||||||
|
| "bots"
|
||||||
|
| "workspaces"
|
||||||
|
| "users"
|
||||||
|
| "computers";
|
||||||
|
|
||||||
export type SettingsRecordPath = {
|
export type SettingsRecordPath = {
|
||||||
kind: SettingsRecordKind;
|
kind: SettingsRecordKind;
|
||||||
@@ -21,7 +25,9 @@ export default function parseSettingsRecordPath({
|
|||||||
|
|
||||||
const match = pathname
|
const match = pathname
|
||||||
.replace(/\/$/, "")
|
.replace(/\/$/, "")
|
||||||
.match(/^\/admin\/settings\/(bots|workspaces|users)\/([^/]+)$/);
|
.match(
|
||||||
|
/^\/admin\/settings\/(bots|workspaces|users|computers)\/([^/]+)$/,
|
||||||
|
);
|
||||||
|
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user