feat: add server settings (#487)

* feat: add server settings

* feat: remove old migration

* feat: add new migrations

* refactor: format

* fix: build error

* refactor: format

* fix: lint
This commit is contained in:
Manuel
2024-05-19 22:29:15 +02:00
committed by GitHub
parent d9f5158662
commit 919161798e
25 changed files with 2888 additions and 1 deletions

View File

@@ -33,6 +33,7 @@
"@mantine/hooks": "^7.9.2",
"@mantine/modals": "^7.9.2",
"@mantine/tiptap": "^7.9.2",
"@homarr/server-settings": "workspace:^0.1.0",
"@t3-oss/env-nextjs": "^0.10.1",
"@tanstack/react-query": "^5.37.1",
"@tanstack/react-query-devtools": "^5.37.1",

View File

@@ -14,6 +14,7 @@ import {
IconMailForward,
IconPlug,
IconQuestionMark,
IconSettings,
IconTool,
IconUser,
IconUsers,
@@ -87,6 +88,11 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
},
],
},
{
label: t("items.settings"),
href: "/manage/settings",
icon: IconSettings,
},
{
label: t("items.help.label"),
icon: IconQuestionMark,

View File

@@ -0,0 +1,140 @@
"use client";
import type { ReactNode } from "react";
import React from "react";
import type { MantineSpacing } from "@mantine/core";
import {
Card,
Group,
LoadingOverlay,
Stack,
Switch,
Text,
Title,
UnstyledButton,
} from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { UseFormReturnType } from "@homarr/form";
import { useForm } from "@homarr/form";
import type { defaultServerSettings } from "@homarr/server-settings";
import { useScopedI18n } from "@homarr/translation/client";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
interface AnalyticsSettingsProps {
initialData: typeof defaultServerSettings.analytics;
}
export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
const t = useScopedI18n("management.page.settings.section.analytics");
const form = useForm({
initialValues: initialData,
onValuesChange: (updatedValues, _) => {
if (!form.isValid()) {
return;
}
if (
!updatedValues.enableGeneral &&
(updatedValues.enableWidgetData ||
updatedValues.enableIntegrationData ||
updatedValues.enableUserData)
) {
updatedValues.enableIntegrationData = false;
updatedValues.enableUserData = false;
updatedValues.enableWidgetData = false;
}
void mutateAsync({
settingsKey: "analytics",
value: updatedValues,
});
},
});
const { mutateAsync, isPending } =
clientApi.serverSettings.saveSettings.useMutation({
onSettled: async () => {
await revalidatePathActionAsync("/manage/settings");
},
});
return (
<>
<Title order={2}>{t("title")}</Title>
<Card pos="relative" withBorder>
<LoadingOverlay
visible={isPending}
zIndex={1000}
overlayProps={{ radius: "sm", blur: 2 }}
/>
<Stack>
<SwitchSetting
form={form}
formKey="enableGeneral"
title={t("general.title")}
text={t("general.text")}
/>
<SwitchSetting
form={form}
formKey="enableIntegrationData"
ms="xl"
title={t("integrationData.title")}
text={t("integrationData.text")}
/>
<SwitchSetting
form={form}
formKey="enableWidgetData"
ms="xl"
title={t("widgetData.title")}
text={t("widgetData.text")}
/>
<SwitchSetting
form={form}
formKey="enableUserData"
ms="xl"
title={t("usersData.title")}
text={t("usersData.text")}
/>
</Stack>
</Card>
</>
);
};
const SwitchSetting = ({
form,
ms,
title,
text,
formKey,
}: {
form: UseFormReturnType<typeof defaultServerSettings.analytics>;
formKey: keyof typeof defaultServerSettings.analytics;
ms?: MantineSpacing;
title: string;
text: ReactNode;
}) => {
const handleClick = React.useCallback(() => {
form.setFieldValue(formKey, !form.values[formKey]);
}, [form, formKey]);
return (
<UnstyledButton onClick={handleClick}>
<Group
ms={ms}
justify="space-between"
gap="lg"
align="center"
wrap="nowrap"
>
<Stack gap={0}>
<Text fw="bold">{title}</Text>
<Text c="gray.5">{text}</Text>
</Stack>
<Switch {...form.getInputProps(formKey, { type: "checkbox" })} />
</Group>
</UnstyledButton>
);
};

