chore(release): automatic release v0.1.0

This commit is contained in:
homarr-releases[bot]
2024-09-27 19:13:34 +00:00
committed by GitHub
63 changed files with 1420 additions and 938 deletions

View File

@@ -38,7 +38,7 @@ jobs:
node-version: [20] node-version: [20]
steps: steps:
- name: Discord notification - name: Discord notification
if: ${{ github.events.inputs.send-notifications || true }} if: ${{ github.events.inputs.send-notifications != false }}
env: env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master uses: Ilshidur/action-discord@master
@@ -52,7 +52,7 @@ jobs:
token: ${{ github.token }} token: ${{ github.token }}
branch: dev branch: dev
- name: Discord notification - name: Discord notification
if: ${{ github.events.inputs.send-notifications || true }} if: ${{ github.events.inputs.send-notifications != false }}
env: env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master uses: Ilshidur/action-discord@master
@@ -66,7 +66,7 @@ jobs:
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
- name: Discord notification - name: Discord notification
if: ${{ github.events.inputs.send-notifications || true }} if: ${{ github.events.inputs.send-notifications != false }}
env: env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master uses: Ilshidur/action-discord@master
@@ -120,14 +120,14 @@ jobs:
env: env:
SKIP_ENV_VALIDATION: true SKIP_ENV_VALIDATION: true
- name: Discord notification - name: Discord notification
if: ${{ github.events.inputs.send-notifications || true && (github.events.inputs.push-image == 'true' || github.events.inputs.push-image == null) }} if: ${{ github.events.inputs.send-notifications != false && (github.events.inputs.push-image == 'true' || github.events.inputs.push-image == null) }}
env: env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master uses: Ilshidur/action-discord@master
with: with:
args: "Deployment of image has completed. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'." args: "Deployment of image has completed. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'."
- name: Discord notification - name: Discord notification
if: ${{ github.events.inputs.send-notifications || true && !(github.events.inputs.push-image == 'true' || github.events.inputs.push-image == null) }} if: ${{ github.events.inputs.send-notifications != false && !(github.events.inputs.push-image == 'true' || github.events.inputs.push-image == null) }}
env: env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master uses: Ilshidur/action-discord@master

View File

@@ -67,7 +67,7 @@ jobs:
- id: automerge - id: automerge
if: ${{ steps.semver.outputs.bump != 'major' }} if: ${{ steps.semver.outputs.bump != 'major' }}
name: automerge name: automerge
uses: "pascalgn/automerge-action@v0.16.3" uses: "pascalgn/automerge-action@v0.16.4"
env: env:
GITHUB_TOKEN: ${{ steps.obtainMergeToken.outputs.token }} GITHUB_TOKEN: ${{ steps.obtainMergeToken.outputs.token }}
MERGE_METHOD: merge # we prefer merge commits for merging to master MERGE_METHOD: merge # we prefer merge commits for merging to master

View File

@@ -1,4 +1,4 @@
FROM --platform=linux/amd64 node:20.17.0-alpine AS base FROM node:20.17.0-alpine AS base
FROM base AS builder FROM base AS builder
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat

View File

@@ -36,16 +36,16 @@
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0", "@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^7.12.2", "@mantine/colors-generator": "^7.13.0",
"@mantine/core": "^7.12.2", "@mantine/core": "^7.13.0",
"@mantine/hooks": "^7.12.2", "@mantine/hooks": "^7.13.0",
"@mantine/modals": "^7.12.2", "@mantine/modals": "^7.13.0",
"@mantine/tiptap": "^7.12.2", "@mantine/tiptap": "^7.13.0",
"@million/lint": "1.0.0-rc.84", "@million/lint": "1.0.0-rc.84",
"@t3-oss/env-nextjs": "^0.11.1", "@t3-oss/env-nextjs": "^0.11.1",
"@tabler/icons-react": "^3.17.0", "@tabler/icons-react": "^3.18.0",
"@tanstack/react-query": "^5.56.2", "@tanstack/react-query": "^5.56.2",
"@tanstack/react-query-devtools": "^5.56.2", "@tanstack/react-query-devtools": "^5.58.0",
"@tanstack/react-query-next-experimental": "5.56.2", "@tanstack/react-query-next-experimental": "5.56.2",
"@trpc/client": "next", "@trpc/client": "next",
"@trpc/next": "next", "@trpc/next": "next",
@@ -79,13 +79,13 @@
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "2.4.4", "@types/chroma-js": "2.4.4",
"@types/node": "^20.16.5", "@types/node": "^20.16.10",
"@types/prismjs": "^1.26.4", "@types/prismjs": "^1.26.4",
"@types/react": "^18.3.8", "@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@types/swagger-ui-react": "^4.18.3", "@types/swagger-ui-react": "^4.18.3",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"eslint": "^9.11.0", "eslint": "^9.11.1",
"node-loader": "^2.0.0", "node-loader": "^2.0.0",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"typescript": "^5.6.2" "typescript": "^5.6.2"

View File

