diff --git a/package-lock.json b/package-lock.json index 67e99d42..07c1bd34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "class-variance-authority": "^0.7", "clsx": "^2", "lucide-react": "latest", + "monaco-editor": "^0.55.1", "react": "^19", "react-chartjs-2": "^5.3.1", "react-diff-viewer-continued": "^4", @@ -3001,6 +3002,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -5706,6 +5714,15 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", @@ -9404,6 +9421,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -10603,6 +10632,16 @@ "node": ">=18.0.0" } }, + "node_modules/monaco-editor": { + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "license": "MIT", + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/package.json b/package.json index 7b383dad..6536bac9 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "class-variance-authority": "^0.7", "clsx": "^2", "lucide-react": "latest", + "monaco-editor": "^0.55.1", "react": "^19", "react-chartjs-2": "^5.3.1", "react-diff-viewer-continued": "^4", diff --git a/src-tauri/src/commands/kube.rs b/src-tauri/src/commands/kube.rs index a6a59188..b4862ad9 100644 --- a/src-tauri/src/commands/kube.rs +++ b/src-tauri/src/commands/kube.rs @@ -96,6 +96,9 @@ pub struct PodInfo { pub ready: String, pub age: String, pub containers: Vec, + pub restarts: Option, + pub ip: Option, + pub node: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1159,6 +1162,31 @@ fn parse_pods_json(json_str: &str) -> Result, String> { }) .unwrap_or_default(); + let restarts = item + .get("status") + .and_then(|s| s.get("containerStatuses")) + .and_then(|c| c.as_array()) + .map(|container_statuses| { + container_statuses + .iter() + .map(|c| c.get("restartCount").and_then(|r| r.as_u64()).unwrap_or(0) as u32) + .sum::() + }); + + let ip = item + .get("status") + .and_then(|s| s.get("podIP")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + + let node = item + .get("spec") + .and_then(|s| s.get("nodeName")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + pods.push(PodInfo { name, namespace, @@ -1166,6 +1194,9 @@ fn parse_pods_json(json_str: &str) -> Result, String> { ready, age, containers, + restarts, + ip, + node, }); } diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx index 11e97323..8b5adf23 100644 --- a/src/components/Badge.tsx +++ b/src/components/Badge.tsx @@ -69,5 +69,11 @@ function getStatusVariant(status: string): BadgeProps["variant"] { if (normalized === "succeeded" || normalized === "completed" || normalized === "bound") { return "succeeded"; } + if (normalized.includes("crash") || normalized.includes("error") || normalized.includes("oom") || normalized.includes("backoff")) { + return "failed"; + } + if (normalized === "terminating" || normalized === "evicted") { + return "destructive"; + } return "unknown"; } diff --git a/src/components/Kubernetes/DeploymentDetail.tsx b/src/components/Kubernetes/DeploymentDetail.tsx index ef023a4a..cd0028be 100644 --- a/src/components/Kubernetes/DeploymentDetail.tsx +++ b/src/components/Kubernetes/DeploymentDetail.tsx @@ -3,8 +3,9 @@ import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui"; import { Badge } from "@/components/ui"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui"; import { Button } from "@/components/ui"; -import { X, Loader2 } from "lucide-react"; +import { Network, X, Loader2 } from "lucide-react"; import { YamlEditor } from "./YamlEditor"; +import { PortForwardDialog } from "./PortForwardDialog"; import { scaleDeploymentCmd, restartDeploymentCmd, rollbackDeploymentCmd } from "@/lib/tauriCommands"; import type { DeploymentInfo } from "@/lib/tauriCommands"; @@ -18,6 +19,7 @@ interface DeploymentDetailProps { export function DeploymentDetail({ clusterId, namespace, deployment, onClose }: DeploymentDetailProps) { const [activeTab, setActiveTab] = React.useState("overview"); const [replicaCount, setReplicaCount] = React.useState(deployment.replicas); + const [portForwardOpen, setPortForwardOpen] = React.useState(false); const [scaleLoading, setScaleLoading] = React.useState(false); const [scaleError, setScaleError] = React.useState(null); @@ -74,11 +76,25 @@ export function DeploymentDetail({ clusterId, namespace, deployment, onClose }:

Deployment: {deployment.name}

