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

@@ -14,11 +14,12 @@ import '@xyflow/react/dist/style.css';
import type { ServiceEntity } from '@/lib/api-client';
import { inferAutoConnections, type ServiceVariable } from '../auto-connections';
import {
DEFAULT_VIEWPORT,
type CanvasGroup,
type CanvasNodeLayout,
type ProjectCanvasMetadata,
} from '../model';
import { loadCanvasMetadata, saveCanvasMetadata } from '../storage';
import { canvasStorageKey, loadCanvasMetadata, saveCanvasMetadata } from '../storage';
import { GroupNode, ServiceNode, type GroupNodeData, type ServiceNodeData } from './nodes';
import { Plus, Layers, Maximize2, RotateCcw, Box, Link2 } from 'lucide-react';
@@ -152,6 +153,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
const wrapperRef = useRef<HTMLDivElement | null>(null);
const persistTimeout = useRef<number | null>(null);
const hydratedRef = useRef(false);
const viewportRestoredRef = useRef(false);
const [nodes, setNodes, onNodesChange] = useNodesState<CanvasNode>([]);
const [selectedServiceId, setSelectedServiceId] = useState<string | null>(null);
const [viewportTick, setViewportTick] = useState(0);
@@ -169,20 +171,34 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
const edges = useMemo(() => toFlowEdges(inferredLinks), [inferredLinks]);
useEffect(() => {
const hasStoredViewport = localStorage.getItem(canvasStorageKey(projectId)) !== null;
const metadata = loadCanvasMetadata(projectId, services);
const nextNodes = toFlowNodes(metadata, services, onOpenService);
viewportRestoredRef.current = false;
setNodes(nextNodes);
window.requestAnimationFrame(() => {
setViewport(metadata.viewport, { duration: 120 });
window.requestAnimationFrame(() => {
const isDefaultViewport =
metadata.viewport.x === DEFAULT_VIEWPORT.x &&
metadata.viewport.y === DEFAULT_VIEWPORT.y &&
metadata.viewport.zoom === DEFAULT_VIEWPORT.zoom;
if (!hasStoredViewport || isDefaultViewport) {
void fitView({ padding: 0.15, duration: 120 });
} else {
void setViewport(metadata.viewport, { duration: 120 });
}
viewportRestoredRef.current = true;
});
});
hydratedRef.current = true;
}, [projectId, serviceFingerprint, onOpenService, setNodes, setViewport, services]);
}, [projectId, serviceFingerprint, onOpenService, setNodes, setViewport, fitView, services]);
useEffect(() => {
if (!hydratedRef.current) {
if (!hydratedRef.current || !viewportRestoredRef.current) {
return;
}
@@ -414,7 +430,6 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
}}
onNodeDragStop={onNodeDragStop}
onMoveEnd={() => setViewportTick((value) => value + 1)}
fitView
panOnDrag
zoomOnScroll
minZoom={0.25}
@@ -4,6 +4,9 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import {
createService,
getProjectById,
listGitBranches,
listGitProviders,
listGitRepositories,
listServiceLogs,
listServiceVariables,
listServicesByProject,
@@ -31,6 +34,8 @@ import {
Sparkles,
Box,
Search,
GitBranch,
Container,
} from 'lucide-react';
type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'settings';
@@ -59,6 +64,44 @@ function ServiceCreateDialog(props: {
image: '',
command: '',
});
const [source, setSource] = useState<'image' | 'git'>('image');
const [providerId, setProviderId] = useState('');
const [repoFullName, setRepoFullName] = useState('');
const [repoSearch, setRepoSearch] = useState('');
const providersQuery = useQuery({
queryKey: ['git-providers'],
queryFn: listGitProviders,
enabled: props.open && source === 'git',
});
const repositoriesQuery = useQuery({
queryKey: ['git-repositories', providerId, repoSearch],
queryFn: () => listGitRepositories(providerId, repoSearch),
enabled: props.open && source === 'git' && Boolean(providerId),
});
const selectedRepo = useMemo(
() => (repositoriesQuery.data ?? []).find((repo) => repo.full_name === repoFullName) ?? null,
[repositoriesQuery.data, repoFullName],
);
const branchesQuery = useQuery({
queryKey: ['git-branches', providerId, repoFullName],
queryFn: () => {
const [owner, repo] = repoFullName.split('/');
return listGitBranches(providerId, owner, repo);
},
enabled: props.open && source === 'git' && Boolean(providerId) && Boolean(repoFullName),
});
const providers = providersQuery.data ?? [];
const repositories = repositoriesQuery.data ?? [];
const branches = branchesQuery.data ?? [];
const canSubmit =
Boolean(form.name.trim()) &&
(source === 'image' || Boolean(repoFullName));
if (!props.open) return null;
@@ -122,16 +165,169 @@ function ServiceCreateDialog(props: {
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Image <span className="normal-case text-[var(--text-muted)]">(optional)</span>
Source
</label>
<input
value={form.image ?? ''}
onChange={(e) => setForm((p) => ({ ...p, image: e.target.value }))}
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all mono text-sm"
placeholder="ghcr.io/org/app:latest"
/>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setSource('image')}
className={`flex items-center justify-center gap-2 h-10 rounded-[var(--radius-md)] border text-sm font-medium transition-colors ${
source === 'image'
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] text-[var(--text-primary)]'
: 'border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-tertiary)] hover:border-[var(--border-default)]'
}`}
>
<Container size={14} />
Image
</button>
<button
type="button"
onClick={() => setSource('git')}
className={`flex items-center justify-center gap-2 h-10 rounded-[var(--radius-md)] border text-sm font-medium transition-colors ${
source === 'git'
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] text-[var(--text-primary)]'
: 'border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-tertiary)] hover:border-[var(--border-default)]'
}`}
>
<GitBranch size={14} />
Git Repository
</button>
</div>
</div>
{source === 'image' ? (
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Image <span className="normal-case text-[var(--text-muted)]">(optional)</span>
</label>
<input
value={form.image ?? ''}
onChange={(e) => setForm((p) => ({ ...p, image: e.target.value }))}
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all mono text-sm"
placeholder="ghcr.io/org/app:latest"
/>
</div>
) : (
<div className="space-y-3 p-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/50">
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Provider
</label>
<select
value={providerId}
onChange={(e) => {
setProviderId(e.target.value);
setRepoFullName('');
setForm((p) => ({ ...p, git_repo: '', git_branch: '' }));
}}
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
>
<option value="">Select provider</option>
{providers.map((provider) => (
<option key={provider.id} value={provider.id}>{provider.display_name}</option>
))}
</select>
{!providersQuery.isLoading && providers.length === 0 && (
<p className="text-xs text-[var(--warn)] mt-1.5">
No providers connected — add one in Settings → Git Providers
</p>
)}
</div>
{providerId && (
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Repository
</label>
<input
value={repoSearch}
onChange={(e) => setRepoSearch(e.target.value)}
className="w-full h-9 px-3 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono mb-2"
placeholder="Search repositories..."
/>
{repositoriesQuery.isLoading ? (
<div className="py-3 text-center">
<Loader2 size={14} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
</div>
) : repositoriesQuery.isError ? (
<p className="text-xs text-[var(--error)] py-2">Failed to load repositories — check the provider token</p>
) : repositories.length === 0 ? (
<p className="text-xs text-[var(--text-muted)] py-2">No repositories found</p>
) : (
<div className="max-h-40 overflow-y-auto space-y-0.5 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] p-1">
{repositories.slice(0, 50).map((repo) => (
<button
key={repo.id}
type="button"
onClick={() => {
setRepoFullName(repo.full_name);
setForm((p) => ({
...p,
git_repo: repo.clone_url ?? repo.full_name,
git_branch: p.git_branch || repo.default_branch || '',
}));
}}
className={`w-full flex items-center justify-between px-3 py-2 rounded-[var(--radius-sm)] text-left text-sm transition-colors ${
repoFullName === repo.full_name
? 'bg-[var(--accent-primary-soft)] text-[var(--text-primary)]'
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-hover)]'
}`}
>
<span className="mono text-xs truncate">{repo.full_name}</span>
<span className="text-[10px] text-[var(--text-muted)] ml-2 shrink-0">{repo.default_branch}</span>
</button>
))}
</div>
)}
</div>
)}
{repoFullName && (
<>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Branch
</label>
{branchesQuery.isLoading ? (
<div className="py-2 text-center">
<Loader2 size={14} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
</div>
) : branches.length > 0 ? (
<select
value={form.git_branch ?? ''}
onChange={(e) => setForm((p) => ({ ...p, git_branch: e.target.value }))}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono"
>
<option value="">Select branch</option>
{branches.map((branch) => (
<option key={branch.name} value={branch.name}>{branch.name}</option>
))}
</select>
) : (
<input
value={form.git_branch ?? ''}
onChange={(e) => setForm((p) => ({ ...p, git_branch: e.target.value }))}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono"
placeholder={selectedRepo?.default_branch ?? 'main'}
/>
)}
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Build Path <span className="normal-case text-[var(--text-muted)]">(optional)</span>
</label>
<input
value={form.build_path ?? ''}
onChange={(e) => setForm((p) => ({ ...p, build_path: e.target.value }))}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono"
placeholder="/ (repo root)"
/>
</div>
</>
)}
</div>
)}
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Command <span className="normal-case text-[var(--text-muted)]">(optional)</span>
@@ -159,8 +355,18 @@ function ServiceCreateDialog(props: {
Cancel
</button>
<button
disabled={!form.name.trim() || props.loading}
onClick={() => props.onSubmit({ ...form, name: form.name.trim(), image: form.image?.trim(), command: form.command?.trim() })}
disabled={!canSubmit || props.loading}
onClick={() =>
props.onSubmit({
...form,
name: form.name.trim(),
image: source === 'image' ? form.image?.trim() : '',
command: form.command?.trim(),
git_repo: source === 'git' ? form.git_repo?.trim() : '',
git_branch: source === 'git' ? form.git_branch?.trim() : '',
build_path: source === 'git' ? form.build_path?.trim() : '',
})
}
className="px-5 py-2 rounded-[var(--radius-md)] text-white text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all"
style={{ background: '#e8316a' }}
>