diff --git a/apps/server/src/settings/schemas.ts b/apps/server/src/settings/schemas.ts index 5d41660..98b3f40 100644 --- a/apps/server/src/settings/schemas.ts +++ b/apps/server/src/settings/schemas.ts @@ -6,7 +6,7 @@ export const DEFAULT_SYSTEM_PROMPT = 'Replies are rendered as Markdown. Raw HTML is not rendered, and images load only when the user clicks them.' const minutes = (title: string, fallback: number) => - z.number().positive().default(fallback).meta({ title }) + z.number().positive().default(fallback).meta({ title, unit: 'minutes' }) /** Admin settings kept in the app_setting table, one schema per key. */ export const settingSchemas = { @@ -15,22 +15,41 @@ export const settingSchemas = { inviteRoles: z.array(z.string()).default([]), }), general: z.object({ - appName: z.string().trim().min(1).default('SCN Chat').meta({ title: 'App name' }), + appName: z.string().trim().min(1).default('SCN Chat').meta({ + title: 'App name', + description: 'Shown on the login page, in the sidebar, and in the browser tab.', + }), }), sessions: z.object({ - ttlDays: z.number().int().min(1).default(30).meta({ title: 'Days a web session lasts' }), + ttlDays: z.number().int().min(1).default(30).meta({ + title: 'Session length', + description: 'How long people stay signed in on the web before they have to sign in again.', + unit: 'days', + }), }), turns: z.object({ - ratePerMinute: z.number().int().min(1).default(10).meta({ title: 'Turns per user per minute' }), - maxSteps: z.number().int().min(1).default(8).meta({ title: 'Model steps per turn' }), - timeoutSeconds: z.number().int().min(1).default(600).meta({ title: 'Turn timeout in seconds' }), + ratePerMinute: z.number().int().min(1).default(10).meta({ + title: 'Turns per minute', + description: 'How many replies each person can start in a minute. More wait their turn.', + }), + maxSteps: z.number().int().min(1).default(8).meta({ + title: 'Steps per turn', + description: 'How many times a model can call tools and go on before it has to answer.', + }), + timeoutSeconds: z.number().int().min(1).default(600).meta({ + title: 'Turn timeout', + description: 'A reply still running after this long is stopped.', + unit: 'seconds', + }), backfillMinutes: z.number().int().min(0).default(60).meta({ - title: 'Backfill window in minutes', + title: 'Backfill window', description: 'How old a synced message asking for a reply can be and still get one.', + unit: 'minutes', }), systemPrompt: z.string().default(DEFAULT_SYSTEM_PROMPT).meta({ title: 'System prompt', - description: 'Placeholders: {{appName}}, {{date}}, {{time}}, {{timezone}}.', + description: + 'Sent with every turn. Placeholders: {{appName}}, {{date}}, {{time}}, {{timezone}}.', multiline: true, }), }), @@ -38,30 +57,39 @@ export const settingSchemas = { safetyNet: z .object({ enabled: z.boolean().default(true).meta({ title: 'Enabled' }), - intervalMinutes: minutes('Every this many minutes', 15), + intervalMinutes: minutes('Runs every', 15), activeWithinHours: z .number() .positive() .default(24) - .meta({ title: 'For users active within this many hours' }), + .meta({ title: 'For people active in the last', unit: 'hours' }), }) .prefault({}) - .meta({ title: 'Safety net' }), + .meta({ + title: 'Safety net', + description: + 'Syncs the chats of recently active people now and then, in case a change notice from their PDS was missed.', + }), discovery: z .object({ - intervalMinutes: minutes('Every this many minutes', 60), + intervalMinutes: minutes('Runs every', 60), activeWithinDays: z .number() .positive() .default(30) - .meta({ title: 'For users active within this many days' }), + .meta({ title: 'For people active in the last', unit: 'days' }), }) .prefault({}) - .meta({ title: 'Discovery' }), - allowUserOptOut: z - .boolean() - .default(true) - .meta({ title: 'Users can turn off background sync' }), + .meta({ + title: 'Discovery', + description: + "Looks for chats missing from people's chat index, such as ones another app made, and adds them.", + }), + allowUserOptOut: z.boolean().default(true).meta({ + title: 'People can turn off background sync', + description: + 'Each person can turn it off in their own Sync settings. When this is off, it runs for everyone.', + }), }), } diff --git a/apps/web/src/features/admin/components/Invites.tsx b/apps/web/src/features/admin/components/Invites.tsx index f1db07c..a9454d9 100644 --- a/apps/web/src/features/admin/components/Invites.tsx +++ b/apps/web/src/features/admin/components/Invites.tsx @@ -3,9 +3,12 @@ import { api, json, read } from '../../../shared/api.ts' import { ErrorAlert } from '../../../shared/ErrorAlert.tsx' import { lastError } from '../../../shared/errors.ts' import { useAppForm } from '../../../shared/form.tsx' +import { Button } from '../../../shared/ui/Button.tsx' +import { Item, ItemList, ItemNames, ListLabel } from '../../../shared/ui/ItemList.tsx' +import { cardAlert, inlineForm, SettingsGroup } from '../../../shared/ui/SettingsPage.tsx' import { adminInvitesQuery } from '../queries.ts' -/** People an admin added who haven't signed in yet, and a form to add more. */ +/** People an admin added who haven't signed in yet, under a form to add more. */ export function Invites() { const { data: invites } = useSuspenseQuery(adminInvitesQuery) const queryClient = useQueryClient() @@ -24,11 +27,12 @@ export function Invites() { add.mutate(value.identifier, { onSuccess: () => formApi.reset() }), }) return ( -
+ - -

Add user

-

They can create an account whatever the registration mode is.

+ {(field) => ( @@ -38,22 +42,22 @@ export function Invites() {
{invites.length > 0 && ( - <> -

Added, not signed in yet

- - + +
)} - - + + ) } diff --git a/apps/web/src/features/admin/components/PluginForm.tsx b/apps/web/src/features/admin/components/PluginForm.tsx index 6918e8b..d3117e7 100644 --- a/apps/web/src/features/admin/components/PluginForm.tsx +++ b/apps/web/src/features/admin/components/PluginForm.tsx @@ -2,6 +2,12 @@ import { useStore as useFormStore } from '@tanstack/react-form' import { useAppForm } from '../../../shared/form.tsx' import type { Issue } from '../../../shared/response.ts' import { type Schema, SchemaFields } from '../../../shared/schema-fields/SchemaFields.tsx' +import { + ConfirmButton, + SaveBar, + SettingRow, + SettingsGroup, +} from '../../../shared/ui/SettingsPage.tsx' import type { AdminModel } from '../lib/models.ts' import type { AdminPlugin } from '../queries.ts' import { ProviderModels } from './ProviderModels.tsx' @@ -24,12 +30,17 @@ interface Props { instance: AdminPlugin models: AdminModel[] issues: Issue[] + pending: boolean saved: boolean + error?: string | null + removeError?: string | null onSave: (change: PluginChange) => void + /** Called on any change, since what is shown is no longer what was saved. */ + onEdit: () => void onRemove: () => void } -/** A plugin's switch and options, then each of its providers' models. */ +/** A plugin's switch and options, then each of its providers' models, then its removal. */ export function PluginForm(props: Props) { const form = useAppForm({ defaultValues: { @@ -37,6 +48,7 @@ export function PluginForm(props: Props) { enabled: props.instance.enabled, clearSecrets: [], } as PluginChange, + listeners: { onChange: () => props.onEdit() }, onSubmit: ({ value }) => props.onSave({ ...value, options: knownOptions(props.instance.schema, value.options) }), }) @@ -46,43 +58,49 @@ export function PluginForm(props: Props) { <> - - {(field) => } - - {props.instance.schema && ( - - {(cleared) => ( - - {(field) => ( - - cleared.handleChange((current) => - clear ? [...current, key] : current.filter((name) => name !== key), - ), - }, - }} - issues={props.issues} - onChange={(key, value) => - field.handleChange((current) => ({ ...current, [key]: value })) - } - /> - )} - + + + {(field) => ( + + + )} - - )} - Save{' '} - - {props.saved && Saved.} + + {props.instance.schema && ( + + {(cleared) => ( + + {(field) => ( + + cleared.handleChange((current) => + clear ? [...current, key] : current.filter((name) => name !== key), + ), + }, + }} + issues={props.issues} + onChange={(key, value) => + field.handleChange((current) => ({ ...current, [key]: value })) + } + /> + )} + + )} + + )} + + {props.instance.providers.map((provider) => ( @@ -94,6 +112,25 @@ export function PluginForm(props: Props) { models={props.models.filter((model) => model.provider === provider.id)} /> ))} + + + {props.removeError} +

+ ) + } + > + +
+
) } diff --git a/apps/web/src/features/admin/components/PluginStatus.tsx b/apps/web/src/features/admin/components/PluginStatus.tsx new file mode 100644 index 0000000..b792f15 --- /dev/null +++ b/apps/web/src/features/admin/components/PluginStatus.tsx @@ -0,0 +1,17 @@ +import { Badge } from '../../../shared/ui/Badge.tsx' + +const tones: Record = { loaded: 'good', failed: 'danger' } + +interface Props { + /** `loaded`, `disabled`, or `failed`. */ + status: string +} + +/** A plugin's state as a badge: green once loaded, red when it failed, grey when disabled. */ +export function PluginStatus(props: Props) { + return ( + + {props.status.charAt(0).toUpperCase() + props.status.slice(1)} + + ) +} diff --git a/apps/web/src/features/admin/components/ProviderModels.tsx b/apps/web/src/features/admin/components/ProviderModels.tsx index 2212093..649a5d4 100644 --- a/apps/web/src/features/admin/components/ProviderModels.tsx +++ b/apps/web/src/features/admin/components/ProviderModels.tsx @@ -1,9 +1,20 @@ import { useMutation } from '@tanstack/react-query' import { Link } from '@tanstack/react-router' import { api, json, read } from '../../../shared/api.ts' +import { cn } from '../../../shared/cn.ts' import { ErrorAlert } from '../../../shared/ErrorAlert.tsx' import { lastError } from '../../../shared/errors.ts' import { useAppForm } from '../../../shared/form.tsx' +import { Badge } from '../../../shared/ui/Badge.tsx' +import { Button } from '../../../shared/ui/Button.tsx' +import { ItemList, LinkItem } from '../../../shared/ui/ItemList.tsx' +import { + cardAlert, + inlineForm, + SettingsEmpty, + SettingsGroup, + undivided, +} from '../../../shared/ui/SettingsPage.tsx' import { useModelsChanged } from '../hooks/changes.ts' import { type AdminModel, @@ -23,7 +34,7 @@ interface Props { models: AdminModel[] } -/** A summary of a provider's admin models, each linking to its page, and the ways to add more. */ +/** A provider's admin models, each opening its page, and the ways to add more. */ export function ProviderModels(props: Props) { const modelsChanged = useModelsChanged() const add = useMutation({ @@ -58,94 +69,109 @@ export function ProviderModels(props: Props) { offer({ id, name: id, capabilities: noCapabilities }, { onSuccess: () => formApi.reset() }) }, }) + const name = props.provider.name return ( -
-

Models for {props.provider.name}

- {!props.provider.hasAdminKey &&

Add an admin key to offer this provider's models.

} - - {props.provider.listsModels && ( - - )} - {listed.length > 0 && ( -
    - {listed.map((model) => ( -
  • - -
  • - ))} -
- )} - - - - {(field) => ( - + <> + + + + + {listed.length > 0 && ( + // Models the provider lists, to tick the ones to offer, over the field to type one. +
    + {listed.map((model) => ( +
  • + +
  • + ))} +
+ )} + + 0 && undivided)}> + + {(field) => ( + + )} + + + {props.provider.listsModels && ( + )} -
- -
-
- -
+ + + + + ) } -interface ModelTableProps { +/** What adding a provider's models takes, which depends on its key and whether it lists them. */ +function addHint(provider: Provider) { + if (!provider.hasAdminKey) return "Add an admin key to offer this provider's models." + if (provider.listsModels) + return 'Pick from the models the provider lists, using the settings above as they are now, or type a model ID.' + return 'Type the ID of a model to offer.' +} + +interface ModelListProps { pluginId: string models: AdminModel[] } -/** The provider's admin models, each linking to its page. */ -function ModelTable(props: ModelTableProps) { - if (props.models.length === 0) return null +/** The provider's admin models, each opening its page, with what it can do and who gets it. */ +function ModelList(props: ModelListProps) { + if (props.models.length === 0) return No models yet. return ( - - - - - - - - - - - - {props.models.map((model) => ( - - - - - - - - ))} - -
ModelIDCapabilitiesRolesDefault
- - {model.name} - - {model.id}{capabilityNames.filter((c) => model.capabilities[c]).join(', ')}{model.roles.join(', ')}{model.default ? 'Yes' : ''}
+ + {props.models.map((model) => { + const capabilities = capabilityNames.filter((c) => model.capabilities[c]) + return ( + + + {model.name} + {' '} + {model.default && Default} + + } + details={[ + + {model.id} + , + capabilities.length > 0 && capabilities.join(', '), + model.roles.length ? `For ${model.roles.join(', ')}` : 'No roles', + ]} + /> + ) + })} + ) } diff --git a/apps/web/src/features/admin/components/ReorderButtons.tsx b/apps/web/src/features/admin/components/ReorderButtons.tsx index 3e8040f..30c9f97 100644 --- a/apps/web/src/features/admin/components/ReorderButtons.tsx +++ b/apps/web/src/features/admin/components/ReorderButtons.tsx @@ -1,24 +1,33 @@ +import { ArrowDownIcon, ArrowUpIcon } from '../../../shared/icons.tsx' +import { IconButton } from '../../../shared/ui/Button.tsx' + interface Props { index: number count: number + /** Whether a move is on its way, so another waits for it. */ + disabled?: boolean /** Move the item by this many places: -1 for up, 1 for down. */ onMove: (by: number) => void } -/** Up and Down buttons for an item in an ordered list. */ +/** Up and down arrows for an item in an ordered list. */ export function ReorderButtons(props: Props) { return ( - <> - {' '} - - + + + ) } diff --git a/apps/web/src/features/admin/components/RolePicker.tsx b/apps/web/src/features/admin/components/RolePicker.tsx index 67bda46..d362816 100644 --- a/apps/web/src/features/admin/components/RolePicker.tsx +++ b/apps/web/src/features/admin/components/RolePicker.tsx @@ -1,17 +1,27 @@ +import { cn } from '../../../shared/cn.ts' +import { SettingsEmpty } from '../../../shared/ui/SettingsPage.tsx' + interface Props { names: string[] picked: string[] onChange: (roles: string[]) => void + /** Classes for the row, such as dropping its padding inside a field that has its own. */ + className?: string } -/** A checkbox for each role, for choosing several. */ +/** A checkbox for each role, for choosing several, drawn as a row of chips that turn green when picked. */ export function RolePicker(props: Props) { + if (!props.names.length) return No roles yet. return ( - <> +
{props.names.map((name) => ( -
) } diff --git a/apps/web/src/features/admin/components/SuspendControl.tsx b/apps/web/src/features/admin/components/SuspendControl.tsx index 29adf83..6199f11 100644 --- a/apps/web/src/features/admin/components/SuspendControl.tsx +++ b/apps/web/src/features/admin/components/SuspendControl.tsx @@ -1,4 +1,7 @@ +import { useState } from 'react' import { useAppForm } from '../../../shared/form.tsx' +import { Badge } from '../../../shared/ui/Badge.tsx' +import { Button } from '../../../shared/ui/Button.tsx' import type { AdminUser } from '../queries.ts' interface Props { @@ -9,26 +12,68 @@ interface Props { onRestore: () => void } -/** Who suspended an account and why, with Restore, or a form to suspend it. */ +/** + * Access at the end of an account's row, which drops under its details in a narrow column, in line + * with them. + */ +const access = + 'mt-0.5 inline-flex flex-none items-start gap-2 @max-narrow:mt-0 @max-narrow:basis-full @max-narrow:ps-11.5' + +/** + * Whether an account may use the app: Suspended, with who did it and why cut short under it, and + * Restore, or Active with Suspend, which asks for a reason across the row first. + */ export function SuspendControl(props: Props) { - if (props.suspension) + const [suspending, setSuspending] = useState(false) + if (props.suspension) { + const by = `By ${props.suspension.by}` + const note = props.suspension.reason ? `${props.suspension.reason} · ${by}` : by return ( - <> - Suspended by {props.suspension.by} - {props.suspension.reason && `: ${props.suspension.reason}`}{' '} - - + + + ) + } + if (!suspending) + return ( + + + Active + + + ) - return + return ( + setSuspending(false)} + onSuspend={(reason) => { + setSuspending(false) + props.onSuspend(reason) + }} + /> + ) } interface SuspendFormProps { name: string + onCancel: () => void onSuspend: (reason: string) => void } +/** Asking for a reason, across the row under the account's details. */ function SuspendForm(props: SuspendFormProps) { const form = useAppForm({ defaultValues: { reason: '' }, @@ -36,16 +81,22 @@ function SuspendForm(props: SuspendFormProps) { }) return ( - + {(field) => ( )} - Suspend + + ) diff --git a/apps/web/src/features/admin/components/UserRow.tsx b/apps/web/src/features/admin/components/UserRow.tsx index 4ba8af1..d7c1166 100644 --- a/apps/web/src/features/admin/components/UserRow.tsx +++ b/apps/web/src/features/admin/components/UserRow.tsx @@ -1,4 +1,8 @@ -import { LocalTime } from '../../../shared/LocalTime.tsx' +import { ClientOnly } from '@tanstack/react-router' +import { CloseIcon } from '../../../shared/icons.tsx' +import { timeAgo } from '../../../shared/time.ts' +import { Badge, Chip } from '../../../shared/ui/Badge.tsx' +import { Item, ItemMark, ItemNames } from '../../../shared/ui/ItemList.tsx' import type { UserActions } from '../hooks/useUserActions.ts' import type { AdminUser } from '../queries.ts' import { SuspendControl } from './SuspendControl.tsx' @@ -10,59 +14,79 @@ interface Props { actions: UserActions } -/** An account: who it is, its roles, when it was last active, and what an admin can do to it. */ +/** + * An account: its initial, its handle over its DID, storage, and when it was last active, its roles + * as chips with a way to add one, and its access at the end. + */ export function UserRow(props: Props) { const user = props.user const name = user.handle ?? user.did return ( - - - {user.handle ?? ''} - {user.viewerOnly && ' (viewer)'} - - {user.did} - {user.storageMode} - - {user.roles.map((grant) => ( - - {grant.role} ({grant.source}){' '} - {grant.source === 'member' && ( - - )}{' '} - - ))} - - - - - - - - - props.actions.suspend(user.did, reason)} - onRestore={() => props.actions.restore(user.did)} - /> - - + + + + props.actions.suspend(user.did, reason)} + onRestore={() => props.actions.restore(user.did)} + /> + ) } diff --git a/apps/web/src/features/admin/components/UserSearch.tsx b/apps/web/src/features/admin/components/UserSearch.tsx index b963e19..f39d750 100644 --- a/apps/web/src/features/admin/components/UserSearch.tsx +++ b/apps/web/src/features/admin/components/UserSearch.tsx @@ -1,5 +1,9 @@ import { useNavigate } from '@tanstack/react-router' import { useState } from 'react' +import { cn } from '../../../shared/cn.ts' +import { Button } from '../../../shared/ui/Button.tsx' +import { field } from '../../../shared/ui/controls.tsx' +import { inlineForm } from '../../../shared/ui/SettingsPage.tsx' interface Props { q: string @@ -17,6 +21,8 @@ export function UserSearch(props: Props) { } return (
{ e.preventDefault() const trimmed = search.trim() @@ -28,12 +34,14 @@ export function UserSearch(props: Props) { }} > setSearch(e.target.value)} /> - +
) } diff --git a/apps/web/src/features/admin/pages/AccessAdmin.tsx b/apps/web/src/features/admin/pages/AccessAdmin.tsx index c11d0bb..3f77041 100644 --- a/apps/web/src/features/admin/pages/AccessAdmin.tsx +++ b/apps/web/src/features/admin/pages/AccessAdmin.tsx @@ -1,8 +1,15 @@ import { useStore as useFormStore } from '@tanstack/react-form' import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query' import { api, json, read } from '../../../shared/api.ts' -import { ErrorAlert } from '../../../shared/ErrorAlert.tsx' +import { messageOf } from '../../../shared/errors.ts' import { formKey, useAppForm } from '../../../shared/form.tsx' +import { + Choice, + Choices, + SaveBar, + SettingsGroup, + SettingsPage, +} from '../../../shared/ui/SettingsPage.tsx' import { RolePicker } from '../components/RolePicker.tsx' import { roleNames } from '../lib/roles.ts' import { adminAccessQuery, adminRolesQuery } from '../queries.ts' @@ -14,13 +21,14 @@ interface Access { inviteRoles: string[] } -const modes: { mode: Registration; label: string }[] = [ - { mode: 'open', label: 'Open: anyone with an atproto account can create an account.' }, +const modes: { mode: Registration; label: string; detail: string }[] = [ + { mode: 'open', label: 'Open', detail: 'Anyone with an atproto account can create an account.' }, { mode: 'invite', - label: 'Invite: members of the roles below, and people added on the Users page.', + label: 'Invite', + detail: 'Members of the roles below, and people added on the Users page.', }, - { mode: 'closed', label: 'Closed: only people added on the Users page.' }, + { mode: 'closed', label: 'Closed', detail: 'Only people added on the Users page.' }, ] /** Who can create an account. */ @@ -33,60 +41,71 @@ export function AccessAdmin() { onSuccess: (saved) => queryClient.setQueryData(adminAccessQuery.queryKey, saved), }) return ( -
-

Access

+ - -
+ ) } interface AccessFormProps { stored: Access roleNames: string[] + pending: boolean saved: boolean + error?: string | null onSave: (access: Access) => void + /** Called on any change, since what is shown is no longer what was saved. */ + onEdit: () => void } function AccessForm(props: AccessFormProps) { const form = useAppForm({ defaultValues: props.stored, + listeners: { onChange: () => props.onEdit() }, onSubmit: ({ value }) => props.onSave(value), }) const registration = useFormStore(form.store, (state) => state.values.registration) return ( -

- Who can create an account. People who already have one keep it, until you suspend them on - the Users page. Admins can always sign in, and anyone can sign in from a share link to - view a chat shared with them. -

- {(field) => - modes.map(({ mode, label }) => ( - - )) - } + value={mode.mode} + checked={field.state.value === mode.mode} + onChange={() => field.handleChange(mode.mode)} + label={mode.label} + description={mode.detail} + /> + ))} + + )} {registration === 'invite' && ( -
- Invite roles + {(field) => ( )} -
+ )} - Save - {props.saved && Saved.} +
) diff --git a/apps/web/src/features/admin/pages/ApiKeysAdmin.tsx b/apps/web/src/features/admin/pages/ApiKeysAdmin.tsx index 3f48996..107a7aa 100644 --- a/apps/web/src/features/admin/pages/ApiKeysAdmin.tsx +++ b/apps/web/src/features/admin/pages/ApiKeysAdmin.tsx @@ -1,20 +1,54 @@ import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query' +import { ClientOnly } from '@tanstack/react-router' import { api, json, read } from '../../../shared/api.ts' import { ErrorAlert } from '../../../shared/ErrorAlert.tsx' +import { messageOf } from '../../../shared/errors.ts' import { useAppForm } from '../../../shared/form.tsx' -import { LocalTime } from '../../../shared/LocalTime.tsx' +import { timeAgo } from '../../../shared/time.ts' +import { Item, ItemList, ItemNames } from '../../../shared/ui/ItemList.tsx' +import { Secret } from '../../../shared/ui/Secret.tsx' +import { + Callout, + ConfirmButton, + cardAlert, + SaveBar, + SettingRow, + SettingsEmpty, + SettingsGroup, + SettingsPage, +} from '../../../shared/ui/SettingsPage.tsx' import { RolePicker } from '../components/RolePicker.tsx' import { rolesWithUser } from '../lib/roles.ts' -import { adminApiKeysQuery, adminRolesQuery } from '../queries.ts' +import { type AdminApiKey, adminApiKeysQuery, adminRolesQuery } from '../queries.ts' /** Issue and revoke the keys that scripts, such as a crontab, send as a Bearer token. */ export function ApiKeysAdmin() { + const keysChanged = useKeysChanged() + const issue = useMutation({ + mutationFn: (draft: { label: string; roles: string[] }) => + read(api.admin['api-keys'].$post({}, json(draft))), + onSuccess: keysChanged, + }) return ( -
-

