mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-29 00:01:22 +00:00
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:
1 parent
355a97bab4
commit
9f3b196032
499 files changed
+4471
-94235
No files matched your search
@@ -1,16 +1,14 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link, Navigate, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertCircle, ArrowRight, Github, Gitlab, KeyRound, Loader2, Mail, Shield, User2, Workflow } from 'lucide-react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertCircle, ArrowRight, Github, Loader2, Mail, User2 } from 'lucide-react';
|
||||
import {
|
||||
AuthError,
|
||||
startBitbucketSignIn,
|
||||
startGitLabSignIn,
|
||||
requestMagicLinkInvite,
|
||||
getAuthBootstrap,
|
||||
startGoogleSignIn,
|
||||
signInWithEmail,
|
||||
signUpWithEmail,
|
||||
startGiteaSignIn,
|
||||
startGitHubSignIn,
|
||||
} from '@/lib/auth-client';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
@@ -48,7 +46,7 @@ function AuthCanvas() {
|
||||
Better Auth
|
||||
</h1>
|
||||
<p className="mt-4 max-w-md text-sm leading-relaxed text-[var(--text-secondary)]">
|
||||
Email/password, invite magic links, and OAuth providers (GitHub, GitLab, Bitbucket, Gitea) are handled by a dedicated Better Auth service with cookie sessions.
|
||||
Email/password plus GitHub and Google sign-in run through secure cookie sessions. First account bootstraps platform ownership; every later user is created from inside Containr.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -132,18 +130,24 @@ export function SignInPage() {
|
||||
const redirectPath = useMemo(() => sanitizeRedirect(searchParams.get('redirect')), [searchParams]);
|
||||
|
||||
const sessionQuery = useAuthSession();
|
||||
const bootstrapQuery = useQuery({
|
||||
queryKey: ['auth-bootstrap'],
|
||||
queryFn: getAuthBootstrap,
|
||||
});
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [magicEmail, setMagicEmail] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isMagicLoading, setIsMagicLoading] = useState(false);
|
||||
|
||||
if (sessionQuery.data) {
|
||||
return <Navigate to={redirectPath} replace />;
|
||||
}
|
||||
|
||||
if (bootstrapQuery.data?.mode === 'register') {
|
||||
return <Navigate to={`/auth/sign-up?redirect=${encodeURIComponent(redirectPath)}`} replace />;
|
||||
}
|
||||
|
||||
const submitEmailPassword = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
@@ -162,48 +166,32 @@ export function SignInPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const submitMagicLink = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const signInWithGitHubProvider = async () => {
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
setIsMagicLoading(true);
|
||||
|
||||
try {
|
||||
await requestMagicLinkInvite(magicEmail.trim(), buildOAuthCallbackURL(redirectPath));
|
||||
setInfo('Magic invite link sent. Check your email inbox.');
|
||||
await startGitHubSignIn(buildOAuthCallbackURL(redirectPath));
|
||||
} catch (exception) {
|
||||
const message = exception instanceof AuthError ? exception.message : 'Failed to send magic link';
|
||||
const message = exception instanceof AuthError ? exception.message : 'GitHub sign-in failed';
|
||||
setError(message);
|
||||
} finally {
|
||||
setIsMagicLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const signInWithGitHubProvider = async () => {
|
||||
const signInWithGoogleProvider = async () => {
|
||||
setError(null);
|
||||
await startGitHubSignIn(buildOAuthCallbackURL(redirectPath));
|
||||
};
|
||||
|
||||
const signInWithGiteaProvider = async () => {
|
||||
setError(null);
|
||||
await startGiteaSignIn(buildOAuthCallbackURL(redirectPath));
|
||||
};
|
||||
|
||||
const signInWithGitLabProvider = async () => {
|
||||
setError(null);
|
||||
await startGitLabSignIn(buildOAuthCallbackURL(redirectPath));
|
||||
};
|
||||
|
||||
const signInWithBitbucketProvider = async () => {
|
||||
setError(null);
|
||||
await startBitbucketSignIn(buildOAuthCallbackURL(redirectPath));
|
||||
try {
|
||||
await startGoogleSignIn(buildOAuthCallbackURL(redirectPath));
|
||||
} catch (exception) {
|
||||
const message = exception instanceof AuthError ? exception.message : 'Google sign-in failed';
|
||||
setError(message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<AuthCard title="Sign In" subtitle="Use your Containr account, invite magic link, or provider OAuth.">
|
||||
<AuthCard title="Sign In" subtitle="Use email/password, GitHub, or Google.">
|
||||
{error ? <AuthErrorNotice message={error} /> : null}
|
||||
{info ? <AuthInfoNotice message={info} /> : null}
|
||||
{bootstrapQuery.isLoading ? <AuthInfoNotice message="Checking platform access mode..." /> : null}
|
||||
|
||||
<form className="space-y-3" onSubmit={submitEmailPassword}>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
|
||||
@@ -256,58 +244,17 @@ export function SignInPage() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void signInWithGitLabProvider()}
|
||||
onClick={() => void signInWithGoogleProvider()}
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]"
|
||||
>
|
||||
<Gitlab size={15} />
|
||||
GitLab
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void signInWithBitbucketProvider()}
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]"
|
||||
>
|
||||
<Workflow size={15} />
|
||||
Bitbucket
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void signInWithGiteaProvider()}
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]"
|
||||
>
|
||||
<Shield size={15} />
|
||||
Gitea
|
||||
<span className="text-sm font-semibold">G</span>
|
||||
Google
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form className="mt-4 space-y-2" onSubmit={submitMagicLink}>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
|
||||
Magic Link Invite
|
||||
<input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={magicEmail}
|
||||
onChange={(event) => setMagicEmail(event.target.value)}
|
||||
required
|
||||
className="mt-2 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2.5 text-sm text-[var(--text-primary)] outline-none transition-colors focus:border-[var(--accent-primary)]"
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isMagicLoading}
|
||||
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)] disabled:opacity-60"
|
||||
>
|
||||
{isMagicLoading ? <Loader2 size={15} className="animate-spin" /> : <KeyRound size={15} />}
|
||||
Send Invite Link
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-5 flex items-center justify-between text-xs text-[var(--text-secondary)]">
|
||||
<span>No account yet?</span>
|
||||
<Link to={`/auth/sign-up?redirect=${encodeURIComponent(redirectPath)}`} className="inline-flex items-center gap-1 text-[var(--accent-primary)] hover:underline">
|
||||
Create one <ArrowRight size={12} />
|
||||
</Link>
|
||||
<span>Need access?</span>
|
||||
<span>Ask platform owner to create account.</span>
|
||||
</div>
|
||||
</AuthCard>
|
||||
</AuthLayout>
|
||||
@@ -321,6 +268,10 @@ export function SignUpPage() {
|
||||
const redirectPath = useMemo(() => sanitizeRedirect(searchParams.get('redirect')), [searchParams]);
|
||||
|
||||
const sessionQuery = useAuthSession();
|
||||
const bootstrapQuery = useQuery({
|
||||
queryKey: ['auth-bootstrap'],
|
||||
queryFn: getAuthBootstrap,
|
||||
});
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -331,6 +282,10 @@ export function SignUpPage() {
|
||||
return <Navigate to={redirectPath} replace />;
|
||||
}
|
||||
|
||||
if (bootstrapQuery.data?.mode === 'login') {
|
||||
return <Navigate to={`/auth/sign-in?redirect=${encodeURIComponent(redirectPath)}`} replace />;
|
||||
}
|
||||
|
||||
const submitSignUp = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
@@ -350,8 +305,9 @@ export function SignUpPage() {
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<AuthCard title="Create Account" subtitle="Provision your operator account with email/password auth.">
|
||||
<AuthCard title="Create Account" subtitle="Create first platform owner account. Registration closes after bootstrap.">
|
||||
{error ? <AuthErrorNotice message={error} /> : null}
|
||||
{bootstrapQuery.isLoading ? <AuthInfoNotice message="Checking platform bootstrap state..." /> : null}
|
||||
|
||||
<form className="space-y-3" onSubmit={submitSignUp}>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
|
||||
|
||||
@@ -126,8 +126,11 @@ export function BuildsPage() {
|
||||
[isDemoMode, buildsQuery.data?.builds],
|
||||
);
|
||||
|
||||
const subscribedBuildIds = useMemo(() => builds.map((build) => build.id), [builds]);
|
||||
const liveConnected = useBuildUpdates(subscribedBuildIds, ({ channel }) => {
|
||||
const subscribedBuildIds = useMemo(
|
||||
() => (isDemoMode ? [] : builds.map((build) => build.id)),
|
||||
[isDemoMode, builds],
|
||||
);
|
||||
const liveStatus = useBuildUpdates(subscribedBuildIds, ({ channel }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['builds-page'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['usage-builds'] });
|
||||
if (selectedBuild && channel === `build:${selectedBuild.id}`) {
|
||||
@@ -149,9 +152,6 @@ export function BuildsPage() {
|
||||
queryFn: () => getBuildLogs(selectedBuild!.id),
|
||||
});
|
||||
|
||||
const isCancellingBuild = (buildId: string): boolean =>
|
||||
cancelMutation.isPending && cancelMutation.variables === buildId;
|
||||
|
||||
// Stats
|
||||
const stats = useMemo(() => {
|
||||
const running = builds.filter(b => b.status === 'running' || b.status === 'pending').length;
|
||||
@@ -173,9 +173,9 @@ export function BuildsPage() {
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className={`w-2 h-2 rounded-full ${liveConnected ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
|
||||
<span className={liveConnected ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}>
|
||||
{liveConnected ? 'Live' : 'Offline'}
|
||||
<div className={`w-2 h-2 rounded-full ${liveStatus === 'live' ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
|
||||
<span className={liveStatus === 'live' ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}>
|
||||
{liveStatus === 'live' ? 'Live' : liveStatus === 'offline' ? 'Reconnecting...' : 'Polling'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -226,41 +226,36 @@ export function ProjectsPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative">
|
||||
{/* Hero Section - Premium ambient design */}
|
||||
<div className="relative overflow-hidden border-b border-[var(--border-subtle)]">
|
||||
{/* Solid ambient background */}
|
||||
<div className="absolute inset-0 bg-[#e8316a]/5" />
|
||||
<div className="absolute top-0 left-1/4 w-96 h-96 bg-[var(--accent-primary)]/5 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-0 right-1/4 w-80 h-80 bg-[var(--accent-secondary)]/5 rounded-full blur-3xl" />
|
||||
|
||||
<div className="relative mx-auto w-full max-w-[1400px] px-6 py-12 md:py-16 lg:py-20">
|
||||
<div className="max-w-2xl">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div
|
||||
className="flex items-center justify-center rounded-xl shadow-lg ring-1 ring-white/10"
|
||||
style={{ width: '44px', height: '44px', background: '#e8316a' }}
|
||||
>
|
||||
<FolderOpen size={20} className="text-white" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-[var(--success)] live-pulse" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-[var(--text-tertiary)]">
|
||||
Workspace
|
||||
</span>
|
||||
</div>
|
||||
{/* Header */}
|
||||
<div className="border-b border-[var(--border-subtle)]">
|
||||
<div className="mx-auto w-full max-w-[1400px] px-6 py-5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex items-center justify-center rounded-[var(--radius-md)]"
|
||||
style={{ width: '36px', height: '36px', background: '#e8316a' }}
|
||||
>
|
||||
<FolderOpen size={17} className="text-white" />
|
||||
</div>
|
||||
<h1 className="font-headline text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight text-[var(--text-primary)]">
|
||||
Projects
|
||||
</h1>
|
||||
<p className="mt-4 text-lg text-[var(--text-secondary)] max-w-xl leading-relaxed">
|
||||
Deploy, manage, and monitor your containerized services with visual topology mapping and real-time observability.
|
||||
</p>
|
||||
<div>
|
||||
<h1 className="font-headline text-xl font-semibold tracking-tight text-[var(--text-primary)]">
|
||||
Projects
|
||||
</h1>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">
|
||||
Deploy, manage, and monitor containerized services
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-[var(--success)] live-pulse" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-[var(--text-tertiary)]">
|
||||
Workspace
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="mx-auto w-full max-w-[1400px] px-6 py-8">
|
||||
<div className="mx-auto w-full max-w-[1400px] px-6 py-6">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-8">
|
||||
<div className="relative flex-1">
|
||||
|
||||
@@ -3,9 +3,16 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
getApiBaseUrl,
|
||||
getAgentPublicBaseUrl,
|
||||
getHostMonitoring,
|
||||
createUser,
|
||||
createGitProvider,
|
||||
deleteGitProvider,
|
||||
getCurrentUserProfile,
|
||||
listAuditLogs,
|
||||
listAgents,
|
||||
listBuilds,
|
||||
listGitProviders,
|
||||
listProjects,
|
||||
listTemplates,
|
||||
updateCurrentUserProfile,
|
||||
@@ -21,6 +28,7 @@ import {
|
||||
FileText,
|
||||
Settings,
|
||||
User,
|
||||
UserPlus,
|
||||
Key,
|
||||
Database,
|
||||
RefreshCw,
|
||||
@@ -34,6 +42,11 @@ import {
|
||||
Box,
|
||||
Terminal,
|
||||
Radio,
|
||||
Server,
|
||||
HardDrive,
|
||||
GitBranch,
|
||||
Link2,
|
||||
Unlink,
|
||||
} from 'lucide-react';
|
||||
|
||||
function SecondaryPageHeader({ title, description }: { title: string; description: string }) {
|
||||
@@ -120,15 +133,51 @@ function endpointStateBadge(isLoading: boolean, isError: boolean): {
|
||||
return { label: 'Available', toneClass: 'text-[var(--ok)]' };
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return '0 B';
|
||||
}
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let size = value;
|
||||
let unitIndex = 0;
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) {
|
||||
return '—';
|
||||
}
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
if (days > 0) {
|
||||
return `${days}d ${hours}h`;
|
||||
}
|
||||
return `${hours}h ${Math.floor((seconds % 3600) / 60)}m`;
|
||||
}
|
||||
|
||||
export function UsagePage() {
|
||||
const queryClient = useQueryClient();
|
||||
const buildsQuery = useQuery({
|
||||
queryKey: ['usage-builds'],
|
||||
queryFn: () => listBuilds({ page: 1, limit: 100 }),
|
||||
});
|
||||
const hostQuery = useQuery({
|
||||
queryKey: ['usage-host-monitoring'],
|
||||
queryFn: getHostMonitoring,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: ['usage-agents'],
|
||||
queryFn: listAgents,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
const builds = buildsQuery.data?.builds ?? [];
|
||||
const liveConnected = useBuildUpdates(
|
||||
const liveStatus = useBuildUpdates(
|
||||
builds.map((build) => build.id),
|
||||
() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['usage-builds'] });
|
||||
@@ -156,6 +205,15 @@ export function UsagePage() {
|
||||
const avgBuildMinutes = averageBuildDurationMs / 60_000;
|
||||
const activeBuildPressure = runningBuilds + pendingBuilds;
|
||||
const uniqueServices = new Set(builds.map((build) => build.serviceId).filter(Boolean)).size;
|
||||
const host = hostQuery.data;
|
||||
const agents = agentsQuery.data ?? [];
|
||||
const onlineAgents = agents.filter((agent) => agent.status === 'online' || agent.status === 'connecting').length;
|
||||
const totalAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.total, 0);
|
||||
const availableAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.available, 0);
|
||||
const agentEndpoint = getAgentPublicBaseUrl();
|
||||
const connectCommand = `CONTAINR_API_URL=${getApiBaseUrl().replace(/\/api\/v1$/, '')} \\
|
||||
CONTAINR_AGENT_AUTH_TOKEN=<token> \\
|
||||
containr-agent`;
|
||||
|
||||
let buildActivityBody = 'Track deployment frequency and failed rollouts over time.';
|
||||
let runtimeBody = 'Build duration telemetry will appear after completed builds are available.';
|
||||
@@ -197,9 +255,9 @@ export function UsagePage() {
|
||||
/>
|
||||
<div className="mx-auto w-full max-w-[1400px] px-6 py-6">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<div className={`w-2 h-2 rounded-full ${liveConnected ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
|
||||
<span className={`text-sm ${liveConnected ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}`}>
|
||||
{liveConnected ? 'Live sync active' : 'Offline'}
|
||||
<div className={`w-2 h-2 rounded-full ${liveStatus === 'live' ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
|
||||
<span className={`text-sm ${liveStatus === 'live' ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}`}>
|
||||
{liveStatus === 'live' ? 'Live sync active' : liveStatus === 'offline' ? 'Reconnecting...' : 'Polling for updates'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -216,28 +274,165 @@ export function UsagePage() {
|
||||
<p className="text-sm text-[var(--error)]">Failed to load usage data</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<StatCard
|
||||
title="Runtime Hours"
|
||||
value={totalBuildHours > 0 ? `${totalBuildHours.toFixed(1)}h` : '—'}
|
||||
description={runtimeBody}
|
||||
icon={Clock}
|
||||
color={completedDurationsMs.length > 0 ? 'success' : 'default'}
|
||||
/>
|
||||
<StatCard
|
||||
title="Build Activity"
|
||||
value={String(builds.length)}
|
||||
description={buildActivityBody}
|
||||
icon={Activity}
|
||||
color={failedBuilds > 0 ? 'warning' : 'success'}
|
||||
/>
|
||||
<StatCard
|
||||
title="Capacity"
|
||||
value={String(activeBuildPressure)}
|
||||
description={capacityBody}
|
||||
icon={Gauge}
|
||||
color={activeBuildPressure > 0 ? 'warning' : 'default'}
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<StatCard
|
||||
title="Runtime Hours"
|
||||
value={totalBuildHours > 0 ? `${totalBuildHours.toFixed(1)}h` : '—'}
|
||||
description={runtimeBody}
|
||||
icon={Clock}
|
||||
color={completedDurationsMs.length > 0 ? 'success' : 'default'}
|
||||
/>
|
||||
<StatCard
|
||||
title="Build Activity"
|
||||
value={String(builds.length)}
|
||||
description={buildActivityBody}
|
||||
icon={Activity}
|
||||
color={failedBuilds > 0 ? 'warning' : 'success'}
|
||||
/>
|
||||
<StatCard
|
||||
title="Capacity"
|
||||
value={String(activeBuildPressure)}
|
||||
description={capacityBody}
|
||||
icon={Gauge}
|
||||
color={activeBuildPressure > 0 ? 'warning' : 'default'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="panel p-6">
|
||||
<div className="mb-5 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)]">
|
||||
<Server size={18} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Host Monitoring</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Autoscaling base signal from this Containr host.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
void hostQuery.refetch();
|
||||
void agentsQuery.refetch();
|
||||
}}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-3 text-sm"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hostQuery.isError ? (
|
||||
<div className="rounded-[var(--radius-md)] bg-[var(--error-soft)] px-4 py-3 text-sm text-[var(--error)]">
|
||||
Host monitoring unavailable.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<StatCard
|
||||
title="Host"
|
||||
value={host?.hostname || '—'}
|
||||
description={host ? `${host.os}/${host.architecture} · uptime ${formatUptime(host.uptimeSeconds)}` : 'Loading host identity.'}
|
||||
icon={Server}
|
||||
color={host?.dockerAvailable ? 'success' : 'warning'}
|
||||
/>
|
||||
<StatCard
|
||||
title="CPU Load"
|
||||
value={host ? host.load.load1m.toFixed(2) : '—'}
|
||||
description={host ? `${host.cpu.cores} cores · 5m ${host.load.load5m.toFixed(2)} · 15m ${host.load.load15m.toFixed(2)}` : 'Loading CPU load.'}
|
||||
icon={Gauge}
|
||||
color={host && host.load.load1m > host.cpu.cores ? 'warning' : 'default'}
|
||||
/>
|
||||
<StatCard
|
||||
title="Memory"
|
||||
value={host ? `${host.memory.usagePercent.toFixed(0)}%` : '—'}
|
||||
description={host ? `${formatBytes(host.memory.used)} used · ${formatBytes(host.memory.available)} free` : 'Loading memory telemetry.'}
|
||||
icon={Activity}
|
||||
color={host && host.memory.usagePercent > 80 ? 'warning' : 'success'}
|
||||
/>
|
||||
<StatCard
|
||||
title="Disk"
|
||||
value={host ? `${host.storage.usagePercent.toFixed(0)}%` : '—'}
|
||||
description={host ? `${formatBytes(host.storage.used)} used · ${formatBytes(host.storage.available)} free on ${host.storage.path}` : 'Loading disk telemetry.'}
|
||||
icon={HardDrive}
|
||||
color={host && host.storage.usagePercent > 80 ? 'warning' : 'success'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="panel p-6">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-md)] bg-[var(--surface-muted)]">
|
||||
<Radio size={18} className="text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Node Agents</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Remote VPS, VM, and LXC capacity for placement and autoscaling.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_1fr]">
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<StatCard
|
||||
title="Registered"
|
||||
value={String(agents.length)}
|
||||
description={agentsQuery.isError ? 'Agent API unavailable.' : 'Nodes connected through token heartbeats.'}
|
||||
icon={Radio}
|
||||
color={agents.length > 0 ? 'success' : 'default'}
|
||||
/>
|
||||
<StatCard
|
||||
title="Online"
|
||||
value={String(onlineAgents)}
|
||||
description="Online and connecting agents can receive queued commands."
|
||||
icon={Activity}
|
||||
color={onlineAgents > 0 ? 'success' : 'default'}
|
||||
/>
|
||||
<StatCard
|
||||
title="Remote Memory"
|
||||
value={formatBytes(totalAgentMemory)}
|
||||
description={`${formatBytes(availableAgentMemory)} currently free across agents.`}
|
||||
icon={Database}
|
||||
color={totalAgentMemory > 0 ? 'success' : 'default'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-[var(--radius-md)] border border-[var(--border-subtle)]">
|
||||
{agents.length > 0 ? (
|
||||
agents.slice(0, 5).map((agent) => (
|
||||
<div key={agent.id} className="flex items-center justify-between gap-3 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-[var(--text-primary)]">{agent.name}</p>
|
||||
<p className="truncate text-xs text-[var(--text-tertiary)]">{agent.hostname} · {agent.ipAddress}:{agent.port}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs font-semibold text-[var(--text-primary)]">{agent.status}</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">{formatBytes(agent.resources.memory.available)} free</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="px-4 py-6 text-sm text-[var(--text-tertiary)]">
|
||||
No remote agents registered yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Terminal size={14} className="text-[var(--text-tertiary)]" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Connect Node</span>
|
||||
</div>
|
||||
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">{connectCommand}</pre>
|
||||
<div className="mt-4 space-y-2 text-xs text-[var(--text-tertiary)]">
|
||||
<p>Agent endpoint: <span className="mono text-[var(--text-primary)]">{agentEndpoint}</span></p>
|
||||
<p>Set <span className="mono text-[var(--text-primary)]">CONTAINR_AGENT_AUTH_TOKEN</span> to a value accepted by the backend.</p>
|
||||
<p>Agent sends host resources, polls pending Docker commands, and reports command results.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -246,10 +441,30 @@ export function UsagePage() {
|
||||
}
|
||||
|
||||
export function PeoplePage() {
|
||||
const queryClient = useQueryClient();
|
||||
const profileQuery = useQuery({
|
||||
queryKey: ['user-profile'],
|
||||
queryFn: getCurrentUserProfile,
|
||||
});
|
||||
const [newUserName, setNewUserName] = useState('');
|
||||
const [newUserEmail, setNewUserEmail] = useState('');
|
||||
const [newUserPassword, setNewUserPassword] = useState('');
|
||||
|
||||
const createUserMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createUser({
|
||||
name: newUserName.trim(),
|
||||
email: newUserEmail.trim(),
|
||||
password: newUserPassword,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setNewUserName('');
|
||||
setNewUserEmail('');
|
||||
setNewUserPassword('');
|
||||
queryClient.invalidateQueries({ queryKey: ['user-profile'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['people-audit-logs'] });
|
||||
},
|
||||
});
|
||||
|
||||
const projectsQuery = useQuery({
|
||||
queryKey: ['people-projects'],
|
||||
@@ -312,28 +527,95 @@ export function PeoplePage() {
|
||||
<p className="mt-3 text-sm text-[var(--text-muted)]">Loading team data...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<StatCard
|
||||
title="Members"
|
||||
value={profileQuery.data ? '1' : '—'}
|
||||
description={membersBody}
|
||||
icon={Users}
|
||||
color={profileQuery.data ? 'success' : 'default'}
|
||||
/>
|
||||
<StatCard
|
||||
title="Roles"
|
||||
value={projectsQuery.data?.length ? String(projectsQuery.data.length) : '—'}
|
||||
description={rolesBody}
|
||||
icon={Shield}
|
||||
color="default"
|
||||
/>
|
||||
<StatCard
|
||||
title="Audit Trail"
|
||||
value={auditLogsQuery.data ? String(auditLogsQuery.data.length) : '—'}
|
||||
description={auditBody}
|
||||
icon={FileText}
|
||||
color={auditLogsQuery.data?.length ? 'success' : 'default'}
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<StatCard
|
||||
title="Members"
|
||||
value={profileQuery.data ? '1+' : '—'}
|
||||
description={membersBody}
|
||||
icon={Users}
|
||||
color={profileQuery.data ? 'success' : 'default'}
|
||||
/>
|
||||
<StatCard
|
||||
title="Roles"
|
||||
value={projectsQuery.data?.length ? String(projectsQuery.data.length) : '—'}
|
||||
description={rolesBody}
|
||||
icon={Shield}
|
||||
color="default"
|
||||
/>
|
||||
<StatCard
|
||||
title="Audit Trail"
|
||||
value={auditLogsQuery.data ? String(auditLogsQuery.data.length) : '—'}
|
||||
description={auditBody}
|
||||
icon={FileText}
|
||||
color={auditLogsQuery.data?.length ? 'success' : 'default'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="panel p-6">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)]">
|
||||
<UserPlus size={18} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Manual User Creation</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Public registration is closed after bootstrap.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="grid gap-3 lg:grid-cols-[1fr_1fr_1fr_auto]"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
createUserMutation.mutate();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={newUserName}
|
||||
onChange={(event) => setNewUserName(event.target.value)}
|
||||
required
|
||||
className="h-10 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm"
|
||||
placeholder="Name"
|
||||
/>
|
||||
<input
|
||||
value={newUserEmail}
|
||||
onChange={(event) => setNewUserEmail(event.target.value)}
|
||||
type="email"
|
||||
required
|
||||
className="h-10 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm"
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
<input
|
||||
value={newUserPassword}
|
||||
onChange={(event) => setNewUserPassword(event.target.value)}
|
||||
type="password"
|
||||
minLength={8}
|
||||
required
|
||||
className="h-10 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm"
|
||||
placeholder="Temporary password"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createUserMutation.isPending}
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] px-4 text-sm font-semibold text-white disabled:opacity-60"
|
||||
style={{ background: '#e8316a' }}
|
||||
>
|
||||
{createUserMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <UserPlus size={14} />}
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{createUserMutation.isError ? (
|
||||
<div className="mt-4 rounded-[var(--radius-md)] bg-[var(--error-soft)] px-4 py-3 text-sm text-[var(--error)]">
|
||||
{createUserMutation.error instanceof Error ? createUserMutation.error.message : 'Failed to create user'}
|
||||
</div>
|
||||
) : null}
|
||||
{createUserMutation.isSuccess ? (
|
||||
<div className="mt-4 rounded-[var(--radius-md)] bg-[var(--success-soft)] px-4 py-3 text-sm text-[var(--success)]">
|
||||
User created. Share credentials through secure channel.
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -341,6 +623,196 @@ export function PeoplePage() {
|
||||
);
|
||||
}
|
||||
|
||||
const gitProviderTypes = [
|
||||
{ value: 'github', label: 'GitHub', tokenHint: 'Personal access token with repo scope' },
|
||||
{ value: 'gitlab', label: 'GitLab', tokenHint: 'Personal access token with read_api scope' },
|
||||
{ value: 'gitea', label: 'Gitea', tokenHint: 'Access token from your Gitea instance' },
|
||||
{ value: 'bitbucket', label: 'Bitbucket', tokenHint: 'App password with repository read' },
|
||||
] as const;
|
||||
|
||||
function GitProvidersSection() {
|
||||
const queryClient = useQueryClient();
|
||||
const providersQuery = useQuery({
|
||||
queryKey: ['git-providers'],
|
||||
queryFn: listGitProviders,
|
||||
});
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: 'github' as (typeof gitProviderTypes)[number]['value'],
|
||||
displayName: '',
|
||||
accessToken: '',
|
||||
apiUrl: '',
|
||||
});
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createGitProvider({
|
||||
name: form.name,
|
||||
display_name: form.displayName.trim() || gitProviderTypes.find((t) => t.value === form.name)!.label,
|
||||
access_token: form.accessToken.trim(),
|
||||
api_url: form.apiUrl.trim() || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['git-providers'] });
|
||||
setForm({ name: 'github', displayName: '', accessToken: '', apiUrl: '' });
|
||||
setFormOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (providerId: string) => deleteGitProvider(providerId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['git-providers'] });
|
||||
},
|
||||
});
|
||||
|
||||
const providers = providersQuery.data ?? [];
|
||||
const selectedType = gitProviderTypes.find((t) => t.value === form.name)!;
|
||||
|
||||
return (
|
||||
<section className="panel p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||
<GitBranch size={18} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Git Providers</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Connect accounts to deploy services from repositories</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setFormOpen((open) => !open)}
|
||||
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-white text-sm font-medium shadow-lg transition-all"
|
||||
style={{ background: '#e8316a' }}
|
||||
>
|
||||
<Link2 size={14} />
|
||||
{formOpen ? 'Close' : 'Connect'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{providersQuery.isLoading ? (
|
||||
<div className="py-6 text-center">
|
||||
<Loader2 size={20} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
) : providersQuery.isError ? (
|
||||
<div className="px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
|
||||
Failed to load providers
|
||||
</div>
|
||||
) : providers.length === 0 ? (
|
||||
<div className="py-6 text-center">
|
||||
<p className="text-sm text-[var(--text-secondary)]">No Git providers connected</p>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1">
|
||||
Connect GitHub, GitLab, Gitea, or Bitbucket to deploy services from source
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{providers.map((provider) => (
|
||||
<div
|
||||
key={provider.id}
|
||||
className="flex items-center justify-between p-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--surface-card)] flex items-center justify-center">
|
||||
<GitBranch size={14} className="text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{provider.display_name}</p>
|
||||
<p className="text-xs text-[var(--text-muted)] capitalize">{provider.name.replace('_', ' ')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(provider.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="flex items-center gap-2 h-8 px-3 rounded-[var(--radius-sm)] border border-[var(--error-soft)] text-[var(--error)] text-xs font-medium hover:bg-[var(--error-soft)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Unlink size={12} />
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formOpen && (
|
||||
<div className="mt-4 pt-4 border-t border-[var(--border-subtle)] space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
||||
Provider
|
||||
</label>
|
||||
<select
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value as typeof form.name }))}
|
||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
|
||||
>
|
||||
{gitProviderTypes.map((type) => (
|
||||
<option key={type.value} value={type.value}>{type.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
||||
Display Name
|
||||
</label>
|
||||
<input
|
||||
value={form.displayName}
|
||||
onChange={(e) => setForm((p) => ({ ...p, displayName: 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 focus:border-[var(--accent-primary)] transition-colors"
|
||||
placeholder={selectedType.label}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
||||
Access Token
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.accessToken}
|
||||
onChange={(e) => setForm((p) => ({ ...p, accessToken: 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 focus:border-[var(--accent-primary)] transition-colors"
|
||||
placeholder="••••••••••••••••"
|
||||
/>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1.5">{selectedType.tokenHint}</p>
|
||||
</div>
|
||||
{form.name === 'gitea' && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
||||
Instance URL
|
||||
</label>
|
||||
<input
|
||||
value={form.apiUrl}
|
||||
onChange={(e) => setForm((p) => ({ ...p, apiUrl: 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 focus:border-[var(--accent-primary)] transition-colors"
|
||||
placeholder="https://gitea.example.com"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{createMutation.isError && (
|
||||
<div className="px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
|
||||
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to connect provider'}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end pt-1">
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={!form.accessToken.trim() || createMutation.isPending}
|
||||
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-white text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||
style={{ background: '#e8316a' }}
|
||||
>
|
||||
{createMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
|
||||
Connect Provider
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -502,6 +974,9 @@ export function SettingsPage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Git Providers Section */}
|
||||
<GitProvidersSection />
|
||||
|
||||
{/* Runtime & Local State Section */}
|
||||
<section className="panel p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
@@ -701,7 +1176,7 @@ export function DocsPage() {
|
||||
<FileText size={14} className="text-[var(--text-tertiary)]" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Generated Types</span>
|
||||
</div>
|
||||
<p className="mono text-xs text-[var(--text-primary)]">frontend/src/generated/api-types.ts</p>
|
||||
<p className="mono text-xs text-[var(--text-primary)]">app/frontend/src/generated/api-types.ts</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
@@ -715,7 +1190,7 @@ export function DocsPage() {
|
||||
<FileText size={14} className="text-[var(--text-tertiary)]" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">UI References</span>
|
||||
</div>
|
||||
<p className="mono text-xs text-[var(--text-primary)]">docs/references/self.html</p>
|
||||
<p className="mono text-xs text-[var(--text-primary)]">docs/references/dashboard.png • projects.png</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -735,13 +1210,16 @@ export function DocsPage() {
|
||||
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)]">
|
||||
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">
|
||||
{`# Regenerate frontend API types
|
||||
npm --prefix frontend run generate:api
|
||||
npm --prefix app/frontend run generate:api
|
||||
|
||||
# Frontend type-check + build
|
||||
npm --prefix frontend run build:check
|
||||
npm --prefix app/frontend run build:check
|
||||
|
||||
# Backend API tests
|
||||
cd backend && go test ./internal/api/...`}
|
||||
cd app/backend && go test ./internal/api/...
|
||||
|
||||
# Build remote node agent
|
||||
cd app/backend && go build -o bin/containr-agent ./cmd/agent`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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' }}
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user