Merge pull request 'feat(kube): FreeLens parity — PTY shells, metrics, port-forward, and UX fixes' (#88) from feature/freelens-parity-complete into master
Some checks failed
Auto Tag / autotag (push) Successful in 7s
Auto Tag / wiki-sync (push) Successful in 9s
Auto Tag / changelog (push) Successful in 1m30s
Test / frontend-tests (push) Successful in 1m49s
Test / frontend-typecheck (push) Successful in 1m55s
Auto Tag / build-linux-amd64 (push) Successful in 11m32s
Auto Tag / build-macos-arm64 (push) Successful in 14m40s
Auto Tag / build-windows-amd64 (push) Successful in 13m9s
Auto Tag / build-linux-arm64 (push) Successful in 13m39s
Test / rust-fmt-check (push) Successful in 18m11s
Test / rust-clippy (push) Successful in 19m54s
Test / rust-tests (push) Successful in 21m51s
Renovate / renovate (push) Failing after 18s
Some checks failed
Auto Tag / autotag (push) Successful in 7s
Auto Tag / wiki-sync (push) Successful in 9s
Auto Tag / changelog (push) Successful in 1m30s
Test / frontend-tests (push) Successful in 1m49s
Test / frontend-typecheck (push) Successful in 1m55s
Auto Tag / build-linux-amd64 (push) Successful in 11m32s
Auto Tag / build-macos-arm64 (push) Successful in 14m40s
Auto Tag / build-windows-amd64 (push) Successful in 13m9s
Auto Tag / build-linux-arm64 (push) Successful in 13m39s
Test / rust-fmt-check (push) Successful in 18m11s
Test / rust-clippy (push) Successful in 19m54s
Test / rust-tests (push) Successful in 21m51s
Renovate / renovate (push) Failing after 18s
Reviewed-on: #88
This commit is contained in:
commit
1c4c76329f
39
package-lock.json
generated
39
package-lock.json
generated
@ -20,6 +20,7 @@
|
|||||||
"class-variance-authority": "^0.7",
|
"class-variance-authority": "^0.7",
|
||||||
"clsx": "^2",
|
"clsx": "^2",
|
||||||
"lucide-react": "latest",
|
"lucide-react": "latest",
|
||||||
|
"monaco-editor": "^0.55.1",
|
||||||
"react": "^19",
|
"react": "^19",
|
||||||
"react-chartjs-2": "^5.3.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
"react-diff-viewer-continued": "^4",
|
"react-diff-viewer-continued": "^4",
|
||||||
@ -3001,6 +3002,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/unist": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||||
@ -5706,6 +5714,15 @@
|
|||||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
"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": {
|
"node_modules/domutils": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||||
@ -9404,6 +9421,18 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"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": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@ -10603,6 +10632,16 @@
|
|||||||
"node": ">=18.0.0"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
|||||||
@ -27,6 +27,7 @@
|
|||||||
"class-variance-authority": "^0.7",
|
"class-variance-authority": "^0.7",
|
||||||
"clsx": "^2",
|
"clsx": "^2",
|
||||||
"lucide-react": "latest",
|
"lucide-react": "latest",
|
||||||
|
"monaco-editor": "^0.55.1",
|
||||||
"react": "^19",
|
"react": "^19",
|
||||||
"react-chartjs-2": "^5.3.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
"react-diff-viewer-continued": "^4",
|
"react-diff-viewer-continued": "^4",
|
||||||
|
|||||||
@ -96,6 +96,9 @@ pub struct PodInfo {
|
|||||||
pub ready: String,
|
pub ready: String,
|
||||||
pub age: String,
|
pub age: String,
|
||||||
pub containers: Vec<String>,
|
pub containers: Vec<String>,
|
||||||
|
pub restarts: Option<u32>,
|
||||||
|
pub ip: Option<String>,
|
||||||
|
pub node: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@ -1159,6 +1162,31 @@ fn parse_pods_json(json_str: &str) -> Result<Vec<PodInfo>, String> {
|
|||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.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::<u32>()
|
||||||
|
});
|
||||||
|
|
||||||
|
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 {
|
pods.push(PodInfo {
|
||||||
name,
|
name,
|
||||||
namespace,
|
namespace,
|
||||||
@ -1166,6 +1194,9 @@ fn parse_pods_json(json_str: &str) -> Result<Vec<PodInfo>, String> {
|
|||||||
ready,
|
ready,
|
||||||
age,
|
age,
|
||||||
containers,
|
containers,
|
||||||
|
restarts,
|
||||||
|
ip,
|
||||||
|
node,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -69,5 +69,11 @@ function getStatusVariant(status: string): BadgeProps["variant"] {
|
|||||||
if (normalized === "succeeded" || normalized === "completed" || normalized === "bound") {
|
if (normalized === "succeeded" || normalized === "completed" || normalized === "bound") {
|
||||||
return "succeeded";
|
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";
|
return "unknown";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,8 +3,9 @@ import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui";
|
|||||||
import { Badge } from "@/components/ui";
|
import { Badge } from "@/components/ui";
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui";
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui";
|
||||||
import { Button } 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 { YamlEditor } from "./YamlEditor";
|
||||||
|
import { PortForwardDialog } from "./PortForwardDialog";
|
||||||
import { scaleDeploymentCmd, restartDeploymentCmd, rollbackDeploymentCmd } from "@/lib/tauriCommands";
|
import { scaleDeploymentCmd, restartDeploymentCmd, rollbackDeploymentCmd } from "@/lib/tauriCommands";
|
||||||
import type { DeploymentInfo } from "@/lib/tauriCommands";
|
import type { DeploymentInfo } from "@/lib/tauriCommands";
|
||||||
|
|
||||||
@ -18,6 +19,7 @@ interface DeploymentDetailProps {
|
|||||||
export function DeploymentDetail({ clusterId, namespace, deployment, onClose }: DeploymentDetailProps) {
|
export function DeploymentDetail({ clusterId, namespace, deployment, onClose }: DeploymentDetailProps) {
|
||||||
const [activeTab, setActiveTab] = React.useState("overview");
|
const [activeTab, setActiveTab] = React.useState("overview");
|
||||||
const [replicaCount, setReplicaCount] = React.useState(deployment.replicas);
|
const [replicaCount, setReplicaCount] = React.useState(deployment.replicas);
|
||||||
|
const [portForwardOpen, setPortForwardOpen] = React.useState(false);
|
||||||
|
|
||||||
const [scaleLoading, setScaleLoading] = React.useState(false);
|
const [scaleLoading, setScaleLoading] = React.useState(false);
|
||||||
const [scaleError, setScaleError] = React.useState<string | null>(null);
|
const [scaleError, setScaleError] = React.useState<string | null>(null);
|
||||||
@ -74,11 +76,25 @@ export function DeploymentDetail({ clusterId, namespace, deployment, onClose }:
|
|||||||
<h2 className="text-xl font-semibold">Deployment: {deployment.name}</h2>
|
<h2 className="text-xl font-semibold">Deployment: {deployment.name}</h2>
|
||||||
<Badge variant="outline">{namespace}</Badge>
|
<Badge variant="outline">{namespace}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
<div className="flex items-center gap-2">
|
||||||
<X className="w-4 h-4" />
|
<Button variant="outline" size="sm" onClick={() => setPortForwardOpen(true)}>
|
||||||
</Button>
|
<Network className="w-4 h-4 mr-1.5" />
|
||||||
|
Port Forward
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PortForwardDialog
|
||||||
|
open={portForwardOpen}
|
||||||
|
onOpenChange={setPortForwardOpen}
|
||||||
|
clusterId={clusterId}
|
||||||
|
namespace={namespace}
|
||||||
|
podName={undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
<TabsList className="grid grid-cols-3 mb-4">
|
<TabsList className="grid grid-cols-3 mb-4">
|
||||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
import { Download, Search, Square, Trash2, Play, ChevronUp, ChevronDown, DownloadCloud } from "lucide-react";
|
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 {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@ -12,6 +12,10 @@ import {
|
|||||||
} from "@/components/ui";
|
} from "@/components/ui";
|
||||||
import { streamPodLogsCmd, stopLogStreamCmd } from "@/lib/tauriCommands";
|
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 {
|
interface LogStreamPanelProps {
|
||||||
clusterId: string;
|
clusterId: string;
|
||||||
namespace: string;
|
namespace: string;
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Badge } from "@/components/ui";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Badge, Button } from "@/components/ui";
|
||||||
|
import { Pencil } from "lucide-react";
|
||||||
import type { NamespaceResourceInfo } from "@/lib/tauriCommands";
|
import type { NamespaceResourceInfo } from "@/lib/tauriCommands";
|
||||||
|
import { getResourceYamlCmd } from "@/lib/tauriCommands";
|
||||||
|
import { EditResourceModal } from "./EditResourceModal";
|
||||||
|
|
||||||
interface NamespaceListProps {
|
interface NamespaceListProps {
|
||||||
items: NamespaceResourceInfo[];
|
items: NamespaceResourceInfo[];
|
||||||
@ -14,21 +17,46 @@ function statusVariant(status: string): "success" | "destructive" | "secondary"
|
|||||||
return "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<string | null>(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 (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
|
{editError && (
|
||||||
|
<div className="mb-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{editError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Name</TableHead>
|
<TableHead>Name</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead>Age</TableHead>
|
<TableHead>Age</TableHead>
|
||||||
|
<TableHead className="w-16 text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={3} className="text-center text-muted-foreground">
|
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||||
No namespaces found
|
No namespaces found
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -40,11 +68,35 @@ export function NamespaceList({ items }: NamespaceListProps) {
|
|||||||
<Badge variant={statusVariant(ns.status)}>{ns.status}</Badge>
|
<Badge variant={statusVariant(ns.status)}>{ns.status}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">{ns.age}</TableCell>
|
<TableCell className="text-sm text-muted-foreground">{ns.age}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
title="Edit YAML"
|
||||||
|
onClick={() => void openEdit(ns)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
<span className="sr-only">Edit</span>
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
|
{editState && (
|
||||||
|
<EditResourceModal
|
||||||
|
isOpen={editState.open}
|
||||||
|
clusterId={clusterId}
|
||||||
|
namespace=""
|
||||||
|
resourceType="namespaces"
|
||||||
|
resourceName={editState.name}
|
||||||
|
initialYaml={editState.yaml}
|
||||||
|
onClose={() => setEditState(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,8 +4,9 @@ import { Badge } from "@/components/ui";
|
|||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui";
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||||
import { Button } 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 { Loader2 } from "lucide-react";
|
||||||
|
import { PortForwardDialog } from "./PortForwardDialog";
|
||||||
import { YamlEditor } from "./YamlEditor";
|
import { YamlEditor } from "./YamlEditor";
|
||||||
import { getPodLogsCmd } from "@/lib/tauriCommands";
|
import { getPodLogsCmd } from "@/lib/tauriCommands";
|
||||||
import type { PodInfo } 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<string | null>(null);
|
const [logs, setLogs] = React.useState<string | null>(null);
|
||||||
const [logsLoading, setLogsLoading] = React.useState(false);
|
const [logsLoading, setLogsLoading] = React.useState(false);
|
||||||
const [logsError, setLogsError] = React.useState<string | null>(null);
|
const [logsError, setLogsError] = React.useState<string | null>(null);
|
||||||
|
const [portForwardOpen, setPortForwardOpen] = React.useState(false);
|
||||||
|
|
||||||
const fetchLogs = React.useCallback(
|
const fetchLogs = React.useCallback(
|
||||||
async (containerName: string) => {
|
async (containerName: string) => {
|
||||||
@ -66,11 +68,25 @@ export function PodDetail({ clusterId, namespace, pod, onClose }: PodDetailProps
|
|||||||
<h2 className="text-xl font-semibold">Pod: {pod.name}</h2>
|
<h2 className="text-xl font-semibold">Pod: {pod.name}</h2>
|
||||||
<Badge variant="outline">{namespace}</Badge>
|
<Badge variant="outline">{namespace}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
<div className="flex items-center gap-2">
|
||||||
<X className="w-4 h-4" />
|
<Button variant="outline" size="sm" onClick={() => setPortForwardOpen(true)}>
|
||||||
</Button>
|
<Network className="w-4 h-4 mr-1.5" />
|
||||||
|
Port Forward
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PortForwardDialog
|
||||||
|
open={portForwardOpen}
|
||||||
|
onOpenChange={setPortForwardOpen}
|
||||||
|
clusterId={clusterId}
|
||||||
|
namespace={namespace}
|
||||||
|
podName={pod.name}
|
||||||
|
/>
|
||||||
|
|
||||||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||||
<TabsList className="grid grid-cols-3 mb-4">
|
<TabsList className="grid grid-cols-3 mb-4">
|
||||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Button } from "@/components/ui";
|
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 { FileText, Terminal, Link, Pencil, Trash2, Zap, Settings } from "lucide-react";
|
||||||
import type { PodInfo } from "@/lib/tauriCommands";
|
import type { PodInfo } from "@/lib/tauriCommands";
|
||||||
import { deleteResourceCmd, forceDeleteResourceCmd, getResourceYamlCmd } 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 { useMetrics } from "@/hooks/useMetrics";
|
||||||
import { DEFAULT_COLUMNS } from "@/config/defaultColumns";
|
import { DEFAULT_COLUMNS } from "@/config/defaultColumns";
|
||||||
import { ColumnConfigModal } from "@/components/tables/ColumnConfigModal";
|
import { ColumnConfigModal } from "@/components/tables/ColumnConfigModal";
|
||||||
import { QuickActionColumn } from "@/components/tables/QuickActionColumn";
|
|
||||||
|
|
||||||
interface PodListProps {
|
interface PodListProps {
|
||||||
pods: PodInfo[];
|
pods: PodInfo[];
|
||||||
@ -49,23 +48,6 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps)
|
|||||||
metricsEnabled ? namespace : null
|
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) => {
|
const openEdit = async (pod: PodInfo) => {
|
||||||
setEditError(null);
|
setEditError(null);
|
||||||
try {
|
try {
|
||||||
@ -152,9 +134,7 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps)
|
|||||||
)}
|
)}
|
||||||
{isColumnVisible("status") && (
|
{isColumnVisible("status") && (
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge className={`${getPodStatusColor(pod.status)} text-white`}>
|
<StatusBadge status={pod.status} />
|
||||||
{pod.status}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
{isColumnVisible("ready") && <TableCell>{pod.ready}</TableCell>}
|
{isColumnVisible("ready") && <TableCell>{pod.ready}</TableCell>}
|
||||||
|
|||||||
181
src/components/Kubernetes/PortForwardDialog.tsx
Normal file
181
src/components/Kubernetes/PortForwardDialog.tsx
Normal file
@ -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<string | null>(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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Start Port Forward</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={(e) => void handleSubmit(e)} className="space-y-4 py-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="pfd-namespace">Namespace</Label>
|
||||||
|
<Input
|
||||||
|
id="pfd-namespace"
|
||||||
|
value={namespace}
|
||||||
|
readOnly
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="pfd-pod">Pod Name</Label>
|
||||||
|
<Input
|
||||||
|
id="pfd-pod"
|
||||||
|
value={pod}
|
||||||
|
onChange={(e) => setPod(e.target.value)}
|
||||||
|
placeholder="e.g. nginx-abc123"
|
||||||
|
readOnly={isPodReadonly}
|
||||||
|
disabled={isPodReadonly || loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="pfd-container-port">Container Port</Label>
|
||||||
|
<Input
|
||||||
|
id="pfd-container-port"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
value={containerPort}
|
||||||
|
onChange={(e) => setContainerPort(e.target.value)}
|
||||||
|
placeholder="80"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="pfd-local-port">Local Port (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="pfd-local-port"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
value={localPort}
|
||||||
|
onChange={(e) => setLocalPort(e.target.value)}
|
||||||
|
placeholder="auto"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/15 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="rounded-md bg-green-500/15 px-4 py-3 text-sm text-green-600">
|
||||||
|
Port forward started successfully.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className="pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
|
Start
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -13,7 +13,10 @@ export interface KeyboardShortcut {
|
|||||||
|
|
||||||
export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]): void {
|
export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]): void {
|
||||||
const shortcutsRef = useRef(shortcuts);
|
const shortcutsRef = useRef(shortcuts);
|
||||||
shortcutsRef.current = shortcuts;
|
|
||||||
|
useEffect(() => {
|
||||||
|
shortcutsRef.current = shortcuts;
|
||||||
|
}, [shortcuts]);
|
||||||
|
|
||||||
const handleKeyDown = useCallback((event: KeyboardEvent) => {
|
const handleKeyDown = useCallback((event: KeyboardEvent) => {
|
||||||
for (const shortcut of shortcutsRef.current) {
|
for (const shortcut of shortcutsRef.current) {
|
||||||
|
|||||||
@ -1530,14 +1530,13 @@ export const startPtyExecSessionCmd = (
|
|||||||
namespace: string,
|
namespace: string,
|
||||||
podName: string,
|
podName: string,
|
||||||
containerName: string | null,
|
containerName: string | null,
|
||||||
shell: string
|
_shell: string
|
||||||
) =>
|
) =>
|
||||||
invoke<string>("start_pty_exec_session", {
|
invoke<string>("start_pty_exec_session", {
|
||||||
clusterId,
|
clusterId,
|
||||||
namespace,
|
namespace,
|
||||||
podName,
|
pod: podName,
|
||||||
containerName,
|
container: containerName,
|
||||||
shell,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const startPtyAttachSessionCmd = (
|
export const startPtyAttachSessionCmd = (
|
||||||
@ -1549,8 +1548,8 @@ export const startPtyAttachSessionCmd = (
|
|||||||
invoke<string>("start_pty_attach_session", {
|
invoke<string>("start_pty_attach_session", {
|
||||||
clusterId,
|
clusterId,
|
||||||
namespace,
|
namespace,
|
||||||
podName,
|
pod: podName,
|
||||||
containerName,
|
container: containerName,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sendPtyStdinCmd = (sessionId: string, data: string) =>
|
export const sendPtyStdinCmd = (sessionId: string, data: string) =>
|
||||||
|
|||||||
@ -1,9 +1,15 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import { loader } from "@monaco-editor/react";
|
||||||
|
import * as monaco from "monaco-editor";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import "./styles/globals.css";
|
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(
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
|||||||
@ -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 { Play, Square, Trash2, Plus, RefreshCw } from "lucide-react";
|
||||||
import { useKubernetesStore } from "@/stores/kubernetesStore";
|
import { useKubernetesStore } from "@/stores/kubernetesStore";
|
||||||
import {
|
import {
|
||||||
@ -17,8 +17,6 @@ import {
|
|||||||
startPortForwardCmd,
|
startPortForwardCmd,
|
||||||
stopPortForwardCmd,
|
stopPortForwardCmd,
|
||||||
deletePortForwardCmd,
|
deletePortForwardCmd,
|
||||||
listPodsCmd,
|
|
||||||
listNamespacesCmd,
|
|
||||||
} from "@/lib/tauriCommands";
|
} from "@/lib/tauriCommands";
|
||||||
import { PortForwardForm } from "@/components/Kubernetes";
|
import { PortForwardForm } from "@/components/Kubernetes";
|
||||||
|
|
||||||
@ -29,7 +27,7 @@ export function PortForwardPage() {
|
|||||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadPortForwards = async () => {
|
const loadPortForwards = useCallback(async () => {
|
||||||
if (!selectedClusterId) return;
|
if (!selectedClusterId) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@ -41,13 +39,13 @@ export function PortForwardPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [selectedClusterId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadPortForwards();
|
loadPortForwards();
|
||||||
const interval = setInterval(loadPortForwards, 5000);
|
const interval = setInterval(loadPortForwards, 5000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [selectedClusterId]);
|
}, [loadPortForwards]);
|
||||||
|
|
||||||
const handleStop = async (id: string) => {
|
const handleStop = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -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
|
// Component should render the ANSI-colored line
|
||||||
rerender(
|
rerender(
|
||||||
<LogStreamPanel
|
<LogStreamPanel
|
||||||
|
|||||||
@ -19,4 +19,14 @@ export default defineConfig(async () => ({
|
|||||||
resolve: {
|
resolve: {
|
||||||
alias: { "@": path.resolve(__dirname, "./src") },
|
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",
|
||||||
|
],
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user