293 lines
11 KiB
TypeScript
293 lines
11 KiB
TypeScript
import {
|
|
Bookmark,
|
|
ChevronDown,
|
|
CircleUserRound,
|
|
Server,
|
|
Terminal,
|
|
Trash2,
|
|
Usb,
|
|
} from "lucide-react";
|
|
import React, { memo, useCallback, useMemo, useState } from "react";
|
|
import { useI18n } from "../application/i18n/I18nProvider";
|
|
import { resolveHostIconAppearance } from "../domain/hostIcon";
|
|
import { cn } from "../lib/utils";
|
|
import { ConnectionLog, Host } from "../types";
|
|
import { DistroAvatar } from "./DistroAvatar";
|
|
import { ScrollArea } from "./ui/scroll-area";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
|
|
|
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, locale: string) => {
|
|
const date = new Date(timestamp);
|
|
return date.toLocaleDateString(locale || undefined, {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
};
|
|
|
|
// Format time range
|
|
const formatTimeRange = (start: number, end: number | undefined, locale: string, ongoingLabel: string) => {
|
|
const startDate = new Date(start);
|
|
const startTime = startDate.toLocaleTimeString(locale || undefined, {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
|
|
if (!end) {
|
|
return `${startTime} - ${ongoingLabel}`;
|
|
}
|
|
|
|
const endDate = new Date(end);
|
|
const endTime = endDate.toLocaleTimeString(locale || undefined, {
|
|
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 { t, resolvedLocale } = useI18n();
|
|
const isLocal = log.protocol === "local" || log.hostname === "localhost";
|
|
const isSerial = log.protocol === "serial";
|
|
const customHostIcon = resolveHostIconAppearance({
|
|
iconMode: log.hostIconMode,
|
|
iconId: log.hostIconId,
|
|
iconColor: log.hostIconColor,
|
|
});
|
|
const hasPersistedHostIcon = !isLocal && !isSerial && (!!log.hostDistro || !!customHostIcon);
|
|
|
|
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, resolvedLocale)}</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{formatTimeRange(log.startTime, log.endTime, resolvedLocale, t("logs.ongoing"))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* User column */}
|
|
<div className="flex items-center gap-2 w-56 shrink-0">
|
|
<div className="h-9 w-9 rounded-xl bg-emerald-600 text-white dark:bg-emerald-400 dark:text-slate-950 flex items-center justify-center shrink-0">
|
|
<CircleUserRound size={18} strokeWidth={2.25} />
|
|
</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">
|
|
{hasPersistedHostIcon ? (
|
|
<DistroAvatar
|
|
host={{
|
|
os: log.hostOs ?? "linux",
|
|
distro: log.hostDistro,
|
|
distroMode: "auto",
|
|
iconMode: log.hostIconMode,
|
|
iconId: log.hostIconId,
|
|
iconColor: log.hostIconColor,
|
|
}}
|
|
fallback={(log.hostOs ?? "linux")[0].toUpperCase()}
|
|
size="log"
|
|
/>
|
|
) : (
|
|
<div className={cn(
|
|
"h-9 w-9 rounded-xl flex items-center justify-center shrink-0",
|
|
isSerial
|
|
? "bg-amber-600 text-white dark:bg-amber-400 dark:text-slate-950"
|
|
: isLocal
|
|
? "bg-slate-600 text-white dark:bg-slate-300 dark:text-slate-950"
|
|
: "bg-primary text-primary-foreground"
|
|
)}>
|
|
{isSerial ? <Usb size={17} /> : isLocal ? <Terminal size={17} /> : <Server size={17} />}
|
|
</div>
|
|
)}
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium truncate">{isLocal ? t("logs.localTerminal") : log.hostLabel}</div>
|
|
<div className="text-xs text-muted-foreground truncate">
|
|
{isLocal ? "local" : isSerial ? `serial, ${log.hostname}` : `${log.protocol}, ${log.username}`}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Saved column */}
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<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"
|
|
)}
|
|
>
|
|
<Bookmark size={16} fill={log.saved ? "currentColor" : "none"} />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{log.saved ? t("logs.action.unsave") : t("logs.action.save")}</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<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"
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{t("logs.action.delete")}</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
LogItem.displayName = "LogItem";
|
|
|
|
const ConnectionLogsManager: React.FC<ConnectionLogsManagerProps> = ({
|
|
logs,
|
|
hosts: _hosts,
|
|
onToggleSaved,
|
|
onDelete,
|
|
onClearUnsaved: _onClearUnsaved,
|
|
onOpenLogView,
|
|
}) => {
|
|
const { t } = useI18n();
|
|
const INITIAL_RENDER_LIMIT = 30;
|
|
const LOAD_MORE_COUNT = 30;
|
|
|
|
// Track how many items to show
|
|
const [renderLimit, setRenderLimit] = useState(INITIAL_RENDER_LIMIT);
|
|
|
|
// 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, renderLimit);
|
|
}, [filteredLogs, renderLimit]);
|
|
|
|
const hasMore = filteredLogs.length > renderLimit;
|
|
|
|
const handleLoadMore = useCallback(() => {
|
|
setRenderLimit(prev => prev + LOAD_MORE_COUNT);
|
|
}, []);
|
|
|
|
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">
|
|
{t("logs.table.date")}
|
|
<ChevronDown size={12} />
|
|
</div>
|
|
<div className="w-56 shrink-0">{t("logs.table.user")}</div>
|
|
<div className="flex-1">{t("logs.table.host")}</div>
|
|
<div className="w-20 shrink-0 flex items-center gap-1">
|
|
{t("logs.table.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">
|
|
{t("logs.empty.title")}
|
|
</h3>
|
|
<p className="text-sm text-center max-w-sm">
|
|
{t("logs.empty.desc")}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{renderedItems}
|
|
{hasMore && (
|
|
<button
|
|
onClick={handleLoadMore}
|
|
className="w-full py-3 text-sm text-primary hover:bg-secondary/50 transition-colors"
|
|
>
|
|
{t("logs.loadMore", { count: Math.min(LOAD_MORE_COUNT, filteredLogs.length - renderLimit) })}
|
|
</button>
|
|
)}
|
|
</>
|
|
)}
|
|
</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);
|