refactor: tighten TypeScript boundaries
This commit is contained in:
1 parent
ad0120fe01
commit
24e6e4a47d
38 files changed
+246
-190
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import { TablerIcon } from "@tabler/icons-react";
|
||||
import { ReactNode } from "react";
|
||||
import type { TablerIcon } from "@tabler/icons-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface IntegrationDefinition {
|
||||
name: string;
|
||||
|
||||
+2
-1
@@ -43,7 +43,8 @@ export function CustomWidgetAdvancedManifest({ form }: { form: CustomWidgetWorkb
|
||||
onChange={(next) => {
|
||||
setDraft(next);
|
||||
try {
|
||||
const parsed = customWidgetDefinitionSchema.safeParse(JSON.parse(next) as unknown);
|
||||
const value: unknown = JSON.parse(next);
|
||||
const parsed = customWidgetDefinitionSchema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
setError(parsed.error.issues[0]?.message ?? t("invalidWidget"));
|
||||
return;
|
||||
|
||||
@@ -74,7 +74,8 @@ export function parseSources(value: string): Array<CustomWidgetSource & { id: st
|
||||
|
||||
export function parseJson(value: string): unknown {
|
||||
try {
|
||||
return JSON.parse(value) as unknown;
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -78,13 +78,10 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
() => customWidgetOptionsSchema.safeParse(parseJson(form.values.options)),
|
||||
[form.values.options],
|
||||
);
|
||||
const requestIds = useMemo(
|
||||
() =>
|
||||
Object.keys(
|
||||
isRecord(parseJson(form.values.requests)) ? (parseJson(form.values.requests) as Record<string, unknown>) : {},
|
||||
),
|
||||
[form.values.requests],
|
||||
);
|
||||
const requestIds = useMemo(() => {
|
||||
const requests = parseJson(form.values.requests);
|
||||
return Object.keys(isRecord(requests) ? requests : {});
|
||||
}, [form.values.requests]);
|
||||
const jsxCompletions = useMemo(
|
||||
() => createCustomWidgetCompletions(form.values, requestIds),
|
||||
[form.values, requestIds],
|
||||
@@ -132,7 +129,9 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
className={classes.paneSwitcher}
|
||||
fullWidth
|
||||
value={mobilePane}
|
||||
onChange={(value) => setMobilePane(value as typeof mobilePane)}
|
||||
onChange={(value) => {
|
||||
if (value === "configure" || value === "preview") setMobilePane(value);
|
||||
}}
|
||||
data={[
|
||||
{ value: "configure", label: w("configure") },
|
||||
{ value: "preview", label: w("section.preview") },
|
||||
|
||||
+3
-1
@@ -173,7 +173,9 @@ export function RequestBodyEditor({
|
||||
onChange={(next) => {
|
||||
setDraft(next);
|
||||
try {
|
||||
onChange(next.trim() ? (JSON.parse(next) as unknown) : undefined);
|
||||
let parsed: unknown;
|
||||
if (next.trim()) parsed = JSON.parse(next);
|
||||
onChange(parsed);
|
||||
setError(undefined);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : t("invalidJson"));
|
||||
|
||||
+4
-10
@@ -24,6 +24,8 @@ export function CustomWidgetRequestsEditor({
|
||||
const entries = isRecord(parsed) ? Object.entries(parsed) : [];
|
||||
const sources = parseJson(form.values.sources);
|
||||
const sourceIds = isRecord(sources) ? Object.keys(sources) : ["default"];
|
||||
const options = parseJson(form.values.options);
|
||||
const optionNames = isRecord(options) ? Object.keys(options) : [];
|
||||
|
||||
const commit = (next: Array<[string, unknown]>) =>
|
||||
form.setFieldValue("requests", JSON.stringify(Object.fromEntries(next), null, 2));
|
||||
@@ -129,22 +131,14 @@ export function CustomWidgetRequestsEditor({
|
||||
<Stack gap="sm">
|
||||
<QueryValuesEditor
|
||||
value={isRecord(request.query) ? request.query : {}}
|
||||
optionNames={Object.keys(
|
||||
isRecord(parseJson(form.values.options))
|
||||
? (parseJson(form.values.options) as Record<string, unknown>)
|
||||
: {},
|
||||
)}
|
||||
optionNames={optionNames}
|
||||
allowParams={trigger === "manual" || kind === "action"}
|
||||
onChange={(query) => update(index, id, { ...request, query })}
|
||||
/>
|
||||
<RequestBodyEditor
|
||||
id={id}
|
||||
value={request.body}
|
||||
optionNames={Object.keys(
|
||||
isRecord(parseJson(form.values.options))
|
||||
? (parseJson(form.values.options) as Record<string, unknown>)
|
||||
: {},
|
||||
)}
|
||||
optionNames={optionNames}
|
||||
allowParams={trigger === "manual" || kind === "action"}
|
||||
onChange={(body) => update(index, id, { ...request, body })}
|
||||
/>
|
||||
|
||||
@@ -366,7 +366,8 @@ export async function POST(request: Request) {
|
||||
const parsed = requestSchema.safeParse(
|
||||
(() => {
|
||||
try {
|
||||
return JSON.parse(requestBody) as unknown;
|
||||
const value: unknown = JSON.parse(requestBody);
|
||||
return value;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import { widgetIntegrationConfigs, widgetKinds } from "@homarr/definitions";
|
||||
import { widgetIntegrationSupport, widgetKinds } from "@homarr/definitions";
|
||||
|
||||
export interface BoardRecipeRecommendation {
|
||||
widgetKind: WidgetKind;
|
||||
@@ -36,10 +36,10 @@ export const getBoardRecipeRecommendations = ({
|
||||
return widgetKinds
|
||||
.filter((widgetKind) => !existingKinds.has(widgetKind))
|
||||
.flatMap((widgetKind) => {
|
||||
const capability = widgetIntegrationConfigs[widgetKind as keyof typeof widgetIntegrationConfigs];
|
||||
if (!capability) return [];
|
||||
const supportedIntegrations = widgetIntegrationSupport[widgetKind];
|
||||
if (!supportedIntegrations) return [];
|
||||
|
||||
const matches = capability.supportedIntegrations.filter((integrationKind) => availableKinds.has(integrationKind));
|
||||
const matches = supportedIntegrations.filter((integrationKind) => availableKinds.has(integrationKind));
|
||||
if (matches.length === 0) return [];
|
||||
|
||||
const preferredMatch = preferredIntegrationKind && matches.includes(preferredIntegrationKind);
|
||||
@@ -51,10 +51,10 @@ export const getBoardRecipeRecommendations = ({
|
||||
widgetKind,
|
||||
integrationKind,
|
||||
isNewlyAvailable: Boolean(preferredMatch),
|
||||
score: (preferredMatch ? 1_000 : 0) + matches.length * 10 - capability.supportedIntegrations.length,
|
||||
score: (preferredMatch ? 1_000 : 0) + matches.length * 10 - supportedIntegrations.length,
|
||||
},
|
||||
];
|
||||
})
|
||||
.sort((left, right) => right.score - left.score || left.widgetKind.localeCompare(right.widgetKind))
|
||||
.toSorted((left, right) => right.score - left.score || left.widgetKind.localeCompare(right.widgetKind))
|
||||
.slice(0, Math.max(0, limit));
|
||||
};
|
||||
@@ -47,10 +47,11 @@ const getProcedureInput = (inputs: z.ZodType[]) => {
|
||||
};
|
||||
|
||||
const normalizeJsonSchema = (schema: z.core.JSONSchema.JSONSchema): z.core.JSONSchema.JSONSchema => {
|
||||
const properties = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||
const required = ((schema.required as string[] | undefined) ?? []).filter(
|
||||
(key) => !("default" in (properties[key] ?? {})),
|
||||
);
|
||||
const properties = schema.properties ?? {};
|
||||
const required = (schema.required ?? []).filter((key) => {
|
||||
const property = properties[key];
|
||||
return !property || typeof property !== "object" || !("default" in property);
|
||||
});
|
||||
|
||||
return {
|
||||
...schema,
|
||||
|
||||
@@ -32,6 +32,14 @@ interface IntegrationAccessMetadata {
|
||||
allowedIntegrationKinds: readonly IntegrationKind[];
|
||||
}
|
||||
|
||||
const throwUnexpectedIntegrationKind = (kind: IntegrationKind, allowedKinds: readonly IntegrationKind[]): never => {
|
||||
logger.error("Database returned an integration outside the requested kinds", {
|
||||
integrationKind: kind,
|
||||
allowedIntegrationKinds: allowedKinds,
|
||||
});
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Integration kind validation failed" });
|
||||
};
|
||||
|
||||
const createOneIntegrationMiddlewareForKinds = <TKind extends IntegrationKind>(
|
||||
action: IntegrationAction,
|
||||
kinds: readonly TKind[],
|
||||
@@ -75,21 +83,19 @@ const createOneIntegrationMiddlewareForKinds = <TKind extends IntegrationKind>(
|
||||
resolvedIntegrationKinds: [integration.kind],
|
||||
});
|
||||
|
||||
const {
|
||||
secrets,
|
||||
kind,
|
||||
items: _ignore1,
|
||||
groupPermissions: _ignore2,
|
||||
userPermissions: _ignore3,
|
||||
...rest
|
||||
} = integration;
|
||||
const narrowedKind = kinds.find((kind) => kind === integration.kind);
|
||||
if (!narrowedKind) {
|
||||
return throwUnexpectedIntegrationKind(integration.kind, kinds);
|
||||
}
|
||||
|
||||
const { secrets, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest } = integration;
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
integration: {
|
||||
...rest,
|
||||
externalUrl: rest.app?.href ?? null,
|
||||
kind: kind as TKind,
|
||||
kind: narrowedKind,
|
||||
decryptedSecrets: secrets.map((secret) => ({
|
||||
...secret,
|
||||
value: decryptSecret(secret.value),
|
||||
@@ -176,15 +182,19 @@ const createManyIntegrationMiddlewareForKinds = <TKind extends IntegrationKind>(
|
||||
return next({
|
||||
ctx: {
|
||||
integrations: dbIntegrations.map(
|
||||
({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => ({
|
||||
...rest,
|
||||
externalUrl: rest.app?.href ?? null,
|
||||
kind: kind as TKind,
|
||||
decryptedSecrets: secrets.map((secret) => ({
|
||||
...secret,
|
||||
value: decryptSecret(secret.value),
|
||||
})),
|
||||
}),
|
||||
({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => {
|
||||
const narrowedKind = kinds.find((allowedKind) => allowedKind === kind);
|
||||
if (!narrowedKind) return throwUnexpectedIntegrationKind(kind, kinds);
|
||||
return {
|
||||
...rest,
|
||||
externalUrl: rest.app?.href ?? null,
|
||||
kind: narrowedKind,
|
||||
decryptedSecrets: secrets.map((secret) => ({
|
||||
...secret,
|
||||
value: decryptSecret(secret.value),
|
||||
})),
|
||||
};
|
||||
},
|
||||
),
|
||||
},
|
||||
});
|
||||
@@ -212,13 +222,19 @@ export const createOneWidgetIntegrationMiddleware = <TWidgetKind extends WidgetK
|
||||
action: IntegrationAction,
|
||||
widgetKind: TWidgetKind,
|
||||
): ReturnType<typeof createOneIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>> =>
|
||||
createOneIntegrationMiddlewareForKinds(action, getWidgetIntegrationConfig(widgetKind).supportedIntegrations);
|
||||
createOneIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>(
|
||||
action,
|
||||
getWidgetIntegrationConfig(widgetKind).supportedIntegrations,
|
||||
);
|
||||
|
||||
export const createManyWidgetIntegrationMiddleware = <TWidgetKind extends WidgetKindWithIntegration>(
|
||||
action: IntegrationAction,
|
||||
widgetKind: TWidgetKind,
|
||||
): ReturnType<typeof createManyIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>> =>
|
||||
createManyIntegrationMiddlewareForKinds(action, getWidgetIntegrationConfig(widgetKind).supportedIntegrations);
|
||||
createManyIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>(
|
||||
action,
|
||||
getWidgetIntegrationConfig(widgetKind).supportedIntegrations,
|
||||
);
|
||||
|
||||
/**
|
||||
* Throws a TRPCError FORBIDDEN if the user does not have permission to perform the specified action on at least one of the specified integrations
|
||||
|
||||
@@ -43,7 +43,7 @@ export const calendarRouter = createTRPCRouter({
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
},
|
||||
error: undefined as string | undefined,
|
||||
error: undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -11,23 +11,36 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const firewallMiddleware = createManyWidgetIntegrationMiddleware("query", "firewall");
|
||||
|
||||
const queryFirewall = <
|
||||
THandler extends {
|
||||
handler: (integration: any, input: any) => { getDataAsync: () => Promise<{ data: any; timestamp: Date }> };
|
||||
},
|
||||
>(
|
||||
handler: THandler,
|
||||
createFallback: () => Awaited<ReturnType<ReturnType<THandler["handler"]>["getDataAsync"]>>["data"],
|
||||
) =>
|
||||
type FirewallIntegration = Parameters<typeof firewallCpuRequestHandler.handler>[0];
|
||||
|
||||
interface FirewallHandler<TData> {
|
||||
handler: (
|
||||
integration: FirewallIntegration,
|
||||
input: Record<string, never>,
|
||||
) => { getDataAsync: () => Promise<{ data: TData; timestamp: Date }> };
|
||||
}
|
||||
|
||||
interface FirewallQueryResult<TData> {
|
||||
integration: {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: FirewallIntegration["kind"];
|
||||
updatedAt: Date;
|
||||
};
|
||||
summary: TData;
|
||||
error: string | undefined;
|
||||
}
|
||||
|
||||
const queryFirewall = <TData>(handler: FirewallHandler<TData>, createFallback: () => TData) =>
|
||||
publicProcedure.concat(firewallMiddleware).query(async ({ ctx }) =>
|
||||
settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
async (integration) => {
|
||||
async (integration): Promise<FirewallQueryResult<TData>> => {
|
||||
const { data, timestamp } = await handler.handler(integration, {}).getDataAsync();
|
||||
return {
|
||||
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
|
||||
summary: data as Awaited<ReturnType<ReturnType<THandler["handler"]>["getDataAsync"]>>["data"],
|
||||
error: undefined as string | undefined,
|
||||
summary: data,
|
||||
error: undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -21,7 +21,7 @@ export const indexerManagerRouter = createTRPCRouter({
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
indexers,
|
||||
error: undefined as string | undefined,
|
||||
error: undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -27,7 +27,7 @@ export const mediaOrganizerRouter = createTRPCRouter({
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
...data,
|
||||
error: undefined as string | undefined,
|
||||
error: undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -42,7 +42,7 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
return {
|
||||
integration: { id: integration.id, name: integration.name, kind: integration.kind },
|
||||
data,
|
||||
error: undefined as string | undefined,
|
||||
error: undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
@@ -98,8 +98,8 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync();
|
||||
return {
|
||||
integration: { id: integration.id, name: integration.name, kind: integration.kind },
|
||||
data: { ...data, stats: data.stats as typeof data.stats | null },
|
||||
error: undefined as string | undefined,
|
||||
data,
|
||||
error: undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -29,7 +29,7 @@ export const mediaServerRouter = createTRPCRouter({
|
||||
integrationName: integration.name,
|
||||
integrationKind: integration.kind,
|
||||
sessions: data,
|
||||
error: undefined as string | undefined,
|
||||
error: undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -22,7 +22,7 @@ export const notificationsRouter = createTRPCRouter({
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
data,
|
||||
error: undefined as string | undefined,
|
||||
error: undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -18,9 +18,9 @@ export const speedtestTrackerRouter = createTRPCRouter({
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
dashboard: data as typeof data | null,
|
||||
dashboard: data,
|
||||
updatedAt: timestamp,
|
||||
error: undefined as string | undefined,
|
||||
error: undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -18,9 +18,9 @@ export const tracearrRouter = createTRPCRouter({
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
dashboard: data as typeof data | null,
|
||||
dashboard: data,
|
||||
updatedAt: timestamp,
|
||||
error: undefined as string | undefined,
|
||||
error: undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -25,9 +25,9 @@ export const traefikRouter = createTRPCRouter({
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
dashboard: data as typeof data | null,
|
||||
dashboard: data,
|
||||
updatedAt: timestamp,
|
||||
error: undefined as string | undefined,
|
||||
error: undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -18,7 +18,7 @@ export const upsRouter = createTRPCRouter({
|
||||
integrationUrl: integration.url,
|
||||
summaries: data,
|
||||
updatedAt: timestamp,
|
||||
error: undefined as string | undefined,
|
||||
error: undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -18,9 +18,9 @@ export const uptimeKumaRouter = createTRPCRouter({
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
dashboard: data as typeof data | null,
|
||||
dashboard: data,
|
||||
updatedAt: timestamp,
|
||||
error: undefined as string | undefined,
|
||||
error: undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -19,8 +19,8 @@ export const vpnRouter = createTRPCRouter({
|
||||
kind: integration.kind,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
summary: data as typeof data | null,
|
||||
error: undefined as string | undefined,
|
||||
summary: data,
|
||||
error: undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -15,18 +15,18 @@ interface IntegrationLike {
|
||||
kind: string;
|
||||
}
|
||||
|
||||
interface Options<TIntegration extends IntegrationLike, TResult> {
|
||||
fallback?: (integration: TIntegration, error: unknown) => TResult;
|
||||
interface Options<TIntegration extends IntegrationLike, TFallback> {
|
||||
fallback?: (integration: TIntegration, error: unknown) => TFallback;
|
||||
throwOnAllFailures?: boolean;
|
||||
}
|
||||
|
||||
export async function settleIntegrationQueries<TIntegration extends IntegrationLike, TResult>(
|
||||
export async function settleIntegrationQueries<TIntegration extends IntegrationLike, TResult, TFallback = TResult>(
|
||||
integrations: TIntegration[],
|
||||
fn: (integration: TIntegration) => Promise<TResult>,
|
||||
options?: Options<TIntegration, TResult>,
|
||||
): Promise<TResult[]> {
|
||||
options?: Options<TIntegration, TFallback>,
|
||||
): Promise<(TResult | TFallback)[]> {
|
||||
const settled = await Promise.allSettled(integrations.map(async (integration) => fn(integration)));
|
||||
const results: TResult[] = [];
|
||||
const results: (TResult | TFallback)[] = [];
|
||||
const errors: unknown[] = [];
|
||||
|
||||
settled.forEach((result, index) => {
|
||||
|
||||
@@ -585,11 +585,11 @@ async function runAssistantAttempt(args: {
|
||||
process.stdout.write(` tool: ${toolCall.function.name}\n`);
|
||||
let output: unknown;
|
||||
try {
|
||||
const parsed = JSON.parse(toolCall.function.arguments) as unknown;
|
||||
const input =
|
||||
parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
const parsed: unknown = JSON.parse(toolCall.function.arguments);
|
||||
let input: Record<string, unknown> = {};
|
||||
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
input = Object.fromEntries(Object.entries(parsed));
|
||||
}
|
||||
output = executeAssistantEvaluationTool(args.testCase, state, toolCall.function.name, input);
|
||||
} catch (error) {
|
||||
output = { error: error instanceof Error ? error.message : "Tool input was not valid JSON" };
|
||||
|
||||
@@ -219,10 +219,13 @@ function redactValue(value: unknown, path: Array<string | number> = []): unknown
|
||||
}),
|
||||
);
|
||||
if (typeof value === "string") {
|
||||
const key = typeof path.at(-1) === "string" ? (path.at(-1) as string) : "";
|
||||
const lastPathPart = path.at(-1);
|
||||
let key = "";
|
||||
if (typeof lastPathPart === "string") key = lastPathPart;
|
||||
if (["sources", "requests", "options"].includes(key)) {
|
||||
try {
|
||||
return redactValue(JSON.parse(value) as unknown, path);
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return redactValue(parsed, path);
|
||||
} catch {
|
||||
/* Keep invalid editor JSON useful. */
|
||||
}
|
||||
@@ -236,7 +239,8 @@ function redactValue(value: unknown, path: Array<string | number> = []): unknown
|
||||
|
||||
function redactResponse(value: string) {
|
||||
try {
|
||||
return JSON.stringify(redactValue(JSON.parse(value) as unknown), null, 2);
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return JSON.stringify(redactValue(parsed), null, 2);
|
||||
} catch {
|
||||
return truncatePromptText(redactText(value), 1_500);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Collapsible, PaginatedList, StatBar, TabPanel, TabsContainer, TypeBadge
|
||||
import { getScopedCustomJsxControlName, isSafeCustomJsxUrl } from "./runtime-component-policy";
|
||||
import { sanitizeCustomJsxProps } from "./safe-properties";
|
||||
|
||||
type Namespace = Readonly<Record<string, unknown>>;
|
||||
type Namespace = object;
|
||||
|
||||
function resolveExport(namespace: Namespace, name: string): ComponentType<never> | undefined {
|
||||
let value: unknown = namespace;
|
||||
@@ -26,7 +26,7 @@ function resolveExport(namespace: Namespace, name: string): ComponentType<never>
|
||||
if (!value || (typeof value !== "object" && typeof value !== "function") || !Object.hasOwn(value, segment)) {
|
||||
return undefined;
|
||||
}
|
||||
value = (value as Record<string, unknown>)[segment];
|
||||
value = Reflect.get(value, segment);
|
||||
}
|
||||
return typeof value === "function" || (typeof value === "object" && value !== null)
|
||||
? (value as ComponentType<never>)
|
||||
@@ -222,9 +222,9 @@ export interface CustomJsxComponentAdapters {
|
||||
|
||||
export function createCustomJsxComponents(adapters: CustomJsxComponentAdapters): Record<string, ComponentType<never>> {
|
||||
const namespaces: Record<string, Namespace> = {
|
||||
"@mantine/core": Core as unknown as Namespace,
|
||||
"@mantine/charts": Charts as unknown as Namespace,
|
||||
"@mantine/dates": Dates as unknown as Namespace,
|
||||
"@mantine/core": Core,
|
||||
"@mantine/charts": Charts,
|
||||
"@mantine/dates": Dates,
|
||||
};
|
||||
const components: Record<string, ComponentType<never>> = {};
|
||||
for (const descriptor of enabledCustomJsxComponents) {
|
||||
@@ -242,7 +242,7 @@ export function createCustomJsxComponents(adapters: CustomJsxComponentAdapters):
|
||||
components[descriptor.name] = wrap(descriptor.name, component, additions);
|
||||
}
|
||||
}
|
||||
const core = Core as unknown as Namespace;
|
||||
const core: Namespace = Core;
|
||||
for (const name of [
|
||||
"Notification",
|
||||
"LoadingOverlay",
|
||||
|
||||
@@ -260,7 +260,8 @@ function EditorLoader() {
|
||||
function formatCode(value: string, language: "jsx" | "json" | "css") {
|
||||
if (language === "json") {
|
||||
try {
|
||||
return `${JSON.stringify(JSON.parse(value) as unknown, null, 2)}\n`;
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return `${JSON.stringify(parsed, null, 2)}\n`;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,10 @@ export const integrationSecretKindObject = {
|
||||
slug: { isPublic: true, multiline: false },
|
||||
} satisfies Record<string, { isPublic: boolean; multiline: boolean }>;
|
||||
|
||||
export const integrationSecretKinds = Object.keys(integrationSecretKindObject) as IntegrationSecretKind[];
|
||||
const isIntegrationSecretKind = (value: string): value is IntegrationSecretKind =>
|
||||
Object.hasOwn(integrationSecretKindObject, value);
|
||||
|
||||
export const integrationSecretKinds = Object.keys(integrationSecretKindObject).filter(isIntegrationSecretKind);
|
||||
|
||||
interface IntegrationFeatureMetadata {
|
||||
docker?: {
|
||||
@@ -40,7 +43,7 @@ interface IntegrationFeatureMetadata {
|
||||
};
|
||||
}
|
||||
|
||||
interface integrationDefinition {
|
||||
interface IntegrationDefinition {
|
||||
name: string;
|
||||
iconUrl: string;
|
||||
secretKinds: readonly [readonly IntegrationSecretKind[], ...(readonly IntegrationSecretKind[])[]];
|
||||
@@ -570,9 +573,17 @@ export const integrationDefs = {
|
||||
onboarding: { hidden: true },
|
||||
},
|
||||
},
|
||||
} as const satisfies Record<string, integrationDefinition>;
|
||||
} as const satisfies Record<string, IntegrationDefinition>;
|
||||
|
||||
export const integrationKinds = Object.keys(integrationDefs) as AtLeastOneOf<IntegrationKind>;
|
||||
const isIntegrationKind = (value: string): value is IntegrationKind => Object.hasOwn(integrationDefs, value);
|
||||
const integrationKindValues = Object.keys(integrationDefs).filter(isIntegrationKind);
|
||||
const firstIntegrationKind = integrationKindValues[0];
|
||||
if (!firstIntegrationKind) throw new Error("At least one integration definition is required");
|
||||
|
||||
export const integrationKinds: AtLeastOneOf<IntegrationKind> = [
|
||||
firstIntegrationKind,
|
||||
...integrationKindValues.slice(1),
|
||||
];
|
||||
|
||||
export const getIconUrl = (integration: IntegrationKind) => integrationDefs[integration].iconUrl;
|
||||
|
||||
@@ -607,7 +618,7 @@ export const getIntegrationDocumentationUrl = (kind: IntegrationKind): string |
|
||||
};
|
||||
|
||||
export const getIntegrationDockerMetadata = (kind: IntegrationKind) => {
|
||||
const definition: integrationDefinition = integrationDefs[kind];
|
||||
const definition: IntegrationDefinition = integrationDefs[kind];
|
||||
return {
|
||||
aliases: definition.features?.docker?.aliases ?? [],
|
||||
discoverable: definition.features?.docker?.discoverable ?? true,
|
||||
@@ -615,7 +626,7 @@ export const getIntegrationDockerMetadata = (kind: IntegrationKind) => {
|
||||
};
|
||||
|
||||
export const getIntegrationOnboardingMetadata = (kind: IntegrationKind) => {
|
||||
const definition: integrationDefinition = integrationDefs[kind];
|
||||
const definition: IntegrationDefinition = integrationDefs[kind];
|
||||
return {
|
||||
featuredOrder: definition.features?.onboarding?.featuredOrder ?? null,
|
||||
hidden: definition.features?.onboarding?.hidden ?? false,
|
||||
@@ -646,9 +657,12 @@ export const getIntegrationApiKeyUrl = (integrationUrl: string, kind: Integratio
|
||||
* @returns Partial list of integration kinds
|
||||
*/
|
||||
export const getIntegrationKindsByCategory = <TCategory extends IntegrationCategory>(category: TCategory) => {
|
||||
return (Object.keys(integrationDefs) as IntegrationKind[]).filter((integration) =>
|
||||
integrationDefs[integration].category.some((defCategory) => defCategory === category),
|
||||
) as AtLeastOneOf<IntegrationKindByCategory<TCategory>>;
|
||||
const matchesCategory = (integration: IntegrationKind): integration is IntegrationKindByCategory<TCategory> =>
|
||||
integrationDefs[integration].category.some((definitionCategory) => definitionCategory === category);
|
||||
const matches = integrationKinds.filter(matchesCategory);
|
||||
const firstMatch = matches[0];
|
||||
if (!firstMatch) throw new Error(`No integration definition uses the ${category} category`);
|
||||
return [firstMatch, ...matches.slice(1)] satisfies AtLeastOneOf<IntegrationKindByCategory<TCategory>>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -658,10 +672,8 @@ export type IntegrationKindByCategory<TCategory extends IntegrationCategory> = {
|
||||
[Key in keyof typeof integrationDefs]: TCategory extends (typeof integrationDefs)[Key]["category"][number]
|
||||
? Key
|
||||
: never;
|
||||
}[keyof typeof integrationDefs] extends infer U
|
||||
? //Needed to simplify the type when using it
|
||||
U
|
||||
: never;
|
||||
}[keyof typeof integrationDefs] &
|
||||
IntegrationKind;
|
||||
|
||||
export type IntegrationSecretKind = keyof typeof integrationSecretKindObject;
|
||||
export type IntegrationKind = keyof typeof integrationDefs;
|
||||
|
||||
@@ -14,6 +14,8 @@ import { integrationJsonParseErrorHandler, integrationZodParseErrorHandler } fro
|
||||
const logger = createLogger({ module: "handleIntegrationErrors" });
|
||||
const loggedIntegrationErrors = new WeakSet<IntegrationError>();
|
||||
|
||||
// ponytail: TypeScript requires `any[]` for mixin constructors (TS2545). The
|
||||
// decorator returns the same constructor type T, so callers retain exact args.
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type, @typescript-eslint/no-explicit-any
|
||||
type AbstractConstructor<T = {}> = abstract new (...args: any[]) => T;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { randomUUID } from "crypto";
|
||||
import type { MaybePromise } from "@homarr/common/types";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
import { createRedisConnection } from "./connection";
|
||||
import { createRedisConnection, requireRedisConnection } from "./connection";
|
||||
|
||||
const logger = createLogger({ module: "channelSubscriptionTracker" });
|
||||
|
||||
@@ -63,7 +63,9 @@ export class ChannelSubscriptionTracker {
|
||||
// Subscribe when this channel has no active or pending Redis subscription.
|
||||
if (!ready) {
|
||||
logger.debug("Subscribing to redis channel", { channel: channelName });
|
||||
ready = this.redis.subscribe(channelName).then(() => undefined);
|
||||
ready = requireRedisConnection(this.redis)
|
||||
.subscribe(channelName)
|
||||
.then(() => undefined);
|
||||
this.subscriptionReadiness.set(channelName, ready);
|
||||
void ready.catch(() => {
|
||||
if (this.subscriptionReadiness.get(channelName) === ready) {
|
||||
@@ -91,7 +93,7 @@ export class ChannelSubscriptionTracker {
|
||||
}
|
||||
|
||||
logger.debug("Unsubscribing from redis channel", { channel: channelName });
|
||||
void this.redis.unsubscribe(channelName);
|
||||
void requireRedisConnection(this.redis).unsubscribe(channelName);
|
||||
this.subscriptions.delete(channelName);
|
||||
this.subscriptionReadiness.delete(channelName);
|
||||
};
|
||||
@@ -104,7 +106,7 @@ export class ChannelSubscriptionTracker {
|
||||
*/
|
||||
private static activateListener() {
|
||||
logger.debug("Activating listener");
|
||||
this.redis.on("message", (channel, message) => {
|
||||
requireRedisConnection(this.redis).on("message", (channel, message) => {
|
||||
const channelSubscriptions = this.subscriptions.get(channel);
|
||||
if (!channelSubscriptions) {
|
||||
logger.warn("Received message on unknown channel", { channel });
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
|
||||
import { ChannelSubscriptionTracker } from "./channel-subscription-tracker";
|
||||
import { createRedisConnection } from "./connection";
|
||||
import { createRedisConnection, requireRedisConnection } from "./connection";
|
||||
|
||||
const publisher = createRedisConnection();
|
||||
const lastDataClient = createRedisConnection();
|
||||
@@ -26,11 +26,13 @@ export const createSubPubChannel = <TData>(name: string, { persist }: { persist:
|
||||
*/
|
||||
subscribe: (callback: (data: TData) => void) => {
|
||||
if (persist) {
|
||||
void lastDataClient.get(lastChannelName).then((data) => {
|
||||
if (data) {
|
||||
callback(superjson.parse(data));
|
||||
}
|
||||
});
|
||||
void requireRedisConnection(lastDataClient)
|
||||
.get(lastChannelName)
|
||||
.then((data) => {
|
||||
if (data) {
|
||||
callback(superjson.parse(data));
|
||||
}
|
||||
});
|
||||
}
|
||||
return ChannelSubscriptionTracker.subscribe(channelName, (message) => {
|
||||
callback(superjson.parse(message));
|
||||
@@ -42,12 +44,12 @@ export const createSubPubChannel = <TData>(name: string, { persist }: { persist:
|
||||
*/
|
||||
publishAsync: async (data: TData) => {
|
||||
if (persist) {
|
||||
await lastDataClient.set(lastChannelName, superjson.stringify(data));
|
||||
await requireRedisConnection(lastDataClient).set(lastChannelName, superjson.stringify(data));
|
||||
}
|
||||
await publisher.publish(channelName, superjson.stringify(data));
|
||||
await requireRedisConnection(publisher).publish(channelName, superjson.stringify(data));
|
||||
},
|
||||
getLastDataAsync: async () => {
|
||||
const data = await lastDataClient.get(lastChannelName);
|
||||
const data = await requireRedisConnection(lastDataClient).get(lastChannelName);
|
||||
return data ? superjson.parse<TData>(data) : null;
|
||||
},
|
||||
};
|
||||
@@ -68,7 +70,7 @@ export const createListChannel = <TItem>(name: string) => {
|
||||
* @returns an array of all items
|
||||
*/
|
||||
getAllAsync: async () => {
|
||||
const items = await getSetClient.lrange(listChannelName, 0, -1);
|
||||
const items = await requireRedisConnection(getSetClient).lrange(listChannelName, 0, -1);
|
||||
return items.map((item) => superjson.parse<TItem>(item));
|
||||
},
|
||||
/**
|
||||
@@ -76,20 +78,20 @@ export const createListChannel = <TItem>(name: string) => {
|
||||
* @param item item to remove
|
||||
*/
|
||||
removeAsync: async (item: TItem) => {
|
||||
await getSetClient.lrem(listChannelName, 0, superjson.stringify(item));
|
||||
await requireRedisConnection(getSetClient).lrem(listChannelName, 0, superjson.stringify(item));
|
||||
},
|
||||
/**
|
||||
* Clear all items from the channels list
|
||||
*/
|
||||
clearAsync: async () => {
|
||||
await getSetClient.del(listChannelName);
|
||||
await requireRedisConnection(getSetClient).del(listChannelName);
|
||||
},
|
||||
/**
|
||||
* Add an item to the channels list
|
||||
* @param item item to add
|
||||
*/
|
||||
addAsync: async (item: TItem) => {
|
||||
await getSetClient.lpush(listChannelName, superjson.stringify(item));
|
||||
await requireRedisConnection(getSetClient).lpush(listChannelName, superjson.stringify(item));
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -105,7 +107,7 @@ export const createGetSetChannel = <TData>(name: string) => {
|
||||
* @returns data or null if not found
|
||||
*/
|
||||
getAsync: async () => {
|
||||
const data = await getSetClient.get(name);
|
||||
const data = await requireRedisConnection(getSetClient).get(name);
|
||||
return data ? superjson.parse<TData>(data) : null;
|
||||
},
|
||||
/**
|
||||
@@ -116,23 +118,23 @@ export const createGetSetChannel = <TData>(name: string) => {
|
||||
setAsync: async (data: TData, options?: { ttlSeconds?: number; ttlMs?: number }) => {
|
||||
if (options?.ttlMs !== undefined) {
|
||||
if (options.ttlMs <= 0) {
|
||||
await getSetClient.del(name);
|
||||
await requireRedisConnection(getSetClient).del(name);
|
||||
return;
|
||||
}
|
||||
await getSetClient.set(name, superjson.stringify(data), "PX", options.ttlMs);
|
||||
await requireRedisConnection(getSetClient).set(name, superjson.stringify(data), "PX", options.ttlMs);
|
||||
return;
|
||||
}
|
||||
if (options?.ttlSeconds) {
|
||||
await getSetClient.set(name, superjson.stringify(data), "EX", options.ttlSeconds);
|
||||
await requireRedisConnection(getSetClient).set(name, superjson.stringify(data), "EX", options.ttlSeconds);
|
||||
return;
|
||||
}
|
||||
await getSetClient.set(name, superjson.stringify(data));
|
||||
await requireRedisConnection(getSetClient).set(name, superjson.stringify(data));
|
||||
},
|
||||
/**
|
||||
* Remove data from the channel
|
||||
*/
|
||||
removeAsync: async () => {
|
||||
await getSetClient.del(name);
|
||||
await requireRedisConnection(getSetClient).del(name);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -147,14 +149,14 @@ export const createLockChannel = (name: string) => {
|
||||
return {
|
||||
acquireAsync: async (ttlSeconds: number) => {
|
||||
const token = createId();
|
||||
const client = getSetClient as typeof getSetClient | null;
|
||||
const client = getSetClient;
|
||||
if (!client) return token;
|
||||
|
||||
const result = await client.set(name, token, "EX", ttlSeconds, "NX");
|
||||
return result === "OK" ? token : null;
|
||||
},
|
||||
releaseAsync: async (token: string) => {
|
||||
const client = getSetClient as typeof getSetClient | null;
|
||||
const client = getSetClient;
|
||||
if (!client) return;
|
||||
|
||||
await client.eval(
|
||||
@@ -286,7 +288,7 @@ const getCachedGenerationAsync = async ({
|
||||
};
|
||||
|
||||
const advanceIntegrationCacheGenerationAsync = async (integrationId: string) => {
|
||||
const client = getSetClient as typeof getSetClient | null;
|
||||
const client = getSetClient;
|
||||
if (!client) return null;
|
||||
|
||||
const results = await withCacheGenerationRedisTimeoutAsync(
|
||||
@@ -304,7 +306,7 @@ const advanceIntegrationCacheGenerationAsync = async (integrationId: string) =>
|
||||
};
|
||||
|
||||
const advanceIntegrationResponseCacheGenerationAsync = async (integrationId: string) => {
|
||||
const client = getSetClient as typeof getSetClient | null;
|
||||
const client = getSetClient;
|
||||
if (!client) return null;
|
||||
|
||||
return String(await withCacheGenerationRedisTimeoutAsync(client.incr(integrationCacheGenerationKey(integrationId))));
|
||||
@@ -354,7 +356,7 @@ export const getIntegrationCacheGenerationAsync = async (
|
||||
return generation;
|
||||
}
|
||||
|
||||
const client = getSetClient as typeof getSetClient | null;
|
||||
const client = getSetClient;
|
||||
return await getCachedGenerationAsync({
|
||||
cacheKey,
|
||||
readAsync: async () => {
|
||||
@@ -418,7 +420,7 @@ const pendingWidgetCacheInvalidations = new Map<string, string>();
|
||||
const MAX_PENDING_WIDGET_CACHE_INVALIDATIONS = 1000;
|
||||
|
||||
const advanceWidgetCacheGenerationAsync = async (namespace: string) => {
|
||||
const client = getSetClient as typeof getSetClient | null;
|
||||
const client = getSetClient;
|
||||
if (!client) return null;
|
||||
|
||||
return String(await withCacheGenerationRedisTimeoutAsync(client.incr(widgetCacheGenerationKey(namespace))));
|
||||
@@ -446,7 +448,7 @@ export const getWidgetCacheGenerationAsync = async (namespace: string): Promise<
|
||||
return generation;
|
||||
}
|
||||
|
||||
const client = getSetClient as typeof getSetClient | null;
|
||||
const client = getSetClient;
|
||||
return await getCachedGenerationAsync({
|
||||
cacheKey,
|
||||
readAsync: async () => {
|
||||
@@ -488,11 +490,11 @@ export const invalidateWidgetCache = (namespace: string): void => {
|
||||
*/
|
||||
export const createChannelEventHistoryOld = <TData>(channelName: string, maxElements = 15) => {
|
||||
const popElementsOverMaxAsync = async () => {
|
||||
const length = await getSetClient.llen(channelName);
|
||||
const length = await requireRedisConnection(getSetClient).llen(channelName);
|
||||
if (length <= maxElements) {
|
||||
return;
|
||||
}
|
||||
await getSetClient.ltrim(channelName, 0, maxElements - 1);
|
||||
await requireRedisConnection(getSetClient).ltrim(channelName, 0, maxElements - 1);
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -502,33 +504,41 @@ export const createChannelEventHistoryOld = <TData>(channelName: string, maxElem
|
||||
});
|
||||
},
|
||||
publishAndPushAsync: async (data: TData) => {
|
||||
await publisher.publish(channelName, superjson.stringify(data));
|
||||
await getSetClient.lpush(channelName, superjson.stringify({ data, timestamp: new Date() }));
|
||||
await requireRedisConnection(publisher).publish(channelName, superjson.stringify(data));
|
||||
await requireRedisConnection(getSetClient).lpush(
|
||||
channelName,
|
||||
superjson.stringify({ data, timestamp: new Date() }),
|
||||
);
|
||||
await popElementsOverMaxAsync();
|
||||
},
|
||||
pushAsync: async (data: TData) => {
|
||||
await getSetClient.lpush(channelName, superjson.stringify({ data, timestamp: new Date() }));
|
||||
await requireRedisConnection(getSetClient).lpush(
|
||||
channelName,
|
||||
superjson.stringify({ data, timestamp: new Date() }),
|
||||
);
|
||||
await popElementsOverMaxAsync();
|
||||
},
|
||||
clearAsync: async () => {
|
||||
await getSetClient.del(channelName);
|
||||
await requireRedisConnection(getSetClient).del(channelName);
|
||||
},
|
||||
getLastAsync: async () => {
|
||||
const length = await getSetClient.llen(channelName);
|
||||
const data = await getSetClient.lrange(channelName, length - 1, length);
|
||||
const client = requireRedisConnection(getSetClient);
|
||||
const length = await client.llen(channelName);
|
||||
const data = await client.lrange(channelName, length - 1, length);
|
||||
if (data.length !== 1) return null;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return superjson.parse<{ data: TData; timestamp: Date }>(data[0]!);
|
||||
},
|
||||
getSliceAsync: async (startIndex: number, endIndex: number) => {
|
||||
const range = await getSetClient.lrange(channelName, startIndex, endIndex);
|
||||
const range = await requireRedisConnection(getSetClient).lrange(channelName, startIndex, endIndex);
|
||||
return range.map((item) => superjson.parse<{ data: TData; timestamp: Date }>(item));
|
||||
},
|
||||
getSliceUntilTimeAsync: async (time: Date) => {
|
||||
const length = await getSetClient.llen(channelName);
|
||||
const client = requireRedisConnection(getSetClient);
|
||||
const length = await client.llen(channelName);
|
||||
const items: TData[] = [];
|
||||
const itemsInCollection = await getSetClient.lrange(channelName, 0, length - 1);
|
||||
const itemsInCollection = await client.lrange(channelName, 0, length - 1);
|
||||
|
||||
for (let i = 0; i < length - 1; i++) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
@@ -541,7 +551,7 @@ export const createChannelEventHistoryOld = <TData>(channelName: string, maxElem
|
||||
return items;
|
||||
},
|
||||
getLengthAsync: async () => {
|
||||
return await getSetClient.llen(channelName);
|
||||
return await requireRedisConnection(getSetClient).llen(channelName);
|
||||
},
|
||||
name: channelName,
|
||||
};
|
||||
@@ -559,11 +569,11 @@ type WithId<TItem> = TItem & { _id: string };
|
||||
export const createQueueChannel = <TItem>(name: string) => {
|
||||
const queueChannelName = `queue:${name}`;
|
||||
const getDataAsync = async () => {
|
||||
const data = await queueClient.get(queueChannelName);
|
||||
const data = await requireRedisConnection(queueClient).get(queueChannelName);
|
||||
return data ? superjson.parse<WithId<TItem>[]>(data) : [];
|
||||
};
|
||||
const setDataAsync = async (data: WithId<TItem>[]) => {
|
||||
await queueClient.set(queueChannelName, superjson.stringify(data));
|
||||
await requireRedisConnection(queueClient).set(queueChannelName, superjson.stringify(data));
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -610,5 +620,5 @@ export const createQueueChannel = <TItem>(name: string) => {
|
||||
};
|
||||
|
||||
export const handshakeAsync = async () => {
|
||||
await getSetClient.hello();
|
||||
await requireRedisConnection(getSetClient).hello();
|
||||
};
|
||||
@@ -7,9 +7,13 @@ import { createRedisClient } from "@homarr/core/infrastructure/redis";
|
||||
*/
|
||||
export const createRedisConnection = () => {
|
||||
if (Boolean(process.env.CI) || Boolean(process.env.DISABLE_REDIS_LOGS)) {
|
||||
// Return null if we are in CI as we don't want to connect to Redis
|
||||
return null as unknown as RedisClient;
|
||||
return null;
|
||||
}
|
||||
|
||||
return createRedisClient();
|
||||
};
|
||||
|
||||
export const requireRedisConnection = (client: RedisClient | null): RedisClient => {
|
||||
if (!client) throw new Error("Redis is unavailable in this process");
|
||||
return client;
|
||||
};
|
||||
@@ -18,14 +18,16 @@ const logger = createLogger({ module: "sharedRequestCache" });
|
||||
const REDIS_OPERATION_TIMEOUT_MS = 250;
|
||||
const REFRESH_LOCK_TTL_SECONDS = 15;
|
||||
const namespacePattern = /^[a-z0-9][a-z0-9:-]*$/;
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
const canonicalize = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) return value.map(canonicalize);
|
||||
if (!value || typeof value !== "object") return value;
|
||||
if (!isRecord(value)) return value;
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(value).toSorted()) {
|
||||
result[key] = canonicalize((value as Record<string, unknown>)[key]);
|
||||
result[key] = canonicalize(value[key]);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -46,14 +48,13 @@ const withTimeoutAsync = async <T>(promise: Promise<T>, durationMs: number): Pro
|
||||
};
|
||||
|
||||
const isCacheEntry = (value: unknown): value is CacheEntry<unknown> => {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const entry = value as Partial<CacheEntry<unknown>>;
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
entry.timestamp instanceof Date &&
|
||||
typeof entry.expiresAt === "number" &&
|
||||
Number.isFinite(entry.expiresAt) &&
|
||||
typeof entry.staleUntil === "number" &&
|
||||
Number.isFinite(entry.staleUntil)
|
||||
value.timestamp instanceof Date &&
|
||||
typeof value.expiresAt === "number" &&
|
||||
Number.isFinite(value.expiresAt) &&
|
||||
typeof value.staleUntil === "number" &&
|
||||
Number.isFinite(value.staleUntil)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -94,7 +95,7 @@ const createRedisSharedCache = <TData>({
|
||||
isShared: initialIsShared = true,
|
||||
}: RedisSharedCacheOptions): SharedCacheAdapter<TData> => {
|
||||
const logMetadata = { cacheNamespace: namespace, cacheKind, ...metadata };
|
||||
const payloadChannel = createGetSetChannel<unknown>(
|
||||
const payloadChannel = createGetSetChannel<CacheEntry<TData>>(
|
||||
`${cacheKind}-cache:payload:v1:${namespace}:${generation}:${cacheIdentity}`,
|
||||
);
|
||||
const lock = createLockChannel(`${cacheKind}-cache:lock:v1:${namespace}:${generation}:${cacheIdentity}`);
|
||||
@@ -119,7 +120,7 @@ const createRedisSharedCache = <TData>({
|
||||
getAsync: async () => {
|
||||
const value = await runRedisOperationAsync("payload-read", () => payloadChannel.getAsync(), undefined);
|
||||
if (value === undefined || value === null) return value;
|
||||
if (isCacheEntry(value)) return value as CacheEntry<TData>;
|
||||
if (isCacheEntry(value)) return value;
|
||||
|
||||
logger.warn("Ignored invalid Redis response cache entry", {
|
||||
...logMetadata,
|
||||
|
||||
@@ -2567,7 +2567,7 @@
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"streams": "{count, plural, one {# active stream} other {# active streams}}",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
|
||||
@@ -74,10 +74,9 @@ const loadRegisteredWidgetModule = createRetryableLoader(
|
||||
new Map<WidgetKind, () => Promise<RegisteredWidgetModule>>(objectEntries(widgetModuleLoaders)),
|
||||
);
|
||||
|
||||
// ponytail: TypeScript cannot express a Map whose value depends on its key. This
|
||||
// private assertion restores only the fixed shapes above; the manifest alignment
|
||||
// test checks module.definition.kind. Its ceiling is runtime-only mismatch
|
||||
// detection, so remove it if TypeScript gains dependent Map value types.
|
||||
// ponytail: TypeScript cannot correlate a generic key with the matching value
|
||||
// in a Map. The registry itself is checked against WidgetImports, so this
|
||||
// assertion only restores that already-verified relationship after caching.
|
||||
const correlateWidgetPromise = <TKind extends WidgetKind, TType extends keyof WidgetManifestValue<TKind>>(
|
||||
_kind: TKind,
|
||||
_type: TType,
|
||||
|
||||
@@ -425,9 +425,7 @@ export default function MediaServerWidget({
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<IconVideo size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} />
|
||||
<Text size="sm" fw={500} style={{ whiteSpace: "nowrap" }}>
|
||||
{(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", {
|
||||
count: flatSessions.length,
|
||||
})}
|
||||
{t("footer.streams", { count: flatSessions.length })}
|
||||
</Text>
|
||||
{totalBitrateLabel && (isAdvanced || width >= 300) && (
|
||||
<>
|
||||
|
||||
@@ -60,11 +60,6 @@ import type * as vpn from "./vpn";
|
||||
import type * as weather from "./weather";
|
||||
import type * as wud from "./wud";
|
||||
|
||||
type WidgetModule = {
|
||||
definition: unknown;
|
||||
componentLoader: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
// Keep these imports explicit so Next.js and Turbopack can discover every widget
|
||||
// module without loading any widget definition or component eagerly.
|
||||
export const widgetModuleLoaders = {
|
||||
@@ -127,7 +122,7 @@ export const widgetModuleLoaders = {
|
||||
customApi: () => import("./custom-api"),
|
||||
weather: () => import("./weather"),
|
||||
wud: () => import("./wud"),
|
||||
} satisfies Record<WidgetKind, () => Promise<WidgetModule>>;
|
||||
} satisfies { [TKind in WidgetKind]: () => Promise<WidgetImports[TKind]> };
|
||||
|
||||
export type WidgetImports = {
|
||||
clock: typeof clock;
|
||||
|
||||
Reference in new issue
Block a user