diff --git a/.gitignore b/.gitignore index 8548445..bce6d60 100644 --- a/.gitignore +++ b/.gitignore @@ -123,3 +123,4 @@ bin ref/ .playwright-mcp/ canvas-*.png +.vercel diff --git a/.vercelignore b/.vercelignore new file mode 100644 index 0000000..774a68d --- /dev/null +++ b/.vercelignore @@ -0,0 +1,2 @@ +/* +!/site diff --git a/app/backend/cmd/dumptemplates/main.go b/app/backend/cmd/dumptemplates/main.go new file mode 100644 index 0000000..8d3ecaf --- /dev/null +++ b/app/backend/cmd/dumptemplates/main.go @@ -0,0 +1,24 @@ +// dumptemplates prints the seeded official template catalog as JSON in the same +// shape as GET /api/v1/templates. The landing site (site/templates.json) uses it +// as the offline fallback catalog: `go run ./cmd/dumptemplates > ../../site/templates.json`. +package main + +import ( + "containr/internal/api" + "encoding/json" + "fmt" + "os" +) + +func main() { + out := struct { + Templates []api.ServiceTemplate `json:"templates"` + }{Templates: api.SeedTemplates()} + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(out); err != nil { + fmt.Fprintln(os.Stderr, "encode:", err) + os.Exit(1) + } +} diff --git a/app/backend/internal/api/templates.go b/app/backend/internal/api/templates.go index 8f05600..d530cf5 100644 --- a/app/backend/internal/api/templates.go +++ b/app/backend/internal/api/templates.go @@ -28,6 +28,7 @@ type ServiceTemplate struct { Variables string `json:"variables" db:"variables"` IsOfficial bool `json:"is_official" db:"is_official"` OwnerID string `json:"owner_id,omitempty" db:"owner_id"` + IsPublic bool `json:"is_public" db:"is_public"` CreatedAt time.Time `json:"created_at" db:"created_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } @@ -383,6 +384,7 @@ type templateWriteRequest struct { Logo string `json:"logo"` Config json.RawMessage `json:"config" binding:"required"` Variables json.RawMessage `json:"variables"` + IsPublic bool `json:"is_public"` } var templateCategories = map[string]bool{ @@ -483,6 +485,7 @@ func handleCreateTemplate(c *gin.Context) { Config: req.Config, Variables: templateVariablesParam(req.Variables), OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true}, + IsPublic: req.IsPublic, }); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create template"}) return @@ -529,6 +532,7 @@ func handleUpdateTemplate(c *gin.Context) { Logo: nullableText(req.Logo), Config: req.Config, Variables: templateVariablesParam(req.Variables), + IsPublic: req.IsPublic, ID: templateID, OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true}, }) @@ -1013,10 +1017,11 @@ func defaultTemplateResources(serviceType string) (cpu, memory string) { } } -// templateVisibleTo reports whether the caller may see a template: official -// templates are public, user templates are owner/admin only. +// templateVisibleTo reports whether the caller may see a template: official and +// published (is_public) templates are public, other user templates are +// owner/admin only. func templateVisibleTo(c *gin.Context, t ServiceTemplate) bool { - if t.IsOfficial { + if t.IsOfficial || t.IsPublic { return true } if contextIsAdmin(c) { @@ -1047,6 +1052,7 @@ func mapSQLCTemplate(row sqlcdb.ServiceTemplate) ServiceTemplate { Variables: variables, IsOfficial: row.IsOfficial.Valid && row.IsOfficial.Bool, OwnerID: ownerID, + IsPublic: row.IsPublic, CreatedAt: templateNullTime(row.CreatedAt), UpdatedAt: templateNullTime(row.UpdatedAt), } diff --git a/app/backend/internal/database/sqlcdb/models.go b/app/backend/internal/database/sqlcdb/models.go index 0d39eb3..b424a62 100644 --- a/app/backend/internal/database/sqlcdb/models.go +++ b/app/backend/internal/database/sqlcdb/models.go @@ -207,6 +207,7 @@ type ServiceTemplate struct { Variables pqtype.NullRawMessage `json:"variables"` IsOfficial sql.NullBool `json:"is_official"` OwnerID uuid.NullUUID `json:"owner_id"` + IsPublic bool `json:"is_public"` CreatedAt sql.NullTime `json:"created_at"` UpdatedAt sql.NullTime `json:"updated_at"` } diff --git a/app/backend/internal/database/sqlcdb/templates.sql.go b/app/backend/internal/database/sqlcdb/templates.sql.go index 526b650..36b8ef2 100644 --- a/app/backend/internal/database/sqlcdb/templates.sql.go +++ b/app/backend/internal/database/sqlcdb/templates.sql.go @@ -125,7 +125,7 @@ func (q *Queries) CreateServiceFromTemplate(ctx context.Context, arg CreateServi } const createUserTemplate = `-- name: CreateUserTemplate :exec -INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id) +INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id, is_public) VALUES ( $1, $2, @@ -135,7 +135,8 @@ VALUES ( $6, $7, false, - $8 + $8, + $9 ) ` @@ -148,6 +149,7 @@ type CreateUserTemplateParams struct { Config json.RawMessage `json:"config"` Variables pqtype.NullRawMessage `json:"variables"` OwnerID uuid.NullUUID `json:"owner_id"` + IsPublic bool `json:"is_public"` } func (q *Queries) CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error { @@ -160,6 +162,7 @@ func (q *Queries) CreateUserTemplate(ctx context.Context, arg CreateUserTemplate arg.Config, arg.Variables, arg.OwnerID, + arg.IsPublic, ) return err } @@ -198,7 +201,7 @@ func (q *Queries) GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUI } const getServiceTemplateByID = `-- name: GetServiceTemplateByID :one -SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at +SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at FROM service_templates WHERE id = $1 ` @@ -216,6 +219,7 @@ func (q *Queries) GetServiceTemplateByID(ctx context.Context, id string) (Servic &i.Variables, &i.IsOfficial, &i.OwnerID, + &i.IsPublic, &i.CreatedAt, &i.UpdatedAt, ) @@ -223,10 +227,10 @@ func (q *Queries) GetServiceTemplateByID(ctx context.Context, id string) (Servic } const listServiceTemplatesByCategoryForUser = `-- name: ListServiceTemplatesByCategoryForUser :many -SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at +SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at FROM service_templates WHERE category = $1 - AND (is_official = true OR owner_id = $2) + AND (is_official = true OR is_public = true OR owner_id = $2) ORDER BY is_official DESC, name ASC ` @@ -254,6 +258,7 @@ func (q *Queries) ListServiceTemplatesByCategoryForUser(ctx context.Context, arg &i.Variables, &i.IsOfficial, &i.OwnerID, + &i.IsPublic, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -271,9 +276,9 @@ func (q *Queries) ListServiceTemplatesByCategoryForUser(ctx context.Context, arg } const listServiceTemplatesForUser = `-- name: ListServiceTemplatesForUser :many -SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at +SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at FROM service_templates -WHERE is_official = true OR owner_id = $1 +WHERE is_official = true OR is_public = true OR owner_id = $1 ORDER BY is_official DESC, name ASC ` @@ -296,6 +301,7 @@ func (q *Queries) ListServiceTemplatesForUser(ctx context.Context, ownerID uuid. &i.Variables, &i.IsOfficial, &i.OwnerID, + &i.IsPublic, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -320,9 +326,10 @@ SET name = $1, logo = $4, config = $5, variables = $6, + is_public = $7, updated_at = NOW() -WHERE id = $7 - AND owner_id = $8 +WHERE id = $8 + AND owner_id = $9 AND is_official = false ` @@ -333,6 +340,7 @@ type UpdateUserTemplateParams struct { Logo sql.NullString `json:"logo"` Config json.RawMessage `json:"config"` Variables pqtype.NullRawMessage `json:"variables"` + IsPublic bool `json:"is_public"` ID string `json:"id"` OwnerID uuid.NullUUID `json:"owner_id"` } @@ -345,6 +353,7 @@ func (q *Queries) UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplate arg.Logo, arg.Config, arg.Variables, + arg.IsPublic, arg.ID, arg.OwnerID, ) diff --git a/app/backend/migrations_goose/20260924120000_template_publishing.sql b/app/backend/migrations_goose/20260924120000_template_publishing.sql new file mode 100644 index 0000000..6e28fab --- /dev/null +++ b/app/backend/migrations_goose/20260924120000_template_publishing.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- Railway-style template publishing: owners can mark a user template public so +-- it appears in the anonymous catalog (GET /api/v1/templates) and on the +-- landing page. Official templates stay official; is_public only matters for +-- user templates. +ALTER TABLE public.service_templates ADD COLUMN IF NOT EXISTS is_public boolean NOT NULL DEFAULT false; +CREATE INDEX IF NOT EXISTS idx_service_templates_public ON public.service_templates(is_public) WHERE is_public; + +-- +goose Down +DROP INDEX IF EXISTS public.idx_service_templates_public; +ALTER TABLE public.service_templates DROP COLUMN IF EXISTS is_public; diff --git a/app/backend/sqlc/queries/templates.sql b/app/backend/sqlc/queries/templates.sql index 25d2de4..3830c00 100644 --- a/app/backend/sqlc/queries/templates.sql +++ b/app/backend/sqlc/queries/templates.sql @@ -1,18 +1,18 @@ -- name: ListServiceTemplatesForUser :many -SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at +SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at FROM service_templates -WHERE is_official = true OR owner_id = sqlc.narg(owner_id) +WHERE is_official = true OR is_public = true OR owner_id = sqlc.narg(owner_id) ORDER BY is_official DESC, name ASC; -- name: ListServiceTemplatesByCategoryForUser :many -SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at +SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at FROM service_templates WHERE category = sqlc.arg(category) - AND (is_official = true OR owner_id = sqlc.narg(owner_id)) + AND (is_official = true OR is_public = true OR owner_id = sqlc.narg(owner_id)) ORDER BY is_official DESC, name ASC; -- name: GetServiceTemplateByID :one -SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at +SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at FROM service_templates WHERE id = $1; @@ -39,7 +39,7 @@ SET name = EXCLUDED.name, updated_at = NOW(); -- name: CreateUserTemplate :exec -INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id) +INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id, is_public) VALUES ( sqlc.arg(id), sqlc.arg(name), @@ -49,7 +49,8 @@ VALUES ( sqlc.arg(config), sqlc.narg(variables), false, - sqlc.arg(owner_id) + sqlc.arg(owner_id), + sqlc.arg(is_public) ); -- name: UpdateUserTemplate :execrows @@ -60,6 +61,7 @@ SET name = sqlc.arg(name), logo = sqlc.narg(logo), config = sqlc.arg(config), variables = sqlc.narg(variables), + is_public = sqlc.arg(is_public), updated_at = NOW() WHERE id = sqlc.arg(id) AND owner_id = sqlc.arg(owner_id) diff --git a/app/backend/sqlc/schema.sql b/app/backend/sqlc/schema.sql index a4e69bf..538b658 100644 --- a/app/backend/sqlc/schema.sql +++ b/app/backend/sqlc/schema.sql @@ -84,6 +84,7 @@ CREATE TABLE service_templates ( variables JSONB DEFAULT '[]', is_official BOOLEAN DEFAULT false, owner_id UUID, + is_public BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); diff --git a/app/frontend/src/app/App.tsx b/app/frontend/src/app/App.tsx index 81fe82e..5f71521 100644 --- a/app/frontend/src/app/App.tsx +++ b/app/frontend/src/app/App.tsx @@ -112,10 +112,12 @@ export default function App() { } /> {/* Public browsing — approved projects, templates, docs. */} + {/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */} + } /> + } /> + }> } /> - } /> - } /> } /> } /> } /> diff --git a/app/frontend/src/features/secondary/pages.tsx b/app/frontend/src/features/secondary/pages.tsx index 72ba3d1..af48e90 100644 --- a/app/frontend/src/features/secondary/pages.tsx +++ b/app/frontend/src/features/secondary/pages.tsx @@ -64,6 +64,8 @@ import { demoVulnerabilities, getDemoServicesByProject, } from '@/lib/demo-data'; +import { ServiceIcon } from '@/features/workspace/canvas/ServiceIcon'; +import { serviceAccent } from '@/features/workspace/canvas/service-visuals'; import { DocsBrowser } from '@/features/docs/DocsBrowser'; import { useAuthSession } from '@/lib/use-auth-session'; import { useDemoMode } from '@/lib/demo-mode'; @@ -1699,8 +1701,14 @@ export function DatabasesPage() { onClick={() => setExpandedId(expanded ? null : (db.id ?? null))} className="flex w-full items-center gap-3 p-4 text-left" > -
- +
+
diff --git a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx index e2535c5..2f27626 100644 --- a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx +++ b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { createDeployment, deleteService, @@ -37,6 +37,8 @@ import { } from '@/lib/api-client'; import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data'; import { useDemoMode } from '@/lib/demo-mode'; +import { ServiceIcon } from '@/features/workspace/canvas/ServiceIcon'; +import { serviceAccent } from '@/features/workspace/canvas/service-visuals'; import { useAuthSession } from '@/lib/use-auth-session'; import { getCurrentUserProfile } from '@/lib/api-client'; import { parseDotenv, validateVariableRows, type VariableDraft } from '../variable-utils'; @@ -122,6 +124,7 @@ function StatusBadge({ status }: { status: string }) { export function ServiceDetailPage() { const { projectId = '', serviceId = '' } = useParams<{ projectId: string; serviceId: string }>(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const queryClient = useQueryClient(); const isDemoMode = useDemoMode(); const sessionQuery = useAuthSession({ enabled: !isDemoMode }); @@ -134,7 +137,10 @@ export function ServiceDetailPage() { }); const isAdmin = Boolean(profileQuery.data?.isAdmin); - const [activeSection, setActiveSection] = useState('metrics'); + const [activeSection, setActiveSection] = useState(() => { + const requested = searchParams.get('section'); + return sectionItems.some((item) => item.key === requested) ? (requested as ServiceSection) : 'metrics'; + }); const [logTail, setLogTail] = useState('100'); const [varDrafts, setVarDrafts] = useState(null); const [bulkOpen, setBulkOpen] = useState(false); @@ -382,7 +388,7 @@ export function ServiceDetailPage() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['project-services', projectId] }); queryClient.invalidateQueries({ queryKey: ['projects'] }); - navigate(`/projects/${projectId}`); + navigate(isDemoMode ? `/projects/${projectId}?demo=1` : `/projects/${projectId}`); }, }); @@ -590,18 +596,17 @@ export function ServiceDetailPage() { {/* Project Header - self.html exact match */}
-
- - - +
diff --git a/app/frontend/src/features/templates/pages/TemplatesPage.tsx b/app/frontend/src/features/templates/pages/TemplatesPage.tsx index 81788ae..2ca4835 100644 --- a/app/frontend/src/features/templates/pages/TemplatesPage.tsx +++ b/app/frontend/src/features/templates/pages/TemplatesPage.tsx @@ -125,6 +125,7 @@ export function TemplatesPage() { const [editorOpen, setEditorOpen] = useState(false); const [editorJson, setEditorJson] = useState(EMPTY_TEMPLATE_JSON); const [editingTemplate, setEditingTemplate] = useState(null); + const [editorIsPublic, setEditorIsPublic] = useState(false); const [editorError, setEditorError] = useState(null); const [categoryFilter, setCategoryFilter] = useState(''); @@ -346,6 +347,7 @@ export function TemplatesPage() { const openCreateEditor = () => { setEditingTemplate(null); setEditorJson(EMPTY_TEMPLATE_JSON); + setEditorIsPublic(false); setEditorError(null); setEditorOpen(true); }; @@ -378,6 +380,7 @@ export function TemplatesPage() { 2, ), ); + setEditorIsPublic(template.isPublic); setEditorError(null); setEditorOpen(true); }; @@ -402,6 +405,7 @@ export function TemplatesPage() { logo: body.logo ?? '', config: body.config as Record, variables: Array.isArray(body.variables) ? body.variables : [], + isPublic: editorIsPublic, }); }; @@ -571,6 +575,11 @@ export function TemplatesPage() { Mine ) : null} + {!template.isOfficial && template.isPublic ? ( + + Public + + ) : null}
)} + {!selectedDetail.template.isOfficial && selectedDetail.template.isPublic && ( + + Public + + )}

