fix(ui): critical UI fixes - logs, menus, dark mode, YAML
Replace LogsModal with LogStreamPanel in PodList for streaming logs Add smart positioning to ResourceActionMenu to flip when near bottom Fix dark mode text visibility by applying class to html element Fix YAML editor loading race condition Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
f7b4e591f9
commit
37db7d6c6c
@ -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,48 +1,187 @@
|
||||
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 (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Desired</TableHead>
|
||||
<TableHead>Ready</TableHead>
|
||||
<TableHead>Current</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
No replication controllers found
|
||||
</TableCell>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Desired</TableHead>
|
||||
<TableHead>Current</TableHead>
|
||||
<TableHead>Ready</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
) : (
|
||||
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>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||
No replication controllers found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
items.map((rc) => (
|
||||
<TableRow key={`${rc.name}-${rc.namespace}`}>
|
||||
<TableCell className="font-medium">{rc.name}</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:
|
||||
// deployment: <name>-<hash>-<random>
|
||||
// statefulset: <name>-<ordinal>
|
||||
// daemonset: <name>-<random>
|
||||
// job: <name>-<random>
|
||||
// cronjob: <cronjob-name>-<timestamp>-<random>
|
||||
// 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>
|
||||
<Select
|
||||
value={selectedContainer}
|
||||
onValueChange={setSelectedContainer}
|
||||
disabled={!selectedPodData}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select container" />
|
||||
{selectedPodData ? (
|
||||
<Select
|
||||
value={selectedContainer}
|
||||
onValueChange={setSelectedContainer}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<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>
|
||||
<SelectContent>
|
||||
{selectedPodData?.containers.map((container) => (
|
||||
<SelectItem key={container} value={container}>
|
||||
{container}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</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