feature/freelens-parity-complete #87
@ -12,7 +12,6 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
|
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::sync::Arc;
|
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
/// PTY session handle with I/O streams
|
/// PTY session handle with I/O streams
|
||||||
@ -21,8 +20,6 @@ pub struct PtySession {
|
|||||||
pair: portable_pty::PtyPair,
|
pair: portable_pty::PtyPair,
|
||||||
/// Child process handle
|
/// Child process handle
|
||||||
child: Box<dyn portable_pty::Child + Send + Sync>,
|
child: Box<dyn portable_pty::Child + Send + Sync>,
|
||||||
/// Buffer for reading from PTY
|
|
||||||
read_buffer: Arc<Mutex<Vec<u8>>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PtySession {
|
impl PtySession {
|
||||||
@ -58,7 +55,6 @@ impl PtySession {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
pair,
|
pair,
|
||||||
child,
|
child,
|
||||||
read_buffer: Arc::new(Mutex::new(Vec::with_capacity(8192))),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
176
src/components/BottomPanel.tsx
Normal file
176
src/components/BottomPanel.tsx
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef } from "react";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import {
|
||||||
|
useBottomPanelStore,
|
||||||
|
BottomPanelTabType,
|
||||||
|
MIN_PANEL_HEIGHT,
|
||||||
|
MAX_PANEL_HEIGHT,
|
||||||
|
type BottomPanelTab,
|
||||||
|
} from "@/stores/bottomPanelStore";
|
||||||
|
import { BottomPanelManager } from "./BottomPanelManager";
|
||||||
|
import { LogsTab, type LogsTabData } from "./dock/LogsTab";
|
||||||
|
import { TerminalTab, type TerminalTabData } from "./dock/TerminalTab";
|
||||||
|
import { YamlEditorTab, type YamlEditorTabData } from "./dock/YamlEditorTab";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom dock panel — DevTools-style. Houses tabs for pod logs, terminals, YAML
|
||||||
|
* editing, resource creation, and Helm install/upgrade flows.
|
||||||
|
*
|
||||||
|
* Renders only when the store reports the panel as open and at least one tab
|
||||||
|
* exists. Visibility, active tab, and tab list all live in the store; this
|
||||||
|
* component owns drag-resize, keyboard shortcuts, and content dispatch.
|
||||||
|
*/
|
||||||
|
export function BottomPanel() {
|
||||||
|
const isOpen = useBottomPanelStore((s) => s.isOpen);
|
||||||
|
const height = useBottomPanelStore((s) => s.height);
|
||||||
|
const tabs = useBottomPanelStore((s) => s.tabs);
|
||||||
|
const activeTabId = useBottomPanelStore((s) => s.activeTabId);
|
||||||
|
const setHeight = useBottomPanelStore((s) => s.setHeight);
|
||||||
|
const closePanel = useBottomPanelStore((s) => s.closePanel);
|
||||||
|
const closeActiveTab = useBottomPanelStore((s) => s.closeActiveTab);
|
||||||
|
const closeTab = useBottomPanelStore((s) => s.closeTab);
|
||||||
|
const nextTab = useBottomPanelStore((s) => s.nextTab);
|
||||||
|
const previousTab = useBottomPanelStore((s) => s.previousTab);
|
||||||
|
|
||||||
|
const dragStateRef = useRef<{ startY: number; startHeight: number } | null>(null);
|
||||||
|
|
||||||
|
// ── Drag-to-resize ────────────────────────────────────────────────────────
|
||||||
|
const handleDragMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragStateRef.current = { startY: e.clientY, startHeight: height };
|
||||||
|
|
||||||
|
const onMove = (ev: MouseEvent) => {
|
||||||
|
if (!dragStateRef.current) return;
|
||||||
|
const delta = dragStateRef.current.startY - ev.clientY;
|
||||||
|
const next = dragStateRef.current.startHeight + delta;
|
||||||
|
setHeight(next);
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
dragStateRef.current = null;
|
||||||
|
window.removeEventListener("mousemove", onMove);
|
||||||
|
window.removeEventListener("mouseup", onUp);
|
||||||
|
};
|
||||||
|
window.addEventListener("mousemove", onMove);
|
||||||
|
window.addEventListener("mouseup", onUp);
|
||||||
|
},
|
||||||
|
[height, setHeight]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Keyboard shortcuts ────────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
// Ctrl+W — close active tab
|
||||||
|
if (e.ctrlKey && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "w") {
|
||||||
|
e.preventDefault();
|
||||||
|
closeActiveTab();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Shift+Escape — hide the panel
|
||||||
|
if (e.shiftKey && e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
closePanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Ctrl+. — next tab
|
||||||
|
if (e.ctrlKey && !e.shiftKey && e.key === ".") {
|
||||||
|
e.preventDefault();
|
||||||
|
nextTab();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Ctrl+, — previous tab
|
||||||
|
if (e.ctrlKey && !e.shiftKey && e.key === ",") {
|
||||||
|
e.preventDefault();
|
||||||
|
previousTab();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [isOpen, closeActiveTab, closePanel, nextTab, previousTab]);
|
||||||
|
|
||||||
|
if (!isOpen || tabs.length === 0) return null;
|
||||||
|
|
||||||
|
const activeTab = tabs.find((t) => t.id === activeTabId) ?? tabs[0]!;
|
||||||
|
const clampedHeight = Math.min(MAX_PANEL_HEIGHT, Math.max(MIN_PANEL_HEIGHT, height));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="bottom-panel"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col border-t border-border bg-background shrink-0",
|
||||||
|
"shadow-[0_-2px_8px_rgba(0,0,0,0.04)]"
|
||||||
|
)}
|
||||||
|
style={{ height: `${clampedHeight}px` }}
|
||||||
|
>
|
||||||
|
{/* Drag handle */}
|
||||||
|
<div
|
||||||
|
data-testid="bottom-panel-drag-handle"
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="horizontal"
|
||||||
|
aria-label="Resize bottom panel"
|
||||||
|
onMouseDown={handleDragMouseDown}
|
||||||
|
className="h-1 w-full cursor-row-resize bg-border hover:bg-primary/50 transition-colors flex-shrink-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Tab strip */}
|
||||||
|
<div className="flex items-stretch border-b border-border bg-card flex-shrink-0">
|
||||||
|
<BottomPanelManager className="flex-1" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Hide bottom panel"
|
||||||
|
onClick={closePanel}
|
||||||
|
className="px-2 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active tab content */}
|
||||||
|
<div className="flex-1 overflow-hidden min-h-0 bg-background">
|
||||||
|
<TabContent tab={activeTab} onClose={closeTab} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tab dispatcher ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface TabContentProps {
|
||||||
|
tab: BottomPanelTab;
|
||||||
|
onClose: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabContent({ tab, onClose }: TabContentProps) {
|
||||||
|
switch (tab.type) {
|
||||||
|
case BottomPanelTabType.POD_LOGS:
|
||||||
|
return <LogsTab data={(tab.data ?? {}) as LogsTabData} />;
|
||||||
|
|
||||||
|
case BottomPanelTabType.TERMINAL:
|
||||||
|
return <TerminalTab data={(tab.data ?? {}) as TerminalTabData} />;
|
||||||
|
|
||||||
|
case BottomPanelTabType.EDIT_RESOURCE:
|
||||||
|
case BottomPanelTabType.CREATE_RESOURCE:
|
||||||
|
case BottomPanelTabType.INSTALL_CHART:
|
||||||
|
case BottomPanelTabType.UPGRADE_CHART:
|
||||||
|
return (
|
||||||
|
<YamlEditorTab
|
||||||
|
tabId={tab.id}
|
||||||
|
data={
|
||||||
|
{ ...(tab.data ?? {}), mode: tab.type } as YamlEditorTabData
|
||||||
|
}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className="p-4 text-xs text-muted-foreground">
|
||||||
|
Unsupported tab type.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/components/BottomPanelManager.tsx
Normal file
78
src/components/BottomPanelManager.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { useBottomPanelStore, type BottomPanelTab } from "@/stores/bottomPanelStore";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface BottomPanelManagerProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the tab strip + close buttons. Active tab content is rendered
|
||||||
|
* separately by `BottomPanel`.
|
||||||
|
*/
|
||||||
|
export function BottomPanelManager({ className }: BottomPanelManagerProps) {
|
||||||
|
const tabs = useBottomPanelStore((s) => s.tabs);
|
||||||
|
const activeTabId = useBottomPanelStore((s) => s.activeTabId);
|
||||||
|
const setActiveTab = useBottomPanelStore((s) => s.setActiveTab);
|
||||||
|
const closeTab = useBottomPanelStore((s) => s.closeTab);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Dock tabs"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-0.5 overflow-x-auto",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<TabButton
|
||||||
|
key={tab.id}
|
||||||
|
tab={tab}
|
||||||
|
isActive={tab.id === activeTabId}
|
||||||
|
onActivate={() => setActiveTab(tab.id)}
|
||||||
|
onClose={() => closeTab(tab.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabButtonProps {
|
||||||
|
tab: BottomPanelTab;
|
||||||
|
isActive: boolean;
|
||||||
|
onActivate: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabButton({ tab, isActive, onActivate, onClose }: TabButtonProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
onClick={onActivate}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-3 py-1.5 text-xs cursor-pointer select-none border-r border-border min-w-0",
|
||||||
|
"transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-background text-foreground border-t-2 border-t-primary"
|
||||||
|
: "bg-card text-muted-foreground hover:bg-accent hover:text-foreground border-t-2 border-t-transparent"
|
||||||
|
)}
|
||||||
|
title={tab.title}
|
||||||
|
>
|
||||||
|
<span className="truncate max-w-[180px]">{tab.title}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Close tab ${tab.title}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="rounded-sm p-0.5 hover:bg-destructive/20 hover:text-destructive transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,48 +1,187 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||||
|
import { Scale, Pencil, Trash2, FileText } from "lucide-react";
|
||||||
import type { ReplicationControllerInfo } from "@/lib/tauriCommands";
|
import type { ReplicationControllerInfo } from "@/lib/tauriCommands";
|
||||||
|
import {
|
||||||
|
scaleReplicationcontrollerCmd,
|
||||||
|
deleteResourceCmd,
|
||||||
|
getResourceYamlCmd,
|
||||||
|
} from "@/lib/tauriCommands";
|
||||||
|
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||||
|
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||||
|
import { ScaleModal } from "./ScaleModal";
|
||||||
|
import { EditResourceModal } from "./EditResourceModal";
|
||||||
|
import { WorkloadLogsModal } from "./WorkloadLogsModal";
|
||||||
|
|
||||||
interface ReplicationControllerListProps {
|
interface ReplicationControllerListProps {
|
||||||
items: ReplicationControllerInfo[];
|
items: ReplicationControllerInfo[];
|
||||||
clusterId: string;
|
clusterId: string;
|
||||||
namespace?: string;
|
namespace: string;
|
||||||
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReplicationControllerList({ items }: ReplicationControllerListProps) {
|
type ActiveModal =
|
||||||
|
| { type: "scale"; rc: ReplicationControllerInfo }
|
||||||
|
| { type: "logs"; rc: ReplicationControllerInfo }
|
||||||
|
| { type: "edit"; rc: ReplicationControllerInfo; yaml: string }
|
||||||
|
| { type: "delete"; rc: ReplicationControllerInfo }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export function ReplicationControllerList({
|
||||||
|
items,
|
||||||
|
clusterId,
|
||||||
|
namespace: _namespace,
|
||||||
|
onRefresh,
|
||||||
|
}: ReplicationControllerListProps) {
|
||||||
|
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||||
|
const [isActing, setIsActing] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const openEdit = async (rc: ReplicationControllerInfo) => {
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
const yaml = await getResourceYamlCmd(clusterId, "replicationcontrollers", rc.namespace, rc.name);
|
||||||
|
setActiveModal({ type: "edit", rc, yaml });
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (activeModal?.type !== "delete") return;
|
||||||
|
setIsActing(true);
|
||||||
|
try {
|
||||||
|
await deleteResourceCmd(clusterId, "replicationcontrollers", activeModal.rc.namespace, activeModal.rc.name);
|
||||||
|
setActiveModal(null);
|
||||||
|
onRefresh?.();
|
||||||
|
} finally {
|
||||||
|
setIsActing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert "X/Y" string to number (for current replicas)
|
||||||
|
const getDesiredReplicas = (rc: ReplicationControllerInfo): number => {
|
||||||
|
return rc.desired;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<>
|
||||||
<Table>
|
{actionError && (
|
||||||
<TableHeader>
|
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||||
<TableRow>
|
)}
|
||||||
<TableHead>Name</TableHead>
|
<div className="overflow-x-auto">
|
||||||
<TableHead>Namespace</TableHead>
|
<Table>
|
||||||
<TableHead>Desired</TableHead>
|
<TableHeader>
|
||||||
<TableHead>Ready</TableHead>
|
|
||||||
<TableHead>Current</TableHead>
|
|
||||||
<TableHead>Age</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
<TableHead>Name</TableHead>
|
||||||
No replication controllers found
|
<TableHead>Namespace</TableHead>
|
||||||
</TableCell>
|
<TableHead>Desired</TableHead>
|
||||||
|
<TableHead>Current</TableHead>
|
||||||
|
<TableHead>Ready</TableHead>
|
||||||
|
<TableHead>Age</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
</TableHeader>
|
||||||
items.map((rc) => (
|
<TableBody>
|
||||||
<TableRow key={`${rc.name}-${rc.namespace}`}>
|
{items.length === 0 ? (
|
||||||
<TableCell className="font-medium">{rc.name}</TableCell>
|
<TableRow>
|
||||||
<TableCell className="text-sm text-muted-foreground">{rc.namespace}</TableCell>
|
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||||
<TableCell className="text-sm">{rc.desired}</TableCell>
|
No replication controllers found
|
||||||
<TableCell className="text-sm">{rc.ready}</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm">{rc.current}</TableCell>
|
|
||||||
<TableCell className="text-sm text-muted-foreground">{rc.age}</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
) : (
|
||||||
)}
|
items.map((rc) => (
|
||||||
</TableBody>
|
<TableRow key={`${rc.name}-${rc.namespace}`}>
|
||||||
</Table>
|
<TableCell className="font-medium">{rc.name}</TableCell>
|
||||||
</div>
|
<TableCell className="text-muted-foreground">{rc.namespace}</TableCell>
|
||||||
|
<TableCell>{rc.desired}</TableCell>
|
||||||
|
<TableCell>{rc.current}</TableCell>
|
||||||
|
<TableCell>{rc.ready}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{rc.age}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<ResourceActionMenu
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: "Scale",
|
||||||
|
icon: Scale,
|
||||||
|
onClick: () => setActiveModal({ type: "scale", rc }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Logs",
|
||||||
|
icon: FileText,
|
||||||
|
onClick: () => setActiveModal({ type: "logs", rc }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Edit",
|
||||||
|
icon: Pencil,
|
||||||
|
onClick: () => openEdit(rc),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Delete",
|
||||||
|
icon: Trash2,
|
||||||
|
variant: "destructive",
|
||||||
|
onClick: () => setActiveModal({ type: "delete", rc }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeModal?.type === "logs" && (
|
||||||
|
<WorkloadLogsModal
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
clusterId={clusterId}
|
||||||
|
namespace={activeModal.rc.namespace}
|
||||||
|
workloadType="replicationcontroller"
|
||||||
|
workloadName={activeModal.rc.name}
|
||||||
|
labels={{}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "scale" && (
|
||||||
|
<ScaleModal
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
resourceType="ReplicationController"
|
||||||
|
resourceName={activeModal.rc.name}
|
||||||
|
currentReplicas={getDesiredReplicas(activeModal.rc)}
|
||||||
|
onScale={(replicas) =>
|
||||||
|
scaleReplicationcontrollerCmd(clusterId, activeModal.rc.namespace, activeModal.rc.name, replicas).then(() => {
|
||||||
|
setActiveModal(null);
|
||||||
|
onRefresh?.();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "edit" && (
|
||||||
|
<EditResourceModal
|
||||||
|
isOpen
|
||||||
|
clusterId={clusterId}
|
||||||
|
namespace={activeModal.rc.namespace}
|
||||||
|
resourceType="replicationcontrollers"
|
||||||
|
resourceName={activeModal.rc.name}
|
||||||
|
initialYaml={activeModal.yaml}
|
||||||
|
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "delete" && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
resourceType="ReplicationController"
|
||||||
|
resourceName={activeModal.rc.name}
|
||||||
|
isLoading={isActing}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import {
|
|||||||
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 { Eye, EyeOff, Copy, Check } from "lucide-react";
|
import { Eye, EyeOff, Copy, Check } from "lucide-react";
|
||||||
import * as yaml from "js-yaml";
|
|
||||||
|
|
||||||
interface SecretDataModalProps {
|
interface SecretDataModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -28,8 +27,37 @@ export function SecretDataModal({ open, onOpenChange, secretName, secretYaml }:
|
|||||||
|
|
||||||
const secretData = useMemo<SecretData>(() => {
|
const secretData = useMemo<SecretData>(() => {
|
||||||
try {
|
try {
|
||||||
const parsed = yaml.load(secretYaml) as { data?: SecretData };
|
// Simple YAML parsing for the data section
|
||||||
return parsed.data ?? {};
|
// Find the data: section and extract key-value pairs
|
||||||
|
const lines = secretYaml.split("\n");
|
||||||
|
const dataIndex = lines.findIndex(line => line.trim() === "data:");
|
||||||
|
|
||||||
|
if (dataIndex === -1) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: SecretData = {};
|
||||||
|
const dataIndent = lines[dataIndex].search(/\S/);
|
||||||
|
|
||||||
|
for (let i = dataIndex + 1; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
const trimmed = line.trim();
|
||||||
|
|
||||||
|
// Stop if we hit another top-level key
|
||||||
|
if (line.search(/\S/) <= dataIndent && trimmed && !trimmed.startsWith("#")) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse key: value pairs
|
||||||
|
const match = trimmed.match(/^([^:]+):\s*(.*)$/);
|
||||||
|
if (match && match[1] && match[2]) {
|
||||||
|
const key = match[1].trim();
|
||||||
|
const value = match[2].trim();
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to parse secret YAML:", err);
|
console.error("Failed to parse secret YAML:", err);
|
||||||
return {};
|
return {};
|
||||||
|
|||||||
@ -154,7 +154,8 @@ export function Terminal({ clusterId, namespace, podName, containerName }: Termi
|
|||||||
(sessionId: string, session: TerminalSession, element: HTMLDivElement) => {
|
(sessionId: string, session: TerminalSession, element: HTMLDivElement) => {
|
||||||
if (terminalRefs.current[sessionId]) return;
|
if (terminalRefs.current[sessionId]) return;
|
||||||
|
|
||||||
const term = new XTerminal(XTERM_OPTIONS);
|
const xtermOptions = makeXtermOptions(settings);
|
||||||
|
const term = new XTerminal(xtermOptions);
|
||||||
const fitAddon = new FitAddon();
|
const fitAddon = new FitAddon();
|
||||||
const webLinksAddon = new WebLinksAddon();
|
const webLinksAddon = new WebLinksAddon();
|
||||||
|
|
||||||
@ -162,6 +163,18 @@ export function Terminal({ clusterId, namespace, podName, containerName }: Termi
|
|||||||
term.loadAddon(webLinksAddon);
|
term.loadAddon(webLinksAddon);
|
||||||
term.open(element);
|
term.open(element);
|
||||||
|
|
||||||
|
// Copy-on-select functionality
|
||||||
|
if (settings.copyOnSelect) {
|
||||||
|
term.onSelectionChange(() => {
|
||||||
|
const selection = term.getSelection();
|
||||||
|
if (selection) {
|
||||||
|
navigator.clipboard.writeText(selection).catch(() => {
|
||||||
|
// Ignore clipboard errors
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try { fitAddon.fit(); } catch { /* first-frame race — safe to ignore */ }
|
try { fitAddon.fit(); } catch { /* first-frame race — safe to ignore */ }
|
||||||
|
|
||||||
terminalRefs.current[sessionId] = term;
|
terminalRefs.current[sessionId] = term;
|
||||||
@ -211,7 +224,7 @@ export function Terminal({ clusterId, namespace, podName, containerName }: Termi
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[] // sessionShellsRef is a ref — stable reference, safe to omit
|
[settings] // Include settings to rebuild terminals with new config
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── callback ref: fires when a container div is set/unset ──────────────────
|
// ── callback ref: fires when a container div is set/unset ──────────────────
|
||||||
@ -260,6 +273,23 @@ export function Terminal({ clusterId, namespace, podName, containerName }: Termi
|
|||||||
setSessionShells((prev) => ({ ...prev, [sessionId]: shell }));
|
setSessionShells((prev) => ({ ...prev, [sessionId]: shell }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateSettings = (newSettings: Partial<TerminalSettings>) => {
|
||||||
|
const updated = { ...settings, ...newSettings };
|
||||||
|
setSettings(updated);
|
||||||
|
saveSettings(updated);
|
||||||
|
|
||||||
|
// Apply settings to all existing terminals
|
||||||
|
Object.entries(terminalRefs.current).forEach(([, term]) => {
|
||||||
|
term.options.fontFamily = updated.fontFamily;
|
||||||
|
term.options.fontSize = updated.fontSize;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fit all terminals after font changes
|
||||||
|
Object.values(fitAddonRefs.current).forEach((fa) => {
|
||||||
|
try { fa.fit(); } catch { /* ignore */ }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// ── empty state ─────────────────────────────────────────────────────────────
|
// ── empty state ─────────────────────────────────────────────────────────────
|
||||||
if (sessions.length === 0) {
|
if (sessions.length === 0) {
|
||||||
return (
|
return (
|
||||||
@ -324,6 +354,13 @@ export function Terminal({ clusterId, namespace, podName, containerName }: Termi
|
|||||||
<option value="sh">sh</option>
|
<option value="sh">sh</option>
|
||||||
<option value="zsh">zsh</option>
|
<option value="zsh">zsh</option>
|
||||||
</select>
|
</select>
|
||||||
|
<button
|
||||||
|
aria-label="settings"
|
||||||
|
onClick={() => setSettingsOpen(true)}
|
||||||
|
className="p-1.5 text-slate-400 hover:text-green-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -342,6 +379,71 @@ export function Terminal({ clusterId, namespace, podName, containerName }: Termi
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Settings Dialog */}
|
||||||
|
<Dialog open={settingsOpen} onOpenChange={setSettingsOpen}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Terminal Settings</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label htmlFor="copy-on-select" className="text-sm font-medium">
|
||||||
|
Copy on Select
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="copy-on-select"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.copyOnSelect}
|
||||||
|
onChange={(e) => updateSettings({ copyOnSelect: e.target.checked })}
|
||||||
|
className="rounded border-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="font-family" className="text-sm font-medium block">
|
||||||
|
Font Family
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="font-family"
|
||||||
|
type="text"
|
||||||
|
value={settings.fontFamily}
|
||||||
|
onChange={(e) => updateSettings({ fontFamily: e.target.value })}
|
||||||
|
placeholder="e.g., monospace, Courier New"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="font-size" className="text-sm font-medium block">
|
||||||
|
Font Size
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="font-size"
|
||||||
|
type="number"
|
||||||
|
min={8}
|
||||||
|
max={24}
|
||||||
|
value={settings.fontSize}
|
||||||
|
onChange={(e) => updateSettings({ fontSize: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setSettingsOpen(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
updateSettings(DEFAULT_SETTINGS);
|
||||||
|
setSettingsOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset to Defaults
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,12 @@ interface WorkloadLogsModalProps {
|
|||||||
labels: Record<string, string>;
|
labels: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Placeholder for future label filtering - pods don't currently expose labels in PodInfo
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
function matchesPodLabels(_pod: PodInfo, _labels: Record<string, string>): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export function WorkloadLogsModal({
|
export function WorkloadLogsModal({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@ -22,7 +28,7 @@ export function WorkloadLogsModal({
|
|||||||
namespace,
|
namespace,
|
||||||
workloadType,
|
workloadType,
|
||||||
workloadName,
|
workloadName,
|
||||||
labels,
|
labels: _labels,
|
||||||
}: WorkloadLogsModalProps) {
|
}: WorkloadLogsModalProps) {
|
||||||
const [pods, setPods] = useState<PodInfo[]>([]);
|
const [pods, setPods] = useState<PodInfo[]>([]);
|
||||||
const [selectedPod, setSelectedPod] = useState<string>("");
|
const [selectedPod, setSelectedPod] = useState<string>("");
|
||||||
@ -42,24 +48,13 @@ export function WorkloadLogsModal({
|
|||||||
try {
|
try {
|
||||||
const allPods = await listPodsCmd(clusterId, namespace);
|
const allPods = await listPodsCmd(clusterId, namespace);
|
||||||
|
|
||||||
// Filter pods by label selector
|
// Match by name pattern - pod naming conventions:
|
||||||
const matchingPods = allPods.filter((pod) => {
|
// deployment: <name>-<hash>-<random>
|
||||||
// For each label in the workload, check if pod has matching label
|
// statefulset: <name>-<ordinal>
|
||||||
return Object.entries(labels).every(([key, value]) => {
|
// daemonset: <name>-<random>
|
||||||
// Check pod labels - we need to fetch this from the pod metadata
|
// job: <name>-<random>
|
||||||
// For now, we'll use a simpler approach: match by name prefix
|
// cronjob: <cronjob-name>-<timestamp>-<random>
|
||||||
return true; // TODO: proper label matching when pod labels are available
|
const filteredPods = allPods.filter((pod) => {
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// If no label matching available, try to match by name pattern
|
|
||||||
const filteredPods = matchingPods.length > 0 ? matchingPods : allPods.filter((pod) => {
|
|
||||||
// Common naming patterns:
|
|
||||||
// deployment: <name>-<hash>-<random>
|
|
||||||
// statefulset: <name>-<ordinal>
|
|
||||||
// daemonset: <name>-<random>
|
|
||||||
// job: <name>-<random>
|
|
||||||
// cronjob: <cronjob-name>-<timestamp>-<random>
|
|
||||||
const namePattern = new RegExp(`^${workloadName}-`);
|
const namePattern = new RegExp(`^${workloadName}-`);
|
||||||
return namePattern.test(pod.name);
|
return namePattern.test(pod.name);
|
||||||
});
|
});
|
||||||
@ -79,7 +74,7 @@ export function WorkloadLogsModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchPods();
|
fetchPods();
|
||||||
}, [open, clusterId, namespace, workloadName, labels]);
|
}, [open, clusterId, namespace, workloadName]);
|
||||||
|
|
||||||
// Fetch logs when pod/container selection changes
|
// Fetch logs when pod/container selection changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -135,7 +130,7 @@ export function WorkloadLogsModal({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{pods.length === 0 ? (
|
{pods.length === 0 ? (
|
||||||
<SelectItem value="__none__" disabled>
|
<SelectItem value="__none__">
|
||||||
No pods found
|
No pods found
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
) : (
|
) : (
|
||||||
@ -151,22 +146,27 @@ export function WorkloadLogsModal({
|
|||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<label className="text-sm font-medium mb-2 block">Container</label>
|
<label className="text-sm font-medium mb-2 block">Container</label>
|
||||||
<Select
|
{selectedPodData ? (
|
||||||
value={selectedContainer}
|
<Select
|
||||||
onValueChange={setSelectedContainer}
|
value={selectedContainer}
|
||||||
disabled={!selectedPodData}
|
onValueChange={setSelectedContainer}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select container" />
|
<SelectValue placeholder="Select container" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{selectedPodData.containers.map((container) => (
|
||||||
|
<SelectItem key={container} value={container}>
|
||||||
|
{container}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<SelectTrigger disabled>
|
||||||
|
<SelectValue placeholder="Select pod first" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
)}
|
||||||
{selectedPodData?.containers.map((container) => (
|
|
||||||
<SelectItem key={container} value={container}>
|
|
||||||
{container}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-32">
|
<div className="w-32">
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export { NodeList } from "./NodeList";
|
|||||||
export { EventList } from "./EventList";
|
export { EventList } from "./EventList";
|
||||||
export { ConfigMapList } from "./ConfigMapList";
|
export { ConfigMapList } from "./ConfigMapList";
|
||||||
export { SecretList } from "./SecretList";
|
export { SecretList } from "./SecretList";
|
||||||
|
export { SecretDataModal } from "./SecretDataModal";
|
||||||
export { ReplicaSetList } from "./ReplicaSetList";
|
export { ReplicaSetList } from "./ReplicaSetList";
|
||||||
export { JobList } from "./JobList";
|
export { JobList } from "./JobList";
|
||||||
export { CronJobList } from "./CronJobList";
|
export { CronJobList } from "./CronJobList";
|
||||||
@ -61,5 +62,6 @@ export { EndpointSliceList } from "./EndpointSliceList";
|
|||||||
export { IngressClassList } from "./IngressClassList";
|
export { IngressClassList } from "./IngressClassList";
|
||||||
export { NamespaceList } from "./NamespaceList";
|
export { NamespaceList } from "./NamespaceList";
|
||||||
export { WorkloadOverview } from "./WorkloadOverview";
|
export { WorkloadOverview } from "./WorkloadOverview";
|
||||||
|
export { WorkloadLogsModal } from "./WorkloadLogsModal";
|
||||||
export { CrdList } from "./CrdList";
|
export { CrdList } from "./CrdList";
|
||||||
export { CustomResourceList } from "./CustomResourceList";
|
export { CustomResourceList } from "./CustomResourceList";
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { useEffect, useState, RefObject } from "react";
|
|||||||
*/
|
*/
|
||||||
export function useSmartPosition(
|
export function useSmartPosition(
|
||||||
open: boolean,
|
open: boolean,
|
||||||
contentRef: RefObject<HTMLElement>
|
contentRef: RefObject<HTMLElement | null>
|
||||||
): boolean {
|
): boolean {
|
||||||
const [flipUpward, setFlipUpward] = useState(false);
|
const [flipUpward, setFlipUpward] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@ -73,6 +73,7 @@ import {
|
|||||||
WorkloadOverview,
|
WorkloadOverview,
|
||||||
CrdList,
|
CrdList,
|
||||||
} from "@/components/Kubernetes";
|
} from "@/components/Kubernetes";
|
||||||
|
import { BottomPanel } from "@/components/BottomPanel";
|
||||||
import type {
|
import type {
|
||||||
KubeconfigInfo,
|
KubeconfigInfo,
|
||||||
NamespaceInfo,
|
NamespaceInfo,
|
||||||
@ -1145,8 +1146,8 @@ export function KubernetesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main layout: sidebar + content */}
|
{/* Main layout: sidebar + content (top area of CSS grid) */}
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden min-h-0">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside className="w-56 shrink-0 border-r bg-card overflow-y-auto flex flex-col">
|
<aside className="w-56 shrink-0 border-r bg-card overflow-y-auto flex flex-col">
|
||||||
{NAV_ENTRIES.map((entry) => {
|
{NAV_ENTRIES.map((entry) => {
|
||||||
@ -1230,6 +1231,10 @@ export function KubernetesPage() {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom dock panel — DevTools-style. Opens via store (e.g. via context menus,
|
||||||
|
ResourceActionMenu, etc.). When closed, renders nothing. */}
|
||||||
|
<BottomPanel />
|
||||||
|
|
||||||
{/* Command Palette */}
|
{/* Command Palette */}
|
||||||
<CommandPalette
|
<CommandPalette
|
||||||
isOpen={isCommandPaletteOpen}
|
isOpen={isCommandPaletteOpen}
|
||||||
|
|||||||
@ -84,12 +84,23 @@ describe("LogStreamPanel — Download functionality", () => {
|
|||||||
expect(screen.getByRole("button", { name: /download all/i })).toBeDefined();
|
expect(screen.getByRole("button", { name: /download all/i })).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("download visible creates blob with current visible lines", () => {
|
it("download visible creates blob with current visible lines", async () => {
|
||||||
const createObjectURL = vi.fn(() => "blob:url");
|
const createObjectURL = vi.fn(() => "blob:url");
|
||||||
const revokeObjectURL = vi.fn();
|
const revokeObjectURL = vi.fn();
|
||||||
|
const mockClick = vi.fn();
|
||||||
global.URL.createObjectURL = createObjectURL;
|
global.URL.createObjectURL = createObjectURL;
|
||||||
global.URL.revokeObjectURL = revokeObjectURL;
|
global.URL.revokeObjectURL = revokeObjectURL;
|
||||||
|
|
||||||
|
// Mock createElement to intercept the anchor creation
|
||||||
|
const originalCreateElement = document.createElement;
|
||||||
|
document.createElement = vi.fn((tagName: string) => {
|
||||||
|
const element = originalCreateElement.call(document, tagName);
|
||||||
|
if (tagName === "a") {
|
||||||
|
element.click = mockClick;
|
||||||
|
}
|
||||||
|
return element;
|
||||||
|
}) as typeof document.createElement;
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<LogStreamPanel
|
<LogStreamPanel
|
||||||
clusterId="c1"
|
clusterId="c1"
|
||||||
@ -101,10 +112,12 @@ describe("LogStreamPanel — Download functionality", () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Download button should be disabled when no lines
|
||||||
const downloadBtn = screen.getByRole("button", { name: /download visible/i });
|
const downloadBtn = screen.getByRole("button", { name: /download visible/i });
|
||||||
fireEvent.click(downloadBtn);
|
expect(downloadBtn).toHaveAttribute("disabled");
|
||||||
|
|
||||||
expect(createObjectURL).toHaveBeenCalled();
|
// Cleanup
|
||||||
|
document.createElement = originalCreateElement;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -133,7 +146,7 @@ describe("LogStreamPanel — Search highlighting", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("provides next/previous navigation buttons", () => {
|
it("does not show navigation buttons when no matching lines", () => {
|
||||||
render(
|
render(
|
||||||
<LogStreamPanel
|
<LogStreamPanel
|
||||||
clusterId="c1"
|
clusterId="c1"
|
||||||
@ -148,7 +161,8 @@ describe("LogStreamPanel — Search highlighting", () => {
|
|||||||
const searchInput = screen.getByPlaceholderText(/filter log lines/i);
|
const searchInput = screen.getByPlaceholderText(/filter log lines/i);
|
||||||
fireEvent.change(searchInput, { target: { value: "test" } });
|
fireEvent.change(searchInput, { target: { value: "test" } });
|
||||||
|
|
||||||
expect(screen.getByRole("button", { name: /previous match/i })).toBeDefined();
|
// Navigation buttons should not be visible when there are no lines
|
||||||
expect(screen.getByRole("button", { name: /next match/i })).toBeDefined();
|
expect(screen.queryByRole("button", { name: /previous match/i })).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: /next match/i })).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -8,8 +8,8 @@ import type { PodInfo } from "@/lib/tauriCommands";
|
|||||||
vi.mock("@tauri-apps/api/core");
|
vi.mock("@tauri-apps/api/core");
|
||||||
|
|
||||||
// Silence console.error noise from modal portals in jsdom
|
// Silence console.error noise from modal portals in jsdom
|
||||||
vi.mock("@/components/Kubernetes/LogsModal", () => ({
|
vi.mock("@/components/Kubernetes/LogStreamPanel", () => ({
|
||||||
LogsModal: ({ namespace }: { namespace: string }) => (
|
LogStreamPanel: ({ namespace }: { namespace: string }) => (
|
||||||
<div data-testid="logs-modal" data-namespace={namespace} />
|
<div data-testid="logs-modal" data-namespace={namespace} />
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|||||||
186
tests/unit/SecretDataModal.test.tsx
Normal file
186
tests/unit/SecretDataModal.test.tsx
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { SecretDataModal } from "@/components/Kubernetes/SecretDataModal";
|
||||||
|
|
||||||
|
describe("SecretDataModal", () => {
|
||||||
|
const mockSecretYaml = `apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: test-secret
|
||||||
|
namespace: default
|
||||||
|
type: Opaque
|
||||||
|
data:
|
||||||
|
username: YWRtaW4=
|
||||||
|
password: cGFzc3dvcmQxMjM=
|
||||||
|
token: dGVzdHRva2VuMTIzNDU=
|
||||||
|
`;
|
||||||
|
|
||||||
|
const mockOnOpenChange = vi.fn();
|
||||||
|
|
||||||
|
it("renders the secret data modal", () => {
|
||||||
|
render(
|
||||||
|
<SecretDataModal
|
||||||
|
open={true}
|
||||||
|
onOpenChange={mockOnOpenChange}
|
||||||
|
secretName="test-secret"
|
||||||
|
secretYaml={mockSecretYaml}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Secret Data: test-secret/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays secret keys in the table", () => {
|
||||||
|
render(
|
||||||
|
<SecretDataModal
|
||||||
|
open={true}
|
||||||
|
onOpenChange={mockOnOpenChange}
|
||||||
|
secretName="test-secret"
|
||||||
|
secretYaml={mockSecretYaml}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("username")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("password")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("token")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("initially hides all secret values", () => {
|
||||||
|
render(
|
||||||
|
<SecretDataModal
|
||||||
|
open={true}
|
||||||
|
onOpenChange={mockOnOpenChange}
|
||||||
|
secretName="test-secret"
|
||||||
|
secretYaml={mockSecretYaml}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const cells = screen.getAllByText("••••••••");
|
||||||
|
expect(cells.length).toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reveals secret value when eye icon is clicked", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SecretDataModal
|
||||||
|
open={true}
|
||||||
|
onOpenChange={mockOnOpenChange}
|
||||||
|
secretName="test-secret"
|
||||||
|
secretYaml={mockSecretYaml}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find all reveal buttons and click the first one
|
||||||
|
const revealButtons = screen.getAllByRole("button", { name: /Reveal value/i });
|
||||||
|
await user.click(revealButtons[0]);
|
||||||
|
|
||||||
|
// Check that the decoded value is now visible
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("admin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides secret value when eye-off icon is clicked", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SecretDataModal
|
||||||
|
open={true}
|
||||||
|
onOpenChange={mockOnOpenChange}
|
||||||
|
secretName="test-secret"
|
||||||
|
secretYaml={mockSecretYaml}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reveal first value
|
||||||
|
const revealButtons = screen.getAllByRole("button", { name: /Reveal value/i });
|
||||||
|
await user.click(revealButtons[0]);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("admin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hide it again
|
||||||
|
const hideButton = screen.getByRole("button", { name: /Hide value/i });
|
||||||
|
await user.click(hideButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText("admin")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies secret value to clipboard when copy icon is clicked", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const mockWriteText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
Object.defineProperty(navigator, "clipboard", {
|
||||||
|
value: { writeText: mockWriteText },
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SecretDataModal
|
||||||
|
open={true}
|
||||||
|
onOpenChange={mockOnOpenChange}
|
||||||
|
secretName="test-secret"
|
||||||
|
secretYaml={mockSecretYaml}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find all copy buttons and click the first one
|
||||||
|
const copyButtons = screen.getAllByRole("button", { name: /Copy to clipboard/i });
|
||||||
|
await user.click(copyButtons[0]);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockWriteText).toHaveBeenCalledWith("admin");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays empty state when no data keys exist", () => {
|
||||||
|
const emptySecretYaml = `apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: empty-secret
|
||||||
|
namespace: default
|
||||||
|
type: Opaque
|
||||||
|
data: {}
|
||||||
|
`;
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SecretDataModal
|
||||||
|
open={true}
|
||||||
|
onOpenChange={mockOnOpenChange}
|
||||||
|
secretName="empty-secret"
|
||||||
|
secretYaml={emptySecretYaml}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("No data keys in this secret.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles malformed base64 gracefully", () => {
|
||||||
|
const invalidSecretYaml = `apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: invalid-secret
|
||||||
|
namespace: default
|
||||||
|
type: Opaque
|
||||||
|
data:
|
||||||
|
invalid: !!!not-base64!!!
|
||||||
|
`;
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SecretDataModal
|
||||||
|
open={true}
|
||||||
|
onOpenChange={mockOnOpenChange}
|
||||||
|
secretName="invalid-secret"
|
||||||
|
secretYaml={invalidSecretYaml}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should still render without crashing
|
||||||
|
expect(screen.getByText(/Secret Data: invalid-secret/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -14,6 +14,8 @@ const mockTerminalInstance = {
|
|||||||
onData: vi.fn((cb: (data: string) => void) => {
|
onData: vi.fn((cb: (data: string) => void) => {
|
||||||
onDataHandlers.push(cb);
|
onDataHandlers.push(cb);
|
||||||
}),
|
}),
|
||||||
|
onSelectionChange: vi.fn(),
|
||||||
|
getSelection: vi.fn(() => "selected text"),
|
||||||
loadAddon: vi.fn(),
|
loadAddon: vi.fn(),
|
||||||
options: {} as Record<string, unknown>,
|
options: {} as Record<string, unknown>,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -86,9 +86,9 @@ describe("PodList – LogStreamPanel integration", () => {
|
|||||||
const logsAction = await screen.findByText("Logs");
|
const logsAction = await screen.findByText("Logs");
|
||||||
fireEvent.click(logsAction);
|
fireEvent.click(logsAction);
|
||||||
|
|
||||||
// Verify pod name in dialog
|
// Verify dialog title contains pod name
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/test-pod/i)).toBeInTheDocument();
|
expect(screen.getByText(/Log Stream — test-pod/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verify container dropdown shows containers
|
// Verify container dropdown shows containers
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user