API keys

- - -
+ + {issue.data && ( + + Copy this key now. It can't be shown again. + + + )} + + + + issue.mutate(draft, { onSuccess: issued })} + /> + ) } @@ -23,102 +57,135 @@ function useKeysChanged() { return () => queryClient.invalidateQueries({ queryKey: adminApiKeysQuery.queryKey }) } -function ApiKeyTable() { +/** The issued keys with their roles and use, each with a way to revoke it. */ +function ApiKeyList() { const { data: keys } = useSuspenseQuery(adminApiKeysQuery) const keysChanged = useKeysChanged() const revoke = useMutation({ mutationFn: (id: string) => read(api.admin['api-keys'][':id'].$delete({ param: { id } })), onSuccess: keysChanged, }) + if (!keys.length) return No keys yet. return ( <> - - - - - - - - - - - {keys.map((key) => ( - revoke.mutate(key.id)} /> - ))} - -
LabelRolesCreatedLast used -
- + + {keys.map((key) => ( + revoke.mutate(key.id)} /> + ))} + + ) } interface ApiKeyRowProps { - apiKey: { label: string; roles: string[]; createdAt: string; lastUsedAt: string | null } + apiKey: AdminApiKey onRevoke: () => void } function ApiKeyRow(props: ApiKeyRowProps) { + const key = props.apiKey + return ( + + + + , + + + , + ]} + /> + + + ) +} + +interface LastUsedProps { + date: string | null +} + +function LastUsed(props: LastUsedProps) { + if (!props.date) return 'Never used' return ( - - {props.apiKey.label} - {props.apiKey.roles.join(', ')} - - - - - - - - - - + ) } -function IssueKeyForm() { +interface IssueKeyFormProps { + pending: boolean + error?: string | null + /** Issue a key, calling `issued` once it is, so the form can start over. */ + onIssue: (draft: { label: string; roles: string[] }, issued: () => void) => void +} + +function IssueKeyForm(props: IssueKeyFormProps) { const { data: roles } = useSuspenseQuery(adminRolesQuery) - const keysChanged = useKeysChanged() - const issue = useMutation({ - mutationFn: (draft: { label: string; roles: string[] }) => - read(api.admin['api-keys'].$post({}, json(draft))), - onSuccess: keysChanged, - }) const form = useAppForm({ defaultValues: { label: '', roles: [] as string[] }, - onSubmit: ({ value, formApi }) => issue.mutate(value, { onSuccess: () => formApi.reset() }), + onSubmit: ({ value, formApi }) => props.onIssue(value, () => formApi.reset()), }) return ( - <> - - -

New key

+ + + - {(field) => } + {(field) => ( + + + + )} -
- Roles + + What the key may do. Cron needs admin. + + } + wide + > {(field) => ( )} -
- Issue key -
-
- {issue.data && ( -

- Copy this key now. It can't be shown again: {issue.data.key} -

- )} - - + + + +
+
) } diff --git a/apps/web/src/features/admin/pages/CronAdmin.tsx b/apps/web/src/features/admin/pages/CronAdmin.tsx index 3034e6e..0fa7c78 100644 --- a/apps/web/src/features/admin/pages/CronAdmin.tsx +++ b/apps/web/src/features/admin/pages/CronAdmin.tsx @@ -1,7 +1,15 @@ import { useSuspenseQuery } from '@tanstack/react-query' import { ClientOnly, Link } from '@tanstack/react-router' -import { LocalTime } from '../../../shared/LocalTime.tsx' -import { adminCronQuery } from '../queries.ts' +import { timeAgo } from '../../../shared/time.ts' +import { Badge, Chips } from '../../../shared/ui/Badge.tsx' +import { Secret } from '../../../shared/ui/Secret.tsx' +import { + Callout, + SettingRow, + SettingsGroup, + SettingsPage, +} from '../../../shared/ui/SettingsPage.tsx' +import { adminCronQuery, type CronRun } from '../queries.ts' const dayMs = 86_400_000 @@ -9,65 +17,120 @@ const dayMs = 86_400_000 export function CronAdmin() { const { data: lastRun } = useSuspenseQuery(adminCronQuery) return ( -
-

Cron

+ -

- Issue a key with the admin role on the{' '} - API keys page, then call cron on a schedule, for example - from a crontab: -

- -
-          {`*/5 * * * * curl -fsS -X POST -H "Authorization: Bearer " ${window.location.origin}/api/cron`}
-        
-
-
+ +
    +
  1. + Issue a key with the admin role on the{' '} + API keys page. +
  2. +
  3. + Call cron on a schedule, for example every five minutes from a crontab: + + + +
  4. +
+
+ ) } +/** The crontab line for this server, which only the browser knows the address of. */ +function CronLine() { + return ( + " ${window.location.origin}/api/cron`} + /> + ) +} + +const notUpdated = "Plugins that work on a schedule, such as member lists, aren't being updated." + interface LastRunProps { - run: { startedAt: string; finishedAt: string | null; failed: string[] } | null + /** The last run, or null when cron has never run. */ + run: CronRun | null } -/** How the last run went, warning when cron has never run or hasn't run in a day. */ +/** How the last run went, warning when cron has never run or hasn't in over a day. */ function LastRun(props: LastRunProps) { if (!props.run) return ( -

- Cron has never run. Plugins that work on a schedule, such as member lists, aren't being - updated. -

+ +

Cron has never run. {notUpdated}

+
) + const run = props.run return ( - <> - - {Date.now() - Date.parse(props.run.startedAt) > dayMs && ( -

- Cron hasn't run in over a day. Plugins that work on a schedule, such as member lists, - aren't being updated. -

+ // Times are in the browser's locale and time zone, which the server rendering the page lacks. + + {Date.now() - Date.parse(run.startedAt) > dayMs && ( + +

Cron hasn't run in over a day. {notUpdated}

+
+ )} + + + {run.failed.length > 0 && ( + + + )} -
-

- Last run started - . - {props.run.failed.length > 0 && ` Failed: ${props.run.failed.join(', ')}.`} -

- + +
) } -interface FinishedAtProps { - date: string | null +interface RunProps { + run: CronRun } -function FinishedAt(props: FinishedAtProps) { - if (!props.date) return ' and is still going' +/** When the run started, as how long ago, and how long it took, with how it went. */ +function RunSummary(props: RunProps) { + const run = props.run + const started = new Date(run.startedAt) + const ago = timeAgo(started) + const when = started.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }) + const took = run.finishedAt + ? `took ${duration(Date.parse(run.finishedAt) - started.getTime())}` + : 'is still going' return ( - <> - {' '} - and finished - + + {ago.charAt(0).toUpperCase() + ago.slice(1)} + + } + description={`Started ${when} and ${took}.`} + > + + ) } + +/** How a run went, as a badge: still going, how many plugins failed, or that it succeeded. */ +function RunStatus(props: RunProps) { + if (!props.run.finishedAt) return Still going + if (props.run.failed.length) return {props.run.failed.length} failed + return Succeeded +} + +/** A run's length in whole seconds, or minutes once it passes one. */ +function duration(ms: number) { + const seconds = Math.max(0, Math.round(ms / 1000)) + if (seconds < 60) return seconds === 1 ? '1 second' : `${seconds} seconds` + const minutes = Math.round(seconds / 60) + return minutes === 1 ? '1 minute' : `${minutes} minutes` +} diff --git a/apps/web/src/features/admin/pages/ModelAdmin.tsx b/apps/web/src/features/admin/pages/ModelAdmin.tsx index 99c37eb..8dcf5ef 100644 --- a/apps/web/src/features/admin/pages/ModelAdmin.tsx +++ b/apps/web/src/features/admin/pages/ModelAdmin.tsx @@ -1,12 +1,21 @@ import { useMutation, useSuspenseQuery } from '@tanstack/react-query' import { Link, useNavigate } from '@tanstack/react-router' import { api, json, read } from '../../../shared/api.ts' -import { ErrorAlert } from '../../../shared/ErrorAlert.tsx' -import { lastError } from '../../../shared/errors.ts' +import { messageOf } from '../../../shared/errors.ts' import { formKey, useAppForm } from '../../../shared/form.tsx' +import { ArrowLeftIcon } from '../../../shared/icons.tsx' +import { + backLink, + Callout, + ConfirmButton, + SaveBar, + SettingRow, + SettingsGroup, + SettingsPage, +} from '../../../shared/ui/SettingsPage.tsx' import { RolePicker } from '../components/RolePicker.tsx' import { useModelsChanged } from '../hooks/changes.ts' -import { type AdminModel, capabilityNames, storedModel } from '../lib/models.ts' +import { type AdminModel, storedModel } from '../lib/models.ts' import { rolesWithUser } from '../lib/roles.ts' import { type AdminModelEntry, adminModelsQuery, adminRolesQuery } from '../queries.ts' @@ -35,86 +44,135 @@ export function ModelAdmin(props: ModelAdminProps) { await modelsChanged() }, }) - return ( - <> - - Back to plugin - - candidate.provider === props.provider && candidate.id === props.modelId, - )} - roleNames={rolesWithUser(roles.roles)} - saved={save.isSuccess} - onSave={save.mutate} - onRemove={() => remove.mutate()} - /> - - + const model = models.find( + (candidate) => candidate.provider === props.provider && candidate.id === props.modelId, ) -} - -interface ModelDetailsProps { - model: AdminModelEntry | undefined - roleNames: string[] - saved: boolean - onSave: (model: AdminModel) => void - onRemove: () => void -} - -/** The model's form, or that it isn't offered. */ -function ModelDetails(props: ModelDetailsProps) { - if (!props.model) return

This model isn't offered.

+ const back = ( + + Plugin + + ) + if (!model) + return ( + +

This model isn't offered.

+
+ ) return ( -
-

{props.model.name}

-

+ - {props.model.provider}/{props.model.id} + {model.provider}/{model.id} -

- {props.model.warning &&

{props.model.warning}

} + } + back={back} + > + {model.warning && ( + +

{model.warning}

+
+ )} -
+ + + {messageOf(remove.error)} +

+ ) + } + > + remove.mutate()} + /> +
+
+ ) } interface ModelFormProps { model: AdminModel roleNames: string[] + pending: boolean saved: boolean + error?: string | null onSave: (model: AdminModel) => void - onRemove: () => void + /** Called on any change, since what is shown is no longer what was saved. */ + onEdit: () => void } +const capabilities = [ + { key: 'vision', label: 'Vision', description: 'Reads images people attach.' }, + { + key: 'reasoning', + label: 'Thinking', + description: 'Thinks before it answers, at the effort people pick.', + }, + { key: 'tools', label: 'Tools', description: 'Calls the tools plugins offer.' }, +] as const + /** One admin model's name, capabilities, and roles. */ function ModelForm(props: ModelFormProps) { const form = useAppForm({ defaultValues: props.model, + listeners: { onChange: () => props.onEdit() }, onSubmit: ({ value }) => props.onSave(value), }) return ( - {(field) => } -
- Capabilities - {capabilityNames.map((capability) => ( - - {(field) => } + + + {(field) => ( + + + + )} + + + + {capabilities.map((capability) => ( + + {(field) => ( + + + + )} ))} -
-
- Roles + + {(field) => ( )} -
- Save{' '} - - {props.saved && Saved.} + +
) diff --git a/apps/web/src/features/admin/pages/ModelsAdmin.tsx b/apps/web/src/features/admin/pages/ModelsAdmin.tsx index 8d3d8b0..d2a95c4 100644 --- a/apps/web/src/features/admin/pages/ModelsAdmin.tsx +++ b/apps/web/src/features/admin/pages/ModelsAdmin.tsx @@ -1,8 +1,18 @@ import { useMutation, useSuspenseQuery } from '@tanstack/react-query' import { Link } from '@tanstack/react-router' +import { useId } from 'react' import { api, json, read } from '../../../shared/api.ts' import { ErrorAlert } from '../../../shared/ErrorAlert.tsx' import { lastError } from '../../../shared/errors.ts' +import { Badge } from '../../../shared/ui/Badge.tsx' +import { Radio } from '../../../shared/ui/controls.tsx' +import { Item, ItemNames } from '../../../shared/ui/ItemList.tsx' +import { + cardAlert, + SettingsEmpty, + SettingsGroup, + SettingsPage, +} from '../../../shared/ui/SettingsPage.tsx' import { ReorderButtons } from '../components/ReorderButtons.tsx' import { useModelsChanged } from '../hooks/changes.ts' import { storedModel } from '../lib/models.ts' @@ -29,27 +39,38 @@ export function ModelsAdmin() { onSuccess: modelsChanged, }) return ( -
-

Models

-

- Models are added on their provider's plugin page, under{' '} - Plugins. Here you set the default and the order users see - them in. -

-
    - {models.map((model, index) => ( - makeDefault.mutate(model)} - onMove={(by) => reorder.mutate(moveItem(models, index, by))} - /> - ))} -
- -
+ + The default model, and the order people see models in. Models are added on their + provider's plugin page, under Plugins. + + } + > + {models.length === 0 && No models yet.} + {models.length > 0 && ( + +
    + {models.map((model, index) => ( + makeDefault.mutate(model)} + onMove={(by) => reorder.mutate(moveItem(models, index, by))} + /> + ))} +
+ +
+ )} +
) } @@ -57,25 +78,54 @@ interface ModelRowProps { model: AdminModelEntry index: number count: number + /** Whether a move is on its way. */ + moving: boolean onMakeDefault: () => void onMove: (by: number) => void } +/** + * A model as a row that is also a radio, which picks it as the default from a click anywhere on + * it, with arrows that move it beside it. + */ function ModelRow(props: ModelRowProps) { const model = props.model + const ref = `${model.provider}/${model.id}` + const id = useId() + const warningId = useId() return ( -
  • -
  • + /> + + + ) } diff --git a/apps/web/src/features/admin/pages/NewPluginAdmin.tsx b/apps/web/src/features/admin/pages/NewPluginAdmin.tsx index 9c811b2..23d2dc0 100644 --- a/apps/web/src/features/admin/pages/NewPluginAdmin.tsx +++ b/apps/web/src/features/admin/pages/NewPluginAdmin.tsx @@ -2,10 +2,18 @@ import { useStore as useFormStore } from '@tanstack/react-form' import { useMutation, useSuspenseQuery } from '@tanstack/react-query' import { Link, useNavigate } from '@tanstack/react-router' import { api, json, read } from '../../../shared/api.ts' -import { ErrorAlert } from '../../../shared/ErrorAlert.tsx' -import { issuesOf } from '../../../shared/errors.ts' +import { issuesOf, messageOf } from '../../../shared/errors.ts' import { useAppForm } from '../../../shared/form.tsx' +import { ArrowLeftIcon } from '../../../shared/icons.tsx' import { SchemaFields } from '../../../shared/schema-fields/SchemaFields.tsx' +import { Select } from '../../../shared/ui/Select.tsx' +import { + backLink, + SaveBar, + SettingRow, + SettingsGroup, + SettingsPage, +} from '../../../shared/ui/SettingsPage.tsx' import { usePluginsChanged } from '../hooks/changes.ts' import { adminInstalledQuery, adminPluginsQuery } from '../queries.ts' @@ -35,50 +43,74 @@ export function NewPluginAdmin() { const added = new Set(plugins.map((instance) => instance.package)) const available = installed.filter((p) => p.multiple || !added.has(p.package)) const plugin = available.find((p) => p.package === picked) + const issues = issuesOf(add.error) return ( - <> - Back to plugins + + Plugins + + } + > -

    Add plugin

    - form.setFieldValue('options', {}) }} - > - {(field) => ( - - - {available.map((p) => ( - - ))} - - )} - - {plugin?.description &&

    {plugin.description}

    } - {plugin && ( - + + form.setFieldValue('options', {}) }} + > {(field) => ( - - field.handleChange((current) => ({ ...current, [key]: value })) + + > +