feature/freelens-parity-complete #87
@ -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))),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
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,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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 {};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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} />
|
||||
),
|
||||
}));
|
||||
|
||||
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) => {
|
||||
onDataHandlers.push(cb);
|
||||
}),
|
||||
onSelectionChange: vi.fn(),
|
||||
getSelection: vi.fn(() => "selected text"),
|
||||
loadAddon: vi.fn(),
|
||||
options: {} as Record<string, unknown>,
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user