Overhaul: real metrics, Git integration UI, open-source hygiene, root cleanup

Backend:
- GET /services/:id/metrics with real Docker container stats
- DELETE /git/providers/:id and GET repo branches endpoint

Frontend:
- Service detail shows real telemetry or honest empty states
- Live status distinguishes idle/polling/reconnecting
- Git providers section in Settings; repo+branch pickers in Add Service
- Real logo, compact Projects header, canvas fitView fix
- index.css deduplicated (1725 -> 1384 lines)
- Fixed smoke tests, lint errors, ToastProvider/context unification

Repo hygiene:
- Added LICENSE (MIT), CONTRIBUTING.md, SECURITY.md; rewrote README
- Untracked .env.prod and data/letsencrypt secrets; fixed .gitignore
- Removed legacy root src/internal/cmd and stale configs/docs/artifacts
- Moved guides and design references under docs/

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-15 18:59:40 +02:00
1 parent 355a97bab4
commit 9f3b196032
499 files changed
+4471 -94235

No files matched your search

@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import {
@@ -7,21 +7,18 @@ import {
getProjectById,
getDeploymentLogs,
getServiceById,
getServiceMetrics,
listDeployments,
listServiceLogs,
rollbackDeployment,
} from '@/lib/api-client';
import { getDemoProjectById, getDemoServiceById } from '@/lib/demo-data';
import { formatDate, formatRelative, seededMetric } from '@/lib/time';
import { formatBytes, formatDate, formatRelative, seededMetric } from '@/lib/time';
import {
ArrowLeft,
Activity,
FileText,
Settings,
Sliders,
Play,
RotateCw,
Trash2,
Check,
X,
Loader2,
@@ -171,23 +168,50 @@ export function ServiceDetailPage() {
const project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data;
const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data;
const metricsQuery = useQuery({
queryKey: ['service-metrics', serviceId],
queryFn: () => getServiceMetrics(serviceId),
enabled: !isDemoMode && Boolean(serviceId),
refetchInterval: 5000,
});
// Accumulate live samples into a small history for the timeline charts.
const [metricHistory, setMetricHistory] = useState<{ cpu: number[]; memory: number[] }>({ cpu: [], memory: [] });
useEffect(() => {
const sample = metricsQuery.data;
if (!sample || sample.status !== 'ok') {
return;
}
const memPercent = sample.memory_limit_bytes > 0
? (sample.memory_usage_bytes / sample.memory_limit_bytes) * 100
: 0;
// eslint-disable-next-line react-hooks/set-state-in-effect -- accumulating polled samples is a legitimate sync-to-external pattern
setMetricHistory((prev) => ({
cpu: [...prev.cpu, Math.min(100, sample.cpu_percent)].slice(-24),
memory: [...prev.memory, Math.min(100, memPercent)].slice(-24),
}));
}, [metricsQuery.data]);
const liveMetrics = isDemoMode ? null : metricsQuery.data ?? null;
const hasLiveTelemetry = Boolean(liveMetrics && liveMetrics.status === 'ok' && liveMetrics.instances.length > 0);
const runningInstances = liveMetrics?.instances.filter((instance) => instance.state === 'running').length ?? 0;
const memoryPercent = liveMetrics && liveMetrics.memory_limit_bytes > 0
? (liveMetrics.memory_usage_bytes / liveMetrics.memory_limit_bytes) * 100
: 0;
// Demo mode renders seeded values so the layout can be previewed without a backend.
const metricSet = useMemo(() => {
if (!service) {
if (!service || !isDemoMode) {
return null;
}
const cpu = seededMetric(`${service.id}:cpu`, 12, 78);
const memory = seededMetric(`${service.id}:mem`, 24, 91);
const req = seededMetric(`${service.id}:req`, 120, 5100);
const latency = seededMetric(`${service.id}:lat`, 17, 210);
return {
cpu,
memory,
req,
latency,
cpu: seededMetric(`${service.id}:cpu`, 12, 78),
memory: seededMetric(`${service.id}:mem`, 24, 91),
req: seededMetric(`${service.id}:req`, 120, 5100),
latency: seededMetric(`${service.id}:lat`, 17, 210),
};
}, [service]);
}, [service, isDemoMode]);
const deploymentSummary = useMemo(() => {
const deployments = isDemoMode ? [] : deploymentsQuery.data ?? [];
@@ -372,30 +396,29 @@ export function ServiceDetailPage() {
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard
label="CPU"
value={`${metricSet?.cpu ?? 0}%`}
hint="Current utilization"
value={isDemoMode ? `${metricSet?.cpu ?? 0}%` : hasLiveTelemetry ? `${liveMetrics!.cpu_percent.toFixed(1)}%` : '—'}
hint={isDemoMode ? 'Current utilization' : hasLiveTelemetry ? `${runningInstances} running container${runningInstances === 1 ? '' : 's'}` : 'No running containers'}
icon={Cpu}
trend={metricSet && metricSet.cpu > 60 ? 'up' : metricSet && metricSet.cpu < 30 ? 'down' : 'stable'}
trend={hasLiveTelemetry && liveMetrics!.cpu_percent > 60 ? 'up' : 'stable'}
/>
<MetricCard
label="Memory"
value={`${metricSet?.memory ?? 0}%`}
hint="Container footprint"
value={isDemoMode ? `${metricSet?.memory ?? 0}%` : hasLiveTelemetry ? formatBytes(liveMetrics!.memory_usage_bytes) : '—'}
hint={isDemoMode ? 'Container footprint' : hasLiveTelemetry ? `${memoryPercent.toFixed(0)}% of ${formatBytes(liveMetrics!.memory_limit_bytes)}` : 'No running containers'}
icon={MemoryStick}
trend={metricSet && metricSet.memory > 70 ? 'up' : 'stable'}
trend={hasLiveTelemetry && memoryPercent > 70 ? 'up' : 'stable'}
/>
<MetricCard
label="Requests"
value={`${metricSet?.req ?? 0}`}
hint="Last 60 minutes"
label="Network"
value={isDemoMode ? `${metricSet?.req ?? 0}` : hasLiveTelemetry ? `${formatBytes(liveMetrics!.network_rx_bytes)}` : '—'}
hint={isDemoMode ? 'Requests, last 60 minutes' : hasLiveTelemetry ? `RX total · TX ${formatBytes(liveMetrics!.network_tx_bytes)}` : 'No traffic recorded'}
icon={Zap}
/>
<MetricCard
label="Latency"
value={`${metricSet?.latency ?? 0}ms`}
hint="P95 estimate"
label="Instances"
value={isDemoMode ? `${metricSet?.latency ?? 0}ms` : hasLiveTelemetry ? `${runningInstances}` : '0'}
hint={isDemoMode ? 'P95 latency estimate' : hasLiveTelemetry ? `${liveMetrics!.instances.length} container${liveMetrics!.instances.length === 1 ? '' : 's'} discovered` : 'Deploy to create containers'}
icon={Timer}
trend={metricSet && metricSet.latency > 150 ? 'up' : 'stable'}
/>
</div>
@@ -458,6 +481,20 @@ export function ServiceDetailPage() {
{activeSection === 'metrics' && (
<div className="space-y-6">
{/* Metrics Grid with enhanced cards */}
{!isDemoMode && !hasLiveTelemetry && (
<div className="panel p-8 text-center">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--surface-muted)] flex items-center justify-center">
<Activity size={22} className="text-[var(--text-tertiary)]" />
</div>
<p className="text-base font-medium text-[var(--text-primary)]">No telemetry yet</p>
<p className="mt-2 text-sm text-[var(--text-secondary)]">
{liveMetrics?.status === 'docker_unavailable'
? 'Docker is not reachable on this host, so container metrics cannot be collected.'
: 'Metrics appear once this service has running containers. Deploy it to start collecting.'}
</p>
</div>
)}
{(isDemoMode || hasLiveTelemetry) && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="panel p-5 group hover:border-[var(--accent-primary)]/30 transition-all duration-300">
<div className="flex items-center justify-between mb-4">
@@ -467,28 +504,36 @@ export function ServiceDetailPage() {
</div>
<div>
<p className="text-sm font-medium text-[var(--text-primary)]">CPU Timeline</p>
<p className="text-xs text-[var(--text-tertiary)]">Last 24 intervals</p>
<p className="text-xs text-[var(--text-tertiary)]">
{isDemoMode ? 'Last 24 intervals' : 'Live samples · every 5s'}
</p>
</div>
</div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-xs text-[var(--text-tertiary)]">Click to expand</span>
</div>
</div>
<div className="flex h-32 items-end gap-1">
{Array.from({ length: 24 }).map((_, i) => {
const value = seededMetric(`${service.id}:cpu:${i}`, 8, 80);
return (
<div
key={i}
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80 cursor-pointer group/bar"
style={{
height: `${value}%`,
background: '#ff7043',
}}
title={`${value}%`}
/>
);
})}
{isDemoMode
? Array.from({ length: 24 }).map((_, i) => {
const value = seededMetric(`${service.id}:cpu:${i}`, 8, 80);
return (
<div
key={i}
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80 cursor-pointer"
style={{ height: `${value}%`, background: '#ff7043' }}
title={`${value}%`}
/>
);
})
: metricHistory.cpu.map((value, i) => (
<div
key={i}
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80"
style={{ height: `${Math.max(3, value)}%`, background: '#ff7043' }}
title={`${value.toFixed(1)}%`}
/>
))}
{!isDemoMode && metricHistory.cpu.length === 0 && (
<p className="self-center text-xs text-[var(--text-muted)]">Collecting first sample...</p>
)}
</div>
</div>
@@ -496,32 +541,77 @@ export function ServiceDetailPage() {
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="card-icon">
<Zap size={18} />
<MemoryStick size={18} />
</div>
<div>
<p className="text-sm font-medium text-[var(--text-primary)]">Request Timeline</p>
<p className="text-xs text-[var(--text-tertiary)]">Last 24 intervals</p>
<p className="text-sm font-medium text-[var(--text-primary)]">Memory Timeline</p>
<p className="text-xs text-[var(--text-tertiary)]">
{isDemoMode ? 'Last 24 intervals' : 'Live samples · every 5s'}
</p>
</div>
</div>
</div>
<div className="flex h-32 items-end gap-1">
{Array.from({ length: 24 }).map((_, i) => {
const value = seededMetric(`${service.id}:req:${i}`, 18, 96);
return (
<div
key={i}
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80 cursor-pointer"
style={{
height: `${value}%`,
background: '#3dd68c',
}}
title={`${value}%`}
/>
);
})}
{isDemoMode
? Array.from({ length: 24 }).map((_, i) => {
const value = seededMetric(`${service.id}:req:${i}`, 18, 96);
return (
<div
key={i}
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80 cursor-pointer"
style={{ height: `${value}%`, background: '#3dd68c' }}
title={`${value}%`}
/>
);
})
: metricHistory.memory.map((value, i) => (
<div
key={i}
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80"
style={{ height: `${Math.max(3, value)}%`, background: '#3dd68c' }}
title={`${value.toFixed(1)}%`}
/>
))}
{!isDemoMode && metricHistory.memory.length === 0 && (
<p className="self-center text-xs text-[var(--text-muted)]">Collecting first sample...</p>
)}
</div>
</div>
</div>
)}
{/* Instance list — real container states */}
{!isDemoMode && hasLiveTelemetry && (
<div className="panel p-5">
<div className="flex items-center gap-3 mb-4">
<div className="card-icon">
<Layers size={18} />
</div>
<div>
<p className="text-sm font-medium text-[var(--text-primary)]">Instances</p>
<p className="text-xs text-[var(--text-tertiary)]">Containers backing this service</p>
</div>
</div>
<div className="space-y-2">
{liveMetrics!.instances.map((instance) => (
<div
key={instance.container_id}
className="flex items-center justify-between p-3 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)]"
>
<div className="flex items-center gap-3 min-w-0">
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${instance.state === 'running' ? 'bg-[var(--success)]' : 'bg-[var(--text-muted)]'}`} />
<span className="mono text-xs text-[var(--text-primary)] truncate">{instance.name}</span>
</div>
<div className="flex items-center gap-4 text-xs text-[var(--text-tertiary)] flex-shrink-0">
<span className="mono">{instance.cpu_percent.toFixed(1)}% CPU</span>
<span className="mono">{formatBytes(instance.memory_usage_bytes)}</span>
<span>{instance.started_at ? `up ${formatRelative(instance.started_at).replace(' ago', '')}` : instance.state}</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Domain & Networking Panel */}
{service.status === 'running' && (
@@ -1,25 +1,21 @@
import { useMemo, useState, useEffect } from 'react';
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
import {
Cpu,
MemoryStick,
Zap,
Users,
import { useParams, useNavigate } from 'react-router-dom';
import {
Cpu,
MemoryStick,
Zap,
Users,
Globe,
ArrowLeft,
Search,
Bell,
ChevronUp,
Activity,
FileText,
Sliders,
Settings as SettingsIcon
} from 'lucide-react';
import {
LineChart,
LineAreaChart,
DonutChart,
MultiLineChart,
import {
LineChart,
LineAreaChart,
DonutChart,
EnhancedMetricCard,
CacheMetricCard,
PerformanceMetricCard
@@ -49,7 +45,6 @@ const timePeriods = ['Day', 'Month', 'Year'];
export function ServiceMetricsDashboard() {
const { serviceId = 'demo-service' } = useParams();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<TabKey>('metrics');
@@ -86,10 +81,10 @@ export function ServiceMetricsDashboard() {
{ data: perfData1, color: '#6c8ef0', fillOpacity: 0.15 },
{ data: perfData2, color: '#9c7ef0', fillOpacity: 0.15 }
],
upSpeed: (Math.random() * 5 + 8).toFixed(1),
downSpeed: (Math.random() * 3 + 4).toFixed(1),
upSpeed: seededRandom(`${serviceId}:upspeed`, 8, 13).toFixed(1),
downSpeed: seededRandom(`${serviceId}:downspeed`, 4, 7).toFixed(1),
};
}, [serviceId, timePeriod]);
}, [serviceId]);
// Auto-update metrics every 2 seconds
useEffect(() => {