Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0513a8e6f5 |
No files matched your search
@@ -1,13 +1,16 @@
|
||||
import Link from "next/link";
|
||||
import { Card, Group, SimpleGrid, Space, Stack, Text } from "@mantine/core";
|
||||
import { Card, Group, SimpleGrid, Space, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconArrowRight } from "@tabler/icons-react";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { HeroBanner } from "./_components/hero-banner";
|
||||
|
||||
@@ -30,6 +33,8 @@ export async function generateMetadata() {
|
||||
export default async function ManagementPage() {
|
||||
const statistics = await api.home.getStats();
|
||||
const session = await auth();
|
||||
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
||||
const suggestions = isAdmin ? await api.integration.suggestedIntegrations().catch(catchTrpcNotFound) : [];
|
||||
const t = await getScopedI18n("management.page.home");
|
||||
|
||||
const links: LinkProps[] = [
|
||||
@@ -102,6 +107,37 @@ export default async function ManagementPage() {
|
||||
),
|
||||
)}
|
||||
</SimpleGrid>
|
||||
{suggestions.length >= 1 && (
|
||||
<>
|
||||
<Space h="md" />
|
||||
<Stack gap="sm">
|
||||
<Stack gap={0}>
|
||||
<Title order={3}>Suggested integrations</Title>
|
||||
<Text c="dimmed">Based on your Docker containers</Text>
|
||||
</Stack>
|
||||
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
|
||||
{suggestions.slice(0, 6).map((suggestion) => (
|
||||
<Card key={suggestion.kind} component={Link} href="#" radius="lg">
|
||||
<Group justify="space-between">
|
||||
<Group>
|
||||
<IntegrationAvatar kind={suggestion.kind} size="sm" />
|
||||
<Stack gap={0}>
|
||||
<Text size="md" fw={500}>
|
||||
{getIntegrationName(suggestion.kind)}
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
Show active media streams
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<IconArrowRight />
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -14,15 +14,19 @@ import {
|
||||
integrationUserPermissions,
|
||||
searchEngines,
|
||||
} from "@homarr/db/schema";
|
||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
|
||||
import {
|
||||
getIconUrl,
|
||||
getIntegrationKindsByCategory,
|
||||
getIntegrationName,
|
||||
getPermissionsWithParents,
|
||||
integrationDefs,
|
||||
integrationKinds,
|
||||
integrationSecretKindObject,
|
||||
} from "@homarr/definitions";
|
||||
import type { ContainerInfo, Port } from "@homarr/docker";
|
||||
import { DockerSingleton } from "@homarr/docker";
|
||||
import { env } from "@homarr/docker/env";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { logger } from "@homarr/log";
|
||||
import { byIdSchema } from "@homarr/validation/common";
|
||||
@@ -527,8 +531,169 @@ export const integrationRouter = createTRPCRouter({
|
||||
const integrationInstance = await createIntegrationAsync(ctx.integration);
|
||||
return await integrationInstance.searchAsync(encodeURI(input.query));
|
||||
}),
|
||||
suggestedIntegrations: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
|
||||
if (!env.ENABLE_DOCKER)
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Docker is not enabled",
|
||||
});
|
||||
|
||||
const dockerInstances = DockerSingleton.getInstances();
|
||||
if (dockerInstances.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "No Docker instances found",
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: remove those that were already added
|
||||
const containers = await Promise.all(
|
||||
dockerInstances.map(({ instance, host, isSocket }) =>
|
||||
instance.listContainers().then((containers) =>
|
||||
containers.map((containerInfo) => ({
|
||||
info: containerInfo,
|
||||
host,
|
||||
isSocket,
|
||||
})),
|
||||
),
|
||||
),
|
||||
).then((results) => results.flat());
|
||||
|
||||
// Default docker hostname consist out of first 12 characters of the container id
|
||||
const homarrContainer = containers.find((container) => container.info.Id.slice(0, 12) === process.env.HOSTNAME);
|
||||
|
||||
const suggestions = containers
|
||||
.map((container) => getSuggestionFromContainer(container, homarrContainer))
|
||||
.filter((suggestion) => suggestion !== null);
|
||||
|
||||
const groupedSuggestions = suggestions.reduce(
|
||||
(acc, suggestion) => {
|
||||
acc[suggestion.kind] ??= [];
|
||||
// is defined above
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
acc[suggestion.kind]!.push(suggestion);
|
||||
return acc;
|
||||
},
|
||||
{} as Partial<Record<IntegrationKind, IntegrationSuggestion[]>>,
|
||||
);
|
||||
|
||||
return Object.entries(groupedSuggestions)
|
||||
.map(([kind, suggestions]) => ({
|
||||
kind: kind as IntegrationKind,
|
||||
suggestions,
|
||||
}))
|
||||
.sort((itemA, itemB) => {
|
||||
if (itemA.suggestions.length !== itemB.suggestions.length)
|
||||
return itemB.suggestions.length - itemA.suggestions.length;
|
||||
|
||||
const createdAtA = Math.max(...itemA.suggestions.map((suggestion) => suggestion.createdAt?.getTime() ?? 0));
|
||||
const createdAtB = Math.max(...itemB.suggestions.map((suggestion) => suggestion.createdAt?.getTime() ?? 0));
|
||||
return createdAtB - createdAtA;
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
interface IntegrationSuggestion {
|
||||
containerId: string;
|
||||
name: string;
|
||||
internalUrl: string | null;
|
||||
externalUrl: string | null;
|
||||
kind: IntegrationKind;
|
||||
createdAt?: Date;
|
||||
}
|
||||
|
||||
interface ContainerInfoWithHost {
|
||||
info: ContainerInfo;
|
||||
host: string;
|
||||
isSocket: boolean | undefined;
|
||||
}
|
||||
|
||||
const getSuggestionFromContainer = (
|
||||
container: ContainerInfoWithHost,
|
||||
homarrContainer: ContainerInfoWithHost | undefined,
|
||||
): IntegrationSuggestion | null => {
|
||||
const integrationKind = getSuggestionKind(container.info);
|
||||
|
||||
if (!integrationKind) return null;
|
||||
|
||||
return {
|
||||
containerId: container.info.Id,
|
||||
name: getIntegrationName(integrationKind),
|
||||
kind: integrationKind,
|
||||
internalUrl: getInternalUrl(container, homarrContainer),
|
||||
externalUrl: getExternalUrl(container),
|
||||
createdAt: container.info.Created ? new Date(container.info.Created * 1000) : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const getSuggestionKind = (container: ContainerInfo): IntegrationKind | null => {
|
||||
const [image, _tag] = container.Image.split(":");
|
||||
if (!image) return null;
|
||||
return (
|
||||
objectEntries(integrationDefs).find(
|
||||
([, integration]) =>
|
||||
"containerImages" in integration &&
|
||||
integration.containerImages.some((containerImage) => containerImage.toLowerCase() === image.toLowerCase()),
|
||||
)?.[0] ?? null
|
||||
);
|
||||
};
|
||||
|
||||
const calculatePortScore = (port: Port): number => {
|
||||
let score = 0;
|
||||
|
||||
if (port.PublicPort) score += 10; // Public ports are more likely to be used
|
||||
if (port.PrivatePort % 1000 === 80) score += 5; // Default http ports are most likely suffixed with 80 like 8080 or 80
|
||||
if (port.PrivatePort % 1000 === 443) score += 10; // Default https ports are most likely suffixed with 443 like 8443 or 443
|
||||
return score;
|
||||
};
|
||||
|
||||
const getPort = (container: ContainerInfoWithHost): Port | null => {
|
||||
const ports = container.info.Ports.filter((port) => port.Type === "tcp");
|
||||
return (
|
||||
ports
|
||||
.map((port) => ({ port, score: calculatePortScore(port) }))
|
||||
.sort((portA, portB) => portB.score - portA.score)
|
||||
.at(0)?.port ?? null
|
||||
);
|
||||
};
|
||||
|
||||
const getInternalUrl = (
|
||||
container: ContainerInfoWithHost,
|
||||
homarrContainer: ContainerInfoWithHost | undefined,
|
||||
): string | null => {
|
||||
const port = getPort(container);
|
||||
if (!port) return null;
|
||||
const protocol = port.PrivatePort % 1000 === 443 ? "https" : "http";
|
||||
|
||||
if (homarrContainer) {
|
||||
const matchingNetwork = Object.entries(homarrContainer.info.NetworkSettings.Networks).find(
|
||||
([_key, homarrNetwork]) =>
|
||||
Object.entries(container.info.NetworkSettings.Networks).some(
|
||||
([_name, network]) => homarrNetwork.NetworkID === network.NetworkID,
|
||||
),
|
||||
)?.[1];
|
||||
if (matchingNetwork) return `${protocol}://${matchingNetwork.IPAddress}:${port.PrivatePort}`;
|
||||
}
|
||||
|
||||
// TODO: Let user specify the internal ip-address for integrations --> use as default before falling back to host.docker.internal
|
||||
if (container.host === homarrContainer?.host && container.isSocket === homarrContainer.isSocket) {
|
||||
return `${protocol}://host.docker.internal:${port.PublicPort}`;
|
||||
}
|
||||
|
||||
// TODO: Change this
|
||||
return `${protocol}://localhost:${port.PublicPort}`;
|
||||
};
|
||||
|
||||
const getExternalUrl = (container: ContainerInfoWithHost): string | null => {
|
||||
const port = getPort(container);
|
||||
if (!port) return null;
|
||||
const protocol = port.PrivatePort % 1000 === 443 ? "https" : "http";
|
||||
|
||||
// TODO: Use some labels like "traefik.host" to determine the external url
|
||||
// For now we just use localhost as external url
|
||||
return `${protocol}://localhost:${port.PublicPort}`;
|
||||
};
|
||||
|
||||
interface UpdateSecretInput {
|
||||
integrationId: string;
|
||||
value: string;
|
||||
|
||||
@@ -16,6 +16,7 @@ interface integrationDefinition {
|
||||
iconUrl: string;
|
||||
secretKinds: AtLeastOneOf<IntegrationSecretKind[]>; // at least one secret kind set is required
|
||||
category: AtLeastOneOf<IntegrationCategory>;
|
||||
containerImages?: string[]; // Optional, used for container images to suggest in the UI
|
||||
}
|
||||
|
||||
export const integrationDefs = {
|
||||
@@ -24,30 +25,45 @@ export const integrationDefs = {
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg",
|
||||
category: ["downloadClient", "usenet"],
|
||||
containerImages: ["linuxserver/sabnzbd", "lscr.io/linuxserver/sabnzbd", "ghcr.io/linuxserver/sabnzbd"],
|
||||
},
|
||||
nzbGet: {
|
||||
name: "NZBGet",
|
||||
secretKinds: [["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nzbget.svg",
|
||||
category: ["downloadClient", "usenet"],
|
||||
containerImages: [
|
||||
"nzbgetcom/nzbget",
|
||||
"ghcr.io/nzbgetcom/nzbget",
|
||||
"linuxserver/nzbget",
|
||||
"lscr.io/linuxserver/nzbget",
|
||||
"ghcr.io/linuxserver/nzbget",
|
||||
],
|
||||
},
|
||||
deluge: {
|
||||
name: "Deluge",
|
||||
secretKinds: [["password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/deluge.svg",
|
||||
category: ["downloadClient", "torrent"],
|
||||
containerImages: ["linuxserver/deluge", "lscr.io/linuxserver/deluge", "ghcr.io/linuxserver/deluge"],
|
||||
},
|
||||
transmission: {
|
||||
name: "Transmission",
|
||||
secretKinds: [["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/transmission.svg",
|
||||
category: ["downloadClient", "torrent"],
|
||||
containerImages: [
|
||||
"linuxserver/transmission",
|
||||
"lscr.io/linuxserver/transmission",
|
||||
"ghcr.io/linuxserver/transmission",
|
||||
],
|
||||
},
|
||||
qBittorrent: {
|
||||
name: "qBittorrent",
|
||||
secretKinds: [["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/qbittorrent.svg",
|
||||
category: ["downloadClient", "torrent"],
|
||||
containerImages: ["linuxserver/qbittorrent", "lscr.io/linuxserver/qbittorrent", "ghcr.io/linuxserver/qbittorrent"],
|
||||
},
|
||||
aria2: {
|
||||
name: "Aria2",
|
||||
@@ -60,78 +76,144 @@ export const integrationDefs = {
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sonarr.svg",
|
||||
category: ["calendar"],
|
||||
containerImages: [
|
||||
"hotio/sonarr",
|
||||
"ghcr.io/hotio/sonarr",
|
||||
"linuxserver/sonarr",
|
||||
"lscr.io/linuxserver/sonarr",
|
||||
"ghcr.io/linuxserver/sonarr",
|
||||
],
|
||||
},
|
||||
radarr: {
|
||||
name: "Radarr",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/radarr.svg",
|
||||
category: ["calendar"],
|
||||
containerImages: [
|
||||
"hotio/radarr",
|
||||
"ghcr.io/hotio/radarr",
|
||||
"linuxserver/radarr",
|
||||
"lscr.io/linuxserver/radarr",
|
||||
"ghcr.io/linuxserver/radarr",
|
||||
],
|
||||
},
|
||||
lidarr: {
|
||||
name: "Lidarr",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/lidarr.svg",
|
||||
category: ["calendar"],
|
||||
containerImages: [
|
||||
"hotio/lidarr",
|
||||
"ghcr.io/hotio/lidarr",
|
||||
"linuxserver/lidarr",
|
||||
"lscr.io/linuxserver/lidarr",
|
||||
"ghcr.io/linuxserver/lidarr",
|
||||
],
|
||||
},
|
||||
readarr: {
|
||||
name: "Readarr",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/readarr.svg",
|
||||
category: ["calendar"],
|
||||
containerImages: [
|
||||
"hotio/readarr",
|
||||
"ghcr.io/hotio/readarr",
|
||||
"linuxserver/readarr",
|
||||
"lscr.io/linuxserver/readarr",
|
||||
"ghcr.io/linuxserver/readarr",
|
||||
],
|
||||
},
|
||||
prowlarr: {
|
||||
name: "Prowlarr",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/prowlarr.svg",
|
||||
category: ["indexerManager"],
|
||||
containerImages: [
|
||||
"hotio/prowlarr",
|
||||
"ghcr.io/hotio/prowlarr",
|
||||
"linuxserver/prowlarr",
|
||||
"lscr.io/linuxserver/prowlarr",
|
||||
"ghcr.io/linuxserver/prowlarr",
|
||||
],
|
||||
},
|
||||
jellyfin: {
|
||||
name: "Jellyfin",
|
||||
secretKinds: [["username", "password"], ["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg",
|
||||
category: ["mediaService"],
|
||||
containerImages: [
|
||||
"jellyfin/jellyfin",
|
||||
"ghcr.io/jellyfin/jellyfin",
|
||||
"linuxserver/jellyfin",
|
||||
"lscr.io/linuxserver/jellyfin",
|
||||
"ghcr.io/linuxserver/jellyfin",
|
||||
],
|
||||
},
|
||||
emby: {
|
||||
name: "Emby",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/emby.svg",
|
||||
category: ["mediaService"],
|
||||
containerImages: [
|
||||
"emby/embyserver",
|
||||
"emby/embyserver_arm64v8",
|
||||
"emby/embyserver_arm32v7",
|
||||
"linuxserver/emby",
|
||||
"lscr.io/linuxserver/emby",
|
||||
"ghcr.io/linuxserver/emby",
|
||||
],
|
||||
},
|
||||
plex: {
|
||||
name: "Plex",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg",
|
||||
category: ["mediaService"],
|
||||
containerImages: ["plexinc/pms-docker", "linuxserver/plex", "lscr.io/linuxserver/plex", "ghcr.io/linuxserver/plex"],
|
||||
},
|
||||
jellyseerr: {
|
||||
name: "Jellyseerr",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg",
|
||||
category: ["mediaSearch", "mediaRequest", "search"],
|
||||
containerImages: ["fallenbagel/jellyseerr", "ghcr.io/fallenbagel/jellyseerr"],
|
||||
},
|
||||
overseerr: {
|
||||
name: "Overseerr",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/overseerr.svg",
|
||||
category: ["mediaSearch", "mediaRequest", "search"],
|
||||
containerImages: [
|
||||
"sct/overseerr",
|
||||
"linuxserver/overseerr",
|
||||
"lscr.io/linuxserver/overseerr",
|
||||
"ghcr.io/linuxserver/overseerr",
|
||||
],
|
||||
},
|
||||
piHole: {
|
||||
name: "Pi-hole",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pi-hole.svg",
|
||||
category: ["dnsHole"],
|
||||
containerImages: ["pihole/pihole"],
|
||||
},
|
||||
adGuardHome: {
|
||||
name: "AdGuard Home",
|
||||
secretKinds: [["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/adguard-home.svg",
|
||||
category: ["dnsHole"],
|
||||
containerImages: ["adguard/adguardhome"],
|
||||
},
|
||||
homeAssistant: {
|
||||
name: "Home Assistant",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/home-assistant.svg",
|
||||
category: ["smartHomeServer"],
|
||||
containerImages: [
|
||||
"homeassistant/home-assistant",
|
||||
"linuxserver/home-assistant",
|
||||
"lscr.io/linuxserver/home-assistant",
|
||||
"ghcr.io/linuxserver/home-assistant",
|
||||
],
|
||||
},
|
||||
openmediavault: {
|
||||
name: "OpenMediaVault",
|
||||
@@ -144,12 +226,14 @@ export const integrationDefs = {
|
||||
secretKinds: [[]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/dashdot.png",
|
||||
category: ["healthMonitoring"],
|
||||
containerImages: ["mauricenino/dashdot", "ghcr.io/mauricenino/dashdot"],
|
||||
},
|
||||
tdarr: {
|
||||
name: "Tdarr",
|
||||
secretKinds: [[], ["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png",
|
||||
category: ["mediaTranscoding"],
|
||||
containerImages: ["haveagitgat/tdarr", "ghcr.io/haveagitgat/tdarr"],
|
||||
},
|
||||
proxmox: {
|
||||
name: "Proxmox",
|
||||
@@ -168,6 +252,16 @@ export const integrationDefs = {
|
||||
secretKinds: [["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png",
|
||||
category: ["networkController"],
|
||||
containerImages: [
|
||||
"jacobalberty/unifi",
|
||||
"ghcr.io/jacobalberty/unifi",
|
||||
"linuxserver/unifi-controller",
|
||||
"lscr.io/linuxserver/unifi-controller",
|
||||
"ghcr.io/linuxserver/unifi-controller",
|
||||
"linuxserver/unifi-network-application",
|
||||
"lscr.io/linuxserver/unifi-network-application",
|
||||
"ghcr.io/linuxserver/unifi-network-application",
|
||||
],
|
||||
},
|
||||
} as const satisfies Record<string, integrationDefinition>;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { env } from "./env";
|
||||
export interface DockerInstance {
|
||||
host: string;
|
||||
instance: Docker;
|
||||
isSocket?: boolean;
|
||||
}
|
||||
|
||||
export class DockerSingleton {
|
||||
@@ -14,7 +15,7 @@ export class DockerSingleton {
|
||||
const hostVariable = env.DOCKER_HOSTNAMES;
|
||||
const portVariable = env.DOCKER_PORTS;
|
||||
if (hostVariable === undefined || portVariable === undefined) {
|
||||
return [{ host: "socket", instance: new Docker() }];
|
||||
return [{ host: "socket", instance: new Docker(), isSocket: true }];
|
||||
}
|
||||
const hostnames = hostVariable.split(",");
|
||||
const ports = portVariable.split(",");
|
||||
|
||||
Reference in new issue
Block a user