diff --git a/src/__tests__/types.test.ts b/src/__tests__/types.test.ts new file mode 100644 index 0000000..4a94919 --- /dev/null +++ b/src/__tests__/types.test.ts @@ -0,0 +1,271 @@ +import { describe, it, expect } from "vitest"; +import type { DatabaseConfig, DomainConfig, ServiceMetadata } from "@/types"; + +describe("DatabaseConfig type", () => { + it("should have all required fields", () => { + const db: DatabaseConfig = { + id: "test-db", + alias: "primary", + type: "mysql", + mode: "shared", + }; + + expect(db.id).toBe("test-db"); + expect(db.alias).toBe("primary"); + expect(db.type).toBe("mysql"); + expect(db.mode).toBe("shared"); + }); + + it("should support all database types", () => { + const types: DatabaseConfig["type"][] = ["mysql", "postgres", "mariadb", "mongodb", "redis"]; + + types.forEach((type) => { + const db: DatabaseConfig = { + id: `test-${type}`, + alias: "test", + type, + mode: "shared", + }; + expect(db.type).toBe(type); + }); + }); + + it("should support all connection modes", () => { + const modes: DatabaseConfig["mode"][] = ["shared", "create", "existing", "external"]; + + modes.forEach((mode) => { + const db: DatabaseConfig = { + id: `test-${mode}`, + alias: "test", + type: "mysql", + mode, + }; + expect(db.mode).toBe(mode); + }); + }); + + it("should support optional fields", () => { + const db: DatabaseConfig = { + id: "full-config", + alias: "analytics", + type: "postgres", + mode: "external", + service: "backend", + host: "db.example.com", + port: 5432, + container: "postgres-container", + database_name: "analytics_db", + username: "analyst", + env_prefix: "ANALYTICS", + is_shared: false, + }; + + expect(db.service).toBe("backend"); + expect(db.host).toBe("db.example.com"); + expect(db.port).toBe(5432); + expect(db.database_name).toBe("analytics_db"); + expect(db.env_prefix).toBe("ANALYTICS"); + expect(db.is_shared).toBe(false); + }); +}); + +describe("DomainConfig type", () => { + it("should have all required fields", () => { + const domain: DomainConfig = { + id: "test-domain", + service: "frontend", + container_port: 80, + domain: "example.com", + ssl: { + enabled: true, + auto_cert: true, + }, + }; + + expect(domain.id).toBe("test-domain"); + expect(domain.service).toBe("frontend"); + expect(domain.container_port).toBe(80); + expect(domain.domain).toBe("example.com"); + expect(domain.ssl.enabled).toBe(true); + }); + + it("should support optional fields", () => { + const domain: DomainConfig = { + id: "api-domain", + service: "api", + container_port: 8080, + domain: "api.example.com", + path_prefix: "/v1", + strip_prefix: true, + ssl: { + enabled: true, + auto_cert: true, + }, + aliases: ["api-v1.example.com"], + }; + + expect(domain.path_prefix).toBe("/v1"); + expect(domain.strip_prefix).toBe(true); + expect(domain.aliases).toContain("api-v1.example.com"); + }); +}); + +describe("ServiceMetadata with databases", () => { + it("should support empty databases array", () => { + const metadata: ServiceMetadata = { + name: "test-app", + type: "web", + networking: { + expose: true, + domain: "test.example.com", + container_port: 80, + protocol: "http", + }, + ssl: { + enabled: true, + auto_cert: true, + }, + healthcheck: { + path: "/health", + interval: "30s", + }, + }; + + expect(metadata.databases).toBeUndefined(); + }); + + it("should support single database", () => { + const metadata: ServiceMetadata = { + name: "test-app", + type: "web", + networking: { + expose: true, + domain: "test.example.com", + container_port: 80, + protocol: "http", + }, + ssl: { + enabled: true, + auto_cert: true, + }, + healthcheck: { + path: "/health", + interval: "30s", + }, + databases: [ + { + id: "primary", + alias: "primary", + type: "mysql", + mode: "shared", + is_shared: true, + }, + ], + }; + + expect(metadata.databases).toHaveLength(1); + expect(metadata.databases![0].alias).toBe("primary"); + }); + + it("should support multiple databases", () => { + const metadata: ServiceMetadata = { + name: "complex-app", + type: "web", + networking: { + expose: true, + domain: "app.example.com", + container_port: 80, + protocol: "http", + }, + ssl: { + enabled: true, + auto_cert: true, + }, + healthcheck: { + path: "/health", + interval: "30s", + }, + databases: [ + { + id: "primary", + alias: "primary", + type: "mysql", + mode: "shared", + is_shared: true, + }, + { + id: "cache", + alias: "cache", + type: "redis", + mode: "existing", + container: "redis-server", + }, + { + id: "analytics", + alias: "analytics", + type: "postgres", + mode: "external", + host: "analytics.db.example.com", + port: 5432, + }, + ], + }; + + expect(metadata.databases).toHaveLength(3); + expect(metadata.databases![0].type).toBe("mysql"); + expect(metadata.databases![1].type).toBe("redis"); + expect(metadata.databases![2].type).toBe("postgres"); + }); +}); + +describe("ServiceMetadata with domains", () => { + it("should support multiple domains", () => { + const metadata: ServiceMetadata = { + name: "multi-domain-app", + type: "web", + networking: { + expose: true, + domain: "app.example.com", + container_port: 80, + protocol: "http", + }, + ssl: { + enabled: true, + auto_cert: true, + }, + healthcheck: { + path: "/health", + interval: "30s", + }, + domains: [ + { + id: "primary", + service: "frontend", + container_port: 80, + domain: "app.example.com", + ssl: { enabled: true, auto_cert: true }, + }, + { + id: "api", + service: "api", + container_port: 8080, + domain: "api.example.com", + path_prefix: "/v1", + ssl: { enabled: true, auto_cert: true }, + }, + { + id: "admin", + service: "admin", + container_port: 3000, + domain: "admin.example.com", + ssl: { enabled: true, auto_cert: true }, + }, + ], + }; + + expect(metadata.domains).toHaveLength(3); + expect(metadata.domains![0].domain).toBe("app.example.com"); + expect(metadata.domains![1].domain).toBe("api.example.com"); + expect(metadata.domains![2].domain).toBe("admin.example.com"); + }); +}); diff --git a/src/components/DomainFormModal.vue b/src/components/DomainFormModal.vue new file mode 100644 index 0000000..1bff202 --- /dev/null +++ b/src/components/DomainFormModal.vue @@ -0,0 +1,451 @@ + + + + + diff --git a/src/components/DomainsManager.vue b/src/components/DomainsManager.vue new file mode 100644 index 0000000..f637912 --- /dev/null +++ b/src/components/DomainsManager.vue @@ -0,0 +1,435 @@ + + + + + diff --git a/src/components/NewDeploymentModal.vue b/src/components/NewDeploymentModal.vue index 2cc10c8..f2dca77 100644 --- a/src/components/NewDeploymentModal.vue +++ b/src/components/NewDeploymentModal.vue @@ -109,8 +109,8 @@ - -
+ +
@@ -276,13 +276,36 @@

