From e5825fa3e7deacf8ed99fd18b1ad7ca6cd803d35 Mon Sep 17 00:00:00 2001 From: 07calc Date: Wed, 21 Jan 2026 22:38:59 +0530 Subject: [PATCH 1/3] fix: deployments with dir other than root dir --- server/docker/deployerMain.go | 2 +- server/models/app.go | 1 + server/models/services.go | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 server/models/services.go diff --git a/server/docker/deployerMain.go b/server/docker/deployerMain.go index 2a854bb..7e0434b 100644 --- a/server/docker/deployerMain.go +++ b/server/docker/deployerMain.go @@ -41,7 +41,7 @@ func DeployerMain(ctx context.Context, Id int64, db *gorm.DB, logFile *os.File, "app_type": app.AppType, }) - appContextPath := filepath.Join(constants.Constants["RootPath"].(string), fmt.Sprintf("projects/%d/apps/%s", app.ProjectID, app.Name)) + appContextPath := filepath.Join(constants.Constants["RootPath"].(string), fmt.Sprintf("projects/%d/apps/%s/%s", app.ProjectID, app.Name, app.RootDirectory)) imageTag := dep.CommitHash containerName := fmt.Sprintf("app-%d", app.ID) diff --git a/server/models/app.go b/server/models/app.go index 1b55a21..4315c38 100644 --- a/server/models/app.go +++ b/server/models/app.go @@ -25,6 +25,7 @@ const ( AppTypeWeb AppType = "web" AppTypeService AppType = "service" AppTypeDatabase AppType = "database" + AppTypeCompose AppType = "compose" RestartPolicyNo RestartPolicy = "no" RestartPolicyAlways RestartPolicy = "always" diff --git a/server/models/services.go b/server/models/services.go new file mode 100644 index 0000000..2640e7f --- /dev/null +++ b/server/models/services.go @@ -0,0 +1 @@ +package models From da39bde093c5a7110fb4592df6da943ade1a907a Mon Sep 17 00:00:00 2001 From: 07calc Date: Wed, 21 Jan 2026 23:16:24 +0530 Subject: [PATCH 2/3] fix: templates entry in db on startup --- server/compose/down.go | 16 +++++++ server/compose/restart.go | 14 ++++++ server/compose/up.go | 20 ++++++++ server/db/migrations.go | 96 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+) create mode 100644 server/compose/down.go create mode 100644 server/compose/restart.go create mode 100644 server/compose/up.go diff --git a/server/compose/down.go b/server/compose/down.go new file mode 100644 index 0000000..68ce704 --- /dev/null +++ b/server/compose/down.go @@ -0,0 +1,16 @@ +package compose + +import ( + "os" + "os/exec" +) + +func ComposeDown(appContextPath string) { + cmd := exec.Command("docker", "compose", "down") + cmd.Dir = appContextPath + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Run() + +} + diff --git a/server/compose/restart.go b/server/compose/restart.go new file mode 100644 index 0000000..aa54cf5 --- /dev/null +++ b/server/compose/restart.go @@ -0,0 +1,14 @@ +package compose + +import ( + "os" + "os/exec" +) + +func ComposeRestart(appContextPath string) { + cmd := exec.Command("docker", "compose", "restart") + cmd.Dir = appContextPath + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Run() +} diff --git a/server/compose/up.go b/server/compose/up.go new file mode 100644 index 0000000..121bf91 --- /dev/null +++ b/server/compose/up.go @@ -0,0 +1,20 @@ +package compose + +import ( + "fmt" + "os" + "os/exec" +) + +func ComposeUp(appContextPath string, env map[string]string, logFile *os.File) { + cmd := exec.Command("docker", "compose", "up", "-d") + var envArray []string + for k, v := range env { + envArray = append(envArray, fmt.Sprintf("%s=%s", k, v)) + } + cmd.Env = envArray + cmd.Dir = appContextPath + cmd.Stdout = logFile + cmd.Stderr = logFile + cmd.Run() +} diff --git a/server/db/migrations.go b/server/db/migrations.go index 89557aa..324d3f0 100644 --- a/server/db/migrations.go +++ b/server/db/migrations.go @@ -147,9 +147,105 @@ func migrateDbInternal(dbInstance *gorm.DB) error { Value: "1.0.4", } + templates := []models.ServiceTemplate{ + { + Name: "postgres", + DisplayName: "PostgreSQL 16", + Category: "database", + Description: ptr("PostgreSQL is a powerful, open source object-relational database system"), + DockerImage: "postgres:16-alpine", + DefaultPort: 5432, + DefaultEnvVars: ptr(`{"POSTGRES_PASSWORD":"GENERATE","POSTGRES_DB":"myapp","POSTGRES_USER":"postgres"}`), + DefaultVolumePath: ptr("/var/lib/postgresql/data"), + RecommendedMemory: ptrInt(512), + MinMemory: ptrInt(256), + }, + { + Name: "redis", + DisplayName: "Redis 7", + Category: "cache", + Description: ptr("Redis is an in-memory data structure store, used as a database, cache, and message broker"), + DockerImage: "redis:7-alpine", + DefaultPort: 6379, + DefaultEnvVars: ptr(`{}`), + DefaultVolumePath: ptr("/data"), + RecommendedMemory: ptrInt(256), + MinMemory: ptrInt(128), + }, + { + Name: "mysql", + DisplayName: "MySQL 8", + Category: "database", + Description: ptr("MySQL is the world's most popular open source database"), + DockerImage: "mysql:8", + DefaultPort: 3306, + DefaultEnvVars: ptr(`{"MYSQL_ROOT_PASSWORD":"GENERATE","MYSQL_DATABASE":"myapp"}`), + DefaultVolumePath: ptr("/var/lib/mysql"), + RecommendedMemory: ptrInt(512), + MinMemory: ptrInt(256), + }, + { + Name: "mariadb", + DisplayName: "MariaDB 11", + Category: "database", + Description: ptr("MariaDB is a community-developed fork of MySQL"), + DockerImage: "mariadb:11", + DefaultPort: 3306, + DefaultEnvVars: ptr(`{"MARIADB_ROOT_PASSWORD":"GENERATE","MARIADB_DATABASE":"myapp"}`), + DefaultVolumePath: ptr("/var/lib/mysql"), + RecommendedMemory: ptrInt(512), + MinMemory: ptrInt(256), + }, + { + Name: "mongodb", + DisplayName: "MongoDB 7", + Category: "database", + Description: ptr("MongoDB is a source-available cross-platform document-oriented database"), + DockerImage: "mongo:7", + DefaultPort: 27017, + DefaultEnvVars: ptr(`{"MONGO_INITDB_ROOT_USERNAME":"admin","MONGO_INITDB_ROOT_PASSWORD":"GENERATE"}`), + DefaultVolumePath: ptr("/data/db"), + RecommendedMemory: ptrInt(512), + MinMemory: ptrInt(256), + }, + { + Name: "rabbitmq", + DisplayName: "RabbitMQ 3", + Category: "queue", + Description: ptr("RabbitMQ is a reliable and mature messaging and streaming broker"), + DockerImage: "rabbitmq:3-management", + DefaultPort: 5672, + DefaultEnvVars: ptr(`{"RABBITMQ_DEFAULT_USER":"admin","RABBITMQ_DEFAULT_PASS":"GENERATE"}`), + DefaultVolumePath: ptr("/var/lib/rabbitmq"), + RecommendedMemory: ptrInt(512), + MinMemory: ptrInt(256), + }, + { + Name: "minio", + DisplayName: "MinIO", + Category: "storage", + Description: ptr("MinIO is a high-performance, S3 compatible object store"), + DockerImage: "minio/minio", + DefaultPort: 9000, + DefaultEnvVars: ptr(`{"MINIO_ROOT_USER":"admin","MINIO_ROOT_PASSWORD":"GENERATE"}`), + DefaultVolumePath: ptr("/data"), + RecommendedMemory: ptrInt(512), + MinMemory: ptrInt(256), + }, + } + + dbInstance.Create(&templates) dbInstance.Clauses(clause.Insert{Modifier: "OR IGNORE"}).Create(&wildCardDomain) dbInstance.Clauses(clause.Insert{Modifier: "OR IGNORE"}).Create(&MistAppName) dbInstance.Clauses(clause.Insert{Modifier: "OR REPLACE"}).Create(&Version) return nil } + +func ptr(s string) *string { + return &s +} + +func ptrInt(i int) *int { + return &i +} From 22832b5949d95cfb463e5b0f23d971ffc7d996cb Mon Sep 17 00:00:00 2001 From: 07calc Date: Mon, 26 Jan 2026 16:45:37 +0530 Subject: [PATCH 3/3] feat: compose apps support --- dash/src/components/applications/app-info.tsx | 67 +++++ .../applications/compose-app-settings.tsx | 81 +++++++ .../applications/compose-status.tsx | 229 ++++++++++++++++++ dash/src/features/applications/AppPage.tsx | 21 +- .../features/applications/ComposeAppPage.tsx | 158 ++++++++++++ .../projects/components/AppTypeSelection.tsx | 9 +- .../projects/components/ComposeAppForm.tsx | 75 ++++++ .../projects/components/CreateAppModal.tsx | 9 + dash/src/types/app.ts | 2 +- .../handlers/applications/containerControl.go | 110 +++++++-- server/api/handlers/applications/create.go | 8 +- server/compose/compose_deployer.go | 87 +++++++ server/compose/down.go | 6 +- server/compose/logs.go | 19 ++ server/compose/restart.go | 4 +- server/compose/status.go | 106 ++++++++ server/compose/up.go | 4 +- server/docker/deployer.go | 9 +- server/queue/handleWork.go | 8 +- server/websockets/containerLogs.go | 120 ++++++--- 20 files changed, 1057 insertions(+), 75 deletions(-) create mode 100644 dash/src/components/applications/compose-app-settings.tsx create mode 100644 dash/src/components/applications/compose-status.tsx create mode 100644 dash/src/features/applications/ComposeAppPage.tsx create mode 100644 dash/src/features/projects/components/ComposeAppForm.tsx create mode 100644 server/compose/compose_deployer.go create mode 100644 server/compose/logs.go create mode 100644 server/compose/status.go diff --git a/dash/src/components/applications/app-info.tsx b/dash/src/components/applications/app-info.tsx index 9063705..f54f717 100644 --- a/dash/src/components/applications/app-info.tsx +++ b/dash/src/components/applications/app-info.tsx @@ -265,6 +265,73 @@ export const AppInfo = ({ app, latestCommit }: Props) => { )} + ) : app.appType === 'compose' ? ( + <> + + + {/* Git Repository */} + + {app.gitRepository ? ( + + {app.gitRepository} + + + ) : ( +

Not connected

+ )} +
+ + {/* Branch */} + + + {app.gitBranch || "Not specified"} + + + + {/* Latest Commit */} + {latestCommit && ( + <> + + +
+ +
+
+ + {latestCommit.sha.slice(0, 7)} + + + {latestCommit.author && ( + + by {latestCommit.author} + + )} + {latestCommit.timestamp && ( + + + {new Date(latestCommit.timestamp).toLocaleString()} + + )} +
+ {latestCommit.message && ( +

{latestCommit.message}

+ )} +
+
+
+ + )} + ) : ( <> {/* Git Configuration Section */} diff --git a/dash/src/components/applications/compose-app-settings.tsx b/dash/src/components/applications/compose-app-settings.tsx new file mode 100644 index 0000000..6f8ae3e --- /dev/null +++ b/dash/src/components/applications/compose-app-settings.tsx @@ -0,0 +1,81 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { toast } from "sonner"; +import { applicationsService } from "@/services"; +import type { App } from "@/types"; + +interface ComposeAppSettingsProps { + app: App; + onUpdate: () => void; +} + +export const ComposeAppSettings = ({ app, onUpdate }: ComposeAppSettingsProps) => { + const [rootDirectory, setRootDirectory] = useState(app.rootDirectory || ""); + const [saving, setSaving] = useState(false); + + const handleSave = async () => { + try { + setSaving(true); + + await applicationsService.update(app.id, { + rootDirectory, + }); + + toast.success("Settings updated successfully"); + onUpdate(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to update settings"); + } finally { + setSaving(false); + } + }; + + return ( + + +
+ Application Settings + + Configure your compose application settings. + +
+
+ +
+
+ +
+ + setRootDirectory(e.target.value)} + /> +

+ The directory containing your docker-compose.yml file +

+
+ +
+

Read-only Configuration

+
+
+ Repository + {app.gitRepository || "Not connected"} +
+
+ Branch + {app.gitBranch} +
+
+
+
+
+ ); +}; diff --git a/dash/src/components/applications/compose-status.tsx b/dash/src/components/applications/compose-status.tsx new file mode 100644 index 0000000..a838057 --- /dev/null +++ b/dash/src/components/applications/compose-status.tsx @@ -0,0 +1,229 @@ +import { useState, useEffect } from "react" +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { applicationsService } from "@/services" +import { toast } from "sonner" +import { + Activity, + RefreshCw, + Server, + Loader2, + Power, + Square, + RotateCw, + Box, +} from "lucide-react" + +interface ComposeService { + name: string + status: string + state: string +} + +interface ComposeStatusData { + name: string + status: string + state: string + uptime: string + services: ComposeService[] + error?: string +} + +interface ComposeStatusProps { + appId: number + onStatusChange?: () => void +} + +export const ComposeStatus = ({ appId, onStatusChange }: ComposeStatusProps) => { + const [status, setStatus] = useState(null) + const [loading, setLoading] = useState(false) + const [actionLoading, setActionLoading] = useState(null) + const [lastUpdated, setLastUpdated] = useState(new Date()) + + const fetchStatus = async () => { + try { + setLoading(true) + // We rely on the same endpoint, but it returns different structure for compose + const data = await applicationsService.getContainerStatus(appId) as ComposeStatusData + setStatus(data) + setLastUpdated(new Date()) + } catch (error) { + console.error("Failed to fetch compose status:", error) + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchStatus() + const interval = setInterval(fetchStatus, 15000) + return () => clearInterval(interval) + }, [appId]) + + const handleStart = async () => { + try { + setActionLoading("start") + await applicationsService.startContainer(appId) + toast.success("Stack start triggered successfully") + await fetchStatus() + onStatusChange?.() + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to start stack") + } finally { + setActionLoading(null) + } + } + + const handleStop = async () => { + try { + setActionLoading("stop") + await applicationsService.stopContainer(appId) + toast.success("Stack stop triggered successfully") + await fetchStatus() + onStatusChange?.() + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to stop stack") + } finally { + setActionLoading(null) + } + } + + const handleRestart = async () => { + try { + setActionLoading("restart") + await applicationsService.restartContainer(appId) + toast.success("Stack restart triggered successfully") + await fetchStatus() + onStatusChange?.() + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to restart stack") + } finally { + setActionLoading(null) + } + } + + const getOverallStateBadge = () => { + if (!status) return null + if (status.error) return Error + + switch (status.state) { + case "running": + return Running + case "stopped": + return Stopped + case "partial": + return Partial + default: + return {status.state} + } + } + + return ( + + +
+ + + Stack Status + + +
+
+ + + {!status && loading && ( +
+ + Checking status... +
+ )} + + {status && ( +
+ {/* Controls */} +
+ + + +
+ + {/* Overall Status */} +
+
+ + Overall Status +
+
+ {getOverallStateBadge()} + ({status.status}) +
+
+ + {/* Services List */} + {status.services && status.services.length > 0 && ( +
+

+ Services +

+
+ {status.services.map((svc, idx) => ( +
+ {svc.name} + + {svc.state} + +
+ ))} +
+
+ )} + + {status.error && ( +
+ Error: {status.error} +
+ )} + +
+ Last updated: {lastUpdated.toLocaleTimeString()} +
+
+ )} +
+
+ ) +} diff --git a/dash/src/features/applications/AppPage.tsx b/dash/src/features/applications/AppPage.tsx index 3e53a9c..1b89ce0 100644 --- a/dash/src/features/applications/AppPage.tsx +++ b/dash/src/features/applications/AppPage.tsx @@ -7,6 +7,7 @@ import { useApplication } from "@/hooks"; import { TabsList, Tabs, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { AppInfo, GitProviderTab, EnvironmentVariables, Domains, AppSettings, LiveLogsViewer, AppStats, Volumes, ContainerStats } from "@/components/applications"; import { DeploymentsTab } from "@/components/deployments"; +import { ComposeAppPage } from "./ComposeAppPage"; export const AppPage = () => { @@ -67,6 +68,10 @@ export const AppPage = () => { if (!app) return null; + if (app.appType === 'compose') { + return ; + } + return (
{/* Header */} @@ -91,14 +96,14 @@ export const AppPage = () => {
- Info - {app.appType !== 'database' && Git} - Environment - {app.appType === 'web' && Domains} - Deployments - Stats - Logs - Settings + Info + {app.appType !== 'database' && Git} + Environment + {app.appType === 'web' && Domains} + Deployments + Stats + Logs + Settings
diff --git a/dash/src/features/applications/ComposeAppPage.tsx b/dash/src/features/applications/ComposeAppPage.tsx new file mode 100644 index 0000000..17c2cf8 --- /dev/null +++ b/dash/src/features/applications/ComposeAppPage.tsx @@ -0,0 +1,158 @@ +import { FormModal } from "@/components/common/form-modal"; +import { FullScreenLoading } from "@/components/common"; +import { Button } from "@/components/ui/button"; +import { useMemo, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { useApplication } from "@/hooks"; +import { TabsList, Tabs, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { AppInfo, GitProviderTab, EnvironmentVariables, LiveLogsViewer, Volumes } from "@/components/applications"; +import { ComposeStatus } from "@/components/applications/compose-status"; +import { ComposeAppSettings } from "@/components/applications/compose-app-settings"; +import { DeploymentsTab } from "@/components/deployments"; + + +export const ComposeAppPage = () => { + const [isModalOpen, setIsModalOpen] = useState(false); + const [activeTab, setActiveTab] = useState("info"); + + const params = useParams(); + const navigate = useNavigate(); + + const appId = useMemo(() => Number(params.appId), [params.appId]); + const projectId = useMemo(() => Number(params.projectId), [params.projectId]) + + const { + app, + loading, + error, + latestCommit, + updateApp, + deleteApp, + refreshApp, + } = useApplication({ + appId, + autoFetch: true, + projectId + }); + + const deleteAppHandler = async () => { + const success = await deleteApp(); + if (success) { + navigate(-1); + } + }; + + const handleUpdateApp = async (appData: { + name: string; + description: string; + }) => { + const result = await updateApp(appData); + if (result) { + setIsModalOpen(false); + } + }; + + if (loading) return ; + + if (error) + return ( +
+
+ {error} +
+
+ ); + + if (!app) return null; + + return ( +
+ {/* Header */} +
+
+
+

{app.name}

+ Compose +
+

{app.description}

+
+ +
+ + +
+
+ + {/* App Info */} +
+ +
+ + Info + Git + Environment + Deployments + Logs + Settings + +
+ + {/* ✅ INFO TAB */} + +
+
+ +
+
+ +
+
+
+ + + + + + {/* ✅ ENVIRONMENT TAB */} + + + + + {/* ✅ DEPLOYMENTS TAB */} + + + + + {/* Stats tab removed as requested ("keep the stats if its possible to get the compose stats" -> we put compose stats in info/status card for now as overall stats. Detailed container stats are hard for multiple containers yet. User said 'keep only the necessary things') */} + {/* Actually user said "keep the stats if its possible to get the compose stats". But our backend currently doesn't provide resource stats for compose. */} + {/* I will hide the stats tab for now. */} + + + + + + + + + +
+
+ + {/* Edit Modal */} + setIsModalOpen(false)} + title="Edit App" + fields={[ + { label: "App Name", name: "name", type: "text", defaultValue: app.name }, + { label: "Description", name: "description", type: "textarea", defaultValue: app.description || "" }, + ]} + onSubmit={(data) => handleUpdateApp(data as { name: string; description: string })} + /> +
+ ); +}; diff --git a/dash/src/features/projects/components/AppTypeSelection.tsx b/dash/src/features/projects/components/AppTypeSelection.tsx index 6a75f52..3cedd32 100644 --- a/dash/src/features/projects/components/AppTypeSelection.tsx +++ b/dash/src/features/projects/components/AppTypeSelection.tsx @@ -1,4 +1,4 @@ -import { Globe, Cog, Database } from "lucide-react"; +import { Globe, Cog, Database, Container } from "lucide-react"; import { Card } from "@/components/ui/card"; import type { AppType } from "@/types/app"; @@ -29,6 +29,13 @@ export function AppTypeSelection({ onSelect }: AppTypeSelectionProps) { description: "Pre-configured databases and services deployed from official Docker images", examples: "PostgreSQL, Redis, MySQL, MongoDB, RabbitMQ", }, + { + type: "compose" as AppType, + icon: Container, + title: "Docker Compose", + description: "Deploy complex multi-container applications using docker-compose", + examples: "Full stack apps, microservices", + }, ]; return ( diff --git a/dash/src/features/projects/components/ComposeAppForm.tsx b/dash/src/features/projects/components/ComposeAppForm.tsx new file mode 100644 index 0000000..7c38db8 --- /dev/null +++ b/dash/src/features/projects/components/ComposeAppForm.tsx @@ -0,0 +1,75 @@ +import { useState } from "react"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import type { CreateAppRequest } from "@/types/app"; + +interface ComposeAppFormProps { + projectId: number; + onSubmit: (data: CreateAppRequest) => void; + onBack: () => void; +} + +export function ComposeAppForm({ projectId, onSubmit, onBack }: ComposeAppFormProps) { + const [formData, setFormData] = useState({ + name: "", + description: "", + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit({ + projectId, + appType: "compose", + name: formData.name, + description: formData.description || undefined, + }); + }; + + return ( +
+
+

Create Compose Application

+

+ Deploy complex multi-container applications using docker-compose +

+
+ +
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="my-compose-app" + required + className="mt-1" + /> +

+ Lowercase letters, numbers, and hyphens only +

+
+ +
+ +