Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d3ae23660 | ||
|
|
8d4bf04452 | ||
|
|
65b725e1bb | ||
|
|
5e631b2f80 | ||
|
|
0b7b0b9650 | ||
|
|
0c59c7572f | ||
|
|
6dc1ee4536 | ||
|
|
f9e62a4932 |
No files matched your search
@@ -92,6 +92,3 @@ homarr-backup*.zip
|
||||
*.sqlite-wal
|
||||
*.sqlite-shm
|
||||
/scripts/benchmarks/
|
||||
|
||||
# PR description screenshots belong on the pull request, not in the repository
|
||||
.screenshots/
|
||||
@@ -72,34 +72,6 @@ API key authentication enables programmatic access for:
|
||||
- Custom dashboards and monitoring tools
|
||||
- Container orchestration systems that need to sync app definitions
|
||||
|
||||
## Permissions
|
||||
|
||||
API keys act as the user that created them, so every request is subject to that user's group
|
||||
permissions. A call that the user is not allowed to make returns `FORBIDDEN`, and list endpoints
|
||||
return only the records the user may see rather than failing.
|
||||
|
||||
### App endpoints require app management access
|
||||
|
||||
`GET /api/apps`, `GET /api/apps/paginated` and `GET /api/apps/search` (tRPC `app.all`,
|
||||
`app.getPaginated`, `app.search`) require the **modify all apps** permission. They previously
|
||||
returned the full app catalogue — including internal URLs — to any authenticated user.
|
||||
|
||||
If a script that used to work now returns `FORBIDDEN`, grant **modify all apps** to a group the key
|
||||
owner belongs to, or switch to `GET /api/apps/selectable`, which returns the reduced fields used by
|
||||
board pickers.
|
||||
|
||||
### Integration endpoints are filtered per key
|
||||
|
||||
`integration.all`, `integration.allThatSupportSearch`, `integration.allOfGivenCategory` and
|
||||
`integration.search` filter server side. Without a global integration permission, a key only
|
||||
receives the integrations its owner has been granted access to, so responses may contain fewer
|
||||
records than before rather than returning an error. `integration.search` applies its `limit` after
|
||||
filtering, so a scoped key still receives a full page of results.
|
||||
|
||||
Each returned integration carries a `permissions` object — check `hasUseAccess` before reading data
|
||||
and `hasInteractAccess` before performing actions; `false` means the key owner lacks that level for
|
||||
that integration, not that the call failed.
|
||||
|
||||
## Board Automation
|
||||
|
||||
The OpenAPI and MCP APIs include board management endpoints for common automation workflows:
|
||||
|
||||
@@ -51,17 +51,6 @@ Note that the ping URL only accepts `http://` and `https://` URLs — custom URI
|
||||
|
||||
## List of apps
|
||||
|
||||
On the apps management page you find all apps that exist in your Homarr instance. Search by name or URL, open an app
|
||||
directly, or use its row actions to edit and delete it. A filtered search can be cleared without leaving the page.
|
||||
On the apps management page you find all apps that exist in your Homarr instance. Search by name or URL, open an app directly, or use its row actions to edit and delete it. A filtered search can be cleared without leaving the page.
|
||||
|
||||
### Accessing the page
|
||||
|
||||
The `/manage/apps` page is reachable when you can manage all apps (`app-modify-all`) or create apps (`app-create`):
|
||||
|
||||
- **Manage all (`app-modify-all`)**: the full list of apps is shown and searchable, and each row can be edited.
|
||||
- **Create only (`app-create`)**: the page shows an empty "create an app" state instead of the list, so the names and
|
||||
URLs of existing apps are never disclosed. The create action is still available.
|
||||
- **Neither**: the page is not reachable and returns a not-found page.
|
||||
|
||||
Creating an app requires `app-create`, editing requires `app-modify-all`, and deleting requires `app-full-all`;
|
||||
board permissions are not required.
|
||||
Any signed-in user can view this list. Creating, editing, and deleting apps requires the corresponding app permission; board permissions are not required.
|
||||
@@ -44,15 +44,12 @@ host gateway address.
|
||||
The assistant can work with boards, apps, integrations, widgets, Custom Widgets, and connected services. Use `@` to
|
||||
add Homarr items as context, `/` for shortcuts, and the paperclip for supported files.
|
||||
|
||||
Use **Conversations** to search, switch, rename, export, or delete threads.
|
||||
|
||||
Each request includes the user's name, current page and board, home board, server time, browser time zone, and a small
|
||||
summary of accessible Homarr resources. Live status is fetched only when needed.
|
||||
|
||||
## Permissions and privacy
|
||||
|
||||
- Read-only actions can run automatically. Changes require approval by default. Automatic approvals apply only to the
|
||||
current conversation, turn off when you switch conversations, and never answer questions for you.
|
||||
- Read-only actions can run automatically. Changes require approval.
|
||||
- Homarr permissions apply to every tool call and mentioned item.
|
||||
- API keys and custom headers are encrypted and never returned to the browser.
|
||||
- Conversations are stored per user and can be exported or deleted.
|
||||
|
||||
@@ -205,7 +205,7 @@ changes, Homarr asks whether to discard them. If the editor cannot load, the boa
|
||||
/>
|
||||
|
||||
While edit mode is active, **Add board content** opens the same Universal Create surface as the global header action and
|
||||
Spotlight. It lists the available actions directly and can add a widget, app, integration, Container, board, Workshop item, or Custom Widget when the
|
||||
Spotlight. It can add a widget, app, integration, Container, board, Workshop item, or Custom Widget when the
|
||||
current user and context allow it. Widget setup offers the main canvas, enabled rails, and existing Containers as
|
||||
destinations before the widget is created, so the common flow does not require a second move step.
|
||||
|
||||
|
||||
@@ -17,24 +17,6 @@ To create an integration, choose **New integration** and select the supported ap
|
||||
|
||||
The integrations management page uses a searchable list. Each row shows the integration type and URL, with the actions you have permission to use.
|
||||
|
||||
### Accessing the page
|
||||
|
||||
The `/manage/integrations` page is reachable when at least one of the following applies:
|
||||
|
||||
- you can manage all integrations (global `integration-full-all`),
|
||||
- you can create integrations (`integration-create`), or
|
||||
- you have been granted full access to at least one specific integration.
|
||||
|
||||
What the page shows depends on the case:
|
||||
|
||||
- **Manage all (`integration-full-all`)**: the full list of integrations is shown.
|
||||
- **Create only (`integration-create`)**: the page shows an empty "create an integration" state instead of the list, with the create action still available.
|
||||
- **Delegated (full access on one or more)**: only the integrations you have full access to are listed, above a note making clear that the view is scoped to what you can manage.
|
||||
- **None**: the page is not reachable and returns a not-found page.
|
||||
|
||||
Where you have a mix of access, the list combines the integrations you can manage. Integration lists are filtered
|
||||
server side, so a user never receives data about integrations they lack access to.
|
||||
|
||||
Next, you'll need to fill out the required fields to configure the integration:
|
||||
|
||||

