mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
feat(vars): project shared variables — ${{shared.KEY}} refs, CRUD, UI
- project_variables table (secret entries encrypted at rest, masked in API)
- resolveServiceEnv resolves shared.* before service lookup
- GET|PUT /projects/{id}/variables; CLI 'variables shared list|set|unset';
MCP containr_shared_variables_{list,set}
- Settings tab → Shared Variables editor; env help text mentions shared refs
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
b22ac401c8
commit
25fa8c47e6
14 files changed
+610
-16
No files matched your search
@@ -1219,8 +1219,9 @@ export function ServiceDetailPage() {
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Environment Variables</h2>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Runtime values injected at deploy. Reference another service's variable with{' '}
|
||||
<code className="mono text-[var(--accent-primary)]">{'${{service.KEY}}'}</code> — e.g.{' '}
|
||||
<code className="mono text-[var(--accent-primary)]">{'${{postgres.POSTGRES_PASSWORD}}'}</code>
|
||||
<code className="mono text-[var(--accent-primary)]">{'${{service.KEY}}'}</code> or a project
|
||||
shared variable with{' '}
|
||||
<code className="mono text-[var(--accent-primary)]">{'${{shared.KEY}}'}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { listProjectVariables, updateProjectVariables } from '@/lib/api-client';
|
||||
import { Loader2, Plus, Save, Trash2, KeyRound } from 'lucide-react';
|
||||
|
||||
type Row = { key: string; value: string; isSecret: boolean };
|
||||
|
||||
// SharedVariablesSection edits project-level variables referenced from
|
||||
// service env values as ${{shared.KEY}}.
|
||||
export function SharedVariablesSection({ projectId }: { projectId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [drafts, setDrafts] = useState<Row[] | null>(null);
|
||||
|
||||
const varsQuery = useQuery({
|
||||
queryKey: ['project-variables', projectId],
|
||||
queryFn: () => listProjectVariables(projectId),
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (rows: Row[]) =>
|
||||
updateProjectVariables(
|
||||
projectId,
|
||||
rows
|
||||
.filter((r) => r.key.trim())
|
||||
.map((r) => ({ key: r.key.trim(), value: r.value, is_secret: r.isSecret })),
|
||||
),
|
||||
onSuccess: () => {
|
||||
setDrafts(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['project-variables', projectId] });
|
||||
},
|
||||
});
|
||||
|
||||
const rows: Row[] = drafts ?? (varsQuery.data ?? []).map((v) => ({ key: v.key, value: v.value, isSecret: v.isSecret }));
|
||||
const dirty = drafts !== null;
|
||||
|
||||
const updateRow = (i: number, patch: Partial<Row>) => {
|
||||
const next = rows.map((r, idx) => (idx === i ? { ...r, ...patch } : r));
|
||||
setDrafts(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)]">Shared Variables</h3>
|
||||
<p className="mt-1 text-xs text-[var(--text-muted)]">
|
||||
Reference from any service as{' '}
|
||||
<code className="mono text-[var(--accent-primary)]">{'${{shared.KEY}}'}</code>. Applied on next
|
||||
deploy/redeploy — not live-updated.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setDrafts([...rows, { key: '', value: '', isSecret: false }])}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
|
||||
>
|
||||
<Plus size={12} />
|
||||
Add
|
||||
</button>
|
||||
<button
|
||||
onClick={() => saveMutation.mutate(rows)}
|
||||
disabled={!dirty || saveMutation.isPending}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] text-xs font-medium text-[var(--accent-on)] disabled:opacity-50 transition-all"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
{saveMutation.isPending ? <Loader2 size={12} className="animate-spin" /> : <Save size={12} />}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{varsQuery.isLoading ? (
|
||||
<div className="flex items-center justify-center py-6 text-[var(--text-muted)]">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-xs text-[var(--text-muted)]">No shared variables.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{rows.map((row, i) => (
|
||||
<div key={i} className="grid grid-cols-[1fr_1fr_auto_auto] gap-2 items-center">
|
||||
<input
|
||||
value={row.key}
|
||||
onChange={(e) => updateRow(i, { key: e.target.value })}
|
||||
placeholder="KEY"
|
||||
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
|
||||
/>
|
||||
<input
|
||||
value={row.value}
|
||||
onChange={(e) => updateRow(i, { value: e.target.value })}
|
||||
type={row.isSecret ? 'password' : 'text'}
|
||||
placeholder="value"
|
||||
autoComplete="off"
|
||||
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
title={row.isSecret ? 'Secret — masked on save' : 'Mark as secret'}
|
||||
onClick={() => updateRow(i, { isSecret: !row.isSecret })}
|
||||
className={`w-9 h-9 rounded-[var(--radius-md)] border flex items-center justify-center transition-colors ${
|
||||
row.isSecret
|
||||
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
|
||||
: 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:border-[var(--border-default)]'
|
||||
}`}
|
||||
>
|
||||
<KeyRound size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDrafts(rows.filter((_, idx) => idx !== i))}
|
||||
className="w-9 h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] flex items-center justify-center text-[var(--text-muted)] hover:border-[var(--error)] hover:text-[var(--error)] transition-colors"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{saveMutation.isError && (
|
||||
<p className="mt-2 text-xs text-[var(--error)]">
|
||||
{saveMutation.error instanceof Error ? saveMutation.error.message : 'Failed to save shared variables'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import type { ServiceVariable } from '../auto-connections';
|
||||
import { ProjectCanvas } from '../canvas/ProjectCanvas';
|
||||
import { canvasStorageKey, clearCanvasMetadata } from '../storage';
|
||||
import { MetricsDashboard } from '../components/MetricsDashboard';
|
||||
import { SharedVariablesSection } from '../components/SharedVariablesSection';
|
||||
import { CommandPalette, StatusBadge, LiveIndicator, useToast } from '@/shared/components';
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -935,6 +936,8 @@ export function ProjectWorkspacePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isDemoMode && signedIn && <SharedVariablesSection projectId={project.id} />}
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-3">Local Data</h3>
|
||||
<button
|
||||
|
||||
@@ -3570,6 +3570,98 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/projects/{id}/variables": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* List shared project variables
|
||||
* @description Project-level variables shared across services. Services reference them as `${{shared.KEY}}` in environment values — resolved at deploy/reconcile, not live-updated. Secret values are masked.
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Variables retrieved */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
variables?: components["schemas"]["ServiceVariable"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Project not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Replace shared project variables
|
||||
* @description Replace the full shared-variable set for a project. Fetch the current list first and merge — the server replaces wholesale. Masked secret placeholders (`********`) keep the stored value.
|
||||
*/
|
||||
put: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["UpdateVariablesRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Variables updated */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
variables?: components["schemas"]["ServiceVariable"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Invalid input */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/git/github-app/install-url": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -1238,6 +1238,22 @@ export async function updateServiceVariables(
|
||||
return normalizeServiceVariables(payload.variables);
|
||||
}
|
||||
|
||||
export async function listProjectVariables(projectId: string): Promise<ServiceVariable[]> {
|
||||
const payload = await requestJson<{ variables?: RawServiceVariable[] }>(`/projects/${projectId}/variables`);
|
||||
return normalizeServiceVariables(payload.variables);
|
||||
}
|
||||
|
||||
export async function updateProjectVariables(
|
||||
projectId: string,
|
||||
variables: UpdateServiceVariableInput[],
|
||||
): Promise<ServiceVariable[]> {
|
||||
const payload = await requestJson<{ variables?: RawServiceVariable[] }>(
|
||||
`/projects/${projectId}/variables`,
|
||||
{ method: 'PUT', body: JSON.stringify({ variables }) },
|
||||
);
|
||||
return normalizeServiceVariables(payload.variables);
|
||||
}
|
||||
|
||||
export async function listAuditLogs(input: ListAuditLogsInput = {}): Promise<AuditLogEntity[]> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (input.resource) {
|
||||
|
||||
Reference in new issue
Block a user