diff --git a/additional-containers/mariadb-backup/backup.sh b/additional-containers/mariadb-backup/backup.sh
index 3cb8d9e..39430bc 100644
--- a/additional-containers/mariadb-backup/backup.sh
+++ b/additional-containers/mariadb-backup/backup.sh
@@ -21,6 +21,10 @@ if [ -z "$S3_BUCKET_NAME" ]; then echo "Error: S3_BUCKET_NAME is not set"; exit
if [ -z "$S3_KEY" ]; then echo "Error: S3_KEY is not set"; exit 1; fi
if [ -z "$S3_REGION" ]; then echo "Error: S3_REGION is not set"; exit 1; fi
+# Insert a sleep timeout so that the network policy is fully applied before attempting to connect to the database
+echo "Waiting for network policies to take effect..."
+sleep 4
+
echo "Starting backup process..."
# Create a temporary directory for the dump
diff --git a/additional-containers/mongodb-backup/backup.sh b/additional-containers/mongodb-backup/backup.sh
index e4d406d..510e5d9 100644
--- a/additional-containers/mongodb-backup/backup.sh
+++ b/additional-containers/mongodb-backup/backup.sh
@@ -17,6 +17,10 @@ if [ -z "$S3_BUCKET_NAME" ]; then echo "Error: S3_BUCKET_NAME is not set"; exit
if [ -z "$S3_KEY" ]; then echo "Error: S3_KEY is not set"; exit 1; fi
if [ -z "$S3_REGION" ]; then echo "Error: S3_REGION is not set"; exit 1; fi
+# Insert a sleep timeout so that the network policy is fully applied before attempting to connect to the database
+echo "Waiting for network policies to take effect..."
+sleep 4
+
echo "Starting backup process..."
# Create a temporary directory for the dump
diff --git a/additional-containers/postgres-backup/backup.sh b/additional-containers/postgres-backup/backup.sh
index 480ff0d..f439a40 100644
--- a/additional-containers/postgres-backup/backup.sh
+++ b/additional-containers/postgres-backup/backup.sh
@@ -21,6 +21,10 @@ if [ -z "$S3_BUCKET_NAME" ]; then echo "Error: S3_BUCKET_NAME is not set"; exit
if [ -z "$S3_KEY" ]; then echo "Error: S3_KEY is not set"; exit 1; fi
if [ -z "$S3_REGION" ]; then echo "Error: S3_REGION is not set"; exit 1; fi
+# Insert a sleep timeout so that the network policy is fully applied before attempting to connect to the database
+echo "Waiting for network policies to take effect..."
+sleep 4
+
echo "Starting backup process..."
# Create a temporary directory for the dump
diff --git a/package.json b/package.json
index f860d18..64273ab 100644
--- a/package.json
+++ b/package.json
@@ -42,6 +42,7 @@
"@radix-ui/react-tooltip": "^1.1.4",
"@tanstack/react-table": "^8.20.5",
"@xterm/xterm": "^5.5.0",
+ "@xyflow/react": "^12.10.0",
"bcrypt": "^5.1.1",
"bufferutil": "^4.0.9",
"class-variance-authority": "^0.7.1",
diff --git a/prisma/migrations/20251216102326_migration/migration.sql b/prisma/migrations/20251216102326_migration/migration.sql
new file mode 100644
index 0000000..b3c5ba9
--- /dev/null
+++ b/prisma/migrations/20251216102326_migration/migration.sql
@@ -0,0 +1,35 @@
+-- RedefineTables
+PRAGMA defer_foreign_keys=ON;
+PRAGMA foreign_keys=OFF;
+CREATE TABLE "new_App" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "name" TEXT NOT NULL,
+ "appType" TEXT NOT NULL DEFAULT 'APP',
+ "projectId" TEXT NOT NULL,
+ "sourceType" TEXT NOT NULL DEFAULT 'GIT',
+ "containerImageSource" TEXT,
+ "containerRegistryUsername" TEXT,
+ "containerRegistryPassword" TEXT,
+ "gitUrl" TEXT,
+ "gitBranch" TEXT,
+ "gitUsername" TEXT,
+ "gitToken" TEXT,
+ "dockerfilePath" TEXT NOT NULL DEFAULT './Dockerfile',
+ "replicas" INTEGER NOT NULL DEFAULT 1,
+ "envVars" TEXT NOT NULL DEFAULT '',
+ "memoryReservation" INTEGER,
+ "memoryLimit" INTEGER,
+ "cpuReservation" INTEGER,
+ "cpuLimit" INTEGER,
+ "webhookId" TEXT,
+ "ingressNetworkPolicy" TEXT NOT NULL DEFAULT 'ALLOW_ALL',
+ "egressNetworkPolicy" TEXT NOT NULL DEFAULT 'ALLOW_ALL',
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" DATETIME NOT NULL,
+ CONSTRAINT "App_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+INSERT INTO "new_App" ("appType", "containerImageSource", "containerRegistryPassword", "containerRegistryUsername", "cpuLimit", "cpuReservation", "createdAt", "dockerfilePath", "envVars", "gitBranch", "gitToken", "gitUrl", "gitUsername", "id", "memoryLimit", "memoryReservation", "name", "projectId", "replicas", "sourceType", "updatedAt", "webhookId") SELECT "appType", "containerImageSource", "containerRegistryPassword", "containerRegistryUsername", "cpuLimit", "cpuReservation", "createdAt", "dockerfilePath", "envVars", "gitBranch", "gitToken", "gitUrl", "gitUsername", "id", "memoryLimit", "memoryReservation", "name", "projectId", "replicas", "sourceType", "updatedAt", "webhookId" FROM "App";
+DROP TABLE "App";
+ALTER TABLE "new_App" RENAME TO "App";
+PRAGMA foreign_keys=ON;
+PRAGMA defer_foreign_keys=OFF;
diff --git a/prisma/migrations/20251222131551_migration/migration.sql b/prisma/migrations/20251222131551_migration/migration.sql
new file mode 100644
index 0000000..d421176
--- /dev/null
+++ b/prisma/migrations/20251222131551_migration/migration.sql
@@ -0,0 +1,36 @@
+-- RedefineTables
+PRAGMA defer_foreign_keys=ON;
+PRAGMA foreign_keys=OFF;
+CREATE TABLE "new_App" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "name" TEXT NOT NULL,
+ "appType" TEXT NOT NULL DEFAULT 'APP',
+ "projectId" TEXT NOT NULL,
+ "sourceType" TEXT NOT NULL DEFAULT 'GIT',
+ "containerImageSource" TEXT,
+ "containerRegistryUsername" TEXT,
+ "containerRegistryPassword" TEXT,
+ "gitUrl" TEXT,
+ "gitBranch" TEXT,
+ "gitUsername" TEXT,
+ "gitToken" TEXT,
+ "dockerfilePath" TEXT NOT NULL DEFAULT './Dockerfile',
+ "replicas" INTEGER NOT NULL DEFAULT 1,
+ "envVars" TEXT NOT NULL DEFAULT '',
+ "memoryReservation" INTEGER,
+ "memoryLimit" INTEGER,
+ "cpuReservation" INTEGER,
+ "cpuLimit" INTEGER,
+ "webhookId" TEXT,
+ "ingressNetworkPolicy" TEXT NOT NULL DEFAULT 'ALLOW_ALL',
+ "egressNetworkPolicy" TEXT NOT NULL DEFAULT 'ALLOW_ALL',
+ "useNetworkPolicy" BOOLEAN NOT NULL DEFAULT true,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" DATETIME NOT NULL,
+ CONSTRAINT "App_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+INSERT INTO "new_App" ("appType", "containerImageSource", "containerRegistryPassword", "containerRegistryUsername", "cpuLimit", "cpuReservation", "createdAt", "dockerfilePath", "egressNetworkPolicy", "envVars", "gitBranch", "gitToken", "gitUrl", "gitUsername", "id", "ingressNetworkPolicy", "memoryLimit", "memoryReservation", "name", "projectId", "replicas", "sourceType", "updatedAt", "webhookId") SELECT "appType", "containerImageSource", "containerRegistryPassword", "containerRegistryUsername", "cpuLimit", "cpuReservation", "createdAt", "dockerfilePath", "egressNetworkPolicy", "envVars", "gitBranch", "gitToken", "gitUrl", "gitUsername", "id", "ingressNetworkPolicy", "memoryLimit", "memoryReservation", "name", "projectId", "replicas", "sourceType", "updatedAt", "webhookId" FROM "App";
+DROP TABLE "App";
+ALTER TABLE "new_App" RENAME TO "App";
+PRAGMA foreign_keys=ON;
+PRAGMA defer_foreign_keys=OFF;
diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml
index e5e5c47..2a5a444 100644
--- a/prisma/migrations/migration_lock.toml
+++ b/prisma/migrations/migration_lock.toml
@@ -1,3 +1,3 @@
# Please do not edit this file manually
-# It should be added in your version-control system (i.e. Git)
-provider = "sqlite"
\ No newline at end of file
+# It should be added in your version-control system (e.g., Git)
+provider = "sqlite"
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index ae17663..f440dea 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -200,6 +200,10 @@ model App {
webhookId String?
+ ingressNetworkPolicy String @default("ALLOW_ALL") // ALLOW_ALL, NAMESPACE_ONLY, DENY_ALL, INTERNET_ONLY
+ egressNetworkPolicy String @default("ALLOW_ALL") // ALLOW_ALL, NAMESPACE_ONLY, DENY_ALL, INTERNET_ONLY
+ useNetworkPolicy Boolean @default(true)
+
appDomains AppDomain[]
appPorts AppPort[]
appVolumes AppVolume[]
diff --git a/src/app/project/[projectId]/page.tsx b/src/app/project/[projectId]/page.tsx
index c083c9b..2851d3a 100644
--- a/src/app/project/[projectId]/page.tsx
+++ b/src/app/project/[projectId]/page.tsx
@@ -3,7 +3,7 @@
import { getAuthUserSession } from "@/server/utils/action-wrapper.utils";
import projectService from "@/server/services/project.service";
-import AppTable from "./apps-table";
+import ProjectOverview from "./project-overview";
import appService from "@/server/services/app.service";
import PageTitle from "@/components/custom/page-title";
import ProjectBreadcrumbs from "./project-breadcrumbs";
@@ -36,7 +36,7 @@ export default async function AppsPage({
{UserGroupUtils.sessionCanCreateNewAppsForProject(session, params.projectId) &&
}
-
+
)
diff --git a/src/app/project/[projectId]/project-network-graph.tsx b/src/app/project/[projectId]/project-network-graph.tsx
new file mode 100644
index 0000000..97943de
--- /dev/null
+++ b/src/app/project/[projectId]/project-network-graph.tsx
@@ -0,0 +1,256 @@
+'use client';
+
+import React, { useMemo } from 'react';
+import { ReactFlow, Background, Controls, Node, Edge, MarkerType, Handle, Position } from '@xyflow/react';
+import '@xyflow/react/dist/style.css';
+import { App, AppDomain, AppPort } from '@prisma/client';
+import { Globe, Network, Lock, Cloud, Shield, ArrowDown } from 'lucide-react';
+import PodStatusIndicator from '@/components/custom/pod-status-indicator';
+import { useRouter } from 'next/navigation';
+
+interface AppWithRelations extends App {
+ appPorts: AppPort[];
+ appDomains: AppDomain[];
+}
+
+interface ProjectNetworkGraphProps {
+ apps: AppWithRelations[];
+}
+
+const PolicyIcon = ({ policy, type, ports, useNetworkPolicy }: { policy: string, type: 'ingress' | 'egress', ports: string, useNetworkPolicy: boolean }) => {
+ let Icon = Globe;
+ let color = type === 'egress' ? 'text-blue-500' : 'text-green-500';
+ let title = policy;
+
+ switch (policy) {
+ case 'ALLOW_ALL':
+ Icon = Globe;
+ color = 'text-green-500';
+ break;
+ case 'NAMESPACE_ONLY':
+ Icon = Network;
+ color = 'text-blue-500';
+ break;
+ case 'DENY_ALL':
+ Icon = Lock;
+ color = 'text-red-500';
+ break;
+ case 'INTERNET_ONLY':
+ Icon = Cloud;
+ color = 'text-orange-500';
+ break;
+ default:
+ Icon = Shield;
+ color = 'text-gray-500';
+ }
+
+ return (
+
+ {useNetworkPolicy &&
}
+ {ports && type === 'ingress' &&
+ {ports}
+
}
+
+ );
+};
+
+const AppNode = ({ data }: { data: any }) => {
+ return (
+
+ );
+};
+
+const nodeTypes = {
+ appNode: AppNode,
+};
+
+const Legend = () => {
+ return (
+
+
+
+
Node Layout
+
+
+
Top Icon: Ingress Policy (Incoming traffic)
+
+
+
+
Bottom Icon: Egress Policy (Outgoing traffic)
+
+
+
+
Network Policy Types
+
+
+
+ Allow All
+
+
+
+ Project Only
+
+
+
+ Internet Only
+
+
+
+ Deny All
+
+
+
+
+
+ );
+};
+
+export default function ProjectNetworkGraph({ apps }: ProjectNetworkGraphProps) {
+ const router = useRouter();
+ const { nodes, edges } = useMemo(() => {
+ const nodes: Node[] = [];
+ const edges: Edge[] = [];
+
+ // Separate apps with domains and without domains
+ const appsWithDomains = apps.filter(app => app.appDomains.length > 0);
+ const appsWithoutDomains = apps.filter(app => app.appDomains.length === 0);
+
+ const nodeSpacing = 250; // Horizontal spacing between nodes
+ const rowSpacing = 150; // Vertical spacing between rows
+ const internetY = 100;
+ const firstRowY = internetY + 200;
+ const secondRowY = firstRowY + rowSpacing;
+
+ // Check if we need an Internet node
+ const hasInternetAccess = appsWithDomains.length > 0;
+ const internetX = (Math.max(appsWithDomains.length, appsWithoutDomains.length) * nodeSpacing) / 2;
+
+ if (hasInternetAccess) {
+ nodes.push({
+ id: 'INTERNET',
+ position: { x: internetX, y: internetY },
+ data: { label: 'Internet' },
+ style: { background: '#e0e0e0', border: '1px solid #777', padding: 10, borderRadius: '50%', width: 100, height: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 'bold' },
+ type: 'input', // It's a source only
+ });
+ }
+
+ // First row: Apps with domains (internet accessible)
+ appsWithDomains.forEach((app, index) => {
+ const totalWidth = (appsWithDomains.length - 1) * nodeSpacing;
+ const startX = internetX - (totalWidth / 2);
+ const x = startX + (index * nodeSpacing);
+ const y = firstRowY;
+
+ const ports = Array.from(new Set([
+ ...app.appDomains,
+ ...app.appPorts
+ ].map(d => d.port))).join(', ');
+
+ nodes.push({
+ id: app.id,
+ position: { x, y },
+ data: {
+ label: app.name,
+ ingressPolicy: app.ingressNetworkPolicy,
+ egressPolicy: app.egressNetworkPolicy,
+ appId: app.id,
+ app,
+ ports
+ },
+ type: 'appNode',
+ });
+
+ // Edge from Internet to App
+ const hostnames = app.appDomains.map(d => d.hostname).join(', ');
+ edges.push({
+ id: `INTERNET-${app.id}`,
+ source: 'INTERNET',
+ target: app.id,
+ label: `${hostnames}`,
+ markerEnd: {
+ type: MarkerType.ArrowClosed,
+ },
+ animated: true,
+ style: { stroke: '#000' },
+ });
+ });
+
+ // Second row: Apps without domains (not internet accessible)
+ appsWithoutDomains.forEach((app, index) => {
+ const totalWidth = (appsWithoutDomains.length - 1) * nodeSpacing;
+ const startX = internetX - (totalWidth / 2);
+ const x = startX + (index * nodeSpacing);
+ const y = secondRowY;
+
+ const ports = Array.from(new Set([
+ ...app.appDomains,
+ ...app.appPorts
+ ].map(d => d.port))).join(', ');
+
+ nodes.push({
+ id: app.id,
+ position: { x, y },
+ data: {
+ label: app.name,
+ ingressPolicy: app.ingressNetworkPolicy,
+ egressPolicy: app.egressNetworkPolicy,
+ appId: app.id,
+ app,
+ ports
+ },
+ type: 'appNode',
+ });
+ });
+
+ return { nodes, edges };
+ }, [apps]);
+ return (
+
+
+ {
+ if (node.id !== 'INTERNET') {
+ router.push(`/project/app/${node.id}`);
+ }
+ }}
+ >
+ {/*
+ */}
+
+
+
+
+ );
+}
diff --git a/src/app/project/[projectId]/project-overview.tsx b/src/app/project/[projectId]/project-overview.tsx
new file mode 100644
index 0000000..d9e4d9f
--- /dev/null
+++ b/src/app/project/[projectId]/project-overview.tsx
@@ -0,0 +1,39 @@
+'use client';
+
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import AppTable from "./apps-table";
+import ProjectNetworkGraph from "./project-network-graph";
+import { App } from "@prisma/client";
+import { UserSession } from "@/shared/model/sim-session.model";
+import { useRouter, useSearchParams } from "next/navigation";
+
+interface ProjectOverviewProps {
+ apps: any[]; // Using any to avoid complex type imports, as we know the data structure is correct
+ session: UserSession;
+ projectId: string;
+}
+
+export default function ProjectOverview({ apps, session, projectId }: ProjectOverviewProps) {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const currentTab = searchParams.get('tab') || 'table';
+
+ const handleTabChange = (value: string) => {
+ router.push(`?tab=${value}`, { scroll: false });
+ };
+
+ return (
+
+
+ Table View
+ Network Graph
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/project/app/[appId]/advanced/actions.ts b/src/app/project/app/[appId]/advanced/actions.ts
index 3faa242..645d5eb 100644
--- a/src/app/project/app/[appId]/advanced/actions.ts
+++ b/src/app/project/app/[appId]/advanced/actions.ts
@@ -4,6 +4,7 @@ import { SuccessActionResult } from "@/shared/model/server-action-error-return.m
import appService from "@/server/services/app.service";
import { isAuthorizedWriteForApp, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
import { BasicAuthEditModel, basicAuthEditZodModel } from "@/shared/model/basic-auth-edit.model";
+import { appNetworkPolicy } from "@/shared/model/network-policy.model";
export const saveBasicAuth = async (prevState: any, inputData: BasicAuthEditModel) =>
@@ -24,3 +25,21 @@ export const deleteBasicAuth = async (basicAuthId: string) =>
await appService.deleteBasicAuthById(basicAuthId);
return new SuccessActionResult(undefined, 'Successfully deleted item');
});
+
+export const saveNetworkPolicy = async (appId: string, ingressPolicy: string, egressPolicy: string, useNetworkPolicy: boolean) =>
+ simpleAction(async () => {
+ await isAuthorizedWriteForApp(appId);
+
+ // validate policies
+ appNetworkPolicy.parse(ingressPolicy);
+ appNetworkPolicy.parse(egressPolicy);
+
+ const app = await appService.getById(appId);
+ await appService.save({
+ ...app,
+ ingressNetworkPolicy: ingressPolicy,
+ egressNetworkPolicy: egressPolicy,
+ useNetworkPolicy: useNetworkPolicy
+ });
+ return new SuccessActionResult(undefined, 'Network policy saved');
+ });
diff --git a/src/app/project/app/[appId]/advanced/basic-auth.tsx b/src/app/project/app/[appId]/advanced/basic-auth.tsx
index ad909b1..a2a094c 100644
--- a/src/app/project/app/[appId]/advanced/basic-auth.tsx
+++ b/src/app/project/app/[appId]/advanced/basic-auth.tsx
@@ -39,7 +39,7 @@ export default function BasicAuth({ app, readonly }: {
- {app.appFileMounts.length} Auth Credentials
+ {app.appBasicAuths.length} Auth Credentials
Username
diff --git a/src/app/project/app/[appId]/advanced/network-policy.tsx b/src/app/project/app/[appId]/advanced/network-policy.tsx
new file mode 100644
index 0000000..3107106
--- /dev/null
+++ b/src/app/project/app/[appId]/advanced/network-policy.tsx
@@ -0,0 +1,167 @@
+'use client';
+
+import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
+import { AppExtendedModel } from "@/shared/model/app-extended.model";
+import { Button } from "@/components/ui/button";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Label } from "@/components/ui/label";
+import { useState } from "react";
+import { Toast } from "@/frontend/utils/toast.utils";
+import { saveNetworkPolicy } from "./actions";
+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
+import { HelpCircle, AlertTriangle } from "lucide-react";
+import { Switch } from "@/components/ui/switch";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+
+export default function NetworkPolicy({ app, readonly }: {
+ app: AppExtendedModel;
+ readonly: boolean;
+}) {
+ const [ingressPolicy, setIngressPolicy] = useState(app.ingressNetworkPolicy);
+ const [egressPolicy, setEgressPolicy] = useState(app.egressNetworkPolicy);
+ const [useNetworkPolicy, setUseNetworkPolicy] = useState(app.useNetworkPolicy);
+ const [showHelp, setShowHelp] = useState(false);
+
+ const handleSave = async () => {
+ await Toast.fromAction(() => saveNetworkPolicy(app.id, ingressPolicy, egressPolicy, useNetworkPolicy));
+ };
+
+ return (
+
+
+ Network Policy
+
+ Configure network traffic rules for your application.
+ Changes will take effect after the next deployment.
+ The Default setting for an App is "Allow All" wich allows traffic to/from all apps within the same project and the internet.
+
+
+
+
+
+
Enable Network Policies
+
+ Control whether network policies are applied to this application
+
+
+
+
+
+ {!useNetworkPolicy && (
+
+
+ Warning
+
+ Disabling network policies removes all network traffic restrictions for this application.
+ This may expose your application to unauthorized access and security risks.
+ Only disable this if you fully understand the security implications.
+
+
+ )}
+
+
+
+
Ingress Policy (Incoming Traffic)
+
+
+
+
+
+ Allow All (Internet + Project Apps)
+ Internet Only
+ Project Apps Only
+ Deny All
+
+
+
+ Controls who can connect to your pods.
+
+
+
+
Egress Policy (Outgoing Traffic)
+
+
+
+
+
+ Allow All (Internet + Project Apps)
+ Internet Only
+ Project Apps Only
+ Deny All
+
+
+
+ Controls where your pods can connect to.
+
+
+
+
+ {!readonly && (
+
+ Save
+
+
+
+
+
+
+
+
+ Network Policy Types
+
+ Understand how each policy type controls traffic to and from your application.
+
+
+
+
+
Allow All (Internet + Project Apps)
+
+ Allows traffic from/to all apps within the same project and the internet.
+ External internet traffic reaches your app through the Traefik ingress controller.
+ Blocks traffic from/to other projects/namespaces.
+
+
+
+
Internet Only
+
+ Allows traffic only from/to the internet (via Traefik ingress controller).
+ Blocks all direct pod-to-pod communication within the cluster, including same-project apps.
+ Useful for public-facing applications that should not communicate with internal services.
+
+
+
+
Project Apps Only
+
+ Allows traffic only from/to apps within the same project.
+ Blocks all internet traffic and traffic from other projects.
+ Ideal for internal microservices that should only communicate within your project.
+
+
+
+
Deny All
+
+ Blocks all incoming or outgoing traffic.
+ Use this for maximum isolation when your application should not communicate with any other service.
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/app/project/app/[appId]/app-tabs.tsx b/src/app/project/app/[appId]/app-tabs.tsx
index f95bdc7..7bab846 100644
--- a/src/app/project/app/[appId]/app-tabs.tsx
+++ b/src/app/project/app/[appId]/app-tabs.tsx
@@ -19,6 +19,7 @@ import DbCredentials from "./credentials/db-crendentials";
import VolumeBackupList from "./volumes/volume-backup";
import { VolumeBackupExtendedModel } from "@/shared/model/volume-backup-extended.model";
import BasicAuth from "./advanced/basic-auth";
+import NetworkPolicy from "./advanced/network-policy";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import DbToolsCard from "./credentials/db-tools";
import { RolePermissionEnum } from "@/shared/model/role-extended.model.ts";
@@ -88,6 +89,7 @@ export default function AppTabs({
+
)
diff --git a/src/app/project/app/[appId]/layout.tsx b/src/app/project/app/[appId]/layout.tsx
index 95c4ef5..1c9b675 100644
--- a/src/app/project/app/[appId]/layout.tsx
+++ b/src/app/project/app/[appId]/layout.tsx
@@ -1,15 +1,9 @@
-import { Inter } from "next/font/google";
import { isAuthorizedReadForApp } from "@/server/utils/action-wrapper.utils";
import appService from "@/server/services/app.service";
-import {
- Breadcrumb,
- BreadcrumbItem,
- BreadcrumbLink,
- BreadcrumbList,
- BreadcrumbSeparator,
-} from "@/components/ui/breadcrumb"
import PageTitle from "@/components/custom/page-title";
import AppActionButtons from "./app-action-buttons";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { AlertTriangle } from "lucide-react";
export default async function RootLayout({
children,
@@ -26,12 +20,24 @@ export default async function RootLayout({
const session = await isAuthorizedReadForApp(appId);
const app = await appService.getExtendedById(appId);
+ const showIngressWarning = app.appDomains.length > 0 && app.ingressNetworkPolicy !== 'ALLOW_ALL' && app.ingressNetworkPolicy !== 'INTERNET_ONLY';
+
return (
+ {showIngressWarning && (
+
+
+ Warning
+
+ You have configured domains for this app, but the Ingress Network Policy is not set to "Allow All" or "Internet Only".
+ External traffic via the domain might be blocked.
+
+
+ )}
{children}
diff --git a/src/app/settings/maintenance/qs-maintenance-settings.tsx b/src/app/settings/maintenance/qs-maintenance-settings.tsx
index c8045c5..477d8ed 100644
--- a/src/app/settings/maintenance/qs-maintenance-settings.tsx
+++ b/src/app/settings/maintenance/qs-maintenance-settings.tsx
@@ -1,13 +1,13 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { cleanupOldBuildJobs, cleanupOldTmpFiles, deleteAllFailedAndSuccededPods, deleteOldAppLogs, purgeRegistryImages, updateRegistry } from "../server/actions";
+import { cleanupOldBuildJobs, cleanupOldTmpFiles, deleteAllFailedAndSuccededPods, deleteAllNetworkPolicies, deleteOldAppLogs, purgeRegistryImages, updateRegistry } from "../server/actions";
import { Button } from "@/components/ui/button";
import { Toast } from "@/frontend/utils/toast.utils";
import { useConfirmDialog } from "@/frontend/states/zustand.states";
import { LogsDialog } from "@/components/custom/logs-overlay";
import { Constants } from "@/shared/utils/constants";
-import { RotateCcw, SquareTerminal, Trash } from "lucide-react";
+import { RotateCcw, SquareTerminal, Trash, ShieldOff } from "lucide-react";
export default function QuickStackMaintenanceSettings({
qsPodName
@@ -97,5 +97,23 @@ export default function QuickStackMaintenanceSettings({
+
+
+ Network Policies
+
+
+
+ {
+ if (await useConfirm.openConfirmDialog({
+ title: '⚠️ Delete All Network Policies',
+ description: 'WARNING: This is a bad idea! This action will delete ALL network policies across all namespaces. Your applications will lose all network security restrictions. Only use this for troubleshooting or emergency situations. Are you absolutely sure?',
+ okButton: "Yes, Delete All Policies",
+ })) {
+ Toast.fromAction(() => deleteAllNetworkPolicies());
+ }
+ }}> Delete All Network Policies
+
+
+
;
}
\ No newline at end of file
diff --git a/src/app/settings/server/actions.ts b/src/app/settings/server/actions.ts
index f18ed56..d48128f 100644
--- a/src/app/settings/server/actions.ts
+++ b/src/app/settings/server/actions.ts
@@ -19,6 +19,7 @@ import maintenanceService from "@/server/services/standalone-services/maintenanc
import appLogsService from "@/server/services/standalone-services/app-logs.service";
import systemBackupService from "@/server/services/standalone-services/system-backup.service";
import backupService from "@/server/services/standalone-services/backup.service";
+import networkPolicyService from "@/server/services/network-policy.service";
export const updateIngressSettings = async (prevState: any, inputData: QsIngressSettingsModel) =>
saveFormAction(inputData, qsIngressSettingsZodModel, async (validatedData) => {
@@ -194,4 +195,13 @@ export const runSystemBackupNow = async () =>
await backupService.runSystemBackup();
return new SuccessActionResult(undefined, 'System backup started successfully');
+ });
+
+export const deleteAllNetworkPolicies = async () =>
+ simpleAction(async () => {
+ await getAdminUserSession();
+
+ const deletedCount = await networkPolicyService.deleteAllNetworkPolicies();
+
+ return new SuccessActionResult(undefined, `Successfully deleted all (${deletedCount}) network policies.`);
});
\ No newline at end of file
diff --git a/src/server/services/app.service.ts b/src/server/services/app.service.ts
index 8e35ea4..c74bb3d 100644
--- a/src/server/services/app.service.ts
+++ b/src/server/services/app.service.ts
@@ -12,6 +12,7 @@ import pvcService from "./pvc.service";
import svcService from "./svc.service";
import deploymentLogService, { dlog } from "./deployment-logs.service";
import crypto from "crypto";
+import networkPolicyService from "./network-policy.service";
class AppService {
@@ -53,6 +54,7 @@ class AppService {
await ingressService.deleteAllIngressForApp(existingApp.projectId, existingApp.id);
await pvcService.deleteAllPvcOfApp(existingApp.projectId, existingApp.id);
await buildService.deleteAllBuildsOfApp(existingApp.id);
+ await networkPolicyService.deleteNetworkPolicy(existingApp.id, existingApp.projectId);
await dataAccess.client.app.delete({
where: {
id
@@ -72,6 +74,10 @@ class AppService {
where: {
projectId
},
+ include: {
+ appPorts: true,
+ appDomains: true
+ },
orderBy: {
name: 'asc'
}
diff --git a/src/server/services/db-tool-services/base-db-tool.service.ts b/src/server/services/db-tool-services/base-db-tool.service.ts
index 60eb615..19fb868 100644
--- a/src/server/services/db-tool-services/base-db-tool.service.ts
+++ b/src/server/services/db-tool-services/base-db-tool.service.ts
@@ -11,6 +11,7 @@ import svcService from "../svc.service";
import podService from "../pod.service";
import appService from "../app.service";
import { AppExtendedModel } from "@/shared/model/app-extended.model";
+import networkPolicyService from "../network-policy.service";
export class BaseDbToolService {
@@ -78,7 +79,7 @@ export class BaseDbToolService {
const hostnameDnsProviderHostname = await hostnameDnsProviderService.getDomainForApp(toolAppName);
console.log(`Creating DB Tool ${toolAppName} deployment for app ${appId}`);
- await this.createOrUpdateDbGateDeployment(app, deplyomentBuilder);
+ await this.createOrUpdateDbToolDeplyoment(app, deplyomentBuilder);
console.log(`Creating service for DB Tool ${toolAppName} for app ${appId}`);
await svcService.createOrUpdateService(namespace, toolAppName, [{
@@ -90,6 +91,9 @@ export class BaseDbToolService {
console.log(`Creating ingress for DB Tool ${toolAppName} for app ${appId}`);
await this.createOrUpdateIngress(toolAppName, namespace, hostnameDnsProviderHostname);
+ console.log(`Creating network policy for DB Tool ${toolAppName} for app ${appId}`);
+ await networkPolicyService.reconcileDbToolNetworkPolicy(toolAppName, appId, namespace);
+
const fileBrowserPods = await podService.getPodsForApp(namespace, toolAppName);
for (const pod of fileBrowserPods) {
await podService.waitUntilPodIsRunningFailedOrSucceded(namespace, pod.podName);
@@ -97,7 +101,7 @@ export class BaseDbToolService {
}
- private async createOrUpdateDbGateDeployment(app: AppExtendedModel, deplyomentBuilder: (app: AppExtendedModel) => V1Deployment | Promise) {
+ private async createOrUpdateDbToolDeplyoment(app: AppExtendedModel, deplyomentBuilder: (app: AppExtendedModel) => V1Deployment | Promise) {
const body = await deplyomentBuilder(app);
const toolAppName = this.appIdToToolNameConverter(app.id);
await deploymentService.applyDeployment(app.projectId, toolAppName, body);
@@ -128,6 +132,8 @@ export class BaseDbToolService {
// do not delete ingress to reduce cert-manager issues --> todo; add cleanup function in maintenance section
//await k3s.network.deleteNamespacedIngress(KubeObjectNameUtils.getIngressName(toolAppName), projectId);
}
+
+ await networkPolicyService.deleteDbToolNetworkPolicy(toolAppName, projectId);
}
private async createOrUpdateIngress(dbGateAppName: string, namespace: string, hostname: string) {
diff --git a/src/server/services/db-tool-services/dbgate.service.ts b/src/server/services/db-tool-services/dbgate.service.ts
index 28291ee..211fd6c 100644
--- a/src/server/services/db-tool-services/dbgate.service.ts
+++ b/src/server/services/db-tool-services/dbgate.service.ts
@@ -108,7 +108,8 @@ class DbGateService extends BaseDbToolService {
template: {
metadata: {
labels: {
- app: dbGateAppName
+ app: dbGateAppName,
+ [Constants.QS_ANNOTATION_CONTAINER_TYPE]: Constants.QS_ANNOTATION_CONTAINER_TYPE_DB_TOOL
},
annotations: {
[Constants.QS_ANNOTATION_APP_ID]: app.id,
diff --git a/src/server/services/db-tool-services/pgadmin.service.ts b/src/server/services/db-tool-services/pgadmin.service.ts
index 5d14d7b..ff81ac5 100644
--- a/src/server/services/db-tool-services/pgadmin.service.ts
+++ b/src/server/services/db-tool-services/pgadmin.service.ts
@@ -62,7 +62,8 @@ class PgAdminService extends BaseDbToolService {
template: {
metadata: {
labels: {
- app: appName
+ app: appName,
+ [Constants.QS_ANNOTATION_CONTAINER_TYPE]: Constants.QS_ANNOTATION_CONTAINER_TYPE_DB_TOOL
},
annotations: {
[Constants.QS_ANNOTATION_APP_ID]: app.id,
diff --git a/src/server/services/db-tool-services/phpmyadmin.service.ts b/src/server/services/db-tool-services/phpmyadmin.service.ts
index 8054d36..2175e8c 100644
--- a/src/server/services/db-tool-services/phpmyadmin.service.ts
+++ b/src/server/services/db-tool-services/phpmyadmin.service.ts
@@ -45,7 +45,8 @@ class PhpMyAdminService extends BaseDbToolService {
template: {
metadata: {
labels: {
- app: appName
+ app: appName,
+ [Constants.QS_ANNOTATION_CONTAINER_TYPE]: Constants.QS_ANNOTATION_CONTAINER_TYPE_DB_TOOL
},
annotations: {
[Constants.QS_ANNOTATION_APP_ID]: app.id,
diff --git a/src/server/services/deployment.service.ts b/src/server/services/deployment.service.ts
index 2388f6f..484a4b8 100644
--- a/src/server/services/deployment.service.ts
+++ b/src/server/services/deployment.service.ts
@@ -18,6 +18,7 @@ import configMapService from "./config-map.service";
import secretService from "./secret.service";
import fileBrowserService from "./file-browser-service";
import podService from "./pod.service";
+import networkPolicyService from "./network-policy.service";
class DeploymentService {
@@ -92,6 +93,9 @@ class DeploymentService {
const envVars = EnvVarUtils.parseEnvVariables(app);
dlog(deploymentId, `Configured ${envVars.length} Env Variables.`);
+ await networkPolicyService.reconcileNetworkPolicy(app);
+ dlog(deploymentId, `Configured Network Policy.`);
+
const existingDeployment = await this.getDeployment(app.projectId, app.id);
const body: V1Deployment = {
metadata: {
diff --git a/src/server/services/file-browser-service.ts b/src/server/services/file-browser-service.ts
index cf20794..889f1b2 100644
--- a/src/server/services/file-browser-service.ts
+++ b/src/server/services/file-browser-service.ts
@@ -11,6 +11,7 @@ import podService from "./pod.service";
import bcrypt from "bcrypt";
import hostnameDnsProviderService from "./hostname-dns-provider.service";
import pvcService from "./pvc.service";
+import networkPolicyService from "./network-policy.service";
class FileBrowserService {
@@ -55,6 +56,9 @@ class FileBrowserService {
console.log(`Creating ingress for filebrowser for volume ${volumeId}`);
await this.createOrUpdateIngress(kubeAppName, namespace, appId, projectId, traefikHostname);
+ console.log(`Creating network policy for filebrowser for volume ${volumeId}`);
+ await networkPolicyService.reconcileFileBrowserNetworkPolicy(kubeAppName, projectId);
+
const fileBrowserPods = await podService.getPodsForApp(projectId, kubeAppName);
for (const pod of fileBrowserPods) {
await podService.waitUntilPodIsRunningFailedOrSucceded(projectId, pod.podName);
@@ -92,6 +96,8 @@ class FileBrowserService {
if (existingIngress) {
await k3s.network.deleteNamespacedIngress(KubeObjectNameUtils.getIngressName(kubeAppName), projectId);
}
+
+ await networkPolicyService.deleteFileBrowserNetworkPolicy(kubeAppName, projectId);
}
private async createOrUpdateIngress(kubeAppName: string, namespace: string, appId: string, projectId: string, traefikHostname: string) {
diff --git a/src/server/services/network-policy.service.ts b/src/server/services/network-policy.service.ts
index 8a45654..556292e 100644
--- a/src/server/services/network-policy.service.ts
+++ b/src/server/services/network-policy.service.ts
@@ -1,47 +1,450 @@
+import { AppExtendedModel } from "@/shared/model/app-extended.model";
+import k3s from "../adapter/kubernetes-api.adapter";
+import { V1NetworkPolicy, V1NetworkPolicyEgressRule, V1NetworkPolicyIngressRule, V1NetworkPolicyPeer } from "@kubernetes/client-node";
+import { KubeObjectNameUtils } from "../utils/kube-object-name.utils";
+import { Constants } from "../../shared/utils/constants";
+import { appNetworkPolicy, AppNetworkPolicyType } from "@/shared/model/network-policy.model";
+
+class NetworkPolicyService {
+
+ async reconcileNetworkPolicy(app: AppExtendedModel) {
+ const policyName = KubeObjectNameUtils.toNetworkPolicyName(app.id);
+ const namespace = app.projectId;
+
+ // If network policies are disabled, delete existing policy if any and return
+ if (!app.useNetworkPolicy) {
+ await this.deleteNetworkPolicy(app.id, app.projectId);
+ return;
+ }
+
+ const ingressPolicy = this.normalizePolicy(app.ingressNetworkPolicy);
+ const egressPolicy = this.normalizePolicy(app.egressNetworkPolicy);
+
+ const policy: V1NetworkPolicy = {
+ apiVersion: "networking.k8s.io/v1",
+ kind: "NetworkPolicy",
+ metadata: {
+ name: policyName,
+ namespace: namespace,
+ labels: {
+ app: app.id
+ },
+ annotations: {
+ [Constants.QS_ANNOTATION_APP_ID]: app.id,
+ [Constants.QS_ANNOTATION_PROJECT_ID]: app.projectId,
+ }
+ },
+ spec: {
+ podSelector: {
+ matchLabels: {
+ app: app.id
+ }
+ },
+ policyTypes: ["Ingress", "Egress"],
+ ingress: this.getIngressRules(ingressPolicy),
+ egress: this.getEgressRules(egressPolicy)
+ }
+ };
+ await this.applyNetworkPolicy(namespace, policyName, policy);
+ }
+
+ private normalizePolicy(raw: string): AppNetworkPolicyType {
+ const parsed = appNetworkPolicy.safeParse(raw);
+ return parsed.success ? parsed.data : 'ALLOW_ALL';
+ }
+
+ private getIngressRules(policyType: AppNetworkPolicyType): V1NetworkPolicyIngressRule[] {
+ const rules: V1NetworkPolicyIngressRule[] = [];
+
+ const traefikFrom: V1NetworkPolicyPeer[] = [
+ {
+ namespaceSelector: {
+ matchLabels: {
+ 'kubernetes.io/metadata.name': 'kube-system'
+ }
+ },
+ podSelector: {
+ matchLabels: {
+ 'app.kubernetes.io/name': 'traefik'
+ }
+ }
+ },
+ /* // Fallback label used in some clusters/charts
+ {
+ namespaceSelector: {
+ matchLabels: {
+ 'kubernetes.io/metadata.name': 'kube-system'
+ }
+ },
+ podSelector: {
+ matchLabels: {
+ app: 'traefik'
+ }
+ }
+ }*/
+ ];
+
+ const backupPodFrom: V1NetworkPolicyPeer[] = [{
+ podSelector: {
+ matchLabels: {
+ [Constants.QS_ANNOTATION_CONTAINER_TYPE]: Constants.QS_ANNOTATION_CONTAINER_TYPE_DB_BACKUP_JOB
+ }
+ }
+ }];
+
+ const dbToolPod: V1NetworkPolicyPeer[] = [{
+ podSelector: {
+ matchLabels: {
+ [Constants.QS_ANNOTATION_CONTAINER_TYPE]: Constants.QS_ANNOTATION_CONTAINER_TYPE_DB_TOOL
+ }
+ }
+ }];
+
+ if (policyType === 'ALLOW_ALL') {
+ // Allow from same namespace and from Traefik (internet traffic comes through Traefik)
+ rules.push({
+ from: [
+ ...traefikFrom,
+ {
+ podSelector: {} // Selects all pods in the same namespace
+ }
+ ]
+ });
+ } else if (policyType === 'INTERNET_ONLY') {
+ // Allow from Traefik (internet traffic comes through Traefik) and from DB-backup jobs.
+ // Block other internal pod traffic.
+ rules.push({
+ from: [
+ ...traefikFrom,
+ ...backupPodFrom,
+ ...dbToolPod
+ ]
+ });
+ } else if (policyType === 'NAMESPACE_ONLY') {
+ // Allow only from same namespace
+ rules.push({
+ from: [{
+ podSelector: {} // Selects all pods in the same namespace
+ }]
+ });
+ } else if (policyType === 'DENY_ALL') {
+ // No rules means deny all --> except the separate container for database backups
+ rules.push({
+ from: [
+ ...backupPodFrom,
+ ...dbToolPod
+ ]
+ });
+ }
+
+ return rules;
+ }
+
+ private getEgressRules(policyType: AppNetworkPolicyType): V1NetworkPolicyEgressRule[] {
+ const rules: V1NetworkPolicyEgressRule[] = [];
+
+ // allow DNS (kube-dns/coredns) on UDP/TCP 53
+ const dnsRuleAllow: V1NetworkPolicyEgressRule = {
+ to: [
+ {
+ namespaceSelector: {
+ matchLabels: {
+ "kubernetes.io/metadata.name": "kube-system"
+ }
+ },
+ podSelector: {
+ matchLabels: {
+ "k8s-app": "kube-dns"
+ }
+ }
+ },
+ {
+ namespaceSelector: {
+ matchLabels: {
+ "kubernetes.io/metadata.name": "kube-system"
+ }
+ },
+ podSelector: {
+ matchLabels: {
+ "k8s-app": "coredns"
+ }
+ }
+ }
+ ],
+ ports: [
+ { protocol: 'UDP', port: 53 as any },
+ { protocol: 'TCP', port: 53 as any }
+ ]
+ };
+
+ if (policyType === 'ALLOW_ALL') {
+ // Allow Internet + Local Namespace, Block other namespaces (Private IPs)
+ rules.push(dnsRuleAllow);
+ rules.push({
+ to: [
+ {
+ ipBlock: {
+ cidr: '0.0.0.0/0',
+ except: [
+ '10.0.0.0/8',
+ '172.16.0.0/12',
+ '192.168.0.0/16'
+ ]
+ }
+ },
+ {
+ podSelector: {} // Allow all in same namespace
+ }
+ ]
+ });
+ } else if (policyType === 'INTERNET_ONLY') {
+ // Allow only to internet, block internal cluster traffic
+ rules.push(dnsRuleAllow);
+ rules.push({
+ to: [{
+ ipBlock: {
+ cidr: '0.0.0.0/0',
+ except: [
+ '10.0.0.0/8',
+ '172.16.0.0/12',
+ '192.168.0.0/16'
+ ]
+ }
+ }]
+ });
+ } else if (policyType === 'NAMESPACE_ONLY') {
+ // Allow only to same namespace
+ rules.push(dnsRuleAllow);
+ rules.push({
+ to: [{
+ podSelector: {}
+ }]
+ });
+ } else if (policyType === 'DENY_ALL') {
+ // Allow completely nothing
+ }
+
+ return rules;
+ }
+
+ async deleteNetworkPolicy(appId: string, projectId: string) {
+ const policyName = KubeObjectNameUtils.toNetworkPolicyName(appId);
+ const existingNetworkPolicy = await this.getExistingNetworkPolicy(projectId, policyName);
+ if (!existingNetworkPolicy) {
+ return;
+ }
+ await k3s.network.deleteNamespacedNetworkPolicy(policyName, projectId);
+ }
+
+ private async applyNetworkPolicy(namespace: string, policyName: string, body: V1NetworkPolicy) {
+ const existing = await this.getExistingNetworkPolicy(namespace, policyName);
+ if (existing) {
+ await k3s.network.replaceNamespacedNetworkPolicy(policyName, namespace, body);
+ } else {
+ await k3s.network.createNamespacedNetworkPolicy(namespace, body);
+ }
+ }
+
+ private async getExistingNetworkPolicy(namespace: string, policyName: string) {
+ const allPolicies = await k3s.network.listNamespacedNetworkPolicy(namespace);
+ return allPolicies.body.items.find(np => np.metadata?.name === policyName);
+ }
+
+ async reconcileDbToolNetworkPolicy(dbToolAppName: string, dbAppId: string, projectId: string) {
+ const policyName = KubeObjectNameUtils.toNetworkPolicyName(dbToolAppName);
+ const namespace = projectId;
+
+ const policy: V1NetworkPolicy = {
+ apiVersion: "networking.k8s.io/v1",
+ kind: "NetworkPolicy",
+ metadata: {
+ name: policyName,
+ namespace: namespace,
+ labels: {
+ app: dbToolAppName,
+ 'db-tool': 'true'
+ },
+ annotations: {
+ [Constants.QS_ANNOTATION_APP_ID]: dbAppId,
+ [Constants.QS_ANNOTATION_PROJECT_ID]: projectId,
+ }
+ },
+ spec: {
+ podSelector: {
+ matchLabels: {
+ app: dbToolAppName
+ }
+ },
+ policyTypes: ["Ingress", "Egress"],
+ ingress: [
+ {
+ // Allow from Traefik (internet traffic)
+ from: [
+ {
+ namespaceSelector: {
+ matchLabels: {
+ 'kubernetes.io/metadata.name': 'kube-system'
+ }
+ },
+ podSelector: {
+ matchLabels: {
+ 'app.kubernetes.io/name': 'traefik'
+ }
+ }
+ }
+ ]
+ }
+ ],
+ egress: [
+ {
+ // Allow DNS
+ to: [
+ {
+ namespaceSelector: {
+ matchLabels: {
+ "kubernetes.io/metadata.name": "kube-system"
+ }
+ },
+ podSelector: {
+ matchLabels: {
+ "k8s-app": "kube-dns"
+ }
+ }
+ },
+ {
+ namespaceSelector: {
+ matchLabels: {
+ "kubernetes.io/metadata.name": "kube-system"
+ }
+ },
+ podSelector: {
+ matchLabels: {
+ "k8s-app": "coredns"
+ }
+ }
+ }
+ ],
+ ports: [
+ { protocol: 'UDP', port: 53 as any },
+ { protocol: 'TCP', port: 53 as any }
+ ]
+ },
+ {
+ // Allow only to database pod in same namespace
+ to: [
+ {
+ podSelector: {
+ matchLabels: {
+ app: dbAppId
+ }
+ }
+ }
+ ]
+ }
+ ]
+ }
+ };
+ console.log('Creating DB Tool Network Policy:', JSON.stringify(policy, null, 2));
+ await this.applyNetworkPolicy(namespace, policyName, policy);
+ }
+
+ async deleteDbToolNetworkPolicy(dbToolAppName: string, projectId: string) {
+ const policyName = KubeObjectNameUtils.toNetworkPolicyName(dbToolAppName);
+ const existingNetworkPolicy = await this.getExistingNetworkPolicy(projectId, policyName);
+ if (!existingNetworkPolicy) {
+ return;
+ }
+ await k3s.network.deleteNamespacedNetworkPolicy(policyName, projectId);
+ }
+
+ async reconcileFileBrowserNetworkPolicy(fileBrowserAppName: string, projectId: string) {
+ const policyName = KubeObjectNameUtils.toNetworkPolicyName(fileBrowserAppName);
+ const namespace = projectId;
+
+ const policy: V1NetworkPolicy = {
+ apiVersion: "networking.k8s.io/v1",
+ kind: "NetworkPolicy",
+ metadata: {
+ name: policyName,
+ namespace: namespace,
+ labels: {
+ app: fileBrowserAppName,
+ 'file-browser': 'true'
+ },
+ annotations: {
+ [Constants.QS_ANNOTATION_PROJECT_ID]: projectId,
+ }
+ },
+ spec: {
+ podSelector: {
+ matchLabels: {
+ app: fileBrowserAppName
+ }
+ },
+ policyTypes: ["Ingress", "Egress"],
+ ingress: [
+ {
+ // Allow from Traefik (internet traffic)
+ from: [
+ {
+ namespaceSelector: {
+ matchLabels: {
+ 'kubernetes.io/metadata.name': 'kube-system'
+ }
+ },
+ podSelector: {
+ matchLabels: {
+ 'app.kubernetes.io/name': 'traefik'
+ }
+ }
+ }
+ ]
+ }
+ ],
+ egress: [] // Deny all outgoing traffic
+ }
+ };
+ console.log('Creating FileBrowser Network Policy:', JSON.stringify(policy, null, 2));
+ await this.applyNetworkPolicy(namespace, policyName, policy);
+ }
+
+ async deleteFileBrowserNetworkPolicy(fileBrowserAppName: string, projectId: string) {
+ const policyName = KubeObjectNameUtils.toNetworkPolicyName(fileBrowserAppName);
+ const existingNetworkPolicy = await this.getExistingNetworkPolicy(projectId, policyName);
+ if (!existingNetworkPolicy) {
+ return;
+ }
+ await k3s.network.deleteNamespacedNetworkPolicy(policyName, projectId);
+ }
+
+ async deleteAllNetworkPolicies() {
+ const namespaces = await k3s.core.listNamespace();
+ let deletedCount = 0;
+
+ for (const ns of namespaces.body.items) {
+ const namespace = ns.metadata?.name;
+ if (!namespace) continue;
+
+ try {
+ const policies = await k3s.network.listNamespacedNetworkPolicy(namespace);
+ for (const policy of policies.body.items) {
+ const policyName = policy.metadata?.name;
+ if (policyName) {
+ await k3s.network.deleteNamespacedNetworkPolicy(policyName, namespace);
+ deletedCount++;
+ }
+ }
+ } catch (error) {
+ console.error(`Error deleting network policies in namespace ${namespace}:`, error);
+ }
+ }
+
+ return deletedCount;
+ }
+}
+
+const networkPolicyService = new NetworkPolicyService();
+export default networkPolicyService;
-
-/*
-
-apiVersion: networking.k8s.io/v1
-kind: NetworkPolicy
-metadata:
- name: allow-same-namespace-and-external-traffic
- namespace: proj-databases-ce53614e
-spec:
- podSelector: {}
- policyTypes:
- - Ingress
- - Egress
- ingress:
- - from:
- - podSelector: {}
- - from:
- - podSelector:
- matchLabels:
- app.kubernetes.io/name: traefik
- - from:
- - namespaceSelector:
- matchLabels:
- name: kube-system
- - from:
- - ipBlock:
- cidr: 0.0.0.0/0
- egress:
- - to:
- - ipBlock:
- cidr: 0.0.0.0/0
- - to:
- - namespaceSelector: {}
- podSelector:
- matchLabels:
- k8s-app: kube-dns
- ports:
- - port: 53
- protocol: UDP
- - to:
- - podSelector: {}
-
-
-*/
\ No newline at end of file
diff --git a/src/server/services/standalone-services/database-backup-services/mariadb-backup.service.ts b/src/server/services/standalone-services/database-backup-services/mariadb-backup.service.ts
index d840585..592dbc7 100644
--- a/src/server/services/standalone-services/database-backup-services/mariadb-backup.service.ts
+++ b/src/server/services/standalone-services/database-backup-services/mariadb-backup.service.ts
@@ -16,7 +16,7 @@ class MariaDbBackupService {
await namespaceService.createNamespaceIfNotExists(backupNamespace);
- const jobName = KubeObjectNameUtils.addRandomSuffix(`backup-mysql-${app.id}`);
+ const jobName = KubeObjectNameUtils.addRandomSuffix(`backup-mariadb-${app.id}`);
console.log(`Creating MariaDB/MySQL backup job with name: ${jobName}`);
const dbCredentials = AppTemplateUtils.getDatabaseModelFromApp(app);
@@ -31,7 +31,7 @@ class MariaDbBackupService {
const endpoint = backupVolume.target.endpoint.includes('http') ? backupVolume.target.endpoint : `https://${backupVolume.target.endpoint}`;
console.log(`S3 Endpoint: ${endpoint}`);
- const imageTag = process.env.QS_VERSION?.includes('canary') ? 'canary' : 'latest';
+ const imageTag = process.env.QS_VERSION?.includes('canary') || process.env.NODE_ENV !== 'production' ? 'canary' : 'latest';
const jobDefinition: V1Job = {
apiVersion: "batch/v1",
@@ -42,11 +42,17 @@ class MariaDbBackupService {
annotations: {
[Constants.QS_ANNOTATION_APP_ID]: app.id,
[Constants.QS_ANNOTATION_PROJECT_ID]: app.projectId,
+ [Constants.QS_ANNOTATION_CONTAINER_TYPE]: Constants.QS_ANNOTATION_CONTAINER_TYPE_DB_BACKUP_JOB
}
},
spec: {
ttlSecondsAfterFinished: 86400, // 1 day
template: {
+ metadata: {
+ labels: {
+ [Constants.QS_ANNOTATION_CONTAINER_TYPE]: Constants.QS_ANNOTATION_CONTAINER_TYPE_DB_BACKUP_JOB,
+ }
+ },
spec: {
containers: [
{
diff --git a/src/server/services/standalone-services/database-backup-services/mongodb-backup.service.ts b/src/server/services/standalone-services/database-backup-services/mongodb-backup.service.ts
index b9f5588..dc63a83 100644
--- a/src/server/services/standalone-services/database-backup-services/mongodb-backup.service.ts
+++ b/src/server/services/standalone-services/database-backup-services/mongodb-backup.service.ts
@@ -7,7 +7,6 @@ import namespaceService from "../../namespace.service";
import sharedBackupService from "./shared-backup.service";
import { VolumeBackupExtendedModel } from "@/shared/model/volume-backup-extended.model";
import { AppExtendedModel } from "@/shared/model/app-extended.model";
-
class MongoDbBackupService {
async backupMongoDb(backupVolume: VolumeBackupExtendedModel, app: AppExtendedModel) {
@@ -32,7 +31,7 @@ class MongoDbBackupService {
const endpoint = backupVolume.target.endpoint.includes('http') ? backupVolume.target.endpoint : `https://${backupVolume.target.endpoint}`;
console.log(`S3 Endpoint: ${endpoint}`);
- const imageTag = process.env.QS_VERSION?.includes('canary') ? 'canary' : 'latest';
+ const imageTag = process.env.QS_VERSION?.includes('canary') || process.env.NODE_ENV !== 'production' ? 'canary' : 'latest';
const jobDefinition: V1Job = {
apiVersion: "batch/v1",
@@ -43,11 +42,17 @@ class MongoDbBackupService {
annotations: {
[Constants.QS_ANNOTATION_APP_ID]: app.id,
[Constants.QS_ANNOTATION_PROJECT_ID]: app.projectId,
+ [Constants.QS_ANNOTATION_CONTAINER_TYPE]: Constants.QS_ANNOTATION_CONTAINER_TYPE_DB_BACKUP_JOB
}
},
spec: {
ttlSecondsAfterFinished: 86400, // 1 day
template: {
+ metadata: {
+ labels: {
+ [Constants.QS_ANNOTATION_CONTAINER_TYPE]: Constants.QS_ANNOTATION_CONTAINER_TYPE_DB_BACKUP_JOB,
+ }
+ },
spec: {
containers: [
{
diff --git a/src/server/services/standalone-services/database-backup-services/postgres-backup.service.ts b/src/server/services/standalone-services/database-backup-services/postgres-backup.service.ts
index 99de043..c2b28b9 100644
--- a/src/server/services/standalone-services/database-backup-services/postgres-backup.service.ts
+++ b/src/server/services/standalone-services/database-backup-services/postgres-backup.service.ts
@@ -31,7 +31,7 @@ class PostgresBackupService {
const endpoint = backupVolume.target.endpoint.includes('http') ? backupVolume.target.endpoint : `https://${backupVolume.target.endpoint}`;
console.log(`S3 Endpoint: ${endpoint}`);
- const imageTag = process.env.QS_VERSION?.includes('canary') ? 'canary' : 'latest';
+ const imageTag = process.env.QS_VERSION?.includes('canary') || process.env.NODE_ENV !== 'production' ? 'canary' : 'latest';
const jobDefinition: V1Job = {
apiVersion: "batch/v1",
@@ -42,11 +42,18 @@ class PostgresBackupService {
annotations: {
[Constants.QS_ANNOTATION_APP_ID]: app.id,
[Constants.QS_ANNOTATION_PROJECT_ID]: app.projectId,
+ [Constants.QS_ANNOTATION_CONTAINER_TYPE]: Constants.QS_ANNOTATION_CONTAINER_TYPE_DB_BACKUP_JOB
}
},
spec: {
ttlSecondsAfterFinished: 86400, // 1 day
template: {
+ metadata: {
+ labels: {
+ [Constants.QS_ANNOTATION_CONTAINER_TYPE]: Constants.QS_ANNOTATION_CONTAINER_TYPE_DB_BACKUP_JOB,
+ 'qs-backup-job': jobName
+ }
+ },
spec: {
containers: [
{
diff --git a/src/server/utils/kube-object-name.utils.ts b/src/server/utils/kube-object-name.utils.ts
index d218d88..b47aba6 100644
--- a/src/server/utils/kube-object-name.utils.ts
+++ b/src/server/utils/kube-object-name.utils.ts
@@ -80,4 +80,8 @@ export class KubeObjectNameUtils {
static toPgAdminId(appId: string): `pga-${string}` {
return `pga-${appId}`;
}
+
+ static toNetworkPolicyName(appId: string): string {
+ return `np-${appId}`.substring(0, 63); // not more than 63 characters
+ }
}
\ No newline at end of file
diff --git a/src/shared/model/generated-zod/app.ts b/src/shared/model/generated-zod/app.ts
index 92ac6d0..b5cac27 100644
--- a/src/shared/model/generated-zod/app.ts
+++ b/src/shared/model/generated-zod/app.ts
@@ -23,6 +23,9 @@ export const AppModel = z.object({
cpuReservation: z.number().int().nullish(),
cpuLimit: z.number().int().nullish(),
webhookId: z.string().nullish(),
+ ingressNetworkPolicy: z.string(),
+ egressNetworkPolicy: z.string(),
+ useNetworkPolicy: z.boolean(),
createdAt: z.date(),
updatedAt: z.date(),
})
diff --git a/src/shared/model/network-policy.model.ts b/src/shared/model/network-policy.model.ts
new file mode 100644
index 0000000..be73d54
--- /dev/null
+++ b/src/shared/model/network-policy.model.ts
@@ -0,0 +1,4 @@
+import { z } from "zod";
+
+export const appNetworkPolicy = z.enum(["ALLOW_ALL", "INTERNET_ONLY", "NAMESPACE_ONLY", "DENY_ALL"]);
+export type AppNetworkPolicyType = z.infer;
\ No newline at end of file
diff --git a/src/shared/templates/apps/wordpress.template.ts b/src/shared/templates/apps/wordpress.template.ts
index 38f6079..7ceb01d 100644
--- a/src/shared/templates/apps/wordpress.template.ts
+++ b/src/shared/templates/apps/wordpress.template.ts
@@ -1,3 +1,4 @@
+import { Constants } from "@/shared/utils/constants";
import { AppTemplateModel } from "../../model/app-template.model";
import { mariadbAppTemplate } from "../databases/mariadb.template";
@@ -35,9 +36,12 @@ export const wordpressAppTemplate: AppTemplateModel = {
sourceType: 'CONTAINER',
containerImageSource: "",
replicas: 1,
+ ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS,
+ egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS,
envVars: `MYSQL_DATABASE=wordpress
MYSQL_USER=wordpress
`,
+ useNetworkPolicy: true,
},
appDomains: [],
appVolumes: [{
@@ -67,12 +71,15 @@ MYSQL_USER=wordpress
sourceType: 'CONTAINER',
containerImageSource: "",
replicas: 1,
+ ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_DATABASES,
+ egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_DATABASES,
envVars: `WORDPRESS_DB_HOST={hostname}:{port}
WORDPRESS_DB_NAME={databaseName}
WORDPRESS_DB_USER={username}
WORDPRESS_DB_PASSWORD={password}
WORDPRESS_TABLE_PREFIX=wp_
`,
+ useNetworkPolicy: true,
},
appDomains: [],
appVolumes: [{
diff --git a/src/shared/templates/databases/mariadb.template.ts b/src/shared/templates/databases/mariadb.template.ts
index abb9c20..9aa2d56 100644
--- a/src/shared/templates/databases/mariadb.template.ts
+++ b/src/shared/templates/databases/mariadb.template.ts
@@ -1,3 +1,4 @@
+import { Constants } from "@/shared/utils/constants";
import { AppTemplateModel } from "../../model/app-template.model";
export const mariadbAppTemplate: AppTemplateModel = {
@@ -46,8 +47,11 @@ export const mariadbAppTemplate: AppTemplateModel = {
appType: 'MARIADB',
sourceType: 'CONTAINER',
containerImageSource: "",
+ ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_DATABASES,
+ egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_DATABASES,
replicas: 1,
envVars: ``,
+ useNetworkPolicy: true,
},
appDomains: [],
appVolumes: [{
diff --git a/src/shared/templates/databases/mongodb.template.ts b/src/shared/templates/databases/mongodb.template.ts
index 3725324..0a49d24 100644
--- a/src/shared/templates/databases/mongodb.template.ts
+++ b/src/shared/templates/databases/mongodb.template.ts
@@ -1,3 +1,4 @@
+import { Constants } from "@/shared/utils/constants";
import { AppTemplateModel } from "../../model/app-template.model";
export const mongodbAppTemplate: AppTemplateModel = {
@@ -39,8 +40,11 @@ export const mongodbAppTemplate: AppTemplateModel = {
appType: 'MONGODB',
sourceType: 'CONTAINER',
containerImageSource: "",
+ ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_DATABASES,
+ egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_DATABASES,
replicas: 1,
envVars: ``,
+ useNetworkPolicy: true,
},
appDomains: [],
appVolumes: [{
diff --git a/src/shared/templates/databases/mysql.template.ts b/src/shared/templates/databases/mysql.template.ts
index 4c3756c..aa9c22d 100644
--- a/src/shared/templates/databases/mysql.template.ts
+++ b/src/shared/templates/databases/mysql.template.ts
@@ -1,3 +1,4 @@
+import { Constants } from "@/shared/utils/constants";
import { AppTemplateModel } from "../../model/app-template.model";
export const mysqlAppTemplate: AppTemplateModel = {
@@ -48,6 +49,9 @@ export const mysqlAppTemplate: AppTemplateModel = {
containerImageSource: "",
replicas: 1,
envVars: ``,
+ ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_DATABASES,
+ egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_DATABASES,
+ useNetworkPolicy: true,
},
appDomains: [],
appVolumes: [{
diff --git a/src/shared/templates/databases/postgres.template.ts b/src/shared/templates/databases/postgres.template.ts
index 2e05c4f..353836d 100644
--- a/src/shared/templates/databases/postgres.template.ts
+++ b/src/shared/templates/databases/postgres.template.ts
@@ -1,3 +1,4 @@
+import { Constants } from "@/shared/utils/constants";
import { AppTemplateModel } from "../../model/app-template.model";
export const postgreAppTemplate: AppTemplateModel = {
@@ -40,8 +41,11 @@ export const postgreAppTemplate: AppTemplateModel = {
sourceType: 'CONTAINER',
containerImageSource: "",
replicas: 1,
+ ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_DATABASES,
+ egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_DATABASES,
envVars: `PGDATA=/var/lib/qs-postgres/data
`,
+ useNetworkPolicy: true,
},
appDomains: [],
appVolumes: [{
diff --git a/src/shared/utils/constants.ts b/src/shared/utils/constants.ts
index 57ad686..54a9d63 100644
--- a/src/shared/utils/constants.ts
+++ b/src/shared/utils/constants.ts
@@ -1,6 +1,9 @@
export class Constants {
static readonly QS_ANNOTATION_APP_ID = 'qs-app-id';
static readonly QS_ANNOTATION_PROJECT_ID = 'qs-project-id';
+ static readonly QS_ANNOTATION_CONTAINER_TYPE = 'qs-containter-type';
+ static readonly QS_ANNOTATION_CONTAINER_TYPE_DB_BACKUP_JOB = 'qs-job-database-backup';
+ static readonly QS_ANNOTATION_CONTAINER_TYPE_DB_TOOL = 'qs-container-db-tool';
static readonly QS_ANNOTATION_DEPLOYMENT_ID = 'qs-deplyoment-id';
static readonly QS_ANNOTATION_GIT_COMMIT = 'qs-git-commit';
static readonly K3S_JOIN_TOKEN = 'k3sJoinToken';
@@ -10,4 +13,8 @@ export class Constants {
static readonly TRAEFIK_ME_SECRET_NAME = 'traefik-me-tls';
static readonly QS_SYSTEM_BACKUP_DEACTIVATED = 'deactivated';
static readonly QS_SYSTEM_BACKUP_LOCATION_PARAM_KEY = 'qsSystemBackupLocation';
+ static readonly DEFAULT_INGRESS_NETWORK_POLICY_APPS = 'ALLOW_ALL';
+ static readonly DEFAULT_EGRESS_NETWORK_POLICY_APPS = 'ALLOW_ALL';
+ static readonly DEFAULT_INGRESS_NETWORK_POLICY_DATABASES = 'NAMESPACE_ONLY';
+ static readonly DEFAULT_EGRESS_NETWORK_POLICY_DATABASES = 'DENY_ALL';
}
\ No newline at end of file
diff --git a/yarn.lock b/yarn.lock
index 582c9a2..88bc0c8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2956,12 +2956,19 @@
resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.3.tgz#368c961a18de721da8200e80bf3943fb53136af2"
integrity sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==
+"@types/d3-drag@^3.0.7":
+ version "3.0.7"
+ resolved "https://registry.yarnpkg.com/@types/d3-drag/-/d3-drag-3.0.7.tgz#b13aba8b2442b4068c9a9e6d1d82f8bcea77fc02"
+ integrity sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==
+ dependencies:
+ "@types/d3-selection" "*"
+
"@types/d3-ease@^3.0.0":
version "3.0.2"
resolved "https://registry.yarnpkg.com/@types/d3-ease/-/d3-ease-3.0.2.tgz#e28db1bfbfa617076f7770dd1d9a48eaa3b6c51b"
integrity sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==
-"@types/d3-interpolate@^3.0.1":
+"@types/d3-interpolate@*", "@types/d3-interpolate@^3.0.1", "@types/d3-interpolate@^3.0.4":
version "3.0.4"
resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c"
integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==
@@ -2980,6 +2987,11 @@
dependencies:
"@types/d3-time" "*"
+"@types/d3-selection@*", "@types/d3-selection@^3.0.10":
+ version "3.0.11"
+ resolved "https://registry.yarnpkg.com/@types/d3-selection/-/d3-selection-3.0.11.tgz#bd7a45fc0a8c3167a631675e61bc2ca2b058d4a3"
+ integrity sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==
+
"@types/d3-shape@^3.1.0":
version "3.1.6"
resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.6.tgz#65d40d5a548f0a023821773e39012805e6e31a72"
@@ -2997,6 +3009,21 @@
resolved "https://registry.yarnpkg.com/@types/d3-timer/-/d3-timer-3.0.2.tgz#70bbda77dc23aa727413e22e214afa3f0e852f70"
integrity sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==
+"@types/d3-transition@^3.0.8":
+ version "3.0.9"
+ resolved "https://registry.yarnpkg.com/@types/d3-transition/-/d3-transition-3.0.9.tgz#1136bc57e9ddb3c390dccc9b5ff3b7d2b8d94706"
+ integrity sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==
+ dependencies:
+ "@types/d3-selection" "*"
+
+"@types/d3-zoom@^3.0.8":
+ version "3.0.8"
+ resolved "https://registry.yarnpkg.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz#dccb32d1c56b1e1c6e0f1180d994896f038bc40b"
+ integrity sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==
+ dependencies:
+ "@types/d3-interpolate" "*"
+ "@types/d3-selection" "*"
+
"@types/debug@4.1.7":
version "4.1.7"
resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.7.tgz#7cc0ea761509124709b8b2d1090d8f6c17aadb82"
@@ -3242,6 +3269,30 @@
resolved "https://registry.yarnpkg.com/@xterm/xterm/-/xterm-5.5.0.tgz#275fb8f6e14afa6e8a0c05d4ebc94523ff775396"
integrity sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==
+"@xyflow/react@^12.10.0":
+ version "12.10.0"
+ resolved "https://registry.yarnpkg.com/@xyflow/react/-/react-12.10.0.tgz#d2924cb38074e8e6141643dd8bd7a0666aaac868"
+ integrity sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==
+ dependencies:
+ "@xyflow/system" "0.0.74"
+ classcat "^5.0.3"
+ zustand "^4.4.0"
+
+"@xyflow/system@0.0.74":
+ version "0.0.74"
+ resolved "https://registry.yarnpkg.com/@xyflow/system/-/system-0.0.74.tgz#4bc01af020504387c88a3b13ac6d426b47cba845"
+ integrity sha512-7v7B/PkiVrkdZzSbL+inGAo6tkR/WQHHG0/jhSvLQToCsfa8YubOGmBYd1s08tpKpihdHDZFwzQZeR69QSBb4Q==
+ dependencies:
+ "@types/d3-drag" "^3.0.7"
+ "@types/d3-interpolate" "^3.0.4"
+ "@types/d3-selection" "^3.0.10"
+ "@types/d3-transition" "^3.0.8"
+ "@types/d3-zoom" "^3.0.8"
+ d3-drag "^3.0.0"
+ d3-interpolate "^3.0.1"
+ d3-selection "^3.0.0"
+ d3-zoom "^3.0.0"
+
abab@^2.0.6:
version "2.0.6"
resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291"
@@ -3907,6 +3958,11 @@ class-variance-authority@^0.7.1:
dependencies:
clsx "^2.1.1"
+classcat@^5.0.3:
+ version "5.0.5"
+ resolved "https://registry.yarnpkg.com/classcat/-/classcat-5.0.5.tgz#8c209f359a93ac302404a10161b501eba9c09c77"
+ integrity sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==
+
client-only@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1"
@@ -4129,7 +4185,20 @@ csstype@^3.0.2:
resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2"
integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==
-d3-ease@^3.0.1:
+"d3-dispatch@1 - 3":
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz#5fc75284e9c2375c36c839411a0cf550cbfc4d5e"
+ integrity sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==
+
+"d3-drag@2 - 3", d3-drag@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/d3-drag/-/d3-drag-3.0.0.tgz#994aae9cd23c719f53b5e10e3a0a6108c69607ba"
+ integrity sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==
+ dependencies:
+ d3-dispatch "1 - 3"
+ d3-selection "3"
+
+"d3-ease@1 - 3", d3-ease@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4"
integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==
@@ -4139,7 +4208,7 @@ d3-ease@^3.0.1:
resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.0.tgz#9260e23a28ea5cb109e93b21a06e24e2ebd55641"
integrity sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==
-"d3-interpolate@1.2.0 - 3", d3-interpolate@^3.0.1:
+"d3-interpolate@1 - 3", "d3-interpolate@1.2.0 - 3", d3-interpolate@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d"
integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==
@@ -4162,6 +4231,11 @@ d3-scale@^4.0.2:
d3-time "2.1.1 - 3"
d3-time-format "2 - 4"
+"d3-selection@2 - 3", d3-selection@3, d3-selection@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31"
+ integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==
+
d3-shape@^3.1.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.2.0.tgz#a1a839cbd9ba45f28674c69d7f855bcf91dfc6a5"
@@ -4183,11 +4257,33 @@ d3-shape@^3.1.0:
dependencies:
d3-array "2 - 3"
-d3-timer@^3.0.1:
+"d3-timer@1 - 3", d3-timer@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0"
integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==
+"d3-transition@2 - 3":
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-3.0.1.tgz#6869fdde1448868077fdd5989200cb61b2a1645f"
+ integrity sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==
+ dependencies:
+ d3-color "1 - 3"
+ d3-dispatch "1 - 3"
+ d3-ease "1 - 3"
+ d3-interpolate "1 - 3"
+ d3-timer "1 - 3"
+
+d3-zoom@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/d3-zoom/-/d3-zoom-3.0.0.tgz#d13f4165c73217ffeaa54295cd6969b3e7aee8f3"
+ integrity sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==
+ dependencies:
+ d3-dispatch "1 - 3"
+ d3-drag "2 - 3"
+ d3-interpolate "1 - 3"
+ d3-selection "2 - 3"
+ d3-transition "2 - 3"
+
damerau-levenshtein@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7"
@@ -8649,6 +8745,11 @@ use-sidecar@^1.1.2:
detect-node-es "^1.1.0"
tslib "^2.0.0"
+use-sync-external-store@^1.2.2:
+ version "1.6.0"
+ resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d"
+ integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==
+
util-deprecate@^1.0.1, util-deprecate@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
@@ -9034,6 +9135,13 @@ zod@^3.23.8:
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d"
integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==
+zustand@^4.4.0:
+ version "4.5.7"
+ resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.5.7.tgz#7d6bb2026a142415dd8be8891d7870e6dbe65f55"
+ integrity sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==
+ dependencies:
+ use-sync-external-store "^1.2.2"
+
zustand@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.1.tgz#2bdca5e4be172539558ce3974fe783174a48fdcf"