View File

@@ -0,0 +1,26 @@
import { Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { getScopedI18n } from "@homarr/translation/server";
import { AnalyticsSettings } from "./_components/analytics.settings";
export async function generateMetadata() {
const t = await getScopedI18n("management");
const metaTitle = `${t("metaTitle")} • Homarr`;
return {
title: metaTitle,
};
}
export default async function SettingsPage() {
const serverSettings = await api.serverSettings.getAll();
const t = await getScopedI18n("management.page.settings");
return (
<Stack>
<Title order={1}>{t("title")}</Title>
<AnalyticsSettings initialData={serverSettings.analytics} />
</Stack>
);
}

View File

@@ -22,12 +22,14 @@
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/icons": "workspace:^0.1.0",
"@homarr/log": "workspace:^",
"@homarr/redis": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"dotenv": "^16.4.5",
"node-cron": "^3.0.3",
"@homarr/icons": "workspace:^0.1.0"
"superjson": "2.2.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -1,3 +1,6 @@
import { jobs } from "./jobs";
import { seedServerSettingsAsync } from "./seed-server-settings";
jobs.startAll();
void seedServerSettingsAsync();

View File

@@ -0,0 +1,33 @@
import SuperJSON from "superjson";
import { db } from "@homarr/db";
import { serverSettings } from "@homarr/db/schema/sqlite";
import { logger } from "@homarr/log";
import {
defaultServerSettings,
defaultServerSettingsKeys,
} from "../../../packages/server-settings";
export const seedServerSettingsAsync = async () => {
const serverSettingsData = await db.query.serverSettings.findMany();
let insertedSettingsCount = 0;
for (const settingsKey of defaultServerSettingsKeys) {
if (
serverSettingsData.some((setting) => setting.settingKey === settingsKey)
) {
return;
}
await db.insert(serverSettings).values({
settingKey: settingsKey,
value: SuperJSON.stringify(defaultServerSettings[settingsKey]),
});
insertedSettingsCount++;
}
if (insertedSettingsCount > 0) {
logger.info(`Inserted ${insertedSettingsCount} missing settings`);
}
};

View File

@@ -27,6 +27,7 @@
"@homarr/redis": "workspace:^0.1.0",
"@homarr/tasks": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@trpc/client": "next",
"@trpc/server": "next",
"superjson": "2.2.1"

View File

@@ -7,6 +7,7 @@ import { integrationRouter } from "./router/integration";
import { inviteRouter } from "./router/invite";
import { locationRouter } from "./router/location";
import { logRouter } from "./router/log";
import { serverSettingsRouter } from "./router/serverSettings";
import { userRouter } from "./router/user";
import { widgetRouter } from "./router/widgets";
import { createTRPCRouter } from "./trpc";
@@ -23,6 +24,7 @@ export const appRouter = createTRPCRouter({
log: logRouter,
icon: iconsRouter,
home: homeRouter,
serverSettings: serverSettingsRouter,
});
// export type definition of API

View File

@@ -0,0 +1,43 @@
import SuperJSON from "superjson";
import { eq } from "@homarr/db";
import { serverSettings } from "@homarr/db/schema/sqlite";
import type { ServerSettings } from "@homarr/server-settings";
import { defaultServerSettingsKeys } from "@homarr/server-settings";
import { z } from "@homarr/validation";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const serverSettingsRouter = createTRPCRouter({
getAll: protectedProcedure.query(async ({ ctx }) => {
const settings = await ctx.db.query.serverSettings.findMany();
const data = {} as ServerSettings;
defaultServerSettingsKeys.forEach((key) => {
const settingValue = settings.find(
(setting) => setting.settingKey === key,
)?.value;
if (!settingValue) {
return;
}
data[key] = SuperJSON.parse(settingValue);
});
return data;
}),
saveSettings: protectedProcedure
.input(
z.object({
settingsKey: z.enum(defaultServerSettingsKeys),
value: z.record(z.string(), z.unknown()),
}),
)
.mutation(async ({ ctx, input }) => {
const databaseRunResult = await ctx.db
.update(serverSettings)
.set({
value: SuperJSON.stringify(input.value),
})
.where(eq(serverSettings.settingKey, input.settingsKey));
return databaseRunResult.changes === 1;
}),
});

View File

@@ -0,0 +1,134 @@
import SuperJSON from "superjson";
import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/db";
import { serverSettings } from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test";
import {
defaultServerSettings,
defaultServerSettingsKeys,
} from "@homarr/server-settings";
import { serverSettingsRouter } from "../serverSettings";
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
const defaultSession = {
user: {
id: createId(),
permissions: [],
},
expires: new Date().toISOString(),
} satisfies Session;
describe("getAll server settings", () => {
test("getAll should throw error when unauthorized", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
session: null,
});
await db.insert(serverSettings).values([
{
settingKey: defaultServerSettingsKeys[0],
value: SuperJSON.stringify(defaultServerSettings.analytics),
},
]);
const actAsync = async () => await caller.getAll();
await expect(actAsync()).rejects.toThrow();
});
test("getAll should return server", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
session: defaultSession,
});
await db.insert(serverSettings).values([
{
settingKey: defaultServerSettingsKeys[0],
value: SuperJSON.stringify(defaultServerSettings.analytics),
},
]);
const result = await caller.getAll();
expect(result).toStrictEqual({
analytics: {
enableGeneral: true,
enableWidgetData: false,
enableIntegrationData: false,
enableUserData: false,
},
});
});
});
describe("saveSettings", () => {
test("saveSettings should return false when it did not update one", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
session: defaultSession,
});
const result = await caller.saveSettings({
settingsKey: "analytics",
value: {
enableGeneral: true,
enableWidgetData: true,
enableIntegrationData: true,
enableUserData: true,
},
});
expect(result).toBe(false);
const dbSettings = await db.select().from(serverSettings);
expect(dbSettings.length).toBe(0);
});
test("saveSettings should update settings and return true when it updated only one", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
session: defaultSession,
});
await db.insert(serverSettings).values([
{
settingKey: defaultServerSettingsKeys[0],
value: SuperJSON.stringify(defaultServerSettings.analytics),
},
]);
const result = await caller.saveSettings({
settingsKey: "analytics",
value: {
enableGeneral: true,
enableWidgetData: true,
enableIntegrationData: true,
enableUserData: true,
},
});
expect(result).toBe(true);
const dbSettings = await db.select().from(serverSettings);
expect(dbSettings).toStrictEqual([
{
settingKey: "analytics",
value: SuperJSON.stringify({
enableGeneral: true,
enableWidgetData: true,
enableIntegrationData: true,
enableUserData: true,
}),
},
]);
});
});