@@ -1,12 +1,14 @@
"use client"; "use client";
import { useState } from "react";
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { useState } from "react";
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core"; import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
import { createTheme, isMantineColorScheme, MantineProvider } from "@mantine/core"; import { createTheme, isMantineColorScheme, MantineProvider } from "@mantine/core";
import dayjs from "dayjs";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { parseCookies, setClientCookie } from "@homarr/common";
export const CustomMantineProvider = ({ children }: PropsWithChildren) => { export const CustomMantineProvider = ({ children }: PropsWithChildren) => {
const manager = useColorSchemeManager(); const manager = useColorSchemeManager();
@@ -50,7 +52,8 @@ function useColorSchemeManager(): MantineColorSchemeManager {
} }
try { try {
return (window.localStorage.getItem(key) as MantineColorScheme | undefined) ?? defaultValue; const cookies = parseCookies(document.cookie);
return (cookies[key] as MantineColorScheme | undefined) ?? defaultValue;
} catch { } catch {
return defaultValue; return defaultValue;
} }
@@ -61,6 +64,7 @@ function useColorSchemeManager(): MantineColorSchemeManager {
if (session) { if (session) {
mutateColorScheme({ colorScheme: value }); mutateColorScheme({ colorScheme: value });
} }
setClientCookie(key, value, { expires: dayjs().add(1, "year").toDate() });
window.localStorage.setItem(key, value); window.localStorage.setItem(key, value);
} catch (error) { } catch (error) {
console.warn("[@mantine/core] Local storage color scheme manager was unable to save color scheme.", error); console.warn("[@mantine/core] Local storage color scheme manager was unable to save color scheme.", error);

View File

@@ -6,6 +6,8 @@ import "@homarr/spotlight/styles.css";
import "@homarr/ui/styles.css"; import "@homarr/ui/styles.css";
import "~/styles/scroll-area.scss"; import "~/styles/scroll-area.scss";
import { cookies } from "next/headers";
import { env } from "@homarr/auth/env.mjs"; import { env } from "@homarr/auth/env.mjs";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { ModalProvider } from "@homarr/modals"; import { ModalProvider } from "@homarr/modals";
@@ -53,7 +55,7 @@ export const viewport: Viewport = {
export default async function Layout(props: { children: React.ReactNode; params: { locale: string } }) { export default async function Layout(props: { children: React.ReactNode; params: { locale: string } }) {
const session = await auth(); const session = await auth();
const colorScheme = session?.user.colorScheme; const colorScheme = cookies().get("homarr-color-scheme")?.value ?? "light";
const StackedProvider = composeWrappers([ const StackedProvider = composeWrappers([
(innerProps) => { (innerProps) => {

View File

@@ -44,9 +44,9 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "^20.16.5", "@types/node": "^20.16.10",
"dotenv-cli": "^7.4.2", "dotenv-cli": "^7.4.2",
"eslint": "^9.11.0", "eslint": "^9.11.1",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"tsx": "4.13.3", "tsx": "4.13.3",
"typescript": "^5.6.2" "typescript": "^5.6.2"

View File

@@ -34,7 +34,7 @@
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/ws": "^8.5.12", "@types/ws": "^8.5.12",
"eslint": "^9.11.0", "eslint": "^9.11.1",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"typescript": "^5.6.2" "typescript": "^5.6.2"
} }

View File

@@ -32,7 +32,7 @@
"@vitest/coverage-v8": "^2.1.1", "@vitest/coverage-v8": "^2.1.1",
"@vitest/ui": "^2.1.1", "@vitest/ui": "^2.1.1",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"jsdom": "^25.0.0", "jsdom": "^25.0.1",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"testcontainers": "^10.13.1", "testcontainers": "^10.13.1",
"turbo": "^2.1.2", "turbo": "^2.1.2",

View File

@@ -32,7 +32,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.0", "eslint": "^9.11.1",
"typescript": "^5.6.2" "typescript": "^5.6.2"
} }
} }

View File

@@ -49,7 +49,7 @@
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/dockerode": "^3.3.31", "@types/dockerode": "^3.3.31",
"eslint": "^9.11.0", "eslint": "^9.11.1",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"typescript": "^5.6.2" "typescript": "^5.6.2"
} }

View File

@@ -1,44 +1,68 @@
import { TRPCError } from "@trpc/server"; import { observable } from "@trpc/server/observable";
import type { Modify } from "@homarr/common/types";
import type { Integration } from "@homarr/db/schema/sqlite";
import type { IntegrationKindByCategory, WidgetKind } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { integrationCreator } from "@homarr/integrations"; import { integrationCreator } from "@homarr/integrations";
import type { DnsHoleSummary } from "@homarr/integrations/types"; import type { DnsHoleSummary } from "@homarr/integrations/types";
import { logger } from "@homarr/log"; import { controlsInputSchema } from "@homarr/integrations/types";
import { createCacheChannel } from "@homarr/redis"; import { createItemAndIntegrationChannel } from "@homarr/redis";
import { z } from "@homarr/validation";
import { controlsInputSchema } from "../../../../integrations/src/pi-hole/pi-hole-types";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
export const dnsHoleRouter = createTRPCRouter({ export const dnsHoleRouter = createTRPCRouter({
summary: publicProcedure summary: publicProcedure
.input(z.object({ widgetKind: z.enum(["dnsHoleSummary", "dnsHoleControls"]) }))
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole"))) .unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
.query(async ({ ctx }) => { .query(async ({ input: { widgetKind }, ctx }) => {
const results = await Promise.all( const results = await Promise.all(
ctx.integrations.map(async (integration) => { ctx.integrations.map(async ({ decryptedSecrets: _, ...integration }) => {
const cache = createCacheChannel<DnsHoleSummary>(`dns-hole-summary:${integration.id}`); const channel = createItemAndIntegrationChannel<DnsHoleSummary>(widgetKind, integration.id);
const { data } = await cache.consumeAsync(async () => { const { data: summary, timestamp } = (await channel.getAsync()) ?? { data: null, timestamp: new Date(0) };
const client = integrationCreator(integration);
return await client.getSummaryAsync().catch((err) => {
logger.error("dns-hole router - ", err);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Failed to fetch DNS Hole summary for ${integration.name} (${integration.id})`,
});
});
});
return { return {
integrationId: integration.id, integration,
integrationKind: integration.kind, timestamp,
summary: data, summary,
}; };
}), }),
); );
return results; return results;
}), }),
subscribeToSummary: publicProcedure
.input(z.object({ widgetKind: z.enum(["dnsHoleSummary", "dnsHoleControls"]) }))
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
.subscription(({ input: { widgetKind }, ctx }) => {
return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"dnsHole"> }>;
timestamp: Date;
summary: DnsHoleSummary;
}>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const channel = createItemAndIntegrationChannel<DnsHoleSummary>(widgetKind as WidgetKind, integration.id);
const unsubscribe = channel.subscribe((summary) => {
emit.next({
integration,
timestamp: new Date(),
summary,
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
enable: publicProcedure enable: publicProcedure
.unstable_concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole"))) .unstable_concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole")))
.mutation(async ({ ctx: { integration } }) => { .mutation(async ({ ctx: { integration } }) => {

View File

@@ -1,6 +1,8 @@
import { observable } from "@trpc/server/observable"; import { observable } from "@trpc/server/observable";
import type { Modify } from "@homarr/common/types";
import type { Integration } from "@homarr/db/schema/sqlite"; import type { Integration } from "@homarr/db/schema/sqlite";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { DownloadClientJobsAndStatus } from "@homarr/integrations"; import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
import { downloadClientItemSchema, integrationCreator } from "@homarr/integrations"; import { downloadClientItemSchema, integrationCreator } from "@homarr/integrations";
@@ -33,7 +35,11 @@ export const downloadsRouter = createTRPCRouter({
subscribeToJobsAndStatuses: publicProcedure subscribeToJobsAndStatuses: publicProcedure
.unstable_concat(createDownloadClientIntegrationMiddleware("query")) .unstable_concat(createDownloadClientIntegrationMiddleware("query"))
.subscription(({ ctx }) => { .subscription(({ ctx }) => {
return observable<{ integration: Integration; timestamp: Date; data: DownloadClientJobsAndStatus }>((emit) => { return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"downloadClient"> }>;
timestamp: Date;
data: DownloadClientJobsAndStatus;
}>((emit) => {
const unsubscribes: (() => void)[] = []; const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) { for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets; const { decryptedSecrets: _, ...integration } = integrationWithSecrets;

View File

@@ -1,5 +1,6 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import type { Adapter } from "@auth/core/adapters"; import type { Adapter } from "@auth/core/adapters";
import dayjs from "dayjs";
import type { NextAuthConfig } from "next-auth"; import type { NextAuthConfig } from "next-auth";
import type { Database } from "@homarr/db"; import type { Database } from "@homarr/db";
@@ -52,12 +53,12 @@ export const createSessionCallback = (db: Database): NextAuthCallbackOf<"session
}; };
export const createSignInCallback = export const createSignInCallback =
(adapter: Adapter, isCredentialsRequest: boolean): NextAuthCallbackOf<"signIn"> => (adapter: Adapter, db: Database, isCredentialsRequest: boolean): NextAuthCallbackOf<"signIn"> =>
async ({ user }) => { async ({ user }) => {
if (!isCredentialsRequest) return true; if (!isCredentialsRequest) return true;
// https://github.com/nextauthjs/next-auth/issues/6106 // https://github.com/nextauthjs/next-auth/issues/6106
if (!adapter.createSession) { if (!adapter.createSession || !user.id) {
return false; return false;
} }
@@ -66,8 +67,7 @@ export const createSignInCallback =
await adapter.createSession({ await adapter.createSession({
sessionToken, sessionToken,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion userId: user.id,
userId: user.id!,
expires: sessionExpires, expires: sessionExpires,
}); });
@@ -79,6 +79,21 @@ export const createSignInCallback =
secure: true, secure: true,
}); });
const dbUser = await db.query.users.findFirst({
where: eq(users.id, user.id),
columns: {
colorScheme: true,
},
});
if (!dbUser) return false;
// We use a cookie as localStorage is not shared with server (otherwise flickering would occur)
cookies().set("homarr-color-scheme", dbUser.colorScheme, {
path: "/",
expires: dayjs().add(1, "year").toDate(),
});
return true; return true;
}; };

View File

@@ -38,7 +38,7 @@ export const createConfiguration = (isCredentialsRequest: boolean, headers: Read
]), ]),
callbacks: { callbacks: {
session: createSessionCallback(db), session: createSessionCallback(db),
signIn: createSignInCallback(adapter, isCredentialsRequest), signIn: createSignInCallback(adapter, db, isCredentialsRequest),
}, },
redirectProxyUrl: createRedirectUri(headers, "/api/auth"), redirectProxyUrl: createRedirectUri(headers, "/api/auth"),
secret: "secret-is-not-defined-yet", // TODO: This should be added later secret: "secret-is-not-defined-yet", // TODO: This should be added later

View File

@@ -23,8 +23,8 @@
}, },
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@auth/core": "^0.35.0", "@auth/core": "^0.35.2",
"@auth/drizzle-adapter": "^1.5.0", "@auth/drizzle-adapter": "^1.5.2",
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0",
@@ -45,7 +45,7 @@
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/bcrypt": "5.0.2", "@types/bcrypt": "5.0.2",
"@types/cookies": "0.9.0", "@types/cookies": "0.9.0",
"eslint": "^9.11.0", "eslint": "^9.11.1",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"typescript": "^5.6.2" "typescript": "^5.6.2"
} }

View File

@@ -5,7 +5,7 @@ import { cookies } from "next/headers";
import type { Adapter, AdapterUser } from "@auth/core/adapters"; import type { Adapter, AdapterUser } from "@auth/core/adapters";
import type { Account } from "next-auth"; import type { Account } from "next-auth";
import type { JWT } from "next-auth/jwt"; import type { JWT } from "next-auth/jwt";
import { describe, expect, it, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema/sqlite"; import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test"; import { createDb } from "@homarr/db/test";
@@ -15,6 +15,7 @@ import { createSessionCallback, createSignInCallback, getCurrentUserPermissionsA
describe("getCurrentUserPermissions", () => { describe("getCurrentUserPermissions", () => {
test("should return empty permissions when non existing user requested", async () => { test("should return empty permissions when non existing user requested", async () => {
// Arrange
const db = createDb(); const db = createDb();
await db.insert(groups).values({ await db.insert(groups).values({
@@ -30,11 +31,16 @@ describe("getCurrentUserPermissions", () => {
}); });
const userId = "1"; const userId = "1";
// Act
const result = await getCurrentUserPermissionsAsync(db, userId); const result = await getCurrentUserPermissionsAsync(db, userId);
// Assert
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
test("should return empty permissions when user has no groups", async () => { test("should return empty permissions when user has no groups", async () => {
// Arrange
const db = createDb(); const db = createDb();
const userId = "1"; const userId = "1";
@@ -50,11 +56,15 @@ describe("getCurrentUserPermissions", () => {
id: userId, id: userId,
}); });
// Act
const result = await getCurrentUserPermissionsAsync(db, userId); const result = await getCurrentUserPermissionsAsync(db, userId);
// Assert
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
test("should return permissions for user", async () => { test("should return permissions for user", async () => {
// Arrange
const db = createDb(); const db = createDb();
const getPermissionsWithChildrenMock = vi const getPermissionsWithChildrenMock = vi
.spyOn(definitions, "getPermissionsWithChildren") .spyOn(definitions, "getPermissionsWithChildren")
@@ -77,14 +87,18 @@ describe("getCurrentUserPermissions", () => {
permission: "admin", permission: "admin",
}); });
// Act
const result = await getCurrentUserPermissionsAsync(db, mockId); const result = await getCurrentUserPermissionsAsync(db, mockId);
// Assert
expect(result).toEqual(["board-create"]); expect(result).toEqual(["board-create"]);
expect(getPermissionsWithChildrenMock).toHaveBeenCalledWith(["admin"]); expect(getPermissionsWithChildrenMock).toHaveBeenCalledWith(["admin"]);
}); });
}); });
describe("session callback", () => { describe("session callback", () => {
it("should add id and name to session user", async () => { test("should add id and name to session user", async () => {
// Arrange
const user: AdapterUser = { const user: AdapterUser = {
id: "id", id: "id",
name: "name", name: "name",
@@ -94,6 +108,8 @@ describe("session callback", () => {
const token: JWT = {}; const token: JWT = {};
const db = createDb(); const db = createDb();
const callback = createSessionCallback(db); const callback = createSessionCallback(db);
// Act
const result = await callback({ const result = await callback({
session: { session: {
user: { user: {
@@ -112,6 +128,8 @@ describe("session callback", () => {
trigger: "update", trigger: "update",
newSession: {}, newSession: {},
}); });
// Assert
expect(result.user).toBeDefined(); expect(result.user).toBeDefined();
expect(result.user!.id).toEqual(user.id); expect(result.user!.id).toEqual(user.id);
expect(result.user!.name).toEqual(user.name); expect(result.user!.name).toEqual(user.name);
@@ -169,37 +187,56 @@ vi.mock("next/headers", async (importOriginal) => {
}); });
describe("createSignInCallback", () => { describe("createSignInCallback", () => {
it("should return true if not credentials request", async () => { test("should return true if not credentials request and set colorScheme & sessionToken cookie", async () => {
// Arrange
const isCredentialsRequest = false; const isCredentialsRequest = false;
const signInCallback = createSignInCallback(createAdapter(), isCredentialsRequest); const db = await prepareDbForSigninAsync("1");
const signInCallback = createSignInCallback(createAdapter(), db, isCredentialsRequest);
// Act
const result = await signInCallback({ const result = await signInCallback({
user: { id: "1", emailVerified: new Date("2023-01-13") }, user: { id: "1", emailVerified: new Date("2023-01-13") },
account: {} as Account, account: {} as Account,
}); });
// Assert
expect(result).toBe(true); expect(result).toBe(true);
}); });
it("should return false if no adapter.createSession", async () => { test("should return false if no adapter.createSession", async () => {
// Arrange
const isCredentialsRequest = true; const isCredentialsRequest = true;
const db = await prepareDbForSigninAsync("1");
const signInCallback = createSignInCallback( const signInCallback = createSignInCallback(
// https://github.com/nextauthjs/next-auth/issues/6106 // https://github.com/nextauthjs/next-auth/issues/6106
{ createSession: undefined } as unknown as Adapter, { createSession: undefined } as unknown as Adapter,
db,
isCredentialsRequest, isCredentialsRequest,
); );
// Act
const result = await signInCallback({ const result = await signInCallback({
user: { id: "1", emailVerified: new Date("2023-01-13") }, user: { id: "1", emailVerified: new Date("2023-01-13") },
account: {} as Account, account: {} as Account,
}); });
// Assert
expect(result).toBe(false); expect(result).toBe(false);
}); });
test("should call adapter.createSession with correct input", async () => { test("should call adapter.createSession with correct input", async () => {
// Arrange
const adapter = createAdapter(); const adapter = createAdapter();
const isCredentialsRequest = true; const isCredentialsRequest = true;
const signInCallback = createSignInCallback(adapter, isCredentialsRequest); const db = await prepareDbForSigninAsync("1");
const signInCallback = createSignInCallback(adapter, db, isCredentialsRequest);
const user = { id: "1", emailVerified: new Date("2023-01-13") }; const user = { id: "1", emailVerified: new Date("2023-01-13") };
const account = {} as Account; const account = {} as Account;
// Act
await signInCallback({ user, account }); await signInCallback({ user, account });
// Assert
expect(adapter.createSession).toHaveBeenCalledWith({ expect(adapter.createSession).toHaveBeenCalledWith({
sessionToken: mockSessionToken, sessionToken: mockSessionToken,
userId: user.id, userId: user.id,
@@ -213,4 +250,52 @@ describe("createSignInCallback", () => {
secure: true, secure: true,
}); });
}); });
test("should set colorScheme from db as cookie", async () => {
// Arrange
const isCredentialsRequest = false;
const db = await prepareDbForSigninAsync("1");
const signInCallback = createSignInCallback(createAdapter(), db, isCredentialsRequest);
// Act
const result = await signInCallback({
user: { id: "1", emailVerified: new Date("2023-01-13") },
account: {} as Account,
});
// Assert
expect(result).toBe(true);
expect(cookies().set).toHaveBeenCalledWith(
"homarr-color-scheme",
"dark",
expect.objectContaining({
path: "/",
}),
);
});
test("should return false if user not found in db", async () => {
// Arrange
const isCredentialsRequest = true;
const db = await prepareDbForSigninAsync("other-id");
const signInCallback = createSignInCallback(createAdapter(), db, isCredentialsRequest);
// Act
const result = await signInCallback({
user: { id: "1", emailVerified: new Date("2023-01-13") },
account: {} as Account,
});
// Assert
expect(result).toBe(false);
});
}); });
const prepareDbForSigninAsync = async (userId: string) => {
const db = createDb();
await db.insert(users).values({
id: userId,
colorScheme: "dark",
});
return db;
};

View File

@@ -33,7 +33,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.0", "eslint": "^9.11.1",
"typescript": "^5.6.2" "typescript": "^5.6.2"
} }
} }

View File

@@ -28,13 +28,13 @@
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"next": "^14.2.13", "next": "^14.2.13",
"react": "^18.3.1", "react": "^18.3.1",
"tldts": "^6.1.47" "tldts": "^6.1.48"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.0", "eslint": "^9.11.1",
"typescript": "^5.6.2" "typescript": "^5.6.2"
} }
} }

View File

@@ -1,3 +1,6 @@
import type { CookieSerializeOptions } from "cookie";
import { serialize } from "cookie";
export function parseCookies(cookieString: string) { export function parseCookies(cookieString: string) {
const list: Record<string, string> = {}; const list: Record<string, string> = {};
const cookieHeader = cookieString; const cookieHeader = cookieString;
@@ -15,3 +18,7 @@ export function parseCookies(cookieString: string) {
return list; return list;
} }
export function setClientCookie(name: string, value: string, options: CookieSerializeOptions = {}) {
document.cookie = serialize(name, value, options);
}

View File

@@ -30,7 +30,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.0", "eslint": "^9.11.1",
"typescript": "^5.6.2" "typescript": "^5.6.2"
} }
} }

View File

@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.0", "eslint": "^9.11.1",
"typescript": "^5.6.2" "typescript": "^5.6.2"
} }
} }

View File

@@ -32,7 +32,7 @@
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-cron": "^3.0.11", "@types/node-cron": "^3.0.11",
"eslint": "^9.11.0", "eslint": "^9.11.1",
"typescript": "^5.6.2" "typescript": "^5.6.2"
} }
} }

View File

@@ -41,7 +41,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.0", "eslint": "^9.11.1",
"typescript": "^5.6.2" "typescript": "^5.6.2"
} }
} }

View File

@@ -1,5 +1,6 @@
import { analyticsJob } from "./jobs/analytics"; import { analyticsJob } from "./jobs/analytics";
import { iconsUpdaterJob } from "./jobs/icons-updater"; import { iconsUpdaterJob } from "./jobs/icons-updater";
import { dnsHoleJob } from "./jobs/integrations/dns-hole";
import { downloadsJob } from "./jobs/integrations/downloads"; import { downloadsJob } from "./jobs/integrations/downloads";
import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant"; import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant";
import { indexerManagerJob } from "./jobs/integrations/indexer-manager"; import { indexerManagerJob } from "./jobs/integrations/indexer-manager";
@@ -19,6 +20,7 @@ export const jobGroup = createCronJobGroup({
mediaServer: mediaServerJob, mediaServer: mediaServerJob,
mediaOrganizer: mediaOrganizerJob, mediaOrganizer: mediaOrganizerJob,
downloads: downloadsJob, downloads: downloadsJob,
dnsHole: dnsHoleJob,
mediaRequests: mediaRequestsJob, mediaRequests: mediaRequestsJob,
rssFeeds: rssFeedsJob, rssFeeds: rssFeedsJob,
indexerManager: indexerManagerJob, indexerManager: indexerManagerJob,

View File

@@ -0,0 +1,28 @@
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
import { db } from "@homarr/db";
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries";
import { integrationCreatorFromSecrets } from "@homarr/integrations";
import type { DnsHoleSummary } from "@homarr/integrations/types";
import { logger } from "@homarr/log";
import { createItemAndIntegrationChannel } from "@homarr/redis";
import { createCronJob } from "../../lib";
export const dnsHoleJob = createCronJob("dnsHole", EVERY_5_SECONDS).withCallback(async () => {
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
kinds: ["dnsHoleSummary", "dnsHoleControls"],
});
for (const itemForIntegration of itemsForIntegration) {
for (const { integration } of itemForIntegration.integrations) {
const integrationInstance = integrationCreatorFromSecrets(integration);
await integrationInstance
.getSummaryAsync()
.then(async (data) => {
const channel = createItemAndIntegrationChannel<DnsHoleSummary>(itemForIntegration.kind, integration.id);
await channel.publishAndUpdateLastStateAsync(data);
})
.catch((error) => logger.error(`Could not retrieve data for ${integration.name}: "${error}"`));
}
}
});

View File

@@ -31,7 +31,7 @@
}, },
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@auth/core": "^0.35.0", "@auth/core": "^0.35.2",
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0",
@@ -49,7 +49,7 @@
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/better-sqlite3": "7.6.11", "@types/better-sqlite3": "7.6.11",
"dotenv-cli": "^7.4.2", "dotenv-cli": "^7.4.2",
"eslint": "^9.11.0", "eslint": "^9.11.1",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"typescript": "^5.6.2" "typescript": "^5.6.2"
} }

View File

@@ -28,7 +28,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.0", "eslint": "^9.11.1",
"typescript": "^5.6.2" "typescript": "^5.6.2"
} }
} }

View File

@@ -24,13 +24,13 @@
"dependencies": { "dependencies": {
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/form": "^7.12.2" "@mantine/form": "^7.13.0"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.0", "eslint": "^9.11.1",
"typescript": "^5.6.2" "typescript": "^5.6.2"
} }
} }

View File

@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.0", "eslint": "^9.11.1",
"typescript": "^5.6.2" "typescript": "^5.6.2"
} }
} }

View File

@@ -39,7 +39,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.0", "eslint": "^9.11.1",
"typescript": "^5.6.2" "typescript": "^5.6.2"
} }
} }

View File

@@ -77,7 +77,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
return { return {
status: status.data.protection_enabled ? ("enabled" as const) : ("disabled" as const), status: status.data.protection_enabled ? ("enabled" as const) : ("disabled" as const),
adsBlockedToday: blockedQueriesToday, adsBlockedToday: blockedQueriesToday,
adsBlockedTodayPercentage: (queriesToday / blockedQueriesToday) * 100, adsBlockedTodayPercentage: blockedQueriesToday > 0 ? (queriesToday / blockedQueriesToday) * 100 : 0,
domainsBeingBlocked: countFilteredDomains, domainsBeingBlocked: countFilteredDomains,
dnsQueriesToday: queriesToday, dnsQueriesToday: queriesToday,
}; };

View File

@@ -1,5 +1,5 @@
export interface DnsHoleSummary { export interface DnsHoleSummary {
status: "enabled" | "disabled"; status?: "enabled" | "disabled";
domainsBeingBlocked: number; domainsBeingBlocked: number;
adsBlockedToday: number; adsBlockedToday: number;
adsBlockedTodayPercentage: number; adsBlockedTodayPercentage: number;

View File

@@ -2,3 +2,4 @@ export * from "./calendar-types";
export * from "./interfaces/dns-hole-summary/dns-hole-summary-types"; export * from "./interfaces/dns-hole-summary/dns-hole-summary-types";
export * from "./interfaces/indexer-manager/indexer"; export * from "./interfaces/indexer-manager/indexer";
export * from "./interfaces/media-requests/media-request"; export * from "./interfaces/media-requests/media-request";
export * from "./pi-hole/pi-hole-types";

View File

@@ -34,7 +34,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.0", "eslint": "^9.11.1",
"typescript": "^5.6.2" "typescript": "^5.6.2"
} }
} }

View File

@@ -30,8 +30,8 @@
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.12.2", "@mantine/core": "^7.13.0",
"@tabler/icons-react": "^3.17.0", "@tabler/icons-react": "^3.18.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"next": "^14.2.13", "next": "^14.2.13",
"react": "^18.3.1" "react": "^18.3.1"
@@ -40,7 +40,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.0", "eslint": "^9.11.1",
"typescript": "^5.6.2" "typescript": "^5.6.2"
}, },
"prettier": "@homarr/prettier-config" "prettier": "@homarr/prettier-config"

View File

@@ -24,15 +24,15 @@
"dependencies": { "dependencies": {
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@mantine/core": "^7.12.2", "@mantine/core": "^7.13.0",
"@mantine/hooks": "^7.12.2", "@mantine/hooks": "^7.13.0",
"react": "^18.3.1" "react": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.0", "eslint": "^9.11.1",
"typescript": "^5.6.2" "typescript": "^5.6.2"
} }
} }

View File

@@ -24,14 +24,14 @@
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@mantine/notifications": "^7.12.2", "@mantine/notifications": "^7.13.0",
"@tabler/icons-react": "^3.17.0" "@tabler/icons-react": "^3.18.0"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.0", "eslint": "^9.11.1",
"typescript": "^5.6.2" "typescript": "^5.6.2"
} }
} }

View File

@@ -33,7 +33,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.0", "eslint": "^9.11.1",
"typescript": "^5.6.2" "typescript": "^5.6.2"
}, },
"prettier": "@homarr/prettier-config" "prettier": "@homarr/prettier-config"

View File

@@ -27,7 +27,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.0", "eslint": "^9.11.1",
"typescript": "^5.6.2" "typescript": "^5.6.2"
}, },
"prettier": "@homarr/prettier-config" "prettier": "@homarr/prettier-config"

View File

@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.0", "eslint": "^9.11.1",
"typescript": "^5.6.2" "typescript": "^5.6.2"
} }
} }

View File

@@ -33,7 +33,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.0", "eslint": "^9.11.1",
"typescript": "^5.6.2" "typescript": "^5.6.2"
} }
} }

View File

@@ -25,7 +25,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.0", "eslint": "^9.11.1",
"typescript": "^5.6.2" "typescript": "^5.6.2"
} }
} }

View File

@@ -30,10 +30,10 @@
"@homarr/modals-collection": "workspace:^0.1.0", "@homarr/modals-collection": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@mantine/core": "^7.12.2", "@mantine/core": "^7.13.0",
"@mantine/hooks": "^7.12.2", "@mantine/hooks": "^7.13.0",
"@mantine/spotlight": "^7.12.2", "@mantine/spotlight": "^7.13.0",
"@tabler/icons-react": "^3.17.0", "@tabler/icons-react": "^3.18.0",
"jotai": "^2.10.0", "jotai": "^2.10.0",
"next": "^14.2.13", "next": "^14.2.13",
"react": "^18.3.1", "react": "^18.3.1",
@@ -43,7 +43,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.0", "eslint": "^9.11.1",
"typescript": "^5.6.2" "typescript": "^5.6.2"
}, },
"prettier": "@homarr/prettier-config" "prettier": "@homarr/prettier-config"

View File

@@ -33,7 +33,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.0", "eslint": "^9.11.1",
"typescript": "^5.6.2" "typescript": "^5.6.2"
} }
} }

View File

@@ -532,10 +532,6 @@ export default {
}, },
beta: "Beta", beta: "Beta",
error: "Error", error: "Error",
errors: {
noData: "No data to show",
noIntegration: "No integration selected",
},
action: { action: {
add: "Add", add: "Add",
apply: "Apply", apply: "Apply",
@@ -799,6 +795,7 @@ export default {
}, },
error: { error: {
internalServerError: "Failed to fetch DNS Hole Summary", internalServerError: "Failed to fetch DNS Hole Summary",
integrationsDisconnected: "No data available, all integrations disconnected",
}, },
data: { data: {
adsBlockedToday: "Blocked today", adsBlockedToday: "Blocked today",
@@ -839,6 +836,8 @@ export default {
set: "Set", set: "Set",
enabled: "Enabled", enabled: "Enabled",
disabled: "Disabled", disabled: "Disabled",
processing: "Processing",
disconnected: "Disconnected",
hours: "Hours", hours: "Hours",
minutes: "Minutes", minutes: "Minutes",
unlimited: "Leave blank to unlimited", unlimited: "Leave blank to unlimited",
@@ -1098,6 +1097,7 @@ export default {
logs: "Check logs for more details", logs: "Check logs for more details",
}, },
noIntegration: "No integration selected", noIntegration: "No integration selected",
noData: "No integration data available",
}, },
option: {}, option: {},
}, },
@@ -1842,6 +1842,9 @@ export default {
indexerManager: { indexerManager: {
label: "Indexer Manager", label: "Indexer Manager",
}, },
dnsHole: {
label: "DNS Hole Data",
},
}, },
}, },
}, },

View File

@@ -28,10 +28,10 @@
"@homarr/log": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.12.2", "@mantine/core": "^7.13.0",
"@mantine/dates": "^7.12.2", "@mantine/dates": "^7.13.0",
"@mantine/hooks": "^7.12.2", "@mantine/hooks": "^7.13.0",
"@tabler/icons-react": "^3.17.0", "@tabler/icons-react": "^3.18.0",
"mantine-react-table": "2.0.0-beta.6", "mantine-react-table": "2.0.0-beta.6",
"next": "^14.2.13", "next": "^14.2.13",
"react": "^18.3.1" "react": "^18.3.1"
@@ -41,7 +41,7 @@
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/css-modules": "^1.0.5", "@types/css-modules": "^1.0.5",
"eslint": "^9.11.0", "eslint": "^9.11.1",
"typescript": "^5.6.2" "typescript": "^5.6.2"
}, },
"prettier": "@homarr/prettier-config" "prettier": "@homarr/prettier-config"

View File

@@ -33,7 +33,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.0", "eslint": "^9.11.1",
"typescript": "^5.6.2" "typescript": "^5.6.2"
} }
} }

View File

@@ -38,24 +38,24 @@
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.12.2", "@mantine/core": "^7.13.0",
"@mantine/hooks": "^7.12.2", "@mantine/hooks": "^7.13.0",
"@tabler/icons-react": "^3.17.0", "@tabler/icons-react": "^3.18.0",
"@tiptap/extension-color": "2.7.2", "@tiptap/extension-color": "2.7.4",
"@tiptap/extension-highlight": "2.7.2", "@tiptap/extension-highlight": "2.7.4",
"@tiptap/extension-image": "2.7.2", "@tiptap/extension-image": "2.7.4",
"@tiptap/extension-link": "^2.7.2", "@tiptap/extension-link": "^2.7.4",
"@tiptap/extension-table": "2.7.2", "@tiptap/extension-table": "2.7.4",
"@tiptap/extension-table-cell": "2.7.2", "@tiptap/extension-table-cell": "2.7.4",
"@tiptap/extension-table-header": "2.7.2", "@tiptap/extension-table-header": "2.7.4",
"@tiptap/extension-table-row": "2.7.2", "@tiptap/extension-table-row": "2.7.4",
"@tiptap/extension-task-item": "2.7.2", "@tiptap/extension-task-item": "2.7.4",
"@tiptap/extension-task-list": "2.7.2", "@tiptap/extension-task-list": "2.7.4",
"@tiptap/extension-text-align": "2.7.2", "@tiptap/extension-text-align": "2.7.4",
"@tiptap/extension-text-style": "2.7.2", "@tiptap/extension-text-style": "2.7.4",
"@tiptap/extension-underline": "2.7.2", "@tiptap/extension-underline": "2.7.4",
"@tiptap/react": "^2.7.2", "@tiptap/react": "^2.7.4",
"@tiptap/starter-kit": "^2.7.2", "@tiptap/starter-kit": "^2.7.4",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"mantine-react-table": "2.0.0-beta.6", "mantine-react-table": "2.0.0-beta.6",
@@ -68,7 +68,7 @@
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/video.js": "^7.3.58", "@types/video.js": "^7.3.58",
"eslint": "^9.11.0", "eslint": "^9.11.1",
"typescript": "^5.6.2" "typescript": "^5.6.2"
} }
} }

View File

@@ -1,140 +1,228 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import "../../widgets-common.css";
import { ActionIcon, Badge, Button, Card, Flex, Image, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconClockPause, IconPlayerPlay, IconPlayerStop } from "@tabler/icons-react";
import { useState } from "react";
import {
ActionIcon,
Badge,
Button,
Card,
Flex,
Image,
ScrollArea,
Stack,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconCircleFilled, IconClockPause, IconPlayerPlay, IconPlayerStop } from "@tabler/icons-react";
import dayjs from "dayjs";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { integrationDefs } from "@homarr/definitions"; import { integrationDefs } from "@homarr/definitions";
import type { TranslationFunction } from "@homarr/translation"; import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { widgetKind } from ".";
import type { WidgetComponentProps } from "../../definition"; import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationSelectedError } from "../../errors"; import { NoIntegrationSelectedError } from "../../errors";
import TimerModal from "./TimerModal"; import TimerModal from "./TimerModal";
const dnsLightStatus = (enabled: boolean): "green" | "red" => (enabled ? "green" : "red"); const dnsLightStatus = (enabled: boolean | undefined) =>
`var(--mantine-color-${typeof enabled === "undefined" ? "blue" : enabled ? "green" : "red"}-6`;
export default function DnsHoleControlsWidget({ options, integrationIds }: WidgetComponentProps<"dnsHoleControls">) { export default function DnsHoleControlsWidget({
if (integrationIds.length === 0) { options,
throw new NoIntegrationSelectedError(); integrationIds,
} isEditMode,
const t = useI18n(); serverData,
const [status, setStatus] = useState<{ integrationId: string; enabled: boolean }[]>( }: WidgetComponentProps<typeof widgetKind>) {
integrationIds.map((id) => ({ integrationId: id, enabled: false })), // DnsHole integrations with interaction permissions
); const integrationsWithInteractions = useIntegrationsWithInteractAccess()
const [selectedIntegrationIds, setSelectedIntegrationIds] = useState<string[]>([]); .map(({ id }) => id)
const [opened, { close, open }] = useDisclosure(false); .filter((id) => integrationIds.includes(id));
const [data] = clientApi.widget.dnsHole.summary.useSuspenseQuery( // Initial summaries, null summary means disconnected, undefined status means processing
const [summaries, setSummaries] = useState(serverData?.initialData ?? []);
// Subscribe to summary updates
clientApi.widget.dnsHole.subscribeToSummary.useSubscription(
{ {
widgetKind,
integrationIds, integrationIds,
}, },
{ {
refetchOnMount: false, onData: (data) => {
retry: false, setSummaries((prevSummaries) =>
prevSummaries.map((summary) => (summary.integration.id === data.integration.id ? data : summary)),
);
},
}, },
); );
useEffect(() => { // Mutations for dnsHole state, set to undefined on click, and change again on settle
const newStatus = data.map((integrationData) => ({
integrationId: integrationData.integrationId,
enabled: integrationData.summary.status === "enabled",
}));
setStatus(newStatus);
}, [data]);
const { mutate: enableDns } = clientApi.widget.dnsHole.enable.useMutation({ const { mutate: enableDns } = clientApi.widget.dnsHole.enable.useMutation({
onSuccess: (_, variables) => { onSettled: (_, error, { integrationId }) => {
setStatus((prevStatus) => setSummaries((prevSummaries) =>
prevStatus.map((item) => (item.integrationId === variables.integrationId ? { ...item, enabled: true } : item)), prevSummaries.map((data) => ({
...data,
summary:
data.integration.id === integrationId && data.summary
? { ...data.summary, status: error ? "disabled" : "enabled" }
: data.summary,
})),
); );
}, },
}); });
const { mutate: disableDns } = clientApi.widget.dnsHole.disable.useMutation({ const { mutate: disableDns } = clientApi.widget.dnsHole.disable.useMutation({
onSuccess: (_, variables) => { onSettled: (_, error, { integrationId }) => {
setStatus((prevStatus) => setSummaries((prevSummaries) =>
prevStatus.map((item) => (item.integrationId === variables.integrationId ? { ...item, enabled: false } : item)), prevSummaries.map((data) => ({
...data,
summary:
data.integration.id === integrationId && data.summary
? { ...data.summary, status: error ? "enabled" : "disabled" }
: data.summary,
})),
); );
}, },
}); });
const toggleDns = (integrationId: string) => { const toggleDns = (integrationId: string) => {
const integrationStatus = status.find((item) => item.integrationId === integrationId); const integrationStatus = summaries.find(({ integration }) => integration.id === integrationId);
if (integrationStatus?.enabled) { if (!integrationStatus?.summary?.status) return;
setSummaries((prevSummaries) =>
prevSummaries.map((data) => ({
...data,
summary:
data.integration.id === integrationId && data.summary ? { ...data.summary, status: undefined } : data.summary,
})),
);
if (integrationStatus.summary.status === "enabled") {
disableDns({ integrationId, duration: 0 }); disableDns({ integrationId, duration: 0 });
} else { } else {
enableDns({ integrationId }); enableDns({ integrationId });
} }
}; };
const enabledIntegrations = integrationIds.filter((id) => status.find((item) => item.integrationId === id)?.enabled); // make lists of enabled and disabled interactable integrations (with permissions, not disconnected and not processing)
const disabledIntegrations = integrationIds.filter( const integrationsSummaries = summaries.reduce(
(id) => !status.find((item) => item.integrationId === id)?.enabled, (acc, { summary, integration: { id } }) =>
integrationsWithInteractions.includes(id) && summary?.status != null ? (acc[summary.status].push(id), acc) : acc,
{ enabled: [] as string[], disabled: [] as string[] },
); );
const t = useI18n();
// Timer modal setup
const [selectedIntegrationIds, setSelectedIntegrationIds] = useState<string[]>([]);
const [opened, { close, open }] = useDisclosure(false);
const controlAllButtonsVisible = options.showToggleAllButtons && integrationsWithInteractions.length > 0;
if (integrationIds.length === 0) {
throw new NoIntegrationSelectedError();
}
return ( return (
<Flex h="100%" direction="column" gap={0} p="2.5cqmin"> <Flex
{options.showToggleAllButtons && ( className="dns-hole-controls-stack"
<Flex m="2.5cqmin" gap="2.5cqmin"> h="100%"
direction="column"
p="2.5cqmin"
gap="2.5cqmin"
style={{ pointerEvents: isEditMode ? "none" : undefined }}
>
{controlAllButtonsVisible && (
<Flex className="dns-hole-controls-buttons" gap="2.5cqmin">
<Tooltip label={t("widget.dnsHoleControls.controls.enableAll")}> <Tooltip label={t("widget.dnsHoleControls.controls.enableAll")}>
<Button <Button
onClick={() => disabledIntegrations.forEach((integrationId) => enableDns({ integrationId }))} className="dns-hole-controls-enable-all-button"
disabled={disabledIntegrations.length === 0} onClick={() => integrationsSummaries.disabled.forEach((integrationId) => toggleDns(integrationId))}
disabled={integrationsSummaries.disabled.length === 0}
variant="light" variant="light"
color="green" color="green"
fullWidth h="fit-content"
h="2rem" p="1.25cqmin"
bd={0}
radius="2.5cqmin"
flex={1}
> >
<IconPlayerPlay size={20} /> <IconPlayerPlay
className="dns-hole-controls-enable-all-icon"
style={{ height: "7.5cqmin", width: "7.5cqmin" }}
/>
</Button> </Button>
</Tooltip> </Tooltip>
<Tooltip label={t("widget.dnsHoleControls.controls.setTimer")}> <Tooltip label={t("widget.dnsHoleControls.controls.setTimer")}>
<Button <Button
className="dns-hole-controls-timer-all-button"
onClick={() => { onClick={() => {
setSelectedIntegrationIds(enabledIntegrations); setSelectedIntegrationIds(integrationsSummaries.enabled);
open(); open();
}} }}
disabled={enabledIntegrations.length === 0} disabled={integrationsSummaries.enabled.length === 0}
variant="light" variant="light"
color="yellow" color="yellow"
fullWidth h="fit-content"
h="2rem" p="1.25cqmin"
bd={0}
radius="2.5cqmin"
flex={1}
> >
<IconClockPause size={20} /> <IconClockPause
className="dns-hole-controls-timer-all-icon"
style={{ height: "7.5cqmin", width: "7.5cqmin" }}
/>
</Button> </Button>
</Tooltip> </Tooltip>
<Tooltip label={t("widget.dnsHoleControls.controls.disableAll")}> <Tooltip label={t("widget.dnsHoleControls.controls.disableAll")}>
<Button <Button
onClick={() => enabledIntegrations.forEach((integrationId) => disableDns({ integrationId, duration: 0 }))} className="dns-hole-controls-disable-all-button"
disabled={enabledIntegrations.length === 0} onClick={() => integrationsSummaries.enabled.forEach((integrationId) => toggleDns(integrationId))}
disabled={integrationsSummaries.enabled.length === 0}
variant="light" variant="light"
color="red" color="red"
fullWidth h="fit-content"
h="2rem" p="1.25cqmin"
bd={0}
radius="2.5cqmin"
flex={1}
> >
<IconPlayerStop size={20} /> <IconPlayerStop
className="dns-hole-controls-disable-all-icon"
style={{ height: "7.5cqmin", width: "7.5cqmin" }}
/>
</Button> </Button>
</Tooltip> </Tooltip>
</Flex> </Flex>
)} )}
<Stack m="2.5cqmin" gap="2.5cqmin" flex={1} justify={options.showToggleAllButtons ? "flex-end" : "space-evenly"}> <ScrollArea className="dns-hole-controls-integration-list-scroll-area flexed-scroll-area">
{data.map((integrationData) => ( <Stack
<ControlsCard className="dns-hole-controls-integration-list"
key={integrationData.integrationId} gap="2.5cqmin"
integrationId={integrationData.integrationId} flex={1}
integrationKind={integrationData.integrationKind} justify={controlAllButtonsVisible ? "flex-end" : "space-evenly"}
toggleDns={toggleDns} >
status={status} {summaries.map((summary) => (
setSelectedIntegrationIds={setSelectedIntegrationIds} <ControlsCard
open={open} key={summary.integration.id}
t={t} integrationsWithInteractions={integrationsWithInteractions}
/> toggleDns={toggleDns}
))} data={summary}
</Stack> setSelectedIntegrationIds={setSelectedIntegrationIds}
open={open}
t={t}
/>
))}
</Stack>
</ScrollArea>
<TimerModal <TimerModal
opened={opened} opened={opened}
@@ -147,52 +235,109 @@ export default function DnsHoleControlsWidget({ options, integrationIds }: Widge
} }
interface ControlsCardProps { interface ControlsCardProps {
integrationId: string; integrationsWithInteractions: string[];
integrationKind: string;
toggleDns: (integrationId: string) => void; toggleDns: (integrationId: string) => void;
status: { integrationId: string; enabled: boolean }[]; data: RouterOutputs["widget"]["dnsHole"]["summary"][number];
setSelectedIntegrationIds: (integrationId: string[]) => void; setSelectedIntegrationIds: (integrationId: string[]) => void;
open: () => void; open: () => void;
t: TranslationFunction; t: TranslationFunction;
} }
const ControlsCard: React.FC<ControlsCardProps> = ({ const ControlsCard: React.FC<ControlsCardProps> = ({
integrationId, integrationsWithInteractions,
integrationKind,
toggleDns, toggleDns,
status, data,
setSelectedIntegrationIds, setSelectedIntegrationIds,
open, open,
t, t,
}) => { }) => {
const integrationStatus = status.find((item) => item.integrationId === integrationId); // Independently determine connection status, current state and permissions
const isEnabled = integrationStatus?.enabled ?? false; const isConnected = data.summary !== null && Math.abs(dayjs(data.timestamp).diff()) < 30000;
const integrationDef = integrationKind === "piHole" ? integrationDefs.piHole : integrationDefs.adGuardHome; const isEnabled = data.summary?.status ? data.summary.status === "enabled" : undefined;
const isInteractPermitted = integrationsWithInteractions.includes(data.integration.id);
// Use all factors to infer the state of the action buttons
const controlEnabled = isInteractPermitted && isEnabled !== undefined && isConnected;
return ( return (
<Card key={integrationId} withBorder p="2.5cqmin" radius="2.5cqmin"> <Card
<Flex justify="space-between" align="center" direction="row" m="2.5cqmin"> className={`dns-hole-controls-integration-item-outer-shell dns-hole-controls-integration-item-${data.integration.id} dns-hole-controls-integration-item-${data.integration.name}`}
<Image src={integrationDef.iconUrl} width="50cqmin" height="50cqmin" fit="contain" /> key={data.integration.id}
<Flex direction="column"> withBorder
<Text>{integrationDef.name}</Text> p="2.5cqmin"
<Flex direction="row" gap="2cqmin"> radius="2.5cqmin"
<UnstyledButton onClick={() => toggleDns(integrationId)}> >
<Badge variant="dot" color={dnsLightStatus(isEnabled)}> <Flex className="dns-hole-controls-item-container" gap="4cqmin" align="center" direction="row">
{t(`widget.dnsHoleControls.controls.${isEnabled ? "enabled" : "disabled"}`)} <Image
className="dns-hole-controls-item-icon"
src={integrationDefs[data.integration.kind].iconUrl}
w="20cqmin"
h="20cqmin"
fit="contain"
style={{ filter: !isConnected ? "grayscale(100%)" : undefined }}
/>
<Flex className="dns-hole-controls-item-data-stack" direction="column" gap="1.5cqmin">
<Text className="dns-hole-controls-item-integration-name" fz="7cqmin">
{data.integration.name}
</Text>
<Flex className="dns-hole-controls-item-controls" direction="row" gap="1.5cqmin">
<UnstyledButton
className="dns-hole-controls-item-toggle-button"
disabled={!controlEnabled}
display="contents"
style={{ cursor: controlEnabled ? "pointer" : "default" }}
onClick={() => toggleDns(data.integration.id)}
>
<Badge
className={`dns-hole-controls-item-toggle-button-styling${controlEnabled ? " hoverable-component clickable-component" : ""}`}
bd="0.1cqmin solid var(--border-color)"
px="2.5cqmin"
h="7.5cqmin"
fz="4.5cqmin"
lts="0.1cqmin"
color="var(--background-color)"
c="var(--mantine-color-text)"
styles={{ section: { marginInlineEnd: "2.5cqmin" } }}
leftSection={
isConnected && (
<IconCircleFilled
className="dns-hole-controls-item-status-icon"
color={dnsLightStatus(isEnabled)}
style={{ height: "3.5cqmin", width: "3.5cqmin" }}
/>
)
}
>
{t(
`widget.dnsHoleControls.controls.${
!isConnected
? "disconnected"
: typeof isEnabled === "undefined"
? "processing"
: isEnabled
? "enabled"
: "disabled"
}`,
)}
</Badge> </Badge>
</UnstyledButton> </UnstyledButton>
<ActionIcon <ActionIcon
disabled={!isEnabled} className="dns-hole-controls-item-timer-button"
size={20} display={isInteractPermitted ? undefined : "none"}
radius="xl" disabled={!controlEnabled || !isEnabled}
top="2.67px" color="yellow"
variant="default" size="fit-content"
radius="999px 999px 0px 999px"
bd={0}
variant="subtle"
onClick={() => { onClick={() => {
setSelectedIntegrationIds([integrationId]); setSelectedIntegrationIds([data.integration.id]);
open(); open();
}} }}
> >
<IconClockPause size={20} color="red" /> <IconClockPause
className="dns-hole-controls-item-timer-icon"
style={{ height: "7.5cqmin", width: "7.5cqmin" }}
/>
</ActionIcon> </ActionIcon>
</Flex> </Flex>
</Flex> </Flex>

View File

@@ -5,7 +5,9 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../../definition"; import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options"; import { optionsBuilder } from "../../options";
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("dnsHoleControls", { export const widgetKind = "dnsHoleControls";
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition(widgetKind, {
icon: IconDeviceGamepad, icon: IconDeviceGamepad,
options: optionsBuilder.from((factory) => ({ options: optionsBuilder.from((factory) => ({
showToggleAllButtons: factory.switch({ showToggleAllButtons: factory.switch({

View File

@@ -2,9 +2,10 @@
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { widgetKind } from ".";
import type { WidgetProps } from "../../definition"; import type { WidgetProps } from "../../definition";
export default async function getServerDataAsync({ integrationIds }: WidgetProps<"dnsHoleControls">) { export default async function getServerDataAsync({ integrationIds }: WidgetProps<typeof widgetKind>) {
if (integrationIds.length === 0) { if (integrationIds.length === 0) {
return { return {
initialData: [], initialData: [],
@@ -13,6 +14,7 @@ export default async function getServerDataAsync({ integrationIds }: WidgetProps
try { try {
const currentDns = await api.widget.dnsHole.summary({ const currentDns = await api.widget.dnsHole.summary({
widgetKind,
integrationIds, integrationIds,
}); });

View File

@@ -1,18 +1,22 @@
"use client"; "use client";
import { useMemo } from "react"; import { useMemo, useState } from "react";
import type { BoxProps } from "@mantine/core"; import type { BoxProps } from "@mantine/core";
import { Box, Card, Flex, Text } from "@mantine/core"; import { Avatar, AvatarGroup, Box, Card, Flex, Stack, Text, Tooltip } from "@mantine/core";
import { useElementSize } from "@mantine/hooks"; import { useElementSize } from "@mantine/hooks";
import { IconBarrierBlock, IconPercentage, IconSearch, IconWorldWww } from "@tabler/icons-react"; import { IconBarrierBlock, IconPercentage, IconSearch, IconWorldWww } from "@tabler/icons-react";
import dayjs from "dayjs";
import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client";
import { formatNumber } from "@homarr/common"; import { formatNumber } from "@homarr/common";
import { integrationDefs } from "@homarr/definitions";
import type { DnsHoleSummary } from "@homarr/integrations/types";
import type { stringOrTranslation, TranslationFunction } from "@homarr/translation"; import type { stringOrTranslation, TranslationFunction } from "@homarr/translation";
import { translateIfNecessary } from "@homarr/translation"; import { translateIfNecessary } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui";
import { widgetKind } from ".";
import type { WidgetComponentProps, WidgetProps } from "../../definition"; import type { WidgetComponentProps, WidgetProps } from "../../definition";
import { NoIntegrationSelectedError } from "../../errors"; import { NoIntegrationSelectedError } from "../../errors";
@@ -20,20 +24,65 @@ export default function DnsHoleSummaryWidget({
options, options,
integrationIds, integrationIds,
serverData, serverData,
}: WidgetComponentProps<"dnsHoleSummary">) { }: WidgetComponentProps<typeof widgetKind>) {
const integrationId = integrationIds.at(0); const [summaries, setSummaries] = useState(serverData?.initialData ?? []);
if (!integrationId) { const t = useI18n();
clientApi.widget.dnsHole.subscribeToSummary.useSubscription(
{
widgetKind,
integrationIds,
},
{
onData: (data) => {
setSummaries((prevSummaries) =>
prevSummaries.map((summary) => (summary.integration.id === data.integration.id ? data : summary)),
);
},
},
);
const data = useMemo(
() =>
summaries
.filter(
(
pair,
): pair is {
integration: typeof pair.integration;
timestamp: typeof pair.timestamp;
summary: DnsHoleSummary;
} => pair.summary !== null && Math.abs(dayjs(pair.timestamp).diff()) < 30000,
)
.flatMap(({ summary }) => summary),
[summaries, serverData],
);
if (integrationIds.length === 0) {
throw new NoIntegrationSelectedError(); throw new NoIntegrationSelectedError();
} }
const data = useMemo(() => (serverData?.initialData ?? []).flatMap((summary) => summary.summary), [serverData]);
return ( return (
<Box h="100%" {...boxPropsByLayout(options.layout)}> <Box h="100%" {...boxPropsByLayout(options.layout)} p="2cqmin">
{stats.map((item, index) => ( {data.length > 0 ? (
<StatCard key={index} item={item} usePiHoleColors={options.usePiHoleColors} data={data} /> stats.map((item) => (
))} <StatCard key={item.color} item={item} usePiHoleColors={options.usePiHoleColors} data={data} t={t} />
))
) : (
<Stack h="100%" w="100%" justify="center" align="center" gap="2.5cqmin" p="2.5cqmin">
<AvatarGroup spacing="10cqmin">
{summaries.map(({ integration }) => (
<Tooltip key={integration.id} label={integration.name}>
<Avatar h="35cqmin" w="35cqmin" src={integrationDefs[integration.kind].iconUrl} />
</Tooltip>
))}
</AvatarGroup>
<Text fz="10cqmin" ta="center">
{t("widget.dnsHoleSummary.error.integrationsDisconnected")}
</Text>
</Stack>
)}
</Box> </Box>
); );
} }
@@ -86,26 +135,26 @@ const stats = [
interface StatItem { interface StatItem {
icon: TablerIcon; icon: TablerIcon;
value: (x: RouterOutputs["widget"]["dnsHole"]["summary"][number]["summary"][], t: TranslationFunction) => string; value: (x: DnsHoleSummary[], t: TranslationFunction) => string;
label: stringOrTranslation; label: stringOrTranslation;
color: string; color: string;
} }
interface StatCardProps { interface StatCardProps {
item: StatItem; item: StatItem;
data: RouterOutputs["widget"]["dnsHole"]["summary"][number]["summary"][]; data: DnsHoleSummary[];
usePiHoleColors: boolean; usePiHoleColors: boolean;
t: TranslationFunction;
} }
const StatCard = ({ item, data, usePiHoleColors }: StatCardProps) => { const StatCard = ({ item, data, usePiHoleColors, t }: StatCardProps) => {
const { ref, height, width } = useElementSize(); const { ref, height, width } = useElementSize();
const isLong = width > height + 20; const isLong = width > height + 20;
const t = useI18n();
return ( return (
<Card <Card
ref={ref} ref={ref}
className="summary-card" className="summary-card"
m="2.5cqmin" m="2cqmin"
p="2.5cqmin" p="2.5cqmin"
bg={usePiHoleColors ? item.color : "rgba(96, 96, 96, 0.1)"} bg={usePiHoleColors ? item.color : "rgba(96, 96, 96, 0.1)"}
style={{ style={{
@@ -122,7 +171,7 @@ const StatCard = ({ item, data, usePiHoleColors }: StatCardProps) => {
direction={isLong ? "row" : "column"} direction={isLong ? "row" : "column"}
style={{ containerType: "size" }} style={{ containerType: "size" }}
> >
<item.icon className="summary-card-icon" size="50cqmin" style={{ margin: "2cqmin" }} /> <item.icon className="summary-card-icon" size="40cqmin" style={{ margin: "2.5cqmin" }} />
<Flex <Flex
className="summary-card-texts" className="summary-card-texts"
justify="center" justify="center"
@@ -134,11 +183,18 @@ const StatCard = ({ item, data, usePiHoleColors }: StatCardProps) => {
h="100%" h="100%"
gap="1cqmin" gap="1cqmin"
> >
<Text className="summary-card-value" ta="center" size="25cqmin" fw="bold"> <Text
key={item.value(data, t)}
className="summary-card-value text-flash"
ta="center"
size="20cqmin"
fw="bold"
style={{ "--glow-size": "2.5cqmin" }}
>
{item.value(data, t)} {item.value(data, t)}
</Text> </Text>
{item.label && ( {item.label && (
<Text className="summary-card-label" ta="center" size="17.5cqmin"> <Text className="summary-card-label" ta="center" size="15cqmin">
{translateIfNecessary(t, item.label)} {translateIfNecessary(t, item.label)}
</Text> </Text>
)} )}

View File

@@ -5,7 +5,9 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../../definition"; import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options"; import { optionsBuilder } from "../../options";
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("dnsHoleSummary", { export const widgetKind = "dnsHoleSummary";
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition(widgetKind, {
icon: IconAd, icon: IconAd,
options: optionsBuilder.from((factory) => ({ options: optionsBuilder.from((factory) => ({
usePiHoleColors: factory.switch({ usePiHoleColors: factory.switch({

View File

@@ -2,9 +2,10 @@
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { widgetKind } from ".";
import type { WidgetProps } from "../../definition"; import type { WidgetProps } from "../../definition";
export default async function getServerDataAsync({ integrationIds }: WidgetProps<"dnsHoleSummary">) { export default async function getServerDataAsync({ integrationIds }: WidgetProps<typeof widgetKind>) {
if (integrationIds.length === 0) { if (integrationIds.length === 0) {
return { return {
initialData: [], initialData: [],
@@ -13,6 +14,7 @@ export default async function getServerDataAsync({ integrationIds }: WidgetProps
try { try {
const currentDns = await api.widget.dnsHole.summary({ const currentDns = await api.widget.dnsHole.summary({
widgetKind,
integrationIds, integrationIds,
}); });

View File

@@ -40,7 +40,9 @@ import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client"; import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { humanFileSize } from "@homarr/common"; import { humanFileSize } from "@homarr/common";
import type { Modify } from "@homarr/common/types";
import type { Integration } from "@homarr/db/schema/sqlite"; import type { Integration } from "@homarr/db/schema/sqlite";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions"; import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions";
import type { import type {
DownloadClientJobsAndStatus, DownloadClientJobsAndStatus,
@@ -97,7 +99,7 @@ export default function DownloadClientsWidget({
); );
const [currentItems, currentItemsHandlers] = useListState<{ const [currentItems, currentItemsHandlers] = useListState<{
integration: Integration; integration: Modify<Integration, { kind: IntegrationKindByCategory<"downloadClient"> }>;
timestamp: Date; timestamp: Date;
data: DownloadClientJobsAndStatus | null; data: DownloadClientJobsAndStatus | null;
}>( }>(
@@ -173,8 +175,13 @@ export default function DownloadClientsWidget({
.filter(({ integration }) => integrationIds.includes(integration.id)) .filter(({ integration }) => integrationIds.includes(integration.id))
//Removing any integration with no data associated //Removing any integration with no data associated
.filter( .filter(
(pair): pair is { integration: Integration; timestamp: Date; data: DownloadClientJobsAndStatus } => (
pair.data != null, pair,
): pair is {
integration: typeof pair.integration;
timestamp: typeof pair.timestamp;
data: DownloadClientJobsAndStatus;
} => pair.data != null,
) )
//Construct normalized items list //Construct normalized items list
.flatMap((pair) => .flatMap((pair) =>

View File

@@ -0,0 +1,19 @@
import { IconDatabaseOff } from "@tabler/icons-react";
import type { TranslationFunction } from "@homarr/translation";
import { ErrorBoundaryError } from "./base";
export class NoIntegrationDataError extends ErrorBoundaryError {
constructor() {
super("No integration data available");
}
public getErrorBoundaryData() {
return {
icon: IconDatabaseOff,
message: (t: TranslationFunction) => t("widget.common.error.noData"),
showLogsLink: false,
};
}
}

View File

@@ -1,18 +1,5 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { import { ActionIcon, Anchor, Avatar, Badge, Card, Group, Image, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
ActionIcon,
Anchor,
Avatar,
Badge,
Card,
Center,
Group,
Image,
ScrollArea,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { IconThumbDown, IconThumbUp } from "@tabler/icons-react"; import { IconThumbDown, IconThumbUp } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
@@ -21,6 +8,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../../definition"; import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationSelectedError } from "../../errors";
import { NoIntegrationDataError } from "../../errors/no-data-integration";
export default function MediaServerWidget({ export default function MediaServerWidget({
integrationIds, integrationIds,
@@ -30,7 +19,6 @@ export default function MediaServerWidget({
itemId, itemId,
}: WidgetComponentProps<"mediaRequests-requestList">) { }: WidgetComponentProps<"mediaRequests-requestList">) {
const t = useScopedI18n("widget.mediaRequests-requestList"); const t = useScopedI18n("widget.mediaRequests-requestList");
const tCommon = useScopedI18n("common");
const isQueryEnabled = Boolean(itemId); const isQueryEnabled = Boolean(itemId);
const { data: mediaRequests, isError: _isError } = clientApi.widget.mediaRequests.getLatestRequests.useQuery( const { data: mediaRequests, isError: _isError } = clientApi.widget.mediaRequests.getLatestRequests.useQuery(
{ {
@@ -67,9 +55,9 @@ export default function MediaServerWidget({
const { mutate: mutateRequestAnswer } = clientApi.widget.mediaRequests.answerRequest.useMutation(); const { mutate: mutateRequestAnswer } = clientApi.widget.mediaRequests.answerRequest.useMutation();
if (integrationIds.length === 0) return <Center h="100%">{tCommon("errors.noIntegration")}</Center>; if (integrationIds.length === 0) throw new NoIntegrationSelectedError();
if (sortedMediaRequests.length === 0) return <Center h="100%">{tCommon("errors.noData")}</Center>; if (sortedMediaRequests.length === 0) throw new NoIntegrationDataError();
return ( return (
<ScrollArea <ScrollArea

View File

@@ -1,5 +1,5 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { ActionIcon, Avatar, Card, Center, Grid, Group, Space, Stack, Text, Tooltip } from "@mantine/core"; import { ActionIcon, Avatar, Card, Grid, Group, Space, Stack, Text, Tooltip } from "@mantine/core";
import { useElementSize } from "@mantine/hooks"; import { useElementSize } from "@mantine/hooks";
import type { Icon } from "@tabler/icons-react"; import type { Icon } from "@tabler/icons-react";
import { import {
@@ -16,10 +16,12 @@ import {
import combineClasses from "clsx"; import combineClasses from "clsx";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import type { RequestStats } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { RequestStats } from "../../../../integrations/src/interfaces/media-requests/media-request";
import type { WidgetComponentProps } from "../../definition"; import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationSelectedError } from "../../errors";
import { NoIntegrationDataError } from "../../errors/no-data-integration";
import classes from "./component.module.css"; import classes from "./component.module.css";
export default function MediaServerWidget({ export default function MediaServerWidget({
@@ -64,19 +66,9 @@ export default function MediaServerWidget({
[baseData], [baseData],
); );
if (integrationIds.length === 0) if (integrationIds.length === 0) throw new NoIntegrationSelectedError();
return (
<Center ref={ref} h="100%">
{tCommon("errors.noIntegration")}
</Center>
);
if (users.length === 0 || stats.length === 0) if (users.length === 0 || stats.length === 0) throw new NoIntegrationDataError();
return (
<Center ref={ref} h="100%">
{tCommon("errors.noData")}
</Center>
);
//Add processing and available //Add processing and available
const data = [ const data = [

View File

@@ -34,3 +34,42 @@
min-height: var(--sortButtonSize); min-height: var(--sortButtonSize);
} }
} }
/*Make background of component different on hover, depending on base var*/
.hoverable-component {
&:hover {
background-color: rgb(from var(--background-color) calc(r + 10) calc(g + 10) calc(b + 10) / var(--opacity));
}
}
/*Make background of component different on click, depending on base var, inverse of hover*/
.clickable-component {
&:active {
background-color: rgb(from var(--background-color) calc(r - 10) calc(g - 10) calc(b - 10) / var(--opacity));
}
}
/*FadingGlowing effect for text that updates, add className and put the updating value as key*/
@keyframes glow {
from {
text-shadow: 0 0 var(--glow-size) var(--mantine-color-text);
}
to {
text-shadow: none;
}
}
.text-flash {
animation: glow 1s ease-in-out;
}
/*To apply to any ScrollArea that we want to flex. Same weird workaround as before*/
.flexed-scroll-area {
height: 100%;
.mantine-ScrollArea-viewport {
& div[style="min-width: 100%; display: table;"] {
display: flex !important;
height: 100%;
}
}
}

1354
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,10 @@
# Run migrations # Run migrations
node ./db/migrations/$DB_DIALECT/migrate.cjs ./db/migrations/$DB_DIALECT if [ $DB_MIGRATIONS_DISABLED = "true" ]; then
echo "DB migrations are disabled, skipping"
else
echo "Running DB migrations"
node ./db/migrations/$DB_DIALECT/migrate.cjs ./db/migrations/$DB_DIALECT
fi
# Start nginx proxy # Start nginx proxy
# 1. Replace the HOSTNAME in the nginx template file # 1. Replace the HOSTNAME in the nginx template file

View File

@@ -21,14 +21,14 @@
"eslint-config-turbo": "^2.1.2", "eslint-config-turbo": "^2.1.2",
"eslint-plugin-import": "^2.30.0", "eslint-plugin-import": "^2.30.0",
"eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-jsx-a11y": "^6.10.0",
"eslint-plugin-react": "^7.36.1", "eslint-plugin-react": "^7.37.0",
"eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-hooks": "^4.6.2",
"typescript-eslint": "^8.6.0" "typescript-eslint": "^8.7.0"
}, },
"devDependencies": { "devDependencies": {
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.0", "eslint": "^9.11.1",
"typescript": "^5.6.2" "typescript": "^5.6.2"
}, },
"prettier": "@homarr/prettier-config" "prettier": "@homarr/prettier-config"