Define multiple services, volumes, and networks

-
- -
- Advanced Options -

Configure environment variables and port mappings

+ +
+
+ + Advanced Options +
+
+ +
+
You'll write your compose file in the next step @@ -429,8 +452,8 @@
- -
+ +
+ + +
+
+ Additional Databases +
+ +
+
+
+ + +
+ +
+ +
+
+ +
+ +
+ + + +
+ +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ +
+ Env: {{ (db.envPrefix || db.alias).toUpperCase() }}_HOST, + {{ (db.envPrefix || db.alias).toUpperCase() }}_PORT... +
+
+
+ + +
@@ -921,8 +1040,77 @@
- -
+ +
+
+
+

Configure Domains

+

Route different domains to different services in your compose stack

+
+
+ +
+ +
+
+ Primary Domain +
+
+ + {{ effectiveDomain || "Not configured" }} + → {{ form.name }}:{{ form.networking.ports[0]?.containerPort || 80 }} +
+
+ + +
+
+ Domain {{ index + 2 }} + +
+ +
+
+ + +
+ +
+ + + Service name from your compose file +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+ + +
+
+ + +
@@ -1044,8 +1232,8 @@
- -
+ +
@@ -1210,6 +1398,7 @@ interface DbContainer { image: string; type: "mysql" | "postgres" | "mariadb" | "mongodb" | "unknown"; } + const existingDbContainers = ref([]); const loadingDbContainers = ref(false); const existingDeployments = ref([]); @@ -1221,6 +1410,83 @@ const showRegistryPassword = ref(false); const existingCredentials = ref([]); const loadingCredentials = ref(false); +const advancedOptions = reactive({ + multiDomain: false, + multiDatabase: false, +}); + +interface DomainFormConfig { + id: string; + domain: string; + service: string; + containerPort: number; + pathPrefix: string; + ssl: { enabled: boolean; autoCert: boolean }; +} + +interface DatabaseFormConfig { + id: string; + alias: string; + type: "none" | "mysql" | "postgres" | "mariadb" | "mongodb" | "redis"; + mode: "create" | "existing" | "external" | "shared"; + service: string; + existingContainer: string; + externalHost: string; + externalPort: string; + dbName: string; + dbUser: string; + dbPassword: string; + envPrefix: string; +} + +const createDomainConfig = (id: string): DomainFormConfig => ({ + id, + domain: "", + service: "", + containerPort: 80, + pathPrefix: "", + ssl: { enabled: true, autoCert: true }, +}); + +const createDatabaseConfig = (id: string, alias: string): DatabaseFormConfig => ({ + id, + alias, + type: "none", + mode: "shared", + service: "", + existingContainer: "", + externalHost: "", + externalPort: "", + dbName: "", + dbUser: "app", + dbPassword: "", + envPrefix: "", +}); + +const additionalDomains = ref([]); +const additionalDatabases = ref([]); + +const addAdditionalDomain = () => { + const id = `domain-${Date.now()}`; + additionalDomains.value.push(createDomainConfig(id)); +}; + +const removeAdditionalDomain = (index: number) => { + additionalDomains.value.splice(index, 1); +}; + +const addAdditionalDatabase = () => { + const id = `db-${Date.now()}`; + const alias = `db${additionalDatabases.value.length + 2}`; + const db = createDatabaseConfig(id, alias); + db.dbPassword = generatePassword(); + additionalDatabases.value.push(db); +}; + +const removeAdditionalDatabase = (index: number) => { + additionalDatabases.value.splice(index, 1); +}; + const easySteps = [ { id: "basics", label: "Basics" }, { id: "database", label: "Database" }, @@ -1228,7 +1494,26 @@ const easySteps = [ { id: "review", label: "Review" }, ]; -const steps = computed(() => easySteps); +const steps = computed(() => { + if (deploymentMode.value === "compose") { + const composeSteps = [ + { id: "basics", label: "Basics" }, + { id: "database", label: "Database" }, + ]; + if (advancedOptions.multiDomain) { + composeSteps.push({ id: "domains", label: "Domains" }); + } + composeSteps.push({ id: "configure", label: "Configure" }); + composeSteps.push({ id: "review", label: "Review" }); + return composeSteps; + } + return easySteps; +}); + +const currentStepId = computed(() => { + if (currentStep.value === 0) return "mode"; + return steps.value[currentStep.value - 1]?.id || ""; +}); const generatePassword = () => { const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; @@ -1944,6 +2229,10 @@ watch( showRegistryPassword.value = false; existingDbContainers.value = []; existingCredentials.value = []; + advancedOptions.multiDomain = false; + advancedOptions.multiDatabase = false; + additionalDomains.value = []; + additionalDatabases.value = []; errors.name = ""; errors.composeContent = ""; selectedQuickApp.value = ""; @@ -2084,7 +2373,56 @@ const handleCreate = async () => { } } + if (advancedOptions.multiDatabase && additionalDatabases.value.length > 0) { + const databases = additionalDatabases.value + .filter((db) => db.type !== "none") + .map((db) => ({ + alias: db.alias, + type: db.type, + mode: db.mode, + service: db.service || undefined, + existing_container: db.mode === "existing" ? db.existingContainer : undefined, + external_host: db.mode === "external" ? db.externalHost : undefined, + external_port: db.mode === "external" && db.externalPort ? parseInt(db.externalPort) : undefined, + database_name: db.dbName || undefined, + username: db.dbUser || undefined, + env_prefix: db.envPrefix || db.alias, + })); + + if (databases.length > 0) { + payload.databases = databases; + } + } + if (finalDomain) { + const domainsArray = []; + + domainsArray.push({ + id: "primary", + domain: finalDomain, + service: form.name, + container_port: form.networking.ports[0]?.containerPort || 80, + ssl: { + enabled: form.ssl.enabled, + auto_cert: form.ssl.autoCert, + }, + }); + + if (advancedOptions.multiDomain && additionalDomains.value.length > 0) { + for (const d of additionalDomains.value) { + if (d.domain.trim()) { + domainsArray.push({ + id: d.id, + domain: d.domain, + service: d.service || form.name, + container_port: d.containerPort || 80, + path_prefix: d.pathPrefix || undefined, + ssl: d.ssl, + }); + } + } + } + payload.metadata = { name: form.name, type: "web", @@ -2102,6 +2440,7 @@ const handleCreate = async () => { path: "/health", interval: "30s", }, + domains: domainsArray.length > 1 ? domainsArray : undefined, }; } @@ -3777,6 +4116,380 @@ const handleClose = () => { color: var(--color-info-700); } +/* Advanced Options Section */ +.advanced-options-section { + border-top: 1px solid var(--color-gray-200); + padding-top: var(--space-4); + margin-top: var(--space-2); +} + +.advanced-options-header { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-sm); + font-weight: var(--font-semibold); + color: var(--color-gray-700); + margin-bottom: var(--space-3); +} + +.advanced-options-header i { + color: var(--color-gray-400); +} + +.advanced-options-list { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.advanced-option { + display: flex; + align-items: flex-start; + gap: var(--space-3); + padding: var(--space-3); + background: var(--color-gray-50); + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.2s ease; +} + +.advanced-option:hover { + background: var(--color-gray-100); +} + +.advanced-option input[type="checkbox"] { + margin-top: 2px; +} + +.advanced-option .option-content { + display: flex; + flex-direction: column; + gap: 2px; +} + +.advanced-option .option-label { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-sm); + font-weight: var(--font-medium); + color: var(--color-gray-900); +} + +.advanced-option .option-label i { + color: var(--color-gray-500); + font-size: var(--text-xs); +} + +.advanced-option .option-desc { + font-size: var(--text-xs); + color: var(--color-gray-500); +} + +/* Domains Step */ +.domains-step { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.domains-step-header .step-intro h3 { + font-size: var(--text-lg); + font-weight: var(--font-semibold); + color: var(--color-gray-900); + margin: 0 0 var(--space-1) 0; +} + +.domains-step-header .step-intro p { + font-size: var(--text-sm); + color: var(--color-gray-500); + margin: 0; +} + +.domains-list-section { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.domain-card { + background: white; + border: 1px solid var(--color-gray-200); + border-radius: var(--radius-lg); + padding: var(--space-4); +} + +.domain-card.primary { + background: var(--color-primary-50); + border-color: var(--color-primary-200); +} + +.domain-card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-3); +} + +.domain-badge { + font-size: var(--text-xs); + font-weight: var(--font-medium); + color: var(--color-gray-600); + background: var(--color-gray-100); + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-sm); +} + +.domain-badge.primary-badge { + color: var(--color-primary-700); + background: var(--color-primary-100); +} + +.domain-preview { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-sm); + color: var(--color-gray-700); +} + +.domain-preview i { + color: var(--color-primary-500); +} + +.domain-hint { + color: var(--color-gray-400); + font-size: var(--text-xs); +} + +.domain-form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-3); +} + +.domain-form-grid .form-field:first-child { + grid-column: 1 / -1; +} + +.domain-form-grid .ssl-toggle { + grid-column: 1 / -1; +} + +.domain-card .remove-btn { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-red-50); + color: var(--color-red-500); + border: none; + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.2s ease; +} + +.domain-card .remove-btn:hover { + background: var(--color-red-100); + color: var(--color-red-600); +} + +.add-domain-btn { + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + width: 100%; + padding: var(--space-3); + background: white; + border: 2px dashed var(--color-gray-300); + border-radius: var(--radius-lg); + color: var(--color-gray-600); + font-size: var(--text-sm); + font-weight: var(--font-medium); + cursor: pointer; + transition: all 0.2s ease; +} + +.add-domain-btn:hover { + border-color: var(--color-primary-400); + color: var(--color-primary-600); + background: var(--color-primary-50); +} + +/* Additional Databases Section */ +.additional-databases-section { + margin-top: var(--space-4); +} + +.section-divider { + display: flex; + align-items: center; + gap: var(--space-3); + margin-bottom: var(--space-4); +} + +.section-divider::before, +.section-divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--color-gray-200); +} + +.section-divider span { + font-size: var(--text-sm); + font-weight: var(--font-medium); + color: var(--color-gray-500); +} + +.additional-db-card { + background: var(--color-gray-50); + border: 1px solid var(--color-gray-200); + border-radius: var(--radius-lg); + padding: var(--space-4); + margin-bottom: var(--space-3); +} + +.db-card-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + margin-bottom: var(--space-3); +} + +.db-alias-input { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.db-alias-input label { + font-size: var(--text-xs); + font-weight: var(--font-medium); + color: var(--color-gray-600); +} + +.db-alias-input .alias-field { + width: 150px; + padding: var(--space-2); + font-size: var(--text-sm); + border: 1px solid var(--color-gray-300); + border-radius: var(--radius-md); +} + +.db-card-content { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.db-type-row { + display: flex; + gap: var(--space-2); +} + +.db-type-btn { + padding: var(--space-2) var(--space-3); + font-size: var(--text-xs); + font-weight: var(--font-medium); + color: var(--color-gray-600); + background: white; + border: 1px solid var(--color-gray-300); + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.2s ease; + text-transform: uppercase; +} + +.db-type-btn:hover { + border-color: var(--color-primary-400); + color: var(--color-primary-600); +} + +.db-type-btn.selected { + background: var(--color-primary-500); + border-color: var(--color-primary-500); + color: white; +} + +.db-mode-row { + display: flex; + gap: var(--space-2); +} + +.mode-btn { + display: flex; + align-items: center; + gap: var(--space-1); + padding: var(--space-2) var(--space-3); + font-size: var(--text-xs); + font-weight: var(--font-medium); + color: var(--color-gray-600); + background: white; + border: 1px solid var(--color-gray-300); + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.2s ease; +} + +.mode-btn:hover { + border-color: var(--color-primary-400); + color: var(--color-primary-600); +} + +.mode-btn.selected { + background: var(--color-primary-50); + border-color: var(--color-primary-400); + color: var(--color-primary-600); +} + +.db-external-config { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-3); + background: white; + border-radius: var(--radius-md); +} + +.db-env-preview { + padding: var(--space-2); + background: var(--color-gray-100); + border-radius: var(--radius-sm); +} + +.db-env-preview .env-prefix { + font-size: var(--text-xs); + font-family: var(--font-mono); + color: var(--color-gray-600); +} + +.add-db-btn { + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + width: 100%; + padding: var(--space-3); + background: white; + border: 2px dashed var(--color-gray-300); + border-radius: var(--radius-lg); + color: var(--color-gray-600); + font-size: var(--text-sm); + font-weight: var(--font-medium); + cursor: pointer; + transition: all 0.2s ease; +} + +.add-db-btn:hover { + border-color: var(--color-primary-400); + color: var(--color-primary-600); + background: var(--color-primary-50); +} + /* Image Config Card */ .image-config-card { flex: 1; diff --git a/src/services/api.ts b/src/services/api.ts index ed9d9bf..5b42335 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -13,6 +13,7 @@ import type { BlockedIP, ProtectedRoute, DeploymentSecurityConfig, + DomainConfig, } from "@/types"; const apiClient = axios.create({ @@ -127,6 +128,13 @@ export const deploymentsApi = { getEnvVars: (name: string) => apiClient.get<{ env_vars: EnvVar[] }>(`/deployments/${name}/env`), updateEnvVars: (name: string, envVars: EnvVar[]) => apiClient.put(`/deployments/${name}/env`, { env_vars: envVars }), disableSSL: (name: string) => apiClient.post<{ message: string; name: string }>(`/deployments/${name}/ssl/disable`), + listDomains: (name: string) => apiClient.get<{ domains: DomainConfig[] }>(`/deployments/${name}/domains`), + addDomain: (name: string, domain: Omit) => + apiClient.post<{ message: string; domain: DomainConfig }>(`/deployments/${name}/domains`, domain), + updateDomain: (name: string, domainId: string, domain: Partial) => + apiClient.put<{ message: string; domain: DomainConfig }>(`/deployments/${name}/domains/${domainId}`, domain), + deleteDomain: (name: string, domainId: string) => + apiClient.delete<{ message: string }>(`/deployments/${name}/domains/${domainId}`), }; export const networksApi = { diff --git a/src/types/index.ts b/src/types/index.ts index 36dd7f4..b4039d7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -28,6 +28,34 @@ export interface ServiceMetadata { quick_actions?: QuickAction[]; security?: DeploymentSecurityConfig; credential_id?: string; + domains?: DomainConfig[]; + databases?: DatabaseConfig[]; +} + +export interface DatabaseConfig { + id: string; + alias: string; + type: "mysql" | "postgres" | "mariadb" | "mongodb" | "redis"; + mode: "shared" | "create" | "existing" | "external"; + service?: string; + host?: string; + port?: number; + container?: string; + database_name?: string; + username?: string; + env_prefix?: string; + is_shared?: boolean; +} + +export interface DomainConfig { + id: string; + service: string; + container_port: number; + domain: string; + path_prefix?: string; + strip_prefix?: boolean; + ssl: SSLConfig; + aliases?: string[]; } export interface QuickAction { @@ -91,10 +119,13 @@ export interface ProxyStatus { deployment_name: string; exposed: boolean; domain?: string; + domains?: string[]; + domains_config?: DomainConfig[]; virtual_host_exists: boolean; ssl_enabled: boolean; certificate_exists: boolean; certificate?: Certificate; + certificates?: Certificate[]; } export interface ProxySetupResult { diff --git a/src/views/DeploymentDetailView.test.ts b/src/views/DeploymentDetailView.test.ts index 494c74d..2fdb277 100644 --- a/src/views/DeploymentDetailView.test.ts +++ b/src/views/DeploymentDetailView.test.ts @@ -418,4 +418,139 @@ describe("DeploymentDetailView", () => { expect(wrapper.find(".deployment-detail").exists()).toBe(true); }); }); + + describe("Domain management", () => { + it("computes hasMultipleDomains as false when no domains", async () => { + const wrapper = mountView(); + await flushPromises(); + expect((wrapper.vm as any).hasMultipleDomains).toBeFalsy(); + }); + + it("computes hasMultipleDomains as false when one domain", async () => { + const { deploymentsApi } = await import("@/services/api"); + vi.mocked(deploymentsApi.get).mockResolvedValueOnce({ + data: { + deployment: { + name: "test-app", + status: "running", + path: "/deployments/test-app", + services: [], + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + metadata: { + domains: [{ id: "domain-1", domain: "app.example.com" }], + }, + }, + proxy_status: { exposed: true, domain: "app.example.com" }, + }, + } as any); + + const wrapper = mountView(); + await flushPromises(); + expect((wrapper.vm as any).hasMultipleDomains).toBe(false); + }); + + it("computes hasMultipleDomains as true when multiple domains", async () => { + const { deploymentsApi } = await import("@/services/api"); + vi.mocked(deploymentsApi.get).mockResolvedValueOnce({ + data: { + deployment: { + name: "test-app", + status: "running", + path: "/deployments/test-app", + services: [], + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + metadata: { + domains: [ + { id: "domain-1", domain: "app.example.com" }, + { id: "domain-2", domain: "api.example.com" }, + ], + }, + }, + proxy_status: { exposed: true }, + }, + } as any); + + const wrapper = mountView(); + await flushPromises(); + expect((wrapper.vm as any).hasMultipleDomains).toBe(true); + }); + + it("computes singleDomainId from explicit domain", async () => { + const { deploymentsApi } = await import("@/services/api"); + vi.mocked(deploymentsApi.get).mockResolvedValueOnce({ + data: { + deployment: { + name: "test-app", + status: "running", + path: "/deployments/test-app", + services: [], + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + metadata: { + domains: [{ id: "my-domain-id", domain: "app.example.com" }], + }, + }, + proxy_status: { exposed: true }, + }, + } as any); + + const wrapper = mountView(); + await flushPromises(); + expect((wrapper.vm as any).singleDomainId).toBe("my-domain-id"); + }); + + it("computes singleDomainId as 'default' for legacy domain", async () => { + const { deploymentsApi, proxyApi } = await import("@/services/api"); + vi.mocked(deploymentsApi.get).mockResolvedValueOnce({ + data: { + deployment: { + name: "test-app", + status: "running", + path: "/deployments/test-app", + services: [], + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + metadata: { + networking: { expose: true, domain: "legacy.example.com" }, + }, + }, + proxy_status: { exposed: true, domain: "legacy.example.com" }, + }, + } as any); + vi.mocked(proxyApi.getStatus).mockResolvedValueOnce({ + data: { status: { exposed: true, domain: "legacy.example.com" } }, + } as any); + + const wrapper = mountView(); + await flushPromises(); + expect((wrapper.vm as any).singleDomainId).toBe("default"); + }); + + it("computes singleDomainId as null when no domains", async () => { + const { deploymentsApi, proxyApi } = await import("@/services/api"); + vi.mocked(deploymentsApi.get).mockResolvedValueOnce({ + data: { + deployment: { + name: "test-app", + status: "running", + path: "/deployments/test-app", + services: [], + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + metadata: {}, + }, + proxy_status: { exposed: false }, + }, + } as any); + vi.mocked(proxyApi.getStatus).mockResolvedValueOnce({ + data: { status: { exposed: false } }, + } as any); + + const wrapper = mountView(); + await flushPromises(); + expect((wrapper.vm as any).singleDomainId).toBeNull(); + }); + }); }); diff --git a/src/views/DeploymentDetailView.vue b/src/views/DeploymentDetailView.vue index 83eea0c..66c79c3 100644 --- a/src/views/DeploymentDetailView.vue +++ b/src/views/DeploymentDetailView.vue @@ -156,12 +156,28 @@

Domain & SSL

-
- @@ -1325,6 +1411,8 @@ import LogViewer from "@/components/LogViewer.vue"; import ConfirmModal from "@/components/ConfirmModal.vue"; import ContainerTerminal from "@/components/ContainerTerminal.vue"; import BackupsTab from "@/components/BackupsTab.vue"; +import DomainsManager from "@/components/DomainsManager.vue"; +import DomainFormModal from "@/components/DomainFormModal.vue"; const route = useRoute(); const router = useRouter(); @@ -1349,6 +1437,8 @@ const proxyStatus = ref(null); const settingUpProxy = ref(false); const requestingCert = ref(false); const disablingSSL = ref(false); +const showAddDomainModal = ref(false); +const addingDomain = ref(false); const securityConfig = ref({ enabled: false, @@ -1389,6 +1479,24 @@ const resourceUsage = ref({ network: 0, }); +const hasMultipleDomains = computed(() => { + return deployment.value?.metadata?.domains && deployment.value.metadata.domains.length > 1; +}); + +const singleDomainId = computed(() => { + const domains = deployment.value?.metadata?.domains; + if (domains && domains.length === 1) { + return domains[0].id; + } + // Legacy domain from networking config uses "default" as ID + if (!domains?.length && proxyStatus.value?.exposed) { + return "default"; + } + return null; +}); + +const deletingDomain = ref(false); + const registryCredential = ref(null); const allCredentials = ref([]); const showCredentialModal = ref(false); @@ -1664,6 +1772,70 @@ const handleSetupProxy = async () => { } }; +const handleDeleteDomain = async (domainId: string) => { + deletingDomain.value = true; + try { + await deploymentsApi.deleteDomain(route.params.name as string, domainId); + notifications.success("Domain Deleted", "Domain has been removed"); + await fetchDeployment(); + } catch (err: any) { + const msg = err.response?.data?.error || err.message; + notifications.error("Delete Failed", msg); + } finally { + deletingDomain.value = false; + } +}; + +const fetchProxyStatus = async () => { + try { + const proxyResponse = await proxyApi.getStatus(route.params.name as string); + proxyStatus.value = proxyResponse.data.status; + } catch { + proxyStatus.value = null; + } +}; + +const handleAddDomain = async (newDomain: any) => { + if (!deployment.value?.metadata) return; + + const currentDomain = deployment.value.metadata.networking?.domain; + const currentPort = deployment.value.metadata.networking?.container_port || 80; + const currentSSL = deployment.value.metadata.ssl || { enabled: false, auto_cert: false }; + + addingDomain.value = true; + try { + // First, convert existing legacy domain to a domain config + if (currentDomain) { + await deploymentsApi.addDomain(route.params.name as string, { + service: deployment.value.metadata.name || (route.params.name as string), + container_port: currentPort, + domain: currentDomain, + ssl: currentSSL, + }); + } + + // Then add the new domain + await deploymentsApi.addDomain(route.params.name as string, { + service: newDomain.service || deployment.value.metadata.name || (route.params.name as string), + container_port: newDomain.container_port || 80, + domain: newDomain.domain, + path_prefix: newDomain.path_prefix, + strip_prefix: newDomain.strip_prefix, + ssl: newDomain.ssl || { enabled: false, auto_cert: false }, + aliases: newDomain.aliases, + }); + + notifications.success("Domain Added", `${newDomain.domain} has been added to this deployment`); + showAddDomainModal.value = false; + await fetchDeployment(); + } catch (err: any) { + const msg = err.response?.data?.error || err.message; + notifications.error("Failed to Add Domain", msg); + } finally { + addingDomain.value = false; + } +}; + const handleRequestCertificate = async () => { if (!proxyStatus.value?.domain) return; @@ -2616,12 +2788,17 @@ onUnmounted(() => { .proxy-actions { display: flex; + flex-wrap: wrap; gap: var(--space-2); margin-top: var(--space-3); padding-top: var(--space-3); border-top: 1px solid var(--color-gray-100); } +.proxy-actions .btn { + white-space: nowrap; +} + .empty-proxy { text-align: center; padding: var(--space-4); @@ -2631,6 +2808,7 @@ onUnmounted(() => { .empty-proxy i { font-size: 2rem; margin-bottom: var(--space-2); + display: block; } .empty-proxy p { @@ -2674,6 +2852,97 @@ onUnmounted(() => { font-weight: var(--font-medium); } +/* Databases List */ +.databases-list { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.database-item { + border: 1px solid var(--color-gray-200); + border-radius: var(--radius-sm); + padding: var(--space-3); + background: var(--color-gray-50); +} + +.database-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--space-2); +} + +.database-alias { + font-weight: var(--font-semibold); + color: var(--color-gray-900); +} + +.database-type { + font-size: var(--text-xs); + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-full); + font-weight: var(--font-medium); + text-transform: uppercase; + background: var(--color-gray-100); + color: var(--color-gray-700); +} + +.database-type.mysql { + background: var(--color-info-100); + color: var(--color-info-700); +} + +.database-type.postgres { + background: var(--color-primary-100); + color: var(--color-primary-700); +} + +.database-type.mariadb { + background: var(--color-success-100); + color: var(--color-success-700); +} + +.database-type.mongodb { + background: var(--color-warning-100); + color: var(--color-warning-700); +} + +.database-type.redis { + background: var(--color-error-100); + color: var(--color-error-700); +} + +.database-details { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.database-details .detail-row { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-sm); +} + +.database-details .detail-label { + color: var(--color-gray-500); + min-width: 80px; +} + +.database-details .detail-value { + color: var(--color-gray-700); +} + +.database-details code.detail-value { + font-family: var(--font-mono); + font-size: var(--text-xs); + background: var(--color-gray-100); + padding: var(--space-0-5) var(--space-1); + border-radius: var(--radius-xs); +} + .service-status.running { background: var(--color-success-50); color: var(--color-success-700);