Watch
1
0
Fork
You've already forked RedFlag
0

feat: setup accepts operator-supplied signing keypair + validation

Extract serverSetupRequest type and resolveSetupSigningKeys(): when no keys
are provided the server generates a fresh Ed25519 pair (existing behaviour);
when a private key is provided it is validated and the public key derived from
it (public key may be omitted or supplied for cross-check). Mismatched pairs
are rejected 400. Remove configure-secrets route from welcome-mode router
(was only usable with Docker socket mounted, unreachable in that mode).
Add inferPublicURL() helper to fill publicURL from X-Forwarded-* headers when
the operator omits it. pq.QuoteLiteral() used for password in ALTER USER.
Tests: generate-when-missing, use-provided-pair, reject-mismatched-pair.
This commit is contained in:
Fimeg 2026-06-10 23:18:41 -04:00
commit 2da1e92fe9
16 changed files with 849 additions and 283 deletions

View file

@ -1,31 +1,33 @@
import React, { useEffect } from 'react';
import React, { lazy, Suspense, useEffect } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { Toaster } from 'react-hot-toast';
import { useAuthStore, useUIStore } from '@/lib/store';
import { authApi } from '@/lib/api';
import ErrorBoundary from '@/components/ErrorBoundary';
import Layout from '@/components/Layout';
import Dashboard from '@/pages/Dashboard';
import Agents from '@/pages/Agents';
import Updates from '@/pages/Updates';
import PackageDetail from '@/pages/PackageDetail';
import Docker from '@/pages/Docker';
import LiveOperations from '@/pages/LiveOperations';
import History from '@/pages/History';
import Settings from '@/pages/Settings';
import TokenManagement from '@/pages/TokenManagement';
import RateLimiting from '@/pages/RateLimiting';
import AgentManagement from '@/pages/settings/AgentManagement';
import MaintenanceWindows from '@/pages/settings/MaintenanceWindows';
import UpstreamTracking from '@/pages/settings/UpstreamTracking';
import General from '@/pages/settings/General';
import AgentPolling from '@/pages/settings/AgentPolling';
import SecuritySettings from '@/pages/SecuritySettings';
import Login from '@/pages/Login';
import Setup from '@/pages/Setup';
import { WelcomeChecker } from '@/components/WelcomeChecker';
import { SetupCompletionChecker } from '@/components/SetupCompletionChecker';
const Dashboard = lazy(() => import('@/pages/Dashboard'));
const Agents = lazy(() => import('@/pages/Agents'));
const Updates = lazy(() => import('@/pages/Updates'));
const PackageDetail = lazy(() => import('@/pages/PackageDetail'));
const Docker = lazy(() => import('@/pages/Docker'));
const LiveOperations = lazy(() => import('@/pages/LiveOperations'));
const History = lazy(() => import('@/pages/History'));
const Settings = lazy(() => import('@/pages/Settings'));
const TokenManagement = lazy(() => import('@/pages/TokenManagement'));
const RateLimiting = lazy(() => import('@/pages/RateLimiting'));
const AgentManagement = lazy(() => import('@/pages/settings/AgentManagement'));
const MaintenanceWindows = lazy(() => import('@/pages/settings/MaintenanceWindows'));
const UpstreamTracking = lazy(() => import('@/pages/settings/UpstreamTracking'));
const General = lazy(() => import('@/pages/settings/General'));
const AgentPolling = lazy(() => import('@/pages/settings/AgentPolling'));
const ProcessExplorer = lazy(() => import('@/pages/settings/ProcessExplorer'));
const SecuritySettings = lazy(() => import('@/pages/SecuritySettings'));
const Login = lazy(() => import('@/pages/Login'));
const Setup = lazy(() => import('@/pages/Setup'));
// Protected route component
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuthenticated } = useAuthStore();
@ -37,6 +39,12 @@ const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) =
return <>{children}</>;
};
const RouteFallback: React.FC = () => (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-indigo-600" />
</div>
);
const App: React.FC = () => {
const { isAuthenticated, token } = useAuthStore();
const { theme } = useUIStore();
@ -108,63 +116,66 @@ const App: React.FC = () => {
{/* App routes */}
<Routes>
{/* Setup route - shown when server needs configuration */}
<Route
path="/setup"
element={
<SetupCompletionChecker>
<Setup />
</SetupCompletionChecker>
}
/>
<Suspense fallback={<RouteFallback />}>
<Routes>
{/* Setup route - shown when server needs configuration */}
<Route
path="/setup"
element={
<SetupCompletionChecker>
<Setup />
</SetupCompletionChecker>
}
/>
{/* Login route */}
<Route
path="/login"
element={isAuthenticated ? <Navigate to="/" replace /> : <Login />}
/>
{/* Login route */}
<Route
path="/login"
element={isAuthenticated ? <Navigate to="/" replace /> : <Login />}
/>
{/* Protected routes */}
<Route
path="/*"
element={
<WelcomeChecker>
<ProtectedRoute>
<Layout>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/agents" element={<Agents />} />
<Route path="/agents/:id" element={<Agents />} />
<Route path="/updates" element={<Updates />} />
<Route path="/updates/:id" element={<Updates />} />
<Route path="/updates/package/:type/:name" element={<PackageDetail />} />
<Route path="/docker" element={<Docker />} />
<Route path="/live" element={<Navigate to="/staging" replace />} />
<Route path="/staging" element={<LiveOperations />} />
<Route path="/history" element={<History />} />
<Route path="/settings" element={<Settings />} />
<Route path="/settings/general" element={<General />} />
<Route path="/settings/tokens" element={<TokenManagement />} />
<Route path="/settings/rate-limiting" element={<RateLimiting />} />
<Route path="/settings/agents" element={<AgentManagement />} />
<Route path="/settings/polling" element={<AgentPolling />} />
<Route path="/settings/security" element={<SecuritySettings />} />
<Route path="/settings/security/:tab" element={<SecuritySettings />} />
<Route path="/settings/maintenance-windows" element={<MaintenanceWindows />} />
<Route path="/settings/upstream" element={<UpstreamTracking />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>
</ProtectedRoute>
</WelcomeChecker>
}
/>
</Routes>
{/* Protected routes */}
<Route
path="/*"
element={
<WelcomeChecker>
<ProtectedRoute>
<Layout>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/agents" element={<Agents />} />
<Route path="/agents/:id" element={<Agents />} />
<Route path="/updates" element={<Updates />} />
<Route path="/updates/:id" element={<Updates />} />
<Route path="/updates/package/:type/:name" element={<PackageDetail />} />
<Route path="/docker" element={<Docker />} />
<Route path="/live" element={<Navigate to="/staging" replace />} />
<Route path="/staging" element={<LiveOperations />} />
<Route path="/history" element={<History />} />
<Route path="/settings" element={<Settings />} />
<Route path="/settings/general" element={<General />} />
<Route path="/settings/tokens" element={<TokenManagement />} />
<Route path="/settings/rate-limiting" element={<RateLimiting />} />
<Route path="/settings/agents" element={<AgentManagement />} />
<Route path="/settings/polling" element={<AgentPolling />} />
<Route path="/settings/security" element={<SecuritySettings />} />
<Route path="/settings/security/:tab" element={<SecuritySettings />} />
<Route path="/settings/maintenance-windows" element={<MaintenanceWindows />} />
<Route path="/settings/upstream" element={<UpstreamTracking />} />
<Route path="/settings/process-explorer" element={<ProcessExplorer />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>
</ProtectedRoute>
</WelcomeChecker>
}
/>
</Routes>
</Suspense>
</div>
</ErrorBoundary>
);
};
export default App;
export default App;

View file

@ -1,8 +1,8 @@
import React, { useState, useMemo } from 'react';
import { Activity, Search, RefreshCw, ArrowUpDown, Clock, Users } from 'lucide-react';
import { Activity, Search, RefreshCw, ArrowUpDown } from 'lucide-react';
import { useProcessSnapshot, useTriggerProcessScan } from '@/hooks/useProcesses';
import { ProcessDetailModal } from '@/components/ProcessDetailModal';
import type { Process, ProcessFilter } from '@/types/process';
import type { ProcessFilter } from '@/types/process';
import { cn } from '@/lib/utils';
interface ProcessesTabProps {
@ -61,7 +61,7 @@ export const ProcessesTab: React.FC<ProcessesTabProps> = ({ agentId }) => {
return `${(ms / 1000).toFixed(1)}s`;
};
const SortHeader: React.FC<{ label; col: ProcessFilter['sort_by']; className?: string }> = ({
const SortHeader: React.FC<{ label: string; col: ProcessFilter['sort_by']; className?: string }> = ({
label,
col,
className,

View file

@ -0,0 +1,72 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '../lib/api'
// Process explorer data collection caps.
// Stored server-side in the security_settings store under 'operational' category.
// Delivered to agents via the config endpoint so changes propagate fleet-wide.
export interface ProcessExplorerSettings {
max_open_files: number
max_sockets: number
max_pipes: number
max_memory_map: number
max_namespaces: number
max_env_keys: number
max_listening_ports: number
}
export const PROCESS_EXPLORER_DEFAULTS: ProcessExplorerSettings = {
max_open_files: 2000,
max_sockets: 500,
max_pipes: 500,
max_memory_map: 2000,
max_namespaces: 50,
max_env_keys: 200,
max_listening_ports: 100,
}
const toNumber = (v: unknown, fallback: number): number => {
const n = typeof v === 'string' ? parseInt(v, 10) : (v as number)
return Number.isFinite(n) && n >= 0 ? (n as number) : fallback
}
export function useProcessExplorerSettings() {
return useQuery({
queryKey: ['process-explorer-settings'],
queryFn: async (): Promise<ProcessExplorerSettings> => {
const { data } = await api.get('/security/settings')
const op = data?.settings?.operational ?? {}
return {
max_open_files: toNumber(op.process_explorer_max_open_files, PROCESS_EXPLORER_DEFAULTS.max_open_files),
max_sockets: toNumber(op.process_explorer_max_sockets, PROCESS_EXPLORER_DEFAULTS.max_sockets),
max_pipes: toNumber(op.process_explorer_max_pipes, PROCESS_EXPLORER_DEFAULTS.max_pipes),
max_memory_map: toNumber(op.process_explorer_max_memory_map, PROCESS_EXPLORER_DEFAULTS.max_memory_map),
max_namespaces: toNumber(op.process_explorer_max_namespaces, PROCESS_EXPLORER_DEFAULTS.max_namespaces),
max_env_keys: toNumber(op.process_explorer_max_env_keys, PROCESS_EXPLORER_DEFAULTS.max_env_keys),
max_listening_ports: toNumber(op.process_explorer_max_listening_ports, PROCESS_EXPLORER_DEFAULTS.max_listening_ports),
}
},
})
}
export interface ProcessExplorerUpdate {
key: keyof ProcessExplorerSettings
value: number
reason?: string
}
export function useUpdateProcessExplorerSetting() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ key, value, reason }: ProcessExplorerUpdate): Promise<void> => {
// Keys are prefixed with "process_explorer_" in the security_settings store.
await api.put(`/security/settings/operational/process_explorer_${key}`, {
value,
reason: reason || 'Updated via Process Explorer settings',
})
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['process-explorer-settings'] })
},
})
}