View File

@@ -0,0 +1,6 @@
CREATE TABLE `serverSetting` (
`key` varchar(64) NOT NULL,
`value` text NOT NULL DEFAULT ('{"json": {}}'),
CONSTRAINT `serverSetting_key` PRIMARY KEY(`key`),
CONSTRAINT `serverSetting_key_unique` UNIQUE(`key`)
);

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,13 @@
"when": 1715885855801,
"tag": "0001_wild_alex_wilder",
"breakpoints": true
},
{
"idx": 2,
"version": "5",
"when": 1716148439439,
"tag": "0002_freezing_black_panther",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,6 @@
CREATE TABLE `serverSetting` (
`key` text PRIMARY KEY NOT NULL,
`value` text DEFAULT '{"json": {}}' NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `serverSetting_key_unique` ON `serverSetting` (`key`);

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,13 @@
"when": 1715871797713,
"tag": "0001_mixed_titanium_man",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1716148434186,
"tag": "0002_adorable_raider",
"breakpoints": true
}
]
}

View File

@@ -311,6 +311,11 @@ export const iconRepositories = mysqlTable("iconRepository", {
slug: varchar("iconRepository_slug", { length: 150 }).notNull(),
});
export const serverSettings = mysqlTable("serverSetting", {
settingKey: varchar("key", { length: 64 }).notNull().unique().primaryKey(),
value: text("value").default('{"json": {}}').notNull(), // empty superjson object
});
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],

View File

@@ -304,6 +304,11 @@ export const iconRepositories = sqliteTable("iconRepository", {
slug: text("iconRepository_slug").notNull(),
});
export const serverSettings = sqliteTable("serverSetting", {
settingKey: text("key").notNull().unique().primaryKey(),
value: text("value").default('{"json": {}}').notNull(), // empty superjson object
});
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],

View File

@@ -0,0 +1 @@
export * from "./src";

View File

