Compare commits

...
Author SHA1 Message Date
Bergasha 2f09b41160 fix(system-resources): divide chart rows evenly via CSS, no board-zoom pixel math
Each visible chart row was hardcoded to a fixed pixel height (110px in
advanced mode, 56px-capped in compact) regardless of the widget's actual
tile size - resizing the tile taller left the extra height unused, and
some visible charts required scrolling to reach.

Divide the available height evenly across rows using CSS Grid's
grid-auto-rows: 1fr (the Stack fills the tile via a definite pixel
height, the SimpleGrid grows into it via flex and divides its rows
evenly) rather than computing a per-chart pixel height in JS. Confirmed
via live inspection of a deployed board that Mantine's h prop is not
safe for this: its rem() converter strips a "px" suffix from any string
before checking whether it's a parseable number, so even a literal pixel
*string* passed through h= gets rescaled by rem() * var(--mantine-scale)
exactly like a raw number would - style.height is the only way to set
an exact, un-rescaled pixel value.
2026-09-01 16:01:25 +10:00
Thomas f57a660088 feat: add global server custom CSS (#6753)
* feat: add global server custom CSS

* refactor: reuse CodeMirror for CSS editors
2026-08-31 14:26:58 +02:00
19 changed files with 170 additions and 374 deletions

No files matched your search

+5 -2
View File
@@ -85,8 +85,11 @@ To do this, enter the board settings:
![](./img/enter-board-settings.png) ![](./img/enter-board-settings.png)
Scroll to the **Custom CSS** section on the board settings page. It is part of the unified settings form; enter your CSS Administrators can add instance-wide CSS under **Management → Settings → Branding → Custom CSS**. Enter CSS directly
there and save the board settings to apply it to that board. or import a community theme from the Workshop, then save the server settings to apply it to every page.
For board-specific styling, scroll to the **Custom CSS** section on the board settings page. Enter your CSS there and
save the board settings to apply it to that board. Board CSS is loaded after the global stylesheet and can override it.
![](./img/custom-css-setting.png) ![](./img/custom-css-setting.png)
@@ -142,6 +142,10 @@ Branding settings let administrators adapt Homarr to the identity and visual sty
Choose the primary and secondary colors used by the theme. **Lock primary color** prevents users from overriding the configured primary color. **Default corner style** controls the global radius used by cards, controls, and other interface elements. Choose the primary and secondary colors used by the theme. **Lock primary color** prevents users from overriding the configured primary color. **Default corner style** controls the global radius used by cards, controls, and other interface elements.
### Global custom CSS
The branding settings include a CSS editor for styles that should apply across the entire instance. You can write CSS directly or import a community theme from the Workshop. CSS configured on an individual board takes precedence over the global stylesheet.
### Sign-in page ### Sign-in page
You can set a greeting and a background image for authentication pages. The overlay slider adjusts the contrast between the background and the sign-in content. The visibility controls independently show or hide the application name, logo, and greeting. You can set a greeting and a background image for authentication pages. The overlay slider adjusts the contrast between the background and the sign-in content. The visibility controls independently show or hide the application name, logo, and greeting.
-3
View File
@@ -106,12 +106,10 @@
"next": "catalog:", "next": "catalog:",
"postcss-preset-mantine": "catalog:", "postcss-preset-mantine": "catalog:",
"posthog-js": "catalog:", "posthog-js": "catalog:",
"prismjs": "catalog:",
"react": "catalog:", "react": "catalog:",
"react-dom": "catalog:", "react-dom": "catalog:",
"react-error-boundary": "catalog:", "react-error-boundary": "catalog:",
"react-shiki": "catalog:", "react-shiki": "catalog:",
"react-simple-code-editor": "catalog:",
"remark-breaks": "catalog:", "remark-breaks": "catalog:",
"remark-directive": "catalog:", "remark-directive": "catalog:",
"remark-gfm": "catalog:", "remark-gfm": "catalog:",
@@ -128,7 +126,6 @@
"@types/better-sqlite3": "catalog:", "@types/better-sqlite3": "catalog:",
"@types/chroma-js": "catalog:", "@types/chroma-js": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"@types/prismjs": "catalog:",
"@types/react": "catalog:", "@types/react": "catalog:",
"@types/react-dom": "catalog:", "@types/react-dom": "catalog:",
"concurrently": "catalog:", "concurrently": "catalog:",
@@ -1,21 +1,16 @@
"use client"; "use client";
import { Alert, Input } from "@mantine/core"; import { Alert } from "@mantine/core";
import { highlight, languages } from "prismjs";
import Editor from "react-simple-code-editor";
import "~/styles/prismjs.scss";
import { IconInfoCircle } from "@tabler/icons-react"; import { IconInfoCircle } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import type { UseFormReturnType } from "@homarr/form"; import type { UseFormReturnType } from "@homarr/form";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { CustomCssEditor } from "~/components/custom-css/custom-css-editor";
import { SectionCard } from "~/components/manage/section-card"; import { SectionCard } from "~/components/manage/section-card";
import { WorkshopCssImportButton } from "~/components/workshop/workshop-css-import-button"; import { WorkshopCssImportButton } from "~/components/workshop/workshop-css-import-button";
import type { FormValues } from "./_settings-form"; import type { FormValues } from "./_settings-form";
import classes from "./customcss.module.css";
interface Props { interface Props {
form: UseFormReturnType<FormValues>; form: UseFormReturnType<FormValues>;
@@ -29,7 +24,12 @@ export const CustomCssSettingsContent = ({ form }: Props) => {
return ( return (
<SectionCard title={t("setting.section.customCss.title")}> <SectionCard title={t("setting.section.customCss.title")}>
<CustomCssInput {...form.getInputProps("customCss")} /> <CustomCssEditor
id="board-custom-css"
label={customCssT("label")}
description={customCssT("description")}
{...form.getInputProps("customCss")}
/>
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}> <Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
{customCssT("customClassesAlert.description")} {customCssT("customClassesAlert.description")}
@@ -38,40 +38,3 @@ export const CustomCssSettingsContent = ({ form }: Props) => {
</SectionCard> </SectionCard>
); );
}; };
interface CustomCssInputProps {
value?: string;
onChange: (value: string) => void;
error?: React.ReactNode;
}
const CustomCssInput = ({ value, onChange, error }: CustomCssInputProps) => {
const customCssT = useI18n("board.field.customCss");
return (
<Input.Wrapper
label={customCssT("label")}
labelProps={{
htmlFor: "custom-css",
}}
description={customCssT("description")}
error={error}
inputWrapperOrder={["label", "description", "input", "error"]}
>
<div className={classes.codeEditorRoot}>
<Editor
textareaId="custom-css"
onValueChange={onChange}
value={value ?? ""}
highlight={(code) => highlight(code, languages.extend("css", {}), "css")}
padding={10}
style={{
fontFamily: '"Fira code", "Fira Mono", monospace',
fontSize: 12,
minHeight: 250,
}}
/>
</div>
</Input.Wrapper>
);
};
@@ -1,22 +0,0 @@
.codeEditorFooter {
border-bottom-left-radius: var(--mantine-radius-sm);
border-bottom-right-radius: var(--mantine-radius-sm);
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-7));
}
.codeEditorRoot {
margin-top: 4px;
border-color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
border-width: 1px;
border-style: solid;
border-radius: var(--mantine-radius-sm);
}
.codeEditor {
background-color: light-dark(white, var(--mantine-color-dark-6));
font-size: var(--mantine-font-size-xs);
}
.codeEditor ::placeholder {
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
+1
View File
@@ -199,6 +199,7 @@ export default async function Layout(props: {
) : null} ) : null}
<SearchEngineOptimization /> <SearchEngineOptimization />
<CrowdinLiveTranslation locale={locale} /> <CrowdinLiveTranslation locale={locale} />
<style data-homarr-global-custom-css>{serverSettings.branding.customCss}</style>
</head> </head>
<body className={[fontSans.className, fontSans.variable].join(" ")} suppressHydrationWarning> <body className={[fontSans.className, fontSans.variable].join(" ")} suppressHydrationWarning>
<Analytics enabled={serverSettings.analytics.enableGeneral} /> <Analytics enabled={serverSettings.analytics.enableGeneral} />
@@ -1,50 +1 @@
"use client"; export { CodeEditor } from "~/components/custom-widgets/code-editor";
import { useMemo } from "react";
import type { ComponentProps } from "react";
import { CustomWidgetCodeEditor } from "@homarr/custom-widgets/workbench";
import { invariantTechnicalLabels } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
type SharedEditorProps = ComponentProps<typeof CustomWidgetCodeEditor>;
type CodeEditorProps = Omit<SharedEditorProps, "messages">;
export function CodeEditor(props: CodeEditorProps) {
const t = useI18n("customWidget.editor");
const tCommon = useI18n("common");
const messages = useMemo<SharedEditorProps["messages"]>(
() => ({
languageJsx: invariantTechnicalLabels.jsx,
languageJson: invariantTechnicalLabels.json,
undo: tCommon("action.undo"),
redo: t("action.redo"),
components: t("action.components"),
componentSearch: t("componentReference.search"),
componentEmpty: t("componentReference.empty"),
componentCount: (count) => t("componentReference.count", { count }),
insertStarter: t("action.insertStarter"),
format: t("action.format"),
copy: tCommon("action.copy"),
copied: t("action.copied"),
schema: t("action.schema"),
schemaTab: invariantTechnicalLabels.jsonSchema,
minimalTab: t("reference.minimal"),
fullTab: t("reference.full"),
errors: (count) => t("status.errors", { count }),
warnings: (count) => t("status.warnings", { count }),
ready: t("status.ready"),
position: (cursor) => t("status.position", cursor),
characters: (count, limit) =>
limit ? t("status.charactersWithLimit", { count, limit }) : t("status.characters", { count }),
diagnosticsTitle: t("diagnostics.title"),
diagnostic: (diagnostic) =>
`${diagnostic.line ? `${t("diagnostics.line", { line: diagnostic.line })}: ` : ""}${t(
`diagnostics.${diagnostic.code}` as never,
{ value: diagnostic.value ?? "" } as never,
)}`,
}),
[t, tCommon],
);
return <CustomWidgetCodeEditor {...props} messages={messages} />;
}
@@ -20,7 +20,9 @@ import type { UseFormReturnType } from "@homarr/form";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { BoardColorInput, CornerStylePicker, cornerStyleValues } from "@homarr/ui"; import { BoardColorInput, CornerStylePicker, cornerStyleValues } from "@homarr/ui";
import { CustomCssEditor } from "~/components/custom-css/custom-css-editor";
import { SectionCard } from "~/components/manage/section-card"; import { SectionCard } from "~/components/manage/section-card";
import { WorkshopCssImportButton } from "~/components/workshop/workshop-css-import-button";
import type { FormValues } from "./settings-form"; import type { FormValues } from "./settings-form";
interface BrandingSettingsFormProps { interface BrandingSettingsFormProps {
@@ -101,6 +103,21 @@ export const BrandingSettingsForm = ({ form }: BrandingSettingsFormProps) => {
</SimpleGrid> </SimpleGrid>
</Fieldset> </Fieldset>
<Fieldset legend={t("groups.customCss")} p="sm">
<CustomCssEditor
id="global-custom-css"
label={t("customCss.label")}
description={t("customCss.description")}
actions={
<WorkshopCssImportButton
size="compact-xs"
onImport={(css) => form.setFieldValue("branding.customCss", css)}
/>
}
{...form.getInputProps("branding.customCss")}
/>
</Fieldset>
<Fieldset legend={t("groups.authentication")} p="sm"> <Fieldset legend={t("groups.authentication")} p="sm">
<Stack gap="sm"> <Stack gap="sm">
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm" verticalSpacing="sm"> <SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm" verticalSpacing="sm">
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Button, Stack } from "@mantine/core"; import { Button, Stack } from "@mantine/core";
import { z } from "zod/v4"; import { z } from "zod/v4";
@@ -70,6 +71,7 @@ interface SettingsFormProps {
export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEngines }: SettingsFormProps) => { export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEngines }: SettingsFormProps) => {
const tCommon = useI18n("common"); const tCommon = useI18n("common");
const tSettings = useI18n("management.page.settings"); const tSettings = useI18n("management.page.settings");
const router = useRouter();
const initialValues = buildInitialValues(initialData); const initialValues = buildInitialValues(initialData);
const initialValuesRef = useRef(initialValues); const initialValuesRef = useRef(initialValues);
@@ -164,6 +166,7 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn
form.setInitialValues(values); form.setInitialValues(values);
form.resetDirty(); form.resetDirty();
await revalidatePathActionAsync("/manage/settings"); await revalidatePathActionAsync("/manage/settings");
router.refresh();
showSuccessNotification({ showSuccessNotification({
title: tCommon("notification.update.success"), title: tCommon("notification.update.success"),
message: tSettings("notification.success.message"), message: tSettings("notification.success.message"),
@@ -0,0 +1,38 @@
"use client";
import { CodeEditor } from "~/components/custom-widgets/code-editor";
interface CustomCssEditorProps {
id: string;
label: string;
description: string;
value?: string;
onChange: (value: string) => void;
error?: React.ReactNode;
actions?: React.ReactNode;
}
export const CustomCssEditor = ({
id,
label,
description,
value = "",
onChange,
error,
actions,
}: CustomCssEditorProps) => {
return (
<CodeEditor
id={id}
label={label}
description={description}
value={value}
onChange={onChange}
error={error}
language="css"
height="250px"
maxLength={16_384}
actions={actions}
/>
);
};
@@ -0,0 +1,50 @@
"use client";
import { useMemo } from "react";
import type { ComponentProps } from "react";
import { CustomWidgetCodeEditor } from "@homarr/custom-widgets/workbench";
import { invariantTechnicalLabels } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
type SharedEditorProps = ComponentProps<typeof CustomWidgetCodeEditor>;
type CodeEditorProps = Omit<SharedEditorProps, "messages">;
export function CodeEditor(props: CodeEditorProps) {
const t = useI18n("customWidget.editor");
const tCommon = useI18n("common");
const messages = useMemo<SharedEditorProps["messages"]>(
() => ({
languageJsx: invariantTechnicalLabels.jsx,
languageJson: invariantTechnicalLabels.json,
undo: tCommon("action.undo"),
redo: t("action.redo"),
components: t("action.components"),
componentSearch: t("componentReference.search"),
componentEmpty: t("componentReference.empty"),
componentCount: (count) => t("componentReference.count", { count }),
insertStarter: t("action.insertStarter"),
format: t("action.format"),
copy: tCommon("action.copy"),
copied: t("action.copied"),
schema: t("action.schema"),
schemaTab: invariantTechnicalLabels.jsonSchema,
minimalTab: t("reference.minimal"),
fullTab: t("reference.full"),
errors: (count) => t("status.errors", { count }),
warnings: (count) => t("status.warnings", { count }),
ready: t("status.ready"),
position: (cursor) => t("status.position", cursor),
characters: (count, limit) =>
limit ? t("status.charactersWithLimit", { count, limit }) : t("status.characters", { count }),
diagnosticsTitle: t("diagnostics.title"),
diagnostic: (diagnostic) =>
`${diagnostic.line ? `${t("diagnostics.line", { line: diagnostic.line })}: ` : ""}${t(
`diagnostics.${diagnostic.code}` as never,
{ value: diagnostic.value ?? "" } as never,
)}`,
}),
[t, tCommon],
);
return <CustomWidgetCodeEditor {...props} messages={messages} />;
}
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import type { ButtonProps } from "@mantine/core";
import { ActionIcon, Button, Group, Loader, Modal, Pagination, Stack, Text, TextInput, Tooltip } from "@mantine/core"; import { ActionIcon, Button, Group, Loader, Modal, Pagination, Stack, Text, TextInput, Tooltip } from "@mantine/core";
import { useDebouncedValue, useDisclosure } from "@mantine/hooks"; import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
import { IconDownload, IconExternalLink, IconSearch } from "@tabler/icons-react"; import { IconDownload, IconExternalLink, IconSearch } from "@tabler/icons-react";
@@ -21,15 +22,27 @@ import { WorkshopVoteControl } from "./workshop-vote-control";
/** /**
* Picks a community CSS theme in a single surface: browse, confirm, done. * Picks a community CSS theme in a single surface: browse, confirm, done.
* The imported rules land in the editor below and are only persisted when the * The imported rules land in the editor below and are only persisted when the
* board settings themselves are saved, so there is nothing to preview twice. * settings themselves are saved, so there is nothing to preview twice.
*/ */
export function WorkshopCssImportButton({ onImport }: { onImport(css: string): void }) { export function WorkshopCssImportButton({
onImport,
size,
}: {
onImport(css: string): void;
size?: ButtonProps["size"];
}) {
const t = useI18n("workshop"); const t = useI18n("workshop");
const [opened, controls] = useDisclosure(false); const [opened, controls] = useDisclosure(false);
return ( return (
<> <>
<Button type="button" variant="light" leftSection={<IconDownload size={16} />} onClick={controls.open}> <Button
type="button"
size={size}
variant="light"
leftSection={<IconDownload size={16} />}
onClick={controls.open}
>
{t("importCss")} {t("importCss")}
</Button> </Button>
<Modal opened={opened} onClose={controls.close} title={t("importCss")} size={modalSizeSelect} radius="md"> <Modal opened={opened} onClose={controls.close} title={t("importCss")} size={modalSizeSelect} radius="md">
-225
View File
@@ -1,225 +0,0 @@
[data-mantine-color-scheme="light"] {
code[class*="language-"],
pre[class*="language-"] {
color: #000;
background: 0 0;
text-shadow: 0 1px #fff;
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
font-size: 1em;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
code[class*="language-"] ::-moz-selection,
code[class*="language-"]::-moz-selection,
pre[class*="language-"] ::-moz-selection,
pre[class*="language-"]::-moz-selection {
text-shadow: none;
background: #b3d4fc;
}
code[class*="language-"] ::selection,
code[class*="language-"]::selection,
pre[class*="language-"] ::selection,
pre[class*="language-"]::selection {
text-shadow: none;
background: #b3d4fc;
}
@media print {
code[class*="language-"],
pre[class*="language-"] {
text-shadow: none;
}
}
pre[class*="language-"] {
padding: 1em;
margin: 0.5em 0;
overflow: auto;
}
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
background: #f5f2f0;
}
:not(pre) > code[class*="language-"] {
padding: 0.1em;
border-radius: 0.3em;
white-space: normal;
}
.token.cdata,
.token.comment,
.token.doctype,
.token.prolog {
color: #708090;
}
.token.punctuation {
color: #999;
}
.token.namespace {
opacity: 0.7;
}
.token.boolean,
.token.constant,
.token.deleted,
.token.number,
.token.property,
.token.symbol,
.token.tag {
color: #905;
}
.token.attr-name,
.token.builtin,
.token.char,
.token.inserted,
.token.selector,
.token.string {
color: #690;
}
.language-css .token.string,
.style .token.string,
.token.entity,
.token.operator,
.token.url {
color: #9a6e3a;
background: hsla(0, 0%, 100%, 0.5);
}
.token.atrule,
.token.attr-value,
.token.keyword {
color: #07a;
}
.token.class-name,
.token.function {
color: #dd4a68;
}
.token.important,
.token.regex,
.token.variable {
color: #e90;
}
.token.bold,
.token.important {
font-weight: 700;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}
}
[data-mantine-color-scheme="dark"] {
code[class*="language-"],
pre[class*="language-"] {
color: #fff;
background: 0 0;
text-shadow: 0 -0.1em 0.2em #000;
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
font-size: 1em;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
@media print {
code[class*="language-"],
pre[class*="language-"] {
text-shadow: none;
}
}
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
background: #4c3f33;
}
pre[class*="language-"] {
padding: 1em;
margin: 0.5em 0;
overflow: auto;
border: 0.3em solid #7a6651;
border-radius: 0.5em;
box-shadow: 1px 1px 0.5em #000 inset;
}
:not(pre) > code[class*="language-"] {
padding: 0.15em 0.2em 0.05em;
border-radius: 0.3em;
border: 0.13em solid #7a6651;
box-shadow: 1px 1px 0.3em -0.1em #000 inset;
white-space: normal;
}
.token.cdata,
.token.comment,
.token.doctype,
.token.prolog {
color: #997f66;
}
.token.punctuation {
opacity: 0.7;
}
.token.namespace {
opacity: 0.7;
}
.token.boolean,
.token.constant,
.token.number,
.token.property,
.token.symbol,
.token.tag {
color: #d1939e;
}
.token.attr-name,
.token.builtin,
.token.char,
.token.inserted,
.token.selector,
.token.string {
color: #bce051;
}
.language-css .token.string,
.style .token.string,
.token.entity,
.token.operator,
.token.url,
.token.variable {
color: #f4b73d;
}
.token.atrule,
.token.attr-value,
.token.keyword {
color: #d1939e;
}
.token.important,
.token.regex {
color: #e90;
}
.token.bold,
.token.important {
font-weight: 700;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}
.token.deleted {
color: red;
}
}
@@ -54,6 +54,7 @@ export function CodeEditorToolbar({
</Text> </Text>
</Group> </Group>
<Group gap={6}> <Group gap={6}>
{props.actions}
{!props.readOnly && ( {!props.readOnly && (
<> <>
<Button <Button
@@ -57,4 +57,5 @@ export interface CustomWidgetCodeEditorProps {
readOnly?: boolean; readOnly?: boolean;
reference?: CustomWidgetSchemaReferenceData; reference?: CustomWidgetSchemaReferenceData;
height?: string; height?: string;
actions?: ReactNode;
} }
+2
View File
@@ -51,6 +51,7 @@ export const brandingServerSettingsSchema = z.object({
signInBackgroundOverlay: z.number().min(0).max(0.9), signInBackgroundOverlay: z.number().min(0).max(0.9),
authBranding: authBrandingSchema, authBranding: authBrandingSchema,
defaultRadius: z.enum(brandingRadiusOptions), defaultRadius: z.enum(brandingRadiusOptions),
customCss: z.string().max(16_384),
}); });
export type BrandingSettings = z.infer<typeof brandingServerSettingsSchema>; export type BrandingSettings = z.infer<typeof brandingServerSettingsSchema>;
@@ -71,6 +72,7 @@ export const defaultBrandingSettings: BrandingSettings = {
showGreeting: true, showGreeting: true,
}, },
defaultRadius: "md", defaultRadius: "md",
customCss: "",
}; };
const hasDefaultBrandingColors = (branding: Pick<BrandingSettings, "primaryColor" | "secondaryColor">) => const hasDefaultBrandingColors = (branding: Pick<BrandingSettings, "primaryColor" | "secondaryColor">) =>
+5
View File
@@ -5972,6 +5972,7 @@
"groups": { "groups": {
"identity": "Identity", "identity": "Identity",
"appearance": "Appearance", "appearance": "Appearance",
"customCss": "Custom CSS",
"authentication": "Authentication" "authentication": "Authentication"
}, },
"identity": { "identity": {
@@ -6018,6 +6019,10 @@
"xl": "Soft" "xl": "Soft"
} }
}, },
"customCss": {
"label": "Global custom CSS",
"description": "Applied to every page. Board custom CSS can override it."
},
"authVisibility": { "authVisibility": {
"description": "Choose what appears on every authentication page.", "description": "Choose what appears on every authentication page.",
"appName": "Application name", "appName": "Application name",
@@ -236,23 +236,26 @@ const SystemCharts = ({
const labelDisplayMode = isAdvanced ? "textWithIcon" : options.labelDisplayMode; const labelDisplayMode = isAdvanced ? "textWithIcon" : options.labelDisplayMode;
const chartCount = visibleCharts.length; const chartCount = visibleCharts.length;
const chartColumns = useMemo(() => (isAdvanced ? getAdvancedChartColumns(width) : 1), [isAdvanced, width]); const chartColumns = useMemo(() => (isAdvanced ? getAdvancedChartColumns(width) : 1), [isAdvanced, width]);
const compactChartGap = 8; const chartGap = isAdvanced ? "xs" : 8;
const compactRowHeight = 56;
const chartHeight = isAdvanced
? 110
: Math.max(
28,
Math.min(compactRowHeight, (height - Math.max(0, chartCount - 1) * compactChartGap) / Math.max(1, chartCount)),
);
return ( return (
<Stack gap={isAdvanced ? "xs" : compactChartGap} h="auto" miw={0}> // style={{height}}, not h={height} or h={`${height}px`} - Mantine's h prop runs numbers
// (and even pixel *strings*, since its rem() converter strips a "px" suffix before checking
// whether the remainder is a parseable number) through rem() * var(--mantine-scale), which
// silently rescales the value again on top of the board's own zoom. Only calc()/percentage/
// non-numeric strings skip that path (why h="100%" below is safe) - a literal style.height
// is the only way to set an exact pixel value that isn't run through it.
<Stack gap={chartGap} style={{ height: `${height}px` }} miw={0}>
{showTitle && ( {showTitle && (
<Text size="sm" fw={600} truncate="end"> <Text size="sm" fw={600} truncate="end">
{integrationName} {integrationName}
</Text> </Text>
)} )}
<SimpleGrid cols={chartColumns} spacing={isAdvanced ? "xs" : compactChartGap}> {/* Divide the available height evenly across however many rows chartColumns wraps the
charts into via CSS grid-auto-rows: 1fr, rather than computing a per-chart pixel height
in JS - keeps every chart's own sizing untouched (still h="100%" of its grid cell) and
needs no board-zoom-aware math of its own. */}
<SimpleGrid cols={chartColumns} spacing={chartGap} style={{ flex: 1, minHeight: 0, gridAutoRows: "1fr" }}>
{chartCount === 0 && ( {chartCount === 0 && (
<Center h="100%"> <Center h="100%">
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
@@ -261,7 +264,7 @@ const SystemCharts = ({
</Center> </Center>
)} )}
{visibleCharts.includes("cpu") && ( {visibleCharts.includes("cpu") && (
<Box h={chartHeight}> <Box h="100%">
<SystemResourceCPUChart <SystemResourceCPUChart
cpuUsageOverTime={items.map((item) => item.cpu)} cpuUsageOverTime={items.map((item) => item.cpu)}
hasShadow={options.hasShadow} hasShadow={options.hasShadow}
@@ -271,7 +274,7 @@ const SystemCharts = ({
</Box> </Box>
)} )}
{visibleCharts.includes("memory") && ( {visibleCharts.includes("memory") && (
<Box h={chartHeight}> <Box h="100%">
<SystemResourceMemoryChart <SystemResourceMemoryChart
memoryUsageOverTime={items.map((item) => item.memory)} memoryUsageOverTime={items.map((item) => item.memory)}
totalCapacityInBytes={memoryCapacityInBytes} totalCapacityInBytes={memoryCapacityInBytes}
@@ -282,7 +285,7 @@ const SystemCharts = ({
</Box> </Box>
)} )}
{visibleCharts.includes("gpu") && ( {visibleCharts.includes("gpu") && (
<Box h={chartHeight}> <Box h="100%">
<SystemResourceGPUChart <SystemResourceGPUChart
gpuUsageOverTime={items.map((item) => item.gpu)} gpuUsageOverTime={items.map((item) => item.gpu)}
hasShadow={options.hasShadow} hasShadow={options.hasShadow}
@@ -293,7 +296,7 @@ const SystemCharts = ({
)} )}
{showNetwork && {showNetwork &&
(width >= 300 ? ( (width >= 300 ? (
<Group h={chartHeight} gap="xs" grow wrap="nowrap"> <Group h="100%" gap="xs" grow wrap="nowrap">
<NetworkTrafficChart <NetworkTrafficChart
usageOverTime={networkItems.map((network) => network.down)} usageOverTime={networkItems.map((network) => network.down)}
isUp={false} isUp={false}
@@ -310,7 +313,7 @@ const SystemCharts = ({
/> />
</Group> </Group>
) : ( ) : (
<Box h={chartHeight}> <Box h="100%">
<CombinedNetworkTrafficChart <CombinedNetworkTrafficChart
usageOverTime={networkItems} usageOverTime={networkItems}
hasShadow={options.hasShadow} hasShadow={options.hasShadow}
-9
View File
@@ -1238,9 +1238,6 @@ importers:
posthog-js: posthog-js:
specifier: 'catalog:' specifier: 'catalog:'
version: 1.399.2(preact-render-to-string@6.5.11) version: 1.399.2(preact-render-to-string@6.5.11)
prismjs:
specifier: 'catalog:'
version: 1.30.0
react: react:
specifier: 'catalog:' specifier: 'catalog:'
version: 19.2.7 version: 19.2.7
@@ -1253,9 +1250,6 @@ importers:
react-shiki: react-shiki:
specifier: 'catalog:' specifier: 'catalog:'
version: 0.11.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(supports-color@10.2.2) version: 0.11.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(supports-color@10.2.2)
react-simple-code-editor:
specifier: 'catalog:'
version: 0.14.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
remark-breaks: remark-breaks:
specifier: 'catalog:' specifier: 'catalog:'
version: 4.0.0 version: 4.0.0
@@ -1299,9 +1293,6 @@ importers:
'@types/node': '@types/node':
specifier: 'catalog:' specifier: 'catalog:'
version: 24.13.3 version: 24.13.3
'@types/prismjs':
specifier: 'catalog:'
version: 1.26.6
'@types/react': '@types/react':
specifier: 'catalog:' specifier: 'catalog:'
version: 19.2.17 version: 19.2.17