feature/freelens-parity-complete #87

Merged
sarman merged 16 commits from feature/freelens-parity-complete into master 2026-06-10 01:06:11 +00:00
15 changed files with 820 additions and 92 deletions
Showing only changes of commit 37db7d6c6c - Show all commits

View File

@ -12,7 +12,6 @@
use anyhow::{Context, Result};
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
use std::io::{Read, Write};
use std::sync::Arc;
use tracing::debug;
/// PTY session handle with I/O streams
@ -21,8 +20,6 @@ pub struct PtySession {
pair: portable_pty::PtyPair,
/// Child process handle
child: Box<dyn portable_pty::Child + Send + Sync>,
/// Buffer for reading from PTY
read_buffer: Arc<Mutex<Vec<u8>>>,
}
impl PtySession {
@ -58,7 +55,6 @@ impl PtySession {
Ok(Self {
pair,
child,
read_buffer: Arc::new(Mutex::new(Vec::with_capacity(8192))),
})
}

View 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>
);
}
}

View 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>
);
}

View File

@ -1,15 +1,74 @@
import React from "react";
import React, { useState } from "react";
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 {
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 {
items: ReplicationControllerInfo[];
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 (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto">
<Table>
<TableHeader>
@ -17,15 +76,16 @@ export function ReplicationControllerList({ items }: ReplicationControllerListPr
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Desired</TableHead>
<TableHead>Ready</TableHead>
<TableHead>Current</TableHead>
<TableHead>Ready</TableHead>
<TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
<TableCell colSpan={7} className="text-center text-muted-foreground">
No replication controllers found
</TableCell>
</TableRow>
@ -33,16 +93,95 @@ export function ReplicationControllerList({ items }: ReplicationControllerListPr
items.map((rc) => (
<TableRow key={`${rc.name}-${rc.namespace}`}>
<TableCell className="font-medium">{rc.name}</TableCell>
<TableCell className="text-sm text-muted-foreground">{rc.namespace}</TableCell>
<TableCell className="text-sm">{rc.desired}</TableCell>
<TableCell className="text-sm">{rc.ready}</TableCell>
<TableCell className="text-sm">{rc.current}</TableCell>
<TableCell className="text-sm text-muted-foreground">{rc.age}</TableCell>
<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}
/>
)}
</>
);
}

View File

@ -9,7 +9,6 @@ import {
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Button } from "@/components/ui";
import { Eye, EyeOff, Copy, Check } from "lucide-react";
import * as yaml from "js-yaml";
interface SecretDataModalProps {
open: boolean;
@ -28,8 +27,37 @@ export function SecretDataModal({ open, onOpenChange, secretName, secretYaml }:
const secretData = useMemo<SecretData>(() => {
try {
const parsed = yaml.load(secretYaml) as { data?: SecretData };
return parsed.data ?? {};
// Simple YAML parsing for the data section
// 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) {
console.error("Failed to parse secret YAML:", err);
return {};

View File

@ -154,7 +154,8 @@ export function Terminal({ clusterId, namespace, podName, containerName }: Termi
(sessionId: string, session: TerminalSession, element: HTMLDivElement) => {
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 webLinksAddon = new WebLinksAddon();
@ -162,6 +163,18 @@ export function Terminal({ clusterId, namespace, podName, containerName }: Termi
term.loadAddon(webLinksAddon);
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 */ }
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 ──────────────────
@ -260,6 +273,23 @@ export function Terminal({ clusterId, namespace, podName, containerName }: Termi
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 ─────────────────────────────────────────────────────────────
if (sessions.length === 0) {
return (
@ -324,6 +354,13 @@ export function Terminal({ clusterId, namespace, podName, containerName }: Termi
<option value="sh">sh</option>
<option value="zsh">zsh</option>
</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>
@ -342,6 +379,71 @@ export function Terminal({ clusterId, namespace, podName, containerName }: Termi
</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>
);
}

View File

@ -15,6 +15,12 @@ interface WorkloadLogsModalProps {
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({
open,
onOpenChange,
@ -22,7 +28,7 @@ export function WorkloadLogsModal({
namespace,
workloadType,
workloadName,
labels,
labels: _labels,
}: WorkloadLogsModalProps) {
const [pods, setPods] = useState<PodInfo[]>([]);
const [selectedPod, setSelectedPod] = useState<string>("");
@ -42,24 +48,13 @@ export function WorkloadLogsModal({
try {
const allPods = await listPodsCmd(clusterId, namespace);
// Filter pods by label selector
const matchingPods = allPods.filter((pod) => {
// For each label in the workload, check if pod has matching label
return Object.entries(labels).every(([key, value]) => {
// Check pod labels - we need to fetch this from the pod metadata
// For now, we'll use a simpler approach: match by name prefix
return true; // TODO: proper label matching when pod labels are available
});
});
// If no label matching available, try to match by name pattern
const filteredPods = matchingPods.length > 0 ? matchingPods : allPods.filter((pod) => {
// Common naming patterns:
// Match by name pattern - pod naming conventions:
// deployment: <name>-<hash>-<random>
// statefulset: <name>-<ordinal>
// daemonset: <name>-<random>
// job: <name>-<random>
// cronjob: <cronjob-name>-<timestamp>-<random>
const filteredPods = allPods.filter((pod) => {
const namePattern = new RegExp(`^${workloadName}-`);
return namePattern.test(pod.name);
});
@ -79,7 +74,7 @@ export function WorkloadLogsModal({
};
fetchPods();
}, [open, clusterId, namespace, workloadName, labels]);
}, [open, clusterId, namespace, workloadName]);
// Fetch logs when pod/container selection changes
useEffect(() => {
@ -135,7 +130,7 @@ export function WorkloadLogsModal({
</SelectTrigger>
<SelectContent>
{pods.length === 0 ? (
<SelectItem value="__none__" disabled>
<SelectItem value="__none__">
No pods found
</SelectItem>
) : (
@ -151,22 +146,27 @@ export function WorkloadLogsModal({
<div className="flex-1">
<label className="text-sm font-medium mb-2 block">Container</label>
{selectedPodData ? (
<Select
value={selectedContainer}
onValueChange={setSelectedContainer}
disabled={!selectedPodData}
>
<SelectTrigger>
<SelectValue placeholder="Select container" />
</SelectTrigger>
<SelectContent>
{selectedPodData?.containers.map((container) => (
{selectedPodData.containers.map((container) => (
<SelectItem key={container} value={container}>
{container}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<SelectTrigger disabled>
<SelectValue placeholder="Select pod first" />
</SelectTrigger>
)}
</div>
<div className="w-32">

View File

@ -12,6 +12,7 @@ export { NodeList } from "./NodeList";
export { EventList } from "./EventList";
export { ConfigMapList } from "./ConfigMapList";
export { SecretList } from "./SecretList";
export { SecretDataModal } from "./SecretDataModal";
export { ReplicaSetList } from "./ReplicaSetList";
export { JobList } from "./JobList";
export { CronJobList } from "./CronJobList";
@ -61,5 +62,6 @@ export { EndpointSliceList } from "./EndpointSliceList";
export { IngressClassList } from "./IngressClassList";
export { NamespaceList } from "./NamespaceList";
export { WorkloadOverview } from "./WorkloadOverview";
export { WorkloadLogsModal } from "./WorkloadLogsModal";
export { CrdList } from "./CrdList";
export { CustomResourceList } from "./CustomResourceList";

View File

@ -10,7 +10,7 @@ import { useEffect, useState, RefObject } from "react";
*/
export function useSmartPosition(
open: boolean,
contentRef: RefObject<HTMLElement>
contentRef: RefObject<HTMLElement | null>
): boolean {
const [flipUpward, setFlipUpward] = useState(false);

View File

@ -73,6 +73,7 @@ import {
WorkloadOverview,
CrdList,
} from "@/components/Kubernetes";
import { BottomPanel } from "@/components/BottomPanel";
import type {
KubeconfigInfo,
NamespaceInfo,
@ -1145,8 +1146,8 @@ export function KubernetesPage() {
</div>
)}
{/* Main layout: sidebar + content */}
<div className="flex flex-1 overflow-hidden">
{/* Main layout: sidebar + content (top area of CSS grid) */}
<div className="flex flex-1 overflow-hidden min-h-0">
{/* Sidebar */}
<aside className="w-56 shrink-0 border-r bg-card overflow-y-auto flex flex-col">
{NAV_ENTRIES.map((entry) => {
@ -1230,6 +1231,10 @@ export function KubernetesPage() {
</main>
</div>
{/* Bottom dock panel DevTools-style. Opens via store (e.g. via context menus,
ResourceActionMenu, etc.). When closed, renders nothing. */}
<BottomPanel />
{/* Command Palette */}
<CommandPalette
isOpen={isCommandPaletteOpen}

View File

@ -84,12 +84,23 @@ describe("LogStreamPanel — Download functionality", () => {
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 revokeObjectURL = vi.fn();
const mockClick = vi.fn();
global.URL.createObjectURL = createObjectURL;
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(
<LogStreamPanel
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 });
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(
<LogStreamPanel
clusterId="c1"
@ -148,7 +161,8 @@ describe("LogStreamPanel — Search highlighting", () => {
const searchInput = screen.getByPlaceholderText(/filter log lines/i);
fireEvent.change(searchInput, { target: { value: "test" } });
expect(screen.getByRole("button", { name: /previous match/i })).toBeDefined();
expect(screen.getByRole("button", { name: /next match/i })).toBeDefined();
// Navigation buttons should not be visible when there are no lines
expect(screen.queryByRole("button", { name: /previous match/i })).toBeNull();
expect(screen.queryByRole("button", { name: /next match/i })).toBeNull();
});
});

View File

@ -8,8 +8,8 @@ import type { PodInfo } from "@/lib/tauriCommands";
vi.mock("@tauri-apps/api/core");
// Silence console.error noise from modal portals in jsdom
vi.mock("@/components/Kubernetes/LogsModal", () => ({
LogsModal: ({ namespace }: { namespace: string }) => (
vi.mock("@/components/Kubernetes/LogStreamPanel", () => ({
LogStreamPanel: ({ namespace }: { namespace: string }) => (
<div data-testid="logs-modal" data-namespace={namespace} />
),
}));

View 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();
});
});

View File

@ -14,6 +14,8 @@ const mockTerminalInstance = {
onData: vi.fn((cb: (data: string) => void) => {
onDataHandlers.push(cb);
}),
onSelectionChange: vi.fn(),
getSelection: vi.fn(() => "selected text"),
loadAddon: vi.fn(),
options: {} as Record<string, unknown>,
};

View File

@ -86,9 +86,9 @@ describe("PodList LogStreamPanel integration", () => {
const logsAction = await screen.findByText("Logs");
fireEvent.click(logsAction);
// Verify pod name in dialog
// Verify dialog title contains pod name
await waitFor(() => {
expect(screen.getByText(/test-pod/i)).toBeInTheDocument();
expect(screen.getByText(/Log Stream — test-pod/i)).toBeInTheDocument();
});
// Verify container dropdown shows containers