From 1425618c92bd3527bf3311d1b1748f5436b30842 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Wed, 23 Sep 2026 18:12:15 +0200 Subject: [PATCH] feat(canvas): brand tech icons on services, matching site preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve a service's tech (postgres, redis, go, node, nginx, kafka, ~50 slugs) from its image/name and render the Simple Icons logo in the tinted icon tile — canvas nodes, inspector, service detail header, and database rows — with graceful fallback to type icons. Demo services get resolvable images, ports, and domains; a Redis Cache service joins Core Platform so the demo canvas mirrors the landing preview (5 services, web/api/worker → api → postgres/redis). Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/frontend/src/features/secondary/pages.tsx | 12 ++- .../service/pages/ServiceDetailPage.tsx | 17 ++-- .../workspace/canvas/ProjectCanvas.tsx | 9 ++- .../features/workspace/canvas/ServiceIcon.tsx | 26 ++++++ .../src/features/workspace/canvas/nodes.tsx | 16 ++-- .../workspace/canvas/service-visuals.tsx | 81 +++++++++++++++++++ app/frontend/src/lib/demo-data.ts | 50 +++++++++--- 7 files changed, 180 insertions(+), 31 deletions(-) create mode 100644 app/frontend/src/features/workspace/canvas/ServiceIcon.tsx diff --git a/app/frontend/src/features/secondary/pages.tsx b/app/frontend/src/features/secondary/pages.tsx index 66caa3f..b26f057 100644 --- a/app/frontend/src/features/secondary/pages.tsx +++ b/app/frontend/src/features/secondary/pages.tsx @@ -51,6 +51,8 @@ import { type FailoverPolicy, } from '@/lib/api-client'; import { demoDatabases } 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 { formatRelative } from '@/lib/time'; @@ -1668,8 +1670,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 43bd837..797f3f7 100644 --- a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx +++ b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx @@ -36,6 +36,8 @@ import { type CronJobEntity, } from '@/lib/api-client'; import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data'; +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'; @@ -590,18 +592,17 @@ export function ServiceDetailPage() { {/* Project Header - self.html exact match */}
-
- - - +
diff --git a/app/frontend/src/features/workspace/canvas/ProjectCanvas.tsx b/app/frontend/src/features/workspace/canvas/ProjectCanvas.tsx index bc70fb8..5ea433c 100644 --- a/app/frontend/src/features/workspace/canvas/ProjectCanvas.tsx +++ b/app/frontend/src/features/workspace/canvas/ProjectCanvas.tsx @@ -34,7 +34,8 @@ import { import { hasCanvasMetadata, loadCanvasMetadata, saveCanvasMetadata } from '../storage'; import { computeLayeredLayout, layoutFromVariables, NODE_HEIGHT, NODE_WIDTH } from './auto-layout'; import { GroupNode, ServiceNode, type GroupNodeData, type ServiceNodeData } from './nodes'; -import { serviceTypeColor, serviceTypeIcon } from './service-visuals'; +import { ServiceIcon } from './ServiceIcon'; +import { serviceAccent } from './service-visuals'; import { Plus, Layers, @@ -656,7 +657,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on if (node.type === 'groupNode') { return 'rgba(255,255,255,0.08)'; } - return serviceTypeColor((node.data as ServiceNodeData).service.type); + return serviceAccent((node.data as ServiceNodeData).service); }, [], ); @@ -762,9 +763,9 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
- {serviceTypeIcon(selectedService.type, 16)} +

{selectedService.name}

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/nodes.tsx b/app/frontend/src/features/workspace/canvas/nodes.tsx index 8203097..e2f66ee 100644 --- a/app/frontend/src/features/workspace/canvas/nodes.tsx +++ b/app/frontend/src/features/workspace/canvas/nodes.tsx @@ -3,7 +3,8 @@ import type { ServiceEntity } from '@/lib/api-client'; import { serviceStatusClass } from '@/lib/api-client'; import { Box, ExternalLink, Copy, Layers, Loader2 } from 'lucide-react'; import { useState } from 'react'; -import { serviceTypeColor, serviceTypeIcon } from './service-visuals'; +import { serviceIcon, serviceAccent } from './service-visuals'; +import { ServiceIcon } from './ServiceIcon'; export type ServiceNodeData = { service: ServiceEntity; @@ -52,7 +53,8 @@ const TRANSIENT_STATUSES = new Set(['building', 'deploying', 'pending', 'rolling 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); @@ -88,11 +90,15 @@ export function ServiceNode({ data }: NodeProps) {
- {isTransient ? : serviceTypeIcon(service.type, 16)} + {isTransient ? ( + + ) : ( + + )}

diff --git a/app/frontend/src/features/workspace/canvas/service-visuals.tsx b/app/frontend/src/features/workspace/canvas/service-visuals.tsx index e50576a..0fe768d 100644 --- a/app/frontend/src/features/workspace/canvas/service-visuals.tsx +++ b/app/frontend/src/features/workspace/canvas/service-visuals.tsx @@ -29,3 +29,84 @@ export function serviceTypeColor(type: string): string { 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/lib/demo-data.ts b/app/frontend/src/lib/demo-data.ts index c2f6fd0..d688a50 100644 --- a/app/frontend/src/lib/demo-data.ts +++ b/app/frontend/src/lib/demo-data.ts @@ -10,9 +10,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', }, }, @@ -55,9 +55,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', }, @@ -68,9 +70,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', }, @@ -81,9 +85,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', }, @@ -94,12 +99,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': [ { @@ -109,9 +129,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', }, @@ -122,9 +144,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', }, @@ -135,9 +158,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', }, @@ -150,9 +174,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', }, @@ -163,7 +188,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', @@ -195,6 +220,7 @@ export const demoVariablesByProject: Record