feat: add shell execution and kubeconfig management UI
Real-time approval modal, settings pages, tool calling auto-detect button, and IPC command wrappers. - Add ShellApprovalModal component for Tier 2 command approvals - Add ShellExecution settings page - Add KubeconfigManager settings page - Update AIProviders page with tool calling detection button - Add shell command wrappers to tauriCommands.ts - Add routes for new settings pages Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
117ab390a2
commit
dd9e6c0d3d
@ -30,6 +30,9 @@ import Ollama from "@/pages/Settings/Ollama";
|
||||
import Integrations from "@/pages/Settings/Integrations";
|
||||
import MCPServers from "@/pages/Settings/MCPServers";
|
||||
import Security from "@/pages/Settings/Security";
|
||||
import ShellExecution from "@/pages/Settings/ShellExecution";
|
||||
import KubeconfigManager from "@/pages/Settings/KubeconfigManager";
|
||||
import { ShellApprovalModal } from "@/components/ShellApprovalModal";
|
||||
|
||||
const navItems = [
|
||||
{ to: "/", icon: Home, label: "Dashboard" },
|
||||
@ -177,9 +180,12 @@ export default function App() {
|
||||
<Route path="/settings/integrations" element={<Integrations />} />
|
||||
<Route path="/settings/mcp" element={<MCPServers />} />
|
||||
<Route path="/settings/security" element={<Security />} />
|
||||
<Route path="/settings/shell-execution" element={<ShellExecution />} />
|
||||
<Route path="/settings/kubeconfig" element={<KubeconfigManager />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
<ShellApprovalModal />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
166
src/components/ShellApprovalModal.tsx
Normal file
166
src/components/ShellApprovalModal.tsx
Normal file
@ -0,0 +1,166 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { listen, UnlistenFn } from '@tauri-apps/api/event';
|
||||
import { Button } from '@/components/ui';
|
||||
import { Badge } from '@/components/ui';
|
||||
import { AlertTriangle, Shield, Terminal, X } from 'lucide-react';
|
||||
import { respondToShellApprovalCmd } from '@/lib/tauriCommands';
|
||||
|
||||
interface ShellApprovalRequest {
|
||||
approval_id: string;
|
||||
command: string;
|
||||
tier: number;
|
||||
reasoning: string;
|
||||
risk_factors: string[];
|
||||
}
|
||||
|
||||
export function ShellApprovalModal() {
|
||||
const [request, setRequest] = useState<ShellApprovalRequest | null>(null);
|
||||
const [isResponding, setIsResponding] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let unlisten: UnlistenFn;
|
||||
|
||||
const setupListener = async () => {
|
||||
unlisten = await listen<ShellApprovalRequest>(
|
||||
'shell:approval-needed',
|
||||
(event) => {
|
||||
setRequest(event.payload);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
setupListener();
|
||||
|
||||
return () => {
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleResponse = async (decision: string) => {
|
||||
if (!request) return;
|
||||
|
||||
setIsResponding(true);
|
||||
try {
|
||||
await respondToShellApprovalCmd(request.approval_id, decision);
|
||||
setRequest(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to respond to approval:', error);
|
||||
} finally {
|
||||
setIsResponding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeny = () => handleResponse('deny');
|
||||
const handleAllowOnce = () => handleResponse('allow_once');
|
||||
const handleAllowSession = () => handleResponse('allow_session');
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="relative bg-card border rounded-lg shadow-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto m-4">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 bg-card border-b p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-yellow-600" />
|
||||
<h2 className="text-lg font-semibold">Command Approval Required</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => !isResponding && setRequest(null)}
|
||||
disabled={isResponding}
|
||||
className="p-1 rounded hover:bg-accent text-muted-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This command requires your approval before execution
|
||||
</p>
|
||||
|
||||
{/* Command Display */}
|
||||
<div className="rounded-lg bg-slate-950 p-4 font-mono text-sm">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Terminal className="h-4 w-4 text-slate-400" />
|
||||
<span className="text-slate-400">Command:</span>
|
||||
</div>
|
||||
<code className="text-green-400">{request.command}</code>
|
||||
</div>
|
||||
|
||||
{/* Tier Badge */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Safety Tier:</span>
|
||||
<Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-300">
|
||||
Tier {request.tier}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Reasoning */}
|
||||
<div className="rounded-lg border border-yellow-300 bg-yellow-50 p-4">
|
||||
<div className="flex gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-700 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<div className="font-medium text-yellow-900 mb-1">Why approval is needed:</div>
|
||||
<div className="text-sm text-yellow-800">{request.reasoning}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk Factors */}
|
||||
{request.risk_factors.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Risk Factors:</div>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
|
||||
{request.risk_factors.map((factor, idx) => (
|
||||
<li key={idx}>{factor}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Safety Notice */}
|
||||
<div className="rounded-lg bg-muted p-3 text-sm text-muted-foreground">
|
||||
<div className="font-medium mb-1">Safety Controls:</div>
|
||||
<ul className="list-disc list-inside space-y-1 text-xs">
|
||||
<li>Command execution is logged and auditable</li>
|
||||
<li>30-second timeout protection</li>
|
||||
<li>PII detection before execution</li>
|
||||
<li>Output is captured for review</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="sticky bottom-0 bg-card border-t p-4 flex flex-col sm:flex-row gap-2 justify-end">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeny}
|
||||
disabled={isResponding}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Deny
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleAllowOnce}
|
||||
disabled={isResponding}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Allow Once
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAllowSession}
|
||||
disabled={isResponding}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Allow for Session
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -17,6 +17,7 @@ export interface ProviderConfig {
|
||||
session_id?: string;
|
||||
user_id?: string;
|
||||
use_datastore_upload?: boolean;
|
||||
supports_tool_calling?: boolean;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
@ -333,6 +334,9 @@ export const applyRedactionsCmd = (logFileId: string, approvedSpanIds: string[])
|
||||
export const testProviderConnectionCmd = (providerConfig: ProviderConfig) =>
|
||||
invoke<ChatResponse>("test_provider_connection", { providerConfig });
|
||||
|
||||
export const detectToolCallingSupportCmd = (providerConfig: ProviderConfig) =>
|
||||
invoke<boolean>("detect_tool_calling_support", { providerConfig });
|
||||
|
||||
export const createIssueCmd = (newIssue: NewIssue) =>
|
||||
invoke<Issue>("create_issue", {
|
||||
title: newIssue.title,
|
||||
@ -683,3 +687,54 @@ export const listAllImageAttachmentsCmd = (search?: string, issueId?: string) =>
|
||||
search: search ?? null,
|
||||
issueId: issueId ?? null,
|
||||
});
|
||||
|
||||
// ─── Shell Execution Commands ────────────────────────────────────────────────
|
||||
|
||||
export interface KubeconfigInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
context: string;
|
||||
cluster_url?: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface CommandExecution {
|
||||
id: string;
|
||||
command: string;
|
||||
tier: number;
|
||||
approval_status: string;
|
||||
exit_code: number | null;
|
||||
stdout: string | null;
|
||||
stderr: string | null;
|
||||
execution_time_ms: number | null;
|
||||
executed_at: string;
|
||||
}
|
||||
|
||||
export interface KubectlStatus {
|
||||
installed: boolean;
|
||||
path?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export const uploadKubeconfigCmd = (name: string, content: string) =>
|
||||
invoke<string>("upload_kubeconfig", { name, content });
|
||||
|
||||
export const listKubeconfigsCmd = () =>
|
||||
invoke<KubeconfigInfo[]>("list_kubeconfigs");
|
||||
|
||||
export const activateKubeconfigCmd = (id: string) =>
|
||||
invoke<void>("activate_kubeconfig", { id });
|
||||
|
||||
export const deleteKubeconfigCmd = (id: string) =>
|
||||
invoke<void>("delete_kubeconfig", { id });
|
||||
|
||||
export const respondToShellApprovalCmd = (approvalId: string, decision: string) =>
|
||||
invoke<void>("respond_to_shell_approval", { approvalId, decision });
|
||||
|
||||
export const listCommandExecutionsCmd = (issueId?: string) =>
|
||||
invoke<CommandExecution[]>("list_command_executions", {
|
||||
issueId: issueId ?? null,
|
||||
});
|
||||
|
||||
export const checkKubectlInstalledCmd = () =>
|
||||
invoke<KubectlStatus>("check_kubectl_installed");
|
||||
|
||||
@ -19,10 +19,13 @@ import {
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import {
|
||||
testProviderConnectionCmd,
|
||||
detectToolCallingSupportCmd,
|
||||
saveAiProviderCmd,
|
||||
loadAiProvidersCmd,
|
||||
deleteAiProviderCmd,
|
||||
listOllamaModelsCmd,
|
||||
type ProviderConfig,
|
||||
type OllamaModel,
|
||||
} from "@/lib/tauriCommands";
|
||||
|
||||
export const CUSTOM_REST_MODELS = [
|
||||
@ -64,6 +67,7 @@ const emptyProvider: ProviderConfig = {
|
||||
api_format: undefined,
|
||||
session_id: undefined,
|
||||
user_id: undefined,
|
||||
supports_tool_calling: false,
|
||||
};
|
||||
|
||||
export default function AIProviders() {
|
||||
@ -81,9 +85,11 @@ export default function AIProviders() {
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [form, setForm] = useState<ProviderConfig>({ ...emptyProvider });
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [isTestingConnection, setIsTestingConnection] = useState(false);
|
||||
const [isDetectingToolCalling, setIsDetectingToolCalling] = useState(false);
|
||||
const [isCustomModel, setIsCustomModel] = useState(false);
|
||||
const [customModelInput, setCustomModelInput] = useState("");
|
||||
const [ollamaModels, setOllamaModels] = useState<OllamaModel[]>([]);
|
||||
|
||||
// Load providers from database on mount
|
||||
// Note: Auto-testing of active provider is handled in App.tsx on startup
|
||||
@ -99,6 +105,22 @@ export default function AIProviders() {
|
||||
loadProviders();
|
||||
}, [setProviders]);
|
||||
|
||||
// Load Ollama models when form provider type changes to ollama
|
||||
useEffect(() => {
|
||||
if (form.provider_type === "ollama") {
|
||||
const loadOllamaModels = async () => {
|
||||
try {
|
||||
const models = await listOllamaModelsCmd();
|
||||
setOllamaModels(models);
|
||||
} catch (err) {
|
||||
console.error("Failed to load Ollama models:", err);
|
||||
setOllamaModels([]);
|
||||
}
|
||||
};
|
||||
loadOllamaModels();
|
||||
}
|
||||
}, [form.provider_type]);
|
||||
|
||||
const startAdd = () => {
|
||||
setForm({ ...emptyProvider });
|
||||
setEditIndex(null);
|
||||
@ -172,7 +194,7 @@ export default function AIProviders() {
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
setIsTesting(true);
|
||||
setIsTestingConnection(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const response = await testProviderConnectionCmd(form);
|
||||
@ -180,7 +202,27 @@ export default function AIProviders() {
|
||||
} catch (err) {
|
||||
setTestResult({ success: false, message: String(err) });
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
setIsTestingConnection(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoDetectToolCalling = async () => {
|
||||
setIsDetectingToolCalling(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const supportsTools = await detectToolCallingSupportCmd(form);
|
||||
// Use functional update to avoid stale closure
|
||||
setForm((prev) => ({ ...prev, supports_tool_calling: supportsTools }));
|
||||
setTestResult({
|
||||
success: supportsTools, // Align success with actual outcome
|
||||
message: supportsTools
|
||||
? "✅ Tool calling supported! Checkbox enabled automatically."
|
||||
: "⚠️ Tool calling not supported. Checkbox disabled automatically.",
|
||||
});
|
||||
} catch (err) {
|
||||
setTestResult({ success: false, message: `Auto-detect failed: ${String(err)}` });
|
||||
} finally {
|
||||
setIsDetectingToolCalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -289,12 +331,14 @@ export default function AIProviders() {
|
||||
const type = v as ProviderConfig["provider_type"];
|
||||
const defaults: Partial<ProviderConfig> =
|
||||
type === "ollama"
|
||||
? { api_url: "http://localhost:11434", api_key: "", model: "llama3.2:3b" }
|
||||
? { api_url: "http://localhost:11434", api_key: "", model: "llama3.2:3b", supports_tool_calling: true }
|
||||
: type === "openai"
|
||||
? { api_url: "https://api.openai.com/v1" }
|
||||
? { api_url: "https://api.openai.com/v1", supports_tool_calling: true }
|
||||
: type === "anthropic"
|
||||
? { api_url: "https://api.anthropic.com" }
|
||||
: {};
|
||||
? { api_url: "https://api.anthropic.com", supports_tool_calling: true }
|
||||
: type === "azure"
|
||||
? { supports_tool_calling: true }
|
||||
: { supports_tool_calling: false }; // Custom providers default to false
|
||||
setForm({ ...form, provider_type: type, ...defaults });
|
||||
}}
|
||||
>
|
||||
@ -332,11 +376,35 @@ export default function AIProviders() {
|
||||
{!(form.provider_type === "custom" && form.api_format === CUSTOM_REST_FORMAT) && (
|
||||
<div className="space-y-2">
|
||||
<Label>Model</Label>
|
||||
{form.provider_type === "ollama" ? (
|
||||
<Select
|
||||
value={form.model}
|
||||
onValueChange={(v) => setForm({ ...form, model: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select installed model..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ollamaModels.length > 0 ? (
|
||||
ollamaModels.map((model) => (
|
||||
<SelectItem key={model.name} value={model.name}>
|
||||
{model.name}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<div className="px-2 py-1.5 text-sm text-muted-foreground">
|
||||
No models installed. Go to Settings → Ollama to pull models.
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={form.model}
|
||||
onChange={(e) => setForm({ ...form, model: e.target.value })}
|
||||
placeholder="gpt-4o"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -506,6 +574,37 @@ export default function AIProviders() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tool Calling Support Toggle */}
|
||||
<div className="space-y-2 pt-4 border-t">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="supports-tool-calling" className="text-base">Tool Calling Support</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enable if this provider supports function/tool calling for shell execution and integrations
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
id="supports-tool-calling"
|
||||
type="checkbox"
|
||||
checked={form.supports_tool_calling ?? false}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, supports_tool_calling: e.target.checked })
|
||||
}
|
||||
className="h-5 w-5 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAutoDetectToolCalling}
|
||||
disabled={isTestingConnection || isDetectingToolCalling}
|
||||
className="w-full"
|
||||
>
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
{isDetectingToolCalling ? "Detecting..." : "Auto-Detect Tool Calling Support"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@ -532,8 +631,8 @@ export default function AIProviders() {
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={handleSave}>Save</Button>
|
||||
<Button variant="outline" onClick={handleTest} disabled={isTesting}>
|
||||
{isTesting ? "Testing..." : "Test Connection"}
|
||||
<Button variant="outline" onClick={handleTest} disabled={isTestingConnection || isDetectingToolCalling}>
|
||||
{isTestingConnection ? "Testing..." : "Test Connection"}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={handleCancel}>
|
||||
Cancel
|
||||
|
||||
251
src/pages/Settings/KubeconfigManager.tsx
Normal file
251
src/pages/Settings/KubeconfigManager.tsx
Normal file
@ -0,0 +1,251 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Upload, Check, Trash2, FileCode } from 'lucide-react';
|
||||
import { Button, Card, CardHeader, CardTitle, CardContent, Badge } from '@/components/ui';
|
||||
import {
|
||||
uploadKubeconfigCmd,
|
||||
listKubeconfigsCmd,
|
||||
activateKubeconfigCmd,
|
||||
deleteKubeconfigCmd,
|
||||
type KubeconfigInfo,
|
||||
} from '@/lib/tauriCommands';
|
||||
|
||||
export default function KubeconfigManager() {
|
||||
const [configs, setConfigs] = useState<KubeconfigInfo[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [uploadContent, setUploadContent] = useState('');
|
||||
const [uploadName, setUploadName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const loadConfigs = async () => {
|
||||
try {
|
||||
const data = await listKubeconfigsCmd();
|
||||
setConfigs(data);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadConfigs();
|
||||
}, []);
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
const content = event.target?.result as string;
|
||||
setUploadContent(content);
|
||||
setUploadName(file.name.replace(/\.(yaml|yml)$/, ''));
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!uploadContent || !uploadName) {
|
||||
setError('Please select a file and provide a name');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await uploadKubeconfigCmd(uploadName, uploadContent);
|
||||
setUploadContent('');
|
||||
setUploadName('');
|
||||
await loadConfigs();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = async (id: string) => {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await activateKubeconfigCmd(id);
|
||||
await loadConfigs();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this kubeconfig?')) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await deleteKubeconfigCmd(id);
|
||||
await loadConfigs();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">Kubeconfig Manager</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Upload and manage multiple Kubernetes cluster configurations for kubectl commands
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-300 bg-red-50 p-4 text-sm text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Upload className="h-5 w-5" />
|
||||
Upload Kubeconfig
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Select File</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".yaml,.yml"
|
||||
onChange={handleFileUpload}
|
||||
className="block w-full text-sm text-foreground file:mr-4 file:py-2 file:px-4 file:rounded file:border file:border-input file:text-sm file:font-semibold file:bg-secondary file:text-secondary-foreground hover:file:bg-secondary/80 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{uploadContent && (
|
||||
<>
|
||||
<div>
|
||||
<label htmlFor="config-name" className="block text-sm font-medium mb-2">
|
||||
Configuration Name
|
||||
</label>
|
||||
<input
|
||||
id="config-name"
|
||||
type="text"
|
||||
value={uploadName}
|
||||
onChange={(e) => setUploadName(e.target.value)}
|
||||
placeholder="e.g., production-cluster"
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-slate-950 p-4 font-mono text-xs text-slate-400 max-h-60 overflow-y-auto">
|
||||
<pre>{uploadContent.substring(0, 500)}...</pre>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleUpload} disabled={isLoading} className="w-full">
|
||||
Upload Kubeconfig
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Configs List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileCode className="h-5 w-5" />
|
||||
Configured Clusters ({configs.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{configs.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No kubeconfig files uploaded yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{configs.map((config) => (
|
||||
<div
|
||||
key={config.id}
|
||||
className={`p-4 rounded-lg border ${
|
||||
config.is_active
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold">{config.name}</h3>
|
||||
{config.is_active && (
|
||||
<Badge variant="default" className="bg-green-600">
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<div>
|
||||
<span className="font-medium">Context:</span> {config.context}
|
||||
</div>
|
||||
{config.cluster_url && (
|
||||
<div>
|
||||
<span className="font-medium">Cluster:</span> {config.cluster_url}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{!config.is_active && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleActivate(config.id)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Activate
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(config.id)}
|
||||
disabled={isLoading}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Info Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>About Kubeconfig Files</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Kubeconfig files contain authentication credentials and cluster connection details for
|
||||
kubectl commands.
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||||
<li>Upload your cluster's kubeconfig file (usually ~/.kube/config)</li>
|
||||
<li>Multiple clusters can be configured and switched between</li>
|
||||
<li>The active configuration is used for kubectl commands</li>
|
||||
<li>All kubeconfig files are encrypted using AES-256-GCM</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
244
src/pages/Settings/ShellExecution.tsx
Normal file
244
src/pages/Settings/ShellExecution.tsx
Normal file
@ -0,0 +1,244 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Terminal, CheckCircle, XCircle, Shield, History } from 'lucide-react';
|
||||
import { Button, Card, CardHeader, CardTitle, CardContent, Badge } from '@/components/ui';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
checkKubectlInstalledCmd,
|
||||
listCommandExecutionsCmd,
|
||||
type KubectlStatus,
|
||||
type CommandExecution,
|
||||
} from '@/lib/tauriCommands';
|
||||
|
||||
export default function ShellExecution() {
|
||||
const [kubectlStatus, setKubectlStatus] = useState<KubectlStatus | null>(null);
|
||||
const [executions, setExecutions] = useState<CommandExecution[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const loadKubectlStatus = async () => {
|
||||
try {
|
||||
const status = await checkKubectlInstalledCmd();
|
||||
setKubectlStatus(status);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const loadExecutions = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await listCommandExecutionsCmd();
|
||||
setExecutions(data);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadKubectlStatus();
|
||||
loadExecutions();
|
||||
}, []);
|
||||
|
||||
const getTierBadge = (tier: number) => {
|
||||
const colors = {
|
||||
1: 'bg-green-100 text-green-700 border-green-300',
|
||||
2: 'bg-yellow-100 text-yellow-700 border-yellow-300',
|
||||
3: 'bg-red-100 text-red-700 border-red-300',
|
||||
};
|
||||
return colors[tier as keyof typeof colors] || colors[1];
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const config = {
|
||||
auto: { label: 'Auto-executed', color: 'bg-blue-100 text-blue-700 border-blue-300' },
|
||||
approved: { label: 'Approved', color: 'bg-green-100 text-green-700 border-green-300' },
|
||||
denied: { label: 'Denied', color: 'bg-red-100 text-red-700 border-red-300' },
|
||||
};
|
||||
const statusConfig = config[status as keyof typeof config] || config.auto;
|
||||
return statusConfig;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">Shell Execution</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Configure and monitor autonomous shell command execution with intelligent safety controls
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-300 bg-red-50 p-4 text-sm text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* kubectl Status */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Terminal className="h-5 w-5" />
|
||||
kubectl Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{kubectlStatus ? (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
{kubectlStatus.installed ? (
|
||||
<>
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
<span className="font-medium text-green-700">kubectl is installed</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="h-5 w-5 text-red-600" />
|
||||
<span className="font-medium text-red-700">kubectl is not installed</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{kubectlStatus.path && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="font-medium">Path:</span> {kubectlStatus.path}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{kubectlStatus.version && (
|
||||
<div className="rounded-lg bg-slate-950 p-3 font-mono text-xs text-slate-400 overflow-x-auto">
|
||||
<pre>{kubectlStatus.version}</pre>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Checking kubectl status...</p>
|
||||
)}
|
||||
|
||||
<div className="pt-2">
|
||||
<Link to="/settings/kubeconfig">
|
||||
<Button variant="outline" className="w-full">
|
||||
Manage Kubeconfig Files
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Safety Architecture */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Safety Architecture
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Commands are automatically classified into three safety tiers:
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-green-50 border border-green-200">
|
||||
<Badge className={getTierBadge(1)}>Tier 1</Badge>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium text-green-900">Auto-execute (Read-only)</div>
|
||||
<div className="text-sm text-green-800">
|
||||
kubectl get, describe, logs | cat, grep, ls
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-yellow-50 border border-yellow-200">
|
||||
<Badge className={getTierBadge(2)}>Tier 2</Badge>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium text-yellow-900">Require approval (Mutating)</div>
|
||||
<div className="text-sm text-yellow-800">
|
||||
kubectl apply, delete, scale | ssh, chmod, systemctl restart
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-red-50 border border-red-200">
|
||||
<Badge className={getTierBadge(3)}>Tier 3</Badge>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium text-red-900">Always deny (Destructive)</div>
|
||||
<div className="text-sm text-red-800">
|
||||
rm -rf, shutdown, mkfs, dd
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Command Execution History */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<History className="h-5 w-5" />
|
||||
Recent Command Executions ({executions.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">Loading...</p>
|
||||
) : executions.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No command executions yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{executions.slice(0, 10).map((exec) => {
|
||||
const statusConfig = getStatusBadge(exec.approval_status);
|
||||
return (
|
||||
<div key={exec.id} className="p-3 rounded-lg border space-y-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<code className="text-sm font-mono text-foreground break-all">
|
||||
{exec.command}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-3 flex-shrink-0">
|
||||
<Badge className={getTierBadge(exec.tier)}>
|
||||
T{exec.tier}
|
||||
</Badge>
|
||||
<Badge className={statusConfig.color}>
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
{exec.exit_code !== undefined && (
|
||||
<span className={exec.exit_code === 0 ? 'text-green-600' : 'text-red-600'}>
|
||||
Exit: {exec.exit_code}
|
||||
</span>
|
||||
)}
|
||||
{exec.execution_time_ms !== undefined && (
|
||||
<span>{exec.execution_time_ms}ms</span>
|
||||
)}
|
||||
<span>{new Date(exec.executed_at).toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
{exec.stdout && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
||||
Show output
|
||||
</summary>
|
||||
<pre className="mt-2 p-2 rounded bg-slate-950 text-slate-400 overflow-x-auto max-h-40">
|
||||
{exec.stdout}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user