{selectedDetail.template.description}

@@ -921,9 +935,15 @@ export function TemplatesPage() {
) : null}
-

- Saved as a user template — only you (and admins) can edit or delete it. -

+
+ + +
+ + +
+ ); +} + +function CanvasInner({ projectId, services, variablesByService, onAddService, onOpenService, onOpenSection, readOnly }: CanvasProps) { const wrapperRef = useRef(null); const persistTimeout = useRef(null); const hydratedRef = useRef(false); @@ -203,7 +390,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on const queryClient = useQueryClient(); const toast = useToast(); - const { fitView, getInternalNode, getViewport, screenToFlowPosition, setViewport } = useReactFlow(); + const { fitView, getInternalNode, getViewport, screenToFlowPosition, setViewport, zoomIn, zoomOut } = useReactFlow(); const environments = useMemo(() => { const envs = new Set(services.map((service) => service.environment ?? 'production')); @@ -226,10 +413,22 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on ); const edges = useMemo(() => { const positionOf = (serviceId: string) => nodes.find((node) => node.id === serviceId)?.position; - return toFlowEdges(inferredLinks, positionOf).filter( + const statusOf = (serviceId: string) => services.find((service) => service.id === serviceId)?.status; + return toFlowEdges(inferredLinks, positionOf, statusOf).filter( (edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target), ); - }, [inferredLinks, visibleIds, nodes]); + }, [inferredLinks, visibleIds, nodes, services]); + + const renameGroup = useCallback( + (groupId: string, title: string) => { + setNodes((current) => + current.map((node) => + node.id === groupId && node.type === 'groupNode' ? { ...node, data: { ...node.data, title } } : node, + ), + ); + }, + [setNodes], + ); const invalidateServices = useCallback(() => { void queryClient.invalidateQueries({ queryKey: ['project-services', projectId] }); @@ -276,6 +475,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on onSuccess: () => { toast.showToast('Service deleted', 'success'); invalidateServices(); + setSelectedServiceId(null); }, onError: actionError, }); @@ -286,10 +486,28 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on stopMutation.isPending || deleteMutation.isPending; + const tidyLayout = useCallback(() => { + const positions = computeLayeredLayout( + services, + inferredLinks.map((link) => link.edge), + ); + setNodes((current) => + current.map((node) => { + if (node.type !== 'serviceNode' || node.parentId) { + return node; + } + const next = positions.get(node.id); + return next ? { ...node, position: next } : node; + }), + ); + window.setTimeout(() => void fitView({ padding: 0.18, duration: 300 }), 40); + }, [services, inferredLinks, setNodes, fitView]); + useEffect(() => { const hasStoredViewport = hasCanvasMetadata(projectId); - const metadata = loadCanvasMetadata(projectId, services); - const nextNodes = toFlowNodes(metadata, services, onOpenService); + const layout = hasStoredViewport ? undefined : layoutFromVariables(services, variablesByService); + const metadata = loadCanvasMetadata(projectId, services, layout); + const nextNodes = toFlowNodes(metadata, services, onOpenService, renameGroup); viewportRestoredRef.current = false; setNodes(nextNodes); @@ -302,7 +520,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on metadata.viewport.zoom === DEFAULT_VIEWPORT.zoom; if (!hasStoredViewport || isDefaultViewport) { - void fitView({ padding: 0.15, duration: 120 }); + void fitView({ padding: 0.18, duration: 120 }); } else { void setViewport(metadata.viewport, { duration: 120 }); } @@ -311,7 +529,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on }); hydratedRef.current = true; - }, [projectId, serviceFingerprint, onOpenService, setNodes, setViewport, fitView, services]); + }, [projectId, serviceFingerprint, onOpenService, setNodes, setViewport, fitView, services, renameGroup, variablesByService]); useEffect(() => { if (!hydratedRef.current || !viewportRestoredRef.current) { @@ -352,6 +570,47 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on ); }, [selectedServiceId, setNodes]); + // Canvas keyboard shortcuts: F fit, 0 reset zoom, +/- zoom, Esc deselect. + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null; + if ( + target && + (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.isContentEditable) + ) { + return; + } + if (event.metaKey || event.ctrlKey || event.altKey) { + return; + } + + switch (event.key) { + case 'f': + case 'F': + void fitView({ padding: 0.18, duration: 240 }); + break; + case '0': + void setViewport({ x: 0, y: 0, zoom: 1 }, { duration: 180 }); + break; + case '=': + case '+': + void zoomIn({ duration: 160 }); + break; + case '-': + case '_': + void zoomOut({ duration: 160 }); + break; + case 'Escape': + setSelectedServiceId(null); + setContextMenu(null); + break; + } + }; + + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [fitView, setViewport, zoomIn, zoomOut]); + const getGroupUnderPoint = useCallback((x: number, y: number, ignoreGroupId?: string) => { for (const node of nodes) { if (node.type !== 'groupNode' || node.id === ignoreGroupId) { @@ -370,6 +629,27 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on return null; }, [getInternalNode, nodes]); + const dragStartPositions = useRef>(new Map()); + + const onNodeDragStart = useCallback( + (_event: MouseEvent, node: CanvasNode) => { + const abs = getInternalNode(node.id)?.internals.positionAbsolute ?? node.position; + dragStartPositions.current.set(node.id, { ...abs }); + }, + [getInternalNode], + ); + + const absoluteServiceRects = useCallback( + (excludeId: string): Rect[] => + nodes + .filter((node) => node.type === 'serviceNode' && node.id !== excludeId) + .map((node) => { + const abs = getInternalNode(node.id)?.internals.positionAbsolute ?? node.position; + return { x: abs.x, y: abs.y, w: NODE_WIDTH, h: NODE_HEIGHT }; + }), + [getInternalNode, nodes], + ); + const onNodeDragStop = useCallback( (_event: MouseEvent, movedNode: CanvasNode) => { if (movedNode.type !== 'serviceNode') { @@ -377,39 +657,90 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on } const basePosition = getInternalNode(movedNode.id)?.internals.positionAbsolute ?? movedNode.position; - const centerX = basePosition.x + SERVICE_NODE_WIDTH / 2; - const centerY = basePosition.y + SERVICE_NODE_HEIGHT / 2; + const fallback = dragStartPositions.current.get(movedNode.id) ?? basePosition; + const centerX = basePosition.x + NODE_WIDTH / 2; + const centerY = basePosition.y + NODE_HEIGHT / 2; const targetGroup = getGroupUnderPoint(centerX, centerY, movedNode.parentId); if (targetGroup) { const groupBase = getInternalNode(targetGroup.id)?.internals.positionAbsolute ?? targetGroup.position; - const targetWidth = typeof targetGroup.style?.width === 'number' ? targetGroup.style.width : GROUP_DEFAULT_WIDTH; - const targetHeight = typeof targetGroup.style?.height === 'number' ? targetGroup.style.height : GROUP_DEFAULT_HEIGHT; - const relativeX = Math.max(12, Math.min(targetWidth - SERVICE_NODE_WIDTH - 12, basePosition.x - groupBase.x)); - const relativeY = Math.max(30, Math.min(targetHeight - SERVICE_NODE_HEIGHT - 12, basePosition.y - groupBase.y)); + setNodes((current) => { + const slot = groupSlot( + current, + targetGroup.id, + targetGroup, + basePosition.x - groupBase.x, + basePosition.y - groupBase.y, + movedNode.id, + ); - setNodes((current) => - current.map((node) => { - if (node.id !== movedNode.id || node.type !== 'serviceNode') { - return node; + return current.map((node) => { + if (node.id === targetGroup.id && node.type === 'groupNode') { + return { ...node, style: { ...node.style, width: slot.width, height: slot.height } }; } + if (node.id === movedNode.id && node.type === 'serviceNode') { + return { + ...node, + parentId: targetGroup.id, + extent: 'parent' as const, + position: { x: slot.x, y: slot.y }, + }; + } + return node; + }); + }); - return { - ...node, - parentId: targetGroup.id, - extent: 'parent', - position: { x: relativeX, y: relativeY }, + return; + } + + // Member staying inside its own frame — keep membership, but resolve + // against siblings so nodes can't stack on each other. + if (movedNode.parentId) { + const parent = nodes.find((entry) => entry.id === movedNode.parentId && entry.type === 'groupNode'); + if (parent) { + const parentAbs = getInternalNode(parent.id)?.internals.positionAbsolute ?? parent.position; + const parentW = typeof parent.style?.width === 'number' ? parent.style.width : GROUP_DEFAULT_WIDTH; + const parentH = typeof parent.style?.height === 'number' ? parent.style.height : GROUP_DEFAULT_HEIGHT; + const inside = + centerX >= parentAbs.x && + centerX <= parentAbs.x + parentW && + centerY >= parentAbs.y && + centerY <= parentAbs.y + parentH; + if (inside) { + const siblingRects: Rect[] = nodes + .filter( + (entry) => + entry.type === 'serviceNode' && entry.parentId === parent.id && entry.id !== movedNode.id, + ) + .map((entry) => ({ x: entry.position.x, y: entry.position.y, w: NODE_WIDTH, h: NODE_HEIGHT })); + const relDesired = { x: basePosition.x - parentAbs.x, y: basePosition.y - parentAbs.y }; + const relFallback = { + x: Math.max(GROUP_PAD_X, Math.min(parentW - GROUP_PAD_X - NODE_WIDTH, fallback.x - parentAbs.x)), + y: Math.max(GROUP_PAD_TOP, Math.min(parentH - GROUP_PAD_BOTTOM - NODE_HEIGHT, fallback.y - parentAbs.y)), }; - }), - ); - - return; + const bounds: Rect = { + x: GROUP_PAD_X, + y: GROUP_PAD_TOP, + w: Math.max(NODE_WIDTH, parentW - GROUP_PAD_X * 2), + h: Math.max(NODE_HEIGHT, parentH - GROUP_PAD_TOP - GROUP_PAD_BOTTOM), + }; + const spot = resolveFreeSpot(relDesired, siblingRects, relFallback, bounds); + setNodes((current) => + current.map((entry) => + entry.id === movedNode.id && entry.type === 'serviceNode' + ? { ...entry, position: spot } + : entry, + ), + ); + return; + } + } } - if (!movedNode.parentId) { - return; - } + // Free placement — snapped to the canvas grid and pushed off any node it + // would overlap; reverts to the pre-drag spot if fully boxed in. + const spot = resolveFreeSpot(basePosition, absoluteServiceRects(movedNode.id), fallback); setNodes((current) => current.map((node) => { @@ -421,47 +752,190 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on ...node, parentId: undefined, extent: undefined, - position: { - x: basePosition.x, - y: basePosition.y, - }, + position: spot, }; }), ); }, - [getGroupUnderPoint, getInternalNode, setNodes], + [absoluteServiceRects, getGroupUnderPoint, getInternalNode, nodes, setNodes], ); - const addGroup = useCallback(() => { - const bounds = wrapperRef.current?.getBoundingClientRect(); - const center = bounds - ? screenToFlowPosition({ - x: bounds.left + bounds.width / 2, - y: bounds.top + bounds.height / 2, - }) - : { x: 180, y: 140 }; + const addGroup = useCallback( + (at?: { x: number; y: number }) => { + const bounds = wrapperRef.current?.getBoundingClientRect(); + const center = + at ?? + (bounds + ? screenToFlowPosition({ + x: bounds.left + bounds.width / 2, + y: bounds.top + bounds.height / 2, + }) + : { x: 180, y: 140 }); - const id = `group-${Date.now()}`; + const id = `group-${Date.now()}`; - setNodes((current) => [ - ...current, - { - id, - type: 'groupNode', - position: { - x: center.x - GROUP_DEFAULT_WIDTH / 2, - y: center.y - GROUP_DEFAULT_HEIGHT / 2, - }, - data: { title: `Group ${current.filter((node) => node.type === 'groupNode').length + 1}` }, - draggable: true, - selectable: true, - style: { - width: GROUP_DEFAULT_WIDTH, - height: GROUP_DEFAULT_HEIGHT, - }, - } satisfies GroupCanvasNode, - ]); - }, [screenToFlowPosition, setNodes]); + setNodes((current) => [ + { + id, + type: 'groupNode', + position: { + x: center.x - GROUP_DEFAULT_WIDTH / 2, + y: center.y - GROUP_DEFAULT_HEIGHT / 2, + }, + data: { title: `Group ${current.filter((node) => node.type === 'groupNode').length + 1}`, onRename: renameGroup }, + draggable: true, + selectable: true, + style: { + width: GROUP_DEFAULT_WIDTH, + height: GROUP_DEFAULT_HEIGHT, + }, + } satisfies GroupCanvasNode, + // Groups must precede their children in the array — keep it first. + ...current, + ]); + + return id; + }, + [screenToFlowPosition, setNodes, renameGroup], + ); + + // Move a service node in/out of a group — mirrors the drag-drop math so + // assignment via the inspector lands identically to dropping the node on a + // group frame. + const assignToGroup = useCallback( + (serviceId: string, groupId: string | null) => { + const node = nodes.find((entry) => entry.id === serviceId && entry.type === 'serviceNode'); + if (!node || node.parentId === (groupId ?? undefined)) { + return; + } + const abs = getInternalNode(serviceId)?.internals.positionAbsolute ?? node.position; + + if (groupId === null) { + setNodes((current) => { + const occupied: Rect[] = current + .filter((entry) => entry.type === 'serviceNode' && entry.id !== serviceId) + .map((entry) => { + const entryAbs = getInternalNode(entry.id)?.internals.positionAbsolute ?? entry.position; + return { x: entryAbs.x, y: entryAbs.y, w: NODE_WIDTH, h: NODE_HEIGHT }; + }); + const spot = resolveFreeSpot(abs, occupied, abs); + return current.map((entry) => + entry.id === serviceId && entry.type === 'serviceNode' + ? { ...entry, parentId: undefined, extent: undefined, position: spot } + : entry, + ); + }); + return; + } + + setNodes((current) => { + const group = current.find((entry) => entry.id === groupId && entry.type === 'groupNode'); + if (!group) { + return current; + } + const groupAbs = getInternalNode(groupId)?.internals.positionAbsolute ?? group.position; + const slot = groupSlot(current, groupId, group, abs.x - groupAbs.x, abs.y - groupAbs.y); + + return current.map((entry) => { + if (entry.id === groupId && entry.type === 'groupNode') { + return { ...entry, style: { ...entry.style, width: slot.width, height: slot.height } }; + } + if (entry.id === serviceId && entry.type === 'serviceNode') { + return { ...entry, parentId: groupId, extent: 'parent' as const, position: { x: slot.x, y: slot.y } }; + } + return entry; + }); + }); + }, + [getInternalNode, nodes, setNodes], + ); + + // Create a group frame wrapped around the service and adopt the node into it. + const newGroupForService = useCallback( + (serviceId: string) => { + const node = nodes.find((entry) => entry.id === serviceId && entry.type === 'serviceNode'); + if (!node) { + return; + } + const abs = getInternalNode(serviceId)?.internals.positionAbsolute ?? node.position; + const groupId = `group-${Date.now()}`; + + setNodes((current) => [ + // Parent precedes the child in the array. + { + id: groupId, + type: 'groupNode', + position: { x: abs.x - GROUP_PAD_X, y: abs.y - GROUP_PAD_TOP }, + data: { + title: `Group ${current.filter((entry) => entry.type === 'groupNode').length + 1}`, + onRename: renameGroup, + }, + draggable: true, + selectable: true, + style: { + width: NODE_WIDTH + GROUP_PAD_X * 2, + height: NODE_HEIGHT + GROUP_PAD_TOP + GROUP_PAD_BOTTOM, + }, + } satisfies GroupCanvasNode, + ...current.map((entry) => + entry.id === serviceId && entry.type === 'serviceNode' + ? { + ...entry, + parentId: groupId, + extent: 'parent' as const, + position: { x: GROUP_PAD_X, y: GROUP_PAD_TOP }, + } + : entry, + ), + ]); + }, + [getInternalNode, nodes, renameGroup, setNodes], + ); + + const deleteGroup = useCallback( + (groupId: string) => { + setNodes((current) => { + const memberIds = new Set( + current + .filter((entry) => entry.type === 'serviceNode' && entry.parentId === groupId) + .map((entry) => entry.id), + ); + // Freed members must not land on free nodes — resolve each in turn and + // treat already-placed members as occupied for the next. + const occupied: Rect[] = current + .filter((entry) => entry.type === 'serviceNode' && !memberIds.has(entry.id)) + .map((entry) => { + const abs = getInternalNode(entry.id)?.internals.positionAbsolute ?? entry.position; + return { x: abs.x, y: abs.y, w: NODE_WIDTH, h: NODE_HEIGHT }; + }); + const unparented = current.map((entry) => { + if (entry.type !== 'serviceNode' || entry.parentId !== groupId) { + return entry; + } + const abs = getInternalNode(entry.id)?.internals.positionAbsolute ?? entry.position; + const spot = resolveFreeSpot(abs, occupied, abs); + occupied.push({ x: spot.x, y: spot.y, w: NODE_WIDTH, h: NODE_HEIGHT }); + return { ...entry, parentId: undefined, extent: undefined, position: spot }; + }); + return unparented.filter((entry) => entry.id !== groupId); + }); + setContextMenu(null); + }, + [getInternalNode, setNodes], + ); + + const requestGroupRename = useCallback( + (groupId: string) => { + setNodes((current) => + current.map((entry) => + entry.id === groupId && entry.type === 'groupNode' + ? { ...entry, data: { ...entry.data, renameNonce: (entry.data.renameNonce ?? 0) + 1 } } + : entry, + ), + ); + }, + [setNodes], + ); // Env filter hides nodes via React Flow's `hidden` flag — positions and // persisted layout stay intact for services outside the current filter. @@ -473,301 +947,436 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on [nodes, visibleIds], ); + const menuPosition = useCallback((clientX: number, clientY: number, menuW = 190, menuH = 240) => { + const bounds = wrapperRef.current?.getBoundingClientRect(); + const x = Math.min(clientX - (bounds?.left ?? 0), Math.max(0, (bounds?.width ?? 0) - menuW)); + const y = Math.min(clientY - (bounds?.top ?? 0), Math.max(0, (bounds?.height ?? 0) - menuH)); + return { x, y }; + }, []); + const onNodeContextMenu = useCallback( (event: MouseEvent, node: CanvasNode) => { - if (node.type !== 'serviceNode') { - return; - } event.preventDefault(); - const bounds = wrapperRef.current?.getBoundingClientRect(); - setSelectedServiceId(node.id); - const menuW = 190; - const menuH = 230; - const x = Math.min(event.clientX - (bounds?.left ?? 0), Math.max(0, (bounds?.width ?? 0) - menuW)); - const y = Math.min(event.clientY - (bounds?.top ?? 0), Math.max(0, (bounds?.height ?? 0) - menuH)); - setContextMenu({ serviceId: node.id, x, y }); + if (node.type === 'serviceNode') { + setSelectedServiceId(node.id); + setContextMenu({ kind: 'service', targetId: node.id, ...menuPosition(event.clientX, event.clientY) }); + } else if (node.type === 'groupNode') { + setContextMenu({ kind: 'group', targetId: node.id, ...menuPosition(event.clientX, event.clientY, 190, 140) }); + } + }, + [menuPosition], + ); + + const onPaneContextMenu = useCallback( + (event: MouseEvent | globalThis.MouseEvent) => { + event.preventDefault(); + const flow = screenToFlowPosition({ x: event.clientX, y: event.clientY }); + setContextMenu({ kind: 'pane', ...menuPosition(event.clientX, event.clientY, 200, 170), flowX: flow.x, flowY: flow.y }); + }, + [menuPosition, screenToFlowPosition], + ); + + const contextService = + contextMenu?.kind === 'service' && contextMenu.targetId + ? services.find((service) => service.id === contextMenu.targetId) ?? null + : null; + const contextGroup = + contextMenu?.kind === 'group' && contextMenu.targetId + ? nodes.find( + (node): node is GroupCanvasNode => node.id === contextMenu.targetId && node.type === 'groupNode', + ) ?? null + : null; + const inspectorGroups: InspectorGroup[] = useMemo( + () => + nodes + .filter((node): node is GroupCanvasNode => node.type === 'groupNode') + .map((node) => ({ id: node.id, title: node.data.title })), + [nodes], + ); + const selectedNode = selectedServiceId ? nodes.find((node) => node.id === selectedServiceId) : undefined; + + const selectedService = services.find((service) => service.id === selectedServiceId) ?? null; + const selectedConnections = useMemo(() => { + if (!selectedService) { + return []; + } + const nameOf = (id: string) => services.find((service) => service.id === id)?.name ?? id; + return inferredLinks + .filter( + (link) => + link.edge.sourceServiceId === selectedService.id || link.edge.targetServiceId === selectedService.id, + ) + .map((link) => ({ + id: link.edge.id, + outbound: link.edge.sourceServiceId === selectedService.id, + peer: link.edge.sourceServiceId === selectedService.id ? nameOf(link.edge.targetServiceId) : nameOf(link.edge.sourceServiceId), + peerId: link.edge.sourceServiceId === selectedService.id ? link.edge.targetServiceId : link.edge.sourceServiceId, + reasons: link.reasons, + })); + }, [inferredLinks, selectedService, services]); + + const minimapNodeColor = useCallback( + (node: CanvasNode) => { + if (node.type === 'groupNode') { + return 'rgba(255,255,255,0.08)'; + } + return serviceAccent((node.data as ServiceNodeData).service); }, [], ); - const contextService = contextMenu ? services.find((service) => service.id === contextMenu.serviceId) ?? null : null; - - const selectedService = services.find((service) => service.id === selectedServiceId) ?? null; - const selectedServiceLinkCount = selectedService - ? inferredLinks.filter( - (link) => - link.edge.sourceServiceId === selectedService.id || link.edge.targetServiceId === selectedService.id, - ).length - : 0; - return ( -
- {/* Toolbar - Railway-inspired premium design */} -
+
+ { + setSelectedServiceId(null); + setContextMenu(null); + }} + onMoveStart={() => setContextMenu(null)} + onNodeClick={(_event, node) => { + setContextMenu(null); + if (node.type === 'serviceNode') { + setSelectedServiceId(node.id); + } + }} + onNodeDoubleClick={(_event, node) => { + if (node.type === 'serviceNode') { + onOpenService(node.id); + } + }} + onNodeContextMenu={onNodeContextMenu} + onPaneContextMenu={onPaneContextMenu} + onNodeDragStart={onNodeDragStart} + onNodeDragStop={onNodeDragStop} + snapToGrid + snapGrid={[GRID_STEP, GRID_STEP]} + onMoveEnd={() => setViewportTick((value) => value + 1)} + panOnDrag + zoomOnScroll + zoomOnDoubleClick={false} + nodesConnectable={false} + nodeDragThreshold={3} + onlyRenderVisibleElements + minZoom={0.2} + maxZoom={2.2} + deleteKeyCode={null} + selectionKeyCode="Shift" + proOptions={{ hideAttribution: true }} + > + + + + + {/* Floating: env filter + group (top-left) */} +
+ {environments.length > 1 && ( +
+ {['all', ...environments].map((env) => ( + + ))} +
+ )} -
- - - {environments.length > 1 && ( - <> -
-
- {['all', ...environments].map((env) => ( - - ))} -
- - )} - -
- - -
- -
-
- - {services.length} -
-
-
- - {edges.length} -
-
- {/* Canvas */} -
- { - setSelectedServiceId(null); - setContextMenu(null); - }} - onMoveStart={() => setContextMenu(null)} - onNodeClick={(_event, node) => { - setContextMenu(null); - if (node.type === 'serviceNode') { - setSelectedServiceId(node.id); - } - }} - onNodeDoubleClick={(_event, node) => { - if (node.type === 'serviceNode') { - onOpenService(node.id); - } - }} - onNodeContextMenu={onNodeContextMenu} - onNodeDragStop={onNodeDragStop} - onMoveEnd={() => setViewportTick((value) => value + 1)} - panOnDrag - zoomOnScroll - minZoom={0.25} - maxZoom={2.3} - deleteKeyCode={null} - proOptions={{ hideAttribution: true }} - > - {/* SVG Definitions for premium edge styling */} - - - - - + {/* Floating: zoom controls (bottom-left) */} +
+ +
- {/* Empty state */} - {services.length === 0 && ( -
-
-
- -
-

Deploy your first service

-

- Add a service from a git repo or Docker image — it runs on a private network with the rest of this - project. -

+ {/* Floating: hint (bottom-center) — fades once a node is selected */} + {!selectedService && services.length > 0 && ( +
+

+ Click to inspect · Double-click to open · Right-click for actions · F to fit +

+
+ )} + + {/* Inspector (right) */} + {selectedService && ( + deployMutation.mutate(selectedService.id), + start: () => startMutation.mutate(selectedService.id), + restart: () => restartMutation.mutate(selectedService.id), + stop: () => stopMutation.mutate(selectedService.id), + remove: () => { + if (window.confirm(`Delete service "${selectedService.name}"? Its containers will be removed.`)) { + deleteMutation.mutate(selectedService.id); + } + }, + pending: actionPending, + deployPending: deployMutation.isPending, + restartPending: restartMutation.isPending, + }} + onClose={() => setSelectedServiceId(null)} + onOpenService={onOpenService} + onOpenSection={(serviceId, section) => + onOpenSection ? onOpenSection(serviceId, section) : onOpenService(serviceId) + } + onSelectPeer={setSelectedServiceId} + onAssignGroup={assignToGroup} + onNewGroupFor={newGroupForService} + /> + )} + + {/* Empty state */} + {services.length === 0 && ( +
+
+
+ +
+

Deploy your first service

+

+ Add a service from a git repo or Docker image - it runs on a private network with the rest of this + project. +

+ +
+
+ )} + + {/* Context menus — service node, group frame, or empty pane */} + {contextMenu && ( +
+ {contextMenu.kind === 'pane' && ( + <> -
-
- )} - - {/* Node context menu */} - {contextMenu && contextService && ( -
-
-

{contextService.name}

-

{contextService.status}

-
- - {(contextService.domain || contextService.publicUrl) && ( - { + setContextMenu(null); + onAddService(); + }} className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors" > - - Visit {(contextService.domain ?? contextService.publicUrl ?? '').replace(/^https?:\/\//, '')} - - )} - + +
+ + +
+ + )} + + {contextMenu.kind === 'group' && contextGroup && ( + <> +
+

{contextGroup.data.title}

+

Group

+
+ +
+ +
+ + )} + + {contextMenu.kind === 'service' && contextService && ( + <> +
+

{contextService.name}

+

{contextService.status}

+
+ + {(contextService.domain || contextService.publicUrl) && ( + - - Deploy - - {contextService.status !== 'running' && ( + + Visit {(contextService.domain ?? contextService.publicUrl ?? '').replace(/^https?:\/\//, '')} + + )} + {!readOnly && ( + <> + {contextService.status !== 'running' && ( + + )} + - )} - - -
-
-
- )} -
- - {/* Footer */} -
- {selectedService ? ( -
-
-
- +
+
-
- {selectedService.name} - ({selectedService.type}) -
-
-
- - {selectedServiceLinkCount} connection{selectedServiceLinkCount !== 1 ? 's' : ''} -
-
- ) : ( -

- Click to select • Double-click to open • Right-click for actions • Connections auto-inferred from variables -

- )} -
+ + )} + + )} +
+ )}
); } diff --git a/app/frontend/src/features/workspace/canvas/ServiceIcon.tsx b/app/frontend/src/features/workspace/canvas/ServiceIcon.tsx new file mode 100644 index 0000000..17d9fc0 --- /dev/null +++ b/app/frontend/src/features/workspace/canvas/ServiceIcon.tsx @@ -0,0 +1,26 @@ +import { useState } from 'react'; +import { serviceIcon, serviceIconUrl, serviceTypeIcon, type ServiceIconInput } from './service-visuals'; + +export function ServiceIcon({ + service, + size = 16, +}: { + service: ServiceIconInput; + size?: number; +}) { + const [failed, setFailed] = useState(false); + const brand = serviceIcon(service); + if (brand && !failed) { + return ( + setFailed(true)} + /> + ); + } + return serviceTypeIcon(service.type ?? 'service', size); +} diff --git a/app/frontend/src/features/workspace/canvas/auto-layout.ts b/app/frontend/src/features/workspace/canvas/auto-layout.ts new file mode 100644 index 0000000..3c710c8 --- /dev/null +++ b/app/frontend/src/features/workspace/canvas/auto-layout.ts @@ -0,0 +1,112 @@ +import type { ServiceEntity } from '@/lib/api-client'; +import type { ServiceVariable } from '../auto-connections'; +import { inferAutoConnections } from '../auto-connections'; + +export const NODE_WIDTH = 240; +export const NODE_HEIGHT = 104; +// Multiples of the 26px canvas grid so laid-out nodes land on the lattice. +export const COL_GAP = 338; +export const ROW_GAP = 182; + +type LinkEdge = { sourceServiceId: string; targetServiceId: string }; + +// Layered left-to-right layout: a service's depth is the longest chain of +// dependencies beneath it, so consumers sit left and datastores right. +// Unconnected services get their own trailing column separated by a gap. +export function computeLayeredLayout( + services: ServiceEntity[], + edges: LinkEdge[], +): Map { + const positions = new Map(); + if (services.length === 0) { + return positions; + } + + const ids = new Set(services.map((service) => service.id)); + const outgoing = new Map(); + const connected = new Set(); + + for (const edge of edges) { + if (!ids.has(edge.sourceServiceId) || !ids.has(edge.targetServiceId)) { + continue; + } + connected.add(edge.sourceServiceId); + connected.add(edge.targetServiceId); + const list = outgoing.get(edge.sourceServiceId) ?? []; + list.push(edge.targetServiceId); + outgoing.set(edge.sourceServiceId, list); + } + + const depthMemo = new Map(); + const depthOf = (id: string, trail: Set): number => { + const memoized = depthMemo.get(id); + if (memoized !== undefined) { + return memoized; + } + if (trail.has(id)) { + return 0; // cycle guard + } + trail.add(id); + let best = 0; + for (const target of outgoing.get(id) ?? []) { + best = Math.max(best, depthOf(target, trail) + 1); + } + trail.delete(id); + depthMemo.set(id, best); + return best; + }; + + const connectedServices = services + .filter((service) => connected.has(service.id)) + .sort((a, b) => a.name.localeCompare(b.name)); + const looseServices = services + .filter((service) => !connected.has(service.id)) + .sort((a, b) => a.name.localeCompare(b.name)); + + const depthById = new Map(connectedServices.map((service) => [service.id, depthOf(service.id, new Set())])); + const maxDepth = Math.max(0, ...depthById.values()); + + // Column = maxDepth - depth so deeper consumers render further left. + const columns = new Map(); + for (const service of connectedServices) { + const col = maxDepth - (depthById.get(service.id) ?? 0); + const list = columns.get(col) ?? []; + list.push(service); + columns.set(col, list); + } + + const connectedColCount = columns.size; + const maxRows = Math.max( + 1, + ...Array.from(columns.values()).map((list) => list.length), + looseServices.length, + ); + + const gridOffset = (value: number) => Math.round(value / 26) * 26; + + for (const [col, list] of columns) { + const offsetY = gridOffset(((maxRows - list.length) * ROW_GAP) / 2); + list.forEach((service, row) => { + positions.set(service.id, { x: col * COL_GAP, y: offsetY + row * ROW_GAP }); + }); + } + + looseServices.forEach((service, row) => { + const col = connectedColCount > 0 ? connectedColCount : 0; + const offsetY = gridOffset(((maxRows - looseServices.length) * ROW_GAP) / 2); + positions.set(service.id, { x: col * COL_GAP, y: offsetY + row * ROW_GAP }); + }); + + return positions; +} + +export function layoutFromVariables( + services: ServiceEntity[], + variablesByService: Record, +): Map { + const links = inferAutoConnections(services, variablesByService); + return computeLayeredLayout( + services, + links.map((link) => link.edge), + ); +} diff --git a/app/frontend/src/features/workspace/canvas/inspector.tsx b/app/frontend/src/features/workspace/canvas/inspector.tsx new file mode 100644 index 0000000..b3c748c --- /dev/null +++ b/app/frontend/src/features/workspace/canvas/inspector.tsx @@ -0,0 +1,415 @@ +import type { ServiceEntity } from '@/lib/api-client'; +import { serviceStatusClass } from '@/lib/api-client'; +import type { ServiceVariable } from '../auto-connections'; +import { ServiceIcon } from './ServiceIcon'; +import { serviceAccent } from './service-visuals'; +import { formatRelative } from '@/lib/time'; +import { + ArrowUpRight, + Copy, + Globe, + KeyRound, + Layers, + Link2, + Loader2, + Play, + Rocket, + Square, + Trash2, + X, +} from 'lucide-react'; +import { useState } from 'react'; + +export type InspectorGroup = { id: string; title: string }; +export type InspectorConnection = { + id: string; + outbound: boolean; + peer: string; + peerId: string; + reasons: string[]; +}; + +export type InspectorActions = { + deploy: () => void; + start: () => void; + restart: () => void; + stop: () => void; + remove: () => void; + pending: boolean; + deployPending: boolean; + restartPending: boolean; +}; + +function statusLabel(status: string): string { + switch (status) { + case 'running': + return 'Online'; + case 'deployed': + return 'Deployed'; + case 'building': + return 'Building'; + case 'deploying': + return 'Deploying'; + case 'pending': + return 'Pending'; + case 'failed': + return 'Failed'; + case 'stopped': + return 'Stopped'; + case 'rolling_back': + return 'Rolling back'; + default: + return status; + } +} + +function statusColor(status: string): string { + switch (status) { + case 'running': + case 'deployed': + return 'var(--success)'; + case 'failed': + return 'var(--error)'; + case 'building': + case 'deploying': + case 'pending': + case 'rolling_back': + return 'var(--warning)'; + default: + return 'var(--text-tertiary)'; + } +} + +function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) { + return ( +
+
+

