merge: resolve main into feat/app-demo-polish

This commit is contained in:
Tomas Dvorak committed 2026-09-23 19:06:42 +02:00
commit 38e0bc1802
42 files changed
+4125 -585

No files matched your search

+24
View File
@@ -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)
}
}
+9 -3
View File
@@ -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),
}
@@ -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"`
}
@@ -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,
)
@@ -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;
+9 -7
View File
@@ -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)
+1
View File
@@ -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()
);
+4 -2
View File
@@ -112,10 +112,12 @@ export default function App() {
<Route path="/auth/sign-up" element={<SignUpPage />} />
{/* Public browsing — approved projects, templates, docs. */}
{/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */}
<Route path="/projects/:projectId" element={<ProjectWorkspacePage />} />
<Route path="/projects/:projectId/services/:serviceId" element={<ServiceDetailPage />} />
<Route element={<PlatformShell />}>
<Route path="/projects" element={<ProjectsPage />} />
<Route path="/projects/:projectId" element={<ProjectWorkspacePage />} />
<Route path="/projects/:projectId/services/:serviceId" element={<ServiceDetailPage />} />
<Route path="/templates" element={<TemplatesPage />} />
<Route path="/databases" element={<DatabasesPage />} />
<Route path="/ha" element={<HighAvailabilityPage />} />
+10 -2
View File
@@ -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"
>
<div className="w-9 h-9 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center shrink-0">
<Database size={17} className="text-[var(--accent-primary)]" />
<div
className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center shrink-0"
style={{
background: `${serviceAccent({ name: db.type })}1c`,
color: serviceAccent({ name: db.type }),
}}
>
<ServiceIcon service={{ name: db.type, type: 'database' }} size={17} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
@@ -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<ServiceSection>('metrics');
const [activeSection, setActiveSection] = useState<ServiceSection>(() => {
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<VariableDraft[] | null>(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 */}
<div className="flex items-center" style={{ padding: '0 24px 18px' }}>
<div
<div
className="rounded-[13px] flex items-center justify-center flex-shrink-0"
style={{
width: '46px',
height: '46px',
background: 'var(--accent-primary)',
style={{
width: '46px',
height: '46px',
background: `${serviceAccent(service)}1f`,
color: serviceAccent(service),
marginRight: '14px'
}}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="white">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z"/>
</svg>
<ServiceIcon service={service} size={22} />
</div>
<div>
<div className="flex items-center" style={{ gap: '10px' }}>
@@ -125,6 +125,7 @@ export function TemplatesPage() {
const [editorOpen, setEditorOpen] = useState(false);
const [editorJson, setEditorJson] = useState(EMPTY_TEMPLATE_JSON);
const [editingTemplate, setEditingTemplate] = useState<TemplateEntity | null>(null);
const [editorIsPublic, setEditorIsPublic] = useState(false);
const [editorError, setEditorError] = useState<string | null>(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<string, unknown>,
variables: Array.isArray(body.variables) ? body.variables : [],
isPublic: editorIsPublic,
});
};
@@ -571,6 +575,11 @@ export function TemplatesPage() {
Mine
</span>
) : null}
{!template.isOfficial && template.isPublic ? (
<span className="px-1.5 py-0.5 rounded bg-[var(--info-soft)] text-[var(--info)] text-[10px] font-semibold">
Public
</span>
) : null}
</div>
<div className="flex items-center gap-2 mt-1">
<span
@@ -646,6 +655,11 @@ export function TemplatesPage() {
Official
</span>
)}
{!selectedDetail.template.isOfficial && selectedDetail.template.isPublic && (
<span className="px-2 py-0.5 rounded-full bg-[var(--info-soft)] text-[var(--info)] text-xs font-medium">
Public
</span>
)}
</div>
<p className="text-sm text-[var(--text-secondary)] mt-1">{selectedDetail.template.description}</p>
</div>
@@ -921,9 +935,15 @@ export function TemplatesPage() {
</div>
) : null}
<div className="flex items-center justify-between border-t border-[var(--border-subtle)] px-5 py-3.5">
<p className="text-[11px] text-[var(--text-tertiary)]">
Saved as a user template — only you (and admins) can edit or delete it.
</p>
<label className="inline-flex cursor-pointer items-center gap-2 text-[11px] text-[var(--text-secondary)]">
<input
type="checkbox"
checked={editorIsPublic}
onChange={(e) => setEditorIsPublic(e.target.checked)}
className="h-3.5 w-3.5 accent-[var(--accent-primary)]"
/>
List in the public catalog - anyone browsing this instance can see and deploy it.
</label>
<div className="flex items-center gap-2">
<button
type="button"
File diff suppressed because it is too large. Load diff
@@ -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 (
<img
src={serviceIconUrl(brand)}
width={size}
height={size}
alt=""
loading="lazy"
onError={() => setFailed(true)}
/>
);
}
return serviceTypeIcon(service.type ?? 'service', size);
}
@@ -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<string, { x: number; y: number }> {
const positions = new Map<string, { x: number; y: number }>();
if (services.length === 0) {
return positions;
}
const ids = new Set(services.map((service) => service.id));
const outgoing = new Map<string, string[]>();
const connected = new Set<string>();
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<string, number>();
const depthOf = (id: string, trail: Set<string>): 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<number, ServiceEntity[]>();
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<string, ServiceVariable[]>,
): Map<string, { x: number; y: number }> {
const links = inferAutoConnections(services, variablesByService);
return computeLayeredLayout(
services,
links.map((link) => link.edge),
);
}
@@ -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 (
<div>
<div className="flex items-center justify-between mb-1.5">
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">{title}</p>
{action}
</div>
{children}
</div>
);
}
function SectionLink({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
return (
<button
type="button"
onClick={onClick}
className="text-[10px] text-[var(--accent-primary)] hover:underline flex items-center gap-0.5"
>
{children}
<ArrowUpRight size={9} />
</button>
);
}
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 (
<aside className="canvas-inspector absolute right-4 top-4 bottom-4 w-[312px] panel-glass flex flex-col overflow-hidden">
<div className="flex items-center gap-3 px-4 pt-4 pb-3 border-b border-[var(--border-subtle)]">
<div
className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center flex-shrink-0"
style={{ background: `${accent}1c`, color: accent }}
>
<ServiceIcon service={service} size={16} />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-[var(--text-primary)] truncate">{service.name}</h3>
<div className="flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)]">
<span className={`status-dot ${serviceStatusClass(service.status)}`} />
<span style={{ color: statusColor(service.status) }}>{statusLabel(service.status)}</span>
<span>· {service.type}</span>
{service.environment && service.environment !== 'production' && <span>· {service.environment}</span>}
</div>
</div>
<button
type="button"
onClick={onClose}
className="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)]"
title="Close (Esc)"
>
<X size={14} />
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
{(publicUrl || service.port) && (
<Section title="Address">
<div className="flex items-center gap-1.5 group">
<Globe size={11} className="text-[var(--text-tertiary)] flex-shrink-0" />
{publicUrl ? (
<a
href={publicUrl}
target="_blank"
rel="noopener noreferrer"
className="mono text-[11px] text-[var(--accent-primary)] hover:underline break-all"
>
{publicUrl.replace(/^https?:\/\//, '')}
</a>
) : (
<span className="mono text-[11px] text-[var(--text-secondary)]">
{service.name}:{service.port}
</span>
)}
<button
type="button"
onClick={copyAddress}
className="ml-auto text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
title="Copy address"
>
{copied ? <span className="text-[10px] text-[var(--success)]">Copied</span> : <Copy size={11} />}
</button>
</div>
{publicUrl && service.port ? (
<p className="mono text-[10.5px] text-[var(--text-tertiary)] mt-0.5 pl-4">
{service.name}:{service.port}
</p>
) : null}
</Section>
)}
<Section title="Group">
<div className="flex items-center gap-2">
<Layers size={11} className="text-[var(--text-tertiary)] flex-shrink-0" />
<select
value={groupId ?? ''}
onChange={(event) => {
const next = event.target.value;
if (next === '__new__') {
onNewGroupFor(service.id);
return;
}
onAssignGroup(service.id, next === '' ? null : next);
}}
className="nodrag flex-1 h-7 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 text-[11px] text-[var(--text-primary)] outline-none focus:border-[var(--accent-primary)]"
>
<option value="">Ungrouped</option>
{groups.map((group) => (
<option key={group.id} value={group.id}>
{group.title}
</option>
))}
<option value="__new__">+ New group…</option>
</select>
</div>
</Section>
<Section
title={`Variables (${variables.length})`}
action={
<SectionLink onClick={() => onOpenSection(service.id, 'variables')}>
Manage
</SectionLink>
}
>
{variables.length === 0 ? (
<p className="text-[11px] text-[var(--text-muted)]">No variables yet.</p>
) : (
<div className="space-y-1">
{variables.slice(0, 6).map((variable) => (
<div
key={variable.key}
className="flex items-center gap-2 px-2 py-1.5 rounded-[var(--radius-sm)] bg-[var(--surface-muted)]/50"
>
<KeyRound size={9} className="text-[var(--text-tertiary)] flex-shrink-0" />
<span className="mono text-[10px] text-[var(--text-primary)] truncate font-medium">
{variable.key}
</span>
<span className="mono text-[10px] text-[var(--text-tertiary)] truncate ml-auto max-w-[130px]" title={variable.isSecret ? 'Secret' : variable.value}>
{variable.isSecret ? '••••••••' : variable.value}
</span>
</div>
))}
{variables.length > 6 && (
<p className="text-[10px] text-[var(--text-muted)] pl-1">+{variables.length - 6} more</p>
)}
</div>
)}
</Section>
<Section
title="Config"
action={
<SectionLink onClick={() => onOpenSection(service.id, 'config')}>
Edit
</SectionLink>
}
>
{visibleConfig.length === 0 ? (
<p className="text-[11px] text-[var(--text-muted)]">No configuration set.</p>
) : (
<dl className="space-y-1">
{visibleConfig.map(([label, value]) => (
<div key={label} className="flex items-baseline gap-2">
<dt className="text-[10px] text-[var(--text-muted)] w-[70px] flex-shrink-0">{label}</dt>
<dd className="mono text-[10.5px] text-[var(--text-secondary)] truncate" title={value}>
{value}
</dd>
</div>
))}
{(service.replicas ?? 0) > 0 && (
<div className="flex items-baseline gap-2">
<dt className="text-[10px] text-[var(--text-muted)] w-[70px] flex-shrink-0">Replicas</dt>
<dd className="mono text-[10.5px] text-[var(--text-secondary)]">{service.replicas}</dd>
</div>
)}
</dl>
)}
</Section>
<Section title={`Connections (${connections.length})`}>
{connections.length === 0 ? (
<p className="text-[11px] text-[var(--text-muted)]">
No inferred connections. Reference another service via{' '}
<span className="mono">{'{{variable}}'}</span> placeholders in env vars.
</p>
) : (
<div className="space-y-1.5">
{connections.map((conn) => (
<button
key={conn.id}
type="button"
onClick={() => onSelectPeer(conn.peerId)}
className="w-full text-left px-2.5 py-2 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/40 hover:bg-[var(--surface-muted)] transition-colors"
>
<div className="flex items-center gap-1.5 text-[11px] text-[var(--text-primary)]">
<Link2 size={10} className="text-[var(--accent-primary)] flex-shrink-0" />
<span className="truncate font-medium">{conn.outbound ? `→ ${conn.peer}` : `← ${conn.peer}`}</span>
</div>
<p className="mono text-[9.5px] text-[var(--text-tertiary)] truncate mt-0.5 pl-4">
{conn.reasons.join(' · ')}
</p>
</button>
))}
</div>
)}
</Section>
{service.updatedAt && (
<p className="text-[10px] text-[var(--text-muted)] pt-1">
Updated {formatRelative(service.updatedAt)}
</p>
)}
</div>
<div className="border-t border-[var(--border-subtle)] px-4 py-3 space-y-2">
<button
type="button"
onClick={() => onOpenService(service.id)}
className="w-full flex items-center justify-center gap-2 h-9 rounded-[var(--radius-md)] text-[var(--accent-on)] text-[13px] font-medium"
style={{ background: 'var(--accent-primary)' }}
>
<ArrowUpRight size={14} />
Open service
</button>
<div className="grid grid-cols-3 gap-1.5">
<button
type="button"
disabled={actions.pending || readOnly}
onClick={actions.deploy}
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
>
{actions.deployPending ? <Loader2 size={11} className="animate-spin" /> : <Rocket size={11} />}
Deploy
</button>
{service.status !== 'running' ? (
<button
type="button"
disabled={actions.pending || readOnly}
onClick={actions.start}
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
>
<Play size={11} />
Start
</button>
) : (
<button
type="button"
disabled={actions.pending || readOnly}
onClick={actions.restart}
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
>
{actions.restartPending ? <Loader2 size={11} className="animate-spin" /> : <Play size={11} />}
Restart
</button>
)}
<button
type="button"
disabled={actions.pending || readOnly || service.status !== 'running'}
onClick={actions.stop}
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
>
<Square size={11} />
Stop
</button>
</div>
<button
type="button"
disabled={actions.pending || readOnly}
onClick={actions.remove}
className="w-full flex items-center justify-center gap-1.5 h-8 rounded-[var(--radius-sm)] text-[11px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] transition-colors disabled:opacity-40"
>
<Trash2 size={11} />
Delete service
</button>
</div>
</aside>
);
}
@@ -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<ServiceNodeData, 'serviceNode'>;
export type GroupNodeType = Node<GroupNodeData, 'groupNode'>;
function renderServiceTypeIcon(type: string, size = 18) {
switch (type) {
case 'web':
return <Globe size={size} />;
case 'database':
return <Database size={size} />;
case 'worker':
return <Terminal size={size} />;
default:
return <Box size={size} />;
}
}
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<ServiceNodeType>) {
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 (
<div
className={`rounded-[var(--radius-lg)] border transition-all duration-200 group relative overflow-hidden ${
className={`w-full rounded-[var(--radius-lg)] border transition-[border-color,box-shadow,transform] duration-150 group relative overflow-hidden ${
selected
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-lg shadow-[var(--accent-primary-glow)]'
: 'border-[var(--border-subtle)] bg-[var(--surface-card)] hover:border-[var(--border-default)] hover:shadow-lg'
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-[0_0_0_1px_var(--accent-primary),0_12px_36px_rgba(0,0,0,0.5)]'
: 'border-[var(--border-subtle)] bg-[var(--surface-card)] hover:border-[var(--border-default)] hover:shadow-[0_12px_32px_rgba(0,0,0,0.45)] hover:-translate-y-px'
}`}
style={{ minWidth: 220, maxWidth: 280 }}
>
<div
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
style={{ background: `${typeColor}10` }}
style={{ background: `${typeColor}08` }}
/>
<Handle id="t-l" type="target" position={Position.Left} className="!opacity-0 !w-2 !h-2 !border-0" />
@@ -105,22 +86,26 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
style={{ background: stateColor }}
/>
<div className="relative p-4">
<div className="flex items-start gap-3 mb-3">
<div className="relative px-3.5 pt-3.5 pb-3">
<div className="flex items-center gap-3 mb-2.5">
<div
className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 transition-all duration-200 ${
selected ? 'ring-2 ring-white/20' : ''
}`}
className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center flex-shrink-0 transition-all duration-200"
style={{
background: selected ? typeColor : `${typeColor}20`,
color: selected ? 'white' : typeColor,
background: selected && !brand ? typeColor : `${typeColor}1c`,
color: selected && !brand ? 'var(--bg-void)' : typeColor,
}}
>
{renderServiceTypeIcon(service.type, 18)}
{isTransient ? (
<Loader2 size={16} className="animate-spin" />
) : (
<ServiceIcon service={service} size={17} />
)}
</div>
<div className="min-w-0 flex-1 pt-0.5">
<h4 className="font-semibold text-sm text-[var(--text-primary)] truncate tracking-tight">{service.name}</h4>
<p className="text-[11px] text-[var(--text-tertiary)] truncate mt-0.5">
<div className="min-w-0 flex-1">
<h4 className="font-semibold text-[13px] leading-tight text-[var(--text-primary)] truncate tracking-tight">
{service.name}
</h4>
<p className="text-[10.5px] text-[var(--text-tertiary)] truncate mt-0.5">
{service.type}
{service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''}
</p>
@@ -133,7 +118,7 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
)}
</div>
<div className="flex items-center gap-2 mb-3 px-2.5 py-1.5 rounded-lg bg-[var(--surface-muted)]/50 border border-[var(--border-subtle)]/50 group/addr">
<div className="flex items-center gap-2 mb-2.5 px-2.5 py-1.5 rounded-[var(--radius-sm)] bg-[var(--surface-muted)]/60 border border-[var(--border-subtle)]/60 group/addr">
<ExternalLink size={10} className="text-[var(--text-tertiary)] flex-shrink-0" />
{publicUrl ? (
<a
@@ -141,12 +126,12 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-[10px] text-[var(--accent-primary)] truncate mono hover:underline"
className="text-[10.5px] text-[var(--accent-primary)] truncate mono hover:underline"
>
{publicUrl.replace(/^https?:\/\//, '')}
</a>
) : (
<span className="text-[10px] text-[var(--text-secondary)] truncate mono" title="Internal network address">
<span className="text-[10.5px] text-[var(--text-secondary)] truncate mono" title="Internal network address">
{internalAddr}
</span>
)}
@@ -164,14 +149,14 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<span className={`status-dot ${serviceStatusClass(service.status)}`} />
<span className="text-[11px] font-semibold tracking-wide uppercase" style={{ color: stateColor }}>
<span className="text-[10px] font-semibold tracking-[0.08em] uppercase" style={{ color: stateColor }}>
{statusLabel(service.status)}
</span>
</div>
{service.image && (
<span className="text-[10px] text-[var(--text-tertiary)] truncate mono max-w-[120px]" title={service.image}>
<span className="text-[9.5px] text-[var(--text-muted)] truncate mono max-w-[110px]" title={service.image}>
{service.image}
</span>
)}
@@ -181,41 +166,78 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
);
}
export function GroupNode({ data }: NodeProps<GroupNodeType>) {
export function GroupNode({ id, data }: NodeProps<GroupNodeType>) {
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 (
<div
className="h-full w-full rounded-[var(--radius-xl)] border-2 border-dashed transition-all duration-200 relative overflow-hidden"
className="h-full w-full rounded-[var(--radius-xl)] border-2 border-dashed transition-all duration-200 relative"
style={{
borderColor: isHovered ? 'var(--accent-primary)' : 'var(--border-default)',
background: isHovered ? 'rgba(232, 49, 106, 0.03)' : 'var(--surface-muted)/30',
borderColor: isHovered ? 'var(--border-strong)' : 'var(--border-default)',
background: isHovered ? 'var(--accent-primary-soft)' : 'rgba(255,255,255,0.02)',
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div
className="absolute inset-0 opacity-0 transition-opacity duration-300 pointer-events-none"
style={{
background: 'rgba(232,49,106,0.03)',
opacity: isHovered ? 1 : 0,
}}
/>
<div className="relative p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200"
style={{
background: isHovered ? 'var(--accent-primary-soft)' : 'var(--surface-card)',
color: isHovered ? 'var(--accent-primary)' : 'var(--text-tertiary)',
}}
>
<Box size={14} />
</div>
<h3 className="text-sm font-semibold text-[var(--text-primary)] tracking-tight">{data.title}</h3>
<div className="flex items-center gap-2.5">
<div
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200"
style={{
background: isHovered ? 'var(--accent-primary-soft)' : 'var(--surface-card)',
color: isHovered ? 'var(--accent-primary)' : 'var(--text-tertiary)',
}}
>
<Box size={14} />
</div>
{editing ? (
<input
autoFocus
value={draft}
onChange={(e) => 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)]"
/>
) : (
<h3
className="text-sm font-semibold text-[var(--text-primary)] tracking-tight"
onDoubleClick={(e) => {
e.stopPropagation();
setDraft(data.title);
setEditing(true);
}}
title="Double-click to rename"
>
{data.title}
</h3>
)}
</div>
<p
@@ -0,0 +1,112 @@
import { Box, Globe, Database, Terminal, Clock } from 'lucide-react';
export function serviceTypeIcon(type: string, size = 18) {
switch (type) {
case 'web':
return <Globe size={size} />;
case 'database':
return <Database size={size} />;
case 'worker':
return <Terminal size={size} />;
case 'cron':
return <Clock size={size} />;
default:
return <Box size={size} />;
}
}
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');
}
+5 -2
View File
@@ -44,14 +44,17 @@ export const DEFAULT_VIEWPORT: CanvasViewport = {
zoom: 1,
};
export function createDefaultCanvasMetadata(services: ServiceEntity[]): ProjectCanvasMetadata {
export function createDefaultCanvasMetadata(
services: ServiceEntity[],
layout?: Map<string, CanvasPoint>,
): 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,
},
@@ -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.
</p>
<button
onClick={() => navigate('/projects')}
onClick={() => navigate(isDemoMode ? '/projects?demo=1' : '/projects')}
className="mt-6 px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors"
>
Back to Projects
@@ -655,131 +657,116 @@ export function ProjectWorkspacePage() {
);
}
const canvasView = activeView === 'canvas';
const projectsHref = isDemoMode ? '/projects?demo=1' : '/projects';
return (
<div className="min-h-screen">
{/* Header */}
<div className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/50 backdrop-blur-sm">
<div className="w-full px-8 py-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div className="flex h-dvh min-h-0 flex-col bg-[var(--bg-void)]">
{/* Slim workspace bar — tabs + project meta, canvas fills everything below */}
<div className="flex h-12 shrink-0 items-center gap-3 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/80 px-4 backdrop-blur-sm">
<button
onClick={() => navigate(projectsHref)}
className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors shrink-0"
>
<ArrowLeft size={14} />
<span className="hidden sm:inline">Projects</span>
</button>
<div className="w-px h-4 bg-[var(--border-subtle)] shrink-0" />
<h1 className="v-title text-sm shrink-0 min-w-0 truncate">{project.name}<span className="v-cursor">_</span></h1>
<span className="hidden lg:flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)] shrink-0">
{services.length} service{services.length !== 1 ? 's' : ''} · {runningServices} running
</span>
{/* View tabs */}
<nav className="flex items-center gap-0.5 rounded-full bg-[var(--surface-muted)] border border-[var(--border-subtle)] p-0.5 mx-2 overflow-x-auto min-w-0">
{viewItems.filter((item) => signedIn || !item.authOnly).map((item) => {
const active = activeView === item.key;
const Icon = item.icon;
return (
<button
onClick={() => navigate('/projects')}
className="flex items-center gap-2 text-sm text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors"
key={item.key}
onClick={() => setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('view', item.key);
return next;
})}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all whitespace-nowrap ${
active
? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-sm'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
}`}
>
<ArrowLeft size={16} />
<span>Projects</span>
<Icon size={13} />
<span className="hidden md:inline">{item.label}</span>
</button>
<div className="w-px h-5 bg-[var(--border-subtle)]" />
<div>
<h1 className="v-title">{project.name}<span className="v-cursor">_</span></h1>
<p className="text-sm text-[var(--text-secondary)]">{project.description || 'No description'}</p>
</div>
</div>
);
})}
</nav>
<div className="flex items-center gap-6">
{/* Stats */}
<div className="hidden md:flex items-center gap-6">
<div className="text-center">
<p className="text-2xl font-semibold text-[var(--text-primary)]">{services.length}</p>
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">Services</p>
</div>
<div className="text-center">
<p className="text-2xl font-semibold text-[var(--success)]">{runningServices}</p>
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">Running</p>
</div>
<div className="w-px h-8 bg-[var(--border-subtle)]" />
<LiveIndicator isLive={runningServices > 0} />
<div className="w-px h-8 bg-[var(--border-subtle)]" />
<div className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)]">
<Clock size={12} />
<span>{formatRelative(project.updatedAt)}</span>
</div>
</div>
{/* Search / Command Palette */}
<button
onClick={() => setCommandPaletteOpen(true)}
className="hidden md:flex items-center gap-2 h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-muted)] text-sm hover:border-[var(--border-default)] hover:text-[var(--text-secondary)] transition-colors"
>
<Search size={14} />
<span>Search...</span>
<kbd className="ml-2 px-1.5 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd>
</button>
{/* Add Service Button */}
{!isDemoMode && (
<button
onClick={openAddService}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg hover:shadow-xl transition-all"
style={{ background: 'var(--accent-primary)' }}
>
<Plus size={16} />
<span className="hidden sm:inline">Add Service</span>
</button>
)}
</div>
<div className="ml-auto flex items-center gap-2 shrink-0">
<div className="hidden xl:block">
<LiveIndicator isLive={runningServices > 0} />
</div>
<span className="hidden xl:flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)]">
<Clock size={11} />
{formatRelative(project.updatedAt)}
</span>
<button
onClick={() => setCommandPaletteOpen(true)}
className="hidden md:flex items-center gap-2 h-8 px-2.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-muted)] text-xs hover:border-[var(--border-default)] hover:text-[var(--text-secondary)] transition-colors"
>
<Search size={12} />
<kbd className="px-1 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd>
</button>
{!isDemoMode && (
<button
onClick={openAddService}
className="flex items-center gap-1.5 h-8 px-3 rounded-[var(--radius-md)] text-[var(--accent-on)] text-xs font-semibold shadow-lg hover:shadow-xl transition-all"
style={{ background: 'var(--accent-primary)' }}
>
<Plus size={14} />
<span className="hidden sm:inline">Add Service</span>
</button>
)}
</div>
</div>
{/* Demo Mode Banner */}
{isDemoMode && (
<div className="w-full px-8 py-4">
<div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50">
<div className="flex items-center gap-2 text-sm text-[var(--warning)]">
<Sparkles size={16} />
<span>Demo mode active — using sample data for preview</span>
{/* Content */}
{canvasView ? (
<div className="relative flex-1 min-h-0">
<ProjectCanvas
projectId={project.id}
services={services}
variablesByService={variablesByService}
onAddService={openAddService}
onOpenService={(serviceId) => navigate(serviceHref(serviceId))}
onOpenSection={(serviceId, section) => navigate(serviceSectionHref(serviceId, section))}
readOnly={isDemoMode}
/>
{isDemoMode && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 pointer-events-none">
<span className="canvas-pill flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-medium text-[var(--warning)]">
<Sparkles size={12} />
Demo data
</span>
</div>
)}
</div>
) : (
<div className="flex-1 min-h-0 overflow-y-auto">
{isDemoMode && (
<div className="w-full px-8 pt-6">
<div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50">
<div className="flex items-center gap-2 text-sm text-[var(--warning)]">
<Sparkles size={16} />
<span>Demo mode active - using sample data for preview</span>
</div>
</div>
</div>
</div>
)}
{/* Main Content */}
<div className="w-full px-8 py-6">
<div className="grid grid-cols-1 lg:grid-cols-[200px_1fr] gap-6">
{/* Sidebar Navigation */}
<aside className="lg:sticky lg:top-6 lg:h-fit">
<nav className="flex lg:flex-col gap-1 overflow-x-auto lg:overflow-visible pb-2 lg:pb-0">
{viewItems.filter((item) => signedIn || !item.authOnly).map((item) => {
const active = activeView === item.key;
const Icon = item.icon;
return (
<button
key={item.key}
onClick={() => setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('view', item.key);
return next;
})}
className={`flex items-center gap-3 px-3 py-2.5 rounded-[var(--radius-md)] transition-all whitespace-nowrap ${
active
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)]'
}`}
>
<Icon size={18} />
<span className="text-sm font-medium">{item.label}</span>
{active && (
<div className="hidden lg:block absolute left-0 w-0.5 h-5 bg-[var(--accent-primary)] rounded-full" />
)}
</button>
);
})}
</nav>
</aside>
{/* Content Area */}
)}
<div className="w-full px-8 py-6">
<section className="min-w-0">
{activeView === 'canvas' && (
<ProjectCanvas
projectId={project.id}
services={services}
variablesByService={variablesByService}
onAddService={openAddService}
onOpenService={(serviceId) => navigate(serviceHref(serviceId))}
/>
)}
{activeView === 'observability' && (
<div className="space-y-6">
{/* Health Summary */}
@@ -966,6 +953,7 @@ export function ProjectWorkspacePage() {
</section>
</div>
</div>
)}
{/* Create Service Dialog */}
{!isDemoMode && (
+19 -9
View File
@@ -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<string, { x: number; y: number }>,
): 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<string>();
+4
View File
@@ -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<string, never>;
/** @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 */
+86 -3
View File
@@ -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;
}
}
+4
View File
@@ -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<string, unknown>;
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,
});
}
+45 -12
View File
@@ -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<string, ServiceEntity[]> = {
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<string, ServiceEntity[]> = {
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<string, ServiceEntity[]> = {
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<string, ServiceEntity[]> = {
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<string, ServiceEntity[]> = {
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<string, ServiceEntity[]> = {
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<string, ServiceEntity[]> = {
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<string, ServiceEntity[]> = {
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<string, ServiceEntity[]> = {
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<string, Record<string, ServiceVariab
] satisfies ServiceVariable[]
),
'demo-svc-postgres': [],
'demo-svc-redis': [],
},
'demo-project-growth': {
'demo-growth-site': (
@@ -594,6 +620,7 @@ export const demoTemplates: TemplateEntity[] = [
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
isPublic: false,
},
{
id: 'tpl-go',
@@ -605,6 +632,7 @@ export const demoTemplates: TemplateEntity[] = [
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
isPublic: false,
},
{
id: 'tpl-postgres',
@@ -616,6 +644,7 @@ export const demoTemplates: TemplateEntity[] = [
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
isPublic: false,
},
{
id: 'tpl-mysql',
@@ -627,6 +656,7 @@ export const demoTemplates: TemplateEntity[] = [
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
isPublic: false,
},
{
id: 'tpl-mariadb',
@@ -638,6 +668,7 @@ export const demoTemplates: TemplateEntity[] = [
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
isPublic: false,
},
{
id: 'tpl-clickhouse',
@@ -649,6 +680,7 @@ export const demoTemplates: TemplateEntity[] = [
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
isPublic: false,
},
{
id: 'tpl-dragonfly',
@@ -660,6 +692,7 @@ export const demoTemplates: TemplateEntity[] = [
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
isPublic: false,
},
];