{namespace} - +
+ + +
+ + Overview diff --git a/src/components/Kubernetes/LogStreamPanel.tsx b/src/components/Kubernetes/LogStreamPanel.tsx index df54f42c..7d5d39d6 100644 --- a/src/components/Kubernetes/LogStreamPanel.tsx +++ b/src/components/Kubernetes/LogStreamPanel.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { Download, Search, Square, Trash2, Play, ChevronUp, ChevronDown, DownloadCloud } from "lucide-react"; -import Ansi from "ansi-to-react"; +import AnsiLib from "ansi-to-react"; import { Dialog, DialogContent, @@ -12,6 +12,10 @@ import { } from "@/components/ui"; import { streamPodLogsCmd, stopLogStreamCmd } from "@/lib/tauriCommands"; +// Handle CJS default export in both dev and production Vite builds +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const Ansi = ((AnsiLib as any).default ?? AnsiLib) as React.ComponentType<{ children: string }>; + interface LogStreamPanelProps { clusterId: string; namespace: string; diff --git a/src/components/Kubernetes/NamespaceList.tsx b/src/components/Kubernetes/NamespaceList.tsx index 0a961209..5ad53927 100644 --- a/src/components/Kubernetes/NamespaceList.tsx +++ b/src/components/Kubernetes/NamespaceList.tsx @@ -1,6 +1,9 @@ -import React from "react"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Badge } from "@/components/ui"; +import React, { useState } from "react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Badge, Button } from "@/components/ui"; +import { Pencil } from "lucide-react"; import type { NamespaceResourceInfo } from "@/lib/tauriCommands"; +import { getResourceYamlCmd } from "@/lib/tauriCommands"; +import { EditResourceModal } from "./EditResourceModal"; interface NamespaceListProps { items: NamespaceResourceInfo[]; @@ -14,21 +17,46 @@ function statusVariant(status: string): "success" | "destructive" | "secondary" return "secondary"; } -export function NamespaceList({ items }: NamespaceListProps) { +export function NamespaceList({ items, clusterId }: NamespaceListProps) { + const [editState, setEditState] = useState<{ + open: boolean; + name: string; + yaml: string; + } | null>(null); + const [editError, setEditError] = useState(null); + + const openEdit = async (ns: NamespaceResourceInfo) => { + setEditError(null); + try { + // Namespaces are cluster-scoped — pass empty string for namespace param + const yaml = await getResourceYamlCmd(clusterId, "namespaces", "", ns.name); + setEditState({ open: true, name: ns.name, yaml }); + } catch (err) { + setEditError(err instanceof Error ? err.message : String(err)); + } + }; + return (
+ {editError && ( +
+ {editError} +
+ )} + Name Status Age + Actions {items.length === 0 ? ( - + No namespaces found @@ -40,11 +68,35 @@ export function NamespaceList({ items }: NamespaceListProps) { {ns.status} {ns.age} + + + )) )}
+ + {editState && ( + setEditState(null)} + /> + )}
); } diff --git a/src/components/Kubernetes/PodDetail.tsx b/src/components/Kubernetes/PodDetail.tsx index 11da02ca..f3251135 100644 --- a/src/components/Kubernetes/PodDetail.tsx +++ b/src/components/Kubernetes/PodDetail.tsx @@ -4,8 +4,9 @@ import { Badge } from "@/components/ui"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; import { Button } from "@/components/ui"; -import { Copy, X } from "lucide-react"; +import { Copy, Network, X } from "lucide-react"; import { Loader2 } from "lucide-react"; +import { PortForwardDialog } from "./PortForwardDialog"; import { YamlEditor } from "./YamlEditor"; import { getPodLogsCmd } from "@/lib/tauriCommands"; import type { PodInfo } from "@/lib/tauriCommands"; @@ -23,6 +24,7 @@ export function PodDetail({ clusterId, namespace, pod, onClose }: PodDetailProps const [logs, setLogs] = React.useState(null); const [logsLoading, setLogsLoading] = React.useState(false); const [logsError, setLogsError] = React.useState(null); + const [portForwardOpen, setPortForwardOpen] = React.useState(false); const fetchLogs = React.useCallback( async (containerName: string) => { @@ -66,11 +68,25 @@ export function PodDetail({ clusterId, namespace, pod, onClose }: PodDetailProps

Pod: {pod.name}

{namespace} - +
+ + +
+ + Overview diff --git a/src/components/Kubernetes/PodList.tsx b/src/components/Kubernetes/PodList.tsx index 7f34974d..2d59facd 100644 --- a/src/components/Kubernetes/PodList.tsx +++ b/src/components/Kubernetes/PodList.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Button } from "@/components/ui"; -import { Badge } from "@/components/ui"; +import { StatusBadge } from "@/components/Badge"; import { FileText, Terminal, Link, Pencil, Trash2, Zap, Settings } from "lucide-react"; import type { PodInfo } from "@/lib/tauriCommands"; import { deleteResourceCmd, forceDeleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands"; @@ -14,7 +14,6 @@ import { useColumnConfig } from "@/hooks/useColumnConfig"; import { useMetrics } from "@/hooks/useMetrics"; import { DEFAULT_COLUMNS } from "@/config/defaultColumns"; import { ColumnConfigModal } from "@/components/tables/ColumnConfigModal"; -import { QuickActionColumn } from "@/components/tables/QuickActionColumn"; interface PodListProps { pods: PodInfo[]; @@ -49,23 +48,6 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps) metricsEnabled ? namespace : null ); - const getPodStatusColor = (status: string) => { - switch (status.toLowerCase()) { - case "running": - return "bg-green-500"; - case "pending": - return "bg-yellow-500"; - case "succeeded": - case "completed": - return "bg-blue-500"; - case "failed": - case "error": - return "bg-red-500"; - default: - return "bg-gray-500"; - } - }; - const openEdit = async (pod: PodInfo) => { setEditError(null); try { @@ -152,9 +134,7 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps) )} {isColumnVisible("status") && ( - - {pod.status} - + )} {isColumnVisible("ready") && {pod.ready}} diff --git a/src/components/Kubernetes/PortForwardDialog.tsx b/src/components/Kubernetes/PortForwardDialog.tsx new file mode 100644 index 00000000..f53bb737 --- /dev/null +++ b/src/components/Kubernetes/PortForwardDialog.tsx @@ -0,0 +1,181 @@ +import React from "react"; +import { Loader2 } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + Button, + Input, + Label, +} from "@/components/ui"; +import { startPortForwardCmd } from "@/lib/tauriCommands"; + +interface PortForwardDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + clusterId: string; + namespace: string; + podName?: string; +} + +export function PortForwardDialog({ + open, + onOpenChange, + clusterId, + namespace, + podName, +}: PortForwardDialogProps) { + const [pod, setPod] = React.useState(podName ?? ""); + const [containerPort, setContainerPort] = React.useState(""); + const [localPort, setLocalPort] = React.useState(""); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(null); + const [success, setSuccess] = React.useState(false); + + React.useEffect(() => { + if (open) { + setPod(podName ?? ""); + setContainerPort(""); + setLocalPort(""); + setError(null); + setSuccess(false); + } + }, [open, podName]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setSuccess(false); + + const podValue = pod.trim(); + if (!podValue) { + setError("Pod name is required."); + return; + } + + const portNum = parseInt(containerPort, 10); + if (isNaN(portNum) || portNum < 1 || portNum > 65535) { + setError("Container port must be a valid number between 1 and 65535."); + return; + } + + let localPortNum: number | undefined; + if (localPort.trim() !== "") { + localPortNum = parseInt(localPort, 10); + if (isNaN(localPortNum) || localPortNum < 1 || localPortNum > 65535) { + setError("Local port must be a valid number between 1 and 65535."); + return; + } + } + + setLoading(true); + try { + await startPortForwardCmd({ + cluster_id: clusterId, + namespace, + pod: podValue, + container_port: portNum, + local_port: localPortNum, + }); + setSuccess(true); + onOpenChange(false); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }; + + const isPodReadonly = podName !== undefined; + + return ( + + + + Start Port Forward + + +
void handleSubmit(e)} className="space-y-4 py-2"> +
+ + +
+ +
+ + setPod(e.target.value)} + placeholder="e.g. nginx-abc123" + readOnly={isPodReadonly} + disabled={isPodReadonly || loading} + /> +
+ +
+ + setContainerPort(e.target.value)} + placeholder="80" + disabled={loading} + /> +
+ +
+ + setLocalPort(e.target.value)} + placeholder="auto" + disabled={loading} + /> +
+ + {error && ( +
+ {error} +
+ )} + + {success && ( +
+ Port forward started successfully. +
+ )} + + + + + +
+
+
+ ); +} diff --git a/src/hooks/useKeyboardShortcuts.ts b/src/hooks/useKeyboardShortcuts.ts index 8cf6f4e3..3b6bd19d 100644 --- a/src/hooks/useKeyboardShortcuts.ts +++ b/src/hooks/useKeyboardShortcuts.ts @@ -13,7 +13,10 @@ export interface KeyboardShortcut { export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]): void { const shortcutsRef = useRef(shortcuts); - shortcutsRef.current = shortcuts; + + useEffect(() => { + shortcutsRef.current = shortcuts; + }, [shortcuts]); const handleKeyDown = useCallback((event: KeyboardEvent) => { for (const shortcut of shortcutsRef.current) { diff --git a/src/lib/tauriCommands.ts b/src/lib/tauriCommands.ts index 7889bfc9..ec9c15fe 100644 --- a/src/lib/tauriCommands.ts +++ b/src/lib/tauriCommands.ts @@ -1530,14 +1530,13 @@ export const startPtyExecSessionCmd = ( namespace: string, podName: string, containerName: string | null, - shell: string + _shell: string ) => invoke("start_pty_exec_session", { clusterId, namespace, - podName, - containerName, - shell, + pod: podName, + container: containerName, }); export const startPtyAttachSessionCmd = ( @@ -1549,8 +1548,8 @@ export const startPtyAttachSessionCmd = ( invoke("start_pty_attach_session", { clusterId, namespace, - podName, - containerName, + pod: podName, + container: containerName, }); export const sendPtyStdinCmd = (sessionId: string, data: string) => diff --git a/src/main.tsx b/src/main.tsx index 7bf91f83..c356d7f9 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,9 +1,15 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; +import { loader } from "@monaco-editor/react"; +import * as monaco from "monaco-editor"; import App from "./App"; import "./styles/globals.css"; +// Use the locally bundled Monaco instead of loading from CDN. +// Tauri's WebView has no internet access so the default CDN loader never resolves. +loader.config({ monaco }); + ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/src/pages/Kubernetes/PortForwardPage.tsx b/src/pages/Kubernetes/PortForwardPage.tsx index ba0ca53a..d50d59d9 100644 --- a/src/pages/Kubernetes/PortForwardPage.tsx +++ b/src/pages/Kubernetes/PortForwardPage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import { Play, Square, Trash2, Plus, RefreshCw } from "lucide-react"; import { useKubernetesStore } from "@/stores/kubernetesStore"; import { @@ -17,8 +17,6 @@ import { startPortForwardCmd, stopPortForwardCmd, deletePortForwardCmd, - listPodsCmd, - listNamespacesCmd, } from "@/lib/tauriCommands"; import { PortForwardForm } from "@/components/Kubernetes"; @@ -29,7 +27,7 @@ export function PortForwardPage() { const [isFormOpen, setIsFormOpen] = useState(false); const [error, setError] = useState(null); - const loadPortForwards = async () => { + const loadPortForwards = useCallback(async () => { if (!selectedClusterId) return; setIsLoading(true); setError(null); @@ -41,13 +39,13 @@ export function PortForwardPage() { } finally { setIsLoading(false); } - }; + }, [selectedClusterId]); useEffect(() => { loadPortForwards(); const interval = setInterval(loadPortForwards, 5000); return () => clearInterval(interval); - }, [selectedClusterId]); + }, [loadPortForwards]); const handleStop = async (id: string) => { try { diff --git a/tests/unit/LogStreamPanel.test.tsx b/tests/unit/LogStreamPanel.test.tsx index 7d6b0c36..93b1062f 100644 --- a/tests/unit/LogStreamPanel.test.tsx +++ b/tests/unit/LogStreamPanel.test.tsx @@ -30,9 +30,6 @@ describe("LogStreamPanel — ANSI color support", () => { /> ); - // Simulate receiving log line with ANSI color codes - const logLine = "\x1b[31mError: something went wrong\x1b[0m"; - // Component should render the ANSI-colored line rerender( ({ resolve: { alias: { "@": path.resolve(__dirname, "./src") }, }, + worker: { + format: "es", + }, + optimizeDeps: { + include: [ + "ansi-to-react", + "monaco-editor/esm/vs/language/json/json.worker", + "monaco-editor/esm/vs/editor/editor.worker", + ], + }, }));