mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat(canvas): brand tech icons on services, matching site preview
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>
This commit is contained in:
1 parent
e57f0470eb
commit
1425618c92
7 files changed
+180
-31
No files matched your search
@@ -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"
|
||||
>
|
||||
<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">
|
||||
|
||||
@@ -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 */}
|
||||
<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' }}>
|
||||
|
||||
@@ -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
|
||||
<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: `${serviceTypeColor(selectedService.type)}1c`, color: serviceTypeColor(selectedService.type) }}
|
||||
style={{ background: `${serviceAccent(selectedService)}1c`, color: serviceAccent(selectedService) }}
|
||||
>
|
||||
{serviceTypeIcon(selectedService.type, 16)}
|
||||
<ServiceIcon service={selectedService} size={16} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-sm font-semibold text-[var(--text-primary)] truncate">{selectedService.name}</h3>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<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);
|
||||
@@ -88,11 +90,15 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
||||
<div
|
||||
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}1c`,
|
||||
color: selected ? 'var(--bg-void)' : typeColor,
|
||||
background: selected && !brand ? typeColor : `${typeColor}1c`,
|
||||
color: selected && !brand ? 'var(--bg-void)' : typeColor,
|
||||
}}
|
||||
>
|
||||
{isTransient ? <Loader2 size={16} className="animate-spin" /> : serviceTypeIcon(service.type, 16)}
|
||||
{isTransient ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<ServiceIcon service={service} size={17} />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="font-semibold text-[13px] leading-tight text-[var(--text-primary)] truncate tracking-tight">
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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<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',
|
||||
},
|
||||
@@ -68,9 +70,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',
|
||||
},
|
||||
@@ -81,9 +85,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',
|
||||
},
|
||||
@@ -94,12 +99,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': [
|
||||
{
|
||||
@@ -109,9 +129,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',
|
||||
},
|
||||
@@ -122,9 +144,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',
|
||||
},
|
||||
@@ -135,9 +158,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',
|
||||
},
|
||||
@@ -150,9 +174,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',
|
||||
},
|
||||
@@ -163,7 +188,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',
|
||||
@@ -195,6 +220,7 @@ export const demoVariablesByProject: Record<string, Record<string, ServiceVariab
|
||||
] satisfies ServiceVariable[]
|
||||
),
|
||||
'demo-svc-postgres': [],
|
||||
'demo-svc-redis': [],
|
||||
},
|
||||
'demo-project-growth': {
|
||||
'demo-growth-site': (
|
||||
|
||||
Reference in new issue
Block a user