mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat(services): clone/move ops + owner-scoped registry credentials (C4)
Service lifecycle: POST /services/:id/clone duplicates config, volumes, domains, access gates, builder settings, and variables (secret ciphertext copied verbatim); POST /services/:id/move re-parents to another owned project. Both enforce ownership, reject name conflicts, and audit. Registries: CRUD at /registries — host-matched pull credentials for prebuilt-image deploys, encrypted at rest via secrets pkg, never returned (has_password flag). Empty password on update keeps the stored credential; host normalized (scheme/case/trailing slash). Credentials thread through BuildConfig → BuildRequest → pull auth config, used only for the pull. Surfaces: CLI services clone|move + registries group, 7 MCP tools, OpenAPI paths/schemas, frontend Lifecycle card (clone/move/delete) and Settings registries section, llms.txt + CLI.md. 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
912f6f5142
commit
3664eaed1a
20 files changed
+1591
-13
No files matched your search
@@ -20,6 +20,10 @@ import {
|
||||
createUserToken,
|
||||
listUserTokens,
|
||||
revokeUserToken,
|
||||
listRegistries,
|
||||
createRegistry,
|
||||
deleteRegistry,
|
||||
type Registry,
|
||||
listDatabases,
|
||||
databaseAction,
|
||||
updateDatabaseBackupSchedule,
|
||||
@@ -1430,6 +1434,135 @@ function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
function RegistriesSection() {
|
||||
const queryClient = useQueryClient();
|
||||
const registriesQuery = useQuery({
|
||||
queryKey: ['registries'],
|
||||
queryFn: listRegistries,
|
||||
});
|
||||
const [name, setName] = useState('');
|
||||
const [host, setHost] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createRegistry({
|
||||
name: name.trim(),
|
||||
host: host.trim(),
|
||||
username: username.trim() || undefined,
|
||||
password: password || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setName('');
|
||||
setHost('');
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
queryClient.invalidateQueries({ queryKey: ['registries'] });
|
||||
},
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteRegistry(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['registries'] }),
|
||||
});
|
||||
|
||||
const registries = registriesQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<section className="panel p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||
<Archive size={18} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Registry credentials</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Pull private images on deploy - matched by image host, stored encrypted</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Name (e.g. ghcr)"
|
||||
className="h-9 flex-1 min-w-[120px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
|
||||
/>
|
||||
<input
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
placeholder="Host (ghcr.io, docker.io)"
|
||||
className="h-9 flex-1 min-w-[160px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
|
||||
/>
|
||||
<input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Username"
|
||||
className="h-9 w-40 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Password / token"
|
||||
className="h-9 w-44 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
|
||||
/>
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={createMutation.isPending || !name.trim() || !host.trim()}
|
||||
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
{createMutation.isPending ? 'Adding…' : 'Add registry'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{createMutation.isError ? (
|
||||
<p className="text-xs text-[var(--error)]">
|
||||
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to add registry'}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{registriesQuery.isLoading ? (
|
||||
<div className="py-4 text-center">
|
||||
<Loader2 size={16} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
) : registries.length === 0 ? (
|
||||
<p className="text-xs text-[var(--text-tertiary)]">
|
||||
No credentials. Image pulls are anonymous unless a registry matching the image host exists here.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1.5 border-t border-[var(--border-subtle)] pt-3">
|
||||
{registries.map((r: Registry) => (
|
||||
<div key={r.id} className="flex items-center justify-between gap-2 text-xs">
|
||||
<div className="min-w-0">
|
||||
<span className="text-[var(--text-primary)]">{r.name}</span>
|
||||
<span className="ml-2 mono text-[var(--text-muted)]">{r.host}</span>
|
||||
{r.username ? <span className="ml-2 text-[var(--text-secondary)]">{r.username}</span> : null}
|
||||
<span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">
|
||||
{r.has_password ? 'password set' : 'anonymous'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm(`Remove credentials for ${r.host}?`)) {
|
||||
deleteMutation.mutate(r.id);
|
||||
}
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="text-[var(--error)] hover:underline disabled:opacity-50 shrink-0"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -1603,6 +1736,7 @@ export function SettingsPage() {
|
||||
|
||||
{/* Personal Access Tokens */}
|
||||
<UserTokensSection isAdmin={profileQuery.data?.isAdmin ?? false} />
|
||||
<RegistriesSection />
|
||||
|
||||
{/* Platform Settings - owner only */}
|
||||
{profileQuery.data?.isAdmin ? <PlatformSettingsSection /> : null}
|
||||
|
||||
@@ -43,6 +43,9 @@ import {
|
||||
removeServiceDomain,
|
||||
setDefaultServiceDomain,
|
||||
checkServiceDomains,
|
||||
cloneService,
|
||||
moveService,
|
||||
listProjects,
|
||||
} from '@/lib/api-client';
|
||||
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
@@ -412,6 +415,36 @@ export function ServiceDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const projectsQuery = useQuery({
|
||||
queryKey: ['projects'],
|
||||
queryFn: () => listProjects({ limit: 100 }),
|
||||
enabled: !isDemoMode && activeSection === 'settings',
|
||||
});
|
||||
|
||||
const cloneMutation = useMutation({
|
||||
mutationFn: (input: { name?: string; project_id?: string }) => cloneService(serviceId, input),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['project-services'] });
|
||||
const targetProject = lifecycleForm?.cloneProject || projectId;
|
||||
navigate(`/projects/${targetProject}/services/${data.service_id}`);
|
||||
},
|
||||
});
|
||||
|
||||
const moveMutation = useMutation({
|
||||
mutationFn: (targetProjectId: string) => moveService(serviceId, targetProjectId),
|
||||
onSuccess: (_data, targetProjectId) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['project-services'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
navigate(`/projects/${targetProjectId}/services/${serviceId}`);
|
||||
},
|
||||
});
|
||||
|
||||
const [lifecycleForm, setLifecycleForm] = useState<{
|
||||
cloneName: string;
|
||||
cloneProject: string;
|
||||
moveTarget: string;
|
||||
} | null>(null);
|
||||
|
||||
const invalidateService = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['service', serviceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['service-runtime', serviceId] });
|
||||
@@ -2632,6 +2665,135 @@ export function ServiceDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isDemoMode && signedIn && (
|
||||
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-medium text-[var(--text-primary)]">Lifecycle</h3>
|
||||
{lifecycleForm === null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLifecycleForm({ cloneName: `${service.name}-copy`, cloneProject: '', moveTarget: '' })}
|
||||
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
|
||||
>
|
||||
Manage
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lifecycleForm === null ? (
|
||||
<p className="text-xs text-[var(--text-tertiary)]">
|
||||
Duplicate this service into the same or another project, move it between projects, or delete it.
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Clone</label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
value={lifecycleForm.cloneName}
|
||||
onChange={(e) => setLifecycleForm({ ...lifecycleForm, cloneName: e.target.value })}
|
||||
placeholder={`${service.name}-copy`}
|
||||
className="w-52 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
|
||||
/>
|
||||
<select
|
||||
value={lifecycleForm.cloneProject}
|
||||
onChange={(e) => setLifecycleForm({ ...lifecycleForm, cloneProject: e.target.value })}
|
||||
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||
>
|
||||
<option value="">same project</option>
|
||||
{(projectsQuery.data ?? [])
|
||||
.filter((p) => p.id !== projectId)
|
||||
.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={cloneMutation.isPending}
|
||||
onClick={() =>
|
||||
cloneMutation.mutate({
|
||||
name: lifecycleForm.cloneName.trim() || undefined,
|
||||
project_id: lifecycleForm.cloneProject || undefined,
|
||||
})
|
||||
}
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{cloneMutation.isPending ? 'Cloning…' : 'Clone'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] text-[var(--text-tertiary)]">
|
||||
Copies config, volumes, domains, and variables. The clone starts stopped.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Move to project</label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={lifecycleForm.moveTarget}
|
||||
onChange={(e) => setLifecycleForm({ ...lifecycleForm, moveTarget: e.target.value })}
|
||||
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||
>
|
||||
<option value="">choose a project…</option>
|
||||
{(projectsQuery.data ?? [])
|
||||
.filter((p) => p.id !== projectId)
|
||||
.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={moveMutation.isPending || !lifecycleForm.moveTarget}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Move ${service.name} to another project?`)) {
|
||||
moveMutation.mutate(lifecycleForm.moveTarget);
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{moveMutation.isPending ? 'Moving…' : 'Move'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] text-[var(--text-tertiary)]">
|
||||
Variables, domains, and volumes move with the service. Running containers keep running.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-[var(--border-subtle)]">
|
||||
<label className="block text-xs text-[var(--error)] mb-1">Delete service</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={deleteServiceMutation.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete ${service.name}? This removes its containers and cannot be undone.`)) {
|
||||
deleteServiceMutation.mutate();
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--error-soft)] text-[var(--error)] text-sm font-medium hover:bg-[var(--error-soft)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{deleteServiceMutation.isPending ? 'Deleting…' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLifecycleForm(null)}
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
{(cloneMutation.isError || moveMutation.isError) && (
|
||||
<p className="text-xs text-[var(--error)]">
|
||||
{((cloneMutation.error ?? moveMutation.error) as Error)?.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user