mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
3b608834f6
Implements persistent logging of terminal sessions, capturing system info and terminal output for each connection, including local and remote. Introduces a logs section in the UI to view, save, delete, and replay connection logs in read-only tabs. Adds deduplication for known hosts and improves tab management to support log replays alongside terminal and workspace tabs. Enables future auditing, troubleshooting, and session review by storing terminal data and session metadata.
241 lines
8.4 KiB
TypeScript
241 lines
8.4 KiB
TypeScript
import {
|
|
Bookmark,
|
|
ChevronDown,
|
|
Server,
|
|
Terminal,
|
|
Trash2,
|
|
User,
|
|
} from "lucide-react";
|
|
import React, { memo, useCallback, useMemo } from "react";
|
|
import { cn } from "../lib/utils";
|
|
import { ConnectionLog, Host } from "../types";
|
|
import { ScrollArea } from "./ui/scroll-area";
|
|
|
|
interface ConnectionLogsManagerProps {
|
|
logs: ConnectionLog[];
|
|
hosts: Host[];
|
|
onToggleSaved: (id: string) => void;
|
|
onDelete: (id: string) => void;
|
|
onClearUnsaved: () => void;
|
|
onOpenLogView: (log: ConnectionLog) => void;
|
|
}
|
|
|
|
// Format date for display
|
|
const formatDate = (timestamp: number) => {
|
|
const date = new Date(timestamp);
|
|
return date.toLocaleDateString("en-US", {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
};
|
|
|
|
// Format time range
|
|
const formatTimeRange = (start: number, end?: number) => {
|
|
const startDate = new Date(start);
|
|
const startTime = startDate.toLocaleTimeString("en-US", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
|
|
if (!end) {
|
|
return `${startTime} - ongoing`;
|
|
}
|
|
|
|
const endDate = new Date(end);
|
|
const endTime = endDate.toLocaleTimeString("en-US", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
|
|
return `${startTime} - ${endTime}`;
|
|
};
|
|
|
|
// Log Item Component
|
|
interface LogItemProps {
|
|
log: ConnectionLog;
|
|
onToggleSaved: (id: string) => void;
|
|
onDelete: (id: string) => void;
|
|
onClick: () => void;
|
|
}
|
|
|
|
const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) => {
|
|
const isLocal = log.protocol === "local" || log.hostname === "localhost";
|
|
|
|
return (
|
|
<div
|
|
className="group flex items-center gap-4 px-4 py-3 hover:bg-secondary/60 transition-colors border-b border-border/30 last:border-b-0 cursor-pointer"
|
|
onClick={onClick}
|
|
>
|
|
{/* Date column */}
|
|
<div className="w-32 shrink-0">
|
|
<div className="text-sm font-medium">{formatDate(log.startTime)}</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{formatTimeRange(log.startTime, log.endTime)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* User column */}
|
|
<div className="flex items-center gap-2 w-56 shrink-0">
|
|
<div className="h-8 w-8 rounded-full bg-primary/10 text-primary flex items-center justify-center shrink-0">
|
|
<User size={14} />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium truncate">{log.localUsername}</div>
|
|
<div className="text-xs text-muted-foreground truncate">{log.localHostname}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Host column */}
|
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|
<div className={cn(
|
|
"h-8 w-8 rounded-lg flex items-center justify-center shrink-0",
|
|
isLocal ? "bg-emerald-500/10 text-emerald-500" : "bg-blue-500/10 text-blue-500"
|
|
)}>
|
|
{isLocal ? <Terminal size={14} /> : <Server size={14} />}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium truncate">{isLocal ? "Local Terminal" : log.hostLabel}</div>
|
|
<div className="text-xs text-muted-foreground truncate">
|
|
{isLocal ? "local" : `${log.protocol}, ${log.username}`}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Saved column */}
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onToggleSaved(log.id);
|
|
}}
|
|
className={cn(
|
|
"p-1.5 rounded-md transition-colors",
|
|
log.saved
|
|
? "text-primary bg-primary/10"
|
|
: "text-muted-foreground hover:text-primary hover:bg-primary/10"
|
|
)}
|
|
title={log.saved ? "Unsave" : "Save"}
|
|
>
|
|
<Bookmark size={16} fill={log.saved ? "currentColor" : "none"} />
|
|
</button>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onDelete(log.id);
|
|
}}
|
|
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100"
|
|
title="Delete"
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
LogItem.displayName = "LogItem";
|
|
|
|
const ConnectionLogsManager: React.FC<ConnectionLogsManagerProps> = ({
|
|
logs,
|
|
hosts: _hosts,
|
|
onToggleSaved,
|
|
onDelete,
|
|
onClearUnsaved: _onClearUnsaved,
|
|
onOpenLogView,
|
|
}) => {
|
|
const RENDER_LIMIT = 100;
|
|
|
|
// Sort logs by newest first
|
|
const filteredLogs = useMemo(() => {
|
|
return [...logs].sort((a, b) => b.startTime - a.startTime);
|
|
}, [logs]);
|
|
|
|
const displayedLogs = useMemo(() => {
|
|
return filteredLogs.slice(0, RENDER_LIMIT);
|
|
}, [filteredLogs]);
|
|
|
|
const hasMore = filteredLogs.length > RENDER_LIMIT;
|
|
|
|
const handleToggleSaved = useCallback(
|
|
(id: string) => onToggleSaved(id),
|
|
[onToggleSaved],
|
|
);
|
|
|
|
const handleDelete = useCallback(
|
|
(id: string) => onDelete(id),
|
|
[onDelete],
|
|
);
|
|
|
|
// Rendered items
|
|
const renderedItems = useMemo(() => {
|
|
return displayedLogs.map((log) => (
|
|
<LogItem
|
|
key={log.id}
|
|
log={log}
|
|
onToggleSaved={handleToggleSaved}
|
|
onDelete={handleDelete}
|
|
onClick={() => onOpenLogView(log)}
|
|
/>
|
|
));
|
|
}, [displayedLogs, handleToggleSaved, handleDelete, onOpenLogView]);
|
|
|
|
return (
|
|
<div className="h-full flex flex-col">
|
|
{/* Table Header */}
|
|
{displayedLogs.length > 0 && (
|
|
<div className="flex items-center gap-4 px-4 py-2 text-xs font-medium text-muted-foreground border-b border-border/30 bg-secondary/30">
|
|
<div className="w-32 shrink-0 flex items-center gap-1">
|
|
Date
|
|
<ChevronDown size={12} />
|
|
</div>
|
|
<div className="w-56 shrink-0">User</div>
|
|
<div className="flex-1">Host</div>
|
|
<div className="w-20 shrink-0 flex items-center gap-1">
|
|
Saved
|
|
<Bookmark size={12} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Content */}
|
|
<ScrollArea className="flex-1">
|
|
<div>
|
|
{displayedLogs.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
|
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
|
|
<Terminal size={32} className="opacity-60" />
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-foreground mb-2">
|
|
No Connection Logs
|
|
</h3>
|
|
<p className="text-sm text-center max-w-sm">
|
|
Your connection history will appear here when you connect to hosts or open local terminals.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{renderedItems}
|
|
{hasMore && (
|
|
<div className="text-center py-4 text-sm text-muted-foreground">
|
|
Showing {RENDER_LIMIT} of {filteredLogs.length} logs.
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Custom comparison
|
|
const logsManagerAreEqual = (
|
|
prev: ConnectionLogsManagerProps,
|
|
next: ConnectionLogsManagerProps,
|
|
): boolean => {
|
|
return prev.logs === next.logs && prev.hosts === next.hosts;
|
|
};
|
|
|
|
export default memo(ConnectionLogsManager, logsManagerAreEqual);
|