|
||||
@@ -81,9 +63,9 @@ When you connect a service from a board, Homarr keeps you in the same setup flow
|
||||
confirms whether an application shortcut was linked and offers the widgets that support the new integration. Selecting a
|
||||
widget returns to its settings with the new integration already selected.
|
||||
|
||||
Users with full access can also test and update a selected integration directly in the **Edit widget → Edit integration**
|
||||
tab on a board. Failed tests keep the editor open and show the same connection diagnostics as the management page.
|
||||
Successful changes apply to every app and widget that uses the integration.
|
||||
Users with full access can also test and update a selected integration from **Edit widget → Edit integration** on a
|
||||
board. Failed tests keep the editor open and show the same connection diagnostics as the management page. Successful
|
||||
changes apply to every app and widget that uses the integration.
|
||||
|
||||
When app creation is not permitted, the setup form offers linking an existing accessible app instead of attempting to
|
||||
create one.
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
tags:
|
||||
- Administration
|
||||
- Logs
|
||||
- Troubleshooting
|
||||
---
|
||||
|
||||
# Logs
|
||||
|
||||
The logs page shows recent Homarr application logs and continues updating as new events occur. Use it to investigate
|
||||
widget, integration, and server errors without opening the container logs separately.
|
||||
|
||||
## Opening the logs page
|
||||
|
||||
Navigate to **Management** > **Tools** > **Logs**. The page and widget log links are only available to users with the
|
||||
**View logs** permission.
|
||||
|
||||
When a widget reports an error, **Check logs for more details** opens the logs page in a new tab and marks the time when
|
||||
the widget displayed the error. The related server error normally appears immediately above that marker. If those logs
|
||||
have already been removed from the retained history, the marker explains that they are no longer available.
|
||||
|
||||
## Retained and live logs
|
||||
|
||||
Homarr retains the latest 5,000 log events in Redis. Opening the page first loads this history and then continues with
|
||||
live events without a gap. The history is bounded, so older events are removed automatically.
|
||||
|
||||
The history survives normal application restarts when the configured Redis instance persists its data. For complete
|
||||
platform-level history, startup failures, or events older than the retained limit, use the logs provided by Docker or
|
||||
your deployment platform.
|
||||
|
||||
## Display controls
|
||||
|
||||
The severity selector applies to both retained and live events. Selecting a different level redraws the existing view
|
||||
using that threshold. The font-size control changes the terminal text size and is remembered in the browser.
|
||||
@@ -13,7 +13,7 @@ tags:
|
||||
The settings page applies options to the whole Homarr application.
|
||||
Some changes need a full page reload before they take effect.
|
||||
|
||||
All settings sections remain visible in a single form. Click the **Save changes** button in the save bar at the bottom of the page to apply them. The save bar only appears when you have unsaved changes, and the **Discard** button reverts your changes.
|
||||
All changes are collected in a single form. Click the **Save changes** button in the save bar at the bottom of the page to apply them. The save bar only appears when you have unsaved changes, and the **Discard** button reverts your changes.
|
||||
|
||||
## Analytics
|
||||
|
||||
|
||||
@@ -163,77 +163,19 @@ Members can be removed from the group using the remove button at the end:
|
||||
#### Permissions tab
|
||||
|
||||
In this tab, you manage what permissions the members of this group will have.
|
||||
Permissions are shown as a matrix with one row per category (boards, apps, integrations, and so on). Each row has a
|
||||
segmented control that lets you pick a single **access level**, plus a checkbox for each **capability** the category
|
||||
has.
|
||||
Currently, the following permissions are available:
|
||||
|
||||
Levels in a category are cumulative: selecting a level grants that level and every level below it in the same
|
||||
category. Choosing **No access** grants no access level for that category.
|
||||
|
||||
##### Capabilities are separate from the access level
|
||||
|
||||
A capability is something you can grant on its own, independently of how much of everyone else's data the members
|
||||
can see. Creating is the usual example: you can let members create their own boards without letting them view or
|
||||
modify anyone else's boards. Apps additionally have **use all apps**, which lets members put any app on a board
|
||||
without being able to edit apps.
|
||||
|
||||
Some access levels already include a capability through permission inheritance (marked in the table below); the
|
||||
checkbox is then ticked, disabled, and explains which level includes it.
|
||||
|
||||
##### Administration
|
||||
|
||||
Selecting the **Administrator** level grants full access to everything. While it is selected, every other category
|
||||
is disabled and explains that administrators already have that access. Setting the administrator row back to
|
||||
**No access** re-enables the other categories with the selections they had before.
|
||||
|
||||
##### Access levels
|
||||
|
||||
The access levels below are cumulative, in ascending order, with the category's capabilities listed separately:
|
||||
|
||||
| Category | Access levels (ascending) | Capabilities | Already included at |
|
||||
| -------------- | ---------------------------------------------------------------------------------------- | ------------------------------- | ---------------------------------------------------- |
|
||||
| Boards | view all boards → modify all boards → full board access | create boards | full board access |
|
||||
| Apps | modify all apps → full app access | use all apps, create apps | create apps: modify all apps and full app access; use all apps: full app access |
|
||||
| Integrations | use all integrations → interact with any integration → full integration access | create integrations | full integration access |
|
||||
| Search engines | modify all search engines → full search engine access | create search engines | modify all search engines, full search engine access |
|
||||
| Medias | view all medias → full media access | upload medias | full media access |
|
||||
| Other | view logs | — | — |
|
||||
| Administrator | administrator | — | — |
|
||||
|
||||
Apps are the one category where "use" is not a level. Being able to modify every app does **not** imply being able
|
||||
to put any app on a board — only full app access grants both — so the two are kept independent rather than stacked,
|
||||
and picking **Modify** never silently grants use of every app.
|
||||
|
||||
The levels grant the following:
|
||||
|
||||
- **Boards**: view all boards; modify all boards (not access control and danger zone); full board access (view, modify and delete every board, including access control and the danger zone).
|
||||
- **Apps**: modify all apps; full app access (manage, use and delete any app).
|
||||
- **Integrations**: use all integrations; interact with any integration; full integration access (manage, use and interact with any integration).
|
||||
- **Search engines**: modify all search engines; full search engine access (manage and delete any search engine).
|
||||
- **Medias**: view all medias; full media access (manage and delete any media).
|
||||
- **Other**: view logs.
|
||||
|
||||
##### Presets
|
||||
|
||||
Above the rows you can apply a preset to fill in a common configuration in one click:
|
||||
|
||||
- **Viewer**: read-only - view all boards and medias, use any app on a board, and use existing integrations.
|
||||
- **Editor**: day-to-day management - modify all boards and apps, create boards and integrations, interact with any integration, modify search engines, and upload medias, without deleting resources or managing access.
|
||||
- **Admin**: grants the administrator permission (full access to everything).
|
||||
|
||||
When the current selection does not match any preset, a **Custom** badge is shown.
|
||||
|
||||
##### Also grants
|
||||
|
||||
Some levels include additional permissions automatically. When a level implies extra permissions (for example,
|
||||
modifying all apps also allows creating apps), a dimmed **Also grants: …** hint is shown below the category row,
|
||||
listing the implied permissions.
|
||||
|
||||
##### Effective permissions
|
||||
|
||||
Below the rows, the **Effective permissions** panel can be expanded to review everything members of this group will
|
||||
be able to do - the full, de-duplicated set of permissions, including everything granted indirectly through the
|
||||
levels - grouped by category, with a count for each.
|
||||
- Boards
|
||||
- **Create boards**: Allow members to create boards
|
||||
- **View all boards**: Allow members to view all boards
|
||||
- **Modify all boards**: Allow members to modify all boards (Does not include access control and danger zone)
|
||||
- **Full board access**: Allow members to view, modify, and delete all boards (Including access control and danger zone)
|
||||
- Integrations
|
||||
- **Create integrations**: Allow members to create integrations
|
||||
- **Use all integrations**: Allows members to add any integrations to their boards
|
||||
- **Interact with any integration**: Allow members to interact with any integration
|
||||
- **Full integration access**: Allow members to manage, use and interact with any integration
|
||||
- **Administrator**: Members with this permission have full access to all features and settings
|
||||
|
||||
Custom Widget authoring, import, migration, preview, secrets, Workshop access, and placement configuration are reserved
|
||||
for administrators and are not separate assignable group permissions. Board modifiers can still move, resize, change
|
||||
|
||||
@@ -22,7 +22,7 @@ import { plexIntegration } from "@site/docs/integrations/plex";
|
||||
<WidgetHeader widget={mediaServerWidget} categories={["Media server"]} />
|
||||
|
||||
The media server widget shows active sessions across your media servers.
|
||||
It shows playback progress, pause state, time remaining, the streaming resolution (e.g. `1080p`), and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
|
||||
It shows playback progress, pause state, time remaining, and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
|
||||
|
||||
Select a session to inspect its media metadata in an anchored popover. Advanced mode adds search, sorting, location, bitrate, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable. Compact visibility switches do not hide location or bitrate from the complete advanced session view.
|
||||
If one selected server is unavailable, a warning indicator identifies the degraded result while sessions from healthy servers remain usable.
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { Session } from "@homarr/auth";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getAppManagementAccess, getIntegrationManagementAccess } from "@homarr/definitions";
|
||||
|
||||
/**
|
||||
* Session-aware wrappers around the shared management access rules in `@homarr/definitions`, so the
|
||||
* navigation and the pages resolve access exactly the same way the API does.
|
||||
*/
|
||||
|
||||
export const getAppsSectionAccess = (session: Session | null) =>
|
||||
getAppManagementAccess(session?.user.permissions ?? []);
|
||||
|
||||
/**
|
||||
* Pure variant for callers that already fetched the integrations the user can see, so the list is
|
||||
* only queried once per request.
|
||||
*/
|
||||
export const getIntegrationsSectionAccess = (
|
||||
session: Session | null,
|
||||
visibleIntegrations: { permissions: { hasFullAccess: boolean } }[],
|
||||
) =>
|
||||
getIntegrationManagementAccess(
|
||||
session?.user.permissions ?? [],
|
||||
visibleIntegrations.some((integration) => integration.permissions.hasFullAccess),
|
||||
);
|
||||
|
||||
export const getIntegrationsSectionAccessAsync = async (session: Session | null) => {
|
||||
const permissions = session?.user.permissions ?? [];
|
||||
|
||||
// Only pay for the lookup when the answer is not already known from the global permissions.
|
||||
const needsDelegatedLookup =
|
||||
session !== null && !permissions.includes("integration-full-all") && !permissions.includes("integration-create");
|
||||
|
||||
return getIntegrationsSectionAccess(session, needsDelegatedLookup ? await api.integration.all() : []);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Fragment } from "react";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Text } from "@mantine/core";
|
||||
import { IconBox, IconPencil } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
@@ -16,7 +16,6 @@ import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import { ManageCollectionItem, ManageCollectionPage } from "~/components/manage/manage-collection";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { getAppsSectionAccess } from "../_access";
|
||||
import { AppDeleteButton } from "./_app-delete-button";
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
@@ -42,27 +41,16 @@ interface AppsPageProps {
|
||||
export default async function AppsPage(props: AppsPageProps) {
|
||||
const session = await auth();
|
||||
if (!session) redirect("/auth/login");
|
||||
const { canManageAll, canCreate, canAccess } = getAppsSectionAccess(session);
|
||||
if (!canAccess) notFound();
|
||||
const canDelete = session.user.permissions.includes("app-full-all");
|
||||
|
||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||
// Without app-modify-all the user may add apps but not browse the ones they cannot manage,
|
||||
// so the list stays empty instead of leaking every app name and URL.
|
||||
const { items: apps, totalCount } = canManageAll
|
||||
? await api.app.getPaginated(searchParams)
|
||||
: { items: [], totalCount: 0 };
|
||||
const { items: apps, totalCount } = await api.app.getPaginated(searchParams);
|
||||
const t = await getScopedI18n("app");
|
||||
const hasSearch = canManageAll && Boolean(searchParams.search?.trim());
|
||||
const canCreate = session.user.permissions.includes("app-create");
|
||||
const canModify = session.user.permissions.includes("app-modify-all");
|
||||
const canDelete = session.user.permissions.includes("app-full-all");
|
||||
const hasSearch = Boolean(searchParams.search?.trim());
|
||||
|
||||
const emptyState = !canManageAll ? (
|
||||
<NoResults
|
||||
icon={IconBox}
|
||||
title={t("page.list.noResults.createOnlyTitle")}
|
||||
description={t("page.list.noResults.createOnlyDescription")}
|
||||
action={{ label: t("page.list.noResults.action"), href: "/manage/apps/new" }}
|
||||
/>
|
||||
) : hasSearch ? (
|
||||
const emptyState = hasSearch ? (
|
||||
<NoResults
|
||||
icon={IconBox}
|
||||
title={t("page.list.noResults.filteredTitle")}
|
||||
@@ -94,14 +82,12 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
) : undefined
|
||||
}
|
||||
toolbar={
|
||||
canManageAll ? (
|
||||
<SearchInput
|
||||
placeholder={`${t("search")}...`}
|
||||
ariaLabel={t("search")}
|
||||
defaultValue={searchParams.search}
|
||||
flexExpand
|
||||
/>
|
||||
) : undefined
|
||||
<SearchInput
|
||||
placeholder={`${t("search")}...`}
|
||||
ariaLabel={t("search")}
|
||||
defaultValue={searchParams.search}
|
||||
flexExpand
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
totalCount > searchParams.pageSize ? (
|
||||
@@ -114,6 +100,7 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
<AppItem
|
||||
key={app.id}
|
||||
app={app}
|
||||
canModify={canModify}
|
||||
canDelete={canDelete}
|
||||
editLabel={t("page.list.action.edit", { name: app.name })}
|
||||
/>
|
||||
@@ -126,11 +113,12 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
|
||||
interface AppItemProps {
|
||||
app: RouterOutputs["app"]["getPaginated"]["items"][number];
|
||||
canModify: boolean;
|
||||
canDelete: boolean;
|
||||
editLabel: string;
|
||||
}
|
||||
|
||||
const AppItem = ({ app, canDelete, editLabel }: AppItemProps) => {
|
||||
const AppItem = ({ app, canModify, canDelete, editLabel }: AppItemProps) => {
|
||||
const descriptionLines = app.description?.split("\n");
|
||||
const safeHref = getSafeAppHref(app.href);
|
||||
|
||||
@@ -169,19 +157,23 @@ const AppItem = ({ app, canDelete, editLabel }: AppItemProps) => {
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/apps/edit/${app.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size={44}
|
||||
aria-label={editLabel}
|
||||
>
|
||||
<IconPencil size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
{canDelete && <AppDeleteButton app={app} />}
|
||||
</ActionIconGroup>
|
||||
canModify || canDelete ? (
|
||||
<ActionIconGroup>
|
||||
{canModify && (
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/apps/edit/${app.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size={44}
|
||||
aria-label={editLabel}
|
||||
>
|
||||
<IconPencil size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{canDelete && <AppDeleteButton app={app} />}
|
||||
</ActionIconGroup>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
+120
-156
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useImperativeHandle, useRef, useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Alert, Anchor, Button, ButtonGroup, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { IconInfoCircle, IconPencil, IconPlus, IconUnlink } from "@tabler/icons-react";
|
||||
@@ -27,14 +27,8 @@ import type { AnyMappedTestConnectionError } from "../../_components/test-connec
|
||||
|
||||
interface EditIntegrationFormProps {
|
||||
integration: RouterOutputs["integration"]["byId"];
|
||||
hideButtons?: boolean;
|
||||
embedded?: boolean;
|
||||
onSuccess?: () => void;
|
||||
formRef?: React.Ref<EditIntegrationFormHandle>;
|
||||
}
|
||||
|
||||
export interface EditIntegrationFormHandle {
|
||||
submit: () => Promise<boolean>;
|
||||
isDirty: () => boolean;
|
||||
}
|
||||
|
||||
const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
|
||||
@@ -50,12 +44,7 @@ const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
|
||||
}),
|
||||
);
|
||||
|
||||
export const EditIntegrationForm = ({
|
||||
integration,
|
||||
hideButtons = false,
|
||||
onSuccess,
|
||||
formRef,
|
||||
}: EditIntegrationFormProps) => {
|
||||
export const EditIntegrationForm = ({ integration, embedded = false, onSuccess }: EditIntegrationFormProps) => {
|
||||
const t = useI18n();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const allSecretKinds = getAllSecretKindOptions(integration.kind);
|
||||
@@ -93,172 +82,147 @@ export const EditIntegrationForm = ({
|
||||
|
||||
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
|
||||
|
||||
const handleSubmitAsync = useCallback(
|
||||
async ({ app, ...values }: FormType) => {
|
||||
setError(null);
|
||||
let url: string;
|
||||
try {
|
||||
url = hasUrlSecret
|
||||
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
|
||||
: values.url;
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("integration.page.edit.notification.error.title"),
|
||||
message: t("integration.page.edit.notification.error.message"),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await mutateAsync({
|
||||
id: integration.id,
|
||||
...values,
|
||||
url,
|
||||
secrets: values.secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
value: secret.value === "" ? null : secret.value,
|
||||
})),
|
||||
appId: app?.id ?? null,
|
||||
const handleSubmitAsync = async ({ app, ...values }: FormType) => {
|
||||
setError(null);
|
||||
let url: string;
|
||||
try {
|
||||
url = hasUrlSecret
|
||||
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
|
||||
: values.url;
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("integration.page.edit.notification.error.title"),
|
||||
message: t("integration.page.edit.notification.error.message"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// We do it this way as we are unable to send a typesafe error through onError
|
||||
if (data?.error) {
|
||||
setError(data.error);
|
||||
showErrorNotification({
|
||||
title: t("integration.page.edit.notification.error.title"),
|
||||
message: t("integration.page.edit.notification.error.message"),
|
||||
});
|
||||
requestAnimationFrame(() => errorRef.current?.focus());
|
||||
return false;
|
||||
}
|
||||
const data = await mutateAsync({
|
||||
id: integration.id,
|
||||
...values,
|
||||
url,
|
||||
secrets: values.secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
value: secret.value === "" ? null : secret.value,
|
||||
})),
|
||||
appId: app?.id ?? null,
|
||||
});
|
||||
|
||||
showSuccessNotification({
|
||||
title: t("integration.page.edit.notification.success.title"),
|
||||
message: t("integration.page.edit.notification.success.message"),
|
||||
// We do it this way as we are unable to send a typesafe error through onError
|
||||
if (data?.error) {
|
||||
setError(data.error);
|
||||
showErrorNotification({
|
||||
title: t("integration.page.edit.notification.error.title"),
|
||||
message: t("integration.page.edit.notification.error.message"),
|
||||
});
|
||||
void Promise.allSettled([utils.integration.invalidate(), utils.widget.invalidate()]);
|
||||
onSuccess?.();
|
||||
if (!hideButtons) {
|
||||
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[hasUrlSecret, hideButtons, integration.id, mutateAsync, onSuccess, router, t, utils.integration, utils.widget],
|
||||
);
|
||||
requestAnimationFrame(() => errorRef.current?.focus());
|
||||
return;
|
||||
}
|
||||
|
||||
useImperativeHandle(
|
||||
formRef,
|
||||
() => ({
|
||||
submit: () =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
form.onSubmit(
|
||||
async (values) => {
|
||||
try {
|
||||
resolve(await handleSubmitAsync(values));
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
},
|
||||
() => resolve(false),
|
||||
)();
|
||||
}),
|
||||
isDirty: () => form.isDirty(),
|
||||
}),
|
||||
[form, handleSubmitAsync],
|
||||
);
|
||||
showSuccessNotification({
|
||||
title: t("integration.page.edit.notification.success.title"),
|
||||
message: t("integration.page.edit.notification.success.message"),
|
||||
});
|
||||
void Promise.allSettled([utils.integration.invalidate(), utils.widget.invalidate()]);
|
||||
onSuccess?.();
|
||||
if (!embedded) {
|
||||
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
|
||||
}
|
||||
};
|
||||
|
||||
const isInitialSecretKinds =
|
||||
initialSecretsKinds.every((kind) => form.values.secrets.some((secret) => secret.kind === kind)) &&
|
||||
form.values.secrets.length === initialSecretsKinds.length;
|
||||
|
||||
const formFields = (
|
||||
<Stack>
|
||||
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(async (values) => await handleSubmitAsync(values))}>
|
||||
<Stack>
|
||||
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
|
||||
|
||||
{hasUrlSecret ? null : (
|
||||
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
|
||||
)}
|
||||
{hasUrlSecret ? null : (
|
||||
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
|
||||
)}
|
||||
|
||||
<Fieldset legend={t("integration.secrets.title")}>
|
||||
<Stack gap="sm">
|
||||
{allSecretKinds.length > 1 && (
|
||||
<SecretKindsSegmentedControl defaultKinds={initialSecretsKinds} secretKinds={allSecretKinds} form={form} />
|
||||
)}
|
||||
{!isInitialSecretKinds
|
||||
? null
|
||||
: form.values.secrets.map((secret, index) => (
|
||||
<SecretCard
|
||||
key={secret.kind}
|
||||
secret={secretsMap.get(secret.kind) ?? { kind: secret.kind, value: null, updatedAt: null }}
|
||||
onCancel={() =>
|
||||
new Promise((resolve) => {
|
||||
// When nothing changed, just close the secret card
|
||||
if ((secret.value ?? "") === (secretsMap.get(secret.kind)?.value ?? "")) {
|
||||
return resolve(true);
|
||||
}
|
||||
openConfirmModal({
|
||||
title: t("integration.secrets.reset.title"),
|
||||
children: t("integration.secrets.reset.message"),
|
||||
onCancel: () => resolve(false),
|
||||
onConfirm: () => {
|
||||
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(secret.kind)?.value ?? "");
|
||||
resolve(true);
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
>
|
||||
<IntegrationSecretInput
|
||||
label={t(`integration.secrets.kind.${secret.kind}.newLabel`)}
|
||||
<Fieldset legend={t("integration.secrets.title")}>
|
||||
<Stack gap="sm">
|
||||
{allSecretKinds.length > 1 && (
|
||||
<SecretKindsSegmentedControl
|
||||
defaultKinds={initialSecretsKinds}
|
||||
secretKinds={allSecretKinds}
|
||||
form={form}
|
||||
/>
|
||||
)}
|
||||
{!isInitialSecretKinds
|
||||
? null
|
||||
: form.values.secrets.map((secret, index) => (
|
||||
<SecretCard
|
||||
key={secret.kind}
|
||||
kind={secret.kind}
|
||||
secret={secretsMap.get(secret.kind) ?? { kind: secret.kind, value: null, updatedAt: null }}
|
||||
onCancel={() =>
|
||||
new Promise((resolve) => {
|
||||
// When nothing changed, just close the secret card
|
||||
if ((secret.value ?? "") === (secretsMap.get(secret.kind)?.value ?? "")) {
|
||||
return resolve(true);
|
||||
}
|
||||
openConfirmModal({
|
||||
title: t("integration.secrets.reset.title"),
|
||||
children: t("integration.secrets.reset.message"),
|
||||
onCancel: () => resolve(false),
|
||||
onConfirm: () => {
|
||||
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(secret.kind)?.value ?? "");
|
||||
resolve(true);
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
>
|
||||
<IntegrationSecretInput
|
||||
label={t(`integration.secrets.kind.${secret.kind}.newLabel`)}
|
||||
key={secret.kind}
|
||||
kind={secret.kind}
|
||||
{...form.getInputProps(`secrets.${index}.value`)}
|
||||
/>
|
||||
</SecretCard>
|
||||
))}
|
||||
{isInitialSecretKinds
|
||||
? null
|
||||
: form.values.secrets.map(({ kind }, index) => (
|
||||
<IntegrationSecretInput
|
||||
withAsterisk
|
||||
key={kind}
|
||||
kind={kind}
|
||||
{...form.getInputProps(`secrets.${index}.value`)}
|
||||
/>
|
||||
</SecretCard>
|
||||
))}
|
||||
{isInitialSecretKinds
|
||||
? null
|
||||
: form.values.secrets.map(({ kind }, index) => (
|
||||
<IntegrationSecretInput
|
||||
withAsterisk
|
||||
key={kind}
|
||||
kind={kind}
|
||||
{...form.getInputProps(`secrets.${index}.value`)}
|
||||
/>
|
||||
))}
|
||||
{form.values.secrets.length === 0 && (
|
||||
<Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}>
|
||||
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
))}
|
||||
{form.values.secrets.length === 0 && (
|
||||
<Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}>
|
||||
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
|
||||
<IntegrationLinkApp value={form.values.app} onChange={(app) => form.setFieldValue("app", app)} />
|
||||
<IntegrationLinkApp value={form.values.app} onChange={(app) => form.setFieldValue("app", app)} />
|
||||
|
||||
{error !== null && (
|
||||
<div ref={errorRef} role="alert" tabIndex={-1}>
|
||||
<IntegrationTestConnectionError error={error} url={form.values.url} />
|
||||
</div>
|
||||
)}
|
||||
{error !== null && (
|
||||
<div ref={errorRef} role="alert" tabIndex={-1}>
|
||||
<IntegrationTestConnectionError error={error} url={form.values.url} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hideButtons && (
|
||||
<Group justify="end" align="center">
|
||||
<Button variant="default" component={Link} href="/manage/integrations">
|
||||
{t("common.action.backToOverview")}
|
||||
</Button>
|
||||
{!embedded && (
|
||||
<Button variant="default" component={Link} href="/manage/integrations">
|
||||
{t("common.action.backToOverview")}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("integration.testConnection.action.edit")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
|
||||
if (hideButtons) {
|
||||
return formFields;
|
||||
}
|
||||
|
||||
return <form onSubmit={form.onSubmit(async (values) => await handleSubmitAsync(values))}>{formFields}</form>;
|
||||
};
|
||||
|
||||
type FormType = z.infer<typeof formSchema>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Badge, Stack, Text } from "@mantine/core";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Badge, Text } from "@mantine/core";
|
||||
import { IconPencil, IconPlugX } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
@@ -15,7 +15,6 @@ import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import { ManageCollectionItem, ManageCollectionPage } from "~/components/manage/manage-collection";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { getIntegrationsSectionAccess } from "../_access";
|
||||
import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
||||
|
||||
const searchParamsSchema = z.object({ search: z.string().optional() });
|
||||
@@ -30,22 +29,11 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
|
||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||
const integrations = await api.integration.all();
|
||||
const {
|
||||
canManageAll: hasGlobalFullAccess,
|
||||
canCreate,
|
||||
canAccess,
|
||||
} = getIntegrationsSectionAccess(session, integrations);
|
||||
if (!canAccess) notFound();
|
||||
|
||||
const t = await getScopedI18n("integration");
|
||||
// Without integration-full-all only the integrations that were explicitly delegated to the user
|
||||
// are manageable, so the list never shows an integration they cannot open.
|
||||
const manageableIntegrations = integrations.filter(
|
||||
(integration) => hasGlobalFullAccess || integration.permissions.hasFullAccess,
|
||||
);
|
||||
|
||||
const canCreate = session.user.permissions.includes("integration-create");
|
||||
const hasGlobalFullAccess = session.user.permissions.includes("integration-full-all");
|
||||
const query = searchParams.search?.trim().toLocaleLowerCase() ?? "";
|
||||
const filteredIntegrations = manageableIntegrations
|
||||
const filteredIntegrations = integrations
|
||||
.filter((integration) => {
|
||||
if (!query) return true;
|
||||
return [integration.name, getIntegrationName(integration.kind), integration.url].some((value) =>
|
||||
@@ -65,14 +53,7 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
ariaLabel={t("page.list.ariaLabel")}
|
||||
itemCount={filteredIntegrations.length}
|
||||
emptyState={
|
||||
manageableIntegrations.length === 0 && !hasGlobalFullAccess ? (
|
||||
<NoResults
|
||||
icon={IconPlugX}
|
||||
title={t("page.list.noResults.createOnlyTitle")}
|
||||
description={t("page.list.noResults.createOnlyDescription")}
|
||||
action={{ label: t("page.list.noResults.action"), href: "/manage/integrations/new" }}
|
||||
/>
|
||||
) : hasSearch ? (
|
||||
hasSearch ? (
|
||||
<NoResults
|
||||
icon={IconPlugX}
|
||||
title={t("page.list.noResults.filteredTitle")}
|
||||
@@ -112,33 +93,24 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
floatingPrimaryAction={canCreate}
|
||||
>
|
||||
{filteredIntegrations.map((integration) => (
|
||||
<IntegrationItem key={integration.id} integration={integration} />
|
||||
<IntegrationItem
|
||||
key={integration.id}
|
||||
integration={integration}
|
||||
canManage={hasGlobalFullAccess || integration.permissions.hasFullAccess}
|
||||
/>
|
||||
))}
|
||||
</ManageCollectionPage>
|
||||
);
|
||||
|
||||
const pageContent =
|
||||
filteredIntegrations.length > 0 ? <TourTarget id="manage-integrations-list">{page}</TourTarget> : page;
|
||||
|
||||
if (hasGlobalFullAccess) {
|
||||
return pageContent;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("page.list.scopedNote")}
|
||||
</Text>
|
||||
{pageContent}
|
||||
</Stack>
|
||||
);
|
||||
return filteredIntegrations.length > 0 ? <TourTarget id="manage-integrations-list">{page}</TourTarget> : page;
|
||||
}
|
||||
|
||||
interface IntegrationItemProps {
|
||||
integration: RouterOutputs["integration"]["all"][number];
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
const IntegrationItem = async ({ integration }: IntegrationItemProps) => {
|
||||
const IntegrationItem = async ({ integration, canManage }: IntegrationItemProps) => {
|
||||
const t = await getScopedI18n("integration");
|
||||
const kindName = getIntegrationName(integration.kind);
|
||||
const safeUrl = getSafeApplicationUrl(integration.url);
|
||||
@@ -171,19 +143,21 @@ const IntegrationItem = async ({ integration }: IntegrationItemProps) => {
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/integrations/edit/${integration.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size={44}
|
||||
aria-label={t("page.list.action.edit", { name: integration.name })}
|
||||
>
|
||||
<IconPencil size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton integration={integration} />
|
||||
</ActionIconGroup>
|
||||
canManage ? (
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/integrations/edit/${integration.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size={44}
|
||||
aria-label={t("page.list.action.edit", { name: integration.name })}
|
||||
>
|
||||
<IconPencil size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton integration={integration} />
|
||||
</ActionIconGroup>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -44,7 +44,6 @@ import { MainNavigation } from "~/components/layout/navigation";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
import { ManageTourGate } from "~/components/onboarding/manage-tour-gate";
|
||||
import { env as nextEnv } from "~/env";
|
||||
import { getAppsSectionAccess, getIntegrationsSectionAccessAsync } from "./_access";
|
||||
|
||||
const logger = createLogger({ module: "manageLayout" });
|
||||
|
||||
@@ -66,8 +65,6 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
sessionPromise,
|
||||
shouldRunManageTourPromise,
|
||||
]);
|
||||
const appsAccess = getAppsSectionAccess(session);
|
||||
const integrationsAccess = await getIntegrationsSectionAccessAsync(session);
|
||||
const navigationLinks: NavigationLink[] = [
|
||||
{
|
||||
label: t("items.home"),
|
||||
@@ -85,7 +82,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
icon: IconBox,
|
||||
href: "/manage/apps",
|
||||
label: t("items.apps"),
|
||||
hidden: !appsAccess.canAccess,
|
||||
hidden: !session?.user.permissions.includes("app-create"),
|
||||
iconProps: {
|
||||
strokeWidth: 2.5,
|
||||
},
|
||||
@@ -95,7 +92,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
icon: IconAffiliateFilled,
|
||||
href: "/manage/integrations",
|
||||
label: t("items.integrations"),
|
||||
hidden: !integrationsAccess.canAccess,
|
||||
hidden: !session?.user.permissions.includes("integration-create"),
|
||||
"data-onboarding-tour-id": "manage-integrations",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Anchor, Center, Stack, Text, Title } from "@mantine/core";
|
||||
import { Center, Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
export default async function NotFound() {
|
||||
const t = await getScopedI18n("management.notFound");
|
||||
@@ -10,9 +9,6 @@ export default async function NotFound() {
|
||||
<Stack align="center">
|
||||
<Title order={1}>{t("title")}</Title>
|
||||
<Text>{t("text")}</Text>
|
||||
<Anchor component={Link} href="/manage">
|
||||
{t("backToHome")}
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
|
||||
@@ -29,12 +29,7 @@ export default async function ManagementPage() {
|
||||
<Space h="md" />
|
||||
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
|
||||
{statistics.map((statistic) => (
|
||||
<Card
|
||||
component={Link}
|
||||
href={statistic.path}
|
||||
key={statistic.path}
|
||||
aria-label={`${statistic.count} ${t(`statistic.${statistic.titleKey}`)}`}
|
||||
>
|
||||
<Card component={Link} href={statistic.path} key={statistic.path}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group wrap="nowrap">
|
||||
<Text size="xl" fw={700}>
|
||||
@@ -47,7 +42,7 @@ export default async function ManagementPage() {
|
||||
<Text fw={600}>{t(`statistic.${statistic.titleKey}`)}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<IconArrowRight size={16} stroke={1.5} color="var(--mantine-color-dimmed)" aria-hidden="true" />
|
||||
<IconArrowRight size={16} stroke={1.5} color="var(--mantine-color-dimmed)" />
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -29,7 +29,6 @@ export const AppearanceSettingsForm = ({ form }: AppearanceSettingsFormProps) =>
|
||||
}))}
|
||||
{...form.getInputProps("defaultColorScheme")}
|
||||
SelectOption={AppearanceCustomOption}
|
||||
withinPortal
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Switch, Text } from "@mantine/core";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
@@ -12,11 +12,11 @@ import type { FormValues } from "./settings-form";
|
||||
|
||||
interface BoardSettingsFormProps {
|
||||
form: UseFormReturnType<FormValues>;
|
||||
selectableBoards: RouterOutputs["board"]["getPublicBoards"];
|
||||
}
|
||||
|
||||
export const BoardSettingsForm = ({ form, selectableBoards }: BoardSettingsFormProps) => {
|
||||
export const BoardSettingsForm = ({ form }: BoardSettingsFormProps) => {
|
||||
const tBoard = useScopedI18n("management.page.settings.section.board");
|
||||
const [selectableBoards] = clientApi.board.getPublicBoards.useSuspenseQuery();
|
||||
|
||||
return (
|
||||
<SectionCard title={tBoard("title")}>
|
||||
@@ -26,7 +26,6 @@ export const BoardSettingsForm = ({ form, selectableBoards }: BoardSettingsFormP
|
||||
clearable
|
||||
boards={selectableBoards}
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
withinPortal
|
||||
/>
|
||||
|
||||
<BoardSelect
|
||||
@@ -35,7 +34,6 @@ export const BoardSettingsForm = ({ form, selectableBoards }: BoardSettingsFormP
|
||||
clearable
|
||||
boards={selectableBoards}
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
withinPortal
|
||||
/>
|
||||
|
||||
<Text fw={500}>{tBoard("status.title")}</Text>
|
||||
|
||||
@@ -22,7 +22,6 @@ export const CultureSettingsForm = ({ form }: CultureSettingsFormProps) => {
|
||||
label={tCulture("defaultLocale.label")}
|
||||
{...localeInputProps}
|
||||
value={localeInputProps.value as SupportedLanguage}
|
||||
withinPortal
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Select } from "@mantine/core";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
@@ -11,11 +11,11 @@ import type { FormValues } from "./settings-form";
|
||||
|
||||
interface SearchSettingsFormProps {
|
||||
form: UseFormReturnType<FormValues>;
|
||||
selectableSearchEngines: RouterOutputs["searchEngine"]["getSelectable"];
|
||||
}
|
||||
|
||||
export const SearchSettingsForm = ({ form, selectableSearchEngines }: SearchSettingsFormProps) => {
|
||||
export const SearchSettingsForm = ({ form }: SearchSettingsFormProps) => {
|
||||
const tSearch = useScopedI18n("management.page.settings.section.search");
|
||||
const [selectableSearchEngines] = clientApi.searchEngine.getSelectable.useSuspenseQuery({ withIntegrations: false });
|
||||
|
||||
return (
|
||||
<SectionCard title={tSearch("title")}>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { Button, Stack } from "@mantine/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
@@ -59,11 +58,9 @@ const buildInitialValues = (initialData: ServerSettings): FormValues => ({
|
||||
|
||||
interface SettingsFormProps {
|
||||
initialData: ServerSettings;
|
||||
selectableBoards: RouterOutputs["board"]["getPublicBoards"];
|
||||
selectableSearchEngines: RouterOutputs["searchEngine"]["getSelectable"];
|
||||
}
|
||||
|
||||
export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEngines }: SettingsFormProps) => {
|
||||
export const SettingsForm = ({ initialData }: SettingsFormProps) => {
|
||||
const t = useI18n();
|
||||
const tSettings = useScopedI18n("management.page.settings");
|
||||
|
||||
@@ -172,13 +169,13 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
||||
<Stack gap="xl">
|
||||
<BoardSettingsForm form={form} selectableBoards={selectableBoards} />
|
||||
<UserSettingsForm form={form} />
|
||||
<SearchSettingsForm form={form} selectableSearchEngines={selectableSearchEngines} />
|
||||
<AppearanceSettingsForm form={form} />
|
||||
<CultureSettingsForm form={form} />
|
||||
<AnalyticsSettings form={form} />
|
||||
<CrawlingAndIndexingSettings form={form} />
|
||||
<BoardSettingsForm form={form} />
|
||||
<UserSettingsForm form={form} />
|
||||
<SearchSettingsForm form={form} />
|
||||
<AppearanceSettingsForm form={form} />
|
||||
<CultureSettingsForm form={form} />
|
||||
|
||||
{form.isDirty() && (
|
||||
<UnsavedChangesBar>
|
||||
|
||||
@@ -23,20 +23,12 @@ export default async function SettingsPage() {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const [serverSettings, selectableBoards, selectableSearchEngines, tSettings] = await Promise.all([
|
||||
api.serverSettings.getAll(),
|
||||
api.board.getPublicBoards(),
|
||||
api.searchEngine.getSelectable({ withIntegrations: false }),
|
||||
getScopedI18n("management.page.settings"),
|
||||
]);
|
||||
const serverSettings = await api.serverSettings.getAll();
|
||||
const tSettings = await getScopedI18n("management.page.settings");
|
||||
|
||||
return (
|
||||
<ManagePageLayout title={tSettings("title")}>
|
||||
<SettingsForm
|
||||
initialData={serverSettings}
|
||||
selectableBoards={selectableBoards}
|
||||
selectableSearchEngines={selectableSearchEngines}
|
||||
/>
|
||||
<SettingsForm initialData={serverSettings} />
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,7 @@
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
interface ClientSideTerminalComponentProps {
|
||||
focusTimestamp?: number;
|
||||
}
|
||||
|
||||
export const ClientSideTerminalComponent = dynamic<ClientSideTerminalComponentProps>(
|
||||
export const ClientSideTerminalComponent = dynamic(
|
||||
() => import("./terminal").then(({ TerminalComponent }) => TerminalComponent),
|
||||
{
|
||||
ssr: false,
|
||||
|
||||
@@ -29,25 +29,12 @@ export async function generateMetadata() {
|
||||
};
|
||||
}
|
||||
|
||||
interface LogsManagementPageProps {
|
||||
searchParams: Promise<{ focus?: string | string[] }>;
|
||||
}
|
||||
|
||||
export default async function LogsManagementPage({ searchParams }: LogsManagementPageProps) {
|
||||
export default async function LogsManagementPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("other-view-logs")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const focus = (await searchParams).focus;
|
||||
let focusTimestamp: number | undefined;
|
||||
if (typeof focus === "string") {
|
||||
const parsedFocusTimestamp = Number(focus);
|
||||
if (Number.isSafeInteger(parsedFocusTimestamp) && parsedFocusTimestamp > 0) {
|
||||
focusTimestamp = parsedFocusTimestamp;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<LogContextProvider defaultLevel={logsEnv.LEVEL}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
@@ -58,7 +45,7 @@ export default async function LogsManagementPage({ searchParams }: LogsManagemen
|
||||
</Group>
|
||||
</Group>
|
||||
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
|
||||
<ClientSideTerminalComponent focusTimestamp={focusTimestamp} />
|
||||
<ClientSideTerminalComponent />
|
||||
</Box>
|
||||
</LogContextProvider>
|
||||
);
|
||||
|
||||
@@ -7,163 +7,25 @@ import { FitAddon } from "@xterm/addon-fit";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { LoggerMessage, LogLevel } from "@homarr/core/infrastructure/logs/constants";
|
||||
import { LOG_HISTORY_MAX_ENTRIES, logLevels } from "@homarr/core/infrastructure/logs/constants";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useLogContext } from "./log-context";
|
||||
import classes from "./terminal.module.css";
|
||||
|
||||
const ALL_LOG_LEVELS = [...logLevels];
|
||||
const TERMINAL_SCROLLBACK_LINES = LOG_HISTORY_MAX_ENTRIES * 20;
|
||||
|
||||
const getFocusMarker = (
|
||||
messages: LoggerMessage[],
|
||||
visibleMessages: LoggerMessage[],
|
||||
focusTimestamp: number | undefined,
|
||||
markerText: string,
|
||||
expiredMarkerText: string,
|
||||
) => {
|
||||
if (focusTimestamp === undefined) return null;
|
||||
|
||||
const oldestTimestamp = messages[0]?.timestamp.getTime();
|
||||
if (oldestTimestamp === undefined || focusTimestamp < oldestTimestamp) {
|
||||
return { index: 0, text: expiredMarkerText };
|
||||
}
|
||||
|
||||
return {
|
||||
index: visibleMessages.findLastIndex((message) => message.timestamp.getTime() <= focusTimestamp) + 1,
|
||||
text: markerText,
|
||||
};
|
||||
};
|
||||
|
||||
const findMarkerLine = (terminal: Terminal, linesFromEnd: number) => {
|
||||
const buffer = terminal.buffer.active;
|
||||
const logicalLineStarts: number[] = [];
|
||||
const cursorLine = buffer.baseY + buffer.cursorY;
|
||||
|
||||
for (let index = 0; index <= cursorLine; index++) {
|
||||
const line = buffer.getLine(index);
|
||||
if (!line) continue;
|
||||
|
||||
if (!line.isWrapped) {
|
||||
logicalLineStarts.push(index);
|
||||
}
|
||||
}
|
||||
|
||||
return logicalLineStarts.at(-linesFromEnd) ?? null;
|
||||
};
|
||||
|
||||
const renderMessages = ({
|
||||
terminal,
|
||||
messages,
|
||||
activeLevels,
|
||||
focusTimestamp,
|
||||
markerText,
|
||||
expiredMarkerText,
|
||||
}: {
|
||||
terminal: Terminal;
|
||||
messages: LoggerMessage[];
|
||||
activeLevels: LogLevel[];
|
||||
focusTimestamp: number | undefined;
|
||||
markerText: string;
|
||||
expiredMarkerText: string;
|
||||
}) => {
|
||||
const activeLevelSet = new Set(activeLevels);
|
||||
const visibleMessages = messages.filter((message) => activeLevelSet.has(message.level));
|
||||
const focusMarker = getFocusMarker(messages, visibleMessages, focusTimestamp, markerText, expiredMarkerText);
|
||||
const output: string[] = [];
|
||||
let markerOutputIndex: number | null = null;
|
||||
|
||||
visibleMessages.forEach((message, index) => {
|
||||
if (focusMarker?.index === index) {
|
||||
markerOutputIndex = output.length;
|
||||
output.push(`\x1b[33m--- ${focusMarker.text} ---\x1b[0m`);
|
||||
}
|
||||
output.push(message.message);
|
||||
});
|
||||
|
||||
if (focusMarker?.index === visibleMessages.length) {
|
||||
markerOutputIndex = output.length;
|
||||
output.push(`\x1b[33m--- ${focusMarker.text} ---\x1b[0m`);
|
||||
}
|
||||
|
||||
let markerLinesFromEnd: number | null = null;
|
||||
if (markerOutputIndex !== null) {
|
||||
markerLinesFromEnd =
|
||||
1 + output.slice(markerOutputIndex).reduce((lineCount, line) => lineCount + line.split(/\r?\n/).length, 0);
|
||||
}
|
||||
|
||||
terminal.write(`\x1bc${output.join("\r\n")}${output.length > 0 ? "\r\n" : ""}`, () => {
|
||||
if (markerLinesFromEnd === null) {
|
||||
terminal.scrollToBottom();
|
||||
return;
|
||||
}
|
||||
|
||||
const markerLine = findMarkerLine(terminal, markerLinesFromEnd);
|
||||
if (markerLine !== null) {
|
||||
terminal.scrollToLine(markerLine);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
interface TerminalComponentProps {
|
||||
focusTimestamp?: number;
|
||||
}
|
||||
|
||||
export const TerminalComponent = ({ focusTimestamp }: TerminalComponentProps) => {
|
||||
export const TerminalComponent = () => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { activeLevels, fontSize } = useLogContext();
|
||||
const t = useI18n();
|
||||
|
||||
const terminalRef = useRef<Terminal>(null);
|
||||
const fitAddonRef = useRef<FitAddon>(null);
|
||||
const messagesRef = useRef<LoggerMessage[]>([]);
|
||||
const initialFontSizeRef = useRef(fontSize);
|
||||
const activeLevelsRef = useRef(activeLevels);
|
||||
const markerTextRef = useRef("");
|
||||
const expiredMarkerTextRef = useRef("");
|
||||
const focusTimestampRef = useRef(focusTimestamp);
|
||||
|
||||
activeLevelsRef.current = activeLevels;
|
||||
markerTextRef.current = t("log.context.widgetError");
|
||||
expiredMarkerTextRef.current = t("log.context.expiredWidgetError");
|
||||
focusTimestampRef.current = focusTimestamp;
|
||||
|
||||
const redrawTerminal = () => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal) return;
|
||||
|
||||
renderMessages({
|
||||
terminal,
|
||||
messages: messagesRef.current,
|
||||
activeLevels: activeLevelsRef.current,
|
||||
focusTimestamp: focusTimestampRef.current,
|
||||
markerText: markerTextRef.current,
|
||||
expiredMarkerText: expiredMarkerTextRef.current,
|
||||
});
|
||||
};
|
||||
|
||||
clientApi.log.subscribe.useSubscription(
|
||||
{
|
||||
levels: ALL_LOG_LEVELS,
|
||||
levels: activeLevels,
|
||||
},
|
||||
{
|
||||
onData(event) {
|
||||
if (event.type === "history") {
|
||||
messagesRef.current = event.messages.slice(-LOG_HISTORY_MAX_ENTRIES);
|
||||
redrawTerminal();
|
||||
return;
|
||||
}
|
||||
|
||||
messagesRef.current.push(event.message);
|
||||
if (messagesRef.current.length > LOG_HISTORY_MAX_ENTRIES) {
|
||||
messagesRef.current.shift();
|
||||
}
|
||||
|
||||
if (activeLevelsRef.current.includes(event.message.level)) {
|
||||
terminalRef.current?.writeln(event.message.message);
|
||||
}
|
||||
onData(data) {
|
||||
terminalRef.current?.writeln(data.message);
|
||||
terminalRef.current?.refresh(0, terminalRef.current.rows - 1);
|
||||
},
|
||||
onError(err) {
|
||||
alert(err);
|
||||
@@ -182,26 +44,22 @@ export const TerminalComponent = ({ focusTimestamp }: TerminalComponentProps) =>
|
||||
cursorBlink: false,
|
||||
disableStdin: true,
|
||||
convertEol: true,
|
||||
fontSize: initialFontSizeRef.current,
|
||||
scrollback: TERMINAL_SCROLLBACK_LINES,
|
||||
fontSize,
|
||||
});
|
||||
terminalRef.current.open(ref.current);
|
||||
terminalRef.current.loadAddon(canvasAddon);
|
||||
|
||||
const fitTimeout = window.setTimeout(() => {
|
||||
setTimeout(() => {
|
||||
const fitAddon = new FitAddon();
|
||||
fitAddonRef.current = fitAddon;
|
||||
terminalRef.current?.loadAddon(fitAddon);
|
||||
fitAddon.fit();
|
||||
redrawTerminal();
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(fitTimeout);
|
||||
canvasAddon.dispose();
|
||||
terminalRef.current?.dispose();
|
||||
terminalRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -211,9 +69,5 @@ export const TerminalComponent = ({ focusTimestamp }: TerminalComponentProps) =>
|
||||
fitAddonRef.current?.fit();
|
||||
}, [fontSize]);
|
||||
|
||||
useEffect(() => {
|
||||
redrawTerminal();
|
||||
}, [activeLevels, focusTimestamp]);
|
||||
|
||||
return <Box ref={ref} id="terminal" className={classes.outerTerminal} h="100%"></Box>;
|
||||
};
|
||||
+25
-357
@@ -1,43 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useCallback, useId } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Collapse,
|
||||
Divider,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
Transition,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconShieldExclamation } from "@tabler/icons-react";
|
||||
import { useCallback } from "react";
|
||||
import { Button, Card, Group, Switch, Text, Transition } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { objectEntries, objectKeys } from "@homarr/common";
|
||||
import type { GroupPermissionKey, PermissionMatrixCategory, PermissionMatrixState } from "@homarr/definitions";
|
||||
import {
|
||||
getPermissionsWithChildren,
|
||||
groupPermissionKeys,
|
||||
isCapabilityImpliedByLevel,
|
||||
matrixStateToPermissions,
|
||||
permissionMatrix,
|
||||
permissionsToMatrixState,
|
||||
} from "@homarr/definitions";
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
import { groupPermissionKeys } from "@homarr/definitions";
|
||||
import { createFormContext } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
export type PermissionLabels = Partial<Record<GroupPermissionKey, string>>;
|
||||
|
||||
const [FormProvider, useFormContext, useForm] = createFormContext<FormType>();
|
||||
|
||||
interface PermissionFormProps {
|
||||
@@ -45,17 +19,27 @@ interface PermissionFormProps {
|
||||
}
|
||||
|
||||
export const PermissionForm = ({ children, initialPermissions }: PropsWithChildren<PermissionFormProps>) => {
|
||||
// Mantine compares each field against the values snapshot, so toggling a control back to its
|
||||
// saved value clears the dirty state on its own - no manual bookkeeping needed here.
|
||||
const form = useForm({
|
||||
initialValues: groupPermissionKeys.reduce((acc, key) => {
|
||||
acc[key] = initialPermissions.includes(key);
|
||||
return acc;
|
||||
}, {} as FormType),
|
||||
onValuesChange(values) {
|
||||
const currentKeys = objectEntries(values)
|
||||
.filter(([_key, value]) => Boolean(value))
|
||||
.map(([key]) => key);
|
||||
|
||||
if (
|
||||
currentKeys.every((key) => initialPermissions.includes(key)) &&
|
||||
initialPermissions.every((key) => currentKeys.includes(key))
|
||||
) {
|
||||
form.resetDirty(); // Reset dirty state if all keys are the same as initial
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={(event) => event.preventDefault()}>
|
||||
<form>
|
||||
<FormProvider form={form}>{children}</FormProvider>
|
||||
</form>
|
||||
);
|
||||
@@ -63,329 +47,15 @@ export const PermissionForm = ({ children, initialPermissions }: PropsWithChildr
|
||||
|
||||
type FormType = Record<GroupPermissionKey, boolean>;
|
||||
|
||||
type MatrixForm = ReturnType<typeof useFormContext>;
|
||||
export const PermissionSwitch = ({ name }: { name: GroupPermissionKey }) => {
|
||||
const form = useFormContext();
|
||||
|
||||
const emptyMatrixState = (): PermissionMatrixState =>
|
||||
objectKeys(permissionMatrix).reduce((acc, category) => {
|
||||
acc[category] = { level: 0, capabilities: [] };
|
||||
return acc;
|
||||
}, {} as PermissionMatrixState);
|
||||
|
||||
const matrixStateOf = (values: FormType) => permissionsToMatrixState(groupPermissionKeys.filter((key) => values[key]));
|
||||
|
||||
export const getMatrixLevel = (values: FormType, category: PermissionMatrixCategory): number =>
|
||||
matrixStateOf(values)[category].level;
|
||||
|
||||
export const hasMatrixCapability = (
|
||||
values: FormType,
|
||||
category: PermissionMatrixCategory,
|
||||
capability: GroupPermissionKey,
|
||||
): boolean => matrixStateOf(values)[category].capabilities.includes(capability);
|
||||
|
||||
export const applyMatrixLevel = (form: MatrixForm, category: PermissionMatrixCategory, level: number) => {
|
||||
const targetKeys = new Set(
|
||||
matrixStateToPermissions({ ...emptyMatrixState(), [category]: { level, capabilities: [] } }),
|
||||
);
|
||||
permissionMatrix[category].levels.forEach((key) => {
|
||||
form.setFieldValue(key, targetKeys.has(key));
|
||||
const props = form.getInputProps(name, {
|
||||
withError: false,
|
||||
type: "checkbox",
|
||||
});
|
||||
// Capability keys are deliberately left untouched: a level that implies one already covers it,
|
||||
// and an explicitly granted capability must survive lowering the level again.
|
||||
};
|
||||
|
||||
export const applyMatrixCapability = (form: MatrixForm, capability: GroupPermissionKey, granted: boolean) => {
|
||||
form.setFieldValue(capability, granted);
|
||||
};
|
||||
|
||||
const presets = {
|
||||
viewer: {
|
||||
board: { level: 1, capabilities: [] },
|
||||
app: { level: 0, capabilities: ["app-use-all"] },
|
||||
integration: { level: 1, capabilities: [] },
|
||||
media: { level: 1, capabilities: [] },
|
||||
},
|
||||
editor: {
|
||||
board: { level: 2, capabilities: ["board-create"] },
|
||||
app: { level: 1, capabilities: ["app-use-all"] },
|
||||
integration: { level: 2, capabilities: ["integration-create"] },
|
||||
"search-engine": { level: 1, capabilities: [] },
|
||||
media: { level: 1, capabilities: ["media-upload"] },
|
||||
},
|
||||
admin: { admin: { level: 1, capabilities: [] } },
|
||||
} satisfies Record<string, Partial<PermissionMatrixState>>;
|
||||
|
||||
export type PermissionPreset = keyof typeof presets;
|
||||
|
||||
const presetToFormState = (preset: PermissionPreset): FormType => {
|
||||
const presetState: Partial<PermissionMatrixState> = presets[preset];
|
||||
const state = objectKeys(permissionMatrix).reduce((acc, category) => {
|
||||
acc[category] = presetState[category] ?? { level: 0, capabilities: [] };
|
||||
return acc;
|
||||
}, {} as PermissionMatrixState);
|
||||
const enabledKeys = new Set(matrixStateToPermissions(state));
|
||||
|
||||
return groupPermissionKeys.reduce((acc, key) => {
|
||||
acc[key] = enabledKeys.has(key);
|
||||
return acc;
|
||||
}, {} as FormType);
|
||||
};
|
||||
|
||||
export const applyPreset = (form: MatrixForm, preset: PermissionPreset) => {
|
||||
form.setValues(presetToFormState(preset));
|
||||
};
|
||||
|
||||
export const getActivePreset = (values: FormType): PermissionPreset | null => {
|
||||
return (
|
||||
objectKeys(presets).find((preset) => {
|
||||
const presetValues = presetToFormState(preset);
|
||||
return groupPermissionKeys.every((key) => presetValues[key] === values[key]);
|
||||
}) ?? null
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Short, scannable label for a level. Categories with a single level use their permission label
|
||||
* instead, so the control reads "No access | View logs" rather than "No access | Enabled".
|
||||
*/
|
||||
const levelLabelKey = (key: GroupPermissionKey) => {
|
||||
if (key.endsWith("-view-all")) return "view" as const;
|
||||
if (key.endsWith("-use-all")) return "use" as const;
|
||||
if (key.endsWith("-interact-all")) return "interact" as const;
|
||||
if (key.endsWith("-modify-all")) return "modify" as const;
|
||||
if (key.endsWith("-full-all")) return "full" as const;
|
||||
return null;
|
||||
};
|
||||
|
||||
interface MatrixImpliedHintProps {
|
||||
category: PermissionMatrixCategory;
|
||||
permissionLabels: PermissionLabels;
|
||||
}
|
||||
|
||||
export const MatrixImpliedHint = ({ category, permissionLabels }: MatrixImpliedHintProps) => {
|
||||
const form = useFormContext();
|
||||
const tPermissions = useScopedI18n("group.permission");
|
||||
|
||||
const level = getMatrixLevel(form.getValues(), category);
|
||||
if (level === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const checkedKeys: GroupPermissionKey[] = permissionMatrix[category].levels.slice(0, level);
|
||||
const implied = getPermissionsWithChildren(checkedKeys).filter((key) => !checkedKeys.includes(key));
|
||||
if (implied.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const labels = implied.map((key) => permissionLabels[key] ?? key);
|
||||
|
||||
return (
|
||||
<Text size="xs" c="dimmed">
|
||||
{tPermissions("matrix.impliedHint", { permissions: labels.join(", ") })}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
interface MatrixRowProps {
|
||||
category: PermissionMatrixCategory;
|
||||
permissionLabels: PermissionLabels;
|
||||
permissionDescriptions: PermissionLabels;
|
||||
}
|
||||
|
||||
export const MatrixRow = ({ category, permissionLabels, permissionDescriptions }: MatrixRowProps) => {
|
||||
const form = useFormContext();
|
||||
const tPermissions = useScopedI18n("group.permission");
|
||||
|
||||
const values = form.getValues();
|
||||
const level = getMatrixLevel(values, category);
|
||||
const isDanger = category === "admin";
|
||||
const disabledByAdmin = !isDanger && getMatrixLevel(values, "admin") > 0;
|
||||
const { levels, capabilities } = permissionMatrix[category];
|
||||
const hasSingleLevel = levels.length === 1;
|
||||
|
||||
const data = [
|
||||
{ value: "0", label: tPermissions("matrix.none") },
|
||||
...levels.map((key, index) => {
|
||||
const shortKey = hasSingleLevel ? null : levelLabelKey(key);
|
||||
return {
|
||||
value: String(index + 1),
|
||||
label: shortKey === null ? (permissionLabels[key] ?? key) : tPermissions(`matrix.level.${shortKey}`),
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
const capabilityStates = capabilities.map((capability) => ({
|
||||
capability,
|
||||
checked: hasMatrixCapability(values, category, capability),
|
||||
implied: isCapabilityImpliedByLevel(category, capability, level),
|
||||
}));
|
||||
|
||||
const selectedKey = level === 0 ? undefined : levels[level - 1];
|
||||
// With no access level but a capability granted, describing the row as "no access" would
|
||||
// contradict the checkbox next to it, so the granted capability describes the row instead.
|
||||
const describedKey = selectedKey ?? capabilityStates.find(({ checked }) => checked)?.capability;
|
||||
const description =
|
||||
describedKey === undefined ? tPermissions("matrix.noneDescription") : (permissionDescriptions[describedKey] ?? "");
|
||||
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
<Group gap="sm" wrap="wrap" align="center" justify="space-between">
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{isDanger && <IconShieldExclamation size={18} color="var(--mantine-color-red-8)" />}
|
||||
<Text fw={500} c={isDanger ? "red.8" : undefined}>
|
||||
{tPermissions(`${category}.title`)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="md" wrap="wrap" align="center">
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
data={data}
|
||||
value={String(level)}
|
||||
disabled={disabledByAdmin}
|
||||
aria-label={tPermissions(`${category}.title`)}
|
||||
onChange={(value) => applyMatrixLevel(form, category, Number(value))}
|
||||
/>
|
||||
{/* Fixed-width column so every level control lines up, whatever capabilities a row has. */}
|
||||
<Box w={{ base: "auto", sm: 230 }}>
|
||||
<Stack gap={4}>
|
||||
{capabilityStates.map(({ capability, checked, implied }) => (
|
||||
<Tooltip
|
||||
key={capability}
|
||||
label={tPermissions("matrix.capabilityIncluded", { level: data[level]?.label ?? "" })}
|
||||
disabled={!implied}
|
||||
>
|
||||
<Checkbox
|
||||
size="sm"
|
||||
label={permissionLabels[capability] ?? capability}
|
||||
checked={checked}
|
||||
disabled={disabledByAdmin || implied}
|
||||
onChange={(event) => applyMatrixCapability(form, capability, event.currentTarget.checked)}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Group>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{description}
|
||||
</Text>
|
||||
{disabledByAdmin && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{tPermissions("matrix.grantedViaAdmin")}
|
||||
</Text>
|
||||
)}
|
||||
<MatrixImpliedHint category={category} permissionLabels={permissionLabels} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface PermissionMatrixProps {
|
||||
permissionLabels: PermissionLabels;
|
||||
permissionDescriptions: PermissionLabels;
|
||||
}
|
||||
|
||||
export const PermissionMatrix = ({ permissionLabels, permissionDescriptions }: PermissionMatrixProps) => (
|
||||
<Card withBorder p="md">
|
||||
<Stack gap="md">
|
||||
{objectKeys(permissionMatrix).map((category, index) => (
|
||||
<Stack key={category} gap="md">
|
||||
{index > 0 && <Divider />}
|
||||
<MatrixRow
|
||||
category={category}
|
||||
permissionLabels={permissionLabels}
|
||||
permissionDescriptions={permissionDescriptions}
|
||||
/>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export const PresetButtons = () => {
|
||||
const form = useFormContext();
|
||||
const tPermissions = useScopedI18n("group.permission");
|
||||
const activePreset = getActivePreset(form.getValues());
|
||||
|
||||
return (
|
||||
<Group gap="xs" wrap="wrap">
|
||||
<Text size="sm" fw={500}>
|
||||
{tPermissions("matrix.preset.label")}
|
||||
</Text>
|
||||
{(["viewer", "editor", "admin"] as const).map((preset) => (
|
||||
<Tooltip key={preset} label={tPermissions(`matrix.preset.${preset}Description`)}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant={activePreset === preset ? "filled" : "default"}
|
||||
onClick={() => applyPreset(form, preset)}
|
||||
>
|
||||
{tPermissions(`matrix.preset.${preset}`)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
))}
|
||||
{activePreset === null && <Badge variant="light">{tPermissions("matrix.preset.custom")}</Badge>}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
interface EffectivePermissionPreviewProps {
|
||||
permissionLabels: PermissionLabels;
|
||||
}
|
||||
|
||||
export const EffectivePermissionPreview = ({ permissionLabels }: EffectivePermissionPreviewProps) => {
|
||||
const form = useFormContext();
|
||||
const tPermissions = useScopedI18n("group.permission");
|
||||
const [opened, { toggle }] = useDisclosure(false);
|
||||
const panelId = useId();
|
||||
|
||||
const values = form.getValues();
|
||||
const checkedKeys = groupPermissionKeys.filter((key) => values[key]);
|
||||
const effective = getPermissionsWithChildren(checkedKeys);
|
||||
|
||||
const grouped = objectKeys(permissionMatrix)
|
||||
.map((category) => ({
|
||||
category,
|
||||
keys: effective.filter((key) => key === category || key.startsWith(`${category}-`)),
|
||||
}))
|
||||
.filter(({ keys }) => keys.length > 0);
|
||||
|
||||
return (
|
||||
<Card withBorder p="md">
|
||||
<UnstyledButton onClick={toggle} w="100%" aria-expanded={opened} aria-controls={panelId}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Stack gap={0}>
|
||||
<Text fw={500}>{tPermissions("matrix.effective.title")}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{tPermissions("matrix.effective.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Badge variant="light">{effective.length}</Badge>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
<Collapse id={panelId} expanded={opened}>
|
||||
<Stack gap="xs" mt="sm">
|
||||
{grouped.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{tPermissions("matrix.effective.empty")}
|
||||
</Text>
|
||||
) : (
|
||||
grouped.map(({ category, keys }) => (
|
||||
<Group key={category} gap="xs" wrap="wrap" align="flex-start">
|
||||
<Text size="sm" fw={500}>
|
||||
{tPermissions(`${category}.title`)}
|
||||
</Text>
|
||||
<Badge size="sm" variant="light">
|
||||
{keys.length}
|
||||
</Badge>
|
||||
<Text size="sm" c="dimmed">
|
||||
{keys.map((key) => permissionLabels[key] ?? key).join(", ")}
|
||||
</Text>
|
||||
</Group>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Card>
|
||||
);
|
||||
return <Switch {...props} />;
|
||||
};
|
||||
|
||||
interface SaveAffixProps {
|
||||
@@ -410,10 +80,8 @@ export const SaveAffix = ({ groupId }: SaveAffixProps) => {
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
// setInitialValues moves the snapshot so Discard returns here, but only resetDirty clears
|
||||
// the dirty flags that keep the unsaved-changes bar on screen.
|
||||
// Set new initial values for discard and reset dirty state
|
||||
form.setInitialValues(values);
|
||||
form.resetDirty(values);
|
||||
showSuccessNotification({
|
||||
title: tNotification("success.title"),
|
||||
message: tNotification("success.message"),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Stack, Title } from "@mantine/core";
|
||||
import { Card, CardSection, Divider, Group, Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
@@ -8,14 +9,7 @@ import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
import { groupPermissions } from "@homarr/definitions";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import type { PermissionLabels } from "./_group-permission-form";
|
||||
import {
|
||||
EffectivePermissionPreview,
|
||||
PermissionForm,
|
||||
PermissionMatrix,
|
||||
PresetButtons,
|
||||
SaveAffix,
|
||||
} from "./_group-permission-form";
|
||||
import { PermissionForm, PermissionSwitch, SaveAffix } from "./_group-permission-form";
|
||||
|
||||
interface GroupPermissionsPageProps {
|
||||
params: Promise<{
|
||||
@@ -32,21 +26,27 @@ export default async function GroupPermissionsPage(props: GroupPermissionsPagePr
|
||||
}
|
||||
|
||||
const group = await api.group.getById({ id: params.id });
|
||||
const tPermissions = await getScopedI18n("group.permission");
|
||||
const t = await getI18n();
|
||||
|
||||
const { permissionLabels, permissionDescriptions } = await buildPermissionTextsAsync();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Title>{t("management.page.group.setting.permissions.title")}</Title>
|
||||
|
||||
<PermissionForm initialPermissions={group.permissions}>
|
||||
<Stack pos="relative">
|
||||
<PresetButtons />
|
||||
{objectKeys(groupPermissions).map((group) => {
|
||||
const isDanger = group === "admin";
|
||||
|
||||
<PermissionMatrix permissionLabels={permissionLabels} permissionDescriptions={permissionDescriptions} />
|
||||
|
||||
<EffectivePermissionPreview permissionLabels={permissionLabels} />
|
||||
return (
|
||||
<Stack key={group} gap="sm">
|
||||
<Title order={2} c={isDanger ? "red.8" : undefined}>
|
||||
{tPermissions(`${group}.title`)}
|
||||
</Title>
|
||||
<PermissionCard isDanger={isDanger} group={group} />
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
|
||||
<SaveAffix groupId={group.id} />
|
||||
</Stack>
|
||||
@@ -55,21 +55,66 @@ export default async function GroupPermissionsPage(props: GroupPermissionsPagePr
|
||||
);
|
||||
}
|
||||
|
||||
const buildPermissionTextsAsync = async () => {
|
||||
const permissionLabels: PermissionLabels = {};
|
||||
const permissionDescriptions: PermissionLabels = {};
|
||||
interface PermissionCardProps {
|
||||
group: keyof typeof groupPermissions;
|
||||
isDanger: boolean;
|
||||
}
|
||||
|
||||
for (const category of objectKeys(groupPermissions)) {
|
||||
const tItem = await getScopedI18n(`group.permission.${category}.item`);
|
||||
const item = groupPermissions[category];
|
||||
const suffixes = typeof item !== "boolean" ? item : (["admin"] as const);
|
||||
const PermissionCard = async ({ group, isDanger }: PermissionCardProps) => {
|
||||
const t = await getScopedI18n(`group.permission.${group}.item`);
|
||||
const item = groupPermissions[group];
|
||||
const permissions = typeof item !== "boolean" ? item : ([group] as "admin"[]);
|
||||
|
||||
suffixes.forEach((suffix) => {
|
||||
const key = (typeof item !== "boolean" ? `${category}-${suffix}` : "admin") as GroupPermissionKey;
|
||||
permissionLabels[key] = tItem(`${suffix}.label`);
|
||||
permissionDescriptions[key] = tItem(`${suffix}.description`);
|
||||
});
|
||||
return (
|
||||
<Card
|
||||
p="md"
|
||||
style={{
|
||||
borderColor: isDanger ? "var(--mantine-color-red-8)" : undefined,
|
||||
}}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
{permissions.map((permission, index) => (
|
||||
<React.Fragment key={permission}>
|
||||
<PermissionRow
|
||||
name={createGroupPermissionKey(group, permission)}
|
||||
label={t(`${permission}.label`)}
|
||||
description={t(`${permission}.description`)}
|
||||
/>
|
||||
|
||||
{index < permissions.length - 1 && (
|
||||
<CardSection>
|
||||
<Divider />
|
||||
</CardSection>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const createGroupPermissionKey = (group: keyof typeof groupPermissions, permission: string): GroupPermissionKey => {
|
||||
if (typeof groupPermissions[group] === "boolean") {
|
||||
return group as GroupPermissionKey;
|
||||
}
|
||||
|
||||
return { permissionLabels, permissionDescriptions };
|
||||
return `${group}-${permission}` as GroupPermissionKey;
|
||||
};
|
||||
|
||||
interface PermissionRowProps {
|
||||
name: GroupPermissionKey;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const PermissionRow = ({ name, label, description }: PermissionRowProps) => {
|
||||
return (
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Stack gap={0}>
|
||||
<Text fw={500}>{label}</Text>
|
||||
<Text c="gray.5">{description}</Text>
|
||||
</Stack>
|
||||
<PermissionSwitch name={name} />
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -104,46 +104,6 @@ describe("assistant automatic approval tracker", () => {
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
test("does not approve a ready action while switching conversations", async () => {
|
||||
const container = document.createElement("div");
|
||||
containers.push(container);
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
let confirmationCount = 0;
|
||||
const renderConversation = async (conversationId: string, ready: boolean) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
createElement(
|
||||
AssistantAutoApprovalProvider,
|
||||
{ conversationId },
|
||||
createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement(AutoApprovalProbe),
|
||||
createElement(AutomaticActionProbe, {
|
||||
ready,
|
||||
completed: false,
|
||||
confirm: () => {
|
||||
confirmationCount += 1;
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
await renderConversation("local-thread-1", false);
|
||||
act(() => currentAutoApproval?.setEnabled(true));
|
||||
await renderConversation("local-thread-2", true);
|
||||
|
||||
expect(confirmationCount).toBe(0);
|
||||
expect(currentAutoApproval?.enabled).toBe(false);
|
||||
expect(automaticActionInProgress).toBe(false);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
test("clears claimed calls when automatic approvals are turned off", async () => {
|
||||
const container = document.createElement("div");
|
||||
containers.push(container);
|
||||
|
||||
@@ -52,8 +52,6 @@ export const AssistantAutoApprovalProvider = ({
|
||||
const trackerRef = useRef(createAssistantAutoApprovalTracker(AUTO_APPROVAL_MAX_ATTEMPTS));
|
||||
const retryTimersRef = useRef(new Map<string, number>());
|
||||
const previousConversationIdRef = useRef(conversationId);
|
||||
const conversationMatches = previousConversationIdRef.current === conversationId;
|
||||
const effectiveEnabled = conversationMatches && enabled;
|
||||
|
||||
const clearRetryTimer = useCallback((toolCallId: string) => {
|
||||
const timer = retryTimersRef.current.get(toolCallId);
|
||||
@@ -88,7 +86,7 @@ export const AssistantAutoApprovalProvider = ({
|
||||
|
||||
const requestAction = useCallback(
|
||||
(toolCallId: string, confirm: () => void) => {
|
||||
if (!effectiveEnabled || !trackerRef.current.claim(toolCallId)) return false;
|
||||
if (!enabled || !trackerRef.current.claim(toolCallId)) return false;
|
||||
|
||||
try {
|
||||
confirm();
|
||||
@@ -107,7 +105,7 @@ export const AssistantAutoApprovalProvider = ({
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[clearRetryTimer, effectiveEnabled],
|
||||
[clearRetryTimer, enabled],
|
||||
);
|
||||
|
||||
const completeAction = useCallback(
|
||||
@@ -118,15 +116,10 @@ export const AssistantAutoApprovalProvider = ({
|
||||
[clearRetryTimer],
|
||||
);
|
||||
|
||||
const value = useMemo(() => {
|
||||
return {
|
||||
enabled: effectiveEnabled,
|
||||
setEnabled,
|
||||
requestAction,
|
||||
completeAction,
|
||||
retryRevision,
|
||||
};
|
||||
}, [completeAction, effectiveEnabled, requestAction, retryRevision, setEnabled]);
|
||||
const value = useMemo(
|
||||
() => ({ enabled, setEnabled, requestAction, completeAction, retryRevision }),
|
||||
[completeAction, enabled, requestAction, retryRevision, setEnabled],
|
||||
);
|
||||
|
||||
return <AssistantAutoApprovalContext.Provider value={value}>{children}</AssistantAutoApprovalContext.Provider>;
|
||||
};
|
||||
|
||||
@@ -20,15 +20,11 @@ export interface AssistantContextValue {
|
||||
isRunning: boolean;
|
||||
isRefreshing: boolean;
|
||||
unreadCount: number;
|
||||
hasVisibleWidget: boolean;
|
||||
activeWidgetId: string | null;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
toggle: () => void;
|
||||
sendPrompt: (prompt: string) => boolean;
|
||||
refreshCurrentView: () => Promise<void>;
|
||||
setWidgetVisible: (widgetId: string, visible: boolean) => void;
|
||||
activateWidget: (widgetId: string) => void;
|
||||
}
|
||||
|
||||
export const AssistantContext = createContext<AssistantContextValue | null>(null);
|
||||
|
||||
@@ -34,15 +34,11 @@ const DisabledAssistant = ({ children, description }: PropsWithChildren<{ descri
|
||||
isRunning: false,
|
||||
isRefreshing: false,
|
||||
unreadCount: 0,
|
||||
hasVisibleWidget: false,
|
||||
activeWidgetId: null,
|
||||
open: () => undefined,
|
||||
close: () => undefined,
|
||||
toggle: () => undefined,
|
||||
sendPrompt: () => false,
|
||||
refreshCurrentView: () => Promise.resolve(),
|
||||
setWidgetVisible: () => undefined,
|
||||
activateWidget: () => undefined,
|
||||
}),
|
||||
[description],
|
||||
);
|
||||
|
||||
@@ -29,7 +29,7 @@ import { AppForm } from "@homarr/forms-collection";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { appManageSchema } from "@homarr/validation/app";
|
||||
|
||||
import { getAssistantAskUserOptionKind } from "./assistant-ask-user";
|
||||
import { getAssistantAffirmativeResult, getAssistantAskUserOptionKind } from "./assistant-ask-user";
|
||||
import { useAssistantAutomaticAction } from "./assistant-auto-approval";
|
||||
import { AssistantAutomaticActionProgress } from "./assistant-automatic-action-progress";
|
||||
import classes from "./assistant-panel.module.css";
|
||||
@@ -67,6 +67,7 @@ export const AssistantAskUserTool = ({
|
||||
result,
|
||||
addResult,
|
||||
status,
|
||||
toolCallId,
|
||||
}: ToolCallMessagePartProps<AskUserArgs, AskUserResult>) => {
|
||||
const t = useScopedI18n("common.assistant.askUser");
|
||||
const [showOther, setShowOther] = useState(false);
|
||||
@@ -74,6 +75,17 @@ export const AssistantAskUserTool = ({
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const otherInputRef = useRef<HTMLInputElement>(null);
|
||||
const options = Array.isArray(args?.options) ? args.options : [];
|
||||
const affirmativeResult = getAssistantAffirmativeResult(options);
|
||||
const hasValidQuestion = hasCompleteAssistantToolArguments(status) && Boolean(args?.question) && options.length >= 2;
|
||||
const autoConfirming = useAssistantAutomaticAction({
|
||||
toolCallId,
|
||||
ready: result === undefined && hasValidQuestion && affirmativeResult !== undefined,
|
||||
completed: result !== undefined,
|
||||
confirm: () => {
|
||||
if (!affirmativeResult) return;
|
||||
addResult(affirmativeResult);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (showOther) otherInputRef.current?.focus();
|
||||
@@ -153,62 +165,67 @@ export const AssistantAskUserTool = ({
|
||||
</Group>
|
||||
|
||||
<Stack gap="xs" mt="md">
|
||||
{options.map((option, index) => {
|
||||
const kind = getAssistantAskUserOptionKind(option);
|
||||
const icon =
|
||||
kind === "affirmative" ? (
|
||||
<IconCheck size={17} />
|
||||
) : kind === "negative" ? (
|
||||
<IconX size={17} />
|
||||
) : (
|
||||
<IconArrowRight size={17} />
|
||||
);
|
||||
const color = kind === "affirmative" ? "green" : kind === "negative" ? "red" : "gray";
|
||||
{autoConfirming ? (
|
||||
<AssistantAutomaticActionProgress label={t("automaticAffirmative")} />
|
||||
) : (
|
||||
options.map((option, index) => {
|
||||
const kind = getAssistantAskUserOptionKind(option);
|
||||
const icon =
|
||||
kind === "affirmative" ? (
|
||||
<IconCheck size={17} />
|
||||
) : kind === "negative" ? (
|
||||
<IconX size={17} />
|
||||
) : (
|
||||
<IconArrowRight size={17} />
|
||||
);
|
||||
const color = kind === "affirmative" ? "green" : kind === "negative" ? "red" : "gray";
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={`${option.id}:${index}`}
|
||||
className={classes.humanToolOption}
|
||||
data-kind={kind}
|
||||
disabled={submitting}
|
||||
onClick={() =>
|
||||
submitResult({ answer: option.label, optionId: option.id, optionKind: kind, source: "option" })
|
||||
}
|
||||
>
|
||||
<ThemeIcon className={classes.humanToolOptionIcon} variant="light" color={color} size="sm">
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Box className={classes.humanToolOptionText} ta="start">
|
||||
<Text component="span" size="sm" fw={650} className={classes.humanToolOptionLabel}>
|
||||
{option.label}
|
||||
</Text>
|
||||
{option.description && (
|
||||
<Text
|
||||
component="span"
|
||||
display="block"
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
fw={400}
|
||||
className={classes.humanToolOptionDescription}
|
||||
>
|
||||
{option.description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Badge
|
||||
className={classes.humanToolOptionBadge}
|
||||
classNames={{ label: classes.humanToolOptionBadgeLabel }}
|
||||
variant="light"
|
||||
color={color}
|
||||
size="xs"
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={`${option.id}:${index}`}
|
||||
className={classes.humanToolOption}
|
||||
data-kind={kind}
|
||||
disabled={submitting}
|
||||
onClick={() =>
|
||||
submitResult({ answer: option.label, optionId: option.id, optionKind: kind, source: "option" })
|
||||
}
|
||||
>
|
||||
{t(`optionKind.${kind}`)}
|
||||
</Badge>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
<ThemeIcon className={classes.humanToolOptionIcon} variant="light" color={color} size="sm">
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Box className={classes.humanToolOptionText} ta="start">
|
||||
<Text component="span" size="sm" fw={650} className={classes.humanToolOptionLabel}>
|
||||
{option.label}
|
||||
</Text>
|
||||
{option.description && (
|
||||
<Text
|
||||
component="span"
|
||||
display="block"
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
fw={400}
|
||||
className={classes.humanToolOptionDescription}
|
||||
>
|
||||
{option.description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Badge
|
||||
className={classes.humanToolOptionBadge}
|
||||
classNames={{ label: classes.humanToolOptionBadgeLabel }}
|
||||
variant="light"
|
||||
color={color}
|
||||
size="xs"
|
||||
>
|
||||
{t(`optionKind.${kind}`)}
|
||||
</Badge>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{args.allowOther !== false &&
|
||||
{!autoConfirming &&
|
||||
args.allowOther !== false &&
|
||||
(showOther ? (
|
||||
<Group align="flex-end" gap="xs" wrap="nowrap">
|
||||
<TextInput
|
||||
|
||||
@@ -98,18 +98,18 @@
|
||||
container-type: size;
|
||||
position: fixed;
|
||||
z-index: 290;
|
||||
inset-block: 1rem;
|
||||
inset-inline-end: 1rem;
|
||||
inset-inline-start: auto;
|
||||
inset-inline-start: 50%;
|
||||
bottom: 1rem;
|
||||
display: flex;
|
||||
width: min(34rem, calc(100vw - 2rem));
|
||||
height: auto;
|
||||
min-height: min(30rem, calc(100dvh - 2rem));
|
||||
width: min(46rem, calc(100vw - 2rem));
|
||||
height: min(36rem, calc(100dvh - 5rem));
|
||||
min-height: min(22rem, calc(100dvh - 5rem));
|
||||
margin: 0;
|
||||
transform: translateX(-50%);
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border: 0;
|
||||
border-radius: var(--mantine-radius-lg);
|
||||
color: var(--mantine-color-text);
|
||||
background: var(--mantine-color-body);
|
||||
@@ -157,28 +157,27 @@
|
||||
border-bottom: 1px solid var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.panelIdentity {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.panelIdentityIcon {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.panelIdentityTitle,
|
||||
.panelThreadTitle {
|
||||
max-width: 14rem;
|
||||
}
|
||||
|
||||
.panelActions {
|
||||
min-width: 0;
|
||||
flex: 0 0 auto;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.newConversationButton {
|
||||
.windowControls {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.windowControl {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
}
|
||||
|
||||
.windowControl:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.panelAction {
|
||||
@@ -192,6 +191,10 @@
|
||||
padding: 0.45rem 0.5rem 0.45rem 0.65rem;
|
||||
}
|
||||
|
||||
.composerHints {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.historyButton {
|
||||
width: 2.625rem;
|
||||
max-width: 2.625rem;
|
||||
@@ -207,21 +210,6 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.newConversationButton {
|
||||
width: 2.625rem;
|
||||
max-width: 2.625rem;
|
||||
height: 2.625rem;
|
||||
padding-inline: 0.75rem;
|
||||
}
|
||||
|
||||
.newConversationButton :global(.mantine-Button-section) {
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
.newConversationButton :global(.mantine-Button-label) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.messages {
|
||||
padding: 0.5rem 0.65rem 1rem;
|
||||
}
|
||||
@@ -258,27 +246,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
@container assistant-widget (max-width: 36rem) {
|
||||
.historyButton {
|
||||
width: 2.625rem;
|
||||
max-width: 2.625rem;
|
||||
height: 2.625rem;
|
||||
padding-inline: 0.75rem;
|
||||
}
|
||||
|
||||
.historyButtonSection {
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.historyButtonLabel {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container assistant-widget (max-width: 22rem) {
|
||||
.panelAction,
|
||||
.historyButton,
|
||||
.newConversationButton {
|
||||
.historyButton {
|
||||
width: 2.25rem;
|
||||
max-width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
@@ -298,11 +268,7 @@
|
||||
padding-inline: 0.15rem;
|
||||
}
|
||||
|
||||
.composerContextLabel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.panelThreadTitle {
|
||||
.composerContext > :last-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -332,70 +298,17 @@
|
||||
}
|
||||
|
||||
.historyMenu {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
.historyMenu :global(.mantine-ScrollArea-content) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.historyItem {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
overflow: hidden;
|
||||
padding: 0.15rem 0.2rem 0.15rem 0.35rem;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
|
||||
.historyItem:hover,
|
||||
.historyItem[data-active] {
|
||||
.historyItem:hover {
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.historyItemTrigger {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: 0.35rem 0.25rem;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.historyItemTrigger > span {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.historyRenameInput {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.historyItemTrigger:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.historyItemMenu {
|
||||
flex: 0 0 auto;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.historyItemActions {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.thread {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -428,11 +341,6 @@
|
||||
margin-block: 0.9rem;
|
||||
}
|
||||
|
||||
.message[data-static] {
|
||||
contain-intrinsic-size: auto 12rem;
|
||||
content-visibility: auto;
|
||||
}
|
||||
|
||||
.userMessageWrap {
|
||||
width: fit-content;
|
||||
max-width: 86%;
|
||||
@@ -798,21 +706,24 @@
|
||||
|
||||
.pendingQuestionDock {
|
||||
min-height: 0;
|
||||
max-height: min(22rem, 48dvh);
|
||||
flex: 0 1 auto;
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid var(--mantine-color-default-border);
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
.thread[data-pending-question] .viewport {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pendingQuestionTarget {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
max-width: 44rem;
|
||||
margin-inline: auto;
|
||||
align-items: flex-start;
|
||||
align-items: safe center;
|
||||
}
|
||||
|
||||
.pendingQuestionTarget .humanTool {
|
||||
@@ -1180,43 +1091,6 @@
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.messageMoreMenu {
|
||||
z-index: 410;
|
||||
min-width: 11rem;
|
||||
padding: 0.25rem;
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
color: var(--mantine-color-text);
|
||||
background: var(--mantine-color-body);
|
||||
box-shadow: var(--mantine-shadow-md);
|
||||
}
|
||||
|
||||
.messageMoreItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
cursor: pointer;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.messageMoreItem[data-highlighted] {
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.messageMoreItem:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.messageMoreSeparator {
|
||||
height: 1px;
|
||||
margin-block: 0.2rem;
|
||||
background: var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.userActions {
|
||||
min-height: 1.75rem;
|
||||
}
|
||||
@@ -1299,13 +1173,13 @@
|
||||
|
||||
.composerContext {
|
||||
display: flex;
|
||||
width: auto;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-inline: 0.3rem 0.42rem;
|
||||
padding: 0;
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
border-radius: 50%;
|
||||
color: var(--mantine-color-text);
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
@@ -1541,6 +1415,10 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.composerHints {
|
||||
padding-inline: 0.45rem;
|
||||
}
|
||||
|
||||
.composerFooter {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
@@ -1763,12 +1641,12 @@
|
||||
@keyframes assistant-panel-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(0.75rem) scale(0.985);
|
||||
transform: translate(-50%, 0.75rem) scale(0.985);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
transform: translate(-50%, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1778,11 +1656,10 @@
|
||||
}
|
||||
|
||||
.floatingPanel {
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
height: 100dvh;
|
||||
min-height: 100dvh;
|
||||
border-radius: 0;
|
||||
bottom: max(0.5rem, env(safe-area-inset-bottom, 0px));
|
||||
width: calc(100vw - 1rem);
|
||||
height: min(44rem, calc(100dvh - 1rem - env(safe-area-inset-bottom, 0px)));
|
||||
min-height: min(20rem, calc(100dvh - 1rem - env(safe-area-inset-bottom, 0px)));
|
||||
}
|
||||
|
||||
.panelHeader {
|
||||
@@ -1809,6 +1686,10 @@
|
||||
padding-inline: 0.1rem;
|
||||
}
|
||||
|
||||
.composerHints {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.runtimeSelectorTrigger {
|
||||
max-width: 100%;
|
||||
}
|
||||
@@ -1824,10 +1705,6 @@
|
||||
}
|
||||
|
||||
@media (max-width: 30em) {
|
||||
.historyItemMenu {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.historyButton {
|
||||
width: 2.625rem;
|
||||
max-width: 2.625rem;
|
||||
|
||||
@@ -25,8 +25,6 @@ import type {
|
||||
import type { MessageStatus } from "@assistant-ui/react";
|
||||
import {
|
||||
ActionBarPrimitive,
|
||||
ActionBarMorePrimitive,
|
||||
AuiIf,
|
||||
AttachmentPrimitive,
|
||||
BranchPickerPrimitive,
|
||||
ChainOfThoughtByIndicesProvider,
|
||||
@@ -68,7 +66,6 @@ import {
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
@@ -94,7 +91,6 @@ import {
|
||||
IconChevronUp,
|
||||
IconCopy,
|
||||
IconDownload,
|
||||
IconDotsVertical,
|
||||
IconFile,
|
||||
IconFileExport,
|
||||
IconLink,
|
||||
@@ -116,8 +112,6 @@ import {
|
||||
IconThumbUp,
|
||||
IconTool,
|
||||
IconTrash,
|
||||
IconVolume,
|
||||
IconVolumeOff,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
@@ -130,7 +124,6 @@ import { useTimeAgo } from "@homarr/common";
|
||||
import { assistantProviderIds, assistantProviderPresets, assistantReasoningModes } from "@homarr/definitions";
|
||||
import type { AssistantProvider } from "@homarr/definitions";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import classes from "./assistant-panel.module.css";
|
||||
@@ -191,7 +184,6 @@ interface AssistantPanelProps extends AssistantConversationControls {
|
||||
onClose: () => void;
|
||||
onDismissActivity: () => void;
|
||||
activityDismissed: boolean;
|
||||
hasVisibleWidget: boolean;
|
||||
isRunning: boolean;
|
||||
unreadCount: number;
|
||||
latestAssistantText: string;
|
||||
@@ -1115,43 +1107,63 @@ const downloadAssistantMarkdown = (markdown: string, filename: string) => {
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
|
||||
};
|
||||
|
||||
const AssistantMessageActions = () => {
|
||||
const AssistantMessageExportAction = () => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const aui = useAui();
|
||||
const exportMessage = () => {
|
||||
const message = aui.message().getState();
|
||||
const markdown = buildAssistantMessageMarkdown({
|
||||
id: message.id,
|
||||
parentId: message.parentId,
|
||||
format: "assistant-ui/runtime",
|
||||
content: message,
|
||||
createdAt: message.createdAt,
|
||||
});
|
||||
downloadAssistantMarkdown(markdown, `assistant-message-${message.id.slice(0, 8)}.md`);
|
||||
};
|
||||
const message = useAuiState((state) => state.message);
|
||||
|
||||
return (
|
||||
<Box className={classes.messageActions}>
|
||||
<ActionBarPrimitive.Root
|
||||
hideWhenRunning
|
||||
autohide="not-last"
|
||||
autohideFloat="single-branch"
|
||||
className={classes.messageActionBar}
|
||||
<Tooltip label={t("exportMarkdown")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
aria-label={t("exportMarkdown")}
|
||||
onClick={() => {
|
||||
const markdown = buildAssistantMessageMarkdown({
|
||||
id: message.id,
|
||||
parentId: message.parentId,
|
||||
format: "assistant-ui/runtime",
|
||||
content: message,
|
||||
createdAt: message.createdAt,
|
||||
});
|
||||
downloadAssistantMarkdown(markdown, `assistant-message-${message.id.slice(0, 8)}.md`);
|
||||
}}
|
||||
>
|
||||
<Group gap={2} wrap="nowrap">
|
||||
<IconFileExport size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const AssistantMessageActions = () => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
return (
|
||||
<Box className={classes.messageActions}>
|
||||
<ActionBarPrimitive.Root hideWhenRunning className={classes.messageActionBar}>
|
||||
<Group gap={2} wrap="wrap">
|
||||
<ProviderMessageInfo />
|
||||
<Tooltip label={t("copy")}>
|
||||
<ActionBarPrimitive.Copy asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("copy")}>
|
||||
<AuiIf condition={(state) => !state.message.isCopied}>
|
||||
<IconCopy size={14} />
|
||||
</AuiIf>
|
||||
<AuiIf condition={(state) => state.message.isCopied}>
|
||||
<IconCheck size={14} />
|
||||
</AuiIf>
|
||||
<IconCopy size={14} />
|
||||
</ActionIcon>
|
||||
</ActionBarPrimitive.Copy>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("helpful")}>
|
||||
<ActionBarPrimitive.FeedbackPositive asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("helpful")}>
|
||||
<IconThumbUp size={14} />
|
||||
</ActionIcon>
|
||||
</ActionBarPrimitive.FeedbackPositive>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("notHelpful")}>
|
||||
<ActionBarPrimitive.FeedbackNegative asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("notHelpful")}>
|
||||
<IconThumbDown size={14} />
|
||||
</ActionIcon>
|
||||
</ActionBarPrimitive.FeedbackNegative>
|
||||
</Tooltip>
|
||||
<AssistantMessageExportAction />
|
||||
<Tooltip label={t("regenerate")}>
|
||||
<ActionBarPrimitive.Reload asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("regenerate")}>
|
||||
@@ -1159,50 +1171,6 @@ const AssistantMessageActions = () => {
|
||||
</ActionIcon>
|
||||
</ActionBarPrimitive.Reload>
|
||||
</Tooltip>
|
||||
<ActionBarMorePrimitive.Root>
|
||||
<ActionBarMorePrimitive.Trigger asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("moreActions")}>
|
||||
<IconDotsVertical size={14} />
|
||||
</ActionIcon>
|
||||
</ActionBarMorePrimitive.Trigger>
|
||||
<ActionBarMorePrimitive.Content className={classes.messageMoreMenu} sideOffset={4} align="end">
|
||||
<ActionBarPrimitive.FeedbackPositive asChild>
|
||||
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
|
||||
<IconThumbUp size={15} />
|
||||
{t("helpful")}
|
||||
</ActionBarMorePrimitive.Item>
|
||||
</ActionBarPrimitive.FeedbackPositive>
|
||||
<ActionBarPrimitive.FeedbackNegative asChild>
|
||||
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
|
||||
<IconThumbDown size={15} />
|
||||
{t("notHelpful")}
|
||||
</ActionBarMorePrimitive.Item>
|
||||
</ActionBarPrimitive.FeedbackNegative>
|
||||
<AuiIf condition={(state) => state.thread.capabilities.speech && state.message.speech === undefined}>
|
||||
<ActionBarPrimitive.Speak asChild>
|
||||
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
|
||||
<IconVolume size={15} />
|
||||
{t("readAloud")}
|
||||
</ActionBarMorePrimitive.Item>
|
||||
</ActionBarPrimitive.Speak>
|
||||
</AuiIf>
|
||||
<AuiIf condition={(state) => state.message.speech !== undefined}>
|
||||
<ActionBarPrimitive.StopSpeaking asChild>
|
||||
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
|
||||
<IconVolumeOff size={15} />
|
||||
{t("stopReading")}
|
||||
</ActionBarMorePrimitive.Item>
|
||||
</ActionBarPrimitive.StopSpeaking>
|
||||
</AuiIf>
|
||||
<ActionBarMorePrimitive.Separator className={classes.messageMoreSeparator} />
|
||||
<ActionBarPrimitive.ExportMarkdown onExport={() => exportMessage()} asChild>
|
||||
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
|
||||
<IconFileExport size={15} />
|
||||
{t("exportMarkdown")}
|
||||
</ActionBarMorePrimitive.Item>
|
||||
</ActionBarPrimitive.ExportMarkdown>
|
||||
</ActionBarMorePrimitive.Content>
|
||||
</ActionBarMorePrimitive.Root>
|
||||
</Group>
|
||||
</ActionBarPrimitive.Root>
|
||||
<BranchPicker />
|
||||
@@ -1213,7 +1181,7 @@ const AssistantMessageActions = () => {
|
||||
const UserMessageActions = () => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
return (
|
||||
<ActionBarPrimitive.Root hideWhenRunning autohide="not-last" className={classes.userActions}>
|
||||
<ActionBarPrimitive.Root hideWhenRunning className={classes.userActions}>
|
||||
<Group gap={2}>
|
||||
<Tooltip label={t("copyMessage")}>
|
||||
<ActionBarPrimitive.Copy asChild>
|
||||
@@ -1257,7 +1225,7 @@ const UserMessage = () => {
|
||||
const isEditing = useAuiState((state) => state.composer.isEditing);
|
||||
if (isEditing) return <EditComposer />;
|
||||
return (
|
||||
<MessagePrimitive.Root className={`${classes.message} ${classes.userMessageWrap}`} data-static>
|
||||
<MessagePrimitive.Root className={`${classes.message} ${classes.userMessageWrap}`}>
|
||||
<Box className={classes.userMessage}>
|
||||
<MessagePrimitive.Quote>
|
||||
{(quote) => (
|
||||
@@ -1764,17 +1732,12 @@ const ConversationContext = () => {
|
||||
aria-haspopup="dialog"
|
||||
onClick={() => setOpened((value) => !value)}
|
||||
>
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<RingProgress
|
||||
size={22}
|
||||
thickness={3}
|
||||
roundCaps
|
||||
sections={hasContext ? [{ value: contextPercentage, color: getContextColor(contextPercentage) }] : []}
|
||||
/>
|
||||
<Text className={classes.composerContextLabel} component="span" size="xs" fw={650}>
|
||||
{hasContext ? `${contextPercentage}%` : t("mentions.context")}
|
||||
</Text>
|
||||
</Group>
|
||||
<RingProgress
|
||||
size={24}
|
||||
thickness={3}
|
||||
roundCaps
|
||||
sections={hasContext ? [{ value: contextPercentage, color: getContextColor(contextPercentage) }] : []}
|
||||
/>
|
||||
</UnstyledButton>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown className={classes.conversationContextPopover}>
|
||||
@@ -1939,80 +1902,60 @@ const WebSearchActivity = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const AssistantMessage = () => {
|
||||
const isComplete = useAuiState((state) => state.message.status?.type === "complete");
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className={`${classes.message} ${classes.assistantMessage}`}
|
||||
data-static={isComplete || undefined}
|
||||
>
|
||||
<MessagePrimitive.GroupedParts groupBy={assistantMessageGroupBy} indicator="empty">
|
||||
{({ part, children }) => {
|
||||
switch (part.type) {
|
||||
case "group-agent-trace": {
|
||||
const startIndex = part.indices[0];
|
||||
const endIndex = part.indices.at(-1);
|
||||
if (startIndex === undefined || endIndex === undefined) return null;
|
||||
return (
|
||||
<ChainOfThoughtByIndicesProvider startIndex={startIndex} endIndex={endIndex}>
|
||||
<AssistantChainOfThought>{children}</AssistantChainOfThought>
|
||||
</ChainOfThoughtByIndicesProvider>
|
||||
);
|
||||
}
|
||||
case "group-reasoning":
|
||||
return <>{children}</>;
|
||||
case "group-tool":
|
||||
return <AgentTraceToolGroup>{children}</AgentTraceToolGroup>;
|
||||
case "indicator":
|
||||
return <AssistantMessagePending status={{ type: "running" }} />;
|
||||
case "text":
|
||||
return <AssistantTextPart />;
|
||||
case "reasoning":
|
||||
return <ReasoningPart {...part} />;
|
||||
case "tool-call":
|
||||
return part.toolUI ?? <ToolPart {...part} />;
|
||||
case "source":
|
||||
return <SourcePart {...part} />;
|
||||
case "file":
|
||||
return <FilePart {...part} />;
|
||||
case "image":
|
||||
return <ImagePart {...part} />;
|
||||
case "data":
|
||||
return part.dataRendererUI;
|
||||
default:
|
||||
return null;
|
||||
const AssistantMessage = () => (
|
||||
<MessagePrimitive.Root className={`${classes.message} ${classes.assistantMessage}`}>
|
||||
<MessagePrimitive.GroupedParts groupBy={assistantMessageGroupBy} indicator="empty">
|
||||
{({ part, children }) => {
|
||||
switch (part.type) {
|
||||
case "group-agent-trace": {
|
||||
const startIndex = part.indices[0];
|
||||
const endIndex = part.indices.at(-1);
|
||||
if (startIndex === undefined || endIndex === undefined) return null;
|
||||
return (
|
||||
<ChainOfThoughtByIndicesProvider startIndex={startIndex} endIndex={endIndex}>
|
||||
<AssistantChainOfThought>{children}</AssistantChainOfThought>
|
||||
</ChainOfThoughtByIndicesProvider>
|
||||
);
|
||||
}
|
||||
}}
|
||||
</MessagePrimitive.GroupedParts>
|
||||
<RuntimeError />
|
||||
<WebSearchActivity />
|
||||
<AssistantMessageActions />
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const assistantThreadMessageComponents = { UserMessage, AssistantMessage };
|
||||
case "group-reasoning":
|
||||
return <>{children}</>;
|
||||
case "group-tool":
|
||||
return <AgentTraceToolGroup>{children}</AgentTraceToolGroup>;
|
||||
case "indicator":
|
||||
return <AssistantMessagePending status={{ type: "running" }} />;
|
||||
case "text":
|
||||
return <AssistantTextPart />;
|
||||
case "reasoning":
|
||||
return <ReasoningPart {...part} />;
|
||||
case "tool-call":
|
||||
return part.toolUI ?? <ToolPart {...part} />;
|
||||
case "source":
|
||||
return <SourcePart {...part} />;
|
||||
case "file":
|
||||
return <FilePart {...part} />;
|
||||
case "image":
|
||||
return <ImagePart {...part} />;
|
||||
case "data":
|
||||
return part.dataRendererUI;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}}
|
||||
</MessagePrimitive.GroupedParts>
|
||||
<RuntimeError />
|
||||
<WebSearchActivity />
|
||||
<AssistantMessageActions />
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
|
||||
const HistorySelectContext = createContext<() => void>(() => undefined);
|
||||
|
||||
const ThreadListItem = () => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const aui = useAui();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const onSelect = useContext(HistorySelectContext);
|
||||
const remoteId = useAuiState((state) => state.threadListItem.remoteId);
|
||||
const title = useAuiState((state) => state.threadListItem.title);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [actionsOpened, setActionsOpened] = useState(false);
|
||||
const [renameOpened, setRenameOpened] = useState(false);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [nextTitle, setNextTitle] = useState(title ?? "");
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (renameOpened) renameInputRef.current?.focus();
|
||||
}, [renameOpened]);
|
||||
|
||||
const exportConversation = async () => {
|
||||
if (!remoteId || exporting) return;
|
||||
@@ -2034,109 +1977,23 @@ const ThreadListItem = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const renameConversation = async () => {
|
||||
const trimmedTitle = nextTitle.trim();
|
||||
if (!trimmedTitle || renaming) return;
|
||||
setRenaming(true);
|
||||
try {
|
||||
await aui.threadListItem().rename(trimmedTitle);
|
||||
setRenameOpened(false);
|
||||
setActionsOpened(false);
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("renameConversation.failedTitle"),
|
||||
message: t("renameConversation.failedDescription"),
|
||||
});
|
||||
} finally {
|
||||
setRenaming(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteConversation = () => {
|
||||
openConfirmModal({
|
||||
title: t("deleteConversation.title"),
|
||||
children: t("deleteConversation.description", { title: title ?? t("newConversation") }),
|
||||
confirmProps: { color: "red" },
|
||||
labels: { confirm: t("delete"), cancel: t("cancel") },
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await aui.threadListItem().delete();
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("deleteConversation.failedTitle"),
|
||||
message: t("deleteConversation.failedDescription"),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (renameOpened) {
|
||||
return (
|
||||
<ThreadListItemPrimitive.Root className={classes.historyItem}>
|
||||
<TextInput
|
||||
ref={renameInputRef}
|
||||
className={classes.historyRenameInput}
|
||||
value={nextTitle}
|
||||
onChange={(event) => setNextTitle(event.currentTarget.value)}
|
||||
aria-label={t("renameConversation.label")}
|
||||
maxLength={72}
|
||||
size="xs"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void renameConversation();
|
||||
if (event.key === "Escape" && !renaming) setRenameOpened(false);
|
||||
}}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="green"
|
||||
size="sm"
|
||||
aria-label={t("renameConversation.save")}
|
||||
disabled={!nextTitle.trim()}
|
||||
loading={renaming}
|
||||
loaderProps={{ type: "bars" }}
|
||||
onClick={() => void renameConversation()}
|
||||
>
|
||||
<IconCheck size={15} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
aria-label={t("cancel")}
|
||||
disabled={renaming}
|
||||
onClick={() => setRenameOpened(false)}
|
||||
>
|
||||
<IconX size={15} />
|
||||
</ActionIcon>
|
||||
</ThreadListItemPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ThreadListItemPrimitive.Root className={classes.historyItem}>
|
||||
<ThreadListItemPrimitive.Trigger asChild>
|
||||
<UnstyledButton className={classes.historyItemTrigger} title={title} onClick={onSelect}>
|
||||
<Text component="span" size="sm" fw={500} truncate>
|
||||
<ThreadListItemPrimitive.Title fallback={t("newConversation")} />
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
</ThreadListItemPrimitive.Trigger>
|
||||
{actionsOpened && (
|
||||
<Group className={classes.historyItemActions} gap={1} wrap="nowrap">
|
||||
<ActionIcon
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<ThreadListItemPrimitive.Trigger asChild>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
title={t("renameConversation.action")}
|
||||
aria-label={t("renameConversation.action")}
|
||||
onClick={() => {
|
||||
setNextTitle(title ?? "");
|
||||
setRenameOpened(true);
|
||||
}}
|
||||
size="compact-sm"
|
||||
justify="flex-start"
|
||||
flex={1}
|
||||
style={{ minWidth: 0 }}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<IconPencil size={14} />
|
||||
</ActionIcon>
|
||||
<ThreadListItemPrimitive.Title fallback={t("newConversation")} />
|
||||
</Button>
|
||||
</ThreadListItemPrimitive.Trigger>
|
||||
<Tooltip label={t("exportConversation.action")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
@@ -2144,49 +2001,24 @@ const ThreadListItem = () => {
|
||||
loading={exporting}
|
||||
loaderProps={{ type: "bars" }}
|
||||
disabled={!remoteId}
|
||||
title={t("exportConversation.action")}
|
||||
aria-label={t("exportConversation.action")}
|
||||
onClick={() => void exportConversation()}
|
||||
>
|
||||
<IconFileExport size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
title={t("delete")}
|
||||
aria-label={t("delete")}
|
||||
onClick={deleteConversation}
|
||||
>
|
||||
</Tooltip>
|
||||
<ThreadListItemPrimitive.Delete asChild>
|
||||
<ActionIcon variant="subtle" color="red" size="sm" aria-label={t("delete")}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
<ActionIcon
|
||||
className={classes.historyItemMenu}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
title={t("conversationActions")}
|
||||
aria-label={t("conversationActions")}
|
||||
aria-expanded={actionsOpened}
|
||||
onClick={() => setActionsOpened((current) => !current)}
|
||||
>
|
||||
{actionsOpened ? <IconX size={15} /> : <IconDotsVertical size={15} />}
|
||||
</ActionIcon>
|
||||
</ThreadListItemPrimitive.Delete>
|
||||
</Group>
|
||||
</ThreadListItemPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const ThreadHistory = ({ onSelect }: { onSelect: () => void }) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const [query, setQuery] = useState("");
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase();
|
||||
const threadItems = useAuiState((state) => state.threads.threadItems);
|
||||
const hasMatchingConversation = threadItems.some((item) => {
|
||||
const itemTitle = item.title ?? t("newConversation");
|
||||
return itemTitle.toLocaleLowerCase().includes(normalizedQuery);
|
||||
});
|
||||
return (
|
||||
<HistorySelectContext.Provider value={onSelect}>
|
||||
<Stack className={classes.historyMenu} gap="xs" p="xs">
|
||||
@@ -2203,28 +2035,9 @@ const ThreadHistory = ({ onSelect }: { onSelect: () => void }) => {
|
||||
{t("conversations")}
|
||||
</Text>
|
||||
</Group>
|
||||
<TextInput
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.currentTarget.value)}
|
||||
leftSection={<IconSearch size={15} />}
|
||||
placeholder={t("searchConversations")}
|
||||
aria-label={t("searchConversations")}
|
||||
size="xs"
|
||||
/>
|
||||
<ScrollArea.Autosize mah="min(24rem, 55dvh)" type="auto" scrollbars="y" offsetScrollbars>
|
||||
<ScrollArea.Autosize mah="min(24rem, 55dvh)" type="auto" offsetScrollbars>
|
||||
<Stack gap={3}>
|
||||
<ThreadListPrimitive.Items>
|
||||
{({ threadListItem }) => {
|
||||
const itemTitle = threadListItem.title ?? t("newConversation");
|
||||
if (normalizedQuery && !itemTitle.toLocaleLowerCase().includes(normalizedQuery)) return null;
|
||||
return <ThreadListItem />;
|
||||
}}
|
||||
</ThreadListPrimitive.Items>
|
||||
{normalizedQuery && !hasMatchingConversation && (
|
||||
<Text size="xs" c="dimmed" ta="center" py="md">
|
||||
{t("noMatchingConversations")}
|
||||
</Text>
|
||||
)}
|
||||
<ThreadListPrimitive.Items components={{ ThreadListItem }} />
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
@@ -2239,6 +2052,7 @@ const ConversationHistory = () => {
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
onDismiss={() => setOpened(false)}
|
||||
position="bottom-end"
|
||||
width="min(22rem, calc(100vw - 1rem))"
|
||||
shadow="md"
|
||||
@@ -2377,7 +2191,7 @@ const EmptyThread = () => {
|
||||
</Text>
|
||||
</Stack>
|
||||
<Box className={classes.suggestions}>
|
||||
<ThreadPrimitive.Suggestion prompt={t("suggestions.health.prompt")} send={false} clearComposer asChild>
|
||||
<ThreadPrimitive.Suggestion prompt={t("suggestions.health.prompt")} send asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
className={classes.suggestion}
|
||||
@@ -2386,17 +2200,17 @@ const EmptyThread = () => {
|
||||
{t("suggestions.health.label")}
|
||||
</Button>
|
||||
</ThreadPrimitive.Suggestion>
|
||||
<ThreadPrimitive.Suggestion prompt={t("suggestions.explore.prompt")} send={false} clearComposer asChild>
|
||||
<ThreadPrimitive.Suggestion prompt={t("suggestions.explore.prompt")} send asChild>
|
||||
<Button variant="default" className={classes.suggestion} leftSection={<IconApps size={18} />}>
|
||||
{t("suggestions.explore.label")}
|
||||
</Button>
|
||||
</ThreadPrimitive.Suggestion>
|
||||
<ThreadPrimitive.Suggestion prompt={t("suggestions.media.prompt")} send={false} clearComposer asChild>
|
||||
<ThreadPrimitive.Suggestion prompt={t("suggestions.media.prompt")} send asChild>
|
||||
<Button variant="default" className={classes.suggestion} leftSection={<IconSearch size={18} />}>
|
||||
{t("suggestions.media.label")}
|
||||
</Button>
|
||||
</ThreadPrimitive.Suggestion>
|
||||
<ThreadPrimitive.Suggestion prompt={t("suggestions.style.prompt")} send={false} clearComposer asChild>
|
||||
<ThreadPrimitive.Suggestion prompt={t("suggestions.style.prompt")} send asChild>
|
||||
<Button variant="default" className={classes.suggestion} leftSection={<IconPalette size={18} />}>
|
||||
{t("suggestions.style.label")}
|
||||
</Button>
|
||||
@@ -2978,6 +2792,14 @@ const Composer = (props: ComposerProps) => {
|
||||
<HomarrProviderQuota />
|
||||
<ConversationContext />
|
||||
</Group>
|
||||
<Group className={classes.composerHints} gap="xs" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("mentions.hint")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("commands.hint")}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</ComposerPrimitive.Root>
|
||||
</ComposerPrimitive.AttachmentDropzone>
|
||||
@@ -3157,30 +2979,9 @@ interface AssistantConversationSurfaceProps extends AssistantConversationControl
|
||||
pendingAction: AssistantPendingAction | undefined;
|
||||
onExpand?: () => void;
|
||||
onMinimize?: () => void;
|
||||
onDismiss?: () => void;
|
||||
onWindowClose?: () => void;
|
||||
}
|
||||
|
||||
const AssistantPanelIdentity = () => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const threadTitle = useAuiState((state) => state.threadListItem.title);
|
||||
|
||||
return (
|
||||
<Group className={classes.panelIdentity} gap="xs" wrap="nowrap">
|
||||
<ThemeIcon className={classes.panelIdentityIcon} variant="light" color="red" radius="xl" size="md">
|
||||
<IconRobot size={16} />
|
||||
</ThemeIcon>
|
||||
<Box miw={0}>
|
||||
<Text className={classes.panelIdentityTitle} size="sm" fw={700} truncate>
|
||||
{t("title")}
|
||||
</Text>
|
||||
<Text className={classes.panelThreadTitle} size="xs" c="dimmed" truncate>
|
||||
{threadTitle ?? t("newConversation")}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export const AssistantConversationSurface = ({
|
||||
isRunning,
|
||||
pendingAction,
|
||||
@@ -3195,7 +2996,7 @@ export const AssistantConversationSurface = ({
|
||||
onReasoningChange,
|
||||
onExpand,
|
||||
onMinimize,
|
||||
onDismiss,
|
||||
onWindowClose,
|
||||
}: AssistantConversationSurfaceProps) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const reducedMotion = useReducedMotion();
|
||||
@@ -3210,23 +3011,52 @@ export const AssistantConversationSurface = ({
|
||||
return (
|
||||
<AssistantDirectiveEntitiesProvider>
|
||||
<Group className={classes.panelHeader} justify="space-between" wrap="nowrap" gap="xs">
|
||||
<AssistantPanelIdentity />
|
||||
{onWindowClose && onMinimize && (
|
||||
<Group
|
||||
component="fieldset"
|
||||
className={classes.windowControls}
|
||||
gap={4}
|
||||
wrap="nowrap"
|
||||
aria-label={t("windowControls")}
|
||||
>
|
||||
<Tooltip label={t("close")}>
|
||||
<ActionIcon
|
||||
className={classes.windowControl}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={onWindowClose}
|
||||
aria-label={t("close")}
|
||||
>
|
||||
<IconX size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("minimize")}>
|
||||
<ActionIcon
|
||||
className={classes.windowControl}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={onMinimize}
|
||||
aria-label={t("minimize")}
|
||||
>
|
||||
<IconMinus size={17} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
)}
|
||||
<Group className={classes.panelActions} gap={2} wrap="nowrap">
|
||||
<ConversationHistory />
|
||||
<ViewRefreshAction isRefreshing={isRefreshing} onRefresh={onRefresh} />
|
||||
<AutoApprovalControl />
|
||||
<Tooltip label={t("newConversation")}>
|
||||
<ThreadListPrimitive.New asChild>
|
||||
<Button
|
||||
className={classes.newConversationButton}
|
||||
<ActionIcon
|
||||
className={classes.panelAction}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
leftSection={<IconPlus size={17} />}
|
||||
aria-label={t("newConversation")}
|
||||
>
|
||||
{t("newConversation")}
|
||||
</Button>
|
||||
<IconPlus size={17} />
|
||||
</ActionIcon>
|
||||
</ThreadListPrimitive.New>
|
||||
</Tooltip>
|
||||
{onExpand && (
|
||||
@@ -3242,32 +3072,6 @@ export const AssistantConversationSurface = ({
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onMinimize && (
|
||||
<Tooltip label={t("minimize")}>
|
||||
<ActionIcon
|
||||
className={classes.panelAction}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={onMinimize}
|
||||
aria-label={t("minimize")}
|
||||
>
|
||||
<IconMinus size={17} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onDismiss && (
|
||||
<Tooltip label={t("close")}>
|
||||
<ActionIcon
|
||||
className={classes.panelAction}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={onDismiss}
|
||||
aria-label={t("close")}
|
||||
>
|
||||
<IconX size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
<AssistantQuestionPortalProvider target={questionPortalTarget}>
|
||||
@@ -3279,7 +3083,7 @@ export const AssistantConversationSurface = ({
|
||||
<ThreadPrimitive.Viewport className={classes.viewport} autoScroll>
|
||||
<Box className={classes.messages}>
|
||||
<EmptyThread />
|
||||
<ThreadPrimitive.Messages components={assistantThreadMessageComponents} />
|
||||
<ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
|
||||
</Box>
|
||||
<SelectionToolbarPrimitive.Root className={classes.selectionToolbar}>
|
||||
<SelectionToolbarPrimitive.Quote asChild>
|
||||
@@ -3326,7 +3130,6 @@ export const AssistantPanel = ({
|
||||
onClose,
|
||||
onDismissActivity,
|
||||
activityDismissed,
|
||||
hasVisibleWidget,
|
||||
isRunning,
|
||||
unreadCount,
|
||||
latestAssistantText,
|
||||
@@ -3344,18 +3147,8 @@ export const AssistantPanel = ({
|
||||
onReasoningChange,
|
||||
}: AssistantPanelProps) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const aui = useAui();
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||
const panelRef = useRef<HTMLDialogElement>(null);
|
||||
const stopSpeechWithoutSurface = () => {
|
||||
if (hasVisibleWidget) return;
|
||||
const thread = aui.thread();
|
||||
if (thread.getState().speech !== undefined) thread.stopSpeaking();
|
||||
};
|
||||
const minimize = () => {
|
||||
stopSpeechWithoutSurface();
|
||||
onClose();
|
||||
};
|
||||
|
||||
useWindowEvent("keydown", (event) => {
|
||||
if (
|
||||
@@ -3367,7 +3160,7 @@ export const AssistantPanel = ({
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
minimize();
|
||||
onClose();
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
@@ -3389,7 +3182,7 @@ export const AssistantPanel = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{!opened && !hasVisibleWidget && (
|
||||
{!opened && (
|
||||
<AssistantActivityBar
|
||||
onOpen={onOpen}
|
||||
onDismissActivity={onDismissActivity}
|
||||
@@ -3417,9 +3210,8 @@ export const AssistantPanel = ({
|
||||
onModelChange={onModelChange}
|
||||
onReasoningChange={onReasoningChange}
|
||||
autoFocusComposer
|
||||
onMinimize={minimize}
|
||||
onDismiss={() => {
|
||||
stopSpeechWithoutSurface();
|
||||
onMinimize={onClose}
|
||||
onWindowClose={() => {
|
||||
onDismissActivity();
|
||||
onClose();
|
||||
}}
|
||||
|
||||
@@ -7,19 +7,11 @@ import type {
|
||||
AttachmentAdapter,
|
||||
MessageFormatAdapter,
|
||||
RemoteThreadListAdapter,
|
||||
SpeechSynthesisAdapter,
|
||||
ThreadHistoryAdapter,
|
||||
ThreadMessage,
|
||||
Toolkit,
|
||||
} from "@assistant-ui/react";
|
||||
import {
|
||||
defineToolkit,
|
||||
useAui,
|
||||
useAuiEvent,
|
||||
useAuiState,
|
||||
useRemoteThreadListRuntime,
|
||||
WebSpeechSynthesisAdapter,
|
||||
} from "@assistant-ui/react";
|
||||
import { defineToolkit, useAui, useAuiEvent, useAuiState, useRemoteThreadListRuntime } from "@assistant-ui/react";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { AssistantChatTransport, useAISDKRuntime } from "@assistant-ui/react-ai-sdk";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
@@ -49,8 +41,7 @@ import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assi
|
||||
import { resolveAssistantPreferenceModelId, resolveAssistantThreadPreferenceModelId } from "./assistant-preferences";
|
||||
import { assistantAiSdkRuntimeOptions } from "./assistant-runtime-options";
|
||||
import {
|
||||
AssistantComposerRuntimeProvider,
|
||||
AssistantComposerSurfaceBoundary,
|
||||
AssistantComposerSurfaceProvider,
|
||||
AssistantRunFocusPreserver,
|
||||
AssistantRuntimeProviderWithTools,
|
||||
} from "./assistant-runtime-provider";
|
||||
@@ -484,12 +475,6 @@ const AssistantThreadRuntime = () => {
|
||||
),
|
||||
[selectedModel],
|
||||
);
|
||||
const [speech, setSpeech] = useState<SpeechSynthesisAdapter>();
|
||||
useEffect(() => {
|
||||
if ("speechSynthesis" in window && typeof SpeechSynthesisUtterance !== "undefined") {
|
||||
setSpeech(new WebSpeechSynthesisAdapter());
|
||||
}
|
||||
}, []);
|
||||
const feedback = useMemo(
|
||||
() => ({
|
||||
submit: ({ message, type }: { message: ThreadMessage; type: "positive" | "negative" }) => {
|
||||
@@ -527,7 +512,6 @@ const AssistantThreadRuntime = () => {
|
||||
history,
|
||||
attachments,
|
||||
feedback,
|
||||
speech,
|
||||
},
|
||||
});
|
||||
transport.setRuntime(runtime);
|
||||
@@ -683,7 +667,6 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [activityDismissed, setActivityDismissed] = useState(true);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [visibleWidgetIds, setVisibleWidgetIds] = useState<Set<string>>(() => new Set());
|
||||
const [queuedPrompt, setQueuedPrompt] = useState<string | null>(null);
|
||||
const aui = useAui();
|
||||
const preferences = useAssistantPreferences();
|
||||
@@ -731,21 +714,6 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
setOpened(true);
|
||||
}, [markRead]);
|
||||
const close = useCallback(() => setOpened(false), []);
|
||||
const setWidgetVisible = useCallback((widgetId: string, visible: boolean) => {
|
||||
setVisibleWidgetIds((current) => {
|
||||
if (current.has(widgetId) === visible) return current;
|
||||
const next = new Set(current);
|
||||
if (visible) next.add(widgetId);
|
||||
else next.delete(widgetId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const activateWidget = useCallback((widgetId: string) => {
|
||||
setVisibleWidgetIds((current) => {
|
||||
if (!current.has(widgetId) || current.values().next().value === widgetId) return current;
|
||||
return new Set([widgetId, ...Array.from(current).filter((id) => id !== widgetId)]);
|
||||
});
|
||||
}, []);
|
||||
const toggle = useCallback(() => {
|
||||
if (opened) close();
|
||||
else open();
|
||||
@@ -867,7 +835,6 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
}, [assistantIsRunning, latestStatus?.type, providerUnavailable, sendPrompt, t]);
|
||||
useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]);
|
||||
|
||||
const activeWidgetId = visibleWidgetIds.values().next().value ?? null;
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
enabled: true,
|
||||
@@ -876,67 +843,46 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
isRunning: assistantIsRunning,
|
||||
isRefreshing,
|
||||
unreadCount,
|
||||
hasVisibleWidget: visibleWidgetIds.size > 0,
|
||||
activeWidgetId,
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
sendPrompt,
|
||||
refreshCurrentView,
|
||||
setWidgetVisible,
|
||||
activateWidget,
|
||||
}),
|
||||
[
|
||||
assistantIsRunning,
|
||||
activateWidget,
|
||||
activeWidgetId,
|
||||
close,
|
||||
isRefreshing,
|
||||
open,
|
||||
opened,
|
||||
refreshCurrentView,
|
||||
sendPrompt,
|
||||
setWidgetVisible,
|
||||
toggle,
|
||||
unreadCount,
|
||||
visibleWidgetIds.size,
|
||||
],
|
||||
[assistantIsRunning, close, isRefreshing, open, opened, refreshCurrentView, sendPrompt, toggle, unreadCount],
|
||||
);
|
||||
|
||||
return (
|
||||
<AssistantAutoApprovalProvider conversationId={conversationId}>
|
||||
<AssistantContext.Provider value={value}>
|
||||
<AssistantComposerRuntimeProvider>
|
||||
<AssistantWidgetRendererProvider renderer={AssistantBoardWidget}>{children}</AssistantWidgetRendererProvider>
|
||||
<AssistantComposerSurfaceBoundary surfaceId="assistant-panel">
|
||||
<AssistantPanel
|
||||
opened={opened}
|
||||
onOpen={open}
|
||||
onClose={close}
|
||||
onDismissActivity={() => {
|
||||
markRead();
|
||||
setActivityDismissed(true);
|
||||
}}
|
||||
activityDismissed={activityDismissed}
|
||||
hasVisibleWidget={visibleWidgetIds.size > 0}
|
||||
isRunning={assistantIsRunning}
|
||||
unreadCount={unreadCount}
|
||||
latestAssistantText={latestAssistantText}
|
||||
latestAssistantPartType={latestAssistantPartType}
|
||||
latestUserText={queuedPrompt ?? latestUserText}
|
||||
latestStatus={latestStatus}
|
||||
pendingAction={pendingAction}
|
||||
modelId={preferences.modelId}
|
||||
models={preferences.models}
|
||||
modelOptionsLoading={preferences.isLoading}
|
||||
reasoning={preferences.reasoning}
|
||||
isRefreshing={isRefreshing}
|
||||
onRefresh={refreshCurrentView}
|
||||
onModelChange={selectModel}
|
||||
onReasoningChange={preferences.setReasoning}
|
||||
/>
|
||||
</AssistantComposerSurfaceBoundary>
|
||||
</AssistantComposerRuntimeProvider>
|
||||
<AssistantWidgetRendererProvider renderer={AssistantBoardWidget}>{children}</AssistantWidgetRendererProvider>
|
||||
<AssistantComposerSurfaceProvider surfaceId="floating-panel">
|
||||
<AssistantPanel
|
||||
opened={opened}
|
||||
onOpen={open}
|
||||
onClose={close}
|
||||
onDismissActivity={() => {
|
||||
markRead();
|
||||
setActivityDismissed(true);
|
||||
}}
|
||||
activityDismissed={activityDismissed}
|
||||
isRunning={assistantIsRunning}
|
||||
unreadCount={unreadCount}
|
||||
latestAssistantText={latestAssistantText}
|
||||
latestAssistantPartType={latestAssistantPartType}
|
||||
latestUserText={queuedPrompt ?? latestUserText}
|
||||
latestStatus={latestStatus}
|
||||
pendingAction={pendingAction}
|
||||
modelId={preferences.modelId}
|
||||
models={preferences.models}
|
||||
modelOptionsLoading={preferences.isLoading}
|
||||
reasoning={preferences.reasoning}
|
||||
isRefreshing={isRefreshing}
|
||||
onRefresh={refreshCurrentView}
|
||||
onModelChange={selectModel}
|
||||
onReasoningChange={preferences.setReasoning}
|
||||
/>
|
||||
</AssistantComposerSurfaceProvider>
|
||||
</AssistantContext.Provider>
|
||||
</AssistantAutoApprovalProvider>
|
||||
);
|
||||
|
||||
@@ -26,8 +26,7 @@ import { z } from "zod/v4";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
|
||||
import {
|
||||
AssistantComposerRuntimeProvider,
|
||||
AssistantComposerSurfaceBoundary,
|
||||
AssistantComposerSurfaceProvider,
|
||||
AssistantRunFocusPreserver,
|
||||
AssistantRuntimeProviderWithTools,
|
||||
assistantSurfaceComposerCacheLimit,
|
||||
@@ -149,22 +148,14 @@ const SurfaceTestAssistant = ({
|
||||
{ runtime, toolkit },
|
||||
createElement(AssistantRunFocusPreserver),
|
||||
createElement(
|
||||
AssistantComposerRuntimeProvider,
|
||||
null,
|
||||
createElement(
|
||||
AssistantComposerSurfaceBoundary,
|
||||
{ surfaceId: "surface-a" },
|
||||
createElement(SurfaceProbe, { id: "a", onReady }),
|
||||
),
|
||||
AssistantComposerSurfaceProvider,
|
||||
{ surfaceId: "surface-a" },
|
||||
createElement(SurfaceProbe, { id: "a", onReady }),
|
||||
),
|
||||
createElement(
|
||||
AssistantComposerRuntimeProvider,
|
||||
null,
|
||||
createElement(
|
||||
AssistantComposerSurfaceBoundary,
|
||||
{ surfaceId: "surface-b" },
|
||||
createElement(SurfaceProbe, { id: "b", onReady }),
|
||||
),
|
||||
AssistantComposerSurfaceProvider,
|
||||
{ surfaceId: "surface-b" },
|
||||
createElement(SurfaceProbe, { id: "b", onReady }),
|
||||
),
|
||||
simulateRunFocus ? createElement(LexicalRunFocusProbe) : null,
|
||||
);
|
||||
|
||||
@@ -157,6 +157,9 @@ const createSurfaceComposerHost = (
|
||||
get attachments() {
|
||||
return getThreadAdapters(threadCore)?.attachments ? attachments : undefined;
|
||||
},
|
||||
get dictation() {
|
||||
return getThreadAdapters(threadCore)?.dictation;
|
||||
},
|
||||
};
|
||||
|
||||
return new Proxy(threadCore as ThreadCoreWithAdapters, {
|
||||
@@ -291,21 +294,18 @@ export const createAssistantSurfaceRuntime = (runtime: AssistantRuntime): Assist
|
||||
subscribe(callback) {
|
||||
let activeCore = sharedBinding.getState();
|
||||
let activeConversationId = getConversationId();
|
||||
let activeComposer = getComposer(activeConversationId, activeCore);
|
||||
retainConversation(activeConversationId);
|
||||
let unsubscribeComposer = activeComposer.subscribe(callback);
|
||||
let unsubscribeComposer = getComposer(activeConversationId, activeCore).subscribe(callback);
|
||||
const handleSharedUpdate = () => {
|
||||
const nextCore = sharedBinding.getState();
|
||||
const nextConversationId = getConversationId();
|
||||
if (nextCore !== activeCore || nextConversationId !== activeConversationId) {
|
||||
if (activeCore.speech !== undefined) activeCore.stopSpeaking();
|
||||
unsubscribeComposer();
|
||||
releaseConversation(activeConversationId);
|
||||
activeCore = nextCore;
|
||||
activeConversationId = nextConversationId;
|
||||
retainConversation(activeConversationId);
|
||||
activeComposer = getComposer(activeConversationId, activeCore);
|
||||
unsubscribeComposer = activeComposer.subscribe(callback);
|
||||
unsubscribeComposer = getComposer(activeConversationId, activeCore).subscribe(callback);
|
||||
}
|
||||
callback();
|
||||
};
|
||||
@@ -360,13 +360,6 @@ export const createAssistantSurfaceRuntime = (runtime: AssistantRuntime): Assist
|
||||
registerModelContextProvider: () => () => {},
|
||||
dispose() {
|
||||
activeConversationCounts.clear();
|
||||
const threadCores = new Set([
|
||||
...Array.from(surfaceCoreByConversation.values(), (entry) => entry.source),
|
||||
...Array.from(composerByConversation.values(), (entry) => entry.source),
|
||||
]);
|
||||
for (const threadCore of threadCores) {
|
||||
if (threadCore.speech !== undefined) threadCore.stopSpeaking();
|
||||
}
|
||||
for (const [conversationId, entry] of composerByConversation) disposeComposer(conversationId, entry);
|
||||
},
|
||||
};
|
||||
@@ -380,7 +373,7 @@ const AssistantComposerSurfaceContext = createContext<AssistantComposerSurfaceVa
|
||||
|
||||
export const useAssistantComposerSurface = () => {
|
||||
const value = useContext(AssistantComposerSurfaceContext);
|
||||
if (!value) throw new Error("useAssistantComposerSurface must be used within AssistantComposerSurfaceBoundary");
|
||||
if (!value) throw new Error("useAssistantComposerSurface must be used within AssistantComposerSurfaceProvider");
|
||||
return value;
|
||||
};
|
||||
|
||||
@@ -444,23 +437,21 @@ const AssistantComposerSurfaceEvents = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export const AssistantComposerSurfaceBoundary = ({ children, surfaceId }: PropsWithChildren<{ surfaceId: string }>) => {
|
||||
const AssistantComposerSurfaceBoundary = ({ children, surfaceId }: PropsWithChildren<{ surfaceId: string }>) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const aui = useAui();
|
||||
const instanceId = useId();
|
||||
const resolvedSurfaceId = `${surfaceId}:${instanceId}`;
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
const contextValue = useMemo(() => ({ id: resolvedSurfaceId }), [resolvedSurfaceId]);
|
||||
const contextValue = useMemo(() => ({ id: surfaceId }), [surfaceId]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = elementRef.current;
|
||||
if (!element) return undefined;
|
||||
surfaceElements.set(resolvedSurfaceId, element);
|
||||
surfaceElements.set(surfaceId, element);
|
||||
|
||||
return () => {
|
||||
if (surfaceElements.get(resolvedSurfaceId) === element) surfaceElements.delete(resolvedSurfaceId);
|
||||
if (surfaceElements.get(surfaceId) === element) surfaceElements.delete(surfaceId);
|
||||
};
|
||||
}, [resolvedSurfaceId]);
|
||||
}, [surfaceId]);
|
||||
|
||||
const handlePasteCapture = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||
const target = event.target;
|
||||
@@ -516,6 +507,7 @@ export const AssistantComposerSurfaceBoundary = ({ children, surfaceId }: PropsW
|
||||
}}
|
||||
onPasteCapture={handlePasteCapture}
|
||||
>
|
||||
<AssistantComposerSurfaceEvents />
|
||||
{children}
|
||||
</div>
|
||||
</AssistantComposerSurfaceContext.Provider>
|
||||
@@ -574,12 +566,18 @@ export const AssistantRunFocusPreserver = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
interface AssistantComposerSurfaceProviderProps extends PropsWithChildren {
|
||||
surfaceId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives every rendered assistant surface one shared local composer while preserving the shared
|
||||
* conversation. Individual surfaces should add AssistantComposerSurfaceBoundary around their UI.
|
||||
* Gives one rendered assistant surface a local composer while preserving the shared conversation.
|
||||
* Every component below this boundary can keep using the normal assistant-ui primitives and hooks.
|
||||
*/
|
||||
export const AssistantComposerRuntimeProvider = ({ children }: PropsWithChildren) => {
|
||||
export const AssistantComposerSurfaceProvider = ({ children, surfaceId }: AssistantComposerSurfaceProviderProps) => {
|
||||
const parentAui = useAui();
|
||||
const instanceId = useId();
|
||||
const resolvedSurfaceId = `${surfaceId}:${instanceId}`;
|
||||
// oxlint-disable-next-line no-underscore-dangle -- required to derive a scoped runtime without cloning the shared thread.
|
||||
const sharedRuntime = parentAui.threads().__internal_getAssistantRuntime?.();
|
||||
const surfaceRuntime = useMemo(() => {
|
||||
@@ -590,8 +588,7 @@ export const AssistantComposerRuntimeProvider = ({ children }: PropsWithChildren
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider aui={parentAui} runtime={surfaceRuntime}>
|
||||
<AssistantComposerSurfaceEvents />
|
||||
{children}
|
||||
<AssistantComposerSurfaceBoundary surfaceId={resolvedSurfaceId}>{children}</AssistantComposerSurfaceBoundary>
|
||||
</AssistantRuntimeProvider>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { useEffect, useId, useRef } from "react";
|
||||
import { ActionIcon, Box, Center, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
|
||||
import { IconArrowsMaximize, IconArrowsMinimize, IconRobot } from "@tabler/icons-react";
|
||||
import { IconArrowsMaximize, IconRobot } from "@tabler/icons-react";
|
||||
import { useAui, useAuiState } from "@assistant-ui/react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
@@ -14,47 +13,23 @@ import classes from "./assistant-panel.module.css";
|
||||
import { AssistantConversationSurface } from "./assistant-panel";
|
||||
import { getPendingAssistantAction } from "./assistant-pending-action";
|
||||
import { useAssistantPreferences, useHomarrAssistant } from "./assistant-context";
|
||||
import { AssistantComposerSurfaceBoundary } from "./assistant-runtime-provider";
|
||||
import { AssistantComposerSurfaceProvider } from "./assistant-runtime-provider";
|
||||
|
||||
export const AssistantBoardWidget = (props: WidgetComponentProps<"assistant">) => {
|
||||
const assistant = useHomarrAssistant();
|
||||
const { enabled, setWidgetVisible } = assistant;
|
||||
const generatedWidgetId = useId();
|
||||
const widgetId = props.itemId ?? generatedWidgetId;
|
||||
const widgetRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const widget = widgetRef.current;
|
||||
if (!enabled || !widget) return;
|
||||
const observer = new IntersectionObserver(([entry]) => setWidgetVisible(widgetId, entry?.isIntersecting === true), {
|
||||
threshold: 0.15,
|
||||
});
|
||||
observer.observe(widget);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
setWidgetVisible(widgetId, false);
|
||||
};
|
||||
}, [enabled, setWidgetVisible, widgetId]);
|
||||
|
||||
if (!assistant.enabled) {
|
||||
return <UnavailableAssistantWidget description={assistant.unavailableDescription} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AssistantComposerSurfaceBoundary surfaceId={`assistant-widget-${widgetId}`}>
|
||||
<Box ref={widgetRef} w="100%" h="100%">
|
||||
<EnabledAssistantBoardWidget {...props} assistantWidgetId={widgetId} />
|
||||
</Box>
|
||||
</AssistantComposerSurfaceBoundary>
|
||||
<AssistantComposerSurfaceProvider surfaceId={`board-widget-${props.itemId ?? "preview"}`}>
|
||||
<EnabledAssistantBoardWidget {...props} />
|
||||
</AssistantComposerSurfaceProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const EnabledAssistantBoardWidget = ({
|
||||
width,
|
||||
height,
|
||||
isEditMode,
|
||||
assistantWidgetId,
|
||||
}: WidgetComponentProps<"assistant"> & { assistantWidgetId: string }) => {
|
||||
const EnabledAssistantBoardWidget = ({ width, height, isEditMode }: WidgetComponentProps<"assistant">) => {
|
||||
const board = useRequiredBoard();
|
||||
const assistant = useHomarrAssistant();
|
||||
const preferences = useAssistantPreferences();
|
||||
@@ -70,14 +45,6 @@ const EnabledAssistantBoardWidget = ({
|
||||
threadListItem.updateCustom({ ...threadListItem.getState().custom, modelId });
|
||||
};
|
||||
|
||||
if (assistant.opened) {
|
||||
return <CompactAssistantWidget onOpen={assistant.close} location="panel" />;
|
||||
}
|
||||
|
||||
if (assistant.activeWidgetId !== null && assistant.activeWidgetId !== assistantWidgetId) {
|
||||
return <CompactAssistantWidget onOpen={() => assistant.activateWidget(assistantWidgetId)} location="widget" />;
|
||||
}
|
||||
|
||||
if (width < 300 || height < 280) {
|
||||
return <CompactAssistantWidget onOpen={assistant.open} />;
|
||||
}
|
||||
@@ -108,30 +75,8 @@ const EnabledAssistantBoardWidget = ({
|
||||
);
|
||||
};
|
||||
|
||||
const CompactAssistantWidget = ({
|
||||
onOpen,
|
||||
location = "compact",
|
||||
}: {
|
||||
onOpen: () => void;
|
||||
location?: "compact" | "panel" | "widget";
|
||||
}) => {
|
||||
const CompactAssistantWidget = ({ onOpen }: { onOpen: () => void }) => {
|
||||
const t = useI18n();
|
||||
let title = t("widget.assistant.compact.title");
|
||||
let description = t("widget.assistant.compact.description");
|
||||
let actionLabel = t("widget.assistant.compact.open");
|
||||
let actionIcon = <IconArrowsMaximize size={18} />;
|
||||
|
||||
if (location === "panel") {
|
||||
title = t("widget.assistant.compact.openTitle");
|
||||
description = t("widget.assistant.compact.openDescription");
|
||||
actionLabel = t("widget.assistant.compact.returnToWidget");
|
||||
actionIcon = <IconArrowsMinimize size={18} />;
|
||||
}
|
||||
if (location === "widget") {
|
||||
title = t("widget.assistant.compact.otherWidgetTitle");
|
||||
description = t("widget.assistant.compact.otherWidgetDescription");
|
||||
actionLabel = t("widget.assistant.compact.useHere");
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={classes.widgetPanel}>
|
||||
@@ -142,16 +87,23 @@ const CompactAssistantWidget = ({
|
||||
</ThemeIcon>
|
||||
<Stack className={classes.compactCopy} gap={1}>
|
||||
<Text className={classes.compactTitle} size="sm" fw={700} lineClamp={1}>
|
||||
{title}
|
||||
{t("widget.assistant.compact.title")}
|
||||
</Text>
|
||||
<Text className={classes.compactDescription} size="xs" c="dimmed" lineClamp={2}>
|
||||
{description}
|
||||
{t("widget.assistant.compact.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Tooltip label={actionLabel}>
|
||||
<ActionIcon variant="light" color="red" size={44} radius="md" onClick={onOpen} aria-label={actionLabel}>
|
||||
{actionIcon}
|
||||
<Tooltip label={t("widget.assistant.compact.open")}>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
size={44}
|
||||
radius="md"
|
||||
onClick={onOpen}
|
||||
aria-label={t("widget.assistant.compact.open")}
|
||||
>
|
||||
<IconArrowsMaximize size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
import { Suspense, use } from "react";
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import type { FallbackProps } from "react-error-boundary";
|
||||
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { createModal, modalSizeForm } from "@homarr/modals";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
import type * as WidgetModalsModule from "@homarr/widgets/modals";
|
||||
import type { WidgetEditModalProps } from "@homarr/widgets/modals";
|
||||
|
||||
import type * as IntegrationEditFormModule from "~/components/integration/embedded-integration-edit-form";
|
||||
import type { EmbeddedIntegrationEditFormProps } from "~/components/integration/embedded-integration-edit-form";
|
||||
import type * as IntegrationEditModalModule from "~/components/integration/embedded-integration-edit-modal";
|
||||
import type { EmbeddedIntegrationEditModalProps } from "~/components/integration/embedded-integration-edit-modal";
|
||||
|
||||
let widgetEditModalPromise: Promise<typeof WidgetModalsModule> | undefined;
|
||||
|
||||
@@ -29,38 +29,42 @@ export const loadWidgetEditModal = () => {
|
||||
|
||||
export const preloadWidgetEditModal = () => void loadWidgetEditModal().catch(() => undefined);
|
||||
|
||||
let integrationEditFormPromise: Promise<typeof IntegrationEditFormModule> | undefined;
|
||||
let integrationEditModalPromise: Promise<typeof IntegrationEditModalModule> | undefined;
|
||||
|
||||
const loadIntegrationEditForm = () => {
|
||||
if (integrationEditFormPromise) return integrationEditFormPromise;
|
||||
const loadIntegrationEditModal = () => {
|
||||
if (integrationEditModalPromise) return integrationEditModalPromise;
|
||||
|
||||
const currentPromise = import("~/components/integration/embedded-integration-edit-form");
|
||||
integrationEditFormPromise = currentPromise;
|
||||
const currentPromise = import("~/components/integration/embedded-integration-edit-modal");
|
||||
integrationEditModalPromise = currentPromise;
|
||||
void currentPromise.catch(() => {
|
||||
if (integrationEditFormPromise === currentPromise) integrationEditFormPromise = undefined;
|
||||
if (integrationEditModalPromise === currentPromise) integrationEditModalPromise = undefined;
|
||||
});
|
||||
return currentPromise;
|
||||
};
|
||||
|
||||
const LazyIntegrationEditFormContent = (props: EmbeddedIntegrationEditFormProps) => {
|
||||
const { EmbeddedIntegrationEditForm } = use(loadIntegrationEditForm());
|
||||
return <EmbeddedIntegrationEditForm {...props} />;
|
||||
const LazyIntegrationEditModalContent = (props: {
|
||||
actions: { closeModal: () => void };
|
||||
innerProps: EmbeddedIntegrationEditModalProps;
|
||||
}) => {
|
||||
const { EmbeddedIntegrationEditModal } = use(loadIntegrationEditModal());
|
||||
const Component = EmbeddedIntegrationEditModal.component;
|
||||
return <Component {...props} />;
|
||||
};
|
||||
|
||||
const IntegrationEditErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => (
|
||||
<Center py="xl">
|
||||
<WidgetError
|
||||
error={error}
|
||||
resetErrorBoundary={() => {
|
||||
integrationEditFormPromise = undefined;
|
||||
resetErrorBoundary();
|
||||
}}
|
||||
/>
|
||||
</Center>
|
||||
);
|
||||
|
||||
const LazyIntegrationEditForm = (props: EmbeddedIntegrationEditFormProps) => (
|
||||
<ErrorBoundary FallbackComponent={IntegrationEditErrorFallback}>
|
||||
const LazyIntegrationEditModal = createModal<EmbeddedIntegrationEditModalProps>((props) => (
|
||||
<ErrorBoundary
|
||||
fallbackRender={({ error, resetErrorBoundary }) => (
|
||||
<Center py="xl">
|
||||
<WidgetError
|
||||
error={error}
|
||||
resetErrorBoundary={() => {
|
||||
integrationEditModalPromise = undefined;
|
||||
resetErrorBoundary();
|
||||
}}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Center py="xl">
|
||||
@@ -68,10 +72,15 @@ const LazyIntegrationEditForm = (props: EmbeddedIntegrationEditFormProps) => (
|
||||
</Center>
|
||||
}
|
||||
>
|
||||
<LazyIntegrationEditFormContent {...props} />
|
||||
<LazyIntegrationEditModalContent {...props} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
)).withOptions({
|
||||
defaultTitle: (t) => t("item.edit.tab.integration"),
|
||||
size: modalSizeForm,
|
||||
presentation: "inspector",
|
||||
closeOnClickOutside: true,
|
||||
});
|
||||
|
||||
interface LazyWidgetEditModalContentProps {
|
||||
actions: { closeModal: () => void };
|
||||
@@ -81,12 +90,17 @@ interface LazyWidgetEditModalContentProps {
|
||||
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
|
||||
const { WidgetEditModal } = use(loadWidgetEditModal());
|
||||
const Component = WidgetEditModal.component;
|
||||
const { openModal: openIntegrationEditModal } = useModalAction(LazyIntegrationEditModal);
|
||||
return (
|
||||
<Component
|
||||
{...props}
|
||||
innerProps={{
|
||||
...props.innerProps,
|
||||
integrationEditForm: LazyIntegrationEditForm,
|
||||
onEditIntegration: (integrationId) =>
|
||||
openIntegrationEditModal({
|
||||
integrationId,
|
||||
onSuccess: props.innerProps.onIntegrationSaved,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Group, Stack, Text, ThemeIcon, UnstyledButton } from "@mantine/core";
|
||||
import { Group, Stack, Text, TextInput, ThemeIcon, UnstyledButton } from "@mantine/core";
|
||||
import {
|
||||
IconApps,
|
||||
IconBox,
|
||||
@@ -26,7 +26,7 @@ import type {
|
||||
UniversalCreateActionGroup,
|
||||
UniversalCreateActionKey,
|
||||
} from "./universal-create-actions";
|
||||
import { getUniversalCreateActionDefinitions } from "./universal-create-actions";
|
||||
import { filterAndRankUniversalCreateActions, getUniversalCreateActionDefinitions } from "./universal-create-actions";
|
||||
import type { SetupMetricEntryPoint } from "./setup-analytics";
|
||||
import { useSetupAnalytics } from "./setup-analytics";
|
||||
|
||||
@@ -52,6 +52,7 @@ export const UniversalCreateModal = createModal<UniversalCreateModalProps>(({ ac
|
||||
const router = useRouter();
|
||||
const t = useScopedI18n("universalCreate");
|
||||
const { openModal: openAddBoardModal } = useModalAction(AddBoardModal);
|
||||
const [query, setQuery] = useState("");
|
||||
const trackedOpen = useRef(false);
|
||||
const boardActions = innerProps.boardActions;
|
||||
const entryPoint = innerProps.entryPoint ?? (boardActions ? "board" : "header");
|
||||
@@ -117,43 +118,58 @@ export const UniversalCreateModal = createModal<UniversalCreateModalProps>(({ ac
|
||||
keywords: t(`action.${definition.key}.keywords`).split(" "),
|
||||
}) satisfies RankedUniversalCreateAction,
|
||||
);
|
||||
const orderedActions = resolvedActions.toSorted((left, right) => right.priority - left.priority);
|
||||
const visibleActions = filterAndRankUniversalCreateActions(resolvedActions, query);
|
||||
const groups = ["currentBoard", "library", "boards"] satisfies UniversalCreateActionGroup[];
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{groups.map((group) => {
|
||||
const groupActions = orderedActions.filter((action) => action.group === group);
|
||||
if (groupActions.length === 0) return null;
|
||||
<TextInput
|
||||
data-autofocus
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.currentTarget.value)}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
placeholder={t("search.placeholder")}
|
||||
aria-label={t("search.label")}
|
||||
/>
|
||||
|
||||
return (
|
||||
<Stack key={group} gap={4}>
|
||||
<Text c="dimmed" fw={600} size="xs" tt="uppercase" px="sm">
|
||||
{t(`group.${group}`)}
|
||||
</Text>
|
||||
{groupActions.map((action) => {
|
||||
const Icon = actionIcons[action.key];
|
||||
return (
|
||||
<UnstyledButton key={action.key} className={classes.action} onClick={() => invokeAction(action.key)}>
|
||||
<Group wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon className={classes.actionIcon} variant="light" radius="md">
|
||||
<Icon size={18} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={1}>
|
||||
<Text fw={600} size="sm">
|
||||
{action.name}
|
||||
</Text>
|
||||
<Text c="dimmed" size="xs">
|
||||
{action.description}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
{visibleActions.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="lg">
|
||||
{t("search.noResults")}
|
||||
</Text>
|
||||
) : (
|
||||
groups.map((group) => {
|
||||
const groupActions = visibleActions.filter((action) => action.group === group);
|
||||
if (groupActions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Stack key={group} gap={4}>
|
||||
<Text c="dimmed" fw={600} size="xs" tt="uppercase" px="sm">
|
||||
{t(`group.${group}`)}
|
||||
</Text>
|
||||
{groupActions.map((action) => {
|
||||
const Icon = actionIcons[action.key];
|
||||
return (
|
||||
<UnstyledButton key={action.key} className={classes.action} onClick={() => invokeAction(action.key)}>
|
||||
<Group wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon className={classes.actionIcon} variant="light" radius="md">
|
||||
<Icon size={18} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={1}>
|
||||
<Text fw={600} size="sm">
|
||||
{action.name}
|
||||
</Text>
|
||||
<Text c="dimmed" size="xs">
|
||||
{action.description}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}).withOptions({
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useImperativeHandle, useRef } from "react";
|
||||
import { Alert, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
import type { EmbeddedIntegrationEditFormProps } from "@homarr/widgets/modals";
|
||||
|
||||
import type { EditIntegrationFormHandle } from "~/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form";
|
||||
import { EditIntegrationForm } from "~/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form";
|
||||
|
||||
export type { EmbeddedIntegrationEditFormProps } from "@homarr/widgets/modals";
|
||||
|
||||
export const EmbeddedIntegrationEditForm = ({
|
||||
integrationId,
|
||||
handleRef,
|
||||
onSuccess,
|
||||
}: EmbeddedIntegrationEditFormProps) => {
|
||||
const t = useI18n();
|
||||
const integrationFormRef = useRef<EditIntegrationFormHandle>(null);
|
||||
const {
|
||||
data: integration,
|
||||
isPending,
|
||||
isError,
|
||||
refetch,
|
||||
} = clientApi.integration.byId.useQuery({
|
||||
id: integrationId,
|
||||
});
|
||||
|
||||
useImperativeHandle(
|
||||
handleRef,
|
||||
() => ({
|
||||
submitIfDirty: async () => {
|
||||
if (!integrationFormRef.current?.isDirty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return integrationFormRef.current.submit();
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<Stack gap="sm" aria-busy="true" aria-label={t("item.edit.integration.loading")}>
|
||||
<Skeleton height={36} width="60%" />
|
||||
<Skeleton height={60} />
|
||||
<Skeleton height={120} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !integration) {
|
||||
return (
|
||||
<Alert color="red" title={t("common.error")}>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{t("item.edit.integration.loadError")}</Text>
|
||||
<Button variant="light" color="red" onClick={() => void refetch()}>
|
||||
{t("common.action.tryAgain")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<IntegrationAvatar kind={integration.kind} size="sm" />
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text fw={600} truncate>
|
||||
{integration.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{getIntegrationName(integration.kind)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
||||
{t("item.edit.integration.propagationNotice")}
|
||||
</Alert>
|
||||
<EditIntegrationForm integration={integration} hideButtons formRef={integrationFormRef} onSuccess={onSuccess} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { createModal, modalSizeForm } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
|
||||
import { EditIntegrationForm } from "~/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form";
|
||||
|
||||
export interface EmbeddedIntegrationEditModalProps {
|
||||
integrationId: string;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export const EmbeddedIntegrationEditModal = createModal<EmbeddedIntegrationEditModalProps>(
|
||||
({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const {
|
||||
data: integration,
|
||||
isPending,
|
||||
isError,
|
||||
refetch,
|
||||
} = clientApi.integration.byId.useQuery({
|
||||
id: innerProps.integrationId,
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<Stack gap="sm" aria-busy="true" aria-label={t("item.edit.integration.loading")}>
|
||||
<Skeleton height={36} width="60%" />
|
||||
<Skeleton height={60} />
|
||||
<Skeleton height={120} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !integration) {
|
||||
return (
|
||||
<Alert color="red" title={t("common.error")}>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{t("item.edit.integration.loadError")}</Text>
|
||||
<Button variant="light" color="red" onClick={() => void refetch()}>
|
||||
{t("common.action.tryAgain")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<IntegrationAvatar kind={integration.kind} size="sm" />
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text fw={600} truncate>
|
||||
{integration.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{getIntegrationName(integration.kind)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
||||
{t("item.edit.integration.propagationNotice")}
|
||||
</Alert>
|
||||
<EditIntegrationForm
|
||||
integration={integration}
|
||||
embedded
|
||||
onSuccess={() => {
|
||||
innerProps.onSuccess?.();
|
||||
actions.closeModal();
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
).withOptions({
|
||||
defaultTitle: (t) => t("item.edit.tab.integration"),
|
||||
size: modalSizeForm,
|
||||
presentation: "inspector",
|
||||
closeOnClickOutside: true,
|
||||
});
|
||||
@@ -18,8 +18,7 @@ import { AppAccessControl } from "./app/app-access-control";
|
||||
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
getPaginated: permissionRequiredProcedure
|
||||
.requiresPermission("app-modify-all")
|
||||
getPaginated: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "GET",
|
||||
@@ -51,8 +50,7 @@ export const appRouter = createTRPCRouter({
|
||||
totalCount,
|
||||
};
|
||||
}),
|
||||
all: permissionRequiredProcedure
|
||||
.requiresPermission("app-modify-all")
|
||||
all: protectedProcedure
|
||||
.input(z.void())
|
||||
.output(z.array(selectAppSchema))
|
||||
.meta({
|
||||
@@ -90,16 +88,6 @@ export const appRouter = createTRPCRouter({
|
||||
},
|
||||
})
|
||||
.query(({ ctx, input }) => {
|
||||
if (
|
||||
!ctx.session.user.permissions.includes("app-modify-all") &&
|
||||
!ctx.session.user.permissions.includes("board-modify-all")
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Permission denied",
|
||||
});
|
||||
}
|
||||
|
||||
return ctx.db.query.apps.findMany({
|
||||
where: like(apps.name, `%${input.query}%`),
|
||||
orderBy: asc(apps.name),
|
||||
|
||||
@@ -302,43 +302,16 @@ export const groupRouter = createTRPCRouter({
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
|
||||
|
||||
// Stored exactly as selected - implied permissions are resolved at read time through
|
||||
// getPermissionsWithChildren, so nothing is silently escalated here.
|
||||
const permissions = [...new Set(input.permissions)];
|
||||
await ctx.db.delete(groupPermissions).where(eq(groupPermissions.groupId, input.groupId));
|
||||
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(db, schema) {
|
||||
await db.transaction(async (transaction) => {
|
||||
await transaction.delete(schema.groupPermissions).where(eq(schema.groupPermissions.groupId, input.groupId));
|
||||
if (permissions.length === 0) {
|
||||
return;
|
||||
}
|
||||
await transaction.insert(schema.groupPermissions).values(
|
||||
permissions.map((permission) => ({
|
||||
groupId: input.groupId,
|
||||
permission,
|
||||
})),
|
||||
);
|
||||
});
|
||||
},
|
||||
handleSync(db) {
|
||||
db.transaction((transaction) => {
|
||||
transaction.delete(groupPermissions).where(eq(groupPermissions.groupId, input.groupId)).run();
|
||||
if (permissions.length === 0) {
|
||||
return;
|
||||
}
|
||||
transaction
|
||||
.insert(groupPermissions)
|
||||
.values(
|
||||
permissions.map((permission) => ({
|
||||
groupId: input.groupId,
|
||||
permission,
|
||||
})),
|
||||
)
|
||||
.run();
|
||||
});
|
||||
},
|
||||
});
|
||||
if (input.permissions.length > 0) {
|
||||
await ctx.db.insert(groupPermissions).values(
|
||||
input.permissions.map((permission) => ({
|
||||
groupId: input.groupId,
|
||||
permission,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}),
|
||||
transferOwnership: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { constructIntegrationPermissions } from "@homarr/auth/shared";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { db, eq, inArray, or } from "@homarr/db";
|
||||
import {
|
||||
apps,
|
||||
@@ -8,46 +6,16 @@ import {
|
||||
boardUserPermissions,
|
||||
groupMembers,
|
||||
groups,
|
||||
integrationGroupPermissions,
|
||||
integrationUserPermissions,
|
||||
integrations,
|
||||
invites,
|
||||
medias,
|
||||
searchEngines,
|
||||
users,
|
||||
} from "@homarr/db/schema";
|
||||
import { getAppManagementAccess, getIntegrationManagementAccess } from "@homarr/definitions";
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from "../trpc";
|
||||
|
||||
const getFullAccessIntegrationIdsAsync = async (db: Database, userId: string) => {
|
||||
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, userId),
|
||||
});
|
||||
|
||||
const accessibleIntegrations = await db.query.integrations.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
},
|
||||
with: {
|
||||
userPermissions: {
|
||||
where: eq(integrationUserPermissions.userId, userId),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(
|
||||
integrationGroupPermissions.groupId,
|
||||
groupsOfCurrentUser.map((group) => group.groupId),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return accessibleIntegrations
|
||||
.filter((integration) => constructIntegrationPermissions(integration, null).hasFullAccess)
|
||||
.map((integration) => integration.id);
|
||||
};
|
||||
|
||||
interface HomeStatistic {
|
||||
titleKey: keyof TranslationObject["management"]["page"]["home"]["statistic"];
|
||||
subtitleKey: keyof TranslationObject["management"]["page"]["home"]["statisticLabel"];
|
||||
@@ -124,30 +92,20 @@ export const homeRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
// A card is only shown when the page behind it is reachable, and it counts only what the user is
|
||||
// allowed to see. The rules come from @homarr/definitions so this cannot drift from the pages.
|
||||
const permissions = ctx.session?.user.permissions ?? [];
|
||||
const hasGlobalFullIntegrationAccess = permissions.includes("integration-full-all");
|
||||
const fullAccessIntegrationIds =
|
||||
hasGlobalFullIntegrationAccess || !ctx.session?.user
|
||||
? []
|
||||
: await getFullAccessIntegrationIdsAsync(ctx.db, ctx.session.user.id);
|
||||
const integrationAccess = getIntegrationManagementAccess(permissions, fullAccessIntegrationIds.length > 0);
|
||||
if (integrationAccess.canAccess) {
|
||||
if (ctx.session?.user.permissions.includes("integration-create")) {
|
||||
statistics.push({
|
||||
titleKey: "integration",
|
||||
subtitleKey: "resources",
|
||||
count: integrationAccess.canManageAll ? await db.$count(integrations) : fullAccessIntegrationIds.length,
|
||||
count: await db.$count(integrations),
|
||||
path: "/manage/integrations",
|
||||
});
|
||||
}
|
||||
|
||||
const appAccess = getAppManagementAccess(permissions);
|
||||
if (appAccess.canAccess) {
|
||||
if (ctx.session?.user.permissions.includes("app-create")) {
|
||||
statistics.push({
|
||||
titleKey: "app",
|
||||
subtitleKey: "resources",
|
||||
count: appAccess.canManageAll ? await db.$count(apps) : 0,
|
||||
count: await db.$count(apps),
|
||||
path: "/manage/apps",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { hasQueryAccessToIntegrationsAsync } from "@homarr/auth/server";
|
||||
import { constructIntegrationPermissions } from "@homarr/auth/shared";
|
||||
import { createId, objectEntries } from "@homarr/common";
|
||||
import { decryptSecret, encryptSecret } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { Database, SQL } from "@homarr/db";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, asc, eq, handleTransactionsAsync, inArray, like, or } from "@homarr/db";
|
||||
import {
|
||||
apps,
|
||||
@@ -18,7 +17,7 @@ import {
|
||||
integrationSecrets,
|
||||
integrationUserPermissions,
|
||||
} from "@homarr/db/schema";
|
||||
import type { GroupPermissionKey, IntegrationPermission, IntegrationSecretKind } from "@homarr/definitions";
|
||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
import {
|
||||
getIntegrationKindsByCategory,
|
||||
getPermissionsWithParents,
|
||||
@@ -72,18 +71,80 @@ export const integrationRouter = createTRPCRouter({
|
||||
},
|
||||
})
|
||||
.query(async ({ ctx }) => {
|
||||
return await getAccessibleIntegrationsAsync(ctx);
|
||||
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, ctx.session.user.id),
|
||||
});
|
||||
|
||||
const integrations = await ctx.db.query.integrations.findMany({
|
||||
with: {
|
||||
userPermissions: {
|
||||
where: eq(integrationUserPermissions.userId, ctx.session.user.id),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(
|
||||
integrationGroupPermissions.groupId,
|
||||
groupsOfCurrentUser.map((group) => group.groupId),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
return integrations
|
||||
.map((integration) => {
|
||||
const permissions = constructIntegrationPermissions(integration, ctx.session);
|
||||
|
||||
return {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
permissions,
|
||||
};
|
||||
})
|
||||
.toSorted(
|
||||
(integrationA, integrationB) =>
|
||||
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
||||
);
|
||||
}),
|
||||
allThatSupportSearch: protectedProcedure.query(async ({ ctx }) => {
|
||||
return await getAccessibleIntegrationsAsync(
|
||||
ctx,
|
||||
inArray(
|
||||
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, ctx.session.user.id),
|
||||
});
|
||||
|
||||
const integrationsFromDb = await ctx.db.query.integrations.findMany({
|
||||
with: {
|
||||
userPermissions: {
|
||||
where: eq(integrationUserPermissions.userId, ctx.session.user.id),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(
|
||||
integrationGroupPermissions.groupId,
|
||||
groupsOfCurrentUser.map((group) => group.groupId),
|
||||
),
|
||||
},
|
||||
},
|
||||
where: inArray(
|
||||
integrations.kind,
|
||||
objectEntries(integrationDefs)
|
||||
.filter(([_, integration]) => [...integration.category].includes("search"))
|
||||
.map(([kind, _]) => kind),
|
||||
),
|
||||
);
|
||||
});
|
||||
return integrationsFromDb
|
||||
.map((integration) => {
|
||||
const permissions = constructIntegrationPermissions(integration, ctx.session);
|
||||
|
||||
return {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
permissions,
|
||||
};
|
||||
})
|
||||
.toSorted(
|
||||
(integrationA, integrationB) =>
|
||||
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
||||
);
|
||||
}),
|
||||
allOfGivenCategory: protectedProcedure
|
||||
.input(
|
||||
@@ -92,10 +153,42 @@ export const integrationRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await getAccessibleIntegrationsAsync(
|
||||
ctx,
|
||||
inArray(integrations.kind, getIntegrationKindsByCategory(input.category)),
|
||||
);
|
||||
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, ctx.session.user.id),
|
||||
});
|
||||
|
||||
const intergrationKinds = getIntegrationKindsByCategory(input.category);
|
||||
|
||||
const integrationsFromDb = await ctx.db.query.integrations.findMany({
|
||||
with: {
|
||||
userPermissions: {
|
||||
where: eq(integrationUserPermissions.userId, ctx.session.user.id),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(
|
||||
integrationGroupPermissions.groupId,
|
||||
groupsOfCurrentUser.map((group) => group.groupId),
|
||||
),
|
||||
},
|
||||
},
|
||||
where: inArray(integrations.kind, intergrationKinds),
|
||||
});
|
||||
return integrationsFromDb
|
||||
.map((integration) => {
|
||||
const permissions = constructIntegrationPermissions(integration, ctx.session);
|
||||
|
||||
return {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
permissions,
|
||||
};
|
||||
})
|
||||
.toSorted(
|
||||
(integrationA, integrationB) =>
|
||||
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
||||
);
|
||||
}),
|
||||
search: protectedProcedure
|
||||
.meta({
|
||||
@@ -112,29 +205,11 @@ export const integrationRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, ctx.session.user.id),
|
||||
});
|
||||
|
||||
const integrationsFromDb = await ctx.db.query.integrations.findMany({
|
||||
return await ctx.db.query.integrations.findMany({
|
||||
where: like(integrations.name, `%${input.query}%`),
|
||||
orderBy: asc(integrations.name),
|
||||
with: getIntegrationAccessRelationsAsync(
|
||||
ctx.session.user.id,
|
||||
groupsOfCurrentUser.map((group) => group.groupId),
|
||||
),
|
||||
limit: input.limit,
|
||||
});
|
||||
|
||||
return integrationsFromDb
|
||||
.filter((integration) => hasIntegrationQueryAccess(ctx.session, integration))
|
||||
.toSorted(
|
||||
(integrationA, integrationB) =>
|
||||
integrationA.name.localeCompare(integrationB.name) || integrationA.id.localeCompare(integrationB.id),
|
||||
)
|
||||
.slice(0, input.limit)
|
||||
.map(
|
||||
({ userPermissions: _userPermissions, groupPermissions: _groupPermissions, ...integration }) => integration,
|
||||
);
|
||||
}),
|
||||
// This is used to get the integrations by their ids it's public because it's needed to get integrations data in the boards
|
||||
byIds: publicProcedure.input(z.array(z.string())).query(async ({ ctx, input }) => {
|
||||
@@ -699,66 +774,6 @@ export const integrationRouter = createTRPCRouter({
|
||||
}),
|
||||
});
|
||||
|
||||
const globalIntegrationPermissions = [
|
||||
"integration-use-all",
|
||||
"integration-interact-all",
|
||||
"integration-full-all",
|
||||
] satisfies GroupPermissionKey[];
|
||||
|
||||
const hasGlobalIntegrationAccess = (session: Session | null) =>
|
||||
globalIntegrationPermissions.some((permission) => session?.user.permissions.includes(permission) ?? false);
|
||||
|
||||
const hasIntegrationQueryAccess = (
|
||||
session: Session | null,
|
||||
integration: {
|
||||
userPermissions: { permission: IntegrationPermission }[];
|
||||
groupPermissions: { permission: IntegrationPermission }[];
|
||||
},
|
||||
) => hasGlobalIntegrationAccess(session) || constructIntegrationPermissions(integration, session).hasUseAccess;
|
||||
|
||||
const getIntegrationAccessRelationsAsync = (userId: string, groupIds: string[]) => ({
|
||||
userPermissions: {
|
||||
where: eq(integrationUserPermissions.userId, userId),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(integrationGroupPermissions.groupId, groupIds),
|
||||
},
|
||||
});
|
||||
|
||||
const getAccessibleIntegrationsAsync = async (ctx: { db: Database; session: Session }, where?: SQL) => {
|
||||
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, ctx.session.user.id),
|
||||
});
|
||||
|
||||
const integrationsFromDb = await ctx.db.query.integrations.findMany({
|
||||
where,
|
||||
with: getIntegrationAccessRelationsAsync(
|
||||
ctx.session.user.id,
|
||||
groupsOfCurrentUser.map((group) => group.groupId),
|
||||
),
|
||||
});
|
||||
|
||||
return integrationsFromDb
|
||||
.filter((integration) => hasIntegrationQueryAccess(ctx.session, integration))
|
||||
.map((integration) => {
|
||||
const permissions = constructIntegrationPermissions(integration, ctx.session);
|
||||
|
||||
return {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
permissions,
|
||||
};
|
||||
})
|
||||
.toSorted(
|
||||
(integrationA, integrationB) =>
|
||||
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind) ||
|
||||
integrationA.name.localeCompare(integrationB.name) ||
|
||||
integrationA.id.localeCompare(integrationB.id),
|
||||
);
|
||||
};
|
||||
|
||||
interface IntegrationRouterContext {
|
||||
db: Database;
|
||||
session: Parameters<typeof hasQueryAccessToIntegrationsAsync>[2];
|
||||
|
||||
@@ -11,10 +11,6 @@ import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
|
||||
|
||||
const logger = createLogger({ module: "logRouter" });
|
||||
|
||||
type LogSubscriptionEvent =
|
||||
| { type: "history"; messages: LoggerMessage[] }
|
||||
| { type: "message"; message: LoggerMessage };
|
||||
|
||||
export const logRouter = createTRPCRouter({
|
||||
subscribe: permissionRequiredProcedure
|
||||
.requiresPermission("other-view-logs")
|
||||
@@ -24,61 +20,15 @@ export const logRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.subscription(({ input }) => {
|
||||
return observable<LogSubscriptionEvent>((emit) => {
|
||||
let isClosed = false;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let isHistoryLoaded = false;
|
||||
const bufferedMessages: LoggerMessage[] = [];
|
||||
|
||||
const onMessage = (message: LoggerMessage) => {
|
||||
if (!input.levels.includes(message.level)) return;
|
||||
|
||||
if (!isHistoryLoaded) {
|
||||
bufferedMessages.push(message);
|
||||
return;
|
||||
}
|
||||
|
||||
emit.next({ type: "message", message });
|
||||
};
|
||||
|
||||
void loggingChannel
|
||||
.subscribeAsync(onMessage)
|
||||
.then(async (unsubscribeFromLogging) => {
|
||||
if (isClosed) {
|
||||
unsubscribeFromLogging();
|
||||
return;
|
||||
}
|
||||
|
||||
unsubscribe = unsubscribeFromLogging;
|
||||
logger.info("A tRPC client has connected to the logging procedure");
|
||||
|
||||
const history = (await loggingChannel.getRecentAsync()).filter((message) =>
|
||||
input.levels.includes(message.level),
|
||||
);
|
||||
if (isClosed) return;
|
||||
|
||||
emit.next({ type: "history", messages: history });
|
||||
|
||||
const historyIds = new Set(history.map((message) => message.id));
|
||||
isHistoryLoaded = true;
|
||||
for (const message of bufferedMessages) {
|
||||
if (!historyIds.has(message.id)) {
|
||||
emit.next({ type: "message", message });
|
||||
}
|
||||
}
|
||||
bufferedMessages.length = 0;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
if (!isClosed) {
|
||||
emit.error(error);
|
||||
}
|
||||
});
|
||||
return observable<LoggerMessage>((emit) => {
|
||||
const unsubscribe = loggingChannel.subscribe((data) => {
|
||||
if (!input.levels.includes(data.level)) return;
|
||||
emit.next(data);
|
||||
});
|
||||
logger.info("A tRPC client has connected to the logging procedure");
|
||||
|
||||
return () => {
|
||||
isClosed = true;
|
||||
unsubscribe?.();
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("all should return all apps", () => {
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(["app-modify-all"]),
|
||||
session: createDefaultSession(),
|
||||
});
|
||||
|
||||
await db.insert(apps).values([
|
||||
@@ -74,20 +74,6 @@ describe("all should return all apps", () => {
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("UNAUTHORIZED");
|
||||
});
|
||||
test("should throw FORBIDDEN without app-modify-all permission", async () => {
|
||||
// Arrange
|
||||
const caller = appRouter.createCaller({
|
||||
db: createDb(),
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(),
|
||||
});
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await caller.all();
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPaginated should return apps to authenticated users", () => {
|
||||
@@ -101,12 +87,12 @@ describe("getPaginated should return apps to authenticated users", () => {
|
||||
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).rejects.toThrow("UNAUTHORIZED");
|
||||
});
|
||||
|
||||
test("should return apps with app-modify-all permission", async () => {
|
||||
test("should return apps without board permissions", async () => {
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(["app-modify-all"]),
|
||||
session: createDefaultSession(),
|
||||
});
|
||||
await db.insert(apps).values({ id: "1", name: "Homarr", iconUrl: "https://homarr.dev/icon.svg" });
|
||||
|
||||
@@ -115,43 +101,6 @@ describe("getPaginated should return apps to authenticated users", () => {
|
||||
items: [{ id: "1" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw FORBIDDEN without app-modify-all permission", async () => {
|
||||
const caller = appRouter.createCaller({
|
||||
db: createDb(),
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(),
|
||||
});
|
||||
|
||||
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).rejects.toThrow("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("search should require app-modify-all or board-modify-all permission", () => {
|
||||
test("should throw FORBIDDEN without app-modify-all or board-modify-all permission", async () => {
|
||||
const caller = appRouter.createCaller({
|
||||
db: createDb(),
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(),
|
||||
});
|
||||
|
||||
await expect(caller.search({ query: "Homarr" })).rejects.toThrow("Permission denied");
|
||||
});
|
||||
|
||||
test("should return apps with board-modify-all permission", async () => {
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(["board-modify-all"]),
|
||||
});
|
||||
await db.insert(apps).values({ id: "1", name: "Homarr", iconUrl: "https://homarr.dev/icon.svg" });
|
||||
|
||||
const result = await caller.search({ query: "Homarr" });
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.id).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("byId should return an app by id", () => {
|
||||
|
||||
@@ -449,165 +449,6 @@ describe("savePermissions should save permissions for group", () => {
|
||||
expect(permissions.map(({ permission }) => permission)).toEqual(["integration-use-all", "board-full-all"]);
|
||||
});
|
||||
|
||||
test("with a standalone create permission it should not escalate to its parent permission", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.savePermissions({
|
||||
groupId,
|
||||
permissions: ["board-create"],
|
||||
});
|
||||
|
||||
// Assert
|
||||
const permissions = await db.query.groupPermissions.findMany({
|
||||
where: eq(groupPermissions.groupId, groupId),
|
||||
});
|
||||
|
||||
expect(permissions.map(({ permission }) => permission)).toEqual(["board-create"]);
|
||||
});
|
||||
|
||||
test.each([["app-create"], ["integration-create"], ["search-engine-create"], ["media-upload"]] as const)(
|
||||
"with %s alone it should persist exactly that permission",
|
||||
async (permission) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.savePermissions({ groupId, permissions: [permission] });
|
||||
|
||||
// Assert
|
||||
const permissions = await db.query.groupPermissions.findMany({
|
||||
where: eq(groupPermissions.groupId, groupId),
|
||||
});
|
||||
|
||||
expect(permissions.map(({ permission: key }) => key)).toEqual([permission]);
|
||||
},
|
||||
);
|
||||
|
||||
test("with a create permission and its parent it should persist exactly two rows", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.savePermissions({
|
||||
groupId,
|
||||
permissions: ["app-create", "app-modify-all"],
|
||||
});
|
||||
|
||||
// Assert
|
||||
const permissions = await db.query.groupPermissions.findMany({
|
||||
where: eq(groupPermissions.groupId, groupId),
|
||||
});
|
||||
|
||||
expect(permissions.length).toBe(2);
|
||||
expect(permissions.map(({ permission }) => permission).toSorted()).toEqual(["app-create", "app-modify-all"]);
|
||||
});
|
||||
|
||||
test("with duplicate permissions it should persist each permission only once", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.savePermissions({
|
||||
groupId,
|
||||
permissions: ["board-view-all", "board-view-all", "board-modify-all"],
|
||||
});
|
||||
|
||||
// Assert
|
||||
const permissions = await db.query.groupPermissions.findMany({
|
||||
where: eq(groupPermissions.groupId, groupId),
|
||||
});
|
||||
|
||||
expect(permissions.map(({ permission }) => permission).toSorted()).toEqual(["board-modify-all", "board-view-all"]);
|
||||
});
|
||||
|
||||
test("saving the same permissions twice should be idempotent", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.savePermissions({ groupId, permissions: ["board-create", "media-upload"] });
|
||||
await caller.savePermissions({ groupId, permissions: ["board-create", "media-upload"] });
|
||||
|
||||
// Assert
|
||||
const permissions = await db.query.groupPermissions.findMany({
|
||||
where: eq(groupPermissions.groupId, groupId),
|
||||
});
|
||||
|
||||
expect(permissions.map(({ permission }) => permission).toSorted()).toEqual(["board-create", "media-upload"]);
|
||||
});
|
||||
|
||||
test("with an empty permission list it should clear all existing permissions", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
await db.insert(groupPermissions).values([
|
||||
{ groupId, permission: "admin" },
|
||||
{ groupId, permission: "board-full-all" },
|
||||
]);
|
||||
|
||||
// Act
|
||||
await caller.savePermissions({
|
||||
groupId,
|
||||
permissions: [],
|
||||
});
|
||||
|
||||
// Assert
|
||||
const permissions = await db.query.groupPermissions.findMany({
|
||||
where: eq(groupPermissions.groupId, groupId),
|
||||
});
|
||||
|
||||
expect(permissions.length).toBe(0);
|
||||
});
|
||||
|
||||
test("with non existing group it should throw not found error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
|
||||
@@ -58,171 +58,6 @@ describe("all should return all integrations", () => {
|
||||
expect(result[1]!.kind).toBe("homeAssistant");
|
||||
expect(result.every(({ permissions }) => permissions.hasFullAccess && permissions.hasUseAccess)).toBe(true);
|
||||
});
|
||||
|
||||
test("with integration full access and no per-integration rows should return all integrations with full access", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-full-all"]),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values([
|
||||
{
|
||||
id: "1",
|
||||
name: "Home assistant",
|
||||
kind: "homeAssistant",
|
||||
url: "http://homeassist.local",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Home plex server",
|
||||
kind: "plex",
|
||||
url: "http://plex.local",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await caller.all();
|
||||
expect(result).toHaveLength(2);
|
||||
for (const integration of result) {
|
||||
expect(integration.permissions.hasFullAccess).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("with integration use access should return all integrations", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-use-all"]),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values([
|
||||
{
|
||||
id: "1",
|
||||
name: "Home assistant",
|
||||
kind: "homeAssistant",
|
||||
url: "http://homeassist.local",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Home plex server",
|
||||
kind: "plex",
|
||||
url: "http://plex.local",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await caller.all();
|
||||
expect(result.length).toBe(2);
|
||||
});
|
||||
|
||||
test("without global integration access should only return integrations with use access", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values([
|
||||
{
|
||||
id: "1",
|
||||
name: "Home assistant",
|
||||
kind: "homeAssistant",
|
||||
url: "http://homeassist.local",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Home plex server",
|
||||
kind: "plex",
|
||||
url: "http://plex.local",
|
||||
},
|
||||
]);
|
||||
await db.insert(users).values({ id: defaultUserId });
|
||||
await db.insert(integrationUserPermissions).values({
|
||||
integrationId: "1",
|
||||
userId: defaultUserId,
|
||||
permission: "use",
|
||||
});
|
||||
|
||||
const result = await caller.all();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.id).toBe("1");
|
||||
expect(result[0]!.permissions.hasUseAccess).toBe(true);
|
||||
});
|
||||
|
||||
test("with a per-integration interact grant should return the integration with use access", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values([
|
||||
{
|
||||
id: "1",
|
||||
name: "Home assistant",
|
||||
kind: "homeAssistant",
|
||||
url: "http://homeassist.local",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Home plex server",
|
||||
kind: "plex",
|
||||
url: "http://plex.local",
|
||||
},
|
||||
]);
|
||||
await db.insert(users).values({ id: defaultUserId });
|
||||
await db.insert(integrationUserPermissions).values({
|
||||
integrationId: "1",
|
||||
userId: defaultUserId,
|
||||
permission: "interact",
|
||||
});
|
||||
|
||||
const result = await caller.all();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.id).toBe("1");
|
||||
expect(result[0]!.permissions.hasUseAccess).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("search should return accessible integrations", () => {
|
||||
test("without global integration access should only return integrations with use access as plain rows", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values([
|
||||
{
|
||||
id: "1",
|
||||
name: "Home assistant",
|
||||
kind: "homeAssistant",
|
||||
url: "http://homeassist.local",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Home plex server",
|
||||
kind: "plex",
|
||||
url: "http://plex.local",
|
||||
},
|
||||
]);
|
||||
await db.insert(users).values({ id: defaultUserId });
|
||||
await db.insert(integrationUserPermissions).values({
|
||||
integrationId: "1",
|
||||
userId: defaultUserId,
|
||||
permission: "use",
|
||||
});
|
||||
|
||||
const result = await caller.search({ query: "Home" });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.id).toBe("1");
|
||||
expect(result[0]).not.toHaveProperty("userPermissions");
|
||||
expect(result[0]).not.toHaveProperty("groupPermissions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mediaRequestSearchTargets should return accessible media search integrations", () => {
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import type { LoggerMessage } from "@homarr/redis";
|
||||
|
||||
import { logRouter } from "../log";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getRecentAsync: vi.fn(),
|
||||
subscribeAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/redis", () => ({
|
||||
loggingChannel: {
|
||||
getRecentAsync: mocks.getRecentAsync,
|
||||
subscribeAsync: mocks.subscribeAsync,
|
||||
},
|
||||
}));
|
||||
|
||||
const session = {
|
||||
user: {
|
||||
id: "log-viewer",
|
||||
permissions: ["other-view-logs"],
|
||||
colorScheme: "light",
|
||||
},
|
||||
expires: new Date(0).toISOString(),
|
||||
} satisfies Session;
|
||||
|
||||
const createMessage = (id: string, timestamp: number): LoggerMessage => ({
|
||||
id,
|
||||
timestamp: new Date(timestamp),
|
||||
level: "info",
|
||||
message: id,
|
||||
});
|
||||
|
||||
describe("log.subscribe", () => {
|
||||
test("emits history before buffered live messages without duplicates", async () => {
|
||||
const historyDeferred = Promise.withResolvers<LoggerMessage[]>();
|
||||
let onMessage: ((message: LoggerMessage) => void) | undefined;
|
||||
const unsubscribe = vi.fn();
|
||||
|
||||
mocks.subscribeAsync.mockImplementation(async (callback: (message: LoggerMessage) => void) => {
|
||||
onMessage = callback;
|
||||
return unsubscribe;
|
||||
});
|
||||
mocks.getRecentAsync.mockReturnValue(historyDeferred.promise);
|
||||
|
||||
const caller = logRouter.createCaller({
|
||||
db: null as never,
|
||||
deviceType: undefined,
|
||||
session,
|
||||
});
|
||||
const subscription = await caller.subscribe({ levels: ["info"] });
|
||||
const events: object[] = [];
|
||||
const observer = subscription.subscribe({
|
||||
next(event) {
|
||||
events.push(event);
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(mocks.getRecentAsync).toHaveBeenCalledOnce());
|
||||
|
||||
const retainedMessage = createMessage("retained", 1);
|
||||
const liveMessage = createMessage("live", 2);
|
||||
onMessage?.(retainedMessage);
|
||||
onMessage?.(liveMessage);
|
||||
historyDeferred.resolve([retainedMessage]);
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(events).toEqual([
|
||||
{ type: "history", messages: [retainedMessage] },
|
||||
{ type: "message", message: liveMessage },
|
||||
]),
|
||||
);
|
||||
|
||||
observer.unsubscribe();
|
||||
expect(unsubscribe).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,6 @@
|
||||
import z from "zod/v4";
|
||||
|
||||
export const logLevels = ["error", "warn", "info", "debug"] as const;
|
||||
export type LogLevel = (typeof logLevels)[number];
|
||||
|
||||
export const loggerMessageSchema = z.object({
|
||||
id: z.string(),
|
||||
timestamp: z.date(),
|
||||
message: z.string(),
|
||||
level: z.enum(logLevels),
|
||||
});
|
||||
export type LoggerMessage = z.infer<typeof loggerMessageSchema>;
|
||||
|
||||
export const LOG_HISTORY_MAX_ENTRIES = 5_000;
|
||||
export const LOG_HISTORY_KEY = "list:logging:history";
|
||||
export const LOG_PUBLISH_CHANNEL = "pubSub:logging";
|
||||
|
||||
export const logLevelConfiguration = {
|
||||
error: {
|
||||
prefix: "🔴",
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { stringify } from "superjson";
|
||||
import superjson from "superjson";
|
||||
import Transport from "winston-transport";
|
||||
|
||||
import type { RedisClient } from "../../redis/client";
|
||||
import { createRedisClient } from "../../redis/client";
|
||||
import type { LoggerMessage } from "../constants";
|
||||
import { LOG_HISTORY_KEY, LOG_HISTORY_MAX_ENTRIES, LOG_PUBLISH_CHANNEL } from "../constants";
|
||||
|
||||
const messageSymbol = Symbol.for("message");
|
||||
const levelSymbol = Symbol.for("level");
|
||||
@@ -16,41 +13,32 @@ const levelSymbol = Symbol.for("level");
|
||||
//
|
||||
export class RedisTransport extends Transport {
|
||||
private redis: RedisClient | null = null;
|
||||
public static readonly publishChannel = "pubSub:logging";
|
||||
|
||||
/**
|
||||
* Retain the log in a bounded Redis list and publish it to live subscribers.
|
||||
* Log the info to the Redis channel
|
||||
*/
|
||||
log(info: { [messageSymbol]: string; [levelSymbol]: LoggerMessage["level"] }, callback: () => void) {
|
||||
log(info: { [messageSymbol]: string; [levelSymbol]: string }, callback: () => void) {
|
||||
setImmediate(() => {
|
||||
this.emit("logged", info);
|
||||
});
|
||||
|
||||
const message: LoggerMessage = {
|
||||
id: randomUUID(),
|
||||
timestamp: new Date(),
|
||||
message: info[messageSymbol],
|
||||
level: info[levelSymbol],
|
||||
};
|
||||
const serializedMessage = stringify(message);
|
||||
// Is only initialized here because it did not work when initialized in the constructor or outside the class
|
||||
this.redis ??= createRedisClient();
|
||||
|
||||
callback();
|
||||
|
||||
void Promise.resolve()
|
||||
this.redis
|
||||
.publish(
|
||||
RedisTransport.publishChannel,
|
||||
superjson.stringify({
|
||||
message: info[messageSymbol],
|
||||
level: info[levelSymbol],
|
||||
}),
|
||||
)
|
||||
.then(() => {
|
||||
// Is only initialized here because it did not work when initialized in the constructor or outside the class
|
||||
if (!this.redis) {
|
||||
this.redis = createRedisClient();
|
||||
this.redis.options.maxRetriesPerRequest = 1;
|
||||
this.redis.on("error", () => undefined);
|
||||
}
|
||||
|
||||
return this.redis
|
||||
.multi()
|
||||
.lpush(LOG_HISTORY_KEY, serializedMessage)
|
||||
.ltrim(LOG_HISTORY_KEY, 0, LOG_HISTORY_MAX_ENTRIES - 1)
|
||||
.publish(LOG_PUBLISH_CHANNEL, serializedMessage)
|
||||
.exec();
|
||||
callback();
|
||||
})
|
||||
.catch(() => undefined);
|
||||
.catch(() => {
|
||||
// Ignore errors
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -117,139 +117,3 @@ export const groupPermissionKeys = objectKeys(groupPermissions).reduce((acc, key
|
||||
}
|
||||
return acc;
|
||||
}, [] as GroupPermissionKey[]);
|
||||
|
||||
/**
|
||||
* Describes how each permission category is presented in the group permission matrix UI.
|
||||
*
|
||||
* `levels` is an ordered escalation of access: every level implies the one below it through
|
||||
* {@link groupPermissionParents}, so picking level N grants everything up to N.
|
||||
*
|
||||
* `capabilities` are the keys that are NOT part of that ladder - "may add new resources", "may
|
||||
* upload", "may put any app on a board". They can be granted on their own (e.g. "members may create
|
||||
* their own boards but not see anyone else's") and some levels already imply them; use
|
||||
* {@link isCapabilityImpliedByLevel} to detect that instead of hard-coding it.
|
||||
*
|
||||
* Only keys that genuinely imply each other may share a ladder. `app-modify-all` implies
|
||||
* `app-create` but NOT `app-use-all` (only `app-full-all` does), so `app-use-all` is a capability -
|
||||
* putting it in the ladder would make picking "Modify" silently grant use of every app.
|
||||
*
|
||||
* Together, `levels` and `capabilities` cover every key in {@link groupPermissionKeys}, so the
|
||||
* matrix is a lossless representation of a group's permissions.
|
||||
*/
|
||||
export const permissionMatrix = {
|
||||
board: { levels: ["board-view-all", "board-modify-all", "board-full-all"], capabilities: ["board-create"] },
|
||||
app: { levels: ["app-modify-all", "app-full-all"], capabilities: ["app-use-all", "app-create"] },
|
||||
integration: {
|
||||
levels: ["integration-use-all", "integration-interact-all", "integration-full-all"],
|
||||
capabilities: ["integration-create"],
|
||||
},
|
||||
"search-engine": {
|
||||
levels: ["search-engine-modify-all", "search-engine-full-all"],
|
||||
capabilities: ["search-engine-create"],
|
||||
},
|
||||
media: { levels: ["media-view-all", "media-full-all"], capabilities: ["media-upload"] },
|
||||
other: { levels: ["other-view-logs"], capabilities: [] },
|
||||
admin: { levels: ["admin"], capabilities: [] },
|
||||
} as const satisfies Record<
|
||||
string,
|
||||
{ levels: readonly GroupPermissionKey[]; capabilities: readonly GroupPermissionKey[] }
|
||||
>;
|
||||
|
||||
export type PermissionMatrixCategory = keyof typeof permissionMatrix;
|
||||
|
||||
export interface PermissionMatrixCategoryState {
|
||||
/** 0 = no access, N = every key up to `permissionMatrix[category].levels[N - 1]` */
|
||||
level: number;
|
||||
/** The category's granted capability keys, including the ones the level already implies */
|
||||
capabilities: readonly GroupPermissionKey[];
|
||||
}
|
||||
|
||||
export type PermissionMatrixState = Record<PermissionMatrixCategory, PermissionMatrixCategoryState>;
|
||||
|
||||
/**
|
||||
* Whether the given level already grants a capability through permission inheritance.
|
||||
* For example board level 3 (`board-full-all`) implies `board-create`.
|
||||
*/
|
||||
export const isCapabilityImpliedByLevel = (
|
||||
category: PermissionMatrixCategory,
|
||||
capability: GroupPermissionKey,
|
||||
level: number,
|
||||
) => {
|
||||
if (level <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return getPermissionsWithChildren([...permissionMatrix[category].levels.slice(0, level)]).includes(capability);
|
||||
};
|
||||
|
||||
export const permissionsToMatrixState = (permissions: GroupPermissionKey[]): PermissionMatrixState => {
|
||||
const permissionSet = new Set(permissions);
|
||||
|
||||
return objectKeys(permissionMatrix).reduce((acc, category) => {
|
||||
let level = 0;
|
||||
permissionMatrix[category].levels.forEach((key, index) => {
|
||||
if (permissionSet.has(key)) {
|
||||
level = index + 1;
|
||||
}
|
||||
});
|
||||
|
||||
acc[category] = {
|
||||
level,
|
||||
capabilities: permissionMatrix[category].capabilities.filter(
|
||||
(capability) => permissionSet.has(capability) || isCapabilityImpliedByLevel(category, capability, level),
|
||||
),
|
||||
};
|
||||
return acc;
|
||||
}, {} as PermissionMatrixState);
|
||||
};
|
||||
|
||||
export const matrixStateToPermissions = (state: PermissionMatrixState): GroupPermissionKey[] => {
|
||||
return objectKeys(permissionMatrix).reduce((acc, category) => {
|
||||
const { level, capabilities } = state[category];
|
||||
acc.push(...permissionMatrix[category].levels.slice(0, level));
|
||||
// Skip capabilities the chosen level already implies, so the stored permissions stay minimal
|
||||
// and a state -> permissions -> state round-trip is exact.
|
||||
acc.push(...capabilities.filter((capability) => !isCapabilityImpliedByLevel(category, capability, level)));
|
||||
return acc;
|
||||
}, [] as GroupPermissionKey[]);
|
||||
};
|
||||
|
||||
export interface ManagementSectionAccess {
|
||||
/** May see and manage every resource of the section */
|
||||
canManageAll: boolean;
|
||||
/** May add new resources to the section */
|
||||
canCreate: boolean;
|
||||
/** May open the section at all */
|
||||
canAccess: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A management section is reachable when the user can do at least one thing in it. Both rules live
|
||||
* here so the navigation, the pages themselves and the management home statistics cannot drift
|
||||
* apart - each of them consumes the same function.
|
||||
*/
|
||||
export const getAppManagementAccess = (permissions: readonly GroupPermissionKey[]): ManagementSectionAccess => {
|
||||
const canManageAll = permissions.includes("app-modify-all");
|
||||
const canCreate = permissions.includes("app-create");
|
||||
|
||||
return { canManageAll, canCreate, canAccess: canManageAll || canCreate };
|
||||
};
|
||||
|
||||
/**
|
||||
* `hasDelegatedFullAccess` is whether the user was granted full access to at least one specific
|
||||
* integration; it has to be looked up per request, so the caller resolves it.
|
||||
*/
|
||||
export const getIntegrationManagementAccess = (
|
||||
permissions: readonly GroupPermissionKey[],
|
||||
hasDelegatedFullAccess: boolean,
|
||||
): ManagementSectionAccess & { hasDelegatedFullAccess: boolean } => {
|
||||
const canManageAll = permissions.includes("integration-full-all");
|
||||
const canCreate = permissions.includes("integration-create");
|
||||
|
||||
return {
|
||||
canManageAll,
|
||||
canCreate,
|
||||
hasDelegatedFullAccess,
|
||||
canAccess: canManageAll || canCreate || hasDelegatedFullAccess,
|
||||
};
|
||||
};
|
||||
@@ -1,28 +1,7 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { GroupPermissionKey, PermissionMatrixState } from "../permissions";
|
||||
import {
|
||||
getAppManagementAccess,
|
||||
getIntegrationManagementAccess,
|
||||
getPermissionsWithChildren,
|
||||
getPermissionsWithParents,
|
||||
groupPermissionKeys,
|
||||
isCapabilityImpliedByLevel,
|
||||
matrixStateToPermissions,
|
||||
permissionMatrix,
|
||||
permissionsToMatrixState,
|
||||
} from "../permissions";
|
||||
|
||||
const emptyState = (overrides: Partial<PermissionMatrixState> = {}): PermissionMatrixState => ({
|
||||
board: { level: 0, capabilities: [] },
|
||||
app: { level: 0, capabilities: [] },
|
||||
integration: { level: 0, capabilities: [] },
|
||||
"search-engine": { level: 0, capabilities: [] },
|
||||
media: { level: 0, capabilities: [] },
|
||||
other: { level: 0, capabilities: [] },
|
||||
admin: { level: 0, capabilities: [] },
|
||||
...overrides,
|
||||
});
|
||||
import type { GroupPermissionKey } from "../permissions";
|
||||
import { getPermissionsWithChildren, getPermissionsWithParents } from "../permissions";
|
||||
|
||||
describe("getPermissionsWithParents should return the correct permissions", () => {
|
||||
test.each([
|
||||
@@ -66,204 +45,3 @@ describe("getPermissionsWithChildren should return the correct permissions", ()
|
||||
expect(getPermissionsWithChildren(input)).toEqual(expect.arrayContaining(expectedOutput));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the matrix must be able to represent every permission", () => {
|
||||
test("every group permission key is covered by exactly one category", () => {
|
||||
const covered = Object.values(permissionMatrix).flatMap(({ levels, capabilities }) => [...levels, ...capabilities]);
|
||||
expect(covered.toSorted()).toEqual(groupPermissionKeys.toSorted());
|
||||
});
|
||||
|
||||
test.each(groupPermissionKeys.map((key) => [key]))("expect %s to survive a round-trip on its own", (key) => {
|
||||
expect(matrixStateToPermissions(permissionsToMatrixState([key]))).toContain(key);
|
||||
});
|
||||
|
||||
test("every ladder is a real chain, so no level can fabricate a sibling permission", () => {
|
||||
Object.values(permissionMatrix).forEach(({ levels }) => {
|
||||
levels.forEach((key, index) => {
|
||||
// Selecting a level must never grant more than that level's key already implies.
|
||||
const implied = getPermissionsWithChildren([key]);
|
||||
expect(levels.slice(0, index + 1).every((lower) => implied.includes(lower))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("permissionsToMatrixState should resolve the highest present level per category", () => {
|
||||
test("resolves the highest present level key", () => {
|
||||
expect(permissionsToMatrixState(["board-view-all", "board-modify-all"]).board.level).toBe(2);
|
||||
});
|
||||
|
||||
test("resolves the admin category", () => {
|
||||
expect(permissionsToMatrixState(["admin"]).admin.level).toBe(1);
|
||||
});
|
||||
|
||||
test("resolves an empty selection to all zero", () => {
|
||||
const state = permissionsToMatrixState([]);
|
||||
expect(Object.values(state).every(({ level, capabilities }) => level === 0 && capabilities.length === 0)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("resolves a standalone create key without granting any level", () => {
|
||||
expect(permissionsToMatrixState(["board-create"]).board).toEqual({ level: 0, capabilities: ["board-create"] });
|
||||
});
|
||||
|
||||
test("marks a capability as granted when the level implies it", () => {
|
||||
expect(permissionsToMatrixState(["board-full-all"]).board.capabilities).toEqual(["board-create"]);
|
||||
expect(permissionsToMatrixState(["app-modify-all"]).app.capabilities).toEqual(["app-create"]);
|
||||
expect(permissionsToMatrixState(["media-full-all"]).media.capabilities).toEqual(["media-upload"]);
|
||||
});
|
||||
|
||||
test("does not mark a capability as granted for a level that does not imply it", () => {
|
||||
expect(permissionsToMatrixState(["board-modify-all"]).board.capabilities).toEqual([]);
|
||||
expect(permissionsToMatrixState(["integration-interact-all"]).integration.capabilities).toEqual([]);
|
||||
});
|
||||
|
||||
test("app-use-all is a capability, not a level, because app-modify-all does not imply it", () => {
|
||||
// Regression: modelling use as the level below modify made picking Modify silently grant the
|
||||
// use of every app, which the permission graph does not do.
|
||||
const state = permissionsToMatrixState(["app-modify-all"]);
|
||||
expect(state.app).toEqual({ level: 1, capabilities: ["app-create"] });
|
||||
expect(matrixStateToPermissions(state)).toEqual(["app-modify-all"]);
|
||||
});
|
||||
|
||||
test("app-full-all implies both app capabilities", () => {
|
||||
expect(permissionsToMatrixState(["app-full-all"]).app).toEqual({
|
||||
level: 2,
|
||||
capabilities: ["app-use-all", "app-create"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCapabilityImpliedByLevel", () => {
|
||||
test.each([
|
||||
["board", "board-create", 0, false],
|
||||
["board", "board-create", 1, false],
|
||||
["board", "board-create", 2, false],
|
||||
["board", "board-create", 3, true],
|
||||
["app", "app-create", 1, true],
|
||||
["app", "app-use-all", 1, false],
|
||||
["app", "app-use-all", 2, true],
|
||||
["integration", "integration-create", 2, false],
|
||||
["integration", "integration-create", 3, true],
|
||||
["search-engine", "search-engine-create", 1, true],
|
||||
["media", "media-upload", 1, false],
|
||||
["media", "media-upload", 2, true],
|
||||
] satisfies [keyof typeof permissionMatrix, GroupPermissionKey, number, boolean][])(
|
||||
"expect %s at level %i to imply %s: %s",
|
||||
(category, capability, level, expected) => {
|
||||
expect(isCapabilityImpliedByLevel(category, capability, level)).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("matrixStateToPermissions should expand levels into permission keys", () => {
|
||||
test("expands a level into the ordered level keys", () => {
|
||||
expect(matrixStateToPermissions(emptyState({ board: { level: 2, capabilities: [] } }))).toEqual([
|
||||
"board-view-all",
|
||||
"board-modify-all",
|
||||
]);
|
||||
});
|
||||
|
||||
test("expands the admin category into the admin key", () => {
|
||||
expect(matrixStateToPermissions(emptyState({ admin: { level: 1, capabilities: [] } }))).toEqual(["admin"]);
|
||||
});
|
||||
|
||||
test("emits a standalone capability without any level", () => {
|
||||
expect(matrixStateToPermissions(emptyState({ board: { level: 0, capabilities: ["board-create"] } }))).toEqual([
|
||||
"board-create",
|
||||
]);
|
||||
});
|
||||
|
||||
test("emits media-upload as the capability of the media category", () => {
|
||||
expect(matrixStateToPermissions(emptyState({ media: { level: 0, capabilities: ["media-upload"] } }))).toEqual([
|
||||
"media-upload",
|
||||
]);
|
||||
});
|
||||
|
||||
test("emits app-use-all without granting any app level", () => {
|
||||
expect(matrixStateToPermissions(emptyState({ app: { level: 0, capabilities: ["app-use-all"] } }))).toEqual([
|
||||
"app-use-all",
|
||||
]);
|
||||
});
|
||||
|
||||
test("omits a capability that the chosen level already implies", () => {
|
||||
expect(matrixStateToPermissions(emptyState({ board: { level: 3, capabilities: ["board-create"] } }))).toEqual([
|
||||
"board-view-all",
|
||||
"board-modify-all",
|
||||
"board-full-all",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matrix helpers should round-trip stably", () => {
|
||||
test.each([
|
||||
[["board-view-all", "board-modify-all"]],
|
||||
[["integration-use-all", "integration-interact-all", "integration-full-all"]],
|
||||
[["admin"]],
|
||||
[[]],
|
||||
[["search-engine-modify-all", "search-engine-full-all", "media-view-all", "other-view-logs"]],
|
||||
[["board-create"]],
|
||||
[["media-upload"]],
|
||||
[["board-create", "app-create", "integration-create", "search-engine-create", "media-upload"]],
|
||||
[["board-create", "board-view-all"]],
|
||||
[["app-modify-all"]],
|
||||
[["app-use-all"]],
|
||||
[["app-use-all", "app-modify-all"]],
|
||||
] satisfies [GroupPermissionKey[]][])("expect %s to round-trip", (permissions) => {
|
||||
const roundTripped = matrixStateToPermissions(permissionsToMatrixState(permissions));
|
||||
expect(roundTripped).toEqual(expect.arrayContaining(permissions));
|
||||
expect(roundTripped).toHaveLength(permissions.length);
|
||||
});
|
||||
|
||||
test("expect a redundant capability key to be normalised away by the level that implies it", () => {
|
||||
// app-modify-all already grants app-create, so the matrix stores the minimal set. The permission
|
||||
// is not lost: getPermissionsWithChildren re-derives it whenever permissions are read.
|
||||
const roundTripped = matrixStateToPermissions(permissionsToMatrixState(["app-create", "app-modify-all"]));
|
||||
expect(roundTripped).toEqual(["app-modify-all"]);
|
||||
expect(getPermissionsWithChildren(roundTripped)).toContain("app-create");
|
||||
});
|
||||
|
||||
test("expect a single higher-level key to expand to its full set on round-trip", () => {
|
||||
expect(matrixStateToPermissions(permissionsToMatrixState(["board-full-all"]))).toEqual([
|
||||
"board-view-all",
|
||||
"board-modify-all",
|
||||
"board-full-all",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("management section access", () => {
|
||||
test.each([
|
||||
[[], false, false],
|
||||
[["app-create"], true, false],
|
||||
[["app-modify-all"], true, true],
|
||||
[["app-full-all", "app-modify-all"], true, true],
|
||||
[["app-use-all"], false, false],
|
||||
[["board-modify-all"], false, false],
|
||||
] satisfies [GroupPermissionKey[], boolean, boolean][])(
|
||||
"expect apps with %s to be reachable: %s, manage-all: %s",
|
||||
(permissions, canAccess, canManageAll) => {
|
||||
const access = getAppManagementAccess(permissions);
|
||||
expect(access.canAccess).toBe(canAccess);
|
||||
expect(access.canManageAll).toBe(canManageAll);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
[[], false, false, false],
|
||||
[[], true, true, false],
|
||||
[["integration-create"], false, true, false],
|
||||
[["integration-full-all"], false, true, true],
|
||||
[["integration-use-all"], false, false, false],
|
||||
[["integration-interact-all"], false, false, false],
|
||||
[["integration-interact-all"], true, true, false],
|
||||
] satisfies [GroupPermissionKey[], boolean, boolean, boolean][])(
|
||||
"expect integrations with %s and delegated access %s to be reachable: %s, manage-all: %s",
|
||||
(permissions, hasDelegatedFullAccess, canAccess, canManageAll) => {
|
||||
const access = getIntegrationManagementAccess(permissions, hasDelegatedFullAccess);
|
||||
expect(access.canAccess).toBe(canAccess);
|
||||
expect(access.canManageAll).toBe(canManageAll);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -112,15 +112,13 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
|
||||
let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null;
|
||||
|
||||
if (sessionInfo.NowPlayingItem) {
|
||||
const isEpisode = sessionInfo.NowPlayingItem.Type === BaseItemKind.Episode;
|
||||
|
||||
currentlyPlaying = {
|
||||
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
|
||||
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
|
||||
seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "",
|
||||
seasonNumber: isEpisode ? (sessionInfo.NowPlayingItem.ParentIndexNumber ?? null) : null,
|
||||
episodeName: isEpisode ? sessionInfo.NowPlayingItem.EpisodeTitle : null,
|
||||
episodeNumber: isEpisode ? (sessionInfo.NowPlayingItem.IndexNumber ?? null) : null,
|
||||
seasonNumber: sessionInfo.NowPlayingItem.ParentIndexNumber ?? null,
|
||||
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
|
||||
episodeNumber: sessionInfo.NowPlayingItem.IndexNumber ?? null,
|
||||
albumName: sessionInfo.NowPlayingItem.Album ?? "",
|
||||
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
|
||||
playback: {
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { extractHost, parseLocation } from "./jellyfin-integration";
|
||||
|
||||
describe("extractHost", () => {
|
||||
test("strips the port from an IPv4 endpoint", () => {
|
||||
expect(extractHost("192.168.1.100:54321")).toBe("192.168.1.100");
|
||||
});
|
||||
|
||||
test("returns an IPv4 address unchanged when there's no port", () => {
|
||||
expect(extractHost("8.8.8.8")).toBe("8.8.8.8");
|
||||
});
|
||||
|
||||
test("strips brackets and the port from a bracketed IPv6 endpoint", () => {
|
||||
expect(extractHost("[::1]:8096")).toBe("::1");
|
||||
});
|
||||
|
||||
test("returns a bare IPv6 address unchanged when there's no port", () => {
|
||||
expect(extractHost("::1")).toBe("::1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseLocation", () => {
|
||||
test("returns null when there's no endpoint to classify", () => {
|
||||
expect(parseLocation(null)).toBeNull();
|
||||
expect(parseLocation(undefined)).toBeNull();
|
||||
expect(parseLocation("")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for a value that isn't an IP", () => {
|
||||
expect(parseLocation("not-an-ip:1234")).toBeNull();
|
||||
});
|
||||
|
||||
test("classifies private IPv4 ranges as lan", () => {
|
||||
expect(parseLocation("192.168.1.100:54321")).toBe("lan");
|
||||
expect(parseLocation("10.0.0.5:1234")).toBe("lan");
|
||||
expect(parseLocation("172.16.5.5:1234")).toBe("lan");
|
||||
expect(parseLocation("127.0.0.1:8096")).toBe("lan");
|
||||
});
|
||||
|
||||
test("classifies a public IPv4 address as wan", () => {
|
||||
expect(parseLocation("8.8.8.8:1234")).toBe("wan");
|
||||
});
|
||||
|
||||
test("classifies IPv6 loopback and unique-local addresses as lan", () => {
|
||||
expect(parseLocation("[::1]:8096")).toBe("lan");
|
||||
expect(parseLocation("[fd12:3456:789a::1]:8096")).toBe("lan");
|
||||
});
|
||||
|
||||
test("classifies a public IPv6 address as wan", () => {
|
||||
expect(parseLocation("[2606:4700:4700::1111]:8096")).toBe("wan");
|
||||
});
|
||||
|
||||
test("unwraps an IPv4-mapped IPv6 address before classifying it", () => {
|
||||
expect(parseLocation("[::ffff:192.168.1.5]:8096")).toBe("lan");
|
||||
expect(parseLocation("[::ffff:8.8.8.8]:8096")).toBe("wan");
|
||||
});
|
||||
|
||||
test("classifies link-local addresses as lan", () => {
|
||||
// Link-local traffic can't traverse a router, so it's always LAN even though
|
||||
// it isn't a private range in the traditional (RFC 1918 / ULA) sense.
|
||||
expect(parseLocation("169.254.1.5:8096")).toBe("lan");
|
||||
expect(parseLocation("[fe80::1]:8096")).toBe("lan");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import { BlockList, isIP } from "node:net";
|
||||
import { Jellyfin } from "@jellyfin/sdk";
|
||||
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||
@@ -22,42 +21,6 @@ function ticksToMs(ticks: number | null | undefined): number | null {
|
||||
return ticks ? Math.round(ticks / 10_000) : null;
|
||||
}
|
||||
|
||||
// Unlike Plex, Jellyfin doesn't report a session's network location directly - only the
|
||||
// client's RemoteEndPoint (an IP, optionally with a port). Classify it ourselves.
|
||||
const privateAddresses = new BlockList();
|
||||
privateAddresses.addSubnet("10.0.0.0", 8, "ipv4");
|
||||
privateAddresses.addSubnet("172.16.0.0", 12, "ipv4");
|
||||
privateAddresses.addSubnet("192.168.0.0", 16, "ipv4");
|
||||
privateAddresses.addSubnet("127.0.0.0", 8, "ipv4");
|
||||
privateAddresses.addSubnet("169.254.0.0", 16, "ipv4");
|
||||
privateAddresses.addSubnet("fc00::", 7, "ipv6");
|
||||
privateAddresses.addSubnet("fe80::", 10, "ipv6");
|
||||
privateAddresses.addAddress("::1", "ipv6");
|
||||
|
||||
export function extractHost(remoteEndPoint: string): string {
|
||||
if (remoteEndPoint.startsWith("[")) {
|
||||
const end = remoteEndPoint.indexOf("]");
|
||||
return end === -1 ? remoteEndPoint : remoteEndPoint.slice(1, end);
|
||||
}
|
||||
// A bare IPv6 address (no brackets, no port) has more than one colon.
|
||||
const isIPv4WithPort = remoteEndPoint.split(":").length === 2;
|
||||
return isIPv4WithPort ? remoteEndPoint.slice(0, remoteEndPoint.lastIndexOf(":")) : remoteEndPoint;
|
||||
}
|
||||
|
||||
export function parseLocation(remoteEndPoint: string | null | undefined): "lan" | "wan" | null {
|
||||
if (!remoteEndPoint) return null;
|
||||
let host = extractHost(remoteEndPoint);
|
||||
let family = isIP(host);
|
||||
// A dual-stack server can report an IPv4 client as an IPv4-mapped IPv6 address
|
||||
// (e.g. "::ffff:192.168.1.5") - unwrap it so the IPv4 private ranges still apply.
|
||||
if (family === 6 && host.toLowerCase().startsWith("::ffff:") && isIP(host.slice(7)) === 4) {
|
||||
host = host.slice(7);
|
||||
family = 4;
|
||||
}
|
||||
if (!family) return null;
|
||||
return privateAddresses.check(host, family === 4 ? "ipv4" : "ipv6") ? "lan" : "wan";
|
||||
}
|
||||
|
||||
@HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
|
||||
export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
|
||||
private readonly jellyfin: Jellyfin = new Jellyfin({
|
||||
@@ -93,27 +56,17 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
|
||||
if (sessionInfo.NowPlayingItem) {
|
||||
const positionMs = ticksToMs(sessionInfo.PlayState?.PositionTicks);
|
||||
const durationMs = ticksToMs(sessionInfo.NowPlayingItem.RunTimeTicks);
|
||||
// Jellyfin only reliably fills in MediaSources[].Bitrate for a transcoding session -
|
||||
// /Sessions has no `fields` selector to force it, so fall back to summing the individual
|
||||
// media streams' own bitrates (populated from the source file's metadata) for direct play.
|
||||
const mediaStreamsBitrateBps = sessionInfo.NowPlayingItem.MediaStreams?.reduce(
|
||||
(sum, stream) => sum + (stream.BitRate ?? 0),
|
||||
0,
|
||||
);
|
||||
const bitrateBps =
|
||||
sessionInfo.TranscodingInfo?.Bitrate ??
|
||||
sessionInfo.NowPlayingItem.MediaSources?.[0]?.Bitrate ??
|
||||
(mediaStreamsBitrateBps || null);
|
||||
sessionInfo.TranscodingInfo?.Bitrate ?? sessionInfo.NowPlayingItem.MediaSources?.[0]?.Bitrate ?? null;
|
||||
const bitrateKbps = bitrateBps !== null ? Math.round(bitrateBps / 1000) : null;
|
||||
const isEpisode = sessionInfo.NowPlayingItem.Type === BaseItemKind.Episode;
|
||||
|
||||
currentlyPlaying = {
|
||||
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
|
||||
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
|
||||
seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "",
|
||||
seasonNumber: isEpisode ? (sessionInfo.NowPlayingItem.ParentIndexNumber ?? null) : null,
|
||||
episodeName: isEpisode ? sessionInfo.NowPlayingItem.EpisodeTitle : null,
|
||||
episodeNumber: isEpisode ? (sessionInfo.NowPlayingItem.IndexNumber ?? null) : null,
|
||||
seasonNumber: sessionInfo.NowPlayingItem.ParentIndexNumber ?? null,
|
||||
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
|
||||
episodeNumber: sessionInfo.NowPlayingItem.IndexNumber ?? null,
|
||||
albumName: sessionInfo.NowPlayingItem.Album ?? "",
|
||||
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
|
||||
playback: {
|
||||
@@ -121,7 +74,7 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
|
||||
positionMs,
|
||||
durationMs,
|
||||
},
|
||||
location: parseLocation(sessionInfo.RemoteEndPoint),
|
||||
location: null,
|
||||
metadata: {
|
||||
video: {
|
||||
resolution:
|
||||
|
||||
@@ -115,9 +115,9 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
|
||||
type: mediaElement.$.live === "1" ? "tv" : PlexIntegration.getCurrentlyPlayingType(mediaElement.$.type),
|
||||
name: mediaElement.$.grandparentTitle ?? mediaElement.$.title ?? "Unknown",
|
||||
seasonName: mediaElement.$.parentTitle,
|
||||
seasonNumber: mediaElement.$.type === "episode" ? parseOptionalNumber(mediaElement.$.parentIndex) : null,
|
||||
episodeName: mediaElement.$.type === "episode" ? (mediaElement.$.title ?? null) : null,
|
||||
episodeNumber: mediaElement.$.type === "episode" ? parseOptionalNumber(mediaElement.$.index) : null,
|
||||
seasonNumber: parseOptionalNumber(mediaElement.$.parentIndex),
|
||||
episodeName: mediaElement.$.title ?? null,
|
||||
episodeNumber: parseOptionalNumber(mediaElement.$.index),
|
||||
albumName: mediaElement.$.type === "track" ? (mediaElement.$.parentTitle ?? null) : null,
|
||||
episodeCount: parseOptionalNumber(mediaElement.$.index),
|
||||
playback: {
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
import { parse } from "superjson";
|
||||
|
||||
import type { LoggerMessage } from "@homarr/core/infrastructure/logs/constants";
|
||||
import {
|
||||
loggerMessageSchema,
|
||||
LOG_HISTORY_KEY,
|
||||
LOG_HISTORY_MAX_ENTRIES,
|
||||
LOG_PUBLISH_CHANNEL,
|
||||
} from "@homarr/core/infrastructure/logs/constants";
|
||||
import type { LogLevel } from "@homarr/core/infrastructure/logs/constants";
|
||||
|
||||
import { createListChannel, createSubPubChannel } from "./lib/channel";
|
||||
import { ChannelSubscriptionTracker } from "./lib/channel-subscription-tracker";
|
||||
import { createRedisConnection } from "./lib/connection";
|
||||
|
||||
export {
|
||||
handshakeAsync,
|
||||
@@ -25,31 +15,9 @@ export const pingChannel = createSubPubChannel<
|
||||
>("ping");
|
||||
export const pingUrlChannel = createListChannel<string>("ping-url");
|
||||
|
||||
export type { LoggerMessage } from "@homarr/core/infrastructure/logs/constants";
|
||||
export interface LoggerMessage {
|
||||
message: string;
|
||||
level: LogLevel;
|
||||
}
|
||||
|
||||
const loggingHistoryClient = createRedisConnection();
|
||||
|
||||
const parseLoggerMessage = (message: string): LoggerMessage | null => {
|
||||
try {
|
||||
const result = loggerMessageSchema.safeParse(parse<unknown>(message));
|
||||
return result.success ? result.data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const loggingChannel = {
|
||||
subscribeAsync: (callback: (message: LoggerMessage) => void) =>
|
||||
ChannelSubscriptionTracker.subscribeAsync(LOG_PUBLISH_CHANNEL, (message) => {
|
||||
const parsedMessage = parseLoggerMessage(message);
|
||||
if (parsedMessage) callback(parsedMessage);
|
||||
}),
|
||||
getRecentAsync: async () => {
|
||||
const messages =
|
||||
(await loggingHistoryClient?.lrange(LOG_HISTORY_KEY, 0, LOG_HISTORY_MAX_ENTRIES - 1).catch(() => [])) ?? [];
|
||||
return messages
|
||||
.map(parseLoggerMessage)
|
||||
.filter((message): message is LoggerMessage => message !== null)
|
||||
.toReversed();
|
||||
},
|
||||
};
|
||||
export const loggingChannel = createSubPubChannel<LoggerMessage>("logging");
|
||||
@@ -18,7 +18,6 @@ type SubscriptionCallback = (message: string) => MaybePromise<void>;
|
||||
*/
|
||||
export class ChannelSubscriptionTracker {
|
||||
private static subscriptions = new Map<string, Map<string, SubscriptionCallback>>();
|
||||
private static subscriptionReadiness = new Map<string, Promise<void>>();
|
||||
private static redis = createRedisConnection();
|
||||
private static listenerActive = false;
|
||||
|
||||
@@ -29,25 +28,6 @@ export class ChannelSubscriptionTracker {
|
||||
* @returns a function to unsubscribe from the channel
|
||||
*/
|
||||
public static subscribe(channelName: string, callback: SubscriptionCallback) {
|
||||
return this.registerSubscription(channelName, callback).unsubscribe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to a channel and resolves once Redis confirms the subscription.
|
||||
*/
|
||||
public static async subscribeAsync(channelName: string, callback: SubscriptionCallback) {
|
||||
const subscription = this.registerSubscription(channelName, callback);
|
||||
|
||||
try {
|
||||
await subscription.ready;
|
||||
return subscription.unsubscribe;
|
||||
} catch (error) {
|
||||
subscription.unsubscribe();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static registerSubscription(channelName: string, callback: SubscriptionCallback) {
|
||||
logger.debug("Adding redis channel callback", { channel: channelName });
|
||||
|
||||
// We only want to activate the listener once
|
||||
@@ -58,18 +38,11 @@ export class ChannelSubscriptionTracker {
|
||||
|
||||
const channelSubscriptions = this.subscriptions.get(channelName) ?? new Map<string, SubscriptionCallback>();
|
||||
const id = randomUUID();
|
||||
let ready = this.subscriptionReadiness.get(channelName);
|
||||
|
||||
// Subscribe when this channel has no active or pending Redis subscription.
|
||||
if (!ready) {
|
||||
// If there are no subscriptions to the channel, subscribe to it
|
||||
if (channelSubscriptions.size === 0) {
|
||||
logger.debug("Subscribing to redis channel", { channel: channelName });
|
||||
ready = this.redis.subscribe(channelName).then(() => undefined);
|
||||
this.subscriptionReadiness.set(channelName, ready);
|
||||
void ready.catch(() => {
|
||||
if (this.subscriptionReadiness.get(channelName) === ready) {
|
||||
this.subscriptionReadiness.delete(channelName);
|
||||
}
|
||||
});
|
||||
void this.redis.subscribe(channelName);
|
||||
}
|
||||
|
||||
logger.debug("Adding redis channel callback", { channel: channelName, id });
|
||||
@@ -77,26 +50,24 @@ export class ChannelSubscriptionTracker {
|
||||
|
||||
this.subscriptions.set(channelName, channelSubscriptions);
|
||||
|
||||
const unsubscribe = () => {
|
||||
// Return a function to unsubscribe
|
||||
return () => {
|
||||
logger.debug("Removing redis channel callback", { channel: channelName, id });
|
||||
|
||||
const currentSubscriptions = this.subscriptions.get(channelName);
|
||||
if (!currentSubscriptions) return;
|
||||
const channelSubscriptions = this.subscriptions.get(channelName);
|
||||
if (!channelSubscriptions) return;
|
||||
|
||||
currentSubscriptions.delete(id);
|
||||
channelSubscriptions.delete(id);
|
||||
|
||||
// If there are no subscriptions to the channel, unsubscribe from it
|
||||
if (currentSubscriptions.size >= 1) {
|
||||
if (channelSubscriptions.size >= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug("Unsubscribing from redis channel", { channel: channelName });
|
||||
void this.redis.unsubscribe(channelName);
|
||||
this.subscriptions.delete(channelName);
|
||||
this.subscriptionReadiness.delete(channelName);
|
||||
};
|
||||
|
||||
return { ready, unsubscribe };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,11 +17,10 @@ export const appIntegrationBoardMode = {
|
||||
return groups;
|
||||
}
|
||||
|
||||
// Mirrors the gate on app.search: either permission may browse the app catalogue, so an app
|
||||
// administrator without board access still gets the group instead of a FORBIDDEN response.
|
||||
const canSearchApps =
|
||||
session.user.permissions.includes("board-modify-all") || session.user.permissions.includes("app-modify-all");
|
||||
|
||||
return groups.concat(canSearchApps ? [appsSearchGroup, integrationsSearchGroup] : [integrationsSearchGroup]);
|
||||
return groups.concat(
|
||||
session.user.permissions.includes("board-modify-all")
|
||||
? [appsSearchGroup, integrationsSearchGroup]
|
||||
: [integrationsSearchGroup],
|
||||
);
|
||||
},
|
||||
} satisfies SearchMode;
|
||||
@@ -648,35 +648,6 @@
|
||||
"description": "Allow members to manage and delete any search engine"
|
||||
}
|
||||
}
|
||||
},
|
||||
"matrix": {
|
||||
"none": "No access",
|
||||
"impliedHint": "Also grants: {permissions}",
|
||||
"grantedViaAdmin": "Administrators already have this. Set Admin to “No access” to configure it separately.",
|
||||
"preset": {
|
||||
"label": "Presets",
|
||||
"viewer": "Viewer",
|
||||
"viewerDescription": "Read-only: view boards and medias, use apps and integrations that already exist.",
|
||||
"editor": "Editor",
|
||||
"editorDescription": "Day-to-day management: create and edit boards, apps, integrations and search engines.",
|
||||
"admin": "Admin",
|
||||
"adminDescription": "Unrestricted access to every feature and setting, including user management.",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"effective": {
|
||||
"title": "Effective permissions",
|
||||
"description": "The full, de-duplicated set of actions members of this group can perform, including permissions granted indirectly.",
|
||||
"empty": "No permissions selected"
|
||||
},
|
||||
"noneDescription": "Members get no access to this area.",
|
||||
"level": {
|
||||
"view": "View",
|
||||
"use": "Use",
|
||||
"interact": "Interact",
|
||||
"modify": "Modify",
|
||||
"full": "Full"
|
||||
},
|
||||
"capabilityIncluded": "Already included in “{level}”"
|
||||
}
|
||||
},
|
||||
"memberNotice": {
|
||||
@@ -803,9 +774,7 @@
|
||||
"action": "Create your first app",
|
||||
"filteredTitle": "No apps match your search",
|
||||
"filteredDescription": "No apps match \"{search}\". Try another name or URL.",
|
||||
"clearSearch": "Clear search",
|
||||
"createOnlyTitle": "You can only add apps",
|
||||
"createOnlyDescription": "Your permissions let you create apps but not browse existing ones. Ask an administrator for app management access to see the full list."
|
||||
"clearSearch": "Clear search"
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
@@ -1607,7 +1576,6 @@
|
||||
"title": "Integrations",
|
||||
"ariaLabel": "Configured integrations",
|
||||
"search": "Search integrations",
|
||||
"scopedNote": "Showing only the integrations you are allowed to manage.",
|
||||
"action": {
|
||||
"edit": "Edit {name}",
|
||||
"delete": "Delete {name}"
|
||||
@@ -1618,9 +1586,7 @@
|
||||
"action": "Create your first integration",
|
||||
"filteredTitle": "No integrations match your search",
|
||||
"filteredDescription": "No integrations match \"{search}\". Try another name, type, or URL.",
|
||||
"clearSearch": "Clear search",
|
||||
"createOnlyTitle": "You can only add integrations",
|
||||
"createOnlyDescription": "Your permissions let you create integrations but not manage existing ones. Ask an administrator for access to the ones you need."
|
||||
"clearSearch": "Clear search"
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
@@ -2309,6 +2275,7 @@
|
||||
"open": "Open Homarr Assistant",
|
||||
"close": "Close assistant",
|
||||
"minimize": "Minimize assistant",
|
||||
"windowControls": "Conversation window controls",
|
||||
"spotlight": "Ask Homarr Assistant",
|
||||
"spotlightDescription": "Type and send a prompt without leaving the command menu.",
|
||||
"spotlightPrompt": {
|
||||
@@ -2326,24 +2293,6 @@
|
||||
"newConversation": "New conversation",
|
||||
"conversations": "Conversations",
|
||||
"delete": "Delete",
|
||||
"cancel": "Cancel",
|
||||
"conversationActions": "Conversation actions",
|
||||
"searchConversations": "Search conversations",
|
||||
"noMatchingConversations": "No matching conversations",
|
||||
"moreActions": "More response actions",
|
||||
"renameConversation": {
|
||||
"action": "Rename",
|
||||
"label": "Conversation name",
|
||||
"save": "Save name",
|
||||
"failedTitle": "Conversation could not be renamed",
|
||||
"failedDescription": "Homarr could not rename this conversation. Try again."
|
||||
},
|
||||
"deleteConversation": {
|
||||
"title": "Delete conversation?",
|
||||
"description": "“{title}” and its messages will be permanently deleted.",
|
||||
"failedTitle": "Conversation could not be deleted",
|
||||
"failedDescription": "Homarr could not delete this conversation. Try again."
|
||||
},
|
||||
"closeHistory": "Close conversation history",
|
||||
"openHistory": "Open conversation history",
|
||||
"emptyTitle": "What can I help with?",
|
||||
@@ -2416,8 +2365,6 @@
|
||||
"regenerate": "Regenerate response",
|
||||
"helpful": "Helpful",
|
||||
"notHelpful": "Not helpful",
|
||||
"readAloud": "Read response aloud",
|
||||
"stopReading": "Stop reading response",
|
||||
"feedbackError": "Your feedback could not be saved. Try again.",
|
||||
"exportMarkdown": "Export as Markdown",
|
||||
"exportConversation": {
|
||||
@@ -2446,6 +2393,7 @@
|
||||
},
|
||||
"mentions": {
|
||||
"menu": "Add Homarr context",
|
||||
"hint": "Type @ to add context",
|
||||
"context": "Context",
|
||||
"app": "Apps",
|
||||
"integration": "Integrations",
|
||||
@@ -2460,6 +2408,7 @@
|
||||
},
|
||||
"commands": {
|
||||
"menu": "Assistant commands",
|
||||
"hint": "Type / for shortcuts",
|
||||
"health": "Prepare a service-health check",
|
||||
"explore": "Prepare an overview of this Homarr instance",
|
||||
"media": "Prepare a media search",
|
||||
@@ -2572,6 +2521,7 @@
|
||||
"otherPlaceholder": "Type another answer…",
|
||||
"submit": "Send answer",
|
||||
"cancelOther": "Cancel other answer",
|
||||
"automaticAffirmative": "Choosing the affirmative answer automatically",
|
||||
"optionKind": {
|
||||
"affirmative": "Confirm",
|
||||
"negative": "Decline",
|
||||
@@ -2646,7 +2596,7 @@
|
||||
"autoApproval": {
|
||||
"label": "Automatic approvals",
|
||||
"title": "Automatic approvals",
|
||||
"description": "Automatically prepare forms and approve protected changes for this conversation. Questions still wait for your answer, and your Homarr permissions still apply. This turns off when you switch conversations.",
|
||||
"description": "Automatically approve protected changes, use presented form details, and choose answers categorized as affirmative. Neutral selections still wait for you. Your Homarr permissions still apply. This turns off when you switch conversations.",
|
||||
"enable": "Enable for this conversation",
|
||||
"disable": "Turn off automatic approvals",
|
||||
"enabled": "Automatic approvals are on",
|
||||
@@ -3549,10 +3499,6 @@
|
||||
"label": "1 Month"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showDetails": {
|
||||
"label": "Show details",
|
||||
"description": "Show the name, change, and time range in the compact view. Turn off for just the symbol and price."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -5760,13 +5706,7 @@
|
||||
"compact": {
|
||||
"title": "Assistant",
|
||||
"description": "Resize this widget to chat here, or open the full conversation.",
|
||||
"open": "Open assistant",
|
||||
"openTitle": "Assistant open in side panel",
|
||||
"openDescription": "The conversation stays in one active surface.",
|
||||
"returnToWidget": "Return the conversation to this widget",
|
||||
"otherWidgetTitle": "Assistant active in another widget",
|
||||
"otherWidgetDescription": "Only one conversation surface stays active at a time.",
|
||||
"useHere": "Use the assistant in this widget"
|
||||
"open": "Open assistant"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -6202,8 +6142,7 @@
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Not Found",
|
||||
"text": "Could not find requested resource",
|
||||
"backToHome": "Back to management home"
|
||||
"text": "Could not find requested resource"
|
||||
},
|
||||
"error": {
|
||||
"title": "This page couldn't load",
|
||||
@@ -8407,10 +8346,6 @@
|
||||
}
|
||||
},
|
||||
"log": {
|
||||
"context": {
|
||||
"widgetError": "Widget reported an error around this time",
|
||||
"expiredWidgetError": "Logs around this widget error are no longer retained"
|
||||
},
|
||||
"level": {
|
||||
"option": {
|
||||
"debug": "Debug",
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Anchor, Button, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import type { stringOrTranslation } from "@homarr/translation";
|
||||
import { translateIfNecessary } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
@@ -17,22 +15,14 @@ export interface BaseWidgetErrorProps {
|
||||
|
||||
export const BaseWidgetError = (props: BaseWidgetErrorProps) => {
|
||||
const t = useI18n();
|
||||
const { data: session } = useSession();
|
||||
const [errorTimestamp] = useState(Date.now);
|
||||
|
||||
return (
|
||||
<Stack h="100%" align="center" justify="center" gap="md" data-homarr-widget-error>
|
||||
<props.icon size={40} />
|
||||
<Stack gap={0}>
|
||||
<Text ta="center">{translateIfNecessary(t, props.message)}</Text>
|
||||
{props.showLogsLink && session?.user.permissions.includes("other-view-logs") && (
|
||||
<Anchor
|
||||
component={Link}
|
||||
href={`/manage/tools/logs?focus=${errorTimestamp}`}
|
||||
target="_blank"
|
||||
ta="center"
|
||||
size="sm"
|
||||
>
|
||||
{props.showLogsLink && (
|
||||
<Anchor component={Link} href="/manage/tools/logs" target="_blank" ta="center" size="sm">
|
||||
{t("common.action.checkLogs")}
|
||||
</Anchor>
|
||||
)}
|
||||
|
||||
@@ -11,10 +11,6 @@ vi.mock("@homarr/translation/client", () => ({
|
||||
useI18n: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/auth/client", () => ({
|
||||
useSession: () => ({ data: null }),
|
||||
}));
|
||||
|
||||
const matchMedia = (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
|
||||
.tableViewport :global(th) {
|
||||
background-color: var(--background-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sortButton {
|
||||
|
||||
@@ -9,12 +9,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { StreamSession } from "@homarr/integrations";
|
||||
|
||||
import {
|
||||
getMediaServerColumnVisibility,
|
||||
getResolutionLabel,
|
||||
getSeasonEpisodeParams,
|
||||
SessionDetailsPopover,
|
||||
} from "./component";
|
||||
import { getMediaServerColumnVisibility, getSeasonEpisodeParams, SessionDetailsPopover } from "./component";
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useScopedI18n: () => (key: string) => key,
|
||||
@@ -109,8 +104,7 @@ describe("getMediaServerColumnVisibility", () => {
|
||||
test("keeps a stable responsive column model from the zero-sized first render", () => {
|
||||
expect(getMediaServerColumnVisibility(0, false)).toEqual({ user: false, status: false });
|
||||
expect(getMediaServerColumnVisibility(300, false)).toEqual({ user: true, status: false });
|
||||
expect(getMediaServerColumnVisibility(420, false)).toEqual({ user: true, status: false });
|
||||
expect(getMediaServerColumnVisibility(540, false)).toEqual({ user: true, status: true });
|
||||
expect(getMediaServerColumnVisibility(420, false)).toEqual({ user: true, status: true });
|
||||
expect(getMediaServerColumnVisibility(0, true)).toEqual({ user: true, status: true });
|
||||
});
|
||||
});
|
||||
@@ -130,30 +124,3 @@ describe("getSeasonEpisodeParams", () => {
|
||||
expect(getSeasonEpisodeParams(null, undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getResolutionLabel", () => {
|
||||
test("labels a standard 16:9 resolution by its exact height", () => {
|
||||
expect(getResolutionLabel(1920, 1080)).toBe("1080p");
|
||||
expect(getResolutionLabel(1280, 720)).toBe("720p");
|
||||
});
|
||||
|
||||
test("classifies a cinemascope source by its width, not its cropped height", () => {
|
||||
// A 1920x804 encode (2.35:1, cropped top and bottom) is still a 1080p source.
|
||||
expect(getResolutionLabel(1920, 804)).toBe("1080p");
|
||||
expect(getResolutionLabel(1280, 640)).toBe("720p");
|
||||
});
|
||||
|
||||
test("rounds a near-standard resolution up to its nearest tier", () => {
|
||||
expect(getResolutionLabel(1912, 796)).toBe("1080p");
|
||||
});
|
||||
|
||||
test("falls back to the raw height when it doesn't clear any known tier", () => {
|
||||
expect(getResolutionLabel(100, 100)).toBe("100p");
|
||||
});
|
||||
|
||||
test("classifies a portrait video by its width, not its long-axis height", () => {
|
||||
// A 1080x1920 portrait phone recording is still a 1080p source, not a 1440p one.
|
||||
expect(getResolutionLabel(1080, 1920)).toBe("1080p");
|
||||
expect(getResolutionLabel(720, 1280)).toBe("720p");
|
||||
});
|
||||
});
|
||||
@@ -51,9 +51,7 @@ type SortState = { column: SortColumn; descending: boolean } | null;
|
||||
|
||||
export const getMediaServerColumnVisibility = (width: number, isAdvanced: boolean) => ({
|
||||
user: isAdvanced || width >= 300,
|
||||
// The user (200px) and status (190px) columns together need a floor here, otherwise the
|
||||
// currentlyPlaying column - the primary content - gets squeezed to almost nothing.
|
||||
status: isAdvanced || width >= 540,
|
||||
status: isAdvanced || width >= 420,
|
||||
});
|
||||
|
||||
function getPlaybackStatus(transcoding: TranscodingDecision | undefined): PlaybackStatus {
|
||||
@@ -79,21 +77,6 @@ function formatBitrate(bitrateKbps: number | null | undefined): string | null {
|
||||
return bitrateKbps >= 1000 ? `${(bitrateKbps / 1000).toFixed(1)} Mbps` : `${Math.round(bitrateKbps)} kbps`;
|
||||
}
|
||||
|
||||
const RESOLUTION_TIER_HEIGHTS = [4320, 2160, 1440, 1080, 720, 480, 360, 240] as const;
|
||||
|
||||
export function getResolutionLabel(width: number, height: number): string {
|
||||
// Classify off the short axis (height for landscape, width for portrait) so orientation
|
||||
// doesn't flip which dimension drives the label. Letterboxed/cinemascope sources are cropped
|
||||
// shorter than their nominal resolution class (a 1920x804 encode is still "1080p"), so derive
|
||||
// a floor for the short axis from the long axis assuming 16:9 - matches how Plex/Jellyfin label these themselves.
|
||||
const isPortrait = height > width;
|
||||
const shortAxis = isPortrait ? width : height;
|
||||
const longAxis = isPortrait ? height : width;
|
||||
const effectiveShortAxis = Math.max(shortAxis, Math.round((longAxis * 9) / 16));
|
||||
const tier = RESOLUTION_TIER_HEIGHTS.find((candidate) => effectiveShortAxis >= candidate * 0.9);
|
||||
return `${tier ?? effectiveShortAxis}p`;
|
||||
}
|
||||
|
||||
export function getSeasonEpisodeParams(
|
||||
seasonNumber: number | null | undefined,
|
||||
episodeNumber: number | null | undefined,
|
||||
@@ -134,33 +117,27 @@ function StreamTableHeader({
|
||||
sortable,
|
||||
sort,
|
||||
onSort,
|
||||
width,
|
||||
}: {
|
||||
column: SortColumn;
|
||||
label: string;
|
||||
sortable: boolean;
|
||||
sort: SortState;
|
||||
onSort: (column: SortColumn) => void;
|
||||
width?: number;
|
||||
}) {
|
||||
const active = sort?.column === column;
|
||||
const SortIcon = !active ? IconArrowsSort : sort.descending ? IconChevronDown : IconChevronUp;
|
||||
|
||||
return (
|
||||
<Table.Th w={width} aria-sort={active ? (sort.descending ? "descending" : "ascending") : "none"}>
|
||||
<Table.Th aria-sort={active ? (sort.descending ? "descending" : "ascending") : "none"}>
|
||||
{sortable ? (
|
||||
<UnstyledButton className={classes.sortButton} onClick={() => onSort(column)}>
|
||||
<Text component="span" size="xs" fw={600} c="dimmed" style={{ letterSpacing: "0.02em" }} truncate>
|
||||
<Text component="span" size="xs" fw={600} truncate>
|
||||
{label}
|
||||
</Text>
|
||||
<SortIcon
|
||||
size="var(--mantine-font-size-xs)"
|
||||
aria-hidden
|
||||
color={active ? undefined : "var(--mantine-color-dimmed)"}
|
||||
/>
|
||||
<SortIcon size="var(--mantine-font-size-xs)" aria-hidden />
|
||||
</UnstyledButton>
|
||||
) : (
|
||||
<Text size="xs" fw={600} c="dimmed" style={{ letterSpacing: "0.02em" }} truncate>
|
||||
<Text size="xs" fw={600} truncate>
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
@@ -218,14 +195,12 @@ export default function MediaServerWidget({
|
||||
current?.column === column ? { column, descending: !current.descending } : { column, descending: false },
|
||||
);
|
||||
|
||||
const uniqueIntegrations = currentStreams
|
||||
.filter((stream) => stream.sessions.length > 0)
|
||||
.map((stream) => ({
|
||||
integrationId: stream.integrationId,
|
||||
integrationKind: stream.integrationKind,
|
||||
integrationIcon: getIconUrl(stream.integrationKind),
|
||||
integrationName: stream.integrationName,
|
||||
}));
|
||||
const uniqueIntegrations = currentStreams.map((stream) => ({
|
||||
integrationId: stream.integrationId,
|
||||
integrationKind: stream.integrationKind,
|
||||
integrationIcon: getIconUrl(stream.integrationKind),
|
||||
integrationName: stream.integrationName,
|
||||
}));
|
||||
|
||||
const playingCount = flatSessions.filter(
|
||||
(session) => session.currentlyPlaying && session.currentlyPlaying.playback?.state !== "paused",
|
||||
@@ -288,7 +263,6 @@ export default function MediaServerWidget({
|
||||
sortable={isAdvanced}
|
||||
sort={sort}
|
||||
onSort={toggleSort}
|
||||
width={200}
|
||||
/>
|
||||
)}
|
||||
<StreamTableHeader
|
||||
@@ -305,7 +279,6 @@ export default function MediaServerWidget({
|
||||
sortable={isAdvanced}
|
||||
sort={sort}
|
||||
onSort={toggleSort}
|
||||
width={190}
|
||||
/>
|
||||
)}
|
||||
</Table.Tr>
|
||||
@@ -317,12 +290,6 @@ export default function MediaServerWidget({
|
||||
const status = getPlaybackStatus(currentlyPlaying?.metadata?.transcoding);
|
||||
const location = showLocation ? currentlyPlaying?.location : null;
|
||||
const bitrateLabel = showBitrate ? formatBitrate(currentlyPlaying?.metadata?.bitrateKbps) : null;
|
||||
// The transcoding resolution reflects what's actually being streamed - fall back to the
|
||||
// source's own resolution when it isn't set (direct play, or a transcode that only
|
||||
// touches audio/container and leaves the video resolution untouched).
|
||||
const resolution =
|
||||
currentlyPlaying?.metadata?.transcoding.resolution ?? currentlyPlaying?.metadata?.video.resolution;
|
||||
const resolutionLabel = resolution ? getResolutionLabel(resolution.width, resolution.height) : null;
|
||||
const toggleDetails = () => setSelectedRowId((current) => (current === rowId ? null : rowId));
|
||||
|
||||
return (
|
||||
@@ -348,7 +315,7 @@ export default function MediaServerWidget({
|
||||
{columnVisibility.user && (
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap" w="100%">
|
||||
<Avatar size={28} src={session.user?.profilePictureUrl} style={{ flexShrink: 0 }} />
|
||||
<Avatar size={28} src={session.user?.profilePictureUrl} />
|
||||
<Stack gap={2} className={classes.cellContent}>
|
||||
<Text size="xs" truncate>
|
||||
{session.user?.username ?? t("items.unknownUser")}
|
||||
@@ -373,30 +340,24 @@ export default function MediaServerWidget({
|
||||
<Table.Td>
|
||||
{currentlyPlaying && (
|
||||
<Stack gap={4} align="flex-start" w="100%" className={classes.cellContent}>
|
||||
<Group gap={4} align="center" justify="space-between" wrap="nowrap" w="100%">
|
||||
<Badge size="xs" variant="light" color={playbackStatusColorMap[status]}>
|
||||
{t(`items.${status}` as never)}
|
||||
</Badge>
|
||||
{location && (
|
||||
<Group gap={4} align="center" wrap="nowrap">
|
||||
{location === "lan" ? (
|
||||
<IconWifi size="var(--mantine-font-size-xs)" />
|
||||
) : (
|
||||
<IconWorld size="var(--mantine-font-size-xs)" />
|
||||
)}
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
{location}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
{(resolutionLabel ?? bitrateLabel) && (
|
||||
<Badge size="xs" variant="light" color={playbackStatusColorMap[status]}>
|
||||
{t(`items.${status}` as never)}
|
||||
</Badge>
|
||||
{(location ?? bitrateLabel) && (
|
||||
<Group gap={4} align="center" justify="space-between" wrap="nowrap" w="100%">
|
||||
{resolutionLabel && (
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{resolutionLabel}
|
||||
</Text>
|
||||
)}
|
||||
<Group gap={4} align="center" wrap="nowrap">
|
||||
{location &&
|
||||
(location === "lan" ? (
|
||||
<IconWifi size="var(--mantine-font-size-xs)" />
|
||||
) : (
|
||||
<IconWorld size="var(--mantine-font-size-xs)" />
|
||||
))}
|
||||
{location && (
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
{location}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
{bitrateLabel && (
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{bitrateLabel}
|
||||
@@ -425,42 +386,25 @@ export default function MediaServerWidget({
|
||||
borderTop: "1px solid var(--border-color)",
|
||||
}}
|
||||
>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconVideo size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} />
|
||||
<Text size="sm" fw={500} style={{ whiteSpace: "nowrap" }}>
|
||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", {
|
||||
count: flatSessions.length,
|
||||
})}
|
||||
</Text>
|
||||
{totalBitrateLabel && (isAdvanced || width >= 300) && (
|
||||
<>
|
||||
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
•
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{t("footer.totalBitrate", { bitrate: totalBitrateLabel })}
|
||||
</Text>
|
||||
</>
|
||||
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{t("footer.totalBitrate", { bitrate: totalBitrateLabel })}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap={6}>
|
||||
<Group gap="xs">
|
||||
{uniqueIntegrations.map((integration) => (
|
||||
<Group
|
||||
key={integration.integrationId}
|
||||
gap={6}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
pl={2}
|
||||
pr={8}
|
||||
py={2}
|
||||
style={{
|
||||
backgroundColor: "var(--mantine-color-default-hover)",
|
||||
borderRadius: 999,
|
||||
}}
|
||||
>
|
||||
<Avatar className="media-server-icon" src={integration.integrationIcon} radius="xl" size={18} />
|
||||
<Group key={integration.integrationId} gap="xs" align="center">
|
||||
<Avatar className="media-server-icon" src={integration.integrationIcon} radius={"xs"} size="xs" />
|
||||
{(isAdvanced || width >= 480) && (
|
||||
<Text className="media-server-name" size="xs" fw={500} truncate="end">
|
||||
<Text className="media-server-name" size="sm" truncate="end">
|
||||
{integration.integrationName}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ComponentType, FormEvent } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { Box, Button, Group, Stack, Tabs, Text } from "@mantine/core";
|
||||
import { schemaResolver } from "@mantine/form";
|
||||
import { IconArrowLeft, IconPencil } from "@tabler/icons-react";
|
||||
import { IconPencil } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { objectEntries } from "@homarr/common";
|
||||
@@ -35,16 +35,6 @@ export interface WidgetEditModalState {
|
||||
advancedOptions: BoardItemAdvancedOptions;
|
||||
}
|
||||
|
||||
export interface EmbeddedIntegrationEditFormHandle {
|
||||
submitIfDirty: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface EmbeddedIntegrationEditFormProps {
|
||||
integrationId: string;
|
||||
handleRef: React.Ref<EmbeddedIntegrationEditFormHandle>;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export interface WidgetEditModalProps<TSort extends WidgetKind> {
|
||||
kind: TSort;
|
||||
definition: WidgetDefinition;
|
||||
@@ -56,7 +46,7 @@ export interface WidgetEditModalProps<TSort extends WidgetKind> {
|
||||
itemId?: string;
|
||||
boardId?: string;
|
||||
appId?: string;
|
||||
integrationEditForm?: ComponentType<EmbeddedIntegrationEditFormProps>;
|
||||
onEditIntegration?: (integrationId: string) => void;
|
||||
onIntegrationSaved?: () => void;
|
||||
}
|
||||
|
||||
@@ -71,12 +61,6 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
const { data: session } = useSession();
|
||||
const [advancedOptions, setAdvancedOptions] = useState<BoardItemAdvancedOptions>(innerProps.value.advancedOptions);
|
||||
const appEditRef = useRef<EmbeddedAppEditFormHandle>(null);
|
||||
const integrationEditHandles = useRef(new Map<string, EmbeddedIntegrationEditFormHandle>());
|
||||
const integrationEditRefCallbacks = useRef(
|
||||
new Map<string, (handle: EmbeddedIntegrationEditFormHandle | null) => void>(),
|
||||
);
|
||||
const [mountedIntegrationIds, setMountedIntegrationIds] = useState<string[]>([]);
|
||||
const [activeIntegrationId, setActiveIntegrationId] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
z.config({
|
||||
@@ -134,24 +118,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
const showAppTab = innerProps.kind === "app" && canModifyApps && Boolean(appId);
|
||||
const canModifyAllIntegrations = session?.user.permissions.includes("integration-full-all") ?? false;
|
||||
const selectedIntegrations = getSelectedWidgetIntegrations(innerProps.integrationData, form.values.integrationIds);
|
||||
const editableIntegrationIds = selectedIntegrations
|
||||
.filter((integration) => canModifyAllIntegrations || integration.permissions?.hasFullAccess === true)
|
||||
.map((integration) => integration.id);
|
||||
let soleEditableIntegrationId: string | null = null;
|
||||
if (selectedIntegrations.length === 1) {
|
||||
soleEditableIntegrationId = editableIntegrationIds[0] ?? null;
|
||||
}
|
||||
|
||||
let displayedIntegrationId = soleEditableIntegrationId;
|
||||
if (activeIntegrationId && editableIntegrationIds.includes(activeIntegrationId)) {
|
||||
displayedIntegrationId = activeIntegrationId;
|
||||
}
|
||||
const integrationIdsToRender = mountedIntegrationIds.filter((id) => editableIntegrationIds.includes(id));
|
||||
if (displayedIntegrationId && !integrationIdsToRender.includes(displayedIntegrationId)) {
|
||||
integrationIdsToRender.push(displayedIntegrationId);
|
||||
}
|
||||
const IntegrationEditForm = innerProps.integrationEditForm;
|
||||
const showIntegrationTab = Boolean(IntegrationEditForm) && selectedIntegrations.length > 0;
|
||||
const showIntegrationTab = Boolean(innerProps.onEditIntegration) && selectedIntegrations.length > 0;
|
||||
const showResourceTabs = showAppTab || showIntegrationTab;
|
||||
const [activeTab, setActiveTab] = useState<string | null>("widget");
|
||||
|
||||
@@ -160,31 +127,15 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
if (activeTab === "app" && !showAppTab) setActiveTab("widget");
|
||||
}, [activeTab, showAppTab, showIntegrationTab]);
|
||||
|
||||
const getIntegrationEditRef = (integrationId: string) => {
|
||||
const existingCallback = integrationEditRefCallbacks.current.get(integrationId);
|
||||
if (existingCallback) {
|
||||
return existingCallback;
|
||||
const handleTabChange = (value: string | null) => {
|
||||
if (value === "integration" && selectedIntegrations.length === 1) {
|
||||
const integration = selectedIntegrations[0];
|
||||
if (integration) {
|
||||
innerProps.onEditIntegration?.(integration.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const callback = (handle: EmbeddedIntegrationEditFormHandle | null) => {
|
||||
if (handle) {
|
||||
integrationEditHandles.current.set(integrationId, handle);
|
||||
} else {
|
||||
integrationEditHandles.current.delete(integrationId);
|
||||
}
|
||||
};
|
||||
integrationEditRefCallbacks.current.set(integrationId, callback);
|
||||
return callback;
|
||||
};
|
||||
|
||||
const beginEditingIntegration = (integrationId: string) => {
|
||||
setMountedIntegrationIds((current) => {
|
||||
if (current.includes(integrationId)) {
|
||||
return current;
|
||||
}
|
||||
return [...current, integrationId];
|
||||
});
|
||||
setActiveIntegrationId(integrationId);
|
||||
setActiveTab(value);
|
||||
};
|
||||
|
||||
const handleSubmit = form.onSubmit(async (values) => {
|
||||
@@ -196,13 +147,6 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
return;
|
||||
}
|
||||
|
||||
for (const integrationEditHandle of integrationEditHandles.current.values()) {
|
||||
const integrationSaved = await integrationEditHandle.submitIfDirty();
|
||||
if (!integrationSaved) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
innerProps.onSuccessfulEdit({
|
||||
...values,
|
||||
advancedOptions,
|
||||
@@ -296,7 +240,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
<FormProvider form={form}>
|
||||
{showResourceTabs ? (
|
||||
<Stack>
|
||||
<Tabs value={activeTab} onChange={setActiveTab}>
|
||||
<Tabs value={activeTab} onChange={handleTabChange}>
|
||||
<Tabs.List grow>
|
||||
<Tabs.Tab value="widget">{t("item.edit.tab.widget")}</Tabs.Tab>
|
||||
{showAppTab && <Tabs.Tab value="app">{t("item.edit.tab.app")}</Tabs.Tab>}
|
||||
@@ -313,68 +257,42 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
{showIntegrationTab && (
|
||||
<Tabs.Panel value="integration" pt="md">
|
||||
<Stack>
|
||||
{displayedIntegrationId === null && (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("item.edit.integration.description")}
|
||||
</Text>
|
||||
{selectedIntegrations.map((integration) => {
|
||||
const canEdit = editableIntegrationIds.includes(integration.id);
|
||||
return (
|
||||
<Group key={integration.id} justify="space-between" wrap="nowrap" gap="sm">
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<IntegrationAvatar kind={integration.kind} size="sm" />
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text fw={600} size="sm" truncate>
|
||||
{integration.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{integration.url}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
{canEdit ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
leftSection={<IconPencil size={16} />}
|
||||
onClick={() => beginEditingIntegration(integration.id)}
|
||||
aria-label={t("item.edit.integration.editLabel", { name: integration.name })}
|
||||
>
|
||||
{t("item.edit.integration.action")}
|
||||
</Button>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" ta="end">
|
||||
{t("item.edit.integration.fullAccessRequired")}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{displayedIntegrationId !== null && selectedIntegrations.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
leftSection={<IconArrowLeft size={16} />}
|
||||
onClick={() => setActiveIntegrationId(null)}
|
||||
style={{ alignSelf: "start" }}
|
||||
>
|
||||
{t("common.action.previous")}
|
||||
</Button>
|
||||
)}
|
||||
{integrationIdsToRender.map((integrationId) => (
|
||||
<Box key={integrationId} hidden={displayedIntegrationId !== integrationId}>
|
||||
{IntegrationEditForm && (
|
||||
<IntegrationEditForm
|
||||
integrationId={integrationId}
|
||||
handleRef={getIntegrationEditRef(integrationId)}
|
||||
onSuccess={innerProps.onIntegrationSaved}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("item.edit.integration.description")}
|
||||
</Text>
|
||||
{selectedIntegrations.map((integration) => {
|
||||
const canEdit = canModifyAllIntegrations || integration.permissions?.hasFullAccess === true;
|
||||
return (
|
||||
<Group key={integration.id} justify="space-between" wrap="nowrap" gap="sm">
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<IntegrationAvatar kind={integration.kind} size="sm" />
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text fw={600} size="sm" truncate>
|
||||
{integration.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{integration.url}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
{canEdit ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
leftSection={<IconPencil size={16} />}
|
||||
onClick={() => innerProps.onEditIntegration?.(integration.id)}
|
||||
aria-label={t("item.edit.integration.editLabel", { name: integration.name })}
|
||||
>
|
||||
{t("item.edit.integration.action")}
|
||||
</Button>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" ta="end">
|
||||
{t("item.edit.integration.fullAccessRequired")}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("stock summary", () => {
|
||||
|
||||
describe("stock layout", () => {
|
||||
it("keeps compact widgets focused on symbol and price", () => {
|
||||
expect(getStockLayout(320, 100, true)).toMatchObject({
|
||||
expect(getStockLayout(320, 100)).toMatchObject({
|
||||
showName: false,
|
||||
showChange: false,
|
||||
showRange: false,
|
||||
@@ -42,12 +42,12 @@ describe("stock layout", () => {
|
||||
});
|
||||
|
||||
it("reveals secondary data only when both axes have room", () => {
|
||||
expect(getStockLayout(320, 160, true)).toMatchObject({
|
||||
expect(getStockLayout(320, 160)).toMatchObject({
|
||||
showName: false,
|
||||
showChange: true,
|
||||
showRange: true,
|
||||
});
|
||||
expect(getStockLayout(320, 220, true)).toMatchObject({
|
||||
expect(getStockLayout(320, 220)).toMatchObject({
|
||||
showName: true,
|
||||
showChange: true,
|
||||
showRange: true,
|
||||
@@ -56,7 +56,7 @@ describe("stock layout", () => {
|
||||
});
|
||||
|
||||
it("exposes the complete summary when the widget is roomy", () => {
|
||||
expect(getStockLayout(640, 360, true)).toEqual({
|
||||
expect(getStockLayout(640, 360)).toEqual({
|
||||
showName: true,
|
||||
showChange: true,
|
||||
showRange: true,
|
||||
@@ -64,12 +64,4 @@ describe("stock layout", () => {
|
||||
priceOrder: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("hides name, change, and range regardless of size when showDetails is off", () => {
|
||||
expect(getStockLayout(640, 360, false)).toMatchObject({
|
||||
showName: false,
|
||||
showChange: false,
|
||||
showRange: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -43,11 +43,11 @@ export interface StockLayout {
|
||||
priceOrder: 1 | 2;
|
||||
}
|
||||
|
||||
export function getStockLayout(width: number, height: number, showDetails: boolean): StockLayout {
|
||||
export function getStockLayout(width: number, height: number): StockLayout {
|
||||
return {
|
||||
showName: showDetails && width >= 300 && height >= 190,
|
||||
showChange: showDetails && width >= 260 && height >= 130,
|
||||
showRange: showDetails && width >= 220 && height >= 130,
|
||||
showName: width >= 300 && height >= 190,
|
||||
showChange: width >= 260 && height >= 130,
|
||||
showRange: width >= 220 && height >= 130,
|
||||
graphHeight: height >= 320 ? "75%" : height >= 220 ? "68%" : "48%",
|
||||
priceOrder: width >= 280 && height >= 120 ? 1 : 2,
|
||||
};
|
||||
@@ -92,7 +92,7 @@ export default function StockPriceWidget({
|
||||
const stockValuesChangePercentage = summary.changePercentage === null ? null : round(summary.changePercentage);
|
||||
const stockGraphValues = summary.graphValues;
|
||||
const trendColor = stockValuesChange > 0 ? "green.7" : stockValuesChange < 0 ? "red.7" : "gray.6";
|
||||
const layout = getStockLayout(width, height, options.showDetails);
|
||||
const layout = getStockLayout(width, height);
|
||||
const formatValue = (value: number) => numberFormatter.format(round(value));
|
||||
const formatSignedValue = (value: number) => `${value > 0 ? "+" : ""}${formatValue(value)}`;
|
||||
|
||||
|
||||
@@ -41,10 +41,6 @@ export const { definition, componentLoader } = createWidgetDefinition("stockPric
|
||||
label: (t) => t(`widget.stockPrice.option.timeInterval.option.${value}.label`),
|
||||
})),
|
||||
}),
|
||||
showDetails: factory.switch({
|
||||
defaultValue: true,
|
||||
withDescription: true,
|
||||
}),
|
||||
}));
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
Reference in new issue
Block a user