feat!: scope per-app config and settings by environment
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m27s
CI / docker (push) Successful in 1m10s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 1m40s
SonarQube / sonarqube (push) Successful in 4m29s
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m27s
CI / docker (push) Successful in 1m10s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 1m40s
SonarQube / sonarqube (push) Successful in 4m29s
BREAKING: wipe dev PostgreSQL before deploying — V1 checksum changes. Agents must now send environmentId on registration (400 if missing). Two tables previously keyed on app name alone caused cross-environment data bleed: writing config for (app=X, env=dev) would overwrite the row used by (app=X, env=prod) agents, and agent startup fetches ignored env entirely. - V1 schema: application_config and app_settings are now PK (app, env). - Repositories: env-keyed finders/saves; env is the authoritative column, stamped on the stored JSON so the row agrees with itself. - ApplicationConfigController.getConfig is dual-mode — AGENT role uses JWT env claim (agents cannot spoof env); non-agent callers provide env via ?environment= query param. - AppSettingsController endpoints now require ?environment=. - SensitiveKeysAdminController fan-out iterates (app, env) slices so each env gets its own merged keys. - DiagramController ingestion stamps env on TaggedDiagram; ClickHouse route_diagrams INSERT + findProcessorRouteMapping are env-scoped. - AgentRegistrationController: environmentId is required on register; removed all "default" fallbacks from register/refresh/heartbeat auto-heal. - UI hooks (useApplicationConfig, useProcessorRouteMapping, useAppSettings, useAllAppSettings, useUpdateAppSettings) take env, wired to useEnvironmentStore at all call sites. - New ConfigEnvIsolationIT covers env-isolation for both repositories. Plan in docs/superpowers/plans/2026-04-16-environment-scoping.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -56,11 +56,12 @@ export function useAllApplicationConfigs() {
|
||||
})
|
||||
}
|
||||
|
||||
export function useApplicationConfig(application: string | undefined) {
|
||||
export function useApplicationConfig(application: string | undefined, environment: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['applicationConfig', application],
|
||||
queryKey: ['applicationConfig', application, environment],
|
||||
queryFn: async () => {
|
||||
const res = await authFetch(`/config/${application}`)
|
||||
const envParam = environment ? `?environment=${encodeURIComponent(environment)}` : ''
|
||||
const res = await authFetch(`/config/${application}${envParam}`)
|
||||
if (!res.ok) throw new Error(`Failed to fetch config: ${res.status}`)
|
||||
const data = await res.json()
|
||||
// Server returns AppConfigResponse: { config, globalSensitiveKeys, mergedSensitiveKeys }
|
||||
@@ -69,7 +70,7 @@ export function useApplicationConfig(application: string | undefined) {
|
||||
cfg.mergedSensitiveKeys = data.mergedSensitiveKeys ?? null
|
||||
return cfg as ApplicationConfig
|
||||
},
|
||||
enabled: !!application,
|
||||
enabled: !!application && !!environment,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -100,15 +101,16 @@ export function useUpdateApplicationConfig() {
|
||||
|
||||
// ── Processor → Route Mapping ─────────────────────────────────────────────
|
||||
|
||||
export function useProcessorRouteMapping(application?: string) {
|
||||
export function useProcessorRouteMapping(application?: string, environment?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['config', application, 'processor-routes'],
|
||||
queryKey: ['config', application, environment, 'processor-routes'],
|
||||
queryFn: async () => {
|
||||
const res = await authFetch(`/config/${application}/processor-routes`)
|
||||
const res = await authFetch(
|
||||
`/config/${application}/processor-routes?environment=${encodeURIComponent(environment!)}`)
|
||||
if (!res.ok) throw new Error('Failed to fetch processor-route mapping')
|
||||
return res.json() as Promise<Record<string, string>>
|
||||
},
|
||||
enabled: !!application,
|
||||
enabled: !!application && !!environment,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +115,7 @@ export function usePunchcard(application?: string, environment?: string) {
|
||||
|
||||
export interface AppSettings {
|
||||
appId: string;
|
||||
environment: string;
|
||||
slaThresholdMs: number;
|
||||
healthErrorWarn: number;
|
||||
healthErrorCrit: number;
|
||||
@@ -124,19 +125,22 @@ export interface AppSettings {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function useAppSettings(appId?: string) {
|
||||
export function useAppSettings(appId?: string, environment?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['app-settings', appId],
|
||||
queryFn: () => fetchJson<AppSettings>(`/admin/app-settings/${appId}`),
|
||||
enabled: !!appId,
|
||||
queryKey: ['app-settings', appId, environment],
|
||||
queryFn: () => fetchJson<AppSettings>(
|
||||
`/admin/app-settings/${appId}?environment=${encodeURIComponent(environment!)}`),
|
||||
enabled: !!appId && !!environment,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAllAppSettings() {
|
||||
export function useAllAppSettings(environment?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['app-settings', 'all'],
|
||||
queryFn: () => fetchJson<AppSettings[]>('/admin/app-settings'),
|
||||
queryKey: ['app-settings', 'all', environment],
|
||||
queryFn: () => fetchJson<AppSettings[]>(
|
||||
`/admin/app-settings?environment=${encodeURIComponent(environment!)}`),
|
||||
enabled: !!environment,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
@@ -144,13 +148,15 @@ export function useAllAppSettings() {
|
||||
export function useUpdateAppSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ appId, settings }: { appId: string; settings: Omit<AppSettings, 'appId' | 'createdAt' | 'updatedAt'> }) => {
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
const res = await fetch(`${config.apiBaseUrl}/admin/app-settings/${appId}`, {
|
||||
method: 'PUT',
|
||||
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
mutationFn: async ({ appId, environment, settings }:
|
||||
{ appId: string; environment: string; settings: Omit<AppSettings, 'appId' | 'createdAt' | 'updatedAt'> }) => {
|
||||
const res = await fetch(
|
||||
`${config.apiBaseUrl}/admin/app-settings/${appId}?environment=${encodeURIComponent(environment)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to update app settings');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
@@ -77,7 +77,7 @@ export default function AppConfigDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const selectedEnv = useEnvironmentStore((s) => s.environment);
|
||||
const { data: config, isLoading } = useApplicationConfig(appId);
|
||||
const { data: config, isLoading } = useApplicationConfig(appId, selectedEnv);
|
||||
const updateConfig = useUpdateApplicationConfig();
|
||||
const { data: catalog } = useCatalog();
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@ export default function AgentHealth() {
|
||||
const { toast } = useToast();
|
||||
const selectedEnv = useEnvironmentStore((s) => s.environment);
|
||||
const { data: agents } = useAgents(undefined, appId, selectedEnv);
|
||||
const { data: appConfig } = useApplicationConfig(appId);
|
||||
const { data: appConfig } = useApplicationConfig(appId, selectedEnv);
|
||||
const updateConfig = useUpdateApplicationConfig();
|
||||
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
@@ -941,11 +941,12 @@ interface RouteRecordingRow { id: string; routeId: string; recording: boolean; }
|
||||
function ConfigSubTab({ app, environment }: { app: App; environment?: Environment }) {
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const { data: agentConfig } = useApplicationConfig(app.slug);
|
||||
const envSlug = environment?.slug;
|
||||
const { data: agentConfig } = useApplicationConfig(app.slug, envSlug);
|
||||
const updateAgentConfig = useUpdateApplicationConfig();
|
||||
const updateContainerConfig = useUpdateContainerConfig();
|
||||
const { data: catalog } = useCatalog();
|
||||
const { data: processorToRoute = {} } = useProcessorRouteMapping(app.slug);
|
||||
const { data: processorToRoute = {} } = useProcessorRouteMapping(app.slug, envSlug);
|
||||
const isProd = environment?.production ?? false;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [configTab, setConfigTab] = useState<'monitoring' | 'resources' | 'variables' | 'traces' | 'recording'>('monitoring');
|
||||
|
||||
@@ -305,7 +305,7 @@ export default function DashboardL1() {
|
||||
const { data: timeseriesByApp } = useTimeseriesByApp(timeFrom, timeTo, selectedEnv);
|
||||
const { data: topErrors } = useTopErrors(timeFrom, timeTo, undefined, undefined, selectedEnv);
|
||||
const { data: punchcardData } = usePunchcard(undefined, selectedEnv);
|
||||
const { data: allAppSettings } = useAllAppSettings();
|
||||
const { data: allAppSettings } = useAllAppSettings(selectedEnv);
|
||||
|
||||
// Build settings lookup map
|
||||
const settingsMap = useMemo(() => {
|
||||
|
||||
@@ -287,7 +287,7 @@ export default function DashboardL2() {
|
||||
const { data: timeseriesByRoute } = useTimeseriesByRoute(timeFrom, timeTo, appId, selectedEnv);
|
||||
const { data: errors } = useTopErrors(timeFrom, timeTo, appId, undefined, selectedEnv);
|
||||
const { data: punchcardData } = usePunchcard(appId, selectedEnv);
|
||||
const { data: appSettings } = useAppSettings(appId);
|
||||
const { data: appSettings } = useAppSettings(appId, selectedEnv);
|
||||
|
||||
const slaThresholdMs = appSettings?.slaThresholdMs ?? 300;
|
||||
|
||||
|
||||
@@ -261,7 +261,7 @@ export default function DashboardL3() {
|
||||
const { data: processorMetrics } = useProcessorMetrics(routeId ?? null, appId, selectedEnv);
|
||||
const { data: topErrors } = useTopErrors(timeFrom, timeTo, appId, routeId, selectedEnv);
|
||||
const { data: diagramLayout } = useDiagramByRoute(appId, routeId);
|
||||
const { data: appSettings } = useAppSettings(appId);
|
||||
const { data: appSettings } = useAppSettings(appId, selectedEnv);
|
||||
|
||||
const slaThresholdMs = appSettings?.slaThresholdMs ?? 300;
|
||||
|
||||
|
||||
@@ -209,7 +209,7 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS
|
||||
}, [catalog]);
|
||||
|
||||
// Build nodeConfigs from app config (for TRACE/TAP badges)
|
||||
const { data: appConfig } = useApplicationConfig(appId);
|
||||
const { data: appConfig } = useApplicationConfig(appId, selectedEnv);
|
||||
const nodeConfigs = useMemo(() => {
|
||||
const map = new Map<string, NodeConfig>();
|
||||
if (appConfig?.tracedProcessors) {
|
||||
|
||||
@@ -340,7 +340,7 @@ export default function RouteDetail() {
|
||||
});
|
||||
|
||||
// ── Application config ──────────────────────────────────────────────────────
|
||||
const config = useApplicationConfig(appId);
|
||||
const config = useApplicationConfig(appId, selectedEnv);
|
||||
const updateConfig = useUpdateApplicationConfig();
|
||||
const testExpressionMutation = useTestExpression();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user