Run computers with resources and health checks, add settings for bots, workspaces, computers and users
- Computers get CPUs and memory (default 1 CPU, 2 GB), set at creation and editable; new resources stop, remove and recreate the container, its home dir kept. Every computer container has a docker health check and stays "creating" until it passes; only healthy computers are reached, by hostname, else by their IP on the network (dev outside docker). - Docker is required: the app creates the cobble network on startup and exits without docker. - Production container: deploy/run-app-container.sh mounts .env, the data dir and the docker socket; the database (DB_DIR), media, computers' homes and bun-sqlite's live schema now live on the data volume. - Settings pages for bots, workspaces, computers and users, sharing list components; bots can be deleted; users are created with a password and confirmation, edited and deleted through RBAC-gated APIs. - User types trimmed to super_admin, admin, member, read_only and revoked; admins manage members. UserAvatar shows profile photos from the user row. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -23,7 +23,8 @@ type Props = {
|
||||
*/
|
||||
export default function ComputerViewerModal({ bot }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const { computers, viewing, closeComputer } = useContext(BotComputersContext);
|
||||
const { computers, viewing, closeComputer } =
|
||||
useContext(BotComputersContext);
|
||||
const [handing_back, setHandingBack] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
@@ -57,7 +58,7 @@ export default function ComputerViewerModal({ bot }: Props) {
|
||||
setOpen={(open) => {
|
||||
if (!open) closeComputer();
|
||||
}}
|
||||
className="max-w-[1280px] h-[90vh] p-0 bg-paper flex flex-col overflow-hidden"
|
||||
className="max-w-[95vw]! h-[90vh] p-0 bg-paper flex flex-col overflow-hidden"
|
||||
>
|
||||
<Row className="w-full gap-3! flex-nowrap items-center pl-5 pr-14 py-3 border-0 border-b border-solid border-hairline">
|
||||
<Stack className="min-w-0 gap-0! items-start">
|
||||
@@ -82,7 +83,10 @@ export default function ComputerViewerModal({ bot }: Props) {
|
||||
{`Hand back to ${bot_name}`}
|
||||
</Button>
|
||||
{error ? (
|
||||
<Span role="alert" className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
<Span
|
||||
role="alert"
|
||||
className="text-[13px] leading-[18px] text-ink-secondary"
|
||||
>
|
||||
{error}
|
||||
</Span>
|
||||
) : null}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { AppContext } from "@/src/pages/__root";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import { useContext } from "react";
|
||||
import dayjs from "dayjs";
|
||||
import UserAvatar from "@/src/components/general/user-avatar";
|
||||
|
||||
type Props = {
|
||||
person: BUN_SQLITE_COBBLE_USERS_JOIN;
|
||||
@@ -54,6 +55,7 @@ export default function PeopleListCard({ person }: Props) {
|
||||
>
|
||||
<Row className="w-full justify-between">
|
||||
<Row>
|
||||
<UserAvatar person={person} size="md" />
|
||||
<Img
|
||||
alt={`${person_name}`}
|
||||
src={
|
||||
@@ -77,10 +79,7 @@ export default function PeopleListCard({ person }: Props) {
|
||||
size={14}
|
||||
className="opacity-50"
|
||||
/>
|
||||
<Span
|
||||
variant="faded"
|
||||
className="text-xs"
|
||||
>
|
||||
<Span variant="faded" className="text-xs">
|
||||
{date_created.toString()}
|
||||
</Span>
|
||||
</Row>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useState } from "react";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
|
||||
type Status = { error?: boolean; success?: boolean; msg?: string };
|
||||
|
||||
type Params<T extends { id?: number | string | "" }> = {
|
||||
items: T[];
|
||||
};
|
||||
|
||||
/**
|
||||
* A settings list's rows with its busy state and one status line, and
|
||||
* `remove`: confirm, post to an admin API route, drop the row.
|
||||
*/
|
||||
export default function useSettingsList<T extends { id?: number | string | "" }>({ items: initial }: Params<T>) {
|
||||
const [items, setItems] = useState(initial);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [status, setStatus] = useState<Status>();
|
||||
|
||||
const remove = async ({ id, path, body, confirm }: { id: T["id"]; path: string; body: Record<string, unknown>; confirm: string }) => {
|
||||
if (busy || !window.confirm(confirm)) return;
|
||||
|
||||
setBusy(true);
|
||||
setStatus(undefined);
|
||||
|
||||
const res = await postAdminApi({ path, body });
|
||||
|
||||
setBusy(false);
|
||||
|
||||
if (!res.success) {
|
||||
setStatus({ error: true, msg: res.msg || "That didn't work." });
|
||||
return;
|
||||
}
|
||||
|
||||
setItems((prev) => prev.filter((item) => item.id != id));
|
||||
setStatus({ success: true, msg: res.msg || "Deleted." });
|
||||
};
|
||||
|
||||
return { items, setItems, busy, status, setStatus, remove };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
|
||||
type Props = {
|
||||
avatar: ReactNode;
|
||||
title: string;
|
||||
/**
|
||||
* A word beside the title, e.g. "Default" or "You".
|
||||
*/
|
||||
badge?: string;
|
||||
subtitle?: string;
|
||||
/**
|
||||
* Its actions, at the end of the row.
|
||||
*/
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* One row of a settings list: an avatar, a title and one line about it,
|
||||
* then what can be done with it.
|
||||
*/
|
||||
export default function SettingsListRow({ avatar, title, badge, subtitle, children }: Props) {
|
||||
return (
|
||||
<Row className="w-full min-h-14 gap-3! flex-nowrap items-center px-3 py-2">
|
||||
{avatar}
|
||||
<Stack className="grow min-w-0 gap-0!">
|
||||
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
|
||||
{title}
|
||||
{badge ? <Span className="ml-2 text-[11px] font-bold leading-4 text-ink-secondary">{badge}</Span> : null}
|
||||
</Span>
|
||||
{subtitle ? (
|
||||
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">{subtitle}</Span>
|
||||
) : null}
|
||||
</Stack>
|
||||
{children ? <Row className="shrink-0 gap-1! flex-nowrap">{children}</Row> : null}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Shown instead when the list has no rows.
|
||||
*/
|
||||
empty?: string;
|
||||
is_empty?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A framed list of rows inside a settings group.
|
||||
*/
|
||||
export default function SettingsList({ children, empty, is_empty }: Props) {
|
||||
return (
|
||||
<Stack className="w-full py-1 gap-0! items-stretch rounded-default border border-solid border-hairline bg-surface">
|
||||
{children}
|
||||
{is_empty && empty ? (
|
||||
<Span className="px-4 py-4 text-[13px] leading-[18px] text-ink-secondary">{empty}</Span>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+8
-3
@@ -3,14 +3,18 @@ import Button from "@moduletrace/twui/layout/Button";
|
||||
type Props = {
|
||||
title: string;
|
||||
label: string;
|
||||
/**
|
||||
* A link instead of a button.
|
||||
*/
|
||||
href?: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One small text action on a workspace computer row.
|
||||
* One small text action on a list row (Edit, Open, Delete).
|
||||
*/
|
||||
export default function WorkspaceComputerAction({ title, label, disabled, onClick }: Props) {
|
||||
export default function SettingsRowAction({ title, label, href, disabled, onClick }: Props) {
|
||||
return (
|
||||
<Button
|
||||
title={title}
|
||||
@@ -18,6 +22,7 @@ export default function WorkspaceComputerAction({ title, label, disabled, onClic
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
href={href}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className="px-2! text-[13px]"
|
||||
@@ -0,0 +1,32 @@
|
||||
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
|
||||
import BotForm from "@/src/layouts/bots/(partials)/bot-form";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* Open while set.
|
||||
*/
|
||||
bot?: BotWithLastMessage;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Edits a bot in a modal, with the same form as adding one (and as the
|
||||
* chat header's avatar).
|
||||
*/
|
||||
export default function BotEditModal({ bot, onClose }: Props) {
|
||||
return (
|
||||
<AddModalShell
|
||||
open={Boolean(bot)}
|
||||
setOpen={(open) => !open && onClose()}
|
||||
title={`Edit ${bot?.name || "the bot"}`}
|
||||
description={
|
||||
bot?.kind == "coordinator"
|
||||
? "The coordinator keeps its name. Give it a picture, or change its core instructions."
|
||||
: "Its core instructions apply everywhere. What it does in one workspace is set on that workspace's page."
|
||||
}
|
||||
>
|
||||
{bot ? <BotForm key={bot.id} bot={bot} onCancel={onClose} /> : null}
|
||||
</AddModalShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import SettingsListRow from "../../(partials)/settings-list-row";
|
||||
import SettingsRowAction from "../../(partials)/settings-row-action";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
busy?: boolean;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One bot: its stone or picture, name and job, then Chat, Edit, and for a
|
||||
* worker its Models and Delete.
|
||||
*/
|
||||
export default function BotSettingsRow({ bot, busy, onEdit, onDelete }: Props) {
|
||||
const name = bot.name || `Bot ${bot.id}`;
|
||||
const is_coordinator = bot.kind == "coordinator";
|
||||
|
||||
return (
|
||||
<SettingsListRow
|
||||
avatar={
|
||||
<BotAvatar
|
||||
image_media_id={bot.image_media_id}
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
face={(bot.face || "idle") as BotFace}
|
||||
/>
|
||||
}
|
||||
title={name}
|
||||
subtitle={bot.role || (is_coordinator ? "Coordinates your crew and hands work to it" : undefined)}
|
||||
>
|
||||
<SettingsRowAction title={`Chat with ${name}`} label="Chat" href={`/admin/bots/${bot.id}`} />
|
||||
<SettingsRowAction title={`Edit ${name}`} label="Edit" disabled={busy} onClick={onEdit} />
|
||||
{is_coordinator ? null : (
|
||||
<>
|
||||
<SettingsRowAction title={`${name}'s models`} label="Models" href={`/admin/bots/${bot.id}/models`} />
|
||||
<SettingsRowAction title={`Delete ${name}`} label="Delete" disabled={busy} onClick={onDelete} />
|
||||
</>
|
||||
)}
|
||||
</SettingsListRow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useContext, useState } from "react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import { Plus } from "lucide-react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import { AddFlowContext } from "@/src/layouts/bots/(data)/add-flow-context";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import useSettingsList from "../../(hooks)/use-settings-list";
|
||||
import SettingsGroup from "../../(partials)/settings-group";
|
||||
import SettingsList from "../../(partials)/settings-list";
|
||||
import SettingsStatus from "../../(partials)/settings-status";
|
||||
import BotEditModal from "../(partials)/bot-edit-modal";
|
||||
import BotSettingsRow from "../(partials)/bot-settings-row";
|
||||
|
||||
/**
|
||||
* The user's crew, coordinator first: chat with a bot, edit it, set its
|
||||
* models, add one or delete a worker.
|
||||
*/
|
||||
export default function BotsSettingsSection() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const { openForm } = useContext(AddFlowContext);
|
||||
const { items, busy, status, remove } = useSettingsList({ items: pageProps?.bots || [] });
|
||||
const [editing, setEditing] = useState<BotWithLastMessage>();
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
title="Your crew"
|
||||
description="The coordinator stays. Deleting a bot removes its conversations, tasks and models."
|
||||
>
|
||||
<SettingsList is_empty={!items.length} empty="No bots yet.">
|
||||
{items.map((bot) => (
|
||||
<BotSettingsRow
|
||||
key={bot.id}
|
||||
bot={bot}
|
||||
busy={busy}
|
||||
onEdit={() => setEditing(bot)}
|
||||
onDelete={() =>
|
||||
remove({
|
||||
id: bot.id,
|
||||
path: "bots/delete-bot",
|
||||
body: { bot_id: bot.id },
|
||||
confirm: `Delete ${bot.name || "this bot"}? Its conversations, tasks and models go with it.`,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SettingsList>
|
||||
<Button
|
||||
title="Add a bot"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
onClick={() => openForm({ kind: "bot" })}
|
||||
className="self-start gap-1!"
|
||||
>
|
||||
<Plus size={14} aria-hidden />
|
||||
New bot
|
||||
</Button>
|
||||
<SettingsStatus status={status} />
|
||||
<BotEditModal bot={editing} onClose={() => setEditing(undefined)} />
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
|
||||
import grabSettingsServerProps from "../(functions)/grab-settings-server-props";
|
||||
|
||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
const settings = await grabSettingsServerProps(params);
|
||||
|
||||
if (!settings.props?.person?.id) return settings;
|
||||
|
||||
return {
|
||||
props: {
|
||||
...settings.props,
|
||||
bots: await grabBots({ user_id: settings.props.person.id }),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import BotsSettingsSection from "./(sections)/bots-settings-section";
|
||||
import SettingsShell from "../(partials)/settings-shell";
|
||||
import settingsPageMeta from "../(functions)/settings-page-meta";
|
||||
|
||||
export default function AdminSettingsBotsPage() {
|
||||
return (
|
||||
<SettingsShell title="Bots" description="Your crew: the coordinator and the bots it hands work to.">
|
||||
<BotsSettingsSection />
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
|
||||
export const meta = settingsPageMeta({ label: "Bots" });
|
||||
@@ -0,0 +1,30 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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 SettingsListRow from "../../(partials)/settings-list-row";
|
||||
import SettingsRowAction from "../../(partials)/settings-row-action";
|
||||
|
||||
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).
|
||||
*/
|
||||
export default function ComputerSettingsRow({ computer, is_super_admin, busy, onEdit, onRetry, onDelete }: Props) {
|
||||
const name = computer.name || `Computer ${computer.id}`;
|
||||
const busy_here = busy || computer.status == "creating";
|
||||
|
||||
return (
|
||||
<SettingsListRow
|
||||
avatar={<ComputerIconTile />}
|
||||
title={name}
|
||||
badge={computer.kind == "main" ? "Main" : undefined}
|
||||
subtitle={[computerStatusLabel({ computer }), computerResourcesLabel({ computer }), computer.description]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
>
|
||||
{is_super_admin ? (
|
||||
<SettingsRowAction title={`Edit ${name}`} label="Edit" disabled={busy_here} onClick={onEdit} />
|
||||
) : null}
|
||||
{is_super_admin && computer.status == "failed" ? (
|
||||
<SettingsRowAction title={`Set ${name} up again`} label="Retry" disabled={busy} onClick={onRetry} />
|
||||
) : null}
|
||||
{is_super_admin && computer.kind != "main" ? (
|
||||
<SettingsRowAction title={`Delete ${name}; its files are kept`} label="Delete" disabled={busy} onClick={onDelete} />
|
||||
) : null}
|
||||
</SettingsListRow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useContext, useState } from "react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import { Plus } from "lucide-react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import useLiveComputers from "@/src/hooks/use-live-computers";
|
||||
import { AddFlowContext } from "@/src/layouts/bots/(data)/add-flow-context";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import type { ComputerWithStatus } from "@/src/types";
|
||||
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.
|
||||
*/
|
||||
export default function ComputersSettingsSection() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const { openForm } = useContext(AddFlowContext);
|
||||
const { computers, setComputers } = useLiveComputers({ computers: pageProps?.computers || [], include_new: true });
|
||||
const [editing, setEditing] = useState<ComputerWithStatus>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [status, setStatus] = useState<Status>();
|
||||
|
||||
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)));
|
||||
|
||||
const run = async ({ path, computer_id }: { path: string; computer_id: number }) => {
|
||||
setBusy(true);
|
||||
setStatus(undefined);
|
||||
|
||||
const res = await postAdminApi<ComputerWithStatus>({ path, body: { computer_id } });
|
||||
|
||||
setBusy(false);
|
||||
|
||||
if (!res.success) {
|
||||
setStatus({ error: true, msg: res.msg || "That didn't work." });
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.singleRes) merge(res.singleRes);
|
||||
else setComputers((prev) => prev.filter((c) => c.id != computer_id));
|
||||
|
||||
setStatus(res.msg ? { success: true, msg: res.msg } : undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
title="Your computers"
|
||||
description="Each one is a sandbox container on this machine. Bots reach them through workspaces or direct access."
|
||||
>
|
||||
<SettingsList is_empty={!computers.length} empty="No computers yet.">
|
||||
{computers.map((computer) => (
|
||||
<ComputerSettingsRow
|
||||
key={computer.id}
|
||||
computer={computer}
|
||||
is_super_admin={is_super_admin}
|
||||
busy={busy}
|
||||
onEdit={() => setEditing(computer)}
|
||||
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;
|
||||
|
||||
run({ path: "computers/delete-computer", computer_id: Number(computer.id) });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</SettingsList>
|
||||
{is_super_admin ? (
|
||||
<Button
|
||||
title="Create a new computer"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
onClick={() => openForm({ kind: "computer" })}
|
||||
className="self-start gap-1!"
|
||||
>
|
||||
<Plus size={14} aria-hidden />
|
||||
New computer
|
||||
</Button>
|
||||
) : null}
|
||||
<SettingsStatus status={status} />
|
||||
<ComputerEditModal
|
||||
computer={editing}
|
||||
onClose={() => setEditing(undefined)}
|
||||
onUpdated={({ computer, msg }) => {
|
||||
merge(computer);
|
||||
setEditing(undefined);
|
||||
setStatus({ success: true, msg });
|
||||
}}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
import grabUserComputers from "@/src/functions/backend/db/computers/grab-user-computers";
|
||||
import grabSettingsServerProps from "../(functions)/grab-settings-server-props";
|
||||
|
||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
const settings = await grabSettingsServerProps(params);
|
||||
|
||||
if (!settings.props?.person?.id) return settings;
|
||||
|
||||
return {
|
||||
props: {
|
||||
...settings.props,
|
||||
computers: await grabUserComputers({ user_id: settings.props.person.id }),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import ComputersSettingsSection from "./(sections)/computers-settings-section";
|
||||
import SettingsShell from "../(partials)/settings-shell";
|
||||
import settingsPageMeta from "../(functions)/settings-page-meta";
|
||||
|
||||
export default function AdminSettingsComputersPage() {
|
||||
return (
|
||||
<SettingsShell title="Computers" description="The machines your bots work on, and what each one gets.">
|
||||
<ComputersSettingsSection />
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
|
||||
export const meta = settingsPageMeta({ label: "Computers" });
|
||||
@@ -32,12 +32,7 @@ export default function ProfilePhotoField({
|
||||
|
||||
return (
|
||||
<Row className="w-full gap-4! flex-nowrap items-center">
|
||||
<UserAvatar
|
||||
src={preview_src}
|
||||
initials={initials}
|
||||
alt="Your photo"
|
||||
size="lg"
|
||||
/>
|
||||
<UserAvatar src={preview_src} initials={initials} alt="Your photo" size="lg" />
|
||||
<Stack className="gap-2! min-w-0">
|
||||
<Row className="gap-2!">
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
|
||||
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
|
||||
import UserForm from "./user-form";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* Edits this user; adds one when not given.
|
||||
*/
|
||||
person?: BUN_SQLITE_COBBLE_USERS_JOIN;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: (params: { person: BUN_SQLITE_COBBLE_USERS_JOIN; msg: string }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds or edits a user in a modal.
|
||||
*/
|
||||
export default function UserEditModal({ person, open, onClose, onSaved }: Props) {
|
||||
return (
|
||||
<AddModalShell
|
||||
open={open}
|
||||
setOpen={(next) => !next && onClose()}
|
||||
title={person ? `Edit ${person.first_name || "the user"}` : "Add a user"}
|
||||
description={
|
||||
person
|
||||
? "Leave the password empty to keep theirs."
|
||||
: "They get their own coordinator and main computer, and sign in with this password."
|
||||
}
|
||||
>
|
||||
{open ? <UserForm key={person?.id || "new"} person={person} onCancel={onClose} onSaved={onSaved} /> : null}
|
||||
</AddModalShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import Form from "@moduletrace/twui/form/Form";
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import { useContext, useState } from "react";
|
||||
import { UneditableUserTypes } from "@/src/dict/user-types-dict";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
|
||||
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import type { UserType } from "@/src/types";
|
||||
import parseUserTypes from "@/src/utils/parse-user-types";
|
||||
import UserPasswordFields from "./user-password-fields";
|
||||
import UserTypesSelect from "./user-types-select";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* Edits this user; adds one when not given.
|
||||
*/
|
||||
person?: BUN_SQLITE_COBBLE_USERS_JOIN;
|
||||
onCancel: () => void;
|
||||
onSaved: (params: { person: BUN_SQLITE_COBBLE_USERS_JOIN; msg: string }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A user's name, email, username, password (twice, to confirm) and user
|
||||
* types. Uneditable types (`super_admin`) aren't offered, and the super
|
||||
* admin's types aren't shown at all: they never change here.
|
||||
*/
|
||||
export default function UserForm({ person, onCancel, onSaved }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const current_types = parseUserTypes({ user_types: person?.user_types });
|
||||
const is_super_admin = current_types.includes("super_admin");
|
||||
|
||||
const [first_name, setFirstName] = useState(person?.first_name || "");
|
||||
const [last_name, setLastName] = useState(person?.last_name || "");
|
||||
const [email, setEmail] = useState(person?.email || "");
|
||||
const [username, setUsername] = useState(person?.username || "");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm_password, setConfirmPassword] = useState("");
|
||||
const [user_types, setUserTypes] = useState<UserType[]>(
|
||||
person ? current_types.filter((type) => !UneditableUserTypes.includes(type)) : ["member"],
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const submit = async () => {
|
||||
if (loading) return;
|
||||
|
||||
if (password != confirm_password) {
|
||||
setError("The passwords don't match.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
const res = await postAdminApi<BUN_SQLITE_COBBLE_USERS_JOIN>({
|
||||
path: "users/save-user",
|
||||
body: {
|
||||
user_id: person?.id || null,
|
||||
first_name,
|
||||
last_name,
|
||||
email,
|
||||
username,
|
||||
password,
|
||||
confirm_password,
|
||||
user_types: is_super_admin ? [] : user_types,
|
||||
},
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!res.success || !res.singleRes?.id) {
|
||||
setError(res.msg || "Couldn't save the user.");
|
||||
return;
|
||||
}
|
||||
|
||||
onSaved({ person: res.singleRes, msg: res.msg || "Saved." });
|
||||
};
|
||||
|
||||
return (
|
||||
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
|
||||
<Row className="w-full gap-4! flex-nowrap items-start">
|
||||
<Input
|
||||
id="user-form-first-name"
|
||||
title="First name"
|
||||
label="First name"
|
||||
showLabel
|
||||
placeholder="Ada"
|
||||
autoComplete="off"
|
||||
defaultValue={first_name}
|
||||
wrapperWrapperProps={{ className: "grow min-w-0" }}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
id="user-form-last-name"
|
||||
title="Last name"
|
||||
label="Last name"
|
||||
showLabel
|
||||
placeholder="Lovelace"
|
||||
autoComplete="off"
|
||||
defaultValue={last_name}
|
||||
wrapperWrapperProps={{ className: "grow min-w-0" }}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
/>
|
||||
</Row>
|
||||
<Input
|
||||
id="user-form-email"
|
||||
title="Email"
|
||||
label="Email"
|
||||
showLabel
|
||||
type="email"
|
||||
placeholder="[email protected]"
|
||||
autoComplete="off"
|
||||
defaultValue={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
id="user-form-username"
|
||||
title="Username"
|
||||
label="Username"
|
||||
showLabel
|
||||
placeholder="ada"
|
||||
autoComplete="off"
|
||||
defaultValue={username}
|
||||
info="Lowercase letters, numbers, dots, dashes or underscores."
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
{is_super_admin ? null : (
|
||||
<UserTypesSelect actor_user_types={pageProps?.user_types} value={user_types} onChange={setUserTypes} />
|
||||
)}
|
||||
<UserPasswordFields optional={Boolean(person)} onPassword={setPassword} onConfirm={setConfirmPassword} />
|
||||
<AddFormActions
|
||||
submit_label={person ? "Save" : "Add user"}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* Editing someone: both may stay empty to keep their password.
|
||||
*/
|
||||
optional?: boolean;
|
||||
onPassword: (password: string) => void;
|
||||
onConfirm: (password: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The password section of the user form: the password, then the same
|
||||
* again to confirm it.
|
||||
*/
|
||||
export default function UserPasswordFields({ optional, onPassword, onConfirm }: Props) {
|
||||
return (
|
||||
<Stack className="w-full gap-3! items-stretch pt-2 border-0 border-t border-solid border-hairline">
|
||||
<Stack className="gap-0.5!">
|
||||
<Span className="text-sm font-bold leading-5 text-ink">{optional ? "New password" : "Password"}</Span>
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
{`At least ${AppData.PasswordMinLength} characters.${optional ? " Leave both empty to keep theirs." : " They sign in with it."}`}
|
||||
</Span>
|
||||
</Stack>
|
||||
<Input
|
||||
id="user-form-password"
|
||||
title="Password"
|
||||
label="Password"
|
||||
showLabel
|
||||
type="password"
|
||||
placeholder={optional ? "Leave empty to keep it" : "A strong password"}
|
||||
autoComplete="new-password"
|
||||
onChange={(e) => onPassword(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
id="user-form-confirm-password"
|
||||
title="Confirm password"
|
||||
label="Confirm password"
|
||||
showLabel
|
||||
type="password"
|
||||
placeholder="The same password again"
|
||||
autoComplete="new-password"
|
||||
onChange={(e) => onConfirm(e.target.value)}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import UserAvatar from "@/src/components/general/user-avatar";
|
||||
import { UserTypes } from "@/src/dict/user-types-dict";
|
||||
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
|
||||
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
|
||||
import parseUserTypes from "@/src/utils/parse-user-types";
|
||||
import canRevokeUser from "@/src/utils/rbac/can-revoke-user";
|
||||
import canUpdateUser from "@/src/utils/rbac/can-update-user";
|
||||
import SettingsListRow from "../../(partials)/settings-list-row";
|
||||
import SettingsRowAction from "../../(partials)/settings-row-action";
|
||||
|
||||
type Props = {
|
||||
person: BUN_SQLITE_COBBLE_USERS_JOIN;
|
||||
actor_user_id?: number | string;
|
||||
actor_user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
|
||||
busy?: boolean;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One user: their photo, name, user types and email, then Edit and
|
||||
* Delete where the actor may (`canUpdateUser`, `canRevokeUser`; never
|
||||
* deleting yourself).
|
||||
*/
|
||||
export default function UserSettingsRow({
|
||||
person,
|
||||
actor_user_id,
|
||||
actor_user_types,
|
||||
busy,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: Props) {
|
||||
const name =
|
||||
[person.first_name, person.last_name].filter(Boolean).join(" ") ||
|
||||
person.email ||
|
||||
`User ${person.id}`;
|
||||
const user_types = parseUserTypes({ user_types: person.user_types });
|
||||
const types_title = UserTypes.filter((type) => user_types.includes(type.value))
|
||||
.map((type) => type.title)
|
||||
.join(", ");
|
||||
const is_self = person.id == actor_user_id;
|
||||
const target_user_types = user_types.map((user_type) => ({ user_type }));
|
||||
const can_edit = canUpdateUser({ actor_user_types, actor_user_id, target_user_id: person.id, target_user_types });
|
||||
const can_delete = !is_self && canRevokeUser({ user_types: actor_user_types, target_user_types });
|
||||
|
||||
return (
|
||||
<SettingsListRow
|
||||
avatar={<UserAvatar person={person} size="md" />}
|
||||
title={name}
|
||||
badge={is_self ? "You" : undefined}
|
||||
subtitle={[types_title, person.email].filter(Boolean).join(" · ")}
|
||||
>
|
||||
{can_edit ? (
|
||||
<SettingsRowAction
|
||||
title={`Edit ${name}`}
|
||||
label="Edit"
|
||||
disabled={busy}
|
||||
onClick={onEdit}
|
||||
/>
|
||||
) : null}
|
||||
{can_delete ? (
|
||||
<SettingsRowAction
|
||||
title={`Delete ${name}`}
|
||||
label="Delete"
|
||||
disabled={busy}
|
||||
onClick={onDelete}
|
||||
/>
|
||||
) : null}
|
||||
</SettingsListRow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import SearchSelect from "@moduletrace/twui/form/SearchSelect";
|
||||
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
|
||||
import { UneditableUserTypes, UserTypes } from "@/src/dict/user-types-dict";
|
||||
import type { UserType } from "@/src/types";
|
||||
import filterAssignableUserTypes from "@/src/utils/rbac/filter-assignable-user-types";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* The session user's types: only what they may grant is offered.
|
||||
*/
|
||||
actor_user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
|
||||
value: UserType[];
|
||||
onChange: (user_types: UserType[]) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Picks a user's types from `UserTypes`, leaving out the uneditable ones
|
||||
* (`super_admin`) and any the assignment matrix keeps from the actor.
|
||||
*/
|
||||
export default function UserTypesSelect({ actor_user_types, value, onChange }: Props) {
|
||||
const assignable = filterAssignableUserTypes({
|
||||
actor_user_types,
|
||||
user_types: UserTypes.map((type) => type.value).filter((type) => !UneditableUserTypes.includes(type)),
|
||||
});
|
||||
|
||||
return (
|
||||
<SearchSelect
|
||||
id="user-form-user-types"
|
||||
title="User types"
|
||||
label="User types"
|
||||
showLabel
|
||||
multiple
|
||||
placeholder="Pick their user types"
|
||||
options={UserTypes.filter((type) => assignable.includes(type.value)).map((type) => ({
|
||||
value: type.value,
|
||||
title: type.title,
|
||||
}))}
|
||||
defaultValues={value}
|
||||
info="At least one, or they can't sign in."
|
||||
changeHandler={(next) =>
|
||||
onChange((Array.isArray(next) ? next : next ? [next] : []) as UserType[])
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useContext, useState } from "react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import { Plus } from "lucide-react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
|
||||
import canCreateUser from "@/src/utils/rbac/can-create-user";
|
||||
import useSettingsList from "../../(hooks)/use-settings-list";
|
||||
import SettingsGroup from "../../(partials)/settings-group";
|
||||
import SettingsList from "../../(partials)/settings-list";
|
||||
import SettingsStatus from "../../(partials)/settings-status";
|
||||
import UserEditModal from "../(partials)/user-edit-modal";
|
||||
import UserSettingsRow from "../(partials)/user-settings-row";
|
||||
|
||||
/**
|
||||
* Everyone who can sign in (for super admins and admins): add a user
|
||||
* with a password, edit one (name, email, username, a new password, user
|
||||
* types), or delete one with everything that's theirs. What each person
|
||||
* may do here is the RBAC helpers' call.
|
||||
*/
|
||||
export default function UsersSettingsSection() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const { items, setItems, busy, status, setStatus, remove } = useSettingsList({ items: pageProps?.persons || [] });
|
||||
/**
|
||||
* The user being edited, or `true` while adding one.
|
||||
*/
|
||||
const [editing, setEditing] = useState<BUN_SQLITE_COBBLE_USERS_JOIN | true>();
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
title="People"
|
||||
description="Sign in is by email or username and password, so set one for each new user."
|
||||
>
|
||||
<SettingsList is_empty={!items.length} empty="No users yet.">
|
||||
{items.map((person) => (
|
||||
<UserSettingsRow
|
||||
key={person.id}
|
||||
person={person}
|
||||
actor_user_id={pageProps?.user?.id}
|
||||
actor_user_types={pageProps?.user_types}
|
||||
busy={busy}
|
||||
onEdit={() => setEditing(person)}
|
||||
onDelete={() =>
|
||||
remove({
|
||||
id: person.id,
|
||||
path: "users/delete-user",
|
||||
body: { user_id: person.id },
|
||||
confirm: `Delete ${person.first_name || "this user"} with their bots, workspaces and computers? Their computers' files stay on disk.`,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SettingsList>
|
||||
{canCreateUser({ user_types: pageProps?.user_types }) ? (
|
||||
<Button
|
||||
title="Add a user"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
onClick={() => setEditing(true)}
|
||||
className="self-start gap-1!"
|
||||
>
|
||||
<Plus size={14} aria-hidden />
|
||||
New user
|
||||
</Button>
|
||||
) : null}
|
||||
<SettingsStatus status={status} />
|
||||
<UserEditModal
|
||||
person={editing === true ? undefined : editing}
|
||||
open={Boolean(editing)}
|
||||
onClose={() => setEditing(undefined)}
|
||||
onSaved={({ person, msg }) => {
|
||||
setItems((prev) =>
|
||||
prev.some((p) => p.id == person.id)
|
||||
? prev.map((p) => (p.id == person.id ? person : p))
|
||||
: [...prev, person],
|
||||
);
|
||||
setEditing(undefined);
|
||||
setStatus({ success: true, msg });
|
||||
}}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
import { SettingsDefaultPath } from "@/src/dict/settings-pages-dict";
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import canListUsers from "@/src/utils/rbac/can-list-users";
|
||||
import grabUsers from "@/src/functions/backend/db/users/grab-users";
|
||||
import grabSettingsServerProps from "../(functions)/grab-settings-server-props";
|
||||
|
||||
/**
|
||||
* For those who may list users (`canListUsers`: super admins, admins);
|
||||
* everyone else lands on the general settings.
|
||||
*/
|
||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
const settings = await grabSettingsServerProps(params);
|
||||
|
||||
if (!settings.props) return settings;
|
||||
|
||||
const { user_types } = await userAuth({ req: params.req });
|
||||
|
||||
if (!canListUsers({ user_types })) {
|
||||
return { redirect: { destination: SettingsDefaultPath, permanent: false } };
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
...settings.props,
|
||||
persons: (await grabUsers()).payload || [],
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import UsersSettingsSection from "./(sections)/users-settings-section";
|
||||
import SettingsShell from "../(partials)/settings-shell";
|
||||
import settingsPageMeta from "../(functions)/settings-page-meta";
|
||||
|
||||
export default function AdminSettingsUsersPage() {
|
||||
return (
|
||||
<SettingsShell title="Users" description="Who can sign in to this install. Each has their own crew and computers.">
|
||||
<UsersSettingsSection />
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
|
||||
export const meta = settingsPageMeta({ label: "Users" });
|
||||
@@ -0,0 +1,29 @@
|
||||
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
|
||||
import type { WorkspaceWithActivity } from "@/src/types";
|
||||
import SettingsListRow from "../../(partials)/settings-list-row";
|
||||
import SettingsRowAction from "../../(partials)/settings-row-action";
|
||||
|
||||
type Props = {
|
||||
workspace: WorkspaceWithActivity;
|
||||
busy?: boolean;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One workspace: its picture, name and what it's about, then Open and
|
||||
* Delete.
|
||||
*/
|
||||
export default function WorkspaceSettingsRow({ workspace, busy, onDelete }: Props) {
|
||||
const name = workspace.name || `Workspace ${workspace.id}`;
|
||||
|
||||
return (
|
||||
<SettingsListRow
|
||||
avatar={<WorkspaceAvatar image_media_id={workspace.image_media_id} size="sm" />}
|
||||
title={name}
|
||||
subtitle={workspace.description || undefined}
|
||||
>
|
||||
<SettingsRowAction title={`Open ${name}`} label="Open" href={`/admin/workspaces/${workspace.id}`} />
|
||||
<SettingsRowAction title={`Delete ${name}`} label="Delete" disabled={busy} onClick={onDelete} />
|
||||
</SettingsListRow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useContext } from "react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import { Plus } from "lucide-react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import { AddFlowContext } from "@/src/layouts/bots/(data)/add-flow-context";
|
||||
import useSettingsList from "../../(hooks)/use-settings-list";
|
||||
import SettingsGroup from "../../(partials)/settings-group";
|
||||
import SettingsList from "../../(partials)/settings-list";
|
||||
import SettingsStatus from "../../(partials)/settings-status";
|
||||
import WorkspaceSettingsRow from "../(partials)/workspace-settings-row";
|
||||
|
||||
/**
|
||||
* Every workspace the user has: open one to set it up (its page has its
|
||||
* context, bots, computers and models), create one, or delete one.
|
||||
*/
|
||||
export default function WorkspacesSettingsSection() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const { openForm } = useContext(AddFlowContext);
|
||||
const { items, busy, status, remove } = useSettingsList({ items: pageProps?.workspaces || [] });
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
title="Your workspaces"
|
||||
description="Bots in a workspace share its context and computers. Deleting one keeps its bots and computers."
|
||||
>
|
||||
<SettingsList is_empty={!items.length} empty="No workspaces yet.">
|
||||
{items.map((workspace) => (
|
||||
<WorkspaceSettingsRow
|
||||
key={workspace.id}
|
||||
workspace={workspace}
|
||||
busy={busy}
|
||||
onDelete={() =>
|
||||
remove({
|
||||
id: workspace.id,
|
||||
path: "workspaces/delete-workspace",
|
||||
body: { workspace_id: workspace.id },
|
||||
confirm: `Delete ${workspace.name || "this workspace"} and its conversations? Its bots and computers stay.`,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SettingsList>
|
||||
<Button
|
||||
title="Create a workspace"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
onClick={() => openForm({ kind: "workspace" })}
|
||||
className="self-start gap-1!"
|
||||
>
|
||||
<Plus size={14} aria-hidden />
|
||||
New workspace
|
||||
</Button>
|
||||
<SettingsStatus status={status} />
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||
import grabSettingsServerProps from "../(functions)/grab-settings-server-props";
|
||||
|
||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
const settings = await grabSettingsServerProps(params);
|
||||
|
||||
if (!settings.props?.person?.id) return settings;
|
||||
|
||||
return {
|
||||
props: {
|
||||
...settings.props,
|
||||
workspaces: await grabUserWorkspaces({ user_id: settings.props.person.id }),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import WorkspacesSettingsSection from "./(sections)/workspaces-settings-section";
|
||||
import SettingsShell from "../(partials)/settings-shell";
|
||||
import settingsPageMeta from "../(functions)/settings-page-meta";
|
||||
|
||||
export default function AdminSettingsWorkspacesPage() {
|
||||
return (
|
||||
<SettingsShell title="Workspaces" description="Projects with their own context, bots, computers and models.">
|
||||
<WorkspacesSettingsSection />
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
|
||||
export const meta = settingsPageMeta({ label: "Workspaces" });
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import useServerEvent from "@/src/hooks/use-server-event";
|
||||
import type { ComputerWithStatus } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
computers: ComputerWithStatus[];
|
||||
};
|
||||
|
||||
/**
|
||||
* A list of computers kept current from `server:computer-updated` (setup
|
||||
* finishing or failing, a container stopping).
|
||||
*/
|
||||
export default function useLiveComputers({ computers: initial }: Params) {
|
||||
const [computers, setComputers] = useState(initial);
|
||||
|
||||
useServerEvent({
|
||||
event: "server:computer-updated",
|
||||
onData: (computer: ComputerWithStatus) =>
|
||||
setComputers((prev) => prev.map((c) => (c.id == computer.id ? { ...c, ...computer } : c))),
|
||||
});
|
||||
|
||||
return { computers, setComputers };
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { Monitor } from "lucide-react";
|
||||
import ComputerIconTile from "@/src/components/general/computer-icon-tile";
|
||||
import SettingsListRow from "@/src/pages/admin/settings/(partials)/settings-list-row";
|
||||
import SettingsRowAction from "@/src/pages/admin/settings/(partials)/settings-row-action";
|
||||
import type { ComputerWithStatus } from "@/src/types";
|
||||
import computerStatusLabel from "../(utils)/computer-status-label";
|
||||
import WorkspaceComputerAction from "./workspace-computer-action";
|
||||
import computerStatusLabel from "@/src/utils/computer-status-label";
|
||||
|
||||
type Props = {
|
||||
computer: ComputerWithStatus;
|
||||
@@ -34,32 +32,22 @@ export default function WorkspaceComputerRow({
|
||||
const name = computer.name || `Computer ${computer.id}`;
|
||||
|
||||
return (
|
||||
<Row className="w-full min-h-14 gap-3! flex-nowrap items-center px-3 py-2">
|
||||
<Span className="size-8 shrink-0 flex items-center justify-center rounded-default-sm border border-solid border-hairline bg-paper">
|
||||
<Monitor size={16} aria-hidden />
|
||||
</Span>
|
||||
<Stack className="grow min-w-0 gap-0!">
|
||||
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
|
||||
{name}
|
||||
{is_default ? <Span className="ml-2 text-[11px] font-bold leading-4 text-ink-secondary">Default</Span> : null}
|
||||
</Span>
|
||||
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">
|
||||
{computerStatusLabel({ computer })}
|
||||
{computer.description ? ` · ${computer.description}` : ""}
|
||||
</Span>
|
||||
</Stack>
|
||||
<Row className="shrink-0 gap-1! flex-nowrap">
|
||||
{is_default ? null : (
|
||||
<WorkspaceComputerAction title={`Make ${name} the default`} label="Make default" disabled={busy} onClick={onMakeDefault} />
|
||||
)}
|
||||
{computer.status == "failed" && is_super_admin ? (
|
||||
<WorkspaceComputerAction title={`Set ${name} up again`} label="Retry" disabled={busy} onClick={onRetry} />
|
||||
) : null}
|
||||
<WorkspaceComputerAction title={`Unlink ${name}; it stays yours`} label="Unlink" disabled={busy} onClick={onUnlink} />
|
||||
{is_super_admin && computer.kind != "main" ? (
|
||||
<WorkspaceComputerAction title={`Delete ${name}; its files are kept`} label="Delete" disabled={busy} onClick={onDelete} />
|
||||
) : null}
|
||||
</Row>
|
||||
</Row>
|
||||
<SettingsListRow
|
||||
avatar={<ComputerIconTile />}
|
||||
title={name}
|
||||
badge={is_default ? "Default" : undefined}
|
||||
subtitle={`${computerStatusLabel({ computer })}${computer.description ? ` · ${computer.description}` : ""}`}
|
||||
>
|
||||
{is_default ? null : (
|
||||
<SettingsRowAction title={`Make ${name} the default`} label="Make default" disabled={busy} onClick={onMakeDefault} />
|
||||
)}
|
||||
{computer.status == "failed" && is_super_admin ? (
|
||||
<SettingsRowAction title={`Set ${name} up again`} label="Retry" disabled={busy} onClick={onRetry} />
|
||||
) : null}
|
||||
<SettingsRowAction title={`Unlink ${name}; it stays yours`} label="Unlink" disabled={busy} onClick={onUnlink} />
|
||||
{is_super_admin && computer.kind != "main" ? (
|
||||
<SettingsRowAction title={`Delete ${name}; its files are kept`} label="Delete" disabled={busy} onClick={onDelete} />
|
||||
) : null}
|
||||
</SettingsListRow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useContext, useState } from "react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { Plus } from "lucide-react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import type { ComputerWithStatus } from "@/src/types";
|
||||
@@ -9,8 +7,9 @@ import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
|
||||
import { AddFlowContext } from "@/src/layouts/bots/(data)/add-flow-context";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
|
||||
import SettingsList from "@/src/pages/admin/settings/(partials)/settings-list";
|
||||
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
|
||||
import useLiveComputers from "../(hooks)/use-live-computers";
|
||||
import useLiveComputers from "@/src/hooks/use-live-computers";
|
||||
import InlineAddSelect from "./inline-add-select";
|
||||
import WorkspaceComputerRow from "./workspace-computer-row";
|
||||
|
||||
@@ -77,7 +76,10 @@ export default function WorkspaceComputersGroup({ workspace_id, computers: initi
|
||||
title="Computers"
|
||||
description="Bots here work on these. Their tasks run on the default one unless they pick another."
|
||||
>
|
||||
<Stack className="w-full py-1 gap-0! items-stretch rounded-default border border-solid border-hairline bg-surface">
|
||||
<SettingsList
|
||||
is_empty={!computers.length}
|
||||
empty="No computers yet. Bots here can chat, but tasks that need a computer can't run."
|
||||
>
|
||||
{computers.map((computer) => (
|
||||
<WorkspaceComputerRow
|
||||
key={computer.id}
|
||||
@@ -95,12 +97,7 @@ export default function WorkspaceComputersGroup({ workspace_id, computers: initi
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{computers.length ? null : (
|
||||
<Span className="px-4 py-4 text-[13px] leading-[18px] text-ink-secondary">
|
||||
No computers yet. Bots here can chat, but tasks that need a computer can't run.
|
||||
</Span>
|
||||
)}
|
||||
</Stack>
|
||||
</SettingsList>
|
||||
<InlineAddSelect
|
||||
id="workspace-add-computer"
|
||||
noun="computer"
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import computerStatusLabel from "./computer-status-label";
|
||||
|
||||
describe("computerStatusLabel", () => {
|
||||
test("names the state", () => {
|
||||
expect(computerStatusLabel({ computer: { id: 1, status: "running" } })).toBe("Running");
|
||||
expect(computerStatusLabel({ computer: { id: 1, status: "creating" } })).toBe("Setting up…");
|
||||
expect(computerStatusLabel({ computer: { id: 1 } })).toBe("Unknown");
|
||||
});
|
||||
|
||||
test("says why setup failed", () => {
|
||||
expect(computerStatusLabel({ computer: { id: 1, status: "failed", error: "no docker" } })).toBe(
|
||||
"Setup failed: no docker",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { ContainerStatuses } from "@/src/dict/computer-dict";
|
||||
import type { ComputerWithStatus } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
computer: ComputerWithStatus;
|
||||
};
|
||||
|
||||
/**
|
||||
* A computer's state in a few words.
|
||||
*/
|
||||
export default function computerStatusLabel({ computer }: Params) {
|
||||
if (computer.status == "creating") return "Setting up…";
|
||||
if (computer.status == "failed") return computer.error ? `Setup failed: ${computer.error}` : "Setup failed";
|
||||
|
||||
return ContainerStatuses.find((s) => s.value == computer.status)?.title || "Unknown";
|
||||
}
|
||||
Reference in New Issue
Block a user