{title}

+ {action} +
+ {children} +
+ ); +} + +function SectionLink({ onClick, children }: { onClick: () => void; children: React.ReactNode }) { + return ( + + ); +} + +export function ServiceInspector({ + service, + variables, + connections, + groups, + groupId, + readOnly, + actions, + onClose, + onOpenService, + onOpenSection, + onSelectPeer, + onAssignGroup, + onNewGroupFor, +}: { + service: ServiceEntity; + variables: ServiceVariable[]; + connections: InspectorConnection[]; + groups: InspectorGroup[]; + groupId: string | undefined; + readOnly?: boolean; + actions: InspectorActions; + onClose: () => void; + onOpenService: (serviceId: string) => void; + onOpenSection: (serviceId: string, section: string) => void; + onSelectPeer: (serviceId: string) => void; + onAssignGroup: (serviceId: string, groupId: string | null) => void; + onNewGroupFor: (serviceId: string) => void; +}) { + const accent = serviceAccent(service); + const publicUrl = service.domain ? `https://${service.domain}` : service.publicUrl; + const [copied, setCopied] = useState(false); + + const copyAddress = () => { + const text = publicUrl ?? (service.port ? `${service.name}:${service.port}` : service.name); + void navigator.clipboard?.writeText(text).then(() => { + setCopied(true); + window.setTimeout(() => setCopied(false), 1200); + }); + }; + + const configRows: [string, string | undefined][] = [ + ['Image', service.image], + ['Command', service.command], + ['Port', service.port ? String(service.port) : undefined], + ['Domain', service.domain], + ['Git repo', service.gitRepo], + ['Branch', service.gitBranch], + ['Build path', service.buildPath], + ['CPU', service.cpu], + ['Memory', service.memory], + ['Restart', service.restartPolicy], + ['Healthcheck', service.healthcheckPath], + ]; + const visibleConfig = configRows.filter(([, value]) => value); + + return ( + + ); +} diff --git a/app/frontend/src/features/workspace/canvas/nodes.tsx b/app/frontend/src/features/workspace/canvas/nodes.tsx index 942babf..f6b85da 100644 --- a/app/frontend/src/features/workspace/canvas/nodes.tsx +++ b/app/frontend/src/features/workspace/canvas/nodes.tsx @@ -1,8 +1,10 @@ import { Handle, Position, type Node, type NodeProps } from '@xyflow/react'; import type { ServiceEntity } from '@/lib/api-client'; import { serviceStatusClass } from '@/lib/api-client'; -import { Box, Globe, Database, Terminal, ExternalLink, Copy, Layers } from 'lucide-react'; +import { Box, ExternalLink, Copy, Layers, Loader2 } from 'lucide-react'; import { useState } from 'react'; +import { serviceIcon, serviceAccent } from './service-visuals'; +import { ServiceIcon } from './ServiceIcon'; export type ServiceNodeData = { service: ServiceEntity; @@ -12,37 +14,13 @@ export type ServiceNodeData = { export type GroupNodeData = { title: string; + onRename?: (groupId: string, title: string) => void; + renameNonce?: number; }; export type ServiceNodeType = Node; export type GroupNodeType = Node; -function renderServiceTypeIcon(type: string, size = 18) { - switch (type) { - case 'web': - return ; - case 'database': - return ; - case 'worker': - return ; - default: - return ; - } -} - -function serviceTypeColor(type: string): string { - switch (type) { - case 'web': - return '#7ab8ff'; - case 'database': - return '#b4e34a'; - case 'worker': - return 'var(--accent-primary)'; - default: - return '#9295a4'; - } -} - function statusLabel(status: string): string { switch (status) { case 'running': @@ -72,27 +50,30 @@ function statusColor(status: string): string { } } +const TRANSIENT_STATUSES = new Set(['building', 'deploying', 'pending', 'rolling_back']); + export function ServiceNode({ data }: NodeProps) { const { service, selected } = data; - const typeColor = serviceTypeColor(service.type); + const brand = serviceIcon(service); + const typeColor = brand?.color ?? serviceAccent(service); const replicas = service.replicas ?? 0; const stateColor = statusColor(service.status); + const isTransient = TRANSIENT_STATUSES.has(service.status); const publicUrl = service.domain ? `https://${service.domain}` : service.publicUrl; const internalAddr = service.port ? `${service.name}:${service.port}` : service.name; return (
@@ -105,22 +86,26 @@ export function ServiceNode({ data }: NodeProps) { style={{ background: stateColor }} /> -
-
+
+
- {renderServiceTypeIcon(service.type, 18)} + {isTransient ? ( + + ) : ( + + )}
-
-

{service.name}

-

+

+

+ {service.name} +

+

{service.type} {service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''}

@@ -133,7 +118,7 @@ export function ServiceNode({ data }: NodeProps) { )}
-
+
-
+
- + {statusLabel(service.status)}
{service.image && ( - + {service.image} )} @@ -181,41 +166,78 @@ export function ServiceNode({ data }: NodeProps) { ); } -export function GroupNode({ data }: NodeProps) { +export function GroupNode({ id, data }: NodeProps) { const [isHovered, setIsHovered] = useState(false); + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(data.title); + + // Context-menu rename requests bump the nonce; enter edit mode when it + // changes. Render-phase state adjustment — the React-sanctioned pattern. + const [seenRenameNonce, setSeenRenameNonce] = useState(data.renameNonce ?? 0); + const incomingNonce = data.renameNonce ?? 0; + if (incomingNonce > seenRenameNonce) { + setSeenRenameNonce(incomingNonce); + setDraft(data.title); + setEditing(true); + } + + const commit = () => { + const next = draft.trim() || data.title; + setEditing(false); + if (next !== data.title) { + data.onRename?.(id, next); + } + }; return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > -
-
-
-
-
- -
-

{data.title}

+
+
+
+ {editing ? ( + setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === 'Enter') commit(); + if (e.key === 'Escape') { + setDraft(data.title); + setEditing(false); + } + }} + className="nodrag h-7 px-2 rounded-[var(--radius-sm)] border border-[var(--border-default)] bg-[var(--surface-card)] text-sm font-semibold text-[var(--text-primary)] outline-none focus:border-[var(--accent-primary)]" + /> + ) : ( +

{ + e.stopPropagation(); + setDraft(data.title); + setEditing(true); + }} + title="Double-click to rename" + > + {data.title} +

+ )}

; + case 'database': + return ; + case 'worker': + return ; + case 'cron': + return ; + default: + return ; + } +} + +export function serviceTypeColor(type: string): string { + switch (type) { + case 'web': + return '#7ab8ff'; + case 'database': + return '#b4e34a'; + case 'worker': + return '#f2c94c'; + case 'cron': + return '#f2994a'; + default: + return '#9295a4'; + } +} + +export type ServiceBrandIcon = { + slug: string; + color: string; +}; + +const TECH_ICONS: (ServiceBrandIcon & { patterns: RegExp[] })[] = [ + { slug: 'postgresql', color: '#7ab8ff', patterns: [/postgres/i, /postgis/i] }, + { slug: 'redis', color: '#ff6b6b', patterns: [/redis/i, /dragonfly/i, /valkey/i, /keydb/i] }, + { slug: 'mysql', color: '#5ba4e5', patterns: [/mysql/i] }, + { slug: 'mariadb', color: '#4ea5ff', patterns: [/mariadb/i] }, + { slug: 'mongodb', color: '#47a248', patterns: [/mongo/i] }, + { slug: 'clickhouse', color: '#ffcc01', patterns: [/clickhouse/i] }, + { slug: 'couchdb', color: '#e42528', patterns: [/couchdb/i] }, + { slug: 'sqlite', color: '#69b7e4', patterns: [/sqlite/i] }, + { slug: 'influxdb', color: '#22adf6', patterns: [/influx/i] }, + { slug: 'elasticsearch', color: '#fec514', patterns: [/elastic/i, /opensearch/i] }, + { slug: 'meilisearch', color: '#ff5caa', patterns: [/meili/i] }, + { slug: 'typesense', color: '#d90368', patterns: [/typesense/i] }, + { slug: 'qdrant', color: '#dc244c', patterns: [/qdrant/i] }, + { slug: 'weaviate', color: '#01c9a7', patterns: [/weaviate/i] }, + { slug: 'minio', color: '#c72e49', patterns: [/minio/i] }, + { slug: 'rabbitmq', color: '#ff6600', patterns: [/rabbitmq/i, /rabbit/i] }, + { slug: 'apachekafka', color: '#d6dae4', patterns: [/kafka/i] }, + { slug: 'natsdotio', color: '#8be1d3', patterns: [/\bnats/i] }, + { slug: 'nginx', color: '#009639', patterns: [/nginx/i] }, + { slug: 'caddy', color: '#1f88c0', patterns: [/caddy/i] }, + { slug: 'traefikproxy', color: '#24a1c1', patterns: [/traefik/i] }, + { slug: 'nodedotjs', color: '#5fa04e', patterns: [/node(?=[:\-\d]|$)/i, /nodejs/i] }, + { slug: 'go', color: '#00add8', patterns: [/golang/i, /(?:^|[^a-z])go(?=[:\-\d]|$)/i] }, + { slug: 'python', color: '#6fa8dc', patterns: [/python/i, /\bpy(?:thon)?(?=[:\-\d]|$)/i] }, + { slug: 'rust', color: '#f74c00', patterns: [/rust/i] }, + { slug: 'php', color: '#8892bf', patterns: [/php/i] }, + { slug: 'ruby', color: '#cc342d', patterns: [/ruby/i, /rails/i] }, + { slug: 'openjdk', color: '#e6a23c', patterns: [/java/i, /openjdk/i, /jdk/i, /jre/i] }, + { slug: 'dotnet', color: '#6f4bd8', patterns: [/dotnet/i, /aspnet/i] }, + { slug: 'deno', color: '#70ffaf', patterns: [/\bdeno/i] }, + { slug: 'bun', color: '#f9f1e1', patterns: [/bun(?=[:\-\d]|$)/i] }, + { slug: 'react', color: '#61dafb', patterns: [/react/i] }, + { slug: 'vuedotjs', color: '#4fc08d', patterns: [/vue/i] }, + { slug: 'angular', color: '#dd0031', patterns: [/angular/i] }, + { slug: 'svelte', color: '#ff3e00', patterns: [/svelte/i] }, + { slug: 'nextdotjs', color: '#e6edf3', patterns: [/nextjs/i, /next\.js/i] }, + { slug: 'nuxtdotjs', color: '#00dc82', patterns: [/nuxt/i] }, + { slug: 'astro', color: '#ff5d01', patterns: [/astro/i] }, + { slug: 'prometheus', color: '#e6522c', patterns: [/prometheus/i] }, + { slug: 'grafana', color: '#f46800', patterns: [/grafana/i] }, + { slug: 'uptimekuma', color: '#5cdd8b', patterns: [/uptime.?kuma/i, /\bkuma/i] }, + { slug: 'n8n', color: '#ea4b71', patterns: [/n8n/i] }, + { slug: 'wordpress', color: '#4b9cd3', patterns: [/wordpress/i, /\bwp-/i] }, + { slug: 'ghost', color: '#a8b8c4', patterns: [/ghost/i] }, + { slug: 'nextcloud', color: '#0082c9', patterns: [/nextcloud/i] }, + { slug: 'immich', color: '#7c9ef5', patterns: [/immich/i] }, + { slug: 'supabase', color: '#3fcf8e', patterns: [/supabase/i] }, + { slug: 'pocketbase', color: '#b8dbe4', patterns: [/pocketbase/i] }, + { slug: 'keycloak', color: '#d6dae4', patterns: [/keycloak/i] }, + { slug: 'authentik', color: '#fd4b2d', patterns: [/authentik/i] }, + { slug: 'portainer', color: '#13bef9', patterns: [/portainer/i] }, + { slug: 'docker', color: '#2496ed', patterns: [/docker/i, /dind/i] }, + { slug: 'kubernetes', color: '#326ce5', patterns: [/kubernetes/i, /\bk8s/i] }, +]; + +export type ServiceIconInput = { image?: string; name?: string; type?: string }; + +export function serviceIcon(service: ServiceIconInput): ServiceBrandIcon | null { + const text = `${service.image ?? ''} ${service.name ?? ''}`.toLowerCase(); + for (const icon of TECH_ICONS) { + if (icon.patterns.some((pattern) => pattern.test(text))) { + return icon; + } + } + return null; +} + +export function serviceIconUrl(icon: ServiceBrandIcon): string { + return `https://cdn.simpleicons.org/${icon.slug}/${icon.color.replace('#', '')}`; +} + +export function serviceAccent(service: ServiceIconInput): string { + return serviceIcon(service)?.color ?? serviceTypeColor(service.type ?? 'service'); +} diff --git a/app/frontend/src/features/workspace/model.ts b/app/frontend/src/features/workspace/model.ts index 704d479..950ad02 100644 --- a/app/frontend/src/features/workspace/model.ts +++ b/app/frontend/src/features/workspace/model.ts @@ -44,14 +44,17 @@ export const DEFAULT_VIEWPORT: CanvasViewport = { zoom: 1, }; -export function createDefaultCanvasMetadata(services: ServiceEntity[]): ProjectCanvasMetadata { +export function createDefaultCanvasMetadata( + services: ServiceEntity[], + layout?: Map, +): ProjectCanvasMetadata { const nodes = services.map((service, index) => { const col = index % 3; const row = Math.floor(index / 3); return { serviceId: service.id, - position: { + position: layout?.get(service.id) ?? { x: 70 + col * 260, y: 80 + row * 170, }, diff --git a/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx b/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx index 56d9d58..cf9eeee 100644 --- a/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx +++ b/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx @@ -621,6 +621,8 @@ export function ProjectWorkspacePage() { isDemoMode ? `/projects/${projectId}/services/${serviceId}?demo=1` : `/projects/${projectId}/services/${serviceId}`; + const serviceSectionHref = (serviceId: string, section: string) => + `${serviceHref(serviceId)}${isDemoMode ? '&' : '?'}section=${section}`; if (!isDemoMode && projectQuery.isLoading) { return ( @@ -645,7 +647,7 @@ export function ProjectWorkspacePage() { This project may have been deleted or you don't have access.

+
+

{project.name}_

+ + {services.length} service{services.length !== 1 ? 's' : ''} · {runningServices} running + + + {/* View tabs */} + -
- {/* Stats */} -
-
-

{services.length}

-

Services

-
-
-

{runningServices}

-

Running

-
-
- 0} /> -
-
- - {formatRelative(project.updatedAt)} -
-
- - {/* Search / Command Palette */} - - - {/* Add Service Button */} - {!isDemoMode && ( - - )} -
+
+
+ 0} />
+ + + {formatRelative(project.updatedAt)} + + + {!isDemoMode && ( + + )}
- {/* Demo Mode Banner */} - {isDemoMode && ( -
-
-
- - Demo mode active — using sample data for preview + {/* Content */} + {canvasView ? ( +
+ navigate(serviceHref(serviceId))} + onOpenSection={(serviceId, section) => navigate(serviceSectionHref(serviceId, section))} + readOnly={isDemoMode} + /> + {isDemoMode && ( +
+ + + Demo data + +
+ )} +
+ ) : ( +
+ {isDemoMode && ( +
+
+
+ + Demo mode active - using sample data for preview +
-
- )} - - {/* Main Content */} -
-
- {/* Sidebar Navigation */} - - - {/* Content Area */} + )} +
- {activeView === 'canvas' && ( - navigate(serviceHref(serviceId))} - /> - )} - {activeView === 'observability' && (
{/* Health Summary */} @@ -966,6 +953,7 @@ export function ProjectWorkspacePage() {
+ )} {/* Create Service Dialog */} {!isDemoMode && ( diff --git a/app/frontend/src/features/workspace/storage.ts b/app/frontend/src/features/workspace/storage.ts index 1a8c74c..e221521 100644 --- a/app/frontend/src/features/workspace/storage.ts +++ b/app/frontend/src/features/workspace/storage.ts @@ -171,9 +171,13 @@ export function saveCanvasMetadata(projectId: string, metadata: ProjectCanvasMet canvasStorage()?.setItem(canvasStorageKey(projectId), JSON.stringify(metadata)); } -export function loadCanvasMetadata(projectId: string, services: ServiceEntity[]): ProjectCanvasMetadata { +export function loadCanvasMetadata( + projectId: string, + services: ServiceEntity[], + layout?: Map, +): ProjectCanvasMetadata { const key = canvasStorageKey(projectId); - const fallback = createDefaultCanvasMetadata(services); + const fallback = createDefaultCanvasMetadata(services, layout); const serviceIds = new Set(services.map((service) => service.id)); const raw = canvasStorage()?.getItem(key); @@ -195,7 +199,14 @@ export function loadCanvasMetadata(projectId: string, services: ServiceEntity[]) const nodeMap = new Map(parsed.nodes.map((node) => [node.serviceId, node])); const normalizedNodes: CanvasNodeLayout[] = []; - for (const [index, service] of services.entries()) { + // Services added after the layout was saved park in a trailing column to + // the right of everything placed so far, instead of overlapping nodes the + // user already arranged. + const placedXs = parsed.nodes.map((node) => node.position.x); + let parkedRow = 0; + const parkedX = placedXs.length > 0 ? Math.max(...placedXs) + 320 : 70; + + for (const service of services) { const existing = nodeMap.get(service.id); if (existing) { normalizedNodes.push({ @@ -206,15 +217,14 @@ export function loadCanvasMetadata(projectId: string, services: ServiceEntity[]) continue; } - const col = index % 3; - const row = Math.floor(index / 3); + const parked = layout?.get(service.id); normalizedNodes.push({ serviceId: service.id, - position: { - x: 70 + col * 260, - y: 80 + row * 170, - }, + position: parked ?? { x: parkedX, y: 80 + parkedRow * 160 }, }); + if (!parked) { + parkedRow += 1; + } } const seenEdgeIds = new Set(); diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts index 5e25daa..e2af875 100644 --- a/app/frontend/src/generated/api-types.ts +++ b/app/frontend/src/generated/api-types.ts @@ -7806,6 +7806,8 @@ export interface components { is_official?: boolean; /** @description Owning user ID for user-created templates; null for official presets */ owner_id?: string | null; + /** @description Whether a user-created template is published to the public catalog */ + is_public?: boolean; /** * Format: date-time * @description Creation timestamp @@ -7830,6 +7832,8 @@ export interface components { config: Record; /** @description Configurable variables exposed at deploy time */ variables?: components["schemas"]["TemplateVariable"][]; + /** @description Publish this template to the public catalog (visible anonymously) */ + is_public?: boolean; }; TemplateConfig: { /** @description Service type for created service */ diff --git a/app/frontend/src/index.css b/app/frontend/src/index.css index e10257d..e0db988 100644 --- a/app/frontend/src/index.css +++ b/app/frontend/src/index.css @@ -634,13 +634,13 @@ textarea { .edge-premium .react-flow__edge-path { stroke-linecap: round; stroke-linejoin: round; - filter: drop-shadow(0 0 4px rgba(156, 126, 240, 0.3)); + filter: drop-shadow(0 0 4px rgba(180, 227, 74, 0.25)); transition: filter 0.2s ease, stroke-width 0.2s ease; } .edge-premium:hover .react-flow__edge-path { - filter: drop-shadow(0 0 8px rgba(156, 126, 240, 0.5)); - stroke-width: 3px; + filter: drop-shadow(0 0 8px rgba(180, 227, 74, 0.45)); + stroke-width: 2.6px; } /* ============================================ @@ -1685,3 +1685,86 @@ textarea { margin: 24px 0; } .docs-article img { max-width: 100%; border-radius: var(--radius-md); } + +/* ============================================ + CANVAS — fullscreen workspace graph + ============================================ */ + +/* Floating glass pills (zoom controls, env filter, hints) */ +.canvas-pill { + display: inline-flex; + align-items: center; + background: rgba(19, 21, 24, 0.82); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid var(--border-default); + border-radius: 999px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); +} + +.canvas-pill-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 30px; + height: 30px; + padding: 0 6px; + border-radius: 999px; + color: var(--text-tertiary); + transition: color 0.15s ease, background 0.15s ease; +} + +.canvas-pill-btn:hover { + color: var(--text-primary); + background: var(--surface-muted); +} + +/* React Flow pane affordances */ +.react-flow__pane { + cursor: grab; +} +.react-flow__pane.dragging { + cursor: grabbing; +} +.react-flow__node.selectable:focus { + outline: none; +} + +/* Marquee selection tint */ +.react-flow__selection { + background: var(--accent-primary-soft); + border: 1px solid var(--border-strong); +} + +/* MiniMap — match surface card, dimmed nodes */ +.react-flow__minimap { + border-radius: var(--radius-md) !important; + border: 1px solid var(--border-default); + overflow: hidden; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); +} +.react-flow__minimap-svg { + display: block; +} + +/* Inspector panel entrance */ +.canvas-inspector { + animation: canvas-panel-in 0.22s cubic-bezier(0.22, 1, 0.36, 1) both; +} + +@keyframes canvas-panel-in { + from { + opacity: 0; + transform: translateX(14px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .canvas-inspector { + animation: none; + } +} diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index 7184980..5842d0c 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -124,6 +124,7 @@ export type TemplateEntity = { variablesRaw: string; isOfficial: boolean; ownerId: string | null; + isPublic: boolean; createdAt?: string; updatedAt?: string; }; @@ -618,6 +619,7 @@ function normalizeTemplate(template: RawServiceTemplate): TemplateEntity | null variablesRaw: template.variables ?? '', isOfficial: Boolean(template.is_official), ownerId: template.owner_id ?? null, + isPublic: Boolean(template.is_public), createdAt: template.created_at, updatedAt: template.updated_at, }; @@ -1361,6 +1363,7 @@ export type TemplateWriteInput = { logo?: string; config: Record; variables?: TemplateVariableEntity[]; + isPublic?: boolean; }; function templateWriteBody(input: TemplateWriteInput): string { @@ -1371,6 +1374,7 @@ function templateWriteBody(input: TemplateWriteInput): string { logo: input.logo ?? '', config: input.config, variables: input.variables ?? [], + is_public: input.isPublic ?? false, }); } diff --git a/app/frontend/src/lib/demo-data.ts b/app/frontend/src/lib/demo-data.ts index ffbeb90..e0f13cc 100644 --- a/app/frontend/src/lib/demo-data.ts +++ b/app/frontend/src/lib/demo-data.ts @@ -26,9 +26,9 @@ export const demoProjects: ProjectEntity[] = [ createdAt: '2026-03-12T09:30:00Z', updatedAt: '2026-03-31T09:10:00Z', stats: { - service_count: 4, + service_count: 5, deployment_count: 27, - running_services: 3, + running_services: 4, last_deployment: '2026-03-31T08:42:00Z', }, }, @@ -71,9 +71,11 @@ export const demoServicesByProject: Record = { type: 'web', status: 'running', environment: 'production', - image: 'ghcr.io/containr/web:2026.03.31', + image: 'ghcr.io/containr/web-react:v42', command: 'npm run serve', gitBranch: 'main', + port: 3000, + domain: 'web.containr.local', createdAt: '2026-03-12T10:00:00Z', updatedAt: '2026-03-31T08:42:00Z', }, @@ -84,9 +86,11 @@ export const demoServicesByProject: Record = { type: 'web', status: 'running', environment: 'production', - image: 'ghcr.io/containr/api:2026.03.31', + image: 'ghcr.io/containr/api-go:v2.4.1', command: './server', gitBranch: 'main', + port: 8080, + domain: 'api.containr.local', createdAt: '2026-03-12T10:02:00Z', updatedAt: '2026-03-31T08:43:00Z', }, @@ -97,9 +101,10 @@ export const demoServicesByProject: Record = { type: 'worker', status: 'building', environment: 'production', - image: 'ghcr.io/containr/worker:2026.03.31', + image: 'ghcr.io/containr/worker:edge', command: './worker', gitBranch: 'main', + port: 9100, createdAt: '2026-03-12T10:05:00Z', updatedAt: '2026-03-31T08:44:00Z', }, @@ -110,12 +115,27 @@ export const demoServicesByProject: Record = { type: 'database', status: 'running', environment: 'production', - image: 'postgres:15-alpine', + image: 'postgres:16', command: 'postgres', gitBranch: 'main', + port: 5432, createdAt: '2026-03-12T10:08:00Z', updatedAt: '2026-03-31T08:40:00Z', }, + { + id: 'demo-svc-redis', + projectId: 'demo-project-core', + name: 'Redis Cache', + type: 'database', + status: 'running', + environment: 'production', + image: 'dragonfly:1.25', + command: 'redis-server', + gitBranch: 'main', + port: 6379, + createdAt: '2026-03-12T10:12:00Z', + updatedAt: '2026-03-31T08:40:00Z', + }, ], 'demo-project-growth': [ { @@ -125,9 +145,11 @@ export const demoServicesByProject: Record = { type: 'web', status: 'running', environment: 'production', - image: 'ghcr.io/containr/marketing:latest', - command: 'npm run start', + image: 'nginx:1.27-alpine', + command: 'nginx -g daemon off;', gitBranch: 'main', + port: 8080, + domain: 'growth.containr.local', createdAt: '2026-03-03T09:00:00Z', updatedAt: '2026-03-30T12:10:00Z', }, @@ -138,9 +160,10 @@ export const demoServicesByProject: Record = { type: 'web', status: 'running', environment: 'production', - image: 'ghcr.io/containr/campaign-api:latest', + image: 'node:20-alpine', command: './api', gitBranch: 'main', + port: 3000, createdAt: '2026-03-03T09:04:00Z', updatedAt: '2026-03-30T12:08:00Z', }, @@ -151,9 +174,10 @@ export const demoServicesByProject: Record = { type: 'database', status: 'running', environment: 'production', - image: 'redis:7-alpine', + image: 'dragonfly:1.25', command: 'redis-server', gitBranch: 'main', + port: 6379, createdAt: '2026-03-03T09:07:00Z', updatedAt: '2026-03-30T12:07:00Z', }, @@ -166,9 +190,10 @@ export const demoServicesByProject: Record = { type: 'web', status: 'running', environment: 'production', - image: 'ghcr.io/containr/inference-api:latest', + image: 'python:3.12-slim', command: './serve', gitBranch: 'main', + port: 8000, createdAt: '2026-02-18T08:00:00Z', updatedAt: '2026-03-29T18:00:00Z', }, @@ -179,7 +204,7 @@ export const demoServicesByProject: Record = { type: 'worker', status: 'failed', environment: 'production', - image: 'ghcr.io/containr/evaluator:latest', + image: 'python:3.12-slim', command: './run-jobs', gitBranch: 'main', createdAt: '2026-02-18T08:03:00Z', @@ -211,6 +236,7 @@ export const demoVariablesByProject: Record + + + + + + + + + + + + + diff --git a/site/fonts/inter-latin-ext.woff2 b/site/fonts/inter-latin-ext.woff2 new file mode 100644 index 0000000..a40c469 Binary files /dev/null and b/site/fonts/inter-latin-ext.woff2 differ diff --git a/site/fonts/inter-latin.woff2 b/site/fonts/inter-latin.woff2 new file mode 100644 index 0000000..479d010 Binary files /dev/null and b/site/fonts/inter-latin.woff2 differ diff --git a/site/fonts/jbmono-latin-ext.woff2 b/site/fonts/jbmono-latin-ext.woff2 new file mode 100644 index 0000000..e38f553 Binary files /dev/null and b/site/fonts/jbmono-latin-ext.woff2 differ diff --git a/site/fonts/jbmono-latin.woff2 b/site/fonts/jbmono-latin.woff2 new file mode 100644 index 0000000..82f9668 Binary files /dev/null and b/site/fonts/jbmono-latin.woff2 differ diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..c103b4b --- /dev/null +++ b/site/index.html @@ -0,0 +1,379 @@ + + + + + +Containr - Self-hosted platform for containerized services + + + + + + + + + + + + + + + + +
+
+ + + Self-hosted PaaS · MIT license + +

Deploy containers on your own cloud

+

Containr turns a Docker host into a full deployment platform. Git builds, managed databases, previews, failover.

+ + +
+
containr › core-platform › canvas
+
+
+ + + Projects + + Core Platform_ + 5 services · 4 running + + CanvasObservabilityLogsSettings + + ● ACTIVE +
+
+ + + +
production
+ +
+
+ +

Web Frontend

web

+
+
web.containr.local
+
runningghcr.io/web:v42
+
+ +
+
+ +

Queue Worker

worker

+
+
queue-worker
+
deployingworker:edge
+
+ +
+
+ +

API Gateway

web · 2 replicas

+
+
api.containr.local
+
runningapi:v2.4.1
+
+ +
+
+ +

Postgres

database

+
+
postgres:5432
+
runningpostgres:16
+
+ +
+
+ +

Redis Cache

database

+
+
redis:6379
+
runningdragonfly:1
+
+ +
+ −100%+ + + +
+
+
+
+
+
+
+ +
+
+
+
+

One command, running stack

+

Requires Docker and openssl. Installs into ./containr, never overwrites your .env.

+
+
+ curl -fsSL https://raw.githubusercontent.com/Dvorinka/Containr/main/install.sh | bash + +
+
+
+
+ +
+
+
+

A control plane, not a pile of YAML

+

Everything a deployment platform needs, running on hardware you already own. One install, one dashboard.

+
+
+
+
+
+ +
+

Visual project canvas

+

Services appear as nodes on a Railway-style canvas. Drag, group, and wire them together; connections and shared variables are inferred automatically. Start, stop, restart, or roll back with a right-click.

+
+
Service view with metrics, logs, and configuration tabs
+
+
+
+ +
+

Managed databases

+

Provision engines from the dashboard, with backups and restore built in.

+
+ PostgreSQLMySQLMariaDBMongoDBRedisDragonflyClickHouse +
+
+
+
+ +
+

Private networking

+

Every project gets an isolated Docker network. Services reach each other by name.

+
web:3000 → ${{service.KEY}}
+
+
+
+ +
+

Git-push deploys

+

Connect GitHub, GitLab, Bitbucket, or Gitea. Builds, history, and image rollback included.

+
+
+
+ +
+

HA and autoscaling

+

Failover policies, health checks, node agents, and metric-driven replica scaling.

+
+
+
+ +
+

Admin and audit

+

Approval console, user management, audit logs, and vulnerability scans behind sign-in.

+
+
+
+
+ +
+
+
+

The whole fleet on one screen

+

Logs, metrics, builds, and rollbacks without leaving the dashboard. Host telemetry included.

+
+ +
+
+ +
+
+
+

A catalog worth browsing

+

Thirty official templates plus community presets. Browse the full catalog or publish your own.

+
+
+
+ n8n + Nextcloud + Immich + Vaultwarden + Grafana + Supabase + Gitea + Uptime Kuma + Pi-hole + Jellyfin + Meilisearch + Mastodon + Home Assistant + Plausible + MinIO + Appwrite +
+
+
+
+ +
+
+
+

Open for visitors, locked for owners

+

The platform doubles as a public showcase. Approved projects are browsable anonymously; secrets never are.

+
+
+
+

Anonymous

+
    +
  • Browse approved projects
  • +
  • Template catalog and docs
  • +
  • HA status overview
  • +
+
+
+

Signed in

+
    +
  • Projects, services, databases
  • +
  • Logs, variables, builds, deploys
  • +
  • Custom templates
  • +
+
+
+

Admin

+
    +
  • Project approval and moderation
  • +
  • User flags and agent tokens
  • +
  • Platform settings
  • +
+
+
+
+
+ +
+
+

Run your own cloud.

+

One Docker host is all it takes. Free, open source, and yours to keep.

+ +
+
+ + + + + + diff --git a/site/screenshots/canvas.png b/site/screenshots/canvas.png new file mode 100644 index 0000000..578ce1e Binary files /dev/null and b/site/screenshots/canvas.png differ diff --git a/site/screenshots/dashboard.png b/site/screenshots/dashboard.png new file mode 100644 index 0000000..da4e0b2 Binary files /dev/null and b/site/screenshots/dashboard.png differ diff --git a/site/screenshots/databases.png b/site/screenshots/databases.png new file mode 100644 index 0000000..8365dd9 Binary files /dev/null and b/site/screenshots/databases.png differ diff --git a/site/screenshots/service-detail.png b/site/screenshots/service-detail.png new file mode 100644 index 0000000..b4a3aa2 Binary files /dev/null and b/site/screenshots/service-detail.png differ diff --git a/site/style.css b/site/style.css new file mode 100644 index 0000000..d406723 --- /dev/null +++ b/site/style.css @@ -0,0 +1,624 @@ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: url('fonts/inter-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; + } + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: url('fonts/inter-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; + } + @font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400 800; + font-display: swap; + src: url('fonts/jbmono-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; + } + @font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400 800; + font-display: swap; + src: url('fonts/jbmono-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; + } + + :root { + --bg-void: #0c0d0f; + --bg-base: #0e0f12; + --surface-card: #131518; + --surface-card-hover: #1a1d22; + --surface-muted: #1a1d22; + --border-subtle: rgba(255, 255, 255, 0.06); + --border-default: rgba(255, 255, 255, 0.1); + --border-strong: rgba(255, 255, 255, 0.18); + --text-primary: #ecedf0; + --text-secondary: #8b919e; + --text-tertiary: #575d68; + --accent: #b4e34a; + --accent-dim: rgba(180, 227, 74, 0.12); + --accent-on: #131507; + --mono: 'JetBrains Mono', 'SF Mono', Consolas, monospace; + --sans: 'Inter', system-ui, -apple-system, sans-serif; + --radius: 14px; + --radius-sm: 9px; + } + + * { margin: 0; padding: 0; box-sizing: border-box; } + html { scroll-behavior: smooth; } + body { + font-family: var(--sans); + background: var(--bg-void); + color: var(--text-secondary); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + line-height: 1.5; + } + body::before { + content: ''; + position: fixed; + inset: 0; + background: radial-gradient(1000px 480px at 50% -8%, rgba(180, 227, 74, 0.05), transparent 60%); + pointer-events: none; + } + ::selection { background: rgba(180, 227, 74, 0.25); color: var(--text-primary); } + + a { color: inherit; text-decoration: none; } + img { display: block; max-width: 100%; } + .wrap { max-width: 1120px; margin: 0 auto; padding: 0 24px; } + + /* ---------- nav ---------- */ + nav { + position: sticky; + top: 0; + z-index: 50; + border-bottom: 1px solid var(--border-subtle); + background: rgba(14, 15, 18, 0.82); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + } + .nav-inner { + display: flex; + align-items: center; + height: 60px; + gap: 28px; + } + .brand { + display: flex; + align-items: center; + gap: 9px; + font-weight: 800; + font-size: 15px; + letter-spacing: -0.02em; + color: var(--text-primary); + } + .brand img { width: 26px; height: 26px; border-radius: 7px; } + .brand em { font-style: normal; color: var(--accent); } + .nav-links { display: flex; gap: 4px; margin-left: 8px; } + .nav-links a { + font-size: 13px; + padding: 6px 12px; + border-radius: var(--radius-sm); + color: var(--text-secondary); + transition: color 0.15s, background 0.15s; + } + .nav-links a:hover { color: var(--text-primary); background: var(--surface-muted); } + .nav-right { margin-left: auto; display: flex; align-items: center; gap: 10px; } + .gh-link { + display: flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-subtle); + color: var(--text-secondary); + transition: color 0.15s, border-color 0.15s; + } + .gh-link:hover { color: var(--text-primary); border-color: var(--border-strong); } + .btn { + display: inline-flex; + align-items: center; + gap: 7px; + font-size: 13px; + font-weight: 600; + padding: 8px 16px; + border-radius: var(--radius-sm); + border: 1px solid transparent; + cursor: pointer; + transition: transform 0.15s, border-color 0.15s, background 0.15s; + white-space: nowrap; + } + .btn-primary { background: var(--accent); color: var(--accent-on); } + .btn-primary:hover { transform: translateY(-1px); } + .btn-ghost { border-color: var(--border-default); color: var(--text-secondary); } + .btn-ghost:hover { color: var(--text-primary); border-color: var(--border-strong); } + + /* ---------- hero ---------- */ + .hero { padding: 84px 0 0; } + .pill { + display: inline-flex; + align-items: center; + gap: 8px; + font-family: var(--mono); + font-size: 11px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--text-tertiary); + border: 1px solid var(--border-subtle); + background: var(--surface-card); + padding: 6px 14px; + border-radius: 999px; + } + .pill svg { color: var(--accent); } + .hero h1 { + margin-top: 22px; + font-size: clamp(34px, 5.4vw, 58px); + font-weight: 800; + letter-spacing: -0.035em; + line-height: 1.06; + color: var(--text-primary); + max-width: 22ch; + } + .hero h1 em { font-style: normal; color: var(--accent); } + .hero .sub { + margin-top: 18px; + font-size: 15.5px; + line-height: 1.65; + max-width: 46ch; + } + .hero-cta { display: flex; gap: 12px; margin-top: 30px; flex-wrap: wrap; } + .hero-cta .btn { font-size: 14px; padding: 11px 22px; } + + .shot-frame { + margin-top: 56px; + border: 1px solid var(--border-default); + border-radius: var(--radius); + background: var(--surface-card); + overflow: hidden; + box-shadow: 0 30px 80px -30px rgba(0, 0, 0, 0.7); + } + .shot-bar { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 14px; + border-bottom: 1px solid var(--border-subtle); + font-family: var(--mono); + font-size: 11px; + color: var(--text-tertiary); + } + .shot-bar .addr { + flex: 1; + text-align: center; + background: var(--bg-void); + border: 1px solid var(--border-subtle); + border-radius: 6px; + padding: 3px 0; + max-width: 320px; + margin: 0 auto; + } + .shot-frame img { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; object-position: top; } + + /* ---------- install ---------- */ + .install { padding: 72px 0 0; } + .install-inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 28px; + border: 1px solid var(--border-default); + border-radius: var(--radius); + background: var(--bg-base); + padding: 26px 30px; + flex-wrap: wrap; + } + .install-copy h2 { font-size: 17px; font-weight: 700; color: var(--text-primary); letter-spacing: -0.01em; } + .install-copy p { font-size: 13px; margin-top: 4px; } + .cmd { + display: flex; + align-items: center; + gap: 0; + font-family: var(--mono); + font-size: 12.5px; + background: var(--bg-void); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + overflow: hidden; + max-width: 100%; + } + .cmd code { + padding: 11px 14px; + color: var(--text-primary); + white-space: nowrap; + overflow-x: auto; + scrollbar-width: none; + } + .cmd code::-webkit-scrollbar { display: none; } + .cmd code::before { content: '$ '; color: var(--accent); } + .copy-btn { + border: none; + border-left: 1px solid var(--border-subtle); + background: transparent; + color: var(--text-tertiary); + padding: 11px 13px; + cursor: pointer; + display: flex; + align-items: center; + transition: color 0.15s, background 0.15s; + flex-shrink: 0; + } + .copy-btn:hover { color: var(--accent); background: var(--surface-muted); } + + /* ---------- sections ---------- */ + section { padding: 96px 0 0; } + .sec-head { margin-bottom: 40px; } + .sec-head h2 { + font-size: clamp(24px, 3vw, 32px); + font-weight: 800; + letter-spacing: -0.025em; + color: var(--text-primary); + max-width: 22ch; + } + .sec-head p { margin-top: 10px; font-size: 14.5px; max-width: 56ch; } + + /* ---------- bento ---------- */ + .bento { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 14px; + } + .cell { + grid-column: span 2; + border: 1px solid var(--border-subtle); + border-radius: var(--radius); + background: var(--surface-card); + padding: 22px; + transition: border-color 0.15s, transform 0.15s; + position: relative; + overflow: hidden; + } + .cell:hover { border-color: var(--border-default); transform: translateY(-2px); } + .cell.wide { grid-column: span 4; display: flex; gap: 26px; align-items: stretch; } + .cell h3 { font-size: 14.5px; font-weight: 700; color: var(--text-primary); letter-spacing: -0.01em; } + .cell p { font-size: 12.5px; line-height: 1.6; margin-top: 8px; color: var(--text-tertiary); } + .cell .icon { + width: 32px; + height: 32px; + border-radius: 8px; + background: var(--accent-dim); + color: var(--accent); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 14px; + } + .cell.wide .icon { margin-bottom: 12px; } + .cell-media { + flex: 0 0 46%; + border-radius: 10px; + overflow: hidden; + border: 1px solid var(--border-subtle); + align-self: center; + } + .cell-media img { width: 100%; height: 100%; object-fit: cover; object-position: left top; } + .cell.tinted { background: linear-gradient(160deg, rgba(180, 227, 74, 0.09), rgba(180, 227, 74, 0.02) 55%), var(--surface-card); } + .chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 14px; } + .chips span { + font-family: var(--mono); + font-size: 10.5px; + color: var(--text-secondary); + background: var(--bg-void); + border: 1px solid var(--border-subtle); + border-radius: 6px; + padding: 3px 8px; + } + .snippet { + margin-top: 14px; + font-family: var(--mono); + font-size: 11.5px; + background: var(--bg-void); + border: 1px solid var(--border-subtle); + border-radius: 8px; + padding: 10px 12px; + color: var(--text-secondary); + overflow-x: auto; + white-space: nowrap; + scrollbar-width: none; + } + .snippet b { color: var(--accent); font-weight: 500; } + + /* ---------- screenshots ---------- */ + .shot-tabs { display: flex; gap: 6px; margin-bottom: 18px; flex-wrap: wrap; } + .shot-tab { + font-family: var(--mono); + font-size: 11.5px; + color: var(--text-tertiary); + background: transparent; + border: 1px solid var(--border-subtle); + border-radius: 999px; + padding: 7px 15px; + cursor: pointer; + transition: all 0.15s; + } + .shot-tab:hover { color: var(--text-secondary); border-color: var(--border-default); } + .shot-tab.on { color: var(--accent); border-color: rgba(180, 227, 74, 0.4); background: var(--accent-dim); } + .gallery img { width: 100%; border-radius: var(--radius); border: 1px solid var(--border-default); } + .gallery .cap { font-size: 12.5px; color: var(--text-tertiary); margin-top: 12px; } + + /* ---------- templates marquee ---------- */ + .marquee { + overflow: hidden; + border: 1px solid var(--border-subtle); + border-radius: var(--radius); + background: var(--bg-base); + padding: 18px 0; + mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent); + -webkit-mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent); + } + .marquee-track { + display: flex; + gap: 10px; + width: max-content; + animation: scroll 46s linear infinite; + } + @keyframes scroll { to { transform: translateX(-50%); } } + .tpl { + display: flex; + align-items: center; + gap: 8px; + font-size: 12.5px; + font-weight: 600; + color: var(--text-secondary); + border: 1px solid var(--border-subtle); + background: var(--surface-card); + border-radius: 999px; + padding: 8px 16px; + white-space: nowrap; + } + .tpl img { width: 15px; height: 15px; } + + /* ---------- access ---------- */ + .access { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0; + border-top: 1px solid var(--border-subtle); + } + .access > div { padding: 28px 28px 4px 0; } + .access > div + div { padding-left: 28px; border-left: 1px solid var(--border-subtle); } + .access h3 { + font-family: var(--mono); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--accent); + } + .access ul { list-style: none; margin-top: 14px; } + .access li { font-size: 13px; padding: 5px 0; color: var(--text-secondary); } + .access li::before { content: '- '; color: var(--text-tertiary); } + + /* ---------- cta ---------- */ + .cta { text-align: center; padding: 110px 0; } + .cta h2 { + font-size: clamp(30px, 4.5vw, 46px); + font-weight: 800; + letter-spacing: -0.03em; + color: var(--text-primary); + } + .cta p { margin: 14px auto 0; font-size: 14.5px; max-width: 46ch; } + .cta .hero-cta { justify-content: center; } + + /* ---------- footer ---------- */ + footer { border-top: 1px solid var(--border-subtle); } + .foot { + display: flex; + align-items: center; + justify-content: space-between; + padding: 26px 0; + font-size: 12.5px; + color: var(--text-tertiary); + flex-wrap: wrap; + gap: 14px; + } + .foot .l { display: flex; align-items: center; gap: 8px; } + .foot img { width: 18px; height: 18px; border-radius: 5px; } + .foot nav { display: flex; gap: 20px; position: static; border: none; background: none; backdrop-filter: none; -webkit-backdrop-filter: none; } + .foot nav a:hover { color: var(--text-primary); } + + /* ---------- reveal (JS-gated: content stays visible without JS) ---------- */ + .js .reveal { opacity: 0; transform: translateY(16px); transition: opacity 0.55s ease, transform 0.55s ease; } + .js .reveal.in { opacity: 1; transform: none; } + + @media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + .marquee-track { animation: none; } + .reveal { opacity: 1; transform: none; transition: none; } + .btn-primary:hover, .cell:hover { transform: none; } + } + + /* ---------- responsive ---------- */ + @media (max-width: 860px) { + .nav-links { display: none; } + .bento { grid-template-columns: repeat(2, 1fr); } + .cell, .cell.wide { grid-column: span 2; } + .cell.wide { flex-direction: column; } + .cell-media { flex: none; } + .access { grid-template-columns: 1fr; } + .access > div { padding: 22px 0; } + .access > div + div { padding-left: 0; border-left: none; border-top: 1px solid var(--border-subtle); } + section { padding: 72px 0 0; } + .hero { padding-top: 56px; } + } + @media (max-width: 520px) { + .bento { grid-template-columns: 1fr; } + .cell, .cell.wide { grid-column: span 1; } + .install-inner { padding: 20px; } + } + + /* ---------- hero canvas mockup ---------- */ + .cv { background: #0a0b0d; } + .cv-top { + display: flex; + align-items: center; + gap: 14px; + padding: 9px 16px; + border-bottom: 1px solid var(--border-subtle); + background: #101216; + } + .cv-back { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: var(--text-tertiary); } + .cv-top b { font-size: 13px; font-weight: 700; color: var(--text-primary); letter-spacing: -0.01em; } + .cv-meta { font-family: var(--mono); font-size: 10.5px; color: var(--text-tertiary); } + .cv-tabs { + margin-left: auto; + display: inline-flex; + gap: 2px; + background: var(--bg-void); + border: 1px solid var(--border-subtle); + border-radius: 999px; + padding: 3px; + } + .cv-tabs i { font-style: normal; font-size: 10.5px; color: var(--text-tertiary); padding: 4px 11px; border-radius: 999px; } + .cv-tabs i.on { color: var(--text-primary); background: var(--surface-muted); } + .cv-active { font-family: var(--mono); font-size: 9.5px; letter-spacing: 0.08em; color: #5ee6a0; } + .cv-cursor { font-style: normal; color: var(--accent); animation: cv-blink 1.1s steps(2, start) infinite; } + @keyframes cv-blink { to { visibility: hidden; } } + + .cv-stage { + position: relative; + height: 380px; + background-image: radial-gradient(circle, rgba(255, 255, 255, 0.055) 1px, transparent 1px); + background-size: 22px 22px; + overflow: hidden; + } + .cv-edges, .cv-edges-m { position: absolute; inset: 0; width: 100%; height: 100%; } + .cv-edges-m { display: none; } + .cv-edges path, .cv-edges-m path { + fill: none; + stroke: rgba(180, 227, 74, 0.42); + stroke-width: 2; + stroke-linecap: round; + filter: drop-shadow(0 0 4px rgba(180, 227, 74, 0.25)); + } + .cv-edges .live, .cv-edges-m .live { stroke-dasharray: 7 9; animation: cv-dash 1.1s linear infinite; } + @keyframes cv-dash { to { stroke-dashoffset: -16; } } + + .cv-node { + position: absolute; + width: 200px; + background: var(--surface-card); + border: 1px solid var(--border-subtle); + border-radius: 14px; + padding: 12px 12px 10px; + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.42); + transition: border-color 0.2s; + } + .cv-node:hover { border-color: var(--border-default); } + .cv-node.sel { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent), 0 16px 44px rgba(0, 0, 0, 0.55); + } + .cv-n1 { left: 3.2%; top: 12%; } + .cv-n2 { left: 3.2%; top: 50%; } + .cv-n3 { left: 35.2%; top: 33%; } + .cv-n4 { left: 70%; top: 7%; } + .cv-n5 { left: 70%; top: 44%; } + .cv-row { display: flex; align-items: center; gap: 10px; } + .cv-ic { + width: 34px; height: 34px; + border-radius: 9px; + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; + font-weight: 800; font-size: 14px; + } + .cv-ic img { width: 18px; height: 18px; } + .cv-node h4 { font-size: 12.5px; font-weight: 700; color: var(--text-primary); letter-spacing: -0.01em; } + .cv-row p { font-size: 10px; color: var(--text-tertiary); margin-top: 2px; } + .cv-addr { + display: flex; align-items: center; gap: 6px; + margin-top: 10px; + padding: 5px 8px; + background: rgba(26, 29, 34, 0.55); + border: 1px solid var(--border-subtle); + border-radius: 7px; + color: var(--text-tertiary); + } + .cv-addr span { + font-family: var(--mono); font-size: 9.5px; color: var(--text-secondary); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .cv-foot { display: flex; align-items: center; gap: 6px; margin-top: 10px; } + .cv-st { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; } + .cv-st.run { background: #5ee6a0; } + .cv-st.dep { background: #f2c94c; animation: cv-pulse 1.4s ease-in-out infinite; } + @keyframes cv-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } + .cv-foot em { font-style: normal; font-size: 9px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #5ee6a0; } + .cv-foot code { + margin-left: auto; font-family: var(--mono); font-size: 9px; color: var(--text-tertiary); + max-width: 92px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + + .cv-pill { + position: absolute; + display: inline-flex; align-items: center; gap: 7px; + background: rgba(19, 21, 24, 0.85); + backdrop-filter: blur(12px); + border: 1px solid var(--border-default); + border-radius: 999px; + padding: 6px 12px; + font-family: var(--mono); font-size: 10px; color: var(--text-secondary); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.35); + } + .cv-pill.env { left: 14px; top: 14px; } + .cv-pill.env i { width: 6px; height: 6px; border-radius: 50%; background: #5ee6a0; } + .cv-pill.env svg { color: var(--text-tertiary); } + .cv-pill.zoom { left: 14px; bottom: 14px; padding: 5px 11px; gap: 9px; } + .cv-pill.zoom b { font-weight: 500; font-size: 10px; } + .cv-pill.zoom i { width: 1px; height: 12px; background: var(--border-subtle); } + .cv-pill.zoom svg { color: var(--text-tertiary); } + .cv-chip { + position: absolute; top: 14px; left: 50%; transform: translateX(-50%); + font-family: var(--mono); font-size: 9.5px; letter-spacing: 0.1em; text-transform: uppercase; + color: var(--accent); background: var(--accent-dim); + border: 1px solid rgba(180, 227, 74, 0.3); + border-radius: 999px; padding: 4px 10px; + } + .cv-minimap { + position: absolute; right: 14px; bottom: 14px; + width: 112px; height: 68px; + border: 1px solid var(--border-default); border-radius: 8px; + background: #0e1013; overflow: hidden; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.35); + } + .cv-minimap i { position: absolute; width: 15px; height: 10px; border-radius: 2px; background: rgba(180, 227, 74, 0.45); } + + @media (max-width: 720px) { + .cv-stage { height: 360px; } + .cv-edges { display: none; } + .cv-edges-m { display: block; } + .cv-node { width: 150px; padding: 9px 9px 8px; border-radius: 11px; } + .cv-node h4 { font-size: 11px; } + .cv-ic { width: 27px; height: 27px; border-radius: 7px; } + .cv-ic img { width: 14px; height: 14px; } + .cv-addr span { font-size: 8.5px; } + .cv-foot code { display: none; } + .cv-n1 { left: 4%; top: 14%; } + .cv-n2 { left: 4%; top: 62%; } + .cv-n3 { left: 52%; top: 36%; } + .cv-n4 { left: 52%; top: 2%; } + .cv-n5 { left: 52%; top: 68%; } + .cv-back, .cv-meta, .cv-minimap, .cv-pill.zoom { display: none; } + .cv-tabs i:not(.on) { display: none; } + } + @media (prefers-reduced-motion: reduce) { + .cv-edges .live, .cv-edges-m .live, .cv-st.dep, .cv-cursor { animation: none; } + } diff --git a/site/templates.html b/site/templates.html new file mode 100644 index 0000000..0e71af3 --- /dev/null +++ b/site/templates.html @@ -0,0 +1,608 @@ + + + + + +Template catalog - Containr + + + + + + + + + + + +
+
+

Template catalog

+

Deploy-ready service presets plus templates published by the community. Pick one, import it into your instance, deploy.

+ loading catalog + +
+ +
+
+
+
+ +
+
+
+
+
+ + + + + + + + diff --git a/site/templates.json b/site/templates.json new file mode 100644 index 0000000..b963324 --- /dev/null +++ b/site/templates.json @@ -0,0 +1,394 @@ +{ + "templates": [ + { + "id": "tpl-nodejs", + "name": "Node.js Application", + "description": "Generic Node.js application with automatic dependency detection", + "category": "web", + "logo": "https://cdn.simpleicons.org/node.js", + "config": "{\"type\":\"web\",\"runtime\":\"node\",\"build_command\":\"npm install && npm run build\",\"start_command\":\"npm start\",\"port\":3000,\"health_check\":\"/health\"}", + "variables": "[{\"key\":\"NODE_ENV\",\"label\":\"Node Environment\",\"default\":\"production\",\"required\":false,\"secret\":false},{\"key\":\"NPM_TOKEN\",\"label\":\"NPM Token\",\"default\":\"\",\"required\":false,\"secret\":true}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-react", + "name": "React Application", + "description": "React single-page application with Vite", + "category": "frontend", + "logo": "https://cdn.simpleicons.org/react", + "config": "{\"type\":\"web\",\"runtime\":\"node\",\"build_command\":\"npm install && npm run build\",\"start_command\":\"npx serve -s dist\",\"port\":3000}", + "variables": "[{\"key\":\"VITE_API_URL\",\"label\":\"API URL\",\"default\":\"\",\"required\":true,\"secret\":false}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-python", + "name": "Python Application", + "description": "Python application with FastAPI/Flask support", + "category": "web", + "logo": "https://cdn.simpleicons.org/python", + "config": "{\"type\":\"web\",\"runtime\":\"python\",\"build_command\":\"pip install -r requirements.txt\",\"start_command\":\"python main.py\",\"port\":8000}", + "variables": "[{\"key\":\"PYTHON_VERSION\",\"label\":\"Python Version\",\"default\":\"3.11\",\"required\":false,\"secret\":false}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-go", + "name": "Go Application", + "description": "Go backend service", + "category": "web", + "logo": "https://cdn.simpleicons.org/go", + "config": "{\"type\":\"web\",\"runtime\":\"go\",\"build_command\":\"go build -o app .\",\"start_command\":\"./app\",\"port\":8080}", + "variables": "[{\"key\":\"GO_VERSION\",\"label\":\"Go Version\",\"default\":\"1.21\",\"required\":false,\"secret\":false}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-postgres", + "name": "PostgreSQL Database", + "description": "Managed PostgreSQL database", + "category": "database", + "logo": "https://cdn.simpleicons.org/postgresql", + "config": "{\"type\":\"database\",\"runtime\":\"postgres\",\"port\":5432}", + "variables": "[{\"key\":\"POSTGRES_USER\",\"label\":\"Username\",\"default\":\"postgres\",\"required\":true,\"secret\":false},{\"key\":\"POSTGRES_PASSWORD\",\"label\":\"Password\",\"default\":\"\",\"required\":true,\"secret\":true},{\"key\":\"POSTGRES_DB\",\"label\":\"Database Name\",\"default\":\"app\",\"required\":true,\"secret\":false}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-redis", + "name": "Redis Cache", + "description": "In-memory data store", + "category": "database", + "logo": "https://cdn.simpleicons.org/redis", + "config": "{\"type\":\"database\",\"runtime\":\"redis\",\"port\":6379}", + "variables": "[{\"key\":\"REDIS_PASSWORD\",\"label\":\"Password\",\"default\":\"\",\"required\":false,\"secret\":true}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-mongodb", + "name": "MongoDB Database", + "description": "NoSQL document database", + "category": "database", + "logo": "https://cdn.simpleicons.org/mongodb", + "config": "{\"type\":\"database\",\"runtime\":\"mongodb\",\"port\":27017}", + "variables": "[{\"key\":\"MONGO_INITDB_ROOT_USERNAME\",\"label\":\"Root Username\",\"default\":\"admin\",\"required\":true,\"secret\":false},{\"key\":\"MONGO_INITDB_ROOT_PASSWORD\",\"label\":\"Root Password\",\"default\":\"\",\"required\":true,\"secret\":true}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-mysql", + "name": "MySQL Database", + "description": "Managed MySQL database service", + "category": "database", + "logo": "https://cdn.simpleicons.org/mysql", + "config": "{\"type\":\"database\",\"runtime\":\"mysql\",\"port\":3306}", + "variables": "[{\"key\":\"MYSQL_DATABASE\",\"label\":\"Database Name\",\"default\":\"app\",\"required\":true,\"secret\":false},{\"key\":\"MYSQL_USER\",\"label\":\"Username\",\"default\":\"app\",\"required\":true,\"secret\":false},{\"key\":\"MYSQL_PASSWORD\",\"label\":\"User Password\",\"default\":\"\",\"required\":true,\"secret\":true},{\"key\":\"MYSQL_ROOT_PASSWORD\",\"label\":\"Root Password\",\"default\":\"\",\"required\":true,\"secret\":true}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-mariadb", + "name": "MariaDB Database", + "description": "Managed MariaDB database service", + "category": "database", + "logo": "https://cdn.simpleicons.org/mariadb", + "config": "{\"type\":\"database\",\"runtime\":\"mariadb\",\"port\":3306}", + "variables": "[{\"key\":\"MARIADB_DATABASE\",\"label\":\"Database Name\",\"default\":\"app\",\"required\":true,\"secret\":false},{\"key\":\"MARIADB_USER\",\"label\":\"Username\",\"default\":\"app\",\"required\":true,\"secret\":false},{\"key\":\"MARIADB_PASSWORD\",\"label\":\"User Password\",\"default\":\"\",\"required\":true,\"secret\":true},{\"key\":\"MARIADB_ROOT_PASSWORD\",\"label\":\"Root Password\",\"default\":\"\",\"required\":true,\"secret\":true}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-clickhouse", + "name": "ClickHouse Database", + "description": "Column-oriented analytics database", + "category": "database", + "logo": "https://cdn.simpleicons.org/clickhouse", + "config": "{\"type\":\"database\",\"runtime\":\"clickhouse\",\"port\":8123}", + "variables": "[{\"key\":\"CLICKHOUSE_DB\",\"label\":\"Database Name\",\"default\":\"app\",\"required\":false,\"secret\":false},{\"key\":\"CLICKHOUSE_USER\",\"label\":\"Username\",\"default\":\"default\",\"required\":false,\"secret\":false},{\"key\":\"CLICKHOUSE_PASSWORD\",\"label\":\"Password\",\"default\":\"\",\"required\":false,\"secret\":true}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-dragonfly", + "name": "Dragonfly Database", + "description": "Redis-compatible in-memory data store powered by Dragonfly", + "category": "database", + "logo": "https://cdn.simpleicons.org/redis", + "config": "{\"type\":\"database\",\"runtime\":\"dragonfly\",\"port\":6379}", + "variables": "[{\"key\":\"DRAGONFLY_PASSWORD\",\"label\":\"Password\",\"default\":\"\",\"required\":false,\"secret\":true}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-worker", + "name": "Background Worker", + "description": "Background job processing service", + "category": "worker", + "logo": "", + "config": "{\"type\":\"worker\",\"runtime\":\"node\",\"build_command\":\"npm install\",\"start_command\":\"npm run worker\"}", + "variables": "[{\"key\":\"WORKER_CONCURRENCY\",\"label\":\"Concurrency\",\"default\":\"4\",\"required\":false,\"secret\":false}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-cron", + "name": "Cron Job", + "description": "Scheduled task runner", + "category": "cron", + "logo": "", + "config": "{\"type\":\"cron\",\"runtime\":\"node\",\"build_command\":\"npm install\",\"start_command\":\"npm run cron\"}", + "variables": "[{\"key\":\"CRON_SCHEDULE\",\"label\":\"Schedule\",\"default\":\"0 * * * *\",\"required\":true,\"secret\":false}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-docker", + "name": "Docker Image", + "description": "Deploy from any Docker image", + "category": "custom", + "logo": "https://cdn.simpleicons.org/docker", + "config": "{\"type\":\"web\",\"runtime\":\"docker\",\"port\":80}", + "variables": "[{\"key\":\"IMAGE\",\"label\":\"Docker Image\",\"default\":\"\",\"required\":true,\"secret\":false},{\"key\":\"TAG\",\"label\":\"Image Tag\",\"default\":\"latest\",\"required\":false,\"secret\":false}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-nextcloud", + "name": "Nextcloud", + "description": "Self-hosted file sync, share and collaboration platform", + "category": "app", + "logo": "https://cdn.simpleicons.org/nextcloud", + "config": "{\"type\":\"web\",\"runtime\":\"nextcloud:30-apache\",\"port\":80,\"health_check\":\"/status.php\"}", + "variables": "[{\"key\":\"NEXTCLOUD_ADMIN_USER\",\"label\":\"Admin User\",\"default\":\"admin\",\"required\":false,\"secret\":false},{\"key\":\"NEXTCLOUD_ADMIN_PASSWORD\",\"label\":\"Admin Password\",\"default\":\"\",\"required\":true,\"secret\":true},{\"key\":\"NEXTCLOUD_TRUSTED_DOMAINS\",\"label\":\"Trusted Domains\",\"default\":\"\",\"required\":false,\"secret\":false}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-gitea", + "name": "Gitea", + "description": "Lightweight self-hosted Git service", + "category": "app", + "logo": "https://cdn.simpleicons.org/gitea", + "config": "{\"type\":\"web\",\"runtime\":\"gitea/gitea:1\",\"port\":3000,\"health_check\":\"/api/healthz\"}", + "variables": "[{\"key\":\"GITEA__server__DOMAIN\",\"label\":\"Server Domain\",\"default\":\"localhost\",\"required\":false,\"secret\":false},{\"key\":\"GITEA__server__ROOT_URL\",\"label\":\"Root URL\",\"default\":\"\",\"required\":false,\"secret\":false}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-ghost", + "name": "Ghost", + "description": "Independent publishing platform for blogs and newsletters", + "category": "app", + "logo": "https://cdn.simpleicons.org/ghost", + "config": "{\"type\":\"web\",\"runtime\":\"ghost:5-alpine\",\"port\":2368,\"health_check\":\"/ghost/api/admin/site/\"}", + "variables": "[{\"key\":\"url\",\"label\":\"Public URL\",\"default\":\"\",\"required\":true,\"secret\":false},{\"key\":\"database__client\",\"label\":\"Database Client\",\"default\":\"sqlite3\",\"required\":false,\"secret\":false}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-uptime-kuma", + "name": "Uptime Kuma", + "description": "Self-hosted monitoring tool for services and websites", + "category": "app", + "logo": "https://cdn.simpleicons.org/uptimekuma", + "config": "{\"type\":\"web\",\"runtime\":\"louislam/uptime-kuma:1\",\"port\":3001,\"health_check\":\"/\"}", + "variables": "[]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-vaultwarden", + "name": "Vaultwarden", + "description": "Lightweight Bitwarden-compatible password manager", + "category": "app", + "logo": "https://cdn.simpleicons.org/bitwarden", + "config": "{\"type\":\"web\",\"runtime\":\"vaultwarden/server:latest\",\"port\":80,\"health_check\":\"/alive\"}", + "variables": "[{\"key\":\"ADMIN_TOKEN\",\"label\":\"Admin Token\",\"default\":\"\",\"required\":false,\"secret\":true},{\"key\":\"SIGNUPS_ALLOWED\",\"label\":\"Allow Signups\",\"default\":\"false\",\"required\":false,\"secret\":false},{\"key\":\"DOMAIN\",\"label\":\"Domain URL\",\"default\":\"\",\"required\":false,\"secret\":false}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-jellyfin", + "name": "Jellyfin", + "description": "Free software media system for movies, shows and music", + "category": "app", + "logo": "https://cdn.simpleicons.org/jellyfin", + "config": "{\"type\":\"web\",\"runtime\":\"jellyfin/jellyfin:latest\",\"port\":8096,\"health_check\":\"/health\"}", + "variables": "[]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-n8n", + "name": "n8n", + "description": "Workflow automation tool with hundreds of integrations", + "category": "app", + "logo": "https://cdn.simpleicons.org/n8n", + "config": "{\"type\":\"web\",\"runtime\":\"n8nio/n8n:latest\",\"port\":5678,\"health_check\":\"/healthz\"}", + "variables": "[{\"key\":\"N8N_HOST\",\"label\":\"Hostname\",\"default\":\"\",\"required\":false,\"secret\":false},{\"key\":\"N8N_ENCRYPTION_KEY\",\"label\":\"Encryption Key\",\"default\":\"\",\"required\":true,\"secret\":true},{\"key\":\"WEBHOOK_URL\",\"label\":\"Webhook URL\",\"default\":\"\",\"required\":false,\"secret\":false}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-grafana", + "name": "Grafana", + "description": "Observability dashboards for metrics, logs and traces", + "category": "app", + "logo": "https://cdn.simpleicons.org/grafana", + "config": "{\"type\":\"web\",\"runtime\":\"grafana/grafana:latest\",\"port\":3000,\"health_check\":\"/api/health\"}", + "variables": "[{\"key\":\"GF_SECURITY_ADMIN_PASSWORD\",\"label\":\"Admin Password\",\"default\":\"\",\"required\":true,\"secret\":true}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-immich", + "name": "Immich", + "description": "Self-hosted photo and video backup", + "category": "app", + "logo": "https://cdn.simpleicons.org/immich", + "config": "{\"type\":\"web\",\"runtime\":\"ghcr.io/immich-app/immich-server:release\",\"port\":2283,\"health_check\":\"/api/server/ping\"}", + "variables": "[{\"key\":\"DB_PASSWORD\",\"label\":\"Database Password\",\"default\":\"\",\"required\":true,\"secret\":true},{\"key\":\"DB_HOSTNAME\",\"label\":\"Database Host\",\"default\":\"\",\"required\":false,\"secret\":false}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-meilisearch", + "name": "Meilisearch", + "description": "Fast, typo-tolerant search engine API", + "category": "app", + "logo": "https://cdn.simpleicons.org/meilisearch", + "config": "{\"type\":\"web\",\"runtime\":\"getmeili/meilisearch:v1\",\"port\":7700,\"health_check\":\"/health\"}", + "variables": "[{\"key\":\"MEILI_MASTER_KEY\",\"label\":\"Master Key\",\"default\":\"\",\"required\":true,\"secret\":true},{\"key\":\"MEILI_ENV\",\"label\":\"Environment\",\"default\":\"production\",\"required\":false,\"secret\":false}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-plausible", + "name": "Plausible Analytics", + "description": "Lightweight, privacy-friendly web analytics", + "category": "app", + "logo": "https://cdn.simpleicons.org/plausibleanalytics", + "config": "{\"type\":\"web\",\"runtime\":\"ghcr.io/plausible/community-edition:v2\",\"port\":8000,\"health_check\":\"/api/health\"}", + "variables": "[{\"key\":\"BASE_URL\",\"label\":\"Base URL\",\"default\":\"\",\"required\":true,\"secret\":false},{\"key\":\"SECRET_KEY_BASE\",\"label\":\"Secret Key Base\",\"default\":\"\",\"required\":true,\"secret\":true}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-minio", + "name": "MinIO", + "description": "S3-compatible object storage", + "category": "app", + "logo": "https://cdn.simpleicons.org/minio", + "config": "{\"type\":\"web\",\"runtime\":\"minio/minio:latest\",\"start_command\":\"server /data --console-address \\\":9001\\\"\",\"port\":9001,\"health_check\":\"/minio/health/live\"}", + "variables": "[{\"key\":\"MINIO_ROOT_USER\",\"label\":\"Root User\",\"default\":\"minioadmin\",\"required\":true,\"secret\":false},{\"key\":\"MINIO_ROOT_PASSWORD\",\"label\":\"Root Password\",\"default\":\"\",\"required\":true,\"secret\":true}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-appwrite", + "name": "Appwrite", + "description": "Backend-as-a-service for web and mobile apps", + "category": "app", + "logo": "https://cdn.simpleicons.org/appwrite", + "config": "{\"type\":\"web\",\"runtime\":\"appwrite/appwrite:1.6\",\"port\":80,\"health_check\":\"/health\"}", + "variables": "[]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-wordpress", + "name": "WordPress", + "description": "Classic CMS powering a large share of the web", + "category": "app", + "logo": "https://cdn.simpleicons.org/wordpress", + "config": "{\"type\":\"web\",\"runtime\":\"wordpress:6-apache\",\"port\":80,\"health_check\":\"/wp-login.php\"}", + "variables": "[{\"key\":\"WORDPRESS_DB_HOST\",\"label\":\"Database Host\",\"default\":\"\",\"required\":true,\"secret\":false},{\"key\":\"WORDPRESS_DB_USER\",\"label\":\"Database User\",\"default\":\"wordpress\",\"required\":true,\"secret\":false},{\"key\":\"WORDPRESS_DB_PASSWORD\",\"label\":\"Database Password\",\"default\":\"\",\"required\":true,\"secret\":true},{\"key\":\"WORDPRESS_DB_NAME\",\"label\":\"Database Name\",\"default\":\"wordpress\",\"required\":true,\"secret\":false}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-paperless", + "name": "Paperless-ngx", + "description": "Document management system with OCR", + "category": "app", + "logo": "https://cdn.simpleicons.org/paperlessngx", + "config": "{\"type\":\"web\",\"runtime\":\"ghcr.io/paperless-ngx/paperless-ngx:latest\",\"port\":8000,\"health_check\":\"/api/\"}", + "variables": "[{\"key\":\"PAPERLESS_ADMIN_PASSWORD\",\"label\":\"Admin Password\",\"default\":\"\",\"required\":true,\"secret\":true},{\"key\":\"PAPERLESS_URL\",\"label\":\"Public URL\",\"default\":\"\",\"required\":false,\"secret\":false}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-ntfy", + "name": "ntfy", + "description": "Push notification service over HTTP", + "category": "app", + "logo": "https://cdn.simpleicons.org/ntfy", + "config": "{\"type\":\"web\",\"runtime\":\"binwiederhier/ntfy:latest\",\"start_command\":\"serve\",\"port\":80,\"health_check\":\"/v1/health\"}", + "variables": "[]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + } + ] +} \ No newline at end of file