View file

@ -1,4 +1,5 @@
import axios, { AxiosResponse } from 'axios';
import { useAuthStore } from './store';
import {
Agent,
UpdatePackage,
@ -76,7 +77,6 @@ api.interceptors.response.use(
const url: string = error.config?.url || '';
const isAuthEndpoint = url.includes('/auth/');
if (!isAuthEndpoint) {
const { useAuthStore } = await import('./store');
useAuthStore.getState().logout();
}
}
@ -610,7 +610,10 @@ export const setupApi = {
dbPassword: string;
serverHost: string;
serverPort: string;
publicURL: string;
maxSeats: string;
signingPrivateKey?: string;
signingPublicKey?: string;
}): Promise<{ message: string; jwtSecret?: string; envContent?: string; manualRestartRequired?: boolean; manualRestartCommand?: string; configFilePath?: string }> => {
const response = await setupApiInstance.post('/setup/configure', config);
return response.data;
@ -1165,4 +1168,4 @@ export const storageMetricsApi = {
// Named export for api instance
export { api };
export default api;
export default api;

View file

@ -125,6 +125,18 @@ const Settings: React.FC = () => {
<h3 className="font-semibold text-gray-900">Upstream Tracking</h3>
<p className="text-sm text-gray-600 mt-1">Compare deployed versions to canonical upstream releases</p>
</Link>
<Link
to="/settings/process-explorer"
className="card block hover:border-cyan-300 hover:shadow-sm transition-all"
>
<div className="flex items-center justify-between mb-4">
<Activity className="w-8 h-8 text-cyan-600" />
<ArrowRight className="w-5 h-5 text-gray-400" />
</div>
<h3 className="font-semibold text-gray-900">Process Explorer</h3>
<p className="text-sm text-gray-600 mt-1">Data collection caps for process drill-down scans</p>
</Link>
</div>
{/* Overview Statistics */}

View file

@ -15,6 +15,7 @@ interface SetupFormData {
dbPassword: string;
serverHost: string;
serverPort: string;
publicURL: string;
maxSeats: string;
}
@ -36,7 +37,6 @@ const Setup: React.FC = () => {
const [showDbPassword, setShowDbPassword] = useState(false);
const [signingKeys, setSigningKeys] = useState<SigningKeys | null>(null);
const [generatingKeys, setGeneratingKeys] = useState(false);
const [configType, setConfigType] = useState<'env' | 'swarm'>('env');
const [formData, setFormData] = useState<SetupFormData>({
adminUser: 'admin',
@ -48,6 +48,7 @@ const Setup: React.FC = () => {
dbPassword: 'redflag',
serverHost: '0.0.0.0',
serverPort: '8080',
publicURL: typeof window !== 'undefined' ? window.location.origin : '',
maxSeats: '50',
});
@ -131,6 +132,20 @@ const Setup: React.FC = () => {
setError('Server port must be between 1 and 65535');
return false;
}
if (!formData.publicURL.trim()) {
setError('Agent-facing server URL is required');
return false;
}
try {
const publicURL = new URL(formData.publicURL);
if (!['http:', 'https:'].includes(publicURL.protocol)) {
setError('Agent-facing server URL must start with http:// or https://');
return false;
}
} catch {
setError('Agent-facing server URL must be a valid URL');
return false;
}
const maxSeats = parseInt(formData.maxSeats);
if (isNaN(maxSeats) || maxSeats <= 0) {
setError('Maximum agent seats must be greater than 0');
@ -151,14 +166,13 @@ const Setup: React.FC = () => {
setIsLoading(true);
try {
const result = await setupApi.configure(formData);
const result = await setupApi.configure({
...formData,
signingPrivateKey: signingKeys?.private_key || '',
signingPublicKey: signingKeys?.public_key || '',
});
let configContent = '';
if (configType === 'env') {
configContent = generateEnvContent(result, signingKeys);
} else {
configContent = generateDockerSecretCommands(result, signingKeys);
}
const configContent = generateEnvContent(result);
setEnvContent(configContent || null);
setShowSuccess(true);
@ -174,56 +188,12 @@ const Setup: React.FC = () => {
}
};
const generateEnvContent = (result: any, _keys: SigningKeys | null): string => {
const generateEnvContent = (result: any): string => {
// Server-side createSharedEnvContentForDisplay already embeds the signing
// keys in envContent — appending here would duplicate the key (BUG-006).
return result.envContent || '';
};
const generateDockerSecretCommands = (result: any, keys: SigningKeys | null): string => {
if (!result.envContent) return '';
// Parse the envContent to extract values
const envLines = result.envContent.split('\n');
const envVars: Record<string, string> = {};
envLines.forEach((line: string) => {
const match = line.match(/^([^#=]+)=(.+)$/);
if (match) {
envVars[match[1].trim()] = match[2].trim();
}
});
// Add signing keys if available
if (keys) {
envVars['REDFLAG_SIGNING_PRIVATE_KEY'] = keys.private_key;
}
// Generate Docker secret commands
const commands = [
'# RedFlag Docker Secrets Configuration',
'# Generated by web setup on 2025-12-13',
'# [WARNING] SECURITY CRITICAL: Backup the signing key or you will lose access to all agents',
'#',
'# Run these commands on your Docker host to create the secrets:',
'#',
`printf '%s' '${envVars['REDFLAG_ADMIN_PASSWORD'] || ''}' | docker secret create redflag_admin_password -`,
`printf '%s' '${envVars['REDFLAG_JWT_SECRET'] || ''}' | docker secret create redflag_jwt_secret -`,
`printf '%s' '${envVars['REDFLAG_DB_PASSWORD'] || ''}' | docker secret create redflag_db_password -`,
`printf '%s' '${envVars['REDFLAG_SIGNING_PRIVATE_KEY'] || ''}' | docker secret create redflag_signing_private_key -`,
'',
'# After creating the secrets, restart your RedFlag server:',
'# docker compose down && docker compose up -d',
'',
'# Optional: Save these values securely (password manager, encrypted storage)',
`# Admin Password: ${envVars['REDFLAG_ADMIN_PASSWORD'] || ''}`,
`# JWT Secret: ${envVars['REDFLAG_JWT_SECRET'] || ''}`,
`# DB Password: ${envVars['REDFLAG_DB_PASSWORD'] || ''}`,
].join('\n');
return commands;
};
// Success screen with configuration display
if (showSuccess && envContent) {
return (
@ -271,21 +241,9 @@ const Setup: React.FC = () => {
{/* Configuration Content Section */}
{envContent && (
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold text-gray-900">
{configType === 'env' ? 'Environment Configuration (.env)' : 'Docker Swarm Secrets'}
</h3>
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-600">.env</span>
<button
onClick={() => setConfigType(configType === 'env' ? 'swarm' : 'env')}
className={`toggle ${configType === 'swarm' ? 'toggle-on' : 'toggle-off'}`}
>
<span className={`toggle-knob ${configType === 'swarm' ? 'toggle-knob-on' : 'toggle-knob-off'}`} />
</button>
<span className="text-sm text-gray-600">Swarm</span>
</div>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-3">
Environment Configuration (.env)
</h3>
<div className="bg-gray-50 border border-gray-200 rounded-md p-4">
<textarea
@ -295,51 +253,25 @@ const Setup: React.FC = () => {
/>
</div>
{configType === 'env' ? (
<>
<button
onClick={() => {
navigator.clipboard.writeText(envContent);
toast.success('.env content copied to clipboard!');
}}
className="mt-3 w-full flex justify-center py-2 px-4 border border-transparent rounded-md text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
Copy .env Content
</button>
<div className="mt-3 alert alert-info rounded-md p-3">
<p className="text-sm text-blue-800">
<strong>Next Steps:</strong> Save this content to <code className="code code-info">config/.env</code> and run <code className="code code-info">docker compose down && docker compose up -d</code> to apply the configuration.
</p>
</div>
<div className="mt-3 alert alert-warning rounded-md p-3">
<p className="text-sm text-yellow-800">
<strong>Security Note:</strong> The <code className="code code-warning">config/.env</code> file contains sensitive credentials. Ensure it has restricted permissions (<code className="code code-warning">chmod 600</code>) and is excluded from version control.
</p>
</div>
</>
) : (
<>
<button
onClick={() => {
navigator.clipboard.writeText(envContent);
toast.success('Docker secret commands copied to clipboard!');
}}
className="mt-3 w-full flex justify-center py-2 px-4 border border-transparent rounded-md text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
Copy Docker Secret Commands
</button>
<div className="mt-3 alert alert-info rounded-md p-3">
<p className="text-sm text-blue-800">
<strong>Requirements:</strong> Docker Swarm mode is required. Run <code className="code code-info">docker swarm init</code> on your Docker host before creating secrets.
</p>
</div>
<div className="mt-3 alert alert-warning rounded-md p-3">
<p className="text-sm text-yellow-800">
<strong>Next Steps:</strong> Run the copied commands on your Docker host, then update <code className="code code-warning">docker-compose.yml</code> to mount the secrets and restart.
</p>
</div>
</>
)}
<button
onClick={() => {
navigator.clipboard.writeText(envContent);
toast.success('.env content copied to clipboard!');
}}
className="mt-3 w-full flex justify-center py-2 px-4 border border-transparent rounded-md text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
Copy .env Content
</button>
<div className="mt-3 alert alert-info rounded-md p-3">
<p className="text-sm text-blue-800">
<strong>Next Steps:</strong> Save this content to <code className="code code-info">config/.env</code> and run <code className="code code-info">docker compose down && docker compose up -d</code> to apply the configuration.
</p>
</div>
<div className="mt-3 alert alert-warning rounded-md p-3">
<p className="text-sm text-yellow-800">
<strong>Security Note:</strong> The <code className="code code-warning">config/.env</code> file contains sensitive credentials. Ensure it has restricted permissions (<code className="code code-warning">chmod 600</code>) and is excluded from version control.
</p>
</div>
</div>
)}
@ -347,23 +279,12 @@ const Setup: React.FC = () => {
{/* Next Steps */}
<div className="border-t border-gray-200 pt-6">
<h3 className="text-lg font-semibold text-gray-900 mb-3">Next Steps</h3>
{configType === 'env' ? (
<ol className="list-decimal list-inside space-y-2 text-sm text-gray-600">
<li>Copy the .env content using the green button above</li>
<li>Save it to <code className="code code-neutral">config/.env</code></li>
<li>Run <code className="code code-neutral">docker compose down && docker compose up -d</code></li>
<li>Login to the dashboard with your admin username and password</li>
</ol>
) : (
<ol className="list-decimal list-inside space-y-2 text-sm text-gray-600">
<li>Initialize Docker Swarm: <code className="code code-neutral">docker swarm init</code></li>
<li>Copy the Docker secret commands using the green button above</li>
<li>Run the commands on your Docker host to create the secrets</li>
<li>Update <code className="code code-neutral">docker-compose.yml</code> to mount the secrets</li>
<li>Restart RedFlag with <code className="code code-neutral">docker compose down && docker compose up -d</code></li>
<li>Login to the dashboard with your admin username and password</li>
</ol>
)}
<ol className="list-decimal list-inside space-y-2 text-sm text-gray-600">
<li>Copy the .env content using the green button above</li>
<li>Save it to <code className="code code-neutral">config/.env</code></li>
<li>Run <code className="code code-neutral">docker compose down && docker compose up -d</code></li>
<li>Login to the dashboard with your admin username and password</li>
</ol>
</div>
<div className="mt-6 pt-6 border-t border-gray-200 space-y-3">
@ -691,6 +612,22 @@ const Setup: React.FC = () => {
/>
<p className="mt-1 text-xs text-gray-500">Security limit for agent registration</p>
</div>
<div className="sm:col-span-2">
<label htmlFor="publicURL" className="block text-sm font-medium text-gray-700 mb-1">
Agent-facing Server URL
</label>
<input
type="url"
id="publicURL"
name="publicURL"
value={formData.publicURL}
onChange={handleInputChange}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="http://redflag.example.com:8080"
required
/>
<p className="mt-1 text-xs text-gray-500">Used in generated install commands and agent callbacks</p>
</div>
</div>
</div>
@ -721,4 +658,4 @@ const Setup: React.FC = () => {
);
};
export default Setup;
export default Setup;

View file

@ -0,0 +1,236 @@
import React from 'react';
import { Activity, Save } from 'lucide-react';
import {
useProcessExplorerSettings,
useUpdateProcessExplorerSetting,
PROCESS_EXPLORER_DEFAULTS,
ProcessExplorerSettings,
} from '../../hooks/useProcessExplorer';
interface Field {
key: keyof ProcessExplorerSettings;
label: string;
unit: string;
min: number;
max: number;
description: string;
}
const FIELDS: Field[] = [
{
key: 'max_open_files',
label: 'Open Files Cap',
unit: 'entries',
min: 0,
max: 50000,
description:
'Maximum open file descriptors returned per process. Set to 0 for no cap. Processes like databases may have thousands of open files.',
},
{
key: 'max_sockets',
label: 'Sockets Cap',
unit: 'entries',
min: 0,
max: 50000,
description:
'Maximum open sockets (TCP/UDP/UNIX) returned per process. Set to 0 for no cap.',
},
{
key: 'max_pipes',
label: 'Pipes Cap',
unit: 'entries',
min: 0,
max: 50000,
description:
'Maximum open pipes returned per process. Set to 0 for no cap.',
},
{
key: 'max_memory_map',
label: 'Memory Map Cap',
unit: 'entries',
min: 0,
max: 100000,
description:
'Maximum memory-mapped regions returned per process. Chrome can have 20,000+ entries. Set to 0 for no cap.',
},
{
key: 'max_namespaces',
label: 'Namespaces Cap',
unit: 'entries',
min: 0,
max: 1000,
description:
'Maximum Linux namespaces returned per process. Typically under 20. Set to 0 for no cap.',
},
{
key: 'max_env_keys',
label: 'Environment Keys Cap',
unit: 'keys',
min: 0,
max: 10000,
description:
'Maximum environment variable key names returned per process. Values are never transmitted (security). Set to 0 for no cap.',
},
{
key: 'max_listening_ports',
label: 'Listening Ports Cap',
unit: 'ports',
min: 0,
max: 10000,
description:
'Maximum TCP listening ports returned per process. Set to 0 for no cap.',
},
];
const ProcessExplorer: React.FC = () => {
const { data, isLoading } = useProcessExplorerSettings();
const updateSetting = useUpdateProcessExplorerSetting();
const [draft, setDraft] = React.useState<ProcessExplorerSettings>(PROCESS_EXPLORER_DEFAULTS);
const [saved, setSaved] = React.useState(false);
const [errors, setErrors] = React.useState<Partial<Record<keyof ProcessExplorerSettings, string>>>({});
React.useEffect(() => {
if (data) setDraft(data);
}, [data]);
const validate = (key: keyof ProcessExplorerSettings, value: number): string | null => {
const field = FIELDS.find((f) => f.key === key);
if (!field) return null;
if (!Number.isFinite(value) || value < field.min) return `Must be at least ${field.min}`;
if (value > field.max) return `Must be at most ${field.max}`;
return null;
};
const handleChange = (key: keyof ProcessExplorerSettings, raw: string) => {
const value = raw === '' ? 0 : parseInt(raw, 10);
setDraft((prev) => ({ ...prev, [key]: Number.isFinite(value) ? value : 0 }));
setErrors((prev) => ({ ...prev, [key]: validate(key, value) }));
setSaved(false);
};
const handleSave = async (key: keyof ProcessExplorerSettings) => {
const value = draft[key];
const error = validate(key, value);
if (error) return;
try {
await updateSetting.mutateAsync({ key, value });
setSaved(true);
} catch {
setErrors((prev) => ({ ...prev, [key]: 'Failed to save' }));
}
};
const handleSaveAll = async () => {
const allErrors: typeof errors = {};
for (const field of FIELDS) {
const error = validate(field.key, draft[field.key]);
if (error) allErrors[field.key] = error;
}
if (Object.keys(allErrors).length > 0) {
setErrors(allErrors);
return;
}
try {
await Promise.all(
FIELDS.map((field) =>
updateSetting.mutateAsync({ key: field.key, value: draft[field.key] })
)
);
setSaved(true);
} catch {
// individual errors shown inline
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center p-8 text-muted-foreground">
Loading process explorer settings...
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold flex items-center gap-2">
<Activity className="h-5 w-5" />
Process Explorer
</h3>
<p className="text-sm text-muted-foreground mt-1">
Control how much data the agent collects per process during drill-down scans.
Set to 0 to disable a cap. Changes propagate to all agents on their next check-in.
</p>
</div>
<button
onClick={handleSaveAll}
className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 text-sm"
>
<Save className="h-4 w-4" />
Save All
</button>
</div>
{saved && (
<div className="text-sm text-green-600 bg-green-50 border border-green-200 rounded-md p-3">
Settings saved. Agents will pick up changes on their next check-in.
</div>
)}
<div className="grid gap-4">
{FIELDS.map((field) => (
<div
key={field.key}
className="border rounded-lg p-4 space-y-2"
>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium">{field.label}</label>
<p className="text-xs text-muted-foreground mt-0.5">
{field.description}
</p>
</div>
<div className="flex items-center gap-2">
<input
type="number"
min={field.min}
max={field.max}
value={draft[field.key]}
onChange={(e) => handleChange(field.key, e.target.value)}
className="w-24 px-3 py-1.5 border rounded-md text-sm text-right"
/>
<span className="text-xs text-muted-foreground w-12">{field.unit}</span>
<button
onClick={() => handleSave(field.key)}
className="px-3 py-1.5 text-xs border rounded-md hover:bg-accent"
>
Save
</button>
</div>
</div>
{errors[field.key] && (
<p className="text-xs text-destructive">{errors[field.key]}</p>
)}
</div>
))}
</div>
<div className="text-xs text-muted-foreground border-t pt-4">
<p>
<strong>How it works:</strong> These caps are stored on the server and delivered to agents
via the config endpoint. When an agent performs a process drill-down scan, it respects
these limits to prevent multi-megabyte responses for processes with thousands of open
files or memory map entries.
</p>
<p className="mt-1">
<strong>Default values</strong> are tuned for typical server workloads. Set a cap to 0
to disable it (no limit, but the underlying /proc data is still bounded by the kernel).
</p>
</div>
</div>
);
};
export default ProcessExplorer;