Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d150285165 | ||
|
|
679cede4ae | ||
|
|
bd9e0f7565 | ||
|
|
c6d6f43c85 | ||
|
|
482a8c20e3 | ||
|
|
f1295f4d28 | ||
|
|
46461136a0 | ||
|
|
fe657fd79d | ||
|
|
f6d89f03b2 | ||
|
|
b2ff7c1011 | ||
|
|
510845d3b9 | ||
|
|
6d6815a901 | ||
|
|
f10e49fa20 | ||
|
|
8ecdd13cd5 |
No files matched your search
@@ -58,12 +58,12 @@ A random secret can be generated by using the following command: `openssl rand -
|
||||
|
||||
## Docker
|
||||
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- |
|
||||
| `DOCKER_SOCKET_PATHS` | Comma-separated mounted Docker-compatible socket paths. Homarr discovers each path as a selectable Docker environment | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
|
||||
| `DOCKER_HOSTNAMES` | Comma-separated plaintext TCP hostnames. Pair each hostname with the port in the same position in `DOCKER_PORTS` | For example `localhost,docker.example.com` | - |
|
||||
| `DOCKER_PORTS` | Comma-separated plaintext TCP ports paired with `DOCKER_HOSTNAMES` | For example `2375,2376` | - |
|
||||
| `DOCKER_ENDPOINTS` | Optional advanced JSON configuration for custom names, TLS, or restricted capabilities. It takes precedence over the simple variables above. See the [Docker and Podman connection guide](/docs/integrations/docker) | A JSON array of endpoint descriptors | - |
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- |
|
||||
| `DOCKER_ENDPOINTS` | JSON array of named, capability-scoped Docker or Podman endpoints. Supports socket, explicitly insecure TCP, and TLS transports. See the [Docker and Podman integration guide](/docs/integrations/docker) | A JSON array of endpoint descriptors | - |
|
||||
| `DOCKER_HOSTNAMES` | Legacy comma-separated list of plaintext TCP hostnames. Ignored when `DOCKER_ENDPOINTS` is set | For example `localhost,docker.example.com` | - |
|
||||
| `DOCKER_PORTS` | Legacy comma-separated list of plaintext TCP ports. Ignored when `DOCKER_ENDPOINTS` is set | For example `2375,2376` | - |
|
||||
| `DOCKER_SOCKET_PATHS` | Legacy comma-separated list of mounted Docker-compatible socket paths. Ignored when `DOCKER_ENDPOINTS` is set | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
|
||||
|
||||
## Database
|
||||
|
||||
|
||||
@@ -24,11 +24,12 @@ import { dockerContainersWidget } from "@site/docs/widgets/docker-containers";
|
||||
]}
|
||||
/>
|
||||
|
||||
## Connecting Docker to Homarr
|
||||
## Enabling the Docker integration
|
||||
|
||||
Docker connections come directly from Homarr's environment variables. They are automatically available in the Docker management table and Docker widget; you do not create a Docker integration under **Management → Integrations**.
|
||||
|
||||
If Homarr runs directly on a host with Docker installed, it uses `/var/run/docker.sock` by default. When Homarr runs in a container, mount the [Docker socket](https://docs.docker.com/engine/security/protect-access/) into it so Homarr can interact with Docker.
|
||||
This integration must first be enabled.
|
||||
If you directly run on the host system and you have docker installed, it will be enabled by default.
|
||||
Otherwise, you'll need to mount the [docker socket](https://docs.docker.com/engine/security/protect-access/) to the Homarr container,
|
||||
so Homarr can interact with the Docker service.
|
||||
|
||||
In Docker Compose, you can simply add this line:
|
||||
|
||||
@@ -121,23 +122,7 @@ services:
|
||||
DOCKER_SOCKET_PATHS: /var/run/docker.sock,/var/run/podman.sock
|
||||
```
|
||||
|
||||
For remote socket proxies, list the hostnames and matching ports in the same order:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
DOCKER_HOSTNAMES: docker-proxy-home,docker-proxy-lab
|
||||
DOCKER_PORTS: 2375,2375
|
||||
```
|
||||
|
||||
Homarr automatically discovers each comma-separated entry as a separate Docker environment. All environments are selected by default; the Docker widget lets you select one or more without any additional setup.
|
||||
|
||||
:::warning[Plaintext TCP]
|
||||
Plaintext Docker TCP can grant unauthenticated control of the Docker host. Prefer a local socket, a least-privilege socket proxy on a private Docker network, or TLS.
|
||||
:::
|
||||
|
||||
### Advanced endpoint configuration
|
||||
|
||||
Most users should use the comma-separated variables above. Use `DOCKER_ENDPOINTS` only when you need custom names, TLS, or restricted capabilities. It is a JSON array where every endpoint declares its identity, transport, and capabilities:
|
||||
For named endpoints, transport security, or least-privilege actions, prefer `DOCKER_ENDPOINTS`. It is a JSON array where every endpoint declares its identity, transport, and capabilities:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -178,12 +163,16 @@ Supported transports are:
|
||||
- `tls`: `host`, `port`, and an absolute `caPath`; add both `certPath` and `keyPath` for mutual TLS
|
||||
- `tcp`: `host`, `port`, and the explicit acknowledgement `allowInsecure: true`
|
||||
|
||||
The structured configuration refuses TCP unless `allowInsecure` is explicitly set to `true`. When `DOCKER_ENDPOINTS` is set, it takes precedence over `DOCKER_SOCKET_PATHS`, `DOCKER_HOSTNAMES`, and `DOCKER_PORTS`.
|
||||
:::warning[Plaintext TCP]
|
||||
Plaintext Docker TCP can grant unauthenticated control of the Docker host. Prefer a local socket, a least-privilege socket proxy, or TLS. The structured configuration refuses TCP unless `allowInsecure` is explicitly set to `true`.
|
||||
:::
|
||||
|
||||
When `DOCKER_ENDPOINTS` is set, it takes precedence over the legacy `DOCKER_SOCKET_PATHS`, `DOCKER_HOSTNAMES`, and `DOCKER_PORTS` variables.
|
||||
|
||||
### Managing your Docker containers within Homarr
|
||||
|
||||
Homarr allows you to interact with Docker containers running on your system.
|
||||
The collapsed **Assisted Docker setup** helper optionally compares visible containers with your existing Homarr apps and integrations. Open it only when you want setup suggestions; it never blocks normal container management. Every service stays distinct by Docker endpoint and container ID, including multiple containers that use the same integration type.
|
||||
The **Service setup** section compares the visible containers with your existing Homarr apps and integrations. It keeps every service distinct by Docker endpoint and container ID, including multiple containers that use the same integration type.
|
||||
|
||||
Homarr can show a service as:
|
||||
|
||||
@@ -192,15 +181,15 @@ Homarr can show a service as:
|
||||
- **Already represented** or **Linked** when an app or integration already matches
|
||||
- **Address changed** when a service appears to match an integration but its reachable address has changed
|
||||
|
||||
Set the usual server address once, then choose whether services normally use different ports, subdomains, or paths. Homarr uses that pattern to prefill every detected service. Each suggestion has one editable URL field with the generated and Docker-reported addresses available as autocomplete options, so you can select a suggestion or type any address directly.
|
||||
Suggested addresses are ranked and explain where they came from. A published address or Docker endpoint host is normally usable from a browser; a container-name address is server-only and requires Homarr to share the container network. Wildcard addresses such as `0.0.0.0` are never offered as browser destinations. You can edit every suggestion before continuing.
|
||||
|
||||
:::info[Review before changing]
|
||||
Service discovery is read-only. Homarr never creates or changes an app or integration automatically. **Set up integration** opens the standard prefilled integration form, while **Create app** opens the existing Docker app form, so you can review and confirm the final setup.
|
||||
Service discovery is read-only. Homarr never creates or changes an app or integration automatically; use the action on an individual suggestion to review and confirm its setup.
|
||||
:::
|
||||
|
||||
Suggestions appear as a compact task list. Expand an item only when you want to edit its URL or inspect relevant setup statuses. Use the filters to focus on services that need attention or those already represented in Homarr. Refresh runs discovery again. Dismissed suggestions are stored in the current browser using their endpoint-qualified service identity and can be restored from the same helper. If multiple existing items could match, Homarr marks the result as ambiguous and does not choose one automatically.
|
||||
Use the inbox filters to focus on services that need attention or those already represented in Homarr. Refresh runs discovery again. Dismissed suggestions are stored in the current browser using their endpoint-qualified service identity and can be restored from the same panel. If multiple existing items could match, Homarr marks the result as ambiguous and does not choose one automatically.
|
||||
|
||||
Compact status icons summarize the setup information Homarr can act on. Unknown and non-applicable runtime layers are omitted to keep the helper focused; use the integration form's connection test or the relevant board action for live diagnostics.
|
||||
The health badges are an evidence-based projection across Docker, integration configuration, app representation, and persisted widget links. Authentication, API request success, and widget query execution are labeled **Not observed** because Homarr does not currently retain trustworthy last-run results for these layers. Use the suggested integration test or board action to diagnose them live; **Not observed** is never presented as healthy.
|
||||
|
||||
If one Docker endpoint is unavailable, Homarr reports that endpoint while continuing to show services from healthy endpoints. An unavailable endpoint is not treated as an empty Docker installation.
|
||||
|
||||
@@ -227,10 +216,6 @@ This opens a dedicated page with an xterm.js terminal that streams the container
|
||||
The Docker management table supports optional **CPU usage** and **Memory usage** columns.
|
||||
These columns are hidden by default and can be toggled on from the table column visibility settings.
|
||||
|
||||
#### Choosing Docker environments in the widget
|
||||
|
||||
The Docker widget can show one or more configured Docker environments. Leave **Docker environments** empty to include every endpoint, which is also the default for existing widgets. Selecting one or more entries limits container and statistics requests to those endpoints.
|
||||
|
||||
#### Add apps from containers
|
||||
|
||||
Homarr also lets you add apps from containers. You can simply select all containers you want to add an app for and then click the `Add to Homarr` button.
|
||||
|
||||
@@ -110,10 +110,6 @@ Pass those pairs unchanged to `docker.startAll`, `docker.stopAll`, `docker.resta
|
||||
names use underscores, such as `docker_restartAll`. Endpoint qualification prevents identical container IDs on different
|
||||
Docker or Podman endpoints from targeting the wrong service.
|
||||
|
||||
`docker.getContainers` also accepts an optional `endpointIds` array. Omit it or pass an empty array to query every configured
|
||||
Docker environment, or pass one or more IDs returned by `docker.getEndpoints` to query only those Dockerode clients. The
|
||||
Docker widget uses the same behavior for its environment selector.
|
||||
|
||||
## Partial integration failures
|
||||
|
||||
Multi-integration widget queries keep data from integrations that remain available. Calendar and media-server results include
|
||||
|
||||
@@ -7,15 +7,20 @@ hide_title: true
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
|
||||
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
|
||||
import { IconAd } from "@tabler/icons-react";
|
||||
import Tabs from "@theme/Tabs";
|
||||
import TabItem from "@theme/TabItem";
|
||||
import { dockerContainersWidget } from ".";
|
||||
import { dockerIntegration } from "@site/docs/integrations/docker";
|
||||
|
||||
<WidgetHeader widget={dockerContainersWidget} categories={["Containers"]} />
|
||||
|
||||
This widget displays the resource usage of your Docker containers and allows you to manage them.
|
||||
|
||||
:::info
|
||||
This widget reads the Docker environments configured through environment variables. You do not need to create a Docker integration under **Management → Integrations**. The widget can only be used with administrator privileges.
|
||||
This widget can only be used with administrator privileges.
|
||||
:::
|
||||
|
||||
### Screenshots
|
||||
@@ -24,6 +29,16 @@ import dockerTable from "./img/table.png";
|
||||
|
||||
<img src={dockerTable} alt="docker-stats table docker-containers" width={128 * 3} height={128 * 2} />
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations
|
||||
items={[
|
||||
{
|
||||
integration: dockerIntegration,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
### Interactions
|
||||
|
||||
Hover a column header to reveal its grab handle, then drag it to reorder the table or drag the header's edge to resize it. The layout is saved automatically for each widget instance and restored after a reload. These controls are disabled while the board is in edit mode.
|
||||
|
||||
@@ -8,12 +8,6 @@ export const dockerContainersWidget: WidgetDefinition = {
|
||||
path: "../../widgets/docker-containers",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Docker environments",
|
||||
description: "Select one or more automatically discovered Docker environments, or leave empty to show all",
|
||||
values: "Any Docker socket or host configured for Homarr",
|
||||
defaultValue: "All Docker environments",
|
||||
},
|
||||
{
|
||||
name: "Columns to show",
|
||||
description: "Select which columns are visible in the table",
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { Avatar, Badge, Button, Group, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { Avatar, Badge, Button, Group, Text, Tooltip } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconApi, IconPencil, IconSparkles } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { CustomWidgetSourceSetupDialog } from "~/components/custom-widgets/custom-widget-source-setup-dialog";
|
||||
import { ManageCollectionItem, ManageCollectionList } from "~/components/manage/manage-collection";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { CustomWidgetRowActions } from "./_custom-widget-actions";
|
||||
@@ -43,92 +41,75 @@ type WidgetDef = RouterOutputs["customWidget"]["list"][number];
|
||||
|
||||
function CustomWidgetRow({ widget }: { widget: WidgetDef }) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const [sourceSetupOpened, sourceSetupControls] = useDisclosure(false);
|
||||
const origins = [...new Set(widget.sources.map((source) => source.origin))];
|
||||
|
||||
return (
|
||||
<>
|
||||
<ManageCollectionItem
|
||||
leading={
|
||||
<Avatar
|
||||
size={40}
|
||||
radius="sm"
|
||||
src={widget.iconUrl}
|
||||
color={widget.valid ? "pink" : "red"}
|
||||
styles={{ image: { objectFit: "contain" } }}
|
||||
alt=""
|
||||
>
|
||||
<IconApi size={20} stroke={1.5} />
|
||||
</Avatar>
|
||||
}
|
||||
title={
|
||||
<Text component="span" fw={600} lineClamp={1} c={widget.enabled || !widget.valid ? undefined : "dimmed"}>
|
||||
{widget.name}
|
||||
<ManageCollectionItem
|
||||
leading={
|
||||
<Avatar
|
||||
size={40}
|
||||
radius="sm"
|
||||
src={widget.iconUrl}
|
||||
color={widget.valid ? "pink" : "red"}
|
||||
styles={{ image: { objectFit: "contain" } }}
|
||||
alt=""
|
||||
>
|
||||
<IconApi size={20} stroke={1.5} />
|
||||
</Avatar>
|
||||
}
|
||||
title={
|
||||
<Text component="span" fw={600} lineClamp={1} c={widget.enabled || !widget.valid ? undefined : "dimmed"}>
|
||||
{widget.name}
|
||||
</Text>
|
||||
}
|
||||
badges={<CustomWidgetStatusBadges widget={widget} />}
|
||||
description={
|
||||
<Text size="sm" c="dimmed" lineClamp={2}>
|
||||
{widget.description || t("page.list.noDescription")}
|
||||
</Text>
|
||||
}
|
||||
metadata={
|
||||
<Group gap={6} wrap="wrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("page.list.requestCount", { count: widget.requestCount })}
|
||||
</Text>
|
||||
}
|
||||
badges={<CustomWidgetStatusBadges widget={widget} onConfigureSources={sourceSetupControls.open} />}
|
||||
description={
|
||||
<Text size="sm" c="dimmed" lineClamp={2}>
|
||||
{widget.description || t("page.list.noDescription")}
|
||||
</Text>
|
||||
}
|
||||
metadata={
|
||||
<Group gap={6} wrap="wrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("page.list.requestCount", { count: widget.requestCount })}
|
||||
{origins.map((origin) => (
|
||||
<Text key={origin} size="xs" c="dimmed" ff="monospace" style={{ wordBreak: "break-all" }}>
|
||||
· {origin}
|
||||
</Text>
|
||||
{origins.map((origin) => (
|
||||
<Text key={origin} size="xs" c="dimmed" ff="monospace" style={{ wordBreak: "break-all" }}>
|
||||
· {origin}
|
||||
</Text>
|
||||
))}
|
||||
</Group>
|
||||
}
|
||||
actions={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{widget.valid && (
|
||||
<Button
|
||||
component={Link}
|
||||
href={`/manage/custom-widgets/edit/${widget.id}`}
|
||||
variant="default"
|
||||
size="sm"
|
||||
leftSection={<IconPencil size={16} stroke={1.5} />}
|
||||
>
|
||||
{t("action.edit")}
|
||||
</Button>
|
||||
)}
|
||||
<CustomWidgetRowActions
|
||||
widget={{
|
||||
id: widget.id,
|
||||
name: widget.name,
|
||||
enabled: widget.enabled,
|
||||
valid: widget.valid,
|
||||
migrationRequired: widget.migrationRequired,
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
}
|
||||
actionsAlignment="center"
|
||||
/>
|
||||
{widget.valid && (
|
||||
<CustomWidgetSourceSetupDialog
|
||||
definitionId={widget.id}
|
||||
opened={sourceSetupOpened}
|
||||
onClose={sourceSetupControls.close}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</Group>
|
||||
}
|
||||
actions={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{widget.valid && (
|
||||
<Button
|
||||
component={Link}
|
||||
href={`/manage/custom-widgets/edit/${widget.id}`}
|
||||
variant="default"
|
||||
size="sm"
|
||||
leftSection={<IconPencil size={16} stroke={1.5} />}
|
||||
>
|
||||
{t("action.edit")}
|
||||
</Button>
|
||||
)}
|
||||
<CustomWidgetRowActions
|
||||
widget={{
|
||||
id: widget.id,
|
||||
name: widget.name,
|
||||
enabled: widget.enabled,
|
||||
valid: widget.valid,
|
||||
migrationRequired: widget.migrationRequired,
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Everything that decides whether this widget can actually run right now. */
|
||||
function CustomWidgetStatusBadges({
|
||||
widget,
|
||||
onConfigureSources,
|
||||
}: {
|
||||
widget: WidgetDef;
|
||||
onConfigureSources(): void;
|
||||
}) {
|
||||
function CustomWidgetStatusBadges({ widget }: { widget: WidgetDef }) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
|
||||
if (widget.migrationRequired) {
|
||||
@@ -176,17 +157,9 @@ function CustomWidgetStatusBadges({
|
||||
multiline
|
||||
maw={320}
|
||||
>
|
||||
<UnstyledButton onClick={onConfigureSources} aria-label={t("action.configureSources")}>
|
||||
<Badge
|
||||
color="yellow"
|
||||
size="sm"
|
||||
variant="light"
|
||||
leftSection={<IconAlertTriangle size={12} />}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{t("page.list.missingCredentials", { count: widget.missingSecrets.length })}
|
||||
</Badge>
|
||||
</UnstyledButton>
|
||||
<Badge color="yellow" size="sm" variant="light" leftSection={<IconAlertTriangle size={12} />}>
|
||||
{t("page.list.missingCredentials", { count: widget.missingSecrets.length })}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
+5
-40
@@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
dismissDockerReconciliationCandidate,
|
||||
filterDockerReconciliationInbox,
|
||||
getTemplateUrl,
|
||||
getValidDockerServiceUrl,
|
||||
} from "./docker-reconciliation-inbox";
|
||||
|
||||
const candidates = [
|
||||
@@ -37,44 +37,9 @@ describe("Docker reconciliation inbox", () => {
|
||||
expect(dismissDockerReconciliationCandidate(dismissed, "home:new")).toBe(dismissed);
|
||||
});
|
||||
|
||||
test("builds service URLs from the server origin and published ports", () => {
|
||||
expect(
|
||||
getTemplateUrl(
|
||||
{
|
||||
container: { name: "Custom service", ports: [{ PrivatePort: 3000, Type: "tcp" }] },
|
||||
match: null,
|
||||
},
|
||||
"home.lan",
|
||||
"hostPort",
|
||||
),
|
||||
).toBe("http://home.lan");
|
||||
|
||||
expect(
|
||||
getTemplateUrl(
|
||||
{
|
||||
container: {
|
||||
name: "Sonarr",
|
||||
ports: [
|
||||
{ PrivatePort: 3000, PublicPort: 3000, Type: "tcp" },
|
||||
{ PrivatePort: 8989, PublicPort: 9999, Type: "tcp" },
|
||||
],
|
||||
},
|
||||
match: { kind: "sonarr" },
|
||||
},
|
||||
"home.lan",
|
||||
"hostPort",
|
||||
),
|
||||
).toBe("http://home.lan:9999");
|
||||
|
||||
expect(
|
||||
getTemplateUrl(
|
||||
{
|
||||
container: { name: "Sonarr", ports: [{ PrivatePort: 8989, PublicPort: 9999, Type: "tcp" }] },
|
||||
match: { kind: "sonarr" },
|
||||
},
|
||||
"",
|
||||
"hostPort",
|
||||
),
|
||||
).toBe("");
|
||||
test("accepts only HTTP service URLs", () => {
|
||||
expect(getValidDockerServiceUrl("https://service.example.com")).toBe("https://service.example.com/");
|
||||
expect(getValidDockerServiceUrl("ftp://service.example.com")).toBeNull();
|
||||
expect(getValidDockerServiceUrl("service.example.com")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,3 @@
|
||||
import type { DockerServiceMatch, DockerServicePort, UrlTemplateMode } from "@homarr/definitions";
|
||||
import { buildAppUrl, buildIntegrationUrl, getIntegrationDefaultPort } from "@homarr/definitions";
|
||||
|
||||
export type DockerReconciliationInboxFilter = "attention" | "represented" | "all";
|
||||
|
||||
interface DockerReconciliationInboxCandidate {
|
||||
@@ -27,28 +24,11 @@ export const filterDockerReconciliationInbox = <Candidate extends DockerReconcil
|
||||
export const dismissDockerReconciliationCandidate = (dismissedCandidateKeys: string[], candidateKey: string) =>
|
||||
dismissedCandidateKeys.includes(candidateKey) ? dismissedCandidateKeys : [...dismissedCandidateKeys, candidateKey];
|
||||
|
||||
interface DockerTemplateCandidate {
|
||||
container: {
|
||||
name: string;
|
||||
ports?: DockerServicePort[];
|
||||
};
|
||||
match: Pick<DockerServiceMatch, "kind"> | null;
|
||||
}
|
||||
|
||||
export const getTemplateUrl = (candidate: DockerTemplateCandidate, serverOrigin: string, urlMode: UrlTemplateMode) => {
|
||||
if (!serverOrigin.trim()) return "";
|
||||
|
||||
const tcpPorts = candidate.container.ports?.filter(({ Type }) => Type.toLowerCase() === "tcp") ?? [];
|
||||
let publishedPort = tcpPorts.find(({ PublicPort }) => PublicPort)?.PublicPort;
|
||||
|
||||
if (candidate.match) {
|
||||
const defaultPort = getIntegrationDefaultPort(candidate.match.kind);
|
||||
const preferredPort = tcpPorts.find(
|
||||
({ PrivatePort, PublicPort }) => PrivatePort === defaultPort && PublicPort !== undefined,
|
||||
);
|
||||
publishedPort = preferredPort?.PublicPort ?? publishedPort;
|
||||
return buildIntegrationUrl(candidate.match.kind, serverOrigin, urlMode, publishedPort);
|
||||
export const getValidDockerServiceUrl = (value: string) => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildAppUrl(candidate.container.name, serverOrigin, urlMode, publishedPort);
|
||||
};
|
||||
@@ -1,72 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Collapse,
|
||||
Divider,
|
||||
Card,
|
||||
Group,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useLocalStorage } from "@mantine/hooks";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconApps,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconBrandDocker,
|
||||
IconMinus,
|
||||
IconPlugConnected,
|
||||
IconRefresh,
|
||||
IconSettings,
|
||||
} from "@tabler/icons-react";
|
||||
import { IconAlertTriangle, IconArrowRight, IconEyeOff, IconPlugConnected, IconRefresh } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { UrlTemplateMode } from "@homarr/definitions";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { normalizeServiceUrl, ServiceUrlTemplate } from "@homarr/onboarding";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import type { DockerReconciliationInboxFilter } from "./docker-reconciliation-inbox";
|
||||
import {
|
||||
dismissDockerReconciliationCandidate,
|
||||
filterDockerReconciliationInbox,
|
||||
getTemplateUrl,
|
||||
getValidDockerServiceUrl,
|
||||
} from "./docker-reconciliation-inbox";
|
||||
|
||||
type ReconciliationCandidate = RouterOutputs["docker"]["reconcileServices"]["candidates"][number];
|
||||
type ServiceHealth = RouterOutputs["docker"]["getServiceHealth"]["services"][number];
|
||||
|
||||
export const DockerReconciliation = ({ defaultServerOrigin }: { defaultServerOrigin: string }) => {
|
||||
export const DockerReconciliation = () => {
|
||||
const t = useScopedI18n("docker.reconciliation");
|
||||
const tGlobal = useI18n();
|
||||
const utils = clientApi.useUtils();
|
||||
const panelId = useId();
|
||||
const [isOpen, setIsOpen] = useLocalStorage({
|
||||
key: "homarr-docker-reconciliation-open",
|
||||
defaultValue: false,
|
||||
});
|
||||
const reconciliation = clientApi.docker.reconcileServices.useQuery();
|
||||
const health = clientApi.docker.getServiceHealth.useQuery(undefined, { enabled: isOpen });
|
||||
const health = clientApi.docker.getServiceHealth.useQuery();
|
||||
const refreshInventory = clientApi.docker.refreshInventory.useMutation({
|
||||
async onSuccess() {
|
||||
await Promise.all([
|
||||
@@ -87,14 +61,6 @@ export const DockerReconciliation = ({ defaultServerOrigin }: { defaultServerOri
|
||||
key: "homarr-docker-reconciliation-dismissed",
|
||||
defaultValue: [],
|
||||
});
|
||||
const [serverOrigin, setServerOrigin] = useLocalStorage({
|
||||
key: "homarr-docker-service-origin",
|
||||
defaultValue: defaultServerOrigin,
|
||||
});
|
||||
const [urlMode, setUrlMode] = useLocalStorage<UrlTemplateMode>({
|
||||
key: "homarr-docker-service-url-mode",
|
||||
defaultValue: "hostPort",
|
||||
});
|
||||
|
||||
if (reconciliation.isError) {
|
||||
return (
|
||||
@@ -102,7 +68,7 @@ export const DockerReconciliation = ({ defaultServerOrigin }: { defaultServerOri
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{t("loadError.message")}</Text>
|
||||
<Button variant="light" color="red" size="xs" w="fit-content" onClick={() => void reconciliation.refetch()}>
|
||||
{tGlobal("common.action.tryAgain")}
|
||||
{t("action.retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
@@ -113,274 +79,231 @@ export const DockerReconciliation = ({ defaultServerOrigin }: { defaultServerOri
|
||||
|
||||
const unavailableEndpoints = reconciliation.data.endpoints.filter(({ status }) => status === "unavailable");
|
||||
const candidates = filterDockerReconciliationInbox(reconciliation.data.candidates, filter, dismissedCandidateKeys);
|
||||
const attentionCount = reconciliation.data.candidates.filter(
|
||||
({ candidateKey, state }) =>
|
||||
["newRecognized", "newApp", "moved"].includes(state) && !dismissedCandidateKeys.includes(candidateKey),
|
||||
).length;
|
||||
const attentionCount = health.data
|
||||
? health.data.services.filter(
|
||||
({ key, status }) =>
|
||||
["newRecognized", "newApp", "moved"].includes(status) && !dismissedCandidateKeys.includes(key),
|
||||
).length
|
||||
: 0;
|
||||
const isRefreshing = refreshInventory.isPending || reconciliation.isFetching || health.isFetching;
|
||||
const toggleLabel = isOpen ? tGlobal("common.action.hide") : t("action.review");
|
||||
|
||||
return (
|
||||
<Paper withBorder p="sm">
|
||||
<Stack gap={isOpen ? "sm" : 0}>
|
||||
<Card withBorder>
|
||||
<Stack>
|
||||
<Group justify="space-between" align="start">
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon variant="light" size="lg" radius="md">
|
||||
<IconBrandDocker size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Group gap="xs">
|
||||
<Text fw={600}>{t("title")}</Text>
|
||||
<Badge variant="light" color={attentionCount > 0 ? "blue" : "gray"} aria-live="polite">
|
||||
{t("suggestions", { count: String(attentionCount) })}
|
||||
</Badge>
|
||||
</Group>
|
||||
</div>
|
||||
</Group>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
rightSection={
|
||||
<IconChevronDown
|
||||
size={15}
|
||||
style={{ transform: isOpen ? "rotate(180deg)" : undefined, transition: "transform 150ms ease" }}
|
||||
/>
|
||||
}
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={panelId}
|
||||
onClick={() => setIsOpen((current) => !current)}
|
||||
>
|
||||
{toggleLabel}
|
||||
</Button>
|
||||
<div>
|
||||
<Group gap="xs">
|
||||
<IconPlugConnected size={20} />
|
||||
<Text fw={600}>{t("title")}</Text>
|
||||
</Group>
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("description")}
|
||||
</Text>
|
||||
</div>
|
||||
{health.data && <Badge variant="light">{t("attention", { count: String(attentionCount) })}</Badge>}
|
||||
</Group>
|
||||
|
||||
<Collapse id={panelId} expanded={isOpen}>
|
||||
<Divider mb="sm" />
|
||||
<Stack gap="sm">
|
||||
<ServiceUrlTemplate
|
||||
serverOrigin={serverOrigin}
|
||||
onServerOriginChange={setServerOrigin}
|
||||
mode={urlMode}
|
||||
onModeChange={setUrlMode}
|
||||
/>
|
||||
<Group justify="space-between">
|
||||
<SegmentedControl
|
||||
value={filter}
|
||||
onChange={(value) => setFilter(value as DockerReconciliationInboxFilter)}
|
||||
data={[
|
||||
{ value: "attention", label: t("filter.attention") },
|
||||
{ value: "represented", label: t("filter.represented") },
|
||||
{ value: "all", label: t("filter.all") },
|
||||
]}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
{dismissedCandidateKeys.length > 0 && (
|
||||
<Button variant="subtle" size="xs" onClick={() => setDismissedCandidateKeys([])}>
|
||||
{t("action.restoreDismissed", { count: String(dismissedCandidateKeys.length) })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
loading={isRefreshing}
|
||||
leftSection={<IconRefresh size={14} />}
|
||||
onClick={() => refreshInventory.mutate()}
|
||||
>
|
||||
{t("action.refresh")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between">
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={filter}
|
||||
onChange={(value) => setFilter(value as DockerReconciliationInboxFilter)}
|
||||
data={[
|
||||
{ value: "attention", label: t("filter.attention") },
|
||||
{ value: "represented", label: t("filter.represented") },
|
||||
{ value: "all", label: t("filter.all") },
|
||||
]}
|
||||
{unavailableEndpoints.length > 0 && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
||||
{t("endpointUnavailable", { names: unavailableEndpoints.map(({ name }) => name).join(", ") })}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{candidates.length === 0 ? (
|
||||
<Text c="dimmed" size="sm">
|
||||
{reconciliation.data.candidates.length === 0 ? t("empty") : t("emptyFilter")}
|
||||
</Text>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }}>
|
||||
{candidates.map((candidate) => (
|
||||
<DockerReconciliationCandidate
|
||||
key={candidate.candidateKey}
|
||||
candidate={candidate}
|
||||
health={health.data?.services.find(({ key }) => key === candidate.candidateKey)}
|
||||
onDismiss={() =>
|
||||
setDismissedCandidateKeys((current) =>
|
||||
dismissDockerReconciliationCandidate(current, candidate.candidateKey),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
{dismissedCandidateKeys.length > 0 && (
|
||||
<Button variant="subtle" size="compact-xs" onClick={() => setDismissedCandidateKeys([])}>
|
||||
{t("action.restoreDismissed", { count: String(dismissedCandidateKeys.length) })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="light"
|
||||
size="compact-xs"
|
||||
loading={isRefreshing}
|
||||
leftSection={<IconRefresh size={14} />}
|
||||
onClick={() => refreshInventory.mutate()}
|
||||
>
|
||||
{tGlobal("docker.action.refresh.label")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{unavailableEndpoints.length > 0 && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
||||
{t("endpointUnavailable", { names: unavailableEndpoints.map(({ name }) => name).join(", ") })}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{candidates.length === 0 ? (
|
||||
<Text c="dimmed" size="sm">
|
||||
{reconciliation.data.candidates.length === 0 ? t("empty") : t("emptyFilter")}
|
||||
</Text>
|
||||
) : (
|
||||
<Accordion multiple variant="separated" radius="sm" chevron={null}>
|
||||
{candidates.map((candidate) => (
|
||||
<DockerReconciliationCandidate
|
||||
key={candidate.candidateKey}
|
||||
candidate={candidate}
|
||||
health={health.data?.services.find(({ key }) => key === candidate.candidateKey)}
|
||||
serverOrigin={serverOrigin}
|
||||
urlMode={urlMode}
|
||||
onDismiss={() =>
|
||||
setDismissedCandidateKeys((current) =>
|
||||
dismissDockerReconciliationCandidate(current, candidate.candidateKey),
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Accordion>
|
||||
)}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const DockerReconciliationCandidate = ({
|
||||
candidate,
|
||||
health,
|
||||
serverOrigin,
|
||||
urlMode,
|
||||
onDismiss,
|
||||
}: {
|
||||
candidate: ReconciliationCandidate;
|
||||
health: ServiceHealth | undefined;
|
||||
serverOrigin: string;
|
||||
urlMode: UrlTemplateMode;
|
||||
onDismiss: () => void;
|
||||
}) => {
|
||||
const t = useScopedI18n("docker.reconciliation");
|
||||
const tGlobal = useI18n();
|
||||
const { openModal: openAppModal } = useModalAction(AddDockerAppToHomarr);
|
||||
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
|
||||
const templateUrl = getTemplateUrl(candidate, serverOrigin, urlMode);
|
||||
const urlSuggestions = Array.from(
|
||||
new Set([templateUrl, ...candidate.urlCandidates.map(({ url }) => url)].filter((url) => url.length > 0)),
|
||||
);
|
||||
const initialUrl = urlSuggestions[0] ?? "";
|
||||
const [url, setUrl] = useState(initialUrl);
|
||||
const isUrlEdited = useRef(false);
|
||||
const target = getCandidateTarget(candidate);
|
||||
const { openModal } = useModalAction(AddDockerAppToHomarr);
|
||||
const initialUrlCandidate =
|
||||
candidate.urlCandidates.find(
|
||||
({ url, scopes }) => url.length > 0 && (candidate.match ? true : scopes.includes("browser")),
|
||||
) ?? candidate.urlCandidates.find(({ source }) => source === "manual");
|
||||
const [selectedCandidateId, setSelectedCandidateId] = useState(initialUrlCandidate?.id ?? null);
|
||||
const [url, setUrl] = useState(initialUrlCandidate?.url ?? "");
|
||||
const selectedCandidate = candidate.urlCandidates.find(({ id }) => id === selectedCandidateId);
|
||||
const validatedUrl = getValidDockerServiceUrl(url);
|
||||
const isInvalidUrl = url.length > 0 && validatedUrl === null;
|
||||
const target = getCandidateTarget(candidate, validatedUrl ?? "");
|
||||
const actionNeedsUrl = target.kind === "createApp" || target.kind === "setupIntegration";
|
||||
const visibleHealthLayers =
|
||||
health?.layers.filter(({ status }) => status !== "notApplicable" && status !== "notObserved") ?? [];
|
||||
const isActionDisabled = actionNeedsUrl && validatedUrl === null;
|
||||
|
||||
useEffect(() => {
|
||||
if (isUrlEdited.current) return;
|
||||
setUrl(initialUrl);
|
||||
}, [initialUrl]);
|
||||
setSelectedCandidateId(initialUrlCandidate?.id ?? null);
|
||||
setUrl(initialUrlCandidate?.url ?? "");
|
||||
}, [initialUrlCandidate?.id, initialUrlCandidate?.url]);
|
||||
|
||||
return (
|
||||
<Accordion.Item value={candidate.candidateKey}>
|
||||
<Group wrap="nowrap" gap={4} pr="sm">
|
||||
<Accordion.Control
|
||||
style={{ flex: 1 }}
|
||||
icon={
|
||||
<Avatar src={candidate.container.iconUrl} radius="sm" size="sm">
|
||||
<Card withBorder padding="md">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="start" wrap="nowrap">
|
||||
<Group wrap="nowrap">
|
||||
<Avatar src={candidate.container.iconUrl} radius="sm">
|
||||
{candidate.container.name.at(0)?.toUpperCase()}
|
||||
</Avatar>
|
||||
}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text fw={600} size="sm" truncate>
|
||||
<div>
|
||||
<Text fw={600} lineClamp={1}>
|
||||
{candidate.container.name}
|
||||
</Text>
|
||||
<Badge color={stateColor(candidate.state)} variant="light" size="xs">
|
||||
{t(`state.${candidate.state}`)}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text c="dimmed" size="xs">
|
||||
{candidate.endpointName}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge color={stateColor(candidate.state)} variant="light">
|
||||
{t(`state.${candidate.state}`)}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{candidate.match && (
|
||||
<Group gap="xs">
|
||||
<Badge variant="outline">{candidate.match.kind}</Badge>
|
||||
<Text c="dimmed" size="xs">
|
||||
{candidate.endpointName}
|
||||
{t("match", { confidence: t(`confidence.${candidate.match.confidence}`) })}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{candidate.representation.signals.ambiguous && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
||||
{t("ambiguous")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{health && (
|
||||
<div>
|
||||
<Text c="dimmed" size="xs" mb={4}>
|
||||
{t("health.title")}
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
{health.layers.map((layer) => (
|
||||
<Badge key={layer.layer} color={healthStatusColor(layer.status)} variant="dot" size="sm">
|
||||
{t(`health.layer.${layer.layer}`)}: {t(`health.status.${layer.status}`)}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</div>
|
||||
</Accordion.Control>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{target.kind === "createApp" && (
|
||||
)}
|
||||
|
||||
<Select
|
||||
label={t("url.label")}
|
||||
description={selectedCandidate ? t(`reason.${selectedCandidate.reason}`) : t("reason.manualHostRequired")}
|
||||
value={selectedCandidateId}
|
||||
data={candidate.urlCandidates.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.url || t("url.manual"),
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
setSelectedCandidateId(value);
|
||||
setUrl(candidate.urlCandidates.find(({ id }) => id === value)?.url ?? "");
|
||||
}}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("url.inputLabel")}
|
||||
aria-label={t("url.inputLabel")}
|
||||
value={url}
|
||||
placeholder="https://service.example.com"
|
||||
error={isInvalidUrl ? t("url.invalid") : undefined}
|
||||
onChange={(event) => setUrl(event.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Button variant="subtle" color="gray" leftSection={<IconEyeOff size={16} />} onClick={onDismiss}>
|
||||
{t("action.dismiss")}
|
||||
</Button>
|
||||
{target.kind === "createApp" ? (
|
||||
<Button
|
||||
variant="light"
|
||||
size="compact-xs"
|
||||
leftSection={<IconApps size={14} />}
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
disabled={isActionDisabled}
|
||||
onClick={() =>
|
||||
openAppModal({
|
||||
openModal({
|
||||
selectedContainers: [candidate.container],
|
||||
initialUrls: [(normalizeServiceUrl(url) ?? url) || null],
|
||||
initialUrls: validatedUrl ? [validatedUrl] : undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
{tGlobal("integration.field.createApp.label")}
|
||||
{t("action.createApp")}
|
||||
</Button>
|
||||
)}
|
||||
{target.kind === "setupIntegration" && (
|
||||
<Button
|
||||
variant="light"
|
||||
size="compact-xs"
|
||||
leftSection={<IconPlugConnected size={14} />}
|
||||
onClick={() =>
|
||||
openIntegrationModal({
|
||||
initialKind: target.integrationKind,
|
||||
initialName: candidate.container.name,
|
||||
initialUrl: normalizeServiceUrl(url) ?? url,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("action.setupIntegration")}
|
||||
</Button>
|
||||
)}
|
||||
{(target.kind === "reviewIntegration" || target.kind === "viewRepresentation") && (
|
||||
) : (
|
||||
<Button
|
||||
component={Link}
|
||||
href={target.href}
|
||||
variant="light"
|
||||
size="compact-xs"
|
||||
leftSection={target.kind === "reviewIntegration" ? <IconSettings size={14} /> : <IconEye size={14} />}
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
disabled={isActionDisabled}
|
||||
onClick={(event) => {
|
||||
if (isActionDisabled) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
{t(`action.${target.kind}`)}
|
||||
</Button>
|
||||
)}
|
||||
<Tooltip label={t("action.dismiss")} events={{ hover: true, focus: true, touch: false }}>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("action.dismiss")} onClick={onDismiss}>
|
||||
<IconEyeOff size={15} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
{candidate.representation.signals.ambiguous && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
||||
{t("ambiguous")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{visibleHealthLayers.length > 0 && (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="xs">
|
||||
{visibleHealthLayers.map((layer) => (
|
||||
<Group key={layer.layer} gap="xs" wrap="nowrap">
|
||||
<HealthStatusIcon status={layer.status} />
|
||||
<Text size="xs">
|
||||
{t(`health.layer.${layer.layer}`)} ·{" "}
|
||||
<Text component="span" c="dimmed" inherit>
|
||||
{t(`health.status.${layer.status}`)}
|
||||
</Text>
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{actionNeedsUrl && (
|
||||
<Autocomplete
|
||||
label={tGlobal("integration.field.url.label")}
|
||||
value={url}
|
||||
data={urlSuggestions}
|
||||
placeholder={t("url.manual")}
|
||||
onChange={(value) => {
|
||||
isUrlEdited.current = true;
|
||||
setUrl(value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const getCandidateTarget = (candidate: ReconciliationCandidate) => {
|
||||
const getCandidateTarget = (candidate: ReconciliationCandidate, url: string) => {
|
||||
if (candidate.representation.signals.ambiguous) {
|
||||
if (candidate.nextAction === "reviewIntegration" && candidate.match) {
|
||||
const params = new URLSearchParams({ search: getIntegrationName(candidate.match.kind) });
|
||||
@@ -389,7 +312,9 @@ const getCandidateTarget = (candidate: ReconciliationCandidate) => {
|
||||
return { kind: "viewRepresentation" as const, href: "/manage/apps" };
|
||||
}
|
||||
if (candidate.state === "newRecognized" && candidate.match) {
|
||||
return { kind: "setupIntegration" as const, integrationKind: candidate.match.kind };
|
||||
const params = new URLSearchParams({ kind: candidate.match.kind, name: candidate.container.name });
|
||||
if (url) params.set("url", url);
|
||||
return { kind: "setupIntegration" as const, href: `/manage/integrations/new?${params.toString()}` };
|
||||
}
|
||||
|
||||
if (candidate.state === "newApp") return { kind: "createApp" as const };
|
||||
@@ -412,21 +337,8 @@ const stateColor = (state: ReconciliationCandidate["state"]) => {
|
||||
return "blue";
|
||||
};
|
||||
|
||||
const HealthStatusIcon = ({ status }: { status: ServiceHealth["layers"][number]["status"] }) => {
|
||||
let color = "gray";
|
||||
let icon = <IconMinus size={12} />;
|
||||
|
||||
if (["available", "configured", "linked"].includes(status)) {
|
||||
color = "green";
|
||||
icon = <IconCheck size={12} />;
|
||||
} else if (["missing", "changed", "unused"].includes(status)) {
|
||||
color = "yellow";
|
||||
icon = <IconAlertTriangle size={12} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeIcon color={color} variant="light" radius="xl" size="sm">
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
);
|
||||
const healthStatusColor = (status: ServiceHealth["layers"][number]["status"]) => {
|
||||
if (["available", "configured", "linked"].includes(status)) return "green";
|
||||
if (["missing", "changed", "unused"].includes(status)) return "yellow";
|
||||
return "gray";
|
||||
};
|
||||
@@ -1,10 +1,8 @@
|
||||
import { headers } from "next/headers";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Stack, Title } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
||||
import { env } from "@homarr/docker/env";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
@@ -18,10 +16,7 @@ export default async function DockerPage() {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const [{ containers, endpoints, timestamp }, requestHeaders] = await Promise.all([
|
||||
api.docker.getContainers(),
|
||||
headers(),
|
||||
]);
|
||||
const { containers, endpoints, timestamp } = await api.docker.getContainers();
|
||||
const tDocker = await getScopedI18n("docker");
|
||||
|
||||
return (
|
||||
@@ -29,7 +24,7 @@ export default async function DockerPage() {
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{tDocker("title")}</Title>
|
||||
<DockerReconciliation defaultServerOrigin={extractBaseUrlFromHeaders(requestHeaders)} />
|
||||
<DockerReconciliation />
|
||||
<DockerTable initialData={{ containers, endpoints, timestamp }} />
|
||||
</Stack>
|
||||
</>
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button, Center, Group, Loader, Modal, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { getCustomWidgetSourceSetups } from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetSecretKind } from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
createCustomWidgetSourceSetupValues,
|
||||
CustomWidgetSourceSetupPanel,
|
||||
isCustomWidgetSourceSetupReady,
|
||||
} from "@homarr/custom-widgets/workbench";
|
||||
import type {
|
||||
CustomWidgetSourceSetupMessages,
|
||||
CustomWidgetSourceSetupValue,
|
||||
} from "@homarr/custom-widgets/workbench";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface CustomWidgetSourceSetupDialogProps {
|
||||
definitionId: string;
|
||||
opened: boolean;
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
export function CustomWidgetSourceSetupDialog({
|
||||
definitionId,
|
||||
opened,
|
||||
onClose,
|
||||
}: CustomWidgetSourceSetupDialogProps) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const router = useRouter();
|
||||
const utils = clientApi.useUtils();
|
||||
const definitionQuery = clientApi.customWidget.get.useQuery({ id: definitionId }, { enabled: opened });
|
||||
const configureMutation = clientApi.customWidget.sourceConfigure.useMutation();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [values, setValues] = useState<Record<string, CustomWidgetSourceSetupValue>>({});
|
||||
const setups = useMemo(
|
||||
() => getCustomWidgetSourceSetups(definitionQuery.data?.sources ?? {}, definitionQuery.data?.secrets ?? []),
|
||||
[definitionQuery.data],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (opened && definitionQuery.data) setValues(createCustomWidgetSourceSetupValues(setups));
|
||||
}, [definitionQuery.data, opened, setups]);
|
||||
|
||||
const messages: CustomWidgetSourceSetupMessages = {
|
||||
title: t("importReview.sourceSetup.title"),
|
||||
description: t("sourceSetupDialog.description"),
|
||||
suggestedUrl: t("importReview.sourceSetup.suggestedUrl"),
|
||||
baseUrl: t("workbench.sources.baseUrl"),
|
||||
networkScope: t("workbench.sources.networkScope"),
|
||||
authentication: t("workbench.sources.authentication"),
|
||||
confirmUrl: t("importReview.sourceSetup.confirmUrl"),
|
||||
ready: t("importReview.sourceSetup.ready"),
|
||||
needsUrl: t("importReview.sourceSetup.needsUrl"),
|
||||
credentialsMissing: t("importReview.sourceSetup.credentialsMissing"),
|
||||
credentialsOptional: t("sourceSetupDialog.credentialsRequired"),
|
||||
configured: t("workbench.sources.configured"),
|
||||
secret: (kind) => t(`importReview.credentials.field.${kind}`),
|
||||
urlError: (issue) => t(`workbench.sources.baseUrlError.${issue}`),
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!isCustomWidgetSourceSetupReady(setups, values) || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
for (const setup of setups) {
|
||||
const value = values[setup.sourceId];
|
||||
if (!value) continue;
|
||||
const secrets = Object.entries(value.secrets).flatMap(([kind, secret]) =>
|
||||
secret?.trim() ? [{ sourceId: setup.sourceId, kind: kind as CustomWidgetSecretKind, value: secret }] : [],
|
||||
);
|
||||
await configureMutation.mutateAsync({
|
||||
definitionId,
|
||||
sourceId: setup.sourceId,
|
||||
baseUrl: value.baseUrl,
|
||||
networkScope: value.networkScope,
|
||||
secrets,
|
||||
});
|
||||
}
|
||||
showSuccessNotification({
|
||||
title: t("action.configureSources"),
|
||||
message: t("notification.sourcesConfigured"),
|
||||
});
|
||||
await utils.customWidget.get.invalidate({ id: definitionId });
|
||||
await utils.customWidget.list.invalidate();
|
||||
await utils.customWidget.available.invalidate();
|
||||
await utils.widget.customApi.getData.invalidate();
|
||||
await revalidatePathActionAsync("/manage/custom-widgets");
|
||||
router.refresh();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
showErrorNotification({
|
||||
title: t("action.configureSources"),
|
||||
message: error instanceof Error ? error.message : t("notification.sourceConfigurationError"),
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={t("sourceSetupDialog.title")} size="lg">
|
||||
{definitionQuery.isPending ? (
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : definitionQuery.error ? (
|
||||
<Text c="red" size="sm">
|
||||
{definitionQuery.error.message}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
<CustomWidgetSourceSetupPanel
|
||||
setups={setups}
|
||||
values={values}
|
||||
messages={messages}
|
||||
onChange={(sourceId, value) => setValues((current) => ({ ...current, [sourceId]: value }))}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose} disabled={saving}>
|
||||
{t("sourceSetupDialog.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void save()}
|
||||
loading={saving}
|
||||
disabled={!isCustomWidgetSourceSetupReady(setups, values)}
|
||||
>
|
||||
{t("sourceSetupDialog.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Group, ScrollArea, Title, UnstyledButton } from "@mantine/core";
|
||||
import { IconArrowLeft, IconX } from "@tabler/icons-react";
|
||||
import { Group, ScrollArea, Title } from "@mantine/core";
|
||||
import { IconArrowLeft } from "@tabler/icons-react";
|
||||
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
|
||||
import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form";
|
||||
@@ -20,15 +19,11 @@ interface IntegrationSelectModalProps {
|
||||
enableMockIntegration?: boolean;
|
||||
allowedKinds?: readonly IntegrationKind[];
|
||||
completionBoardId?: string;
|
||||
initialKind?: IntegrationKind;
|
||||
initialUrl?: string;
|
||||
initialName?: string;
|
||||
}
|
||||
|
||||
export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const [step, setStep] = useState<"select" | "form">(innerProps.initialKind ? "form" : "select");
|
||||
const [selectedKind, setSelectedKind] = useState<IntegrationKind | null>(innerProps.initialKind ?? null);
|
||||
const [step, setStep] = useState<"select" | "form">("select");
|
||||
const [selectedKind, setSelectedKind] = useState<IntegrationKind | null>(null);
|
||||
const { openModal: openCompletionModal } = useModalAction(IntegrationCompletionModal);
|
||||
|
||||
const handleSelect = (kind: IntegrationKind) => {
|
||||
@@ -40,7 +35,6 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
|
||||
setStep("select");
|
||||
setSelectedKind(null);
|
||||
};
|
||||
const handleFormBack = innerProps.initialKind ? actions.closeModal : handleBack;
|
||||
|
||||
const handleSuccess = (result?: CreatedIntegrationResult) => {
|
||||
actions.closeModal();
|
||||
@@ -52,29 +46,14 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
|
||||
};
|
||||
|
||||
if (step === "form" && selectedKind) {
|
||||
let HeaderIcon = IconArrowLeft;
|
||||
let headerLabel = t("common.action.previous");
|
||||
if (innerProps.initialKind) {
|
||||
HeaderIcon = IconX;
|
||||
headerLabel = t("common.action.close");
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea.Autosize mah="80vh">
|
||||
<UnstyledButton mb="md" onClick={handleFormBack} aria-label={headerLabel}>
|
||||
<Group gap="xs">
|
||||
<HeaderIcon size={18} />
|
||||
<IntegrationAvatar kind={selectedKind} size="sm" />
|
||||
<Title order={4}>{getIntegrationName(selectedKind)}</Title>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
<NewIntegrationForm
|
||||
kind={selectedKind}
|
||||
initialUrl={innerProps.initialUrl}
|
||||
initialName={innerProps.initialName}
|
||||
onSuccess={handleSuccess}
|
||||
onCancel={handleFormBack}
|
||||
/>
|
||||
<Group gap="xs" mb="md" style={{ cursor: "pointer" }} onClick={handleBack}>
|
||||
<IconArrowLeft size={18} />
|
||||
<IntegrationAvatar kind={selectedKind} size="sm" />
|
||||
<Title order={4}>{getIntegrationName(selectedKind)}</Title>
|
||||
</Group>
|
||||
<NewIntegrationForm kind={selectedKind} onSuccess={handleSuccess} onCancel={handleBack} />
|
||||
</ScrollArea.Autosize>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ export interface ManageCollectionItemProps {
|
||||
metadata?: ReactNode;
|
||||
badges?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
actionsAlignment?: "flex-start" | "center";
|
||||
}
|
||||
|
||||
export const ManageCollectionItem = ({
|
||||
@@ -47,7 +46,6 @@ export const ManageCollectionItem = ({
|
||||
metadata,
|
||||
badges,
|
||||
actions,
|
||||
actionsAlignment = "flex-start",
|
||||
}: ManageCollectionItemProps) => (
|
||||
<Paper component="li" p="sm" withBorder radius="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap" gap="sm">
|
||||
@@ -61,7 +59,7 @@ export const ManageCollectionItem = ({
|
||||
{metadata}
|
||||
</Stack>
|
||||
{actions && (
|
||||
<Group gap={4} wrap="nowrap" style={{ flexShrink: 0, alignSelf: actionsAlignment }}>
|
||||
<Group gap={4} wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
{actions}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
@@ -23,11 +23,6 @@ const dockerContainerTargetSchema = z.object({
|
||||
});
|
||||
const dockerContainerTargetsSchema = z.array(dockerContainerTargetSchema).min(1).max(100);
|
||||
|
||||
const normalizeGetContainersInput = (input: { endpointIds?: string[] } | undefined) => {
|
||||
if (!input?.endpointIds || input.endpointIds.length === 0) return {};
|
||||
return input;
|
||||
};
|
||||
|
||||
export const dockerRouter = createTRPCRouter({
|
||||
reconcileServices: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
@@ -70,14 +65,12 @@ export const dockerRouter = createTRPCRouter({
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"List Docker containers with their state, image, CPU/memory usage, and ports. Optionally filter by endpoint IDs; omitted or empty means all endpoints.",
|
||||
description: "List all Docker containers with their state, image, CPU/memory usage, and ports",
|
||||
},
|
||||
})
|
||||
.concat(dockerMiddleware())
|
||||
.input(z.object({ endpointIds: z.array(z.string().min(1)).max(100).optional() }).optional())
|
||||
.query(async ({ input }) => {
|
||||
const innerHandler = dockerContainersRequestHandler.handler(normalizeGetContainersInput(input));
|
||||
.query(async () => {
|
||||
const innerHandler = dockerContainersRequestHandler.handler({});
|
||||
const result = await innerHandler.getDataAsync();
|
||||
|
||||
const { data, timestamp } = result;
|
||||
|
||||
@@ -124,7 +124,7 @@ export const integrationDefs = {
|
||||
readarr: {
|
||||
name: "Readarr",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@main/png/readarr.png",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/readarr.svg",
|
||||
category: ["calendar"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/readarr"),
|
||||
defaultPort: 8787,
|
||||
|
||||
@@ -20,9 +20,7 @@ const sessionSchema = z.object({
|
||||
SeriesName: z.string().nullish(),
|
||||
Name: z.string().nullish(),
|
||||
SeasonName: z.string().nullish(),
|
||||
ParentIndexNumber: z.number().nullish(),
|
||||
EpisodeTitle: z.string().nullish(),
|
||||
IndexNumber: z.number().nullish(),
|
||||
Album: z.string().nullish(),
|
||||
EpisodeCount: z.number().nullish(),
|
||||
})
|
||||
@@ -116,9 +114,7 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
|
||||
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
|
||||
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
|
||||
seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "",
|
||||
seasonNumber: sessionInfo.NowPlayingItem.ParentIndexNumber ?? null,
|
||||
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
|
||||
episodeNumber: sessionInfo.NowPlayingItem.IndexNumber ?? null,
|
||||
albumName: sessionInfo.NowPlayingItem.Album ?? "",
|
||||
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
|
||||
playback: {
|
||||
|
||||
@@ -10,9 +10,7 @@ export interface StreamSession {
|
||||
type: "audio" | "video" | "tv" | "movie";
|
||||
name: string;
|
||||
seasonName: string | undefined;
|
||||
seasonNumber?: number | null;
|
||||
episodeName?: string | null;
|
||||
episodeNumber?: number | null;
|
||||
albumName?: string | null;
|
||||
episodeCount?: number | null;
|
||||
playback: {
|
||||
|
||||
@@ -64,9 +64,7 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
|
||||
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
|
||||
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
|
||||
seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "",
|
||||
seasonNumber: sessionInfo.NowPlayingItem.ParentIndexNumber ?? null,
|
||||
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
|
||||
episodeNumber: sessionInfo.NowPlayingItem.IndexNumber ?? null,
|
||||
albumName: sessionInfo.NowPlayingItem.Album ?? "",
|
||||
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
|
||||
playback: {
|
||||
|
||||
@@ -26,9 +26,7 @@ const sessions: StreamSession[] = [
|
||||
type: "video",
|
||||
name: "Breaking Bad",
|
||||
seasonName: "Season 5",
|
||||
seasonNumber: 5,
|
||||
episodeName: "Ozymandias",
|
||||
episodeNumber: 14,
|
||||
albumName: null,
|
||||
episodeCount: null,
|
||||
playback: { state: null, positionMs: null, durationMs: null },
|
||||
@@ -60,9 +58,7 @@ const sessions: StreamSession[] = [
|
||||
type: "video",
|
||||
name: "Severance",
|
||||
seasonName: "Season 2",
|
||||
seasonNumber: 2,
|
||||
episodeName: "The We We Are",
|
||||
episodeNumber: 10,
|
||||
albumName: null,
|
||||
episodeCount: null,
|
||||
playback: { state: null, positionMs: null, durationMs: null },
|
||||
|
||||
@@ -80,9 +80,8 @@ interface Session {
|
||||
$: {
|
||||
grandparentTitle?: string;
|
||||
parentTitle?: string;
|
||||
parentIndex?: string;
|
||||
title?: string;
|
||||
index?: string;
|
||||
index?: number;
|
||||
type: string;
|
||||
live?: string;
|
||||
viewOffset?: string;
|
||||
|
||||
@@ -115,11 +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: parseOptionalNumber(mediaElement.$.parentIndex),
|
||||
episodeName: mediaElement.$.title ?? null,
|
||||
episodeNumber: parseOptionalNumber(mediaElement.$.index),
|
||||
albumName: mediaElement.$.type === "track" ? (mediaElement.$.parentTitle ?? null) : null,
|
||||
episodeCount: parseOptionalNumber(mediaElement.$.index),
|
||||
episodeCount: mediaElement.$.index ?? null,
|
||||
playback: {
|
||||
state: playbackState,
|
||||
positionMs,
|
||||
|
||||
@@ -8,35 +8,35 @@ import { useZodForm } from "@homarr/form";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { appHrefSchema } from "@homarr/validation/app";
|
||||
|
||||
interface AddDockerAppToHomarrProps {
|
||||
selectedContainers: RouterOutputs["docker"]["getContainers"]["containers"];
|
||||
initialUrls?: (string | null)[];
|
||||
initialUrls?: string[];
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
containerUrls: z.array(appHrefSchema),
|
||||
});
|
||||
|
||||
export const AddDockerAppToHomarrModal = createModal<AddDockerAppToHomarrProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const form = useZodForm(formSchema, {
|
||||
initialValues: {
|
||||
containerUrls: innerProps.selectedContainers.map((container, index) => {
|
||||
const initialUrl = innerProps.initialUrls?.[index];
|
||||
if (initialUrl !== undefined) return initialUrl;
|
||||
const form = useZodForm(
|
||||
z.object({
|
||||
containerUrls: z.array(z.string().url().nullable()),
|
||||
}),
|
||||
{
|
||||
initialValues: {
|
||||
containerUrls: innerProps.selectedContainers.map((container, index) => {
|
||||
const initialUrl = innerProps.initialUrls?.[index];
|
||||
if (initialUrl) return initialUrl;
|
||||
|
||||
const candidates = buildDockerServiceUrlCandidates({
|
||||
containerName: container.name,
|
||||
endpointHost: container.host,
|
||||
ports: container.ports,
|
||||
}).filter(({ url }) => url.length > 0);
|
||||
const candidates = buildDockerServiceUrlCandidates({
|
||||
containerName: container.name,
|
||||
endpointHost: container.host,
|
||||
ports: container.ports,
|
||||
}).filter(({ url }) => url.length > 0);
|
||||
|
||||
return candidates.find(({ scopes }) => scopes.includes("browser"))?.url ?? candidates[0]?.url ?? null;
|
||||
}),
|
||||
return candidates.find(({ scopes }) => scopes.includes("browser"))?.url ?? candidates[0]?.url ?? null;
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
const { mutate, isPending } = clientApi.app.createMany.useMutation({
|
||||
onSuccess() {
|
||||
actions.closeModal();
|
||||
@@ -52,13 +52,13 @@ export const AddDockerAppToHomarrModal = createModal<AddDockerAppToHomarrProps>(
|
||||
});
|
||||
},
|
||||
});
|
||||
const handleSubmit = ({ containerUrls }: z.infer<typeof formSchema>) => {
|
||||
const handleSubmit = () => {
|
||||
mutate(
|
||||
innerProps.selectedContainers.map((container, index) => ({
|
||||
name: container.name,
|
||||
iconUrl: container.iconUrl,
|
||||
description: null,
|
||||
href: containerUrls[index] || null,
|
||||
href: form.values.containerUrls[index] ?? null,
|
||||
pingUrl: null,
|
||||
})),
|
||||
);
|
||||
@@ -68,31 +68,28 @@ export const AddDockerAppToHomarrModal = createModal<AddDockerAppToHomarrProps>(
|
||||
<LoadingOverlay visible={isPending} />
|
||||
<Stack>
|
||||
<List spacing={"xs"}>
|
||||
{innerProps.selectedContainers.map((container, index) => {
|
||||
const inputProps = form.getInputProps(`containerUrls.${index}`);
|
||||
return (
|
||||
<List.Item
|
||||
styles={{ itemWrapper: { width: "100%" }, itemLabel: { flex: 1 } }}
|
||||
icon={
|
||||
<Avatar
|
||||
variant="outline"
|
||||
radius={container.iconUrl ? "sm" : "md"}
|
||||
size={30}
|
||||
styles={{ image: { objectFit: "contain" } }}
|
||||
src={container.iconUrl}
|
||||
>
|
||||
{container.name.at(0)?.toUpperCase()}
|
||||
</Avatar>
|
||||
}
|
||||
key={container.id}
|
||||
>
|
||||
<Group justify="space-between" wrap={"nowrap"}>
|
||||
<Text lineClamp={1}>{container.name}</Text>
|
||||
<TextInput {...inputProps} value={inputProps.value ?? ""} />
|
||||
</Group>
|
||||
</List.Item>
|
||||
);
|
||||
})}
|
||||
{innerProps.selectedContainers.map((container, index) => (
|
||||
<List.Item
|
||||
styles={{ itemWrapper: { width: "100%" }, itemLabel: { flex: 1 } }}
|
||||
icon={
|
||||
<Avatar
|
||||
variant="outline"
|
||||
radius={container.iconUrl ? "sm" : "md"}
|
||||
size={30}
|
||||
styles={{ image: { objectFit: "contain" } }}
|
||||
src={container.iconUrl}
|
||||
>
|
||||
{container.name.at(0)?.toUpperCase()}
|
||||
</Avatar>
|
||||
}
|
||||
key={container.id}
|
||||
>
|
||||
<Group justify="space-between" wrap={"nowrap"}>
|
||||
<Text lineClamp={1}>{container.name}</Text>
|
||||
<TextInput {...form.getInputProps(`containerUrls.${index}`)} />
|
||||
</Group>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
<Group justify="end">
|
||||
<Button onClick={actions.closeModal} variant="light" px={"xl"}>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export { OnboardingAuthShell } from "./onboarding-auth-shell";
|
||||
export { OnboardingStudio } from "./onboarding-studio";
|
||||
export { normalizeServiceUrl } from "./discovery-selection";
|
||||
export { ServiceUrlTemplate } from "./service-url-template";
|
||||
export type { OnboardingEnvironment, OnboardingStudioProps } from "./types";
|
||||
@@ -358,7 +358,6 @@
|
||||
display: flex;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
margin: 0;
|
||||
padding: 0.2rem;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: var(--mantine-color-default);
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { FloatingIndicator, Stack, TextInput, UnstyledButton } from "@mantine/core";
|
||||
|
||||
import type { UrlTemplateMode } from "@homarr/definitions";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useOnboardingSounds } from "./use-onboarding-sounds";
|
||||
import classes from "./onboarding-studio.module.css";
|
||||
|
||||
interface FloatingControlProps<T extends string> {
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
options: readonly { value: T; label: ReactNode }[];
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
export const OnboardingFloatingControl = <T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
ariaLabel,
|
||||
}: FloatingControlProps<T>) => {
|
||||
const [rootRef, setRootRef] = useState<HTMLFieldSetElement | null>(null);
|
||||
const controlRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
const sounds = useOnboardingSounds();
|
||||
|
||||
return (
|
||||
<div className={classes.floatingControlScroller}>
|
||||
<fieldset ref={setRootRef} className={classes.floatingControlRoot} aria-label={ariaLabel}>
|
||||
<FloatingIndicator
|
||||
target={controlRefs.current[value] ?? null}
|
||||
parent={rootRef}
|
||||
className={classes.floatingControlIndicator}
|
||||
/>
|
||||
{options.map((option) => {
|
||||
const selected = value === option.value;
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={option.value}
|
||||
ref={(node) => {
|
||||
controlRefs.current[option.value] = node;
|
||||
}}
|
||||
type="button"
|
||||
className={classes.floatingControl}
|
||||
data-active={selected}
|
||||
aria-pressed={selected}
|
||||
onClick={() => {
|
||||
sounds.click();
|
||||
onChange(option.value);
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ServiceUrlTemplateProps {
|
||||
serverOrigin: string;
|
||||
onServerOriginChange: (value: string) => void;
|
||||
mode: UrlTemplateMode;
|
||||
onModeChange: (value: UrlTemplateMode) => void;
|
||||
readOnly?: boolean;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export const ServiceUrlTemplate = ({
|
||||
serverOrigin,
|
||||
onServerOriginChange,
|
||||
mode,
|
||||
onModeChange,
|
||||
readOnly,
|
||||
required,
|
||||
}: ServiceUrlTemplateProps) => {
|
||||
const t = useScopedI18n("common.serviceUrlTemplate");
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label={t("serverTitle")}
|
||||
placeholder="home.lan · 192.168.1.10 · https://homarr.example.com"
|
||||
value={serverOrigin}
|
||||
onChange={(event) => onServerOriginChange(event.currentTarget.value)}
|
||||
readOnly={readOnly}
|
||||
required={required}
|
||||
withAsterisk={required}
|
||||
/>
|
||||
<OnboardingFloatingControl
|
||||
ariaLabel={t("urlModeLabel")}
|
||||
value={mode}
|
||||
onChange={onModeChange}
|
||||
options={[
|
||||
{ value: "hostPort", label: t("hostPort") },
|
||||
{ value: "subdomain", label: t("subdomain") },
|
||||
{ value: "path", label: t("path") },
|
||||
]}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
ColorSwatch,
|
||||
CopyButton,
|
||||
Fieldset,
|
||||
FloatingIndicator,
|
||||
Group,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
@@ -98,7 +99,6 @@ import type { OnboardingStudioProps } from "./types";
|
||||
import { getBoardValidationErrors } from "./board-validation";
|
||||
import { OnboardingBackdrop } from "./onboarding-backdrop";
|
||||
import { OnboardingWordmark } from "./onboarding-wordmark";
|
||||
import { OnboardingFloatingControl, ServiceUrlTemplate } from "./service-url-template";
|
||||
import { useOnboardingSounds } from "./use-onboarding-sounds";
|
||||
import {
|
||||
normalizeServiceUrl,
|
||||
@@ -152,6 +152,57 @@ const initialSecondaryColor = "#fd7e14";
|
||||
const emptyDiscoveredIntegrations: DockerDiscoveryData["integrations"] = [];
|
||||
const emptyDiscoveredApps: DockerDiscoveryData["apps"] = [];
|
||||
|
||||
interface OnboardingFloatingControlProps<T extends string> {
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
options: readonly { value: T; label: ReactNode }[];
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
const OnboardingFloatingControl = <T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
ariaLabel,
|
||||
}: OnboardingFloatingControlProps<T>) => {
|
||||
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
|
||||
const controlRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
const sounds = useOnboardingSounds();
|
||||
|
||||
return (
|
||||
<div className={classes.floatingControlScroller}>
|
||||
<div ref={setRootRef} className={classes.floatingControlRoot} role="group" aria-label={ariaLabel}>
|
||||
<FloatingIndicator
|
||||
target={controlRefs.current[value] ?? null}
|
||||
parent={rootRef}
|
||||
className={classes.floatingControlIndicator}
|
||||
/>
|
||||
{options.map((option) => {
|
||||
const selected = value === option.value;
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={option.value}
|
||||
ref={(node) => {
|
||||
controlRefs.current[option.value] = node;
|
||||
}}
|
||||
type="button"
|
||||
className={classes.floatingControl}
|
||||
data-active={selected}
|
||||
aria-pressed={selected}
|
||||
onClick={() => {
|
||||
sounds.click();
|
||||
onChange(option.value);
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingStudioProps) => {
|
||||
const t = useScopedI18n("init.studio");
|
||||
const tCommon = useScopedI18n("common.action");
|
||||
@@ -873,13 +924,24 @@ const Essentials = (props: StudioSectionProps) => {
|
||||
<Alert variant="light" icon={<IconInfoCircle size={18} />} my="xs">
|
||||
{t("serverOriginHelp")}
|
||||
</Alert>
|
||||
<ServiceUrlTemplate
|
||||
serverOrigin={props.serverOrigin}
|
||||
onServerOriginChange={props.setServerOrigin}
|
||||
mode={props.urlMode}
|
||||
onModeChange={props.setUrlMode}
|
||||
<TextInput
|
||||
label={t("serverTitle")}
|
||||
placeholder="home.lan · 192.168.1.10 · https://homarr.example.com"
|
||||
value={props.serverOrigin}
|
||||
onChange={(event) => props.setServerOrigin(event.currentTarget.value)}
|
||||
readOnly={!isHydrated}
|
||||
required
|
||||
withAsterisk
|
||||
/>
|
||||
<OnboardingFloatingControl
|
||||
ariaLabel={t("urlModeLabel")}
|
||||
value={props.urlMode}
|
||||
onChange={props.setUrlMode}
|
||||
options={[
|
||||
{ value: "hostPort", label: t("hostPort") },
|
||||
{ value: "subdomain", label: t("subdomain") },
|
||||
{ value: "path", label: t("path") },
|
||||
]}
|
||||
/>
|
||||
{props.serverOrigin ? (
|
||||
<Code block>{buildIntegrationUrl("sonarr", props.serverOrigin, props.urlMode)}</Code>
|
||||
|
||||
@@ -195,24 +195,9 @@ const mockContainers: {
|
||||
},
|
||||
];
|
||||
|
||||
const demoDockerEndpoint = {
|
||||
id: "demo",
|
||||
name: "Demo Docker",
|
||||
status: "available",
|
||||
kind: "docker",
|
||||
transport: "socket",
|
||||
capabilities: ["inventory"],
|
||||
source: "default",
|
||||
scope: "admin",
|
||||
} satisfies DockerEndpointStatus;
|
||||
|
||||
export const dockerContainersRequestHandler = createWidgetRequestHandler({
|
||||
async requestAsync({ endpointIds }: { endpointIds?: string[] }) {
|
||||
async requestAsync() {
|
||||
if (isDemoMode()) {
|
||||
const includesDemoEndpoint =
|
||||
endpointIds === undefined || endpointIds.length === 0 || endpointIds.includes("demo");
|
||||
if (!includesDemoEndpoint) return { containers: [], endpoints: [] };
|
||||
|
||||
return {
|
||||
containers: mockContainers.map((container) => ({
|
||||
...container,
|
||||
@@ -220,10 +205,21 @@ export const dockerContainersRequestHandler = createWidgetRequestHandler({
|
||||
endpointName: "Demo Docker",
|
||||
resourceId: `demo:${container.id}`,
|
||||
})),
|
||||
endpoints: [demoDockerEndpoint],
|
||||
endpoints: [
|
||||
{
|
||||
id: "demo",
|
||||
name: "Demo Docker",
|
||||
status: "available",
|
||||
kind: "docker",
|
||||
transport: "socket",
|
||||
capabilities: ["inventory"],
|
||||
source: "default",
|
||||
scope: "admin",
|
||||
},
|
||||
] satisfies DockerEndpointStatus[],
|
||||
};
|
||||
}
|
||||
return await getContainersWithStatsAsync(dockerWidgetEndpointTimeoutMs, endpointIds);
|
||||
return await getContainersWithStatsAsync();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -308,8 +304,6 @@ export const streamContainerLogsAsync = async (
|
||||
};
|
||||
|
||||
export const getDockerEndpointsAsync = (): DockerEndpointStatus[] => {
|
||||
if (isDemoMode()) return [demoDockerEndpoint];
|
||||
|
||||
const dockerInstances = DockerSingleton.getInstances();
|
||||
const initializationFailures = DockerSingleton.getInitializationFailures();
|
||||
|
||||
@@ -337,18 +331,9 @@ export const getDockerEndpointsAsync = (): DockerEndpointStatus[] => {
|
||||
];
|
||||
};
|
||||
|
||||
export async function getContainersWithStatsAsync(
|
||||
timeoutMs = dockerWidgetEndpointTimeoutMs,
|
||||
endpointIds: string[] = [],
|
||||
) {
|
||||
const selectedEndpointIds = new Set(endpointIds);
|
||||
const includesAllEndpoints = selectedEndpointIds.size === 0;
|
||||
const dockerInstances = DockerSingleton.getInstances().filter(
|
||||
({ endpointId }) => includesAllEndpoints || selectedEndpointIds.has(endpointId),
|
||||
);
|
||||
const initializationFailures = DockerSingleton.getInitializationFailures().filter(
|
||||
({ descriptor }) => includesAllEndpoints || selectedEndpointIds.has(descriptor.id),
|
||||
);
|
||||
export async function getContainersWithStatsAsync(timeoutMs = dockerWidgetEndpointTimeoutMs) {
|
||||
const dockerInstances = DockerSingleton.getInstances();
|
||||
const initializationFailures = DockerSingleton.getInitializationFailures();
|
||||
const results = await Promise.allSettled(
|
||||
dockerInstances.map(async ({ instance, host, endpointId, endpointName }) => {
|
||||
const instanceContainers = await withDockerTimeoutAsync(
|
||||
|
||||
@@ -7,8 +7,6 @@ import { DockerSingleton } from "@homarr/docker";
|
||||
import {
|
||||
calculateCpuUsage,
|
||||
calculateMemoryUsage,
|
||||
dockerContainersRequestHandler,
|
||||
getDockerEndpointsAsync,
|
||||
getContainersWithStatsAsync,
|
||||
hasDockerEndpointCapability,
|
||||
} from "../docker";
|
||||
@@ -129,31 +127,6 @@ describe("calculateMemoryUsage", () => {
|
||||
});
|
||||
|
||||
describe("getContainersWithStatsAsync", () => {
|
||||
test("queries only selected Docker endpoints and leaves an empty selection as all", async () => {
|
||||
const firstListContainers = vi.fn(async () => []);
|
||||
const secondListContainers = vi.fn(async () => []);
|
||||
vi.spyOn(DockerSingleton, "getInstances").mockReturnValue([
|
||||
createDockerInstance("first", firstListContainers),
|
||||
createDockerInstance("second", secondListContainers),
|
||||
] as never);
|
||||
vi.spyOn(DockerSingleton, "getInitializationFailures").mockReturnValue([]);
|
||||
|
||||
try {
|
||||
const selectedResult = await getContainersWithStatsAsync(50, ["second"]);
|
||||
|
||||
expect(firstListContainers).not.toHaveBeenCalled();
|
||||
expect(secondListContainers).toHaveBeenCalledOnce();
|
||||
expect(selectedResult.endpoints.map(({ id }) => id)).toEqual(["second"]);
|
||||
|
||||
await getContainersWithStatsAsync(50, []);
|
||||
|
||||
expect(firstListContainers).toHaveBeenCalledOnce();
|
||||
expect(secondListContainers).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
test("marks a timed-out endpoint unavailable without blocking healthy endpoints", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
@@ -238,26 +211,14 @@ describe("getContainersWithStatsAsync", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("demo mode advertises and filters the same inventory-only endpoint", async () => {
|
||||
test("demo capabilities match the advertised inventory-only endpoint", () => {
|
||||
const previousDemoMode = process.env.DEMO_MODE;
|
||||
process.env.DEMO_MODE = "true";
|
||||
try {
|
||||
expect(getDockerEndpointsAsync()).toEqual([
|
||||
expect.objectContaining({ id: "demo", name: "Demo Docker", capabilities: ["inventory"] }),
|
||||
]);
|
||||
|
||||
const selected = await dockerContainersRequestHandler.handler({ endpointIds: ["demo"] }).getDataAsync();
|
||||
expect(selected.data.endpoints).toEqual([expect.objectContaining({ id: "demo" })]);
|
||||
expect(selected.data.containers.length).toBeGreaterThan(0);
|
||||
|
||||
const excluded = await dockerContainersRequestHandler.handler({ endpointIds: ["other"] }).getDataAsync();
|
||||
expect(excluded.data).toEqual({ containers: [], endpoints: [] });
|
||||
|
||||
expect(hasDockerEndpointCapability("demo", "inventory")).toBe(true);
|
||||
expect(hasDockerEndpointCapability("demo", "logs")).toBe(false);
|
||||
expect(hasDockerEndpointCapability("other", "inventory")).toBe(false);
|
||||
} finally {
|
||||
dockerContainersRequestHandler.invalidateCache();
|
||||
if (previousDemoMode === undefined) delete process.env.DEMO_MODE;
|
||||
else process.env.DEMO_MODE = previousDemoMode;
|
||||
}
|
||||
|
||||
@@ -66,10 +66,15 @@
|
||||
"themeAuto": "System",
|
||||
"themeLight": "Light",
|
||||
"themeDark": "Dark",
|
||||
"serverTitle": "Usual server address",
|
||||
"serverOriginHelp": "Homarr uses this address to prefill detected service URLs. You can still edit every service individually.",
|
||||
"serverDescription": "This is a shortcut, not a restriction. Every detected service remains editable.",
|
||||
"serverOrigin": "Domain, IP, or reverse-proxy path",
|
||||
"serverOriginDescription": "Examples: home.lan, 192.168.1.10, or https://home.example.com/services. Choose how each service is appended below.",
|
||||
"urlModeLabel": "Service address pattern",
|
||||
"hostPort": "Same host, different ports",
|
||||
"subdomain": "One subdomain per service",
|
||||
"path": "One path per service",
|
||||
"analytics": "Share anonymous product analytics",
|
||||
"analyticsDescription": "Helps Homarr improve feature reliability. No service credentials or dashboard content are sent."
|
||||
},
|
||||
@@ -896,7 +901,7 @@
|
||||
"migrationDescription": "This v1 custom widget is preserved but cannot run until it is migrated to Custom JSX v2.",
|
||||
"migrationInstructions": "Open Actions to copy or export the original v1 definition for manual recreation. You can also use the migration prompt with an LLM. Existing board placements keep working after migration.",
|
||||
"missingCredentials": "{count, plural, one {Credential missing} other {# credentials missing}}",
|
||||
"missingCredentialsDescription": "{count, plural, one {This widget still needs one credential.} other {This widget still needs # credentials.}} Select this badge to configure the URL and encrypted values."
|
||||
"missingCredentialsDescription": "{count, plural, one {This widget still needs one credential.} other {This widget still needs # credentials.}} Edit it to configure the encrypted values."
|
||||
},
|
||||
"create": {
|
||||
"title": "Create Custom Widget",
|
||||
@@ -929,7 +934,6 @@
|
||||
"downloadSkill": "View Custom Widget Skill",
|
||||
"publishWorkshop": "Publish to Workshop",
|
||||
"migrate": "Migrate Custom Widget",
|
||||
"configureSources": "Configure sources",
|
||||
"copyLegacyDefinition": "Copy original widget content",
|
||||
"exportLegacyDefinition": "Export original widget content",
|
||||
"copyMigrationPrompt": "Copy LLM migration prompt",
|
||||
@@ -943,13 +947,6 @@
|
||||
"removeItem": "Remove item",
|
||||
"addBar": "Add Bar"
|
||||
},
|
||||
"sourceSetupDialog": {
|
||||
"title": "Configure widget sources",
|
||||
"description": "Review where this widget connects and add or replace its encrypted credentials.",
|
||||
"credentialsRequired": "Requests will fail until the required credentials are added.",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save configuration"
|
||||
},
|
||||
"field": {
|
||||
"name": "Name",
|
||||
"description": "Description",
|
||||
@@ -1301,8 +1298,6 @@
|
||||
"legacyDefinitionCopyError": "Failed to copy the original widget content.",
|
||||
"migrationPromptCopied": "A redacted migration prompt was copied. Paste it into an LLM, copy its single JSON response, then choose Paste migrated widget here.",
|
||||
"migrationPromptCopyError": "Failed to copy the migration prompt.",
|
||||
"sourcesConfigured": "Widget sources and credentials saved.",
|
||||
"sourceConfigurationError": "Failed to configure widget sources.",
|
||||
"aiPromptCopied": "AI prompt copied to clipboard",
|
||||
"aiPromptCopiedWithCount": "AI prompt copied to clipboard ({count, number} characters).",
|
||||
"aiPromptCopyError": "Failed to copy prompt to clipboard. Your browser may not support this feature.",
|
||||
@@ -2132,13 +2127,6 @@
|
||||
"undo": "Undo"
|
||||
},
|
||||
"here": "here",
|
||||
"serviceUrlTemplate": {
|
||||
"serverTitle": "Usual server address",
|
||||
"urlModeLabel": "Service address pattern",
|
||||
"hostPort": "Same host, different ports",
|
||||
"subdomain": "One subdomain per service",
|
||||
"path": "One path per service"
|
||||
},
|
||||
"iconPicker": {
|
||||
"label": "Icon URL",
|
||||
"description": "Search the library, paste an image URL, or upload a file.",
|
||||
@@ -3739,11 +3727,6 @@
|
||||
"name": "Docker stats",
|
||||
"description": "Stats of your containers (This widget can only be added with administrator privileges)",
|
||||
"option": {
|
||||
"endpointIds": {
|
||||
"label": "Docker environments",
|
||||
"description": "Leave empty to show containers from every Docker environment",
|
||||
"placeholder": "All Docker environments"
|
||||
},
|
||||
"columns": {
|
||||
"label": "Columns to show"
|
||||
},
|
||||
@@ -4137,7 +4120,6 @@
|
||||
"transcoding": "Transcoding",
|
||||
"paused": "Paused",
|
||||
"remaining": "{minutes} min left",
|
||||
"seasonEpisode": "S{season} E{episode}",
|
||||
"metadata": {
|
||||
"title": "Stats for nerds",
|
||||
"video": {
|
||||
@@ -5692,7 +5674,6 @@
|
||||
},
|
||||
"dynamicSelect": {
|
||||
"noResults": "No results found",
|
||||
"loading": "Loading...",
|
||||
"loadError": "Failed to load options"
|
||||
},
|
||||
"assistant": {
|
||||
@@ -7080,18 +7061,23 @@
|
||||
"docker": {
|
||||
"title": "Containers",
|
||||
"reconciliation": {
|
||||
"title": "Assisted Docker setup",
|
||||
"suggestions": "{count, plural, =0 {No suggestions} one {# suggestion} other {# suggestions}}",
|
||||
"title": "Service setup",
|
||||
"description": "Docker services are compared with your apps and integrations. Review every suggestion before Homarr changes anything.",
|
||||
"attention": "{count} need attention",
|
||||
"endpointUnavailable": "Some services could not be checked because these endpoints are unavailable: {names}",
|
||||
"empty": "No visible Docker services were found.",
|
||||
"emptyFilter": "No services match this view.",
|
||||
"ambiguous": "More than one existing item could match this service. Homarr will not choose one automatically.",
|
||||
"match": "{confidence} confidence match",
|
||||
"filter": {
|
||||
"attention": "Needs attention",
|
||||
"represented": "Already set up",
|
||||
"all": "All"
|
||||
},
|
||||
"confidence": {
|
||||
"high": "High",
|
||||
"medium": "Medium"
|
||||
},
|
||||
"state": {
|
||||
"newRecognized": "Ready to connect",
|
||||
"newApp": "New service",
|
||||
@@ -7100,9 +7086,19 @@
|
||||
"moved": "Address changed"
|
||||
},
|
||||
"url": {
|
||||
"manual": "Enter an address manually"
|
||||
"label": "Service address",
|
||||
"inputLabel": "Service URL",
|
||||
"manual": "Enter an address manually",
|
||||
"invalid": "Enter a valid HTTP or HTTPS URL"
|
||||
},
|
||||
"reason": {
|
||||
"publishedAddress": "Published address reported by Docker",
|
||||
"dockerEndpointHost": "Docker endpoint host combined with the published port",
|
||||
"sharedContainerNetwork": "Server-only address for services sharing the container network",
|
||||
"manualHostRequired": "Enter the address that your browser or Homarr server can reach"
|
||||
},
|
||||
"health": {
|
||||
"title": "Observed service layers",
|
||||
"layer": {
|
||||
"docker": "Docker",
|
||||
"integrationConfiguration": "Integration",
|
||||
@@ -7124,15 +7120,17 @@
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"review": "Review",
|
||||
"refresh": "Refresh",
|
||||
"retry": "Try again",
|
||||
"dismiss": "Dismiss",
|
||||
"restoreDismissed": "Restore dismissed ({count})",
|
||||
"setupIntegration": "Set up integration",
|
||||
"createApp": "Create app",
|
||||
"reviewIntegration": "Review integration",
|
||||
"viewRepresentation": "View setup"
|
||||
},
|
||||
"loadError": {
|
||||
"title": "Docker setup assistant could not be loaded",
|
||||
"title": "Service setup could not be loaded",
|
||||
"message": "Docker service information is unavailable. Try again without leaving this page."
|
||||
},
|
||||
"refreshError": {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { WidgetAppInput } from "./widget-app-input";
|
||||
import { WidgetCustomWidgetSelectInput } from "./widget-custom-widget-select-input";
|
||||
import { WidgetCustomWidgetConfigurationInput } from "./widget-custom-widget-configuration-input";
|
||||
import { WidgetDynamicSelectInput } from "./widget-dynamic-select-input";
|
||||
import { WidgetDynamicMultiSelectInput } from "./widget-dynamic-multiselect-input";
|
||||
import { WidgetLocationInput } from "./widget-location-input";
|
||||
import { WidgetMultiTextInput } from "./widget-multi-text-input";
|
||||
import { WidgetMultiReleasesRepositoriesInput } from "./widget-multiReleasesRepositories-input";
|
||||
@@ -37,7 +36,6 @@ const mapping = {
|
||||
sortableItemList: WidgetSortedItemListInput,
|
||||
multiReleasesRepositories: WidgetMultiReleasesRepositoriesInput,
|
||||
dynamicSelect: WidgetDynamicSelectInput,
|
||||
dynamicMultiSelect: WidgetDynamicMultiSelectInput,
|
||||
umamiEventName: WidgetUmamiEventInput,
|
||||
umamiEventNames: WidgetUmamiEventNamesInput,
|
||||
umamiWebsite: WidgetUmamiWebsiteInput,
|
||||
|
||||
@@ -314,7 +314,7 @@ export function JsonPathTreePicker({
|
||||
{hasData && (
|
||||
<Combobox.Header>
|
||||
<Group gap="xs" px="xs" pt="xs" pb={4}>
|
||||
<Badge size="sm" color="teal" variant="light" leftSection={<IconCircleCheck size="var(--mantine-font-size-xs)" />}>
|
||||
<Badge size="sm" color="teal" variant="light" leftSection={<IconCircleCheck size={12} />}>
|
||||
{loadedHint}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
@@ -353,7 +353,7 @@ function TreeNodeOption({ node, selected, isSearching }: { node: FlatNode; selec
|
||||
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
{node.hasChildren && (
|
||||
<IconChevronDown
|
||||
size="var(--mantine-font-size-sm)"
|
||||
size={14}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
color: "var(--mantine-color-dimmed)",
|
||||
@@ -362,7 +362,7 @@ function TreeNodeOption({ node, selected, isSearching }: { node: FlatNode; selec
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<TypeIcon size="var(--mantine-font-size-sm)" style={{ flexShrink: 0, opacity: 0.5 }} />
|
||||
<TypeIcon size={14} style={{ flexShrink: 0, opacity: 0.5 }} />
|
||||
<Text
|
||||
size="sm"
|
||||
fw={selected ? 600 : 400}
|
||||
|
||||
@@ -76,7 +76,7 @@ export const WidgetCustomWidgetConfigurationInput = ({
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{issues[0] && (
|
||||
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size="var(--mantine-font-size-md)" />}>
|
||||
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size={16} />}>
|
||||
<Text size="sm">{labels("needsAttention", { message: issues[0].message })}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -115,7 +115,7 @@ export const WidgetCustomWidgetSelectInput = ({
|
||||
>
|
||||
<Group wrap="nowrap" gap="sm">
|
||||
<Avatar src={def.iconUrl} size={36} radius="md" color="blue">
|
||||
<IconApi size="var(--mantine-font-size-lg)" />
|
||||
<IconApi size={18} />
|
||||
</Avatar>
|
||||
<Stack gap={1} style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
@@ -152,7 +152,7 @@ export const WidgetCustomWidgetSelectInput = ({
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
icon={<IconAlertTriangle size="var(--mantine-font-size-lg)" />}
|
||||
icon={<IconAlertTriangle size={18} />}
|
||||
title={labels("migrationRequired")}
|
||||
>
|
||||
<Text size="xs">{labels("migrationDescription")}</Text>
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Loader, MultiSelect } from "@mantine/core";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { CommonWidgetInputProps } from "./common";
|
||||
import { useWidgetInputTranslation } from "./common";
|
||||
import { useFormContext } from "./form";
|
||||
|
||||
export const WidgetDynamicMultiSelectInput = ({
|
||||
property,
|
||||
kind,
|
||||
options,
|
||||
}: CommonWidgetInputProps<"dynamicMultiSelect">) => {
|
||||
const t = useScopedI18n("widget.dynamicSelect");
|
||||
const tInput = useWidgetInputTranslation(kind, property);
|
||||
const form = useFormContext();
|
||||
const inputProps = form.getInputProps(`options.${property}`);
|
||||
const selectedValues = (inputProps.value as string[] | undefined) ?? [];
|
||||
const { data: selectData, isPending, isError } = options.useOptions();
|
||||
const data = [
|
||||
...selectData,
|
||||
...selectedValues
|
||||
.filter((selectedValue) => !selectData.some((option) => option.value === selectedValue))
|
||||
.map((selectedValue) => ({ value: selectedValue, label: selectedValue })),
|
||||
];
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
{...inputProps}
|
||||
label={tInput("label")}
|
||||
description={options.withDescription ? tInput("description") : undefined}
|
||||
placeholder={isPending ? t("loading") : tInput("placeholder")}
|
||||
leftSection={isPending ? <Loader size="xs" /> : undefined}
|
||||
searchable
|
||||
clearable
|
||||
maxValues={options.maxValues}
|
||||
nothingFoundMessage={t("noResults")}
|
||||
data={data}
|
||||
disabled={isError}
|
||||
error={isError ? t("loadError") : inputProps.error}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -89,7 +89,7 @@ export const WidgetDynamicSelectInput = ({
|
||||
style={{ marginInlineStart: "auto" }}
|
||||
color="currentColor"
|
||||
opacity={0.6}
|
||||
size="var(--mantine-font-size-lg)"
|
||||
size={18}
|
||||
stroke={1.5}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -97,7 +97,7 @@ export const WidgetLocationInput = ({ property, kind, options }: CommonWidgetInp
|
||||
disabled={!selectionEnabled}
|
||||
onClick={onSearch}
|
||||
variant="light"
|
||||
leftSection={<IconListSearch size="var(--mantine-font-size-md)" />}
|
||||
leftSection={<IconListSearch size={16} />}
|
||||
>
|
||||
{tLocation("search")}
|
||||
</Button>
|
||||
@@ -257,7 +257,7 @@ const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: Location
|
||||
})}
|
||||
>
|
||||
<ActionIcon color="red" variant="subtle" onClick={onSelect}>
|
||||
<IconClick size="var(--mantine-font-size-md)" />
|
||||
<IconClick size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
|
||||
@@ -229,19 +229,19 @@ export const WidgetMultiReleasesRepositoriesInput = ({
|
||||
})
|
||||
}
|
||||
variant="light"
|
||||
leftSection={<IconEdit size="var(--mantine-font-size-sm)" />}
|
||||
leftSection={<IconEdit size={15} />}
|
||||
size="xs"
|
||||
>
|
||||
{tRepository("edit.label")}
|
||||
</Button>
|
||||
|
||||
<ActionIcon variant="transparent" color="red" onClick={() => onRepositoryRemove(index)}>
|
||||
<IconTrash size="var(--mantine-font-size-sm)" />
|
||||
<IconTrash size={15} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
{Object.keys(form.errors).filter((key) => key.startsWith(`options.${property}.${index}.`)).length > 0 && (
|
||||
<Group align="center" justify="center" gap="xs" bg="red.1">
|
||||
<IconTriangleFilled size="var(--mantine-font-size-sm)" color="var(--mantine-color-red-filled)" />
|
||||
<IconTriangleFilled size={15} color="var(--mantine-color-red-filled)" />
|
||||
<Text size="sm" c="red">
|
||||
{tRepository("invalid")}
|
||||
</Text>
|
||||
@@ -371,7 +371,7 @@ const ProviderTokenInput = ({
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
size="xs"
|
||||
leftSection={<IconKey size="var(--mantine-font-size-sm)" />}
|
||||
leftSection={<IconKey size={14} />}
|
||||
/>
|
||||
{editing && value.trim() && (
|
||||
<Button size="xs" onClick={handleSave} loading={saving}>
|
||||
@@ -380,7 +380,7 @@ const ProviderTokenInput = ({
|
||||
)}
|
||||
{hasToken && !editing && (
|
||||
<ActionIcon variant="light" color="red" size="sm" onClick={handleDelete} loading={saving}>
|
||||
<IconTrash size="var(--mantine-font-size-sm)" />
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
@@ -51,20 +51,20 @@ export const WidgetSelectInput = ({ property, kind, options }: CommonWidgetInput
|
||||
label: translateIfNecessary(t, option.label) ?? option.value,
|
||||
},
|
||||
)}
|
||||
leftSection={CurrentIcon && <CurrentIcon size="var(--mantine-font-size-md)" stroke={1.5} />}
|
||||
leftSection={CurrentIcon && <CurrentIcon size={16} stroke={1.5} />}
|
||||
renderOption={({ option, checked }) => {
|
||||
const Icon = getIconFor(options.options, String(option.value));
|
||||
|
||||
return (
|
||||
<Group flex="1" gap="xs">
|
||||
{Icon && <Icon color="currentColor" opacity={0.6} size="var(--mantine-font-size-lg)" stroke={1.5} />}
|
||||
{Icon && <Icon color="currentColor" opacity={0.6} size={18} stroke={1.5} />}
|
||||
{option.label}
|
||||
{checked && (
|
||||
<IconCheck
|
||||
style={{ marginInlineStart: "auto" }}
|
||||
color="currentColor"
|
||||
opacity={0.6}
|
||||
size="var(--mantine-font-size-lg)"
|
||||
size={18}
|
||||
stroke={1.5}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -263,7 +263,7 @@ const AnchorNoteWidgetContent = ({
|
||||
onClick={handleEdit}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<IconEdit size="var(--mantine-font-size-md)" />
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function AssistantWidget(props: WidgetComponentProps<"assistant">
|
||||
<Center h="100%" p="md">
|
||||
<Stack align="center" gap="xs" ta="center">
|
||||
<ThemeIcon variant="light" color="red" radius="xl" size="lg">
|
||||
<IconRobot size="var(--mantine-font-size-xl)" />
|
||||
<IconRobot size={20} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600}>
|
||||
{t("title")}
|
||||
|
||||
@@ -201,7 +201,7 @@ export default function BeszelSystemStatsWidget({
|
||||
<Button
|
||||
variant="default"
|
||||
size="compact-xs"
|
||||
leftSection={<IconServer size="var(--mantine-font-size-sm)" />}
|
||||
leftSection={<IconServer size={14} />}
|
||||
className={classes.beszelStatsSystemToggle}
|
||||
disabled={isSelectionSavePending}
|
||||
>
|
||||
|
||||
@@ -49,7 +49,7 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks
|
||||
</Group>
|
||||
|
||||
<ActionIcon variant="transparent" color="red" onClick={removeItem}>
|
||||
<IconX size="var(--mantine-font-size-xl)" />
|
||||
<IconX size={20} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -303,7 +303,7 @@ const CalendarAgenda = ({
|
||||
disabled={isEditMode}
|
||||
onClick={() => setMonth(moveCalendarMonth(month, -1))}
|
||||
>
|
||||
<IconChevronLeft size="var(--mantine-font-size-lg)" aria-hidden />
|
||||
<IconChevronLeft size={18} aria-hidden />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Stack gap={0} miw={0}>
|
||||
@@ -323,7 +323,7 @@ const CalendarAgenda = ({
|
||||
disabled={isEditMode}
|
||||
onClick={() => setMonth(moveCalendarMonth(month, 1))}
|
||||
>
|
||||
<IconChevronRight size="var(--mantine-font-size-lg)" aria-hidden />
|
||||
<IconChevronRight size={18} aria-hidden />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
@@ -25,7 +25,7 @@ export function IntegrationErrorIndicator({ results }: IntegrationErrorIndicator
|
||||
return (
|
||||
<Tooltip label={label} position="left" withArrow>
|
||||
<ActionIcon variant="transparent" color="orange" size={28} aria-label={label} style={{ cursor: "help" }}>
|
||||
<IconAlertTriangle aria-hidden size="var(--mantine-font-size-sm)" />
|
||||
<IconAlertTriangle aria-hidden size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -28,7 +28,7 @@ export function WidgetQueryErrorIndicator({ error, label }: WidgetQueryErrorIndi
|
||||
tabIndex={0}
|
||||
style={{ cursor: "help" }}
|
||||
>
|
||||
<IconAlertTriangle aria-hidden size="var(--mantine-font-size-sm)" />
|
||||
<IconAlertTriangle aria-hidden size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ export function ApplicationsSection({ applications, baseUrl, isTiny }: Applicati
|
||||
|
||||
return (
|
||||
<Accordion.Item value="applications">
|
||||
<Accordion.Control icon={isTiny ? null : <IconCloud size="var(--mantine-font-size-md)" />}>
|
||||
<Accordion.Control icon={isTiny ? null : <IconCloud size={16} />}>
|
||||
<Group gap="xs">
|
||||
<Text size="xs">{tCommon("applications")}</Text>
|
||||
<Badge variant="dot" color={getBadgeColor(runningApps, applications.length)} size="xs">
|
||||
|
||||
@@ -106,7 +106,7 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
|
||||
variant="subtle"
|
||||
c="dimmed"
|
||||
>
|
||||
<IconLink size="var(--mantine-font-size-xs)" />
|
||||
<IconLink size={12} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{!isTiny && logsUrl && (
|
||||
@@ -121,7 +121,7 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
|
||||
variant="subtle"
|
||||
c="dimmed"
|
||||
>
|
||||
<IconFileText size="var(--mantine-font-size-xs)" />
|
||||
<IconFileText size={12} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{!isTiny && (
|
||||
|
||||
@@ -43,7 +43,7 @@ export function ServersSection({
|
||||
return (
|
||||
<Accordion.Item value="servers">
|
||||
<Group gap={0} wrap="nowrap">
|
||||
<Accordion.Control icon={isTiny ? null : <IconServer size="var(--mantine-font-size-md)" />} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Accordion.Control icon={isTiny ? null : <IconServer size={16} />} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap="xs">
|
||||
<Text size="xs">{tCommon("servers")}</Text>
|
||||
<Badge variant="dot" color={getBadgeColor(onlineServers, servers.length)} size="xs">
|
||||
@@ -61,7 +61,7 @@ export function ServersSection({
|
||||
mr="xs"
|
||||
onClick={onToggleIp}
|
||||
>
|
||||
{showIp ? <IconEye size="var(--mantine-font-size-xs)" /> : <IconEyeOff size="var(--mantine-font-size-xs)" />}
|
||||
{showIp ? <IconEye size={12} /> : <IconEyeOff size={12} />}
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Accordion.Panel p={4}>
|
||||
|
||||
@@ -25,7 +25,7 @@ export function ServicesSection({ services, baseUrl, isTiny, isAdvanced }: Servi
|
||||
|
||||
return (
|
||||
<Accordion.Item value="services">
|
||||
<Accordion.Control icon={isTiny ? null : <IconStack2 size="var(--mantine-font-size-md)" />}>
|
||||
<Accordion.Control icon={isTiny ? null : <IconStack2 size={16} />}>
|
||||
<Group gap="xs">
|
||||
<Text size="xs">{tCommon("services")}</Text>
|
||||
<Badge variant="dot" color={getBadgeColor(runningServices, services.length)} size="xs">
|
||||
|
||||
@@ -90,7 +90,7 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<IconClockPause size="var(--mantine-font-size-xl)" />}
|
||||
leftSection={<IconClockPause size={20} />}
|
||||
h="2rem"
|
||||
w="12rem"
|
||||
onClick={handleSetTimer}
|
||||
|
||||
@@ -184,7 +184,7 @@ export default function DnsHoleControlsWidget({
|
||||
radius={board.itemRadius}
|
||||
flex={1}
|
||||
>
|
||||
<IconPlayerPlay className="dns-hole-controls-enable-all-icon" size="var(--mantine-font-size-md)" />
|
||||
<IconPlayerPlay className="dns-hole-controls-enable-all-icon" size={16} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
@@ -205,7 +205,7 @@ export default function DnsHoleControlsWidget({
|
||||
radius={board.itemRadius}
|
||||
flex={1}
|
||||
>
|
||||
<IconClockPause className="dns-hole-controls-timer-all-icon" size="var(--mantine-font-size-md)" />
|
||||
<IconClockPause className="dns-hole-controls-timer-all-icon" size={16} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
@@ -223,7 +223,7 @@ export default function DnsHoleControlsWidget({
|
||||
radius={board.itemRadius}
|
||||
flex={1}
|
||||
>
|
||||
<IconPlayerStop className="dns-hole-controls-disable-all-icon" size="var(--mantine-font-size-md)" />
|
||||
<IconPlayerStop className="dns-hole-controls-disable-all-icon" size={16} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
@@ -378,7 +378,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
color="green"
|
||||
variant="light"
|
||||
>
|
||||
<IconPlayerPlay size="var(--mantine-font-size-xs)" />
|
||||
<IconPlayerPlay size={12} />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<ActionIcon
|
||||
@@ -390,7 +390,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
color="red"
|
||||
variant="light"
|
||||
>
|
||||
<IconPlayerStop size="var(--mantine-font-size-xs)" />
|
||||
<IconPlayerStop size={12} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<ActionIcon
|
||||
@@ -406,7 +406,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
display={isInteractPermitted ? undefined : "none"}
|
||||
disabled={!controlEnabled || !isEnabled}
|
||||
>
|
||||
<IconClockPause size="var(--mantine-font-size-xs)" />
|
||||
<IconClockPause size={12} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
@@ -436,7 +436,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
<IconCircleFilled
|
||||
className="dns-hole-controls-item-status-icon"
|
||||
color={dnsLightStatus(isEnabled)}
|
||||
size="var(--mantine-font-size-md)"
|
||||
size={16}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -474,7 +474,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
open();
|
||||
}}
|
||||
>
|
||||
<IconClockPause className="dns-hole-controls-item-timer-icon" size="var(--mantine-font-size-xl)" />
|
||||
<IconClockPause className="dns-hole-controls-item-timer-icon" size={20} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
@@ -74,11 +74,6 @@ const createContainerLogsPath = (container: Pick<DockerContainer, "endpointId" |
|
||||
|
||||
const getContainerTarget = ({ endpointId, id }: DockerContainer) => ({ endpointId, id });
|
||||
|
||||
const getContainersQueryInput = (endpointIds: string[]) => {
|
||||
if (endpointIds.length === 0) return undefined;
|
||||
return { endpointIds };
|
||||
};
|
||||
|
||||
const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
|
||||
const t = useScopedI18n("docker.field.state.option");
|
||||
|
||||
@@ -253,7 +248,7 @@ export default function DockerWidget({
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
|
||||
const utils = clientApi.useUtils();
|
||||
const containersQuery = clientApi.docker.getContainers.useQuery(getContainersQueryInput(options.endpointIds));
|
||||
const containersQuery = clientApi.docker.getContainers.useQuery();
|
||||
const data = getUsableWidgetQueryData(containersQuery);
|
||||
const { isFetching } = containersQuery;
|
||||
const refreshInventory = clientApi.docker.refreshInventory.useMutation({
|
||||
@@ -435,7 +430,7 @@ export default function DockerWidget({
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconBrandDocker size="var(--mantine-font-size-xl)" style={{ flexShrink: 0 }} />
|
||||
<IconBrandDocker size={20} style={{ flexShrink: 0 }} />
|
||||
<Text size="sm" truncate>
|
||||
{t("table.footer", { count: containers.length.toString() })}
|
||||
</Text>
|
||||
@@ -462,7 +457,7 @@ export default function DockerWidget({
|
||||
onClick={() => refreshInventory.mutate()}
|
||||
aria-label={t("table.refresh.lastUpdated", { when: relativeTime })}
|
||||
>
|
||||
<IconRefresh size="var(--mantine-font-size-md)" />
|
||||
<IconRefresh size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
@@ -496,7 +491,7 @@ function ContainerMenuButton({
|
||||
aria-label={t("title")}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<IconDots size="var(--mantine-font-size-md)" />
|
||||
<IconDots size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown w={containerMenuWidth} miw={containerMenuWidth} maw={containerMenuWidth}>
|
||||
@@ -530,7 +525,7 @@ function ContainerActionItems({
|
||||
{container.name}
|
||||
</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={<IconFileText size="var(--mantine-font-size-sm)" />}
|
||||
leftSection={<IconFileText size={14} />}
|
||||
disabled={!handlers.canUse(container, "logs")}
|
||||
onClick={() => {
|
||||
handlers.onOpenLogs(container);
|
||||
@@ -542,7 +537,7 @@ function ContainerActionItems({
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color={stateAction === "start" ? "green" : "red"}
|
||||
leftSection={<StateIcon size="var(--mantine-font-size-sm)" />}
|
||||
leftSection={<StateIcon size={14} />}
|
||||
disabled={!handlers.canUse(container, "lifecycle")}
|
||||
onClick={() => invokeAction(stateAction)}
|
||||
>
|
||||
@@ -550,7 +545,7 @@ function ContainerActionItems({
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
color="orange"
|
||||
leftSection={<IconRotateClockwise size="var(--mantine-font-size-sm)" />}
|
||||
leftSection={<IconRotateClockwise size={14} />}
|
||||
disabled={!handlers.canUse(container, "lifecycle")}
|
||||
onClick={() => invokeAction("restart")}
|
||||
>
|
||||
@@ -558,7 +553,7 @@ function ContainerActionItems({
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash size="var(--mantine-font-size-sm)" />}
|
||||
leftSection={<IconTrash size={14} />}
|
||||
disabled={!handlers.canUse(container, "remove")}
|
||||
onClick={() => invokeAction("remove")}
|
||||
>
|
||||
@@ -566,7 +561,7 @@ function ContainerActionItems({
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<IconCategoryPlus size="var(--mantine-font-size-sm)" />}
|
||||
leftSection={<IconCategoryPlus size={14} />}
|
||||
onClick={() => {
|
||||
handlers.onAddToHomarr(container);
|
||||
onClose();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { IconBrandDocker, IconServerOff } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
@@ -31,19 +30,6 @@ export const { definition, componentLoader } = createWidgetDefinition("dockerCon
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
(factory) => ({
|
||||
endpointIds: factory.dynamicMultiSelect({
|
||||
defaultValue: [],
|
||||
maxValues: 100,
|
||||
withDescription: true,
|
||||
useOptions: () => {
|
||||
const { data = [], isPending, isError } = clientApi.docker.getEndpoints.useQuery();
|
||||
return {
|
||||
data: data.map((endpoint) => ({ value: endpoint.id, label: endpoint.name })),
|
||||
isPending,
|
||||
isError,
|
||||
};
|
||||
},
|
||||
}),
|
||||
columns: factory.multiSelect({
|
||||
defaultValue: [...allColumnsList],
|
||||
options: allColumnsList.map((value) => ({
|
||||
|
||||
@@ -279,7 +279,7 @@ function SpeedCell({
|
||||
const { Icon, color } = speedColumnConfig[direction];
|
||||
return (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Icon size="var(--mantine-font-size-xs)" style={{ flexShrink: 0, opacity: 0.5 }} />
|
||||
<Icon size={12} style={{ flexShrink: 0, opacity: 0.5 }} />
|
||||
<Text size={fontSize} c={color}>
|
||||
{formatByteRate(speed)}
|
||||
</Text>
|
||||
@@ -914,7 +914,7 @@ function ExpandedRow({ item, collapse }: { item: ExtendedDownloadClientItem; col
|
||||
<DetailPair
|
||||
label={t("items.downSpeed.detailsTitle")}
|
||||
value={formatByteRate(item.downSpeed)}
|
||||
icon={<IconDownload size="var(--mantine-font-size-xs)" />}
|
||||
icon={<IconDownload size={10} />}
|
||||
color="blue"
|
||||
/>
|
||||
)}
|
||||
@@ -922,7 +922,7 @@ function ExpandedRow({ item, collapse }: { item: ExtendedDownloadClientItem; col
|
||||
<DetailPair
|
||||
label={t("items.upSpeed.detailsTitle")}
|
||||
value={formatByteRate(item.upSpeed)}
|
||||
icon={<IconUpload size="var(--mantine-font-size-xs)" />}
|
||||
icon={<IconUpload size={10} />}
|
||||
color="green"
|
||||
/>
|
||||
)}
|
||||
@@ -1005,7 +1005,7 @@ function GlobalStatsBar({
|
||||
|
||||
<Tooltip label={t("stats.totalSize")} withArrow>
|
||||
<Group gap={4}>
|
||||
<IconDatabase size="var(--mantine-font-size-xs)" style={{ opacity: 0.6 }} />
|
||||
<IconDatabase size={12} style={{ opacity: 0.6 }} />
|
||||
<Text size="xs">{`${formatBytes(queueStats.completedSize)} / ${formatBytes(queueStats.totalSize)}`}</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
@@ -1013,14 +1013,14 @@ function GlobalStatsBar({
|
||||
|
||||
<Group gap="md">
|
||||
<Group gap={4}>
|
||||
<IconDownload size="var(--mantine-font-size-xs)" style={{ opacity: 0.6 }} />
|
||||
<IconDownload size={12} style={{ opacity: 0.6 }} />
|
||||
<Text size="xs" fw={600} c="blue">
|
||||
{formatByteRate(totalSpeed)}
|
||||
</Text>
|
||||
</Group>
|
||||
{totalUpSpeed > 0 && (
|
||||
<Group gap={4}>
|
||||
<IconUpload size="var(--mantine-font-size-xs)" style={{ opacity: 0.6 }} />
|
||||
<IconUpload size={12} style={{ opacity: 0.6 }} />
|
||||
<Text size="xs" fw={600} c="green">
|
||||
{formatByteRate(totalUpSpeed)}
|
||||
</Text>
|
||||
@@ -1093,7 +1093,7 @@ function RowContextMenu({ state, onClose, t }: { state: ContextMenuState; onClos
|
||||
<Menu.Label>{truncateText(item.name, 40)}</Menu.Label>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconInfoCircle size="var(--mantine-font-size-sm)" />}
|
||||
leftSection={<IconInfoCircle size={14} />}
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(item.name).catch(() => {});
|
||||
onClose();
|
||||
@@ -1104,7 +1104,7 @@ function RowContextMenu({ state, onClose, t }: { state: ContextMenuState; onClos
|
||||
|
||||
{item.id && (
|
||||
<Menu.Item
|
||||
leftSection={<IconCopy size="var(--mantine-font-size-sm)" />}
|
||||
leftSection={<IconCopy size={14} />}
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(item.id).catch(() => {});
|
||||
onClose();
|
||||
@@ -1118,7 +1118,7 @@ function RowContextMenu({ state, onClose, t }: { state: ContextMenuState; onClos
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<PauseResumeIcon size="var(--mantine-font-size-sm)" />}
|
||||
leftSection={<PauseResumeIcon size={14} />}
|
||||
onClick={() => {
|
||||
pauseResumeInvoke?.();
|
||||
onClose();
|
||||
@@ -1130,7 +1130,7 @@ function RowContextMenu({ state, onClose, t }: { state: ContextMenuState; onClos
|
||||
{!showDeleteConfirm && (
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash size="var(--mantine-font-size-sm)" />}
|
||||
leftSection={<IconTrash size={14} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openDelete();
|
||||
@@ -1144,7 +1144,7 @@ function RowContextMenu({ state, onClose, t }: { state: ContextMenuState; onClos
|
||||
<>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash size="var(--mantine-font-size-sm)" />}
|
||||
leftSection={<IconTrash size={14} />}
|
||||
onClick={() => {
|
||||
item.actions?.delete({ fromDisk: false });
|
||||
onClose();
|
||||
@@ -1154,7 +1154,7 @@ function RowContextMenu({ state, onClose, t }: { state: ContextMenuState; onClos
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrashX size="var(--mantine-font-size-sm)" />}
|
||||
leftSection={<IconTrashX size={14} />}
|
||||
onClick={() => {
|
||||
item.actions?.delete({ fromDisk: true });
|
||||
onClose();
|
||||
@@ -1321,14 +1321,14 @@ function WidgetFooter({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FilterIcon size="var(--mantine-font-size-sm)" />
|
||||
<FilterIcon size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{toggleStats ? (
|
||||
<Tooltip label={statsTooltip}>
|
||||
<ActionIcon size="xs" variant={statsIconVariant} aria-label={statsTooltip} onClick={toggleStats}>
|
||||
<StatsIcon size="var(--mantine-font-size-sm)" />
|
||||
<StatsIcon size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
@@ -1356,7 +1356,7 @@ function WidgetFooter({
|
||||
disabled={integrationsStatuses.paused.length === 0}
|
||||
onClick={() => resumeQueue({ integrationIds: integrationsStatuses.paused })}
|
||||
>
|
||||
<IconPlayerPlay size="var(--mantine-font-size-sm)" />
|
||||
<IconPlayerPlay size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -1364,12 +1364,12 @@ function WidgetFooter({
|
||||
{!showStats && (
|
||||
<Group gap={2}>
|
||||
<Text size="xs" fw={600} c="blue">
|
||||
<IconDownload size="var(--mantine-font-size-xs)" style={{ verticalAlign: "middle", marginRight: 2 }} />
|
||||
<IconDownload size={10} style={{ verticalAlign: "middle", marginRight: 2 }} />
|
||||
{formatByteRate(totalSpeed)}
|
||||
</Text>
|
||||
{totalUpSpeed > 0 && (
|
||||
<Text size="xs" fw={600} c="green">
|
||||
<IconUpload size="var(--mantine-font-size-xs)" style={{ verticalAlign: "middle", marginRight: 2 }} />
|
||||
<IconUpload size={10} style={{ verticalAlign: "middle", marginRight: 2 }} />
|
||||
{formatByteRate(totalUpSpeed)}
|
||||
</Text>
|
||||
)}
|
||||
@@ -1386,7 +1386,7 @@ function WidgetFooter({
|
||||
disabled={integrationsStatuses.active.length === 0}
|
||||
onClick={() => pauseQueue({ integrationIds: integrationsStatuses.active })}
|
||||
>
|
||||
<IconPlayerPause size="var(--mantine-font-size-sm)" />
|
||||
<IconPlayerPause size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -386,7 +386,7 @@ const InterfacesPanel = ({
|
||||
variant={isAdvanced ? "contained" : "default"}
|
||||
>
|
||||
<Accordion.Item value="interfaces">
|
||||
<Accordion.Control icon={<IconTopologyBus size="var(--mantine-font-size-md)" />}>
|
||||
<Accordion.Control icon={<IconTopologyBus size={16} />}>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xs">
|
||||
<Text size="xs">{label}</Text>
|
||||
{hasError && (
|
||||
@@ -410,7 +410,7 @@ const InterfacesPanel = ({
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconArrowBarUp
|
||||
size="var(--mantine-font-size-xs)"
|
||||
size={12}
|
||||
color="light-dark(var(--mantine-color-green-8), var(--mantine-color-green-3))"
|
||||
/>
|
||||
<Text size="xs" c="light-dark(var(--mantine-color-green-8), var(--mantine-color-green-3))">
|
||||
@@ -419,7 +419,7 @@ const InterfacesPanel = ({
|
||||
</Group>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconArrowBarDown
|
||||
size="var(--mantine-font-size-xs)"
|
||||
size={12}
|
||||
color="light-dark(var(--mantine-color-yellow-9), var(--mantine-color-yellow-3))"
|
||||
/>
|
||||
<Text size="xs" c="light-dark(var(--mantine-color-yellow-9), var(--mantine-color-yellow-3))">
|
||||
|
||||
@@ -26,7 +26,7 @@ export const ResourceAccordionItem = ({
|
||||
}: PropsWithChildren<ResourceAccordionItemProps>) => {
|
||||
return (
|
||||
<Accordion.Item value={value}>
|
||||
<Accordion.Control icon={isTiny ? null : <Icon size="var(--mantine-font-size-md)" />}>
|
||||
<Accordion.Control icon={isTiny ? null : <Icon size={16} />}>
|
||||
<Group style={{ rowGap: "0" }} gap="xs">
|
||||
<Text size="xs">{title}</Text>
|
||||
<Badge variant="dot" color={badge.color} size="xs">
|
||||
|
||||
@@ -97,20 +97,20 @@ const ComputeResourceDetails = ({ item }: { item: ComputeResource }) => {
|
||||
const storage = formatBytesPair(item.storage.used, item.storage.total);
|
||||
return (
|
||||
<List>
|
||||
<List.Item icon={<IconCpu size="var(--mantine-font-size-md)" />}>
|
||||
<List.Item icon={<IconCpu size={16} />}>
|
||||
{t("cpu")} - {item.cpu.cores}
|
||||
</List.Item>
|
||||
<List.Item icon={<IconBrain size="var(--mantine-font-size-md)" />}>
|
||||
<List.Item icon={<IconBrain size={16} />}>
|
||||
{t("memory")} - {memory.used} / {memory.available}
|
||||
</List.Item>
|
||||
<List.Item icon={<IconDatabase size="var(--mantine-font-size-md)" />}>
|
||||
<List.Item icon={<IconDatabase size={16} />}>
|
||||
{t("storage")} - {storage.used} / {storage.available}
|
||||
</List.Item>
|
||||
<List.Item icon={<IconClockHour3 size="var(--mantine-font-size-md)" />}>
|
||||
<List.Item icon={<IconClockHour3 size={16} />}>
|
||||
{t("uptime")} - {dayjs(dayjs().add(-item.uptime, "seconds")).fromNow(true)}
|
||||
</List.Item>
|
||||
{item.haState && (
|
||||
<List.Item icon={<IconHeartBolt size="var(--mantine-font-size-md)" />}>
|
||||
<List.Item icon={<IconHeartBolt size={16} />}>
|
||||
{t("haState")} - {capitalize(item.haState)}
|
||||
</List.Item>
|
||||
)}
|
||||
@@ -152,15 +152,15 @@ const DiskStats = ({ item }: { item: ComputeResource }) => {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<List.Item icon={<IconDatabase size="var(--mantine-font-size-md)" />}>
|
||||
<List.Item icon={<IconDatabase size={16} />}>
|
||||
<Group gap="sm">
|
||||
<Group gap={0}>
|
||||
<Text>{formatBytes(item.storage.write)}</Text>
|
||||
<IconArrowNarrowDown size="var(--mantine-font-size-sm)" />
|
||||
<IconArrowNarrowDown size={14} />
|
||||
</Group>
|
||||
<Group gap={0}>
|
||||
<Text>{formatBytes(item.storage.read)}</Text>
|
||||
<IconArrowNarrowUp size="var(--mantine-font-size-sm)" />
|
||||
<IconArrowNarrowUp size={14} />
|
||||
</Group>
|
||||
</Group>
|
||||
</List.Item>
|
||||
@@ -172,15 +172,15 @@ const NetStats = ({ item }: { item: ComputeResource }) => {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<List.Item icon={<IconNetwork size="var(--mantine-font-size-md)" />}>
|
||||
<List.Item icon={<IconNetwork size={16} />}>
|
||||
<Group gap="sm">
|
||||
<Group gap={0}>
|
||||
<Text>{formatBytes(item.network.in)}</Text>
|
||||
<IconArrowNarrowDown size="var(--mantine-font-size-sm)" />
|
||||
<IconArrowNarrowDown size={14} />
|
||||
</Group>
|
||||
<Group gap={0}>
|
||||
<Text>{formatBytes(item.network.out)}</Text>
|
||||
<IconArrowNarrowUp size="var(--mantine-font-size-sm)" />
|
||||
<IconArrowNarrowUp size={14} />
|
||||
</Group>
|
||||
</Group>
|
||||
</List.Item>
|
||||
|
||||
@@ -178,7 +178,7 @@ function Carousel({
|
||||
size={40}
|
||||
onClick={() => move(-1)}
|
||||
>
|
||||
<IconChevronLeft size="var(--mantine-font-size-lg)" />
|
||||
<IconChevronLeft size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<ActionIcon
|
||||
@@ -189,7 +189,7 @@ function Carousel({
|
||||
size={advanced ? 40 : 32}
|
||||
onClick={() => setPaused((value) => !value)}
|
||||
>
|
||||
{paused ? <IconPlayerPlay size="var(--mantine-font-size-lg)" /> : <IconPlayerPause size="var(--mantine-font-size-lg)" />}
|
||||
{paused ? <IconPlayerPlay size={18} /> : <IconPlayerPause size={18} />}
|
||||
</ActionIcon>
|
||||
{advanced && (
|
||||
<ActionIcon
|
||||
@@ -200,7 +200,7 @@ function Carousel({
|
||||
size={40}
|
||||
onClick={() => move(1)}
|
||||
>
|
||||
<IconChevronRight size="var(--mantine-font-size-lg)" />
|
||||
<IconChevronRight size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
@@ -214,7 +214,7 @@ function Carousel({
|
||||
</Text>
|
||||
)}
|
||||
<Group gap="xs">
|
||||
<IconCalendar size="var(--mantine-font-size-md)" />
|
||||
<IconCalendar size={16} />
|
||||
<Text size="xs">{new Date(currentAsset.fileModifiedAt).toLocaleDateString(locale)}</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function ImmichServerStatsWidget({
|
||||
<SimpleGrid cols={statsLayout.columns} spacing={statsLayout.dense ? 4 : "sm"}>
|
||||
{statVisibility.showUsers && (
|
||||
<StatItem
|
||||
icon={<IconUsers size="var(--mantine-font-size-xl)" />}
|
||||
icon={<IconUsers size={20} />}
|
||||
label={t("widget.immich-serverStats.users")}
|
||||
value={stats.userCount.toLocaleString(locale)}
|
||||
dense={statsLayout.dense}
|
||||
@@ -67,7 +67,7 @@ export default function ImmichServerStatsWidget({
|
||||
)}
|
||||
{statVisibility.showPhotos && (
|
||||
<StatItem
|
||||
icon={<IconPhoto size="var(--mantine-font-size-xl)" />}
|
||||
icon={<IconPhoto size={20} />}
|
||||
label={t("widget.immich-serverStats.photos")}
|
||||
value={stats.photoCount.toLocaleString(locale)}
|
||||
dense={statsLayout.dense}
|
||||
@@ -75,7 +75,7 @@ export default function ImmichServerStatsWidget({
|
||||
)}
|
||||
{statVisibility.showVideos && (
|
||||
<StatItem
|
||||
icon={<IconVideo size="var(--mantine-font-size-xl)" />}
|
||||
icon={<IconVideo size={20} />}
|
||||
label={t("widget.immich-serverStats.videos")}
|
||||
value={stats.videoCount.toLocaleString(locale)}
|
||||
dense={statsLayout.dense}
|
||||
@@ -83,7 +83,7 @@ export default function ImmichServerStatsWidget({
|
||||
)}
|
||||
{statVisibility.showStorage && (
|
||||
<StatItem
|
||||
icon={<IconDatabase size="var(--mantine-font-size-xl)" />}
|
||||
icon={<IconDatabase size={20} />}
|
||||
label={t("widget.immich-serverStats.storage")}
|
||||
value={formatBytes(stats.totalLibraryUsageInBytes)}
|
||||
dense={statsLayout.dense}
|
||||
|
||||
@@ -133,7 +133,7 @@ export default function IndexerManagerWidget({
|
||||
}}
|
||||
aria-label={t("widget.indexerManager.testAll")}
|
||||
>
|
||||
<IconTestPipe size="var(--mantine-font-size-sm)" />
|
||||
<IconTestPipe size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
@@ -141,7 +141,7 @@ export default function MediaMissingWidget({
|
||||
{showMissing && (
|
||||
<Paper withBorder radius="sm" style={{ minHeight: 0, overflow: "hidden" }}>
|
||||
<Group p="xs" gap="xs">
|
||||
<IconQuestionMark size="var(--mantine-font-size-md)" />
|
||||
<IconQuestionMark size={16} />
|
||||
<Text size="sm" fw={600}>
|
||||
{tabLabel(t("tab.missing"), missing.length, missingCount)}
|
||||
</Text>
|
||||
@@ -152,7 +152,7 @@ export default function MediaMissingWidget({
|
||||
{showQueued && (
|
||||
<Paper withBorder radius="sm" style={{ minHeight: 0, overflow: "hidden" }}>
|
||||
<Group p="xs" gap="xs">
|
||||
<IconDownload size="var(--mantine-font-size-md)" />
|
||||
<IconDownload size={16} />
|
||||
<Text size="sm" fw={600}>
|
||||
{tabLabel(t("tab.queued"), queued.length, queuedCount)}
|
||||
</Text>
|
||||
@@ -181,12 +181,12 @@ export default function MediaMissingWidget({
|
||||
</Group>
|
||||
<Tabs.List grow>
|
||||
{showMissing && (
|
||||
<Tabs.Tab value="missing" px={isThin ? 6 : undefined} leftSection={<IconQuestionMark size="var(--mantine-font-size-sm)" />}>
|
||||
<Tabs.Tab value="missing" px={isThin ? 6 : undefined} leftSection={<IconQuestionMark size={14} />}>
|
||||
{tabLabel(t("tab.missing"), missing.length, missingCount)}
|
||||
</Tabs.Tab>
|
||||
)}
|
||||
{showQueued && (
|
||||
<Tabs.Tab value="queued" px={isThin ? 6 : undefined} leftSection={<IconDownload size="var(--mantine-font-size-sm)" />}>
|
||||
<Tabs.Tab value="queued" px={isThin ? 6 : undefined} leftSection={<IconDownload size={14} />}>
|
||||
{tabLabel(t("tab.queued"), queued.length, queuedCount)}
|
||||
</Tabs.Tab>
|
||||
)}
|
||||
|
||||
@@ -273,7 +273,7 @@ const InfoDivider = () => (
|
||||
const Info = ({ icon: Icon, label }: IconAndLabelProps) => {
|
||||
return (
|
||||
<Group gap={4} wrap="nowrap" miw={0}>
|
||||
{Icon && <Icon size="var(--mantine-font-size-xs)" color={getMantineColor("gray", 5)} style={{ flexShrink: 0 }} />}
|
||||
{Icon && <Icon size={12} color={getMantineColor("gray", 5)} style={{ flexShrink: 0 }} />}
|
||||
<Text size="xs" c="dimmed" truncate="end">
|
||||
{label}
|
||||
</Text>
|
||||
|
||||
@@ -94,7 +94,7 @@ const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[]
|
||||
aria-label={t("action.search.label")}
|
||||
onClick={() => openMediaRequestSearch({ integrationIds })}
|
||||
>
|
||||
<IconSearch size="var(--mantine-font-size-md)" />
|
||||
<IconSearch size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -281,7 +281,7 @@ const DecisionButtons = ({ requestId, integrationId, canInteract, alwaysVisible
|
||||
handleDecision("approve");
|
||||
}}
|
||||
>
|
||||
<IconThumbUp size="var(--mantine-font-size-md)" />
|
||||
<IconThumbUp size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("pending.decline")}>
|
||||
@@ -296,7 +296,7 @@ const DecisionButtons = ({ requestId, integrationId, canInteract, alwaysVisible
|
||||
handleDecision("decline");
|
||||
}}
|
||||
>
|
||||
<IconThumbDown size="var(--mantine-font-size-md)" />
|
||||
<IconThumbDown size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
@@ -132,7 +132,7 @@ export default function MediaServerWidget({
|
||||
<Tooltip label={t(`titles.stats.${stat.name}`)}>
|
||||
<Card p={0} radius={board.itemRadius} className={classes.card}>
|
||||
<Group className="mediaRequests-stats-stat-stack" justify="center" align="center" gap="xs" w="100%">
|
||||
<stat.icon className="mediaRequests-stats-stat-icon" size="var(--mantine-font-size-md)" />
|
||||
<stat.icon className="mediaRequests-stats-stat-icon" size={16} />
|
||||
<Text className="mediaRequests-stats-stat-value" size="md">
|
||||
{stat.number}
|
||||
</Text>
|
||||
@@ -226,7 +226,7 @@ const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[]
|
||||
aria-label={t("action.search.label")}
|
||||
onClick={() => openMediaRequestSearch({ integrationIds })}
|
||||
>
|
||||
<IconSearch size="var(--mantine-font-size-md)" />
|
||||
<IconSearch size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -23,10 +23,8 @@
|
||||
|
||||
.tableViewport :global(th),
|
||||
.tableViewport :global(td) {
|
||||
padding-block: 8px;
|
||||
padding-inline: 8px;
|
||||
padding-block: 4px;
|
||||
overflow: hidden;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.tableViewport :global(th) {
|
||||
@@ -43,20 +41,7 @@
|
||||
|
||||
.cellContent {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.userIdentity {
|
||||
display: grid;
|
||||
flex: 1 1 0;
|
||||
grid-template-rows: auto auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
row-gap: 1px;
|
||||
}
|
||||
|
||||
.userIdentityLine {
|
||||
min-width: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.sessionRow {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { StreamSession } from "@homarr/integrations";
|
||||
|
||||
import { getMediaServerColumnVisibility, getSeasonEpisodeParams, SessionDetailsPopover } from "./component";
|
||||
import { getMediaServerColumnVisibility, SessionDetailsPopover } from "./component";
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useScopedI18n: () => (key: string) => key,
|
||||
@@ -108,19 +108,3 @@ describe("getMediaServerColumnVisibility", () => {
|
||||
expect(getMediaServerColumnVisibility(0, true)).toEqual({ user: true, status: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSeasonEpisodeParams", () => {
|
||||
test("zero-pads season and episode numbers", () => {
|
||||
expect(getSeasonEpisodeParams(4, 12)).toEqual({ season: "04", episode: "12" });
|
||||
});
|
||||
|
||||
test("does not pad numbers already two digits or longer", () => {
|
||||
expect(getSeasonEpisodeParams(12, 104)).toEqual({ season: "12", episode: "104" });
|
||||
});
|
||||
|
||||
test("returns null when either number is missing", () => {
|
||||
expect(getSeasonEpisodeParams(undefined, 12)).toBeNull();
|
||||
expect(getSeasonEpisodeParams(4, null)).toBeNull();
|
||||
expect(getSeasonEpisodeParams(null, undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -77,16 +77,6 @@ function formatBitrate(bitrateKbps: number | null | undefined): string | null {
|
||||
return bitrateKbps >= 1000 ? `${(bitrateKbps / 1000).toFixed(1)} Mbps` : `${Math.round(bitrateKbps)} kbps`;
|
||||
}
|
||||
|
||||
export function getSeasonEpisodeParams(
|
||||
seasonNumber: number | null | undefined,
|
||||
episodeNumber: number | null | undefined,
|
||||
): { season: string; episode: string } | null {
|
||||
if (seasonNumber === null || seasonNumber === undefined || episodeNumber === null || episodeNumber === undefined) {
|
||||
return null;
|
||||
}
|
||||
return { season: String(seasonNumber).padStart(2, "0"), episode: String(episodeNumber).padStart(2, "0") };
|
||||
}
|
||||
|
||||
const getSessionSortValue = (session: StreamSession, column: SortColumn) => {
|
||||
if (column === "user") return session.user?.username ?? session.sessionName;
|
||||
if (column === "currentlyPlaying") return session.currentlyPlaying?.name ?? session.sessionName;
|
||||
@@ -134,7 +124,7 @@ function StreamTableHeader({
|
||||
<Text component="span" size="xs" fw={600} truncate>
|
||||
{label}
|
||||
</Text>
|
||||
<SortIcon size="var(--mantine-font-size-xs)" aria-hidden />
|
||||
<SortIcon size={13} aria-hidden />
|
||||
</UnstyledButton>
|
||||
) : (
|
||||
<Text size="xs" fw={600} truncate>
|
||||
@@ -235,7 +225,7 @@ export default function MediaServerWidget({
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
placeholder={tGlobal("search.placeholder")}
|
||||
aria-label={tGlobal("search.placeholder")}
|
||||
leftSection={<IconSearch size="var(--mantine-font-size-xs)" aria-hidden />}
|
||||
leftSection={<IconSearch size={14} aria-hidden />}
|
||||
/>
|
||||
<WidgetQueryErrorIndicator error={currentStreamsQuery.error} label={t("name")} />
|
||||
<IntegrationErrorIndicator results={currentStreams} />
|
||||
@@ -314,13 +304,13 @@ export default function MediaServerWidget({
|
||||
>
|
||||
{columnVisibility.user && (
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap" w="100%">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Avatar size={28} src={session.user?.profilePictureUrl} />
|
||||
<Stack gap={2} className={classes.cellContent}>
|
||||
<Stack gap={0} className={classes.cellContent}>
|
||||
<Text size="xs" truncate>
|
||||
{session.user?.username ?? t("items.unknownUser")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
<Text size="10px" c="dimmed" truncate>
|
||||
{session.sessionName}
|
||||
</Text>
|
||||
</Stack>
|
||||
@@ -339,27 +329,22 @@ export default function MediaServerWidget({
|
||||
{columnVisibility.status && (
|
||||
<Table.Td>
|
||||
{currentlyPlaying && (
|
||||
<Stack gap={4} align="flex-start" w="100%" className={classes.cellContent}>
|
||||
<Stack gap={4} align="flex-start" className={classes.cellContent}>
|
||||
<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%">
|
||||
<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 && (location === "lan" ? <IconWifi size={12} /> : <IconWorld size={12} />)}
|
||||
{location && (
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
<Text size="10px" c="dimmed" tt="uppercase">
|
||||
{location}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
{bitrateLabel && (
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
<Text size="10px" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{bitrateLabel}
|
||||
</Text>
|
||||
)}
|
||||
@@ -387,7 +372,7 @@ export default function MediaServerWidget({
|
||||
}}
|
||||
>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconVideo size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} />
|
||||
<IconVideo size={16} style={{ flexShrink: 0 }} />
|
||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", {
|
||||
count: flatSessions.length,
|
||||
@@ -439,25 +424,14 @@ function CurrentlyPlaying({ item }: { item: StreamSession }) {
|
||||
: null;
|
||||
const remainingMinutes =
|
||||
positionMs !== null && durationMs !== null ? Math.max(0, Math.round((durationMs - positionMs) / 60_000)) : null;
|
||||
const seasonEpisodeParams = getSeasonEpisodeParams(currentlyPlaying.seasonNumber, currentlyPlaying.episodeNumber);
|
||||
const seasonEpisodeLabel = seasonEpisodeParams ? t("items.seasonEpisode", seasonEpisodeParams) : null;
|
||||
|
||||
return (
|
||||
<Stack gap={6} style={{ minWidth: 0 }}>
|
||||
<Stack gap={4} style={{ minWidth: 0 }}>
|
||||
<Group gap="xs" align="center" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Icon
|
||||
size="var(--mantine-font-size-xs)"
|
||||
color={isPaused ? "var(--mantine-color-yellow-6)" : undefined}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<Icon size={16} color={isPaused ? "var(--mantine-color-yellow-6)" : undefined} style={{ flexShrink: 0 }} />
|
||||
<Text size="xs" lineClamp={1} style={{ minWidth: 0 }}>
|
||||
{currentlyPlaying.name}
|
||||
</Text>
|
||||
{seasonEpisodeLabel && (
|
||||
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
|
||||
{seasonEpisodeLabel}
|
||||
</Text>
|
||||
)}
|
||||
{isPaused ? (
|
||||
<Text size="xs" c="yellow" style={{ flexShrink: 0 }}>
|
||||
{t("items.paused")}
|
||||
|
||||
@@ -153,7 +153,7 @@ export default function MediaTranscodingWidget({
|
||||
return {
|
||||
label: (
|
||||
<Center style={{ gap: 4 }}>
|
||||
<Icon size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} />
|
||||
<Icon size={12} />
|
||||
{footerLayout.showTabLabels ? (
|
||||
<Text span size="xs">
|
||||
{t(`tab.${value}`)}
|
||||
@@ -259,7 +259,7 @@ export const resolveQueuePagination = (
|
||||
const AdvancedPanel = ({ title, icon: Icon, children }: { title: string; icon: TablerIcon; children: ReactNode }) => (
|
||||
<Paper withBorder radius="sm" p="xs" mih={280} style={{ display: "flex", flexDirection: "column" }}>
|
||||
<Group gap="xs" mb="xs">
|
||||
<Icon size="var(--mantine-font-size-md)" style={{ flexShrink: 0 }} />
|
||||
<Icon size={16} />
|
||||
<Text size="sm" fw={600}>
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
@@ -27,7 +27,7 @@ export function HealthCheckStatus(props: HealthCheckStatusProps) {
|
||||
<Popover.Target>
|
||||
<ActionIcon className={actionTargetClasses.root} variant="subtle" size="sm" aria-label={t("title")}>
|
||||
<Indicator color={textColor(indicatorColor, colorScheme)} size={6} display="flex">
|
||||
<IconHeartbeat size="var(--mantine-font-size-md)" />
|
||||
<IconHeartbeat size={16} />
|
||||
</Indicator>
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
@@ -38,7 +38,7 @@ export function HealthCheckStatus(props: HealthCheckStatusProps) {
|
||||
>
|
||||
<Stack gap="sm" align="center">
|
||||
<Group gap="xs">
|
||||
<IconHeartbeat size="var(--mantine-font-size-lg)" />
|
||||
<IconHeartbeat size={18} />
|
||||
<Text size="sm">{t("title")}</Text>
|
||||
</Group>
|
||||
<Divider
|
||||
|
||||
@@ -59,11 +59,11 @@ export function QueuePanel(props: QueuePanelProps) {
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{item.type === "transcode" ? (
|
||||
<Tooltip label={t("table.transcode")}>
|
||||
<IconTransform size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} />
|
||||
<IconTransform size={12} />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip label={t("table.healthCheck")}>
|
||||
<IconHeartbeat size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} />
|
||||
<IconHeartbeat size={12} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<Text lineClamp={1} size="xs">
|
||||
|
||||
@@ -94,7 +94,7 @@ function StatisticItem(props: StatisticItemProps) {
|
||||
<Tooltip label={props.label}>
|
||||
<Card p={0} radius={board.itemRadius} miw={48} flex={1}>
|
||||
<Group justify="center" align="center" gap="xs" w="100%" wrap="nowrap">
|
||||
<props.icon size="var(--mantine-font-size-md)" style={{ flexShrink: 0 }} />
|
||||
<props.icon size={16} style={{ minWidth: 16 }} />
|
||||
<Text size="md">{props.value}</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
@@ -66,11 +66,11 @@ export function WorkersPanel(props: WorkersPanelProps) {
|
||||
<div>
|
||||
{worker.jobType === "transcode" ? (
|
||||
<Tooltip label={t("table.transcode")}>
|
||||
<IconTransform size="var(--mantine-font-size-sm)" style={{ flexShrink: 0 }} />
|
||||
<IconTransform size={14} />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip label={t("table.healthCheck")}>
|
||||
<IconHeartbeat size="var(--mantine-font-size-sm)" style={{ flexShrink: 0 }} />
|
||||
<IconHeartbeat size={14} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -76,7 +76,7 @@ export const EmbeddedAppEditForm = ({ appId, handleRef }: EmbeddedAppEditFormPro
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Alert icon={<IconInfoCircle size="var(--mantine-font-size-md)" />} color="blue" variant="light">
|
||||
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
||||
{t("item.edit.app.propagationNotice")}
|
||||
</Alert>
|
||||
<AppForm
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function NetworkControllerSummaryWidget({
|
||||
<StatusIcon
|
||||
status={section.status}
|
||||
label={statusLabels[getBinaryStatusKey(section.status)]}
|
||||
size="var(--mantine-font-size-md)"
|
||||
size={16}
|
||||
/>
|
||||
<Text size="sm" fw={600}>
|
||||
{getMatrixSectionLabel(section.key, t)}
|
||||
@@ -210,7 +210,7 @@ export default function NetworkControllerSummaryWidget({
|
||||
<StatusIcon
|
||||
status={summary.lan.status}
|
||||
label={statusLabels[getBinaryStatusKey(summary.lan.status)]}
|
||||
size="var(--mantine-font-size-xl)"
|
||||
size={20}
|
||||
/>
|
||||
}
|
||||
>
|
||||
@@ -256,15 +256,7 @@ const getMatrixSectionLabel = (key: NetworkControllerMatrixSectionKey, t: Transl
|
||||
return t(`widget.networkControllerSummary.card.${key}`);
|
||||
};
|
||||
|
||||
const StatusIcon = ({
|
||||
status,
|
||||
label,
|
||||
size,
|
||||
}: {
|
||||
status?: "enabled" | "disabled";
|
||||
label: string;
|
||||
size: number | string;
|
||||
}) => {
|
||||
const StatusIcon = ({ status, label, size }: { status?: "enabled" | "disabled"; label: string; size: number }) => {
|
||||
const mantineTheme = useMantineTheme();
|
||||
if (status === "enabled") {
|
||||
return <IconCircleCheckFilled aria-label={label} size={size} color={mantineTheme.colors.green[6]} />;
|
||||
|
||||
@@ -69,15 +69,6 @@ interface DynamicSelectInput extends CommonInput<DynamicSelectOption | null> {
|
||||
};
|
||||
}
|
||||
|
||||
interface DynamicMultiSelectInput extends CommonInput<string[]> {
|
||||
maxValues?: number;
|
||||
useOptions: () => {
|
||||
data: DynamicSelectOption[];
|
||||
isPending: boolean;
|
||||
isError: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface IntegrationSelectInput extends CommonInput<string> {
|
||||
clearable?: boolean;
|
||||
searchable?: boolean;
|
||||
@@ -150,13 +141,6 @@ const optionsFactory = {
|
||||
useOptions: input.useOptions,
|
||||
withDescription: input.withDescription ?? false,
|
||||
}),
|
||||
dynamicMultiSelect: (input: DynamicMultiSelectInput) => ({
|
||||
type: "dynamicMultiSelect" as const,
|
||||
defaultValue: input.defaultValue ?? [],
|
||||
maxValues: input.maxValues,
|
||||
useOptions: input.useOptions,
|
||||
withDescription: input.withDescription ?? false,
|
||||
}),
|
||||
select: <const TOptions extends SelectOption[]>(input: SelectInput<TOptions>) => ({
|
||||
type: "select" as const,
|
||||
defaultValue: (input.defaultValue ?? input.options[0]) as inferSelectOptionValue<TOptions[number]>,
|
||||
|
||||
@@ -445,7 +445,7 @@ function CompactStatGrid({
|
||||
style={{ "--stat-bg": severityToMantineBgColor(severity) } as CSSProperties}
|
||||
>
|
||||
{showIcon && (
|
||||
<Icon className={classes.statIcon} size="var(--mantine-font-size-lg)" stroke={1.5} color={severityToIconColor(severity)} />
|
||||
<Icon className={classes.statIcon} size={18} stroke={1.5} color={severityToIconColor(severity)} />
|
||||
)}
|
||||
<span className={classes.compactStatValue}>
|
||||
<Text component="span" fw={700} size="inherit" c={severityToMantineColor(severity)}>
|
||||
|
||||
@@ -303,20 +303,20 @@ export default function ReleasesWidget({ options, itemId, width, displayMode }:
|
||||
{hasError ? (
|
||||
<IconTriangleFilled
|
||||
className="releases-repository-header-releaseDate-icon releases-repository-header-releaseDate-error"
|
||||
size="var(--mantine-font-size-xs)"
|
||||
size={10}
|
||||
color="var(--mantine-color-red-filled)"
|
||||
/>
|
||||
) : repository.viewed ? (
|
||||
<IconCheck
|
||||
className="releases-repository-header-releaseDate-icon releases-repository-header-releaseDate-confirmed"
|
||||
size="var(--mantine-font-size-xs)"
|
||||
size={10}
|
||||
color="green"
|
||||
/>
|
||||
) : (
|
||||
(repository.isNewRelease || repository.isStaleRelease) && (
|
||||
<IconCircleFilled
|
||||
className="releases-repository-header-releaseDate-icon releases-repository-header-releaseDate-marker"
|
||||
size="var(--mantine-font-size-xs)"
|
||||
size={10}
|
||||
color={
|
||||
repository.isNewRelease
|
||||
? "var(--mantine-color-primaryColor-filled)"
|
||||
@@ -390,7 +390,7 @@ const DetailsDisplay = ({ repository, isExpanded, toggleExpandedDisplay }: Detai
|
||||
"releases-repository-details-icon",
|
||||
"releases-repository-details-icon-preRelease",
|
||||
)}
|
||||
size="var(--mantine-font-size-sm)"
|
||||
size={13}
|
||||
color={
|
||||
repository.isPreRelease ? "var(--mantine-color-secondaryColor-text)" : "var(--mantine-color-dimmed)"
|
||||
}
|
||||
@@ -411,7 +411,7 @@ const DetailsDisplay = ({ repository, isExpanded, toggleExpandedDisplay }: Detai
|
||||
"releases-repository-details-icon",
|
||||
"releases-repository-details-icon-archived",
|
||||
)}
|
||||
size="var(--mantine-font-size-sm)"
|
||||
size={13}
|
||||
color={
|
||||
repository.isArchived ? "var(--mantine-color-secondaryColor-text)" : "var(--mantine-color-dimmed)"
|
||||
}
|
||||
@@ -432,7 +432,7 @@ const DetailsDisplay = ({ repository, isExpanded, toggleExpandedDisplay }: Detai
|
||||
"releases-repository-details-icon",
|
||||
"releases-repository-details-icon-forked",
|
||||
)}
|
||||
size="var(--mantine-font-size-sm)"
|
||||
size={13}
|
||||
color={repository.isFork ? "var(--mantine-color-secondaryColor-text)" : "var(--mantine-color-dimmed)"}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -459,7 +459,7 @@ const DetailsDisplay = ({ repository, isExpanded, toggleExpandedDisplay }: Detai
|
||||
"releases-repository-details-stats-icon",
|
||||
"releases-repository-details-stats-stars-icon",
|
||||
)}
|
||||
size="var(--mantine-font-size-xs)"
|
||||
size={12}
|
||||
color={!repository.starsCount ? "var(--mantine-color-dimmed)" : "var(--mantine-color-text)"}
|
||||
/>
|
||||
<Text
|
||||
@@ -501,7 +501,7 @@ const DetailsDisplay = ({ repository, isExpanded, toggleExpandedDisplay }: Detai
|
||||
"releases-repository-details-stats-icon",
|
||||
"releases-repository-details-stats-forks-icon",
|
||||
)}
|
||||
size="var(--mantine-font-size-xs)"
|
||||
size={12}
|
||||
color={!repository.forksCount ? "var(--mantine-color-dimmed)" : "var(--mantine-color-text)"}
|
||||
/>
|
||||
<Text
|
||||
@@ -543,7 +543,7 @@ const DetailsDisplay = ({ repository, isExpanded, toggleExpandedDisplay }: Detai
|
||||
"releases-repository-details-stats-icon",
|
||||
"releases-repository-details-stats-issues-icon",
|
||||
)}
|
||||
size="var(--mantine-font-size-xs)"
|
||||
size={12}
|
||||
color={!repository.openIssues ? "var(--mantine-color-dimmed)" : "var(--mantine-color-text)"}
|
||||
/>
|
||||
<Text
|
||||
|
||||
@@ -73,7 +73,7 @@ export default function RssFeed({ options, width, height, displayMode }: WidgetC
|
||||
return (
|
||||
<Flex direction="column" h="100%" p="xs">
|
||||
{warning && (
|
||||
<Alert role="presentation" color="orange" icon={<IconAlertTriangle aria-hidden size="var(--mantine-font-size-md)" />} p="xs">
|
||||
<Alert role="presentation" color="orange" icon={<IconAlertTriangle aria-hidden size={16} />} p="xs">
|
||||
<output>{warning}</output>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -103,7 +103,7 @@ export default function RssFeed({ options, width, height, displayMode }: WidgetC
|
||||
return (
|
||||
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p={isTiny ? 4 : "xs"}>
|
||||
{warning && (
|
||||
<Alert role="presentation" color="orange" icon={<IconAlertTriangle aria-hidden size="var(--mantine-font-size-md)" />} p="xs" mb="xs">
|
||||
<Alert role="presentation" color="orange" icon={<IconAlertTriangle aria-hidden size={16} />} p="xs" mb="xs">
|
||||
<output>{warning}</output>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -78,19 +78,14 @@ const TimetableWidgetInner = ({ station, baseUrl, itemId, displayMode, width, he
|
||||
)}
|
||||
{compactStaleWarning && displayMode === "compact" && (
|
||||
<Group gap={2} wrap="nowrap">
|
||||
<IconAlertTriangle
|
||||
aria-hidden
|
||||
size="var(--mantine-font-size-xs)"
|
||||
color="var(--mantine-color-orange-light-color)"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<IconAlertTriangle aria-hidden size={12} color="var(--mantine-color-orange-light-color)" />
|
||||
<Text component="output" size="xs" c="var(--mantine-color-text)" style={{ whiteSpace: "nowrap" }}>
|
||||
{compactStaleWarning}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{staleWarning && displayMode === "advanced" && (
|
||||
<Alert role="presentation" color="orange" icon={<IconAlertTriangle aria-hidden size="var(--mantine-font-size-md)" />} p="xs">
|
||||
<Alert role="presentation" color="orange" icon={<IconAlertTriangle aria-hidden size={16} />} p="xs">
|
||||
<output>{staleWarning}</output>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -18,14 +18,14 @@ export function StatsBar({
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={cols} spacing="xs">
|
||||
<StatCard icon={<IconVideo size="var(--mantine-font-size-md)" />} label={t("stats.activeStreams")} value={stats.activeStreams} />
|
||||
<StatCard icon={<IconUsers size="var(--mantine-font-size-md)" />} label={t("stats.totalUsers")} value={stats.totalUsers} />
|
||||
<StatCard icon={<IconVideo size={16} />} label={t("stats.activeStreams")} value={stats.activeStreams} />
|
||||
<StatCard icon={<IconUsers size={16} />} label={t("stats.totalUsers")} value={stats.totalUsers} />
|
||||
<StatCard
|
||||
icon={<IconDevices size="var(--mantine-font-size-md)" />}
|
||||
icon={<IconDevices size={16} />}
|
||||
label={t("stats.transcodes")}
|
||||
value={`${summary.transcodes}/${summary.total}`}
|
||||
/>
|
||||
<StatCard icon={<IconNetwork size="var(--mantine-font-size-md)" />} label={t("stats.bandwidth")} value={summary.totalBitrate} />
|
||||
<StatCard icon={<IconNetwork size={16} />} label={t("stats.bandwidth")} value={summary.totalBitrate} />
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,9 +88,9 @@ function StreamCard({
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
|
||||
{stream.state === "playing" ? (
|
||||
<IconPlayerPlay size="var(--mantine-font-size-sm)" color="var(--mantine-color-green-6)" />
|
||||
<IconPlayerPlay size={14} color="var(--mantine-color-green-6)" />
|
||||
) : (
|
||||
<IconPlayerPause size="var(--mantine-font-size-sm)" color="var(--mantine-color-yellow-6)" />
|
||||
<IconPlayerPause size={14} color="var(--mantine-color-yellow-6)" />
|
||||
)}
|
||||
<Text size="sm" fw={600} lineClamp={1}>
|
||||
{stream.username}
|
||||
|
||||
@@ -34,7 +34,7 @@ export function ViolationsList({
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
|
||||
<IconAlertTriangle
|
||||
size="var(--mantine-font-size-sm)"
|
||||
size={14}
|
||||
color={
|
||||
violation.severity === "high"
|
||||
? "var(--mantine-color-red-6)"
|
||||
|
||||
@@ -308,7 +308,7 @@ function ProtocolCard({ protocol, data }: { protocol: ProtocolKey; data: Traefik
|
||||
{rows.map(({ key, icon: Icon, summary }) => (
|
||||
<div key={key} className={classes.protocolRow}>
|
||||
<Group gap={4} wrap="nowrap" className={classes.protocolLabel}>
|
||||
<Icon size="var(--mantine-font-size-sm)" />
|
||||
<Icon size={14} />
|
||||
<Text>{t(`resource.${key}`)}</Text>
|
||||
</Group>
|
||||
<StatusCount summary={summary} />
|
||||
@@ -354,7 +354,7 @@ function HealthBadge({ errors, warnings }: { errors: number; warnings: number })
|
||||
|
||||
if (errors > 0) {
|
||||
return (
|
||||
<Badge color="red" variant="light" leftSection={<IconAlertTriangle size="var(--mantine-font-size-xs)" />} radius="sm">
|
||||
<Badge color="red" variant="light" leftSection={<IconAlertTriangle size={12} />} radius="sm">
|
||||
{t("status.errors", { count: errors })}
|
||||
</Badge>
|
||||
);
|
||||
@@ -362,14 +362,14 @@ function HealthBadge({ errors, warnings }: { errors: number; warnings: number })
|
||||
|
||||
if (warnings > 0) {
|
||||
return (
|
||||
<Badge color="yellow" variant="light" leftSection={<IconAlertTriangle size="var(--mantine-font-size-xs)" />} radius="sm">
|
||||
<Badge color="yellow" variant="light" leftSection={<IconAlertTriangle size={12} />} radius="sm">
|
||||
{t("status.warnings", { count: warnings })}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge color="green" variant="light" leftSection={<IconCircleCheck size="var(--mantine-font-size-xs)" />} radius="sm">
|
||||
<Badge color="green" variant="light" leftSection={<IconCircleCheck size={12} />} radius="sm">
|
||||
{t("status.healthy")}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
@@ -159,7 +159,7 @@ function VpnInfoColumn({
|
||||
</Text>
|
||||
{!dense && (city || country) && (
|
||||
<Group gap={4} justify="flex-start" wrap="nowrap">
|
||||
<IconMapPin aria-hidden size="var(--mantine-font-size-xs)" />
|
||||
<IconMapPin aria-hidden size={10} />
|
||||
<Text fs="italic" fw={500} size="sm" lh={1.2} lineClamp={1}>
|
||||
{[city, country].filter(Boolean).join(", ")}
|
||||
</Text>
|
||||
@@ -176,7 +176,7 @@ function VpnProviderDetails({ provider, protocol }: { provider: string; protocol
|
||||
<Text fz="xs" lh={1.2} tt="capitalize" lineClamp={1}>
|
||||
{provider}
|
||||
</Text>
|
||||
<IconArrowsExchange stroke={1} size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} />
|
||||
<IconArrowsExchange stroke={1} size={12} style={{ flexShrink: 0 }} />
|
||||
<Text fz="xs" lh={1.2} tt="capitalize" lineClamp={1}>
|
||||
{protocol}
|
||||
</Text>
|
||||
@@ -196,7 +196,7 @@ function DnsStatusBadge({ status }: { status: string }) {
|
||||
color={getStatusColor(status)}
|
||||
size="xs"
|
||||
radius="xl"
|
||||
leftSection={<StatusIcon aria-hidden size="var(--mantine-font-size-xs)" />}
|
||||
leftSection={<StatusIcon aria-hidden size={9} />}
|
||||
>
|
||||
DNS
|
||||
<VisuallyHidden> {statusLabel}</VisuallyHidden>
|
||||
|
||||
@@ -116,11 +116,11 @@ const AdvancedWeather = ({ options, weather }: WeatherProps) => {
|
||||
|
||||
<Group gap="lg" wrap="wrap">
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<IconMapPin size="var(--mantine-font-size-md)" aria-hidden />
|
||||
<IconMapPin size={16} aria-hidden />
|
||||
<Text size="sm">{options.location.name}</Text>
|
||||
</Group>
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<IconWind size="var(--mantine-font-size-md)" aria-hidden />
|
||||
<IconWind size={16} aria-hidden />
|
||||
<Text size="sm">
|
||||
{t("currentWindSpeed", {
|
||||
currentWindSpeed: getPreferredWindSpeed(weather.current.windspeed, options.useImperialSpeed),
|
||||
@@ -196,7 +196,7 @@ const AdvancedWeather = ({ options, weather }: WeatherProps) => {
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconArrowUpRight size="var(--mantine-font-size-sm)" aria-hidden />
|
||||
<IconArrowUpRight size={15} aria-hidden />
|
||||
<Text size="sm">
|
||||
{getPreferredUnit(
|
||||
dayWeather.maxTemp,
|
||||
@@ -207,7 +207,7 @@ const AdvancedWeather = ({ options, weather }: WeatherProps) => {
|
||||
<Text size="sm" c="dimmed">
|
||||
/
|
||||
</Text>
|
||||
<IconArrowDownRight size="var(--mantine-font-size-sm)" aria-hidden />
|
||||
<IconArrowDownRight size={15} aria-hidden />
|
||||
<Text size="sm">
|
||||
{getPreferredUnit(
|
||||
dayWeather.minTemp,
|
||||
@@ -219,13 +219,13 @@ const AdvancedWeather = ({ options, weather }: WeatherProps) => {
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<IconDroplets size="var(--mantine-font-size-sm)" aria-hidden />
|
||||
<IconDroplets size={15} aria-hidden />
|
||||
<Text size="sm">{dayWeather.humidity === undefined ? "?" : `${dayWeather.humidity}%`}</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<IconWind size="var(--mantine-font-size-sm)" aria-hidden />
|
||||
<IconWind size={15} aria-hidden />
|
||||
<Text size="sm">
|
||||
{getPreferredWindSpeed(dayWeather.maxWindSpeed, options.useImperialSpeed)} /{" "}
|
||||
{getPreferredWindSpeed(dayWeather.maxWindGusts, options.useImperialSpeed)}
|
||||
@@ -235,14 +235,14 @@ const AdvancedWeather = ({ options, weather }: WeatherProps) => {
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Group gap={3} wrap="nowrap">
|
||||
<IconSunrise size="var(--mantine-font-size-sm)" aria-hidden />
|
||||
<IconSunrise size={15} aria-hidden />
|
||||
<Text size="sm">{getPreferredTime(dayWeather.sunrise, locale)}</Text>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
/
|
||||
</Text>
|
||||
<Group gap={3} wrap="nowrap">
|
||||
<IconSunset size="var(--mantine-font-size-sm)" aria-hidden />
|
||||
<IconSunset size={15} aria-hidden />
|
||||
<Text size="sm">{getPreferredTime(dayWeather.sunset, locale)}</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
@@ -285,7 +285,7 @@ const DailyWeather = ({ options, weather, isEditMode }: CompactWeatherProps) =>
|
||||
<Stack gap="xs" align="center">
|
||||
{options.showCurrentWindSpeed && (
|
||||
<Group className="weather-current-wind-speed-group" wrap="nowrap" gap="xs">
|
||||
<IconWind size="var(--mantine-font-size-md)" />
|
||||
<IconWind size={16} />
|
||||
<Text fz={16}>
|
||||
{t("currentWindSpeed", {
|
||||
currentWindSpeed: (options.useImperialSpeed
|
||||
@@ -301,13 +301,13 @@ const DailyWeather = ({ options, weather, isEditMode }: CompactWeatherProps) =>
|
||||
)}
|
||||
{weather.daily[0]?.humidity !== undefined && (
|
||||
<Group className="weather-humidity-group" wrap="nowrap" gap="xs">
|
||||
<IconDroplets size="var(--mantine-font-size-md)" />
|
||||
<IconDroplets size={16} />
|
||||
<Text fz={16}>{t("dailyForecast.humidity", { humidity: weather.daily[0].humidity })}</Text>
|
||||
</Group>
|
||||
)}
|
||||
<Group className="weather-max-min-temp-group" wrap="nowrap" gap="sm">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<IconArrowUpRight size="var(--mantine-font-size-md)" />
|
||||
<IconArrowUpRight size={16} />
|
||||
<Text fz={16}>
|
||||
{getPreferredUnit(
|
||||
weather.daily[0]?.maxTemp,
|
||||
@@ -317,7 +317,7 @@ const DailyWeather = ({ options, weather, isEditMode }: CompactWeatherProps) =>
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<IconArrowDownRight size="var(--mantine-font-size-md)" />
|
||||
<IconArrowDownRight size={16} />
|
||||
<Text fz={16}>
|
||||
{getPreferredUnit(
|
||||
weather.daily[0]?.minTemp,
|
||||
@@ -331,7 +331,7 @@ const DailyWeather = ({ options, weather, isEditMode }: CompactWeatherProps) =>
|
||||
{options.showCity && (
|
||||
<>
|
||||
<Group className="weather-city-group" wrap="nowrap" gap="xs">
|
||||
<IconMapPin size="var(--mantine-font-size-md)" />
|
||||
<IconMapPin size={16} />
|
||||
<Text fz={16} style={{ whiteSpace: "nowrap" }}>
|
||||
{options.location.name}
|
||||
</Text>
|
||||
@@ -350,7 +350,7 @@ const WeeklyForecast = ({ options, weather, isEditMode, maxDays }: CompactWeathe
|
||||
<Group className="weather-forecast-city-temp-group" wrap="nowrap" gap="md">
|
||||
{options.showCity && (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<IconMapPin size="var(--mantine-font-size-md)" />
|
||||
<IconMapPin size={16} />
|
||||
<Text fz={16} style={{ whiteSpace: "nowrap" }}>
|
||||
{options.location.name}
|
||||
</Text>
|
||||
|
||||
@@ -95,15 +95,15 @@ export const WeatherDescription = ({
|
||||
<Text fz="24px">{formatWeatherDate(time, locale, dateFormat)}</Text>
|
||||
<Text fz="16px">{t(`kind.${name}`)}</Text>
|
||||
<List>
|
||||
<List.Item icon={<IconTemperaturePlus size="var(--mantine-font-size-sm)" />}>{`${tCommon("information.max")}: ${maxTemp}`}</List.Item>
|
||||
<List.Item icon={<IconTemperatureMinus size="var(--mantine-font-size-sm)" />}>{`${tCommon("information.min")}: ${minTemp}`}</List.Item>
|
||||
<List.Item icon={<IconSun size="var(--mantine-font-size-sm)" />}>{`${t("dailyForecast.sunrise")}: ${sunrise}`}</List.Item>
|
||||
<List.Item icon={<IconMoon size="var(--mantine-font-size-sm)" />}>{`${t("dailyForecast.sunset")}: ${sunset}`}</List.Item>
|
||||
<List.Item icon={<IconTemperaturePlus size={15} />}>{`${tCommon("information.max")}: ${maxTemp}`}</List.Item>
|
||||
<List.Item icon={<IconTemperatureMinus size={15} />}>{`${tCommon("information.min")}: ${minTemp}`}</List.Item>
|
||||
<List.Item icon={<IconSun size={15} />}>{`${t("dailyForecast.sunrise")}: ${sunrise}`}</List.Item>
|
||||
<List.Item icon={<IconMoon size={15} />}>{`${t("dailyForecast.sunset")}: ${sunset}`}</List.Item>
|
||||
{humidity !== undefined && (
|
||||
<List.Item icon={<IconDroplets size="var(--mantine-font-size-sm)" />}>{t("dailyForecast.humidity", { humidity })}</List.Item>
|
||||
<List.Item icon={<IconDroplets size={15} />}>{t("dailyForecast.humidity", { humidity })}</List.Item>
|
||||
)}
|
||||
{maxWindSpeed !== undefined && (
|
||||
<List.Item icon={<IconWind size="var(--mantine-font-size-sm)" />}>
|
||||
<List.Item icon={<IconWind size={15} />}>
|
||||
{t("dailyForecast.maxWindSpeed", {
|
||||
maxWindSpeed: (useImperialSpeed ? metricToImperial(maxWindSpeed) : maxWindSpeed).toFixed(1),
|
||||
unit: useImperialSpeed ? tCommon("unit.speed.milesPerHour") : tCommon("unit.speed.kilometersPerHour"),
|
||||
@@ -111,7 +111,7 @@ export const WeatherDescription = ({
|
||||
</List.Item>
|
||||
)}
|
||||
{maxWindGusts !== undefined && (
|
||||
<List.Item icon={<IconWind size="var(--mantine-font-size-sm)" />}>
|
||||
<List.Item icon={<IconWind size={15} />}>
|
||||
{t("dailyForecast.maxWindGusts", {
|
||||
maxWindGusts: (useImperialSpeed ? metricToImperial(maxWindGusts) : maxWindGusts).toFixed(1),
|
||||
unit: useImperialSpeed ? tCommon("unit.speed.milesPerHour") : tCommon("unit.speed.kilometersPerHour"),
|
||||
|
||||
@@ -88,7 +88,7 @@ export const WidgetIntegrationSelect = ({
|
||||
return (
|
||||
<Combobox.Option value={item.id} key={item.id} active={multiSelectValues.includes(item.id)}>
|
||||
<Group gap="sm" align="center">
|
||||
{multiSelectValues.includes(item.id) ? <CheckIcon size="var(--mantine-font-size-xs)" /> : null}
|
||||
{multiSelectValues.includes(item.id) ? <CheckIcon size={12} /> : null}
|
||||
<Group gap={7} align="center">
|
||||
<Avatar src={getIconUrl(item.kind)} size="sm" />
|
||||
<Stack gap={0}>
|
||||
|
||||
Reference in new issue
Block a user