@@ -0,0 +1,36 @@
{
"name": "@homarr/server-settings",
"private": true,
"version": "0.1.0",
"exports": {
".": "./index.ts"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"type": "module",
"license": "MIT",
"scripts": {
"clean": "rm -rf .turbo node_modules",
"lint": "eslint .",
"format": "prettier --check . --ignore-path ../../.gitignore",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^8.57.0",
"typescript": "^5.4.5"
},
"eslintConfig": {
"extends": [
"@homarr/eslint-config/base"
]
},
"prettier": "@homarr/prettier-config"
}

View File

@@ -0,0 +1,16 @@
export const defaultServerSettingsKeys = ["analytics"] as const;
export type ServerSettingsRecord = {
[key in (typeof defaultServerSettingsKeys)[number]]: Record<string, unknown>;
};
export const defaultServerSettings = {
analytics: {
enableGeneral: true,
enableWidgetData: false,
enableIntegrationData: false,
enableUserData: false,
},
} satisfies ServerSettingsRecord;
export type ServerSettings = typeof defaultServerSettings;

View File

@@ -0,0 +1,8 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}

View File

@@ -1114,6 +1114,7 @@ export default {
logs: "Logs",
},
},
settings: "Settings",
help: {
label: "Help",
items: {
@@ -1283,6 +1284,30 @@ export default {
},
},
},
settings: {
title: "Settings",
section: {
analytics: {
title: "Analytics",
general: {
title: "Send anonymous analytics",
text: "Homarr will send anonymized analytics using the open source software Umami. It never collects any personal information and is therefore fully GDPR & CCPA compliant. We encourage you to enable analytics because it helps our open source team to identify issues and prioritize our backlog.",
},
widgetData: {
title: "Widget data",
text: "Send which widgets (and their quantity) you have configured. Does not include URLs, names or any other data.",
},
integrationData: {
title: "Integration data",
text: "Send which integrations (and their quantity) you have configured. Does not include URLs, names or any other data.",
},
usersData: {
title: "Users data",
text: "Send the amount of users and whether you've activated SSO",
},
},
},
},
about: {
version: "Version {version}",
text: "Homarr is a community driven open source project that is being maintained by volunteers. Thanks to these people, Homarr has been a growing project since 2021. Our team is working completely remote from many different countries on Homarr in their leisure time for no compensation.",

30
pnpm-lock.yaml generated
View File

@@ -90,6 +90,9 @@ importers:
'@homarr/notifications':
specifier: workspace:^0.1.0
version: link:../../packages/notifications
'@homarr/server-settings':
specifier: workspace:^0.1.0
version: link:../../packages/server-settings
'@homarr/spotlight':
specifier: workspace:^0.1.0
version: link:../../packages/spotlight
@@ -247,6 +250,9 @@ importers:
'@homarr/redis':
specifier: workspace:^0.1.0
version: link:../../packages/redis
'@homarr/server-settings':
specifier: workspace:^0.1.0
version: link:../../packages/server-settings
'@homarr/validation':
specifier: workspace:^0.1.0
version: link:../../packages/validation
@@ -256,6 +262,9 @@ importers:
node-cron:
specifier: ^3.0.3
version: 3.0.3
superjson:
specifier: 2.2.1
version: 2.2.1
devDependencies:
'@homarr/eslint-config':
specifier: workspace:^0.2.0
@@ -363,6 +372,9 @@ importers:
'@homarr/redis':
specifier: workspace:^0.1.0
version: link:../redis
'@homarr/server-settings':
specifier: workspace:^0.1.0
version: link:../server-settings
'@homarr/tasks':
specifier: workspace:^0.1.0
version: link:../../apps/tasks
@@ -713,6 +725,24 @@ importers:
specifier: ^5.4.5
version: 5.4.5
packages/server-settings:
devDependencies:
'@homarr/eslint-config':
specifier: workspace:^0.2.0
version: link:../../tooling/eslint
'@homarr/prettier-config':
specifier: workspace:^0.1.0
version: link:../../tooling/prettier
'@homarr/tsconfig':
specifier: workspace:^0.1.0
version: link:../../tooling/typescript
eslint:
specifier: ^8.57.0
version: 8.57.0
typescript:
specifier: ^5.4.5
version: 5.4.5
packages/spotlight:
dependencies:
'@homarr/translation':