Merge remote-tracking branch 'origin/dev' into feat/add-integration-from-dashboard
# Conflicts: # packages/db/migrations/seed.ts
This commit is contained in:
34 files changed
+1280
-2200
No files matched your search
@@ -5,3 +5,26 @@ export const capitalize = <T extends string>(str: T) => {
|
||||
export const isNullOrWhitespace = (value: string | null): value is null => {
|
||||
return value == null || value.trim() === "";
|
||||
};
|
||||
|
||||
export const bestMatch = <TItem extends Record<string, unknown>>(
|
||||
search: string,
|
||||
options: TItem[],
|
||||
by: (item: TItem) => string,
|
||||
) => {
|
||||
if (options.length === 0) return null;
|
||||
|
||||
const normalizedSearch = search.toLowerCase().trim();
|
||||
|
||||
return options.reduce<TItem | null>((best, current) => {
|
||||
const currentMatchIndex = by(current).toLowerCase().indexOf(normalizedSearch);
|
||||
|
||||
if (currentMatchIndex === -1) return best;
|
||||
if (best === null) return current;
|
||||
|
||||
const bestMatchIndex = by(best).toLowerCase().indexOf(normalizedSearch);
|
||||
|
||||
if (currentMatchIndex < bestMatchIndex) return current;
|
||||
if (by(current).length < by(best).length && currentMatchIndex === bestMatchIndex) return current;
|
||||
return best;
|
||||
}, null);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { capitalize } from "../string";
|
||||
import { bestMatch, capitalize } from "../string";
|
||||
|
||||
const capitalizeTestCases = [
|
||||
["hello", "Hello"],
|
||||
@@ -12,8 +12,45 @@ const capitalizeTestCases = [
|
||||
|
||||
describe("capitalize should capitalize the first letter of a string", () => {
|
||||
capitalizeTestCases.forEach(([input, expected]) => {
|
||||
it(`should capitalize ${input} to ${expected}`, () => {
|
||||
test(`should capitalize ${input} to ${expected}`, () => {
|
||||
expect(capitalize(input)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("bestMatch should find the best match in an array of options", () => {
|
||||
test("should find exact match for 'nginx'", () => {
|
||||
const options = [
|
||||
{ name: "my-nginx" },
|
||||
{ name: "nginx-proxy" },
|
||||
{ name: "nginx" },
|
||||
{ name: "redis" },
|
||||
{ name: "postgres" },
|
||||
];
|
||||
|
||||
const result = bestMatch("nginx", options, ({ name }) => name);
|
||||
|
||||
expect(result).toEqual({ name: "nginx" });
|
||||
});
|
||||
test("should find closest match for 'nginx' when exact match is not available", () => {
|
||||
const options = [{ name: "redis" }, { name: "nginx-proxy" }, { name: "my-nginx" }, { name: "postgres" }];
|
||||
|
||||
const result = bestMatch("nginx", options, ({ name }) => name);
|
||||
|
||||
expect(result).toEqual({ name: "nginx-proxy" });
|
||||
});
|
||||
test("should return null if no match is found", () => {
|
||||
const options = [{ name: "redis" }, { name: "postgres" }];
|
||||
|
||||
const result = bestMatch("nginx", options, ({ name }) => name);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
test("should return null if options array is empty", () => {
|
||||
const options: { name: string }[] = [];
|
||||
|
||||
const result = bestMatch("nginx", options, ({ name }) => name);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getPortFromUrl } from "../url";
|
||||
import { getPortFromUrl, isAbsoluteUrl } from "../url.js";
|
||||
|
||||
describe("getPortFromUrl", () => {
|
||||
test.each([
|
||||
@@ -38,3 +38,21 @@ describe("getPortFromUrl", () => {
|
||||
expect(act).toThrowError("Unsupported protocol: ftp:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAbsoluteUrl", () => {
|
||||
test.each([
|
||||
["http://example.com", true],
|
||||
["https://example.com", true],
|
||||
["ftp://example.com", true],
|
||||
["file:///path/to/file", true],
|
||||
["mailto:johndoe@example.com", true],
|
||||
["/relative/path", false],
|
||||
["relative/path", false],
|
||||
])("should return %s for URL: %s", (urlString, expected) => {
|
||||
// Act
|
||||
const result = isAbsoluteUrl(urlString);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -38,3 +38,9 @@ export const getPortFromUrl = (url: URL): number => {
|
||||
|
||||
throw new Error(`Unsupported protocol: ${url.protocol}`);
|
||||
};
|
||||
|
||||
const absoluteUrlRegex = /^[a-z]+:(\/\/)?/;
|
||||
|
||||
export const isAbsoluteUrl = (urlOrPath: string): boolean => {
|
||||
return absoluteUrlRegex.test(urlOrPath.toLowerCase());
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createId, objectKeys } from "@homarr/common";
|
||||
import {
|
||||
createDocumentationLink,
|
||||
credentialsAdminGroup,
|
||||
defaultBookmarkApps,
|
||||
everyoneGroup,
|
||||
getIntegrationDefaultUrl,
|
||||
@@ -12,12 +13,25 @@ import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server
|
||||
|
||||
import type { Database } from "..";
|
||||
import { eq } from "..";
|
||||
import { getMaxGroupPositionAsync } from "../queries";
|
||||
import {
|
||||
getServerSettingByKeyAsync,
|
||||
insertServerSettingByKeyAsync,
|
||||
updateServerSettingByKeyAsync,
|
||||
} from "../queries/server-setting";
|
||||
import { apps, boards, groups, integrations, layouts, onboarding, searchEngines, sections } from "../schema";
|
||||
import {
|
||||
apps,
|
||||
boards,
|
||||
groupMembers,
|
||||
groupPermissions,
|
||||
groups,
|
||||
integrations,
|
||||
layouts,
|
||||
onboarding,
|
||||
searchEngines,
|
||||
sections,
|
||||
users,
|
||||
} from "../schema";
|
||||
import type { Integration } from "../schema";
|
||||
|
||||
export const seedDataAsync = async (db: Database) => {
|
||||
@@ -28,6 +42,10 @@ export const seedDataAsync = async (db: Database) => {
|
||||
await seedDefaultIntegrationsAsync(db);
|
||||
await seedDefaultAppsAsync(db);
|
||||
await seedDefaultBoardAsync(db);
|
||||
|
||||
if (process.env.DEMO_MODE === "true") {
|
||||
await seedDemoUserAsync(db);
|
||||
}
|
||||
};
|
||||
|
||||
const seedEveryoneGroupAsync = async (db: Database) => {
|
||||
@@ -252,3 +270,72 @@ const seedDefaultBoardAsync = async (db: Database) => {
|
||||
|
||||
console.log("Created default board 'dashboard' through seed");
|
||||
};
|
||||
|
||||
const seedDemoUserAsync = async (db: Database) => {
|
||||
const existingUser = await db.query.users.findFirst({
|
||||
where: eq(users.name, "demo"),
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
console.log("Skipping seeding of demo user as it already exists");
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = createId();
|
||||
|
||||
await db.insert(users).values({
|
||||
id: userId,
|
||||
name: "demo",
|
||||
email: "demo@example.com",
|
||||
password: "$2b$10$c5AaQb0oj32/bui5Z55PyOb7pY.iEkXYqgdD8MjS9LjSjLMeOkZu2",
|
||||
});
|
||||
|
||||
const maxPosition = await getMaxGroupPositionAsync(db);
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: credentialsAdminGroup,
|
||||
ownerId: userId,
|
||||
position: maxPosition + 1,
|
||||
});
|
||||
await db.insert(groupPermissions).values({
|
||||
groupId,
|
||||
permission: "admin",
|
||||
});
|
||||
await db.insert(groupMembers).values({
|
||||
groupId,
|
||||
userId,
|
||||
});
|
||||
|
||||
await db.update(onboarding).set({
|
||||
step: "finish",
|
||||
previousStep: "settings",
|
||||
});
|
||||
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
name: "default",
|
||||
isPublic: false,
|
||||
creatorId: userId,
|
||||
});
|
||||
await db.insert(sections).values({
|
||||
id: createId(),
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
boardId,
|
||||
});
|
||||
await db.insert(layouts).values({
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
breakpoint: 0,
|
||||
boardId,
|
||||
});
|
||||
await db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, userId));
|
||||
|
||||
console.log(
|
||||
"Your instance has enabled demo mode. Homarr therefore created example data for your demo. You can disable demo mode anytime by setting the env 'DEMO_MODE' to 'false' or unset it.",
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isAbsoluteUrl } from "@homarr/common";
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { ImageProxy } from "@homarr/image-proxy";
|
||||
@@ -85,10 +86,10 @@ export class TracearrIntegration extends Integration {
|
||||
data: await Promise.all(
|
||||
json.data.map(async (stream) => {
|
||||
if (stream.userAvatarUrl) {
|
||||
stream.userAvatarUrl = await this.proxyImageAsync(imageProxy, stream.userAvatarUrl, stream.id, "avatar");
|
||||
stream.userAvatarUrl = await this.proxyImageAsync(imageProxy, stream.userAvatarUrl);
|
||||
}
|
||||
if (stream.posterUrl) {
|
||||
stream.posterUrl = await this.proxyImageAsync(imageProxy, stream.posterUrl, stream.id, "poster");
|
||||
stream.posterUrl = await this.proxyImageAsync(imageProxy, stream.posterUrl);
|
||||
}
|
||||
return stream;
|
||||
}),
|
||||
@@ -120,12 +121,7 @@ export class TracearrIntegration extends Integration {
|
||||
...json,
|
||||
data: await Promise.all(
|
||||
json.data.map(async (violation) => {
|
||||
violation.user.avatarUrl = await this.proxyImageAsync(
|
||||
imageProxy,
|
||||
violation.user.avatarUrl,
|
||||
violation.user.id,
|
||||
"avatar",
|
||||
);
|
||||
violation.user.avatarUrl = await this.proxyImageAsync(imageProxy, violation.user.avatarUrl);
|
||||
return violation;
|
||||
}),
|
||||
),
|
||||
@@ -156,12 +152,7 @@ export class TracearrIntegration extends Integration {
|
||||
...json,
|
||||
data: await Promise.all(
|
||||
json.data.map(async (session) => {
|
||||
session.user.avatarUrl = await this.proxyImageAsync(
|
||||
imageProxy,
|
||||
session.user.avatarUrl,
|
||||
session.user.id,
|
||||
"avatar",
|
||||
);
|
||||
session.user.avatarUrl = await this.proxyImageAsync(imageProxy, session.user.avatarUrl);
|
||||
return session;
|
||||
}),
|
||||
),
|
||||
@@ -194,23 +185,12 @@ export class TracearrIntegration extends Integration {
|
||||
};
|
||||
}
|
||||
|
||||
private async proxyImageAsync(
|
||||
imageProxy: ImageProxy,
|
||||
url: string | null | undefined,
|
||||
uniqueId: string,
|
||||
discriminator: string,
|
||||
): Promise<string | null> {
|
||||
if (!url) return null;
|
||||
private async proxyImageAsync(imageProxy: ImageProxy, urlOrPath: string | null | undefined): Promise<string | null> {
|
||||
if (!urlOrPath) return null;
|
||||
try {
|
||||
// ImageProxy doesn't support fallback urls so we need to remove them
|
||||
const cleanUrl = url.replace(/&fallback=[^&]+/, "").replace(/\?fallback=[^&]+&?/, "?");
|
||||
// Build the full URL, then inject _uid as the FIRST query param.
|
||||
// This is critical because ImageProxy uses bcrypt which truncates at 72 bytes.
|
||||
// Without this, all long avatar/poster URLs from the same session hash identically
|
||||
// since they share the same first 72+ bytes.
|
||||
const baseUrl = this.url(cleanUrl as `/${string}`).toString();
|
||||
const prefix = `_uid=${discriminator}_${uniqueId}&`;
|
||||
const fullUrl = baseUrl.includes("?") ? baseUrl.replace("?", `?${prefix}`) : `${baseUrl}?${prefix}`;
|
||||
const cleanUrl = urlOrPath.replace(/&fallback=[^&]+/, "").replace(/\?fallback=[^&]+&?/, "?");
|
||||
const fullUrl = isAbsoluteUrl(urlOrPath) ? cleanUrl : this.url(cleanUrl as `/${string}`).toString();
|
||||
|
||||
return await imageProxy.createImageAsync(fullUrl, this.getAuthHeaders());
|
||||
} catch {
|
||||
|
||||
@@ -2,6 +2,7 @@ import dayjs from "dayjs";
|
||||
import type { ContainerInfo, ContainerStats } from "dockerode";
|
||||
import type Dockerode from "dockerode";
|
||||
|
||||
import { bestMatch } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { db, like, or } from "@homarr/db";
|
||||
@@ -82,12 +83,7 @@ async function getContainersWithStatsAsync() {
|
||||
name: container.Names[0]?.split("/")[1] ?? "Unknown",
|
||||
host: container.instance,
|
||||
state: container.State as ContainerState,
|
||||
iconUrl:
|
||||
dbIcons.find((icon) => {
|
||||
const extractedImage = extractImage(container);
|
||||
if (!extractedImage) return false;
|
||||
return icon.name.toLowerCase().includes(extractedImage.toLowerCase());
|
||||
})?.url ?? null,
|
||||
iconUrl: bestMatch(extractImage(container), dbIcons, (icon) => icon.name)?.url ?? null,
|
||||
cpuUsage,
|
||||
memoryUsage,
|
||||
image: container.Image,
|
||||
|
||||
@@ -159,7 +159,7 @@
|
||||
"label": "Abrir resultados da busca em uma nova guia"
|
||||
},
|
||||
"ddgBangs": {
|
||||
"label": ""
|
||||
"label": "Ativar bangs do DuckDuckGo"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -252,10 +252,10 @@
|
||||
"changeDdgBangs": {
|
||||
"notification": {
|
||||
"success": {
|
||||
"message": ""
|
||||
"message": "Bangs do DuckDuckGo alternados com sucesso"
|
||||
},
|
||||
"error": {
|
||||
"message": ""
|
||||
"message": "Não foi possível alternar os bangs do DuckDuckGo"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1093,7 +1093,7 @@
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
"auto": "",
|
||||
"auto": "Automático",
|
||||
"light": "Claro",
|
||||
"dark": "Escuro"
|
||||
}
|
||||
@@ -2111,101 +2111,101 @@
|
||||
}
|
||||
},
|
||||
"umami": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"visitors": "",
|
||||
"active": "",
|
||||
"pageviews": "",
|
||||
"visits": "",
|
||||
"bounceRate": "",
|
||||
"avgDuration": "",
|
||||
"name": "Umami Analytics",
|
||||
"description": "Exibir estatísticas de visitantes da sua instância do Umami",
|
||||
"visitors": "visitantes",
|
||||
"active": "ativos",
|
||||
"pageviews": "visualizações de página",
|
||||
"visits": "visitas",
|
||||
"bounceRate": "rejeição",
|
||||
"avgDuration": "média",
|
||||
"option": {
|
||||
"websiteId": {
|
||||
"label": "",
|
||||
"description": "",
|
||||
"placeholder": "",
|
||||
"loading": "",
|
||||
"noWebsites": "",
|
||||
"configureIntegrationFirst": ""
|
||||
"label": "site",
|
||||
"description": "Selecione um site monitorado pela sua instância do Umami",
|
||||
"placeholder": "Selecionar um site",
|
||||
"loading": "Carregando sites...",
|
||||
"noWebsites": "Nenhum site encontrado",
|
||||
"configureIntegrationFirst": "Conecte uma integração do Umami primeiro"
|
||||
},
|
||||
"timeFrame": {
|
||||
"label": "",
|
||||
"description": "",
|
||||
"label": "Período de tempo",
|
||||
"description": "O período de tempo para exibir os dados de visitantes",
|
||||
"option": {
|
||||
"today": "",
|
||||
"24h": "",
|
||||
"7d": "",
|
||||
"30d": "",
|
||||
"month": "",
|
||||
"lastMonth": ""
|
||||
"today": "Hoje",
|
||||
"24h": "Últimas 24 horas",
|
||||
"7d": "Últimos 7 dias",
|
||||
"30d": "Últimos 30 dias",
|
||||
"month": "Este mês",
|
||||
"lastMonth": "Mês passado"
|
||||
}
|
||||
},
|
||||
"eventName": {
|
||||
"label": "",
|
||||
"description": "",
|
||||
"placeholder": "",
|
||||
"loading": "",
|
||||
"noEvents": "",
|
||||
"none": "",
|
||||
"configureWebsiteFirst": ""
|
||||
"label": "Evento",
|
||||
"description": "Exibir total de ocorrências de um evento específico",
|
||||
"placeholder": "Selecionar um evento",
|
||||
"loading": "Carregando eventos...",
|
||||
"noEvents": "Nenhum evento encontrado",
|
||||
"none": "Nenhum evento",
|
||||
"configureWebsiteFirst": "Selecione um site primeiro para carregar os eventos"
|
||||
},
|
||||
"chartType": {
|
||||
"label": "",
|
||||
"description": "",
|
||||
"label": "Tipo de gráfico",
|
||||
"description": "Estilo de exibição do gráfico",
|
||||
"option": {
|
||||
"bar": "",
|
||||
"sparkline": ""
|
||||
"bar": "Gráfico de barras",
|
||||
"sparkline": "Sparkline"
|
||||
}
|
||||
},
|
||||
"chartStyle": {
|
||||
"label": "",
|
||||
"description": "",
|
||||
"label": "Estilo do gráfico",
|
||||
"description": "Como exibir a série de eventos no gráfico",
|
||||
"option": {
|
||||
"grouped": "",
|
||||
"overlay": ""
|
||||
"grouped": "Lado a lado",
|
||||
"overlay": "Sobreposição (subconjunto)"
|
||||
}
|
||||
},
|
||||
"viewMode": {
|
||||
"label": "",
|
||||
"description": "",
|
||||
"label": "Visualização",
|
||||
"description": "O que exibir no corpo do widget",
|
||||
"option": {
|
||||
"chart": "",
|
||||
"events": "",
|
||||
"topPages": "",
|
||||
"topReferrers": ""
|
||||
"chart": "Gráfico",
|
||||
"events": "Gráfico de eventos",
|
||||
"topPages": "Principais páginas",
|
||||
"topReferrers": "Principais referenciadores"
|
||||
}
|
||||
},
|
||||
"eventNames": {
|
||||
"label": "",
|
||||
"description": "",
|
||||
"placeholder": "",
|
||||
"loading": "",
|
||||
"noEvents": "",
|
||||
"configureWebsiteFirst": ""
|
||||
"label": "Eventos",
|
||||
"description": "Selecione um ou mais eventos para o gráfico",
|
||||
"placeholder": "Selecionar eventos...",
|
||||
"loading": "Carregando eventos...",
|
||||
"noEvents": "Nenhum evento encontrado",
|
||||
"configureWebsiteFirst": "Selecione um site primeiro para carregar os eventos"
|
||||
},
|
||||
"topCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Itens para exibir",
|
||||
"description": "Número de itens principais para exibir (máx 500)"
|
||||
}
|
||||
},
|
||||
"event": {
|
||||
"occurrences": "",
|
||||
"label": ""
|
||||
"occurrences": "Eventos",
|
||||
"label": "Eventos"
|
||||
},
|
||||
"topPages": {
|
||||
"direct": ""
|
||||
"direct": "Direto"
|
||||
},
|
||||
"topReferrers": {
|
||||
"direct": ""
|
||||
"direct": "Direto"
|
||||
},
|
||||
"events": {
|
||||
"selectPrompt": ""
|
||||
"selectPrompt": "Selecionar eventos para o gráfico"
|
||||
},
|
||||
"error": {
|
||||
"noIntegration": "",
|
||||
"noWebsite": "",
|
||||
"noData": "",
|
||||
"internalServerError": ""
|
||||
"noIntegration": "Nenhuma integração Umami configurada",
|
||||
"noWebsite": "Nenhum site configurado",
|
||||
"noData": "Nenhum dado disponível",
|
||||
"internalServerError": "Falha ao buscar dados do Umami"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -3680,7 +3680,7 @@
|
||||
"defaultColorScheme": {
|
||||
"label": "Esquema de cores padrão",
|
||||
"options": {
|
||||
"auto": "",
|
||||
"auto": "Automático",
|
||||
"light": "Claro",
|
||||
"dark": "Escuro"
|
||||
}
|
||||
@@ -3783,7 +3783,7 @@
|
||||
"label": "Tracearr"
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": ""
|
||||
"label": "Visitantes Ativos do Umami"
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -3972,7 +3972,7 @@
|
||||
"label": "Portas"
|
||||
},
|
||||
"host": {
|
||||
"label": ""
|
||||
"label": "Host"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
@@ -4499,7 +4499,7 @@
|
||||
"title": "Comandos globais",
|
||||
"option": {
|
||||
"colorScheme": {
|
||||
"auto": "",
|
||||
"auto": "Alternar modo de aparência",
|
||||
"light": "Alternar para o modo claro",
|
||||
"dark": "Alternar para o modo escuro"
|
||||
},
|
||||
@@ -4551,13 +4551,13 @@
|
||||
"group": {
|
||||
"searchEngine": {
|
||||
"title": "Mecanismos de busca",
|
||||
"tipDdgBangs": "",
|
||||
"tipDdgBangs": "Dica: Você também pode usar qualquer bang do DuckDuckGo aqui, ex: !something",
|
||||
"bang": {
|
||||
"searchWithQuery": "",
|
||||
"pressEnterToOpen": "",
|
||||
"engineSelected": "",
|
||||
"typeQueryToContinue": "",
|
||||
"emptyBangHint": ""
|
||||
"searchWithQuery": "Pesquisar \"{query}\" com {label}",
|
||||
"pressEnterToOpen": "Pressione Enter para abrir",
|
||||
"engineSelected": "{label} selecionado (!{bang})",
|
||||
"typeQueryToContinue": "Digite sua pesquisa para continuar",
|
||||
"emptyBangHint": "Digite um bang, ex: !yt, e pressione Espaço para selecionar"
|
||||
},
|
||||
"children": {
|
||||
"action": {
|
||||
|
||||
@@ -65,8 +65,8 @@ const columnsRatios: Record<keyof ExtendedDownloadClientItem, number> = {
|
||||
id: 1,
|
||||
index: 1,
|
||||
integration: 1,
|
||||
name: 8,
|
||||
progress: 4,
|
||||
name: 16,
|
||||
progress: 6,
|
||||
ratio: 2,
|
||||
received: 3,
|
||||
sent: 3,
|
||||
|
||||
Reference in new issue
Block a user