This commit is contained in:
RGJorge
2026-05-10 20:49:06 +00:00
parent 0ebe000a26
commit 713504dc65
34 changed files with 918 additions and 327 deletions
+114
View File
@@ -0,0 +1,114 @@
import { useState } from "react";
import { AlertCircle, X, Copy, Check, ChevronDown, ChevronUp } from "lucide-react";
import type { ActionError } from "../../shared/types";
import { useT } from "../i18n";
const PREVIEW_CHAR_LIMIT = 180;
function ToastItem({ err, onDismiss }: { err: ActionError; onDismiss: (id: string) => void }) {
const { t } = useT();
const [copied, setCopied] = useState(false);
const [expanded, setExpanded] = useState(false);
const shortName = err.uid.split("/").pop() || err.uid;
const project = err.uid.includes("/") ? err.uid.split("/")[0] : null;
const isLong = err.error.length > PREVIEW_CHAR_LIMIT;
const visibleError = expanded || !isLong ? err.error : err.error.slice(0, PREVIEW_CHAR_LIMIT) + "…";
const copy = async () => {
let ok = false;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(err.error);
ok = true;
} else {
const ta = document.createElement("textarea");
ta.value = err.error;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
ok = document.execCommand("copy");
document.body.removeChild(ta);
}
} catch {}
if (ok) {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
};
return (
<div className="bg-slate-800/95 backdrop-blur-sm border border-red-500/40 rounded-lg shadow-xl shadow-black/50 w-[380px] overflow-hidden">
<div className="flex items-start gap-2.5 px-3.5 py-2.5 border-b border-red-500/20 bg-red-500/10">
<AlertCircle size={16} className="text-red-400 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-red-300 capitalize">
{t("toast.actionFailed").replace("{action}", err.action)}
</div>
<div className="flex items-baseline gap-1.5 mt-0.5">
<span className="text-xs text-slate-200 font-medium truncate">{shortName}</span>
{project && <span className="text-[10px] text-slate-500 truncate">{project}</span>}
</div>
</div>
<button
onClick={() => onDismiss(err.id)}
className="p-1 rounded hover:bg-slate-700/60 text-slate-500 hover:text-slate-300 transition-colors shrink-0"
title={t("toast.dismiss")}
>
<X size={14} />
</button>
</div>
<div className="px-3.5 py-2.5">
<pre className="text-[11px] text-slate-300 font-mono whitespace-pre-wrap break-words leading-snug max-h-48 overflow-auto">
{visibleError}
</pre>
<div className="flex items-center justify-end gap-1 mt-2">
{isLong && (
<button
onClick={() => setExpanded((v) => !v)}
className="flex items-center gap-1 px-2 py-1 text-[11px] text-slate-400 hover:text-slate-200 hover:bg-slate-700/60 rounded transition-colors"
>
{expanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
{expanded ? t("toast.collapse") : t("toast.expand")}
</button>
)}
<button
onClick={copy}
className="flex items-center gap-1 px-2 py-1 text-[11px] text-slate-400 hover:text-slate-200 hover:bg-slate-700/60 rounded transition-colors"
>
{copied ? <Check size={12} className="text-emerald-400" /> : <Copy size={12} />}
{copied ? t("toast.copied") : t("toast.copy")}
</button>
</div>
</div>
</div>
);
}
interface ActionErrorToastProps {
errors: ActionError[];
onDismiss: (id: string) => void;
onClearAll: () => void;
}
export function ActionErrorToast({ errors, onDismiss, onClearAll }: ActionErrorToastProps) {
const { t } = useT();
if (errors.length === 0) return null;
return (
<div className="fixed top-16 right-4 z-50 flex flex-col gap-2">
{errors.length > 1 && (
<button
onClick={onClearAll}
className="self-end text-[10px] text-slate-500 hover:text-slate-300 underline transition-colors"
>
{t("toast.dismissAll")} ({errors.length})
</button>
)}
{errors.map((err) => (
<ToastItem key={err.id} err={err} onDismiss={onDismiss} />
))}
</div>
);
}
+25 -12
View File
@@ -1,17 +1,18 @@
import { useEffect, useRef } from "react";
import { RotateCw, Square, Play, Trash2, Terminal, ExternalLink, Hammer } from "lucide-react";
import { RotateCw, Square, Play, Trash2, Terminal, ExternalLink, Hammer, Lock, RefreshCw } from "lucide-react";
import type { Service } from "../../shared/types";
import { useT } from "../i18n";
interface NodeContextMenuProps {
position: { x: number; y: number };
service: Service;
onAction: (action: "start" | "stop" | "restart" | "remove" | "rebuild") => void;
locked?: boolean;
onAction: (action: "start" | "stop" | "restart" | "remove" | "rebuild" | "recreate") => void;
onOpenLogs: () => void;
onClose: () => void;
}
export function NodeContextMenu({ position, service, onAction, onOpenLogs, onClose }: NodeContextMenuProps) {
export function NodeContextMenu({ position, service, locked, onAction, onOpenLogs, onClose }: NodeContextMenuProps) {
const { t } = useT();
const ref = useRef<HTMLDivElement>(null);
@@ -48,21 +49,31 @@ export function NodeContextMenu({ position, service, onAction, onOpenLogs, onClo
className="fixed z-[10000] bg-slate-800 border border-slate-700 rounded-lg shadow-xl shadow-black/50 py-1.5 min-w-[180px]"
style={{ left: x, top: y }}
>
{locked && (
<>
<div className="flex items-center gap-2 px-3.5 py-1.5 text-[11px] text-slate-500 bg-slate-900/40">
<Lock size={11} className="text-slate-500" />
<span>{t("access.viewOnly")}</span>
</div>
<div className="border-t border-slate-700/50" />
</>
)}
{isRunning ? (
<>
<MenuItem icon={RotateCw} label={t("actions.restart")} color="text-yellow-400" onClick={() => { onAction("restart"); onClose(); }} />
<MenuItem icon={Square} label={t("actions.stop")} color="text-red-400" onClick={() => { onAction("stop"); onClose(); }} />
<MenuItem icon={RotateCw} label={t("actions.restart")} tooltip={t("actions.restart.tooltip")} color="text-yellow-400" disabled={locked} onClick={() => { onAction("restart"); onClose(); }} />
<MenuItem icon={Square} label={t("actions.stop")} tooltip={t("actions.stop.tooltip")} color="text-red-400" disabled={locked} onClick={() => { onAction("stop"); onClose(); }} />
</>
) : (
<>
<MenuItem icon={Play} label={t("actions.start")} color="text-emerald-400" onClick={() => { onAction("start"); onClose(); }} />
<MenuItem icon={Trash2} label={t("actions.remove")} color="text-red-400" onClick={() => { onAction("remove"); onClose(); }} />
<MenuItem icon={Play} label={t("actions.start")} tooltip={t("actions.start.tooltip")} color="text-emerald-400" disabled={locked} onClick={() => { onAction("start"); onClose(); }} />
<MenuItem icon={Trash2} label={t("actions.remove")} tooltip={t("actions.remove.tooltip")} color="text-red-400" disabled={locked} onClick={() => { onAction("remove"); onClose(); }} />
</>
)}
{service.compose_file && (
<>
<div className="border-t border-slate-700/50 my-1" />
<MenuItem icon={Hammer} label={t("actions.rebuild")} color="text-cyan-400" onClick={() => { onAction("rebuild"); onClose(); }} />
<MenuItem icon={RefreshCw} label={t("actions.recreate")} tooltip={t("actions.recreate.tooltip")} color="text-cyan-400" disabled={locked} onClick={() => { onAction("recreate"); onClose(); }} />
<MenuItem icon={Hammer} label={t("actions.rebuild")} tooltip={t("actions.rebuild.tooltip")} color="text-cyan-400" disabled={locked} onClick={() => { onAction("rebuild"); onClose(); }} />
</>
)}
<div className="border-t border-slate-700/50 my-1" />
@@ -83,13 +94,15 @@ export function NodeContextMenu({ position, service, onAction, onOpenLogs, onClo
);
}
function MenuItem({ icon: Icon, label, color, onClick }: { icon: typeof Play; label: string; color: string; onClick: () => void }) {
function MenuItem({ icon: Icon, label, color, onClick, disabled, tooltip }: { icon: typeof Play; label: string; color: string; onClick: () => void; disabled?: boolean; tooltip?: string }) {
return (
<button
onClick={onClick}
className="flex items-center gap-2.5 w-full px-3.5 py-2 text-sm text-slate-300 hover:bg-slate-700/60 transition-colors"
onClick={disabled ? undefined : onClick}
disabled={disabled}
title={tooltip}
className={`flex items-center gap-2.5 w-full px-3.5 py-2 text-sm transition-colors ${disabled ? "text-slate-600 cursor-not-allowed" : "text-slate-300 hover:bg-slate-700/60"}`}
>
<Icon size={14} className={color} />
<Icon size={14} className={disabled ? "text-slate-600" : color} />
<span>{label}</span>
</button>
);
+43
View File
@@ -0,0 +1,43 @@
import { useState } from "react";
import { HelpCircle } from "lucide-react";
interface TooltipProps {
text: string;
/** Width of the tooltip popover. Default: w-56 */
width?: string;
/** Icon size. Default: 13 */
size?: number;
/** Where the popover opens relative to the icon. Default: "top" */
placement?: "top" | "bottom";
}
export function Tooltip({ text, width = "w-56", size = 13, placement = "top" }: TooltipProps) {
const [show, setShow] = useState(false);
const popoverPos =
placement === "top"
? "bottom-full mb-2"
: "top-full mt-2";
const arrowPos =
placement === "top"
? "top-full -mt-px border-t-slate-700"
: "bottom-full -mb-px border-b-slate-700";
return (
<span className="relative inline-flex">
<button
type="button"
onMouseEnter={() => setShow(true)}
onMouseLeave={() => setShow(false)}
onClick={(e) => { e.stopPropagation(); setShow((v) => !v); }}
className="text-slate-500 hover:text-slate-300 transition-colors"
>
<HelpCircle size={size} />
</button>
{show && (
<div className={`absolute ${popoverPos} left-1/2 -translate-x-1/2 px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-xs text-slate-200 ${width} text-left shadow-xl z-50 leading-relaxed whitespace-normal`}>
{text}
<div className={`absolute ${arrowPos} left-1/2 -translate-x-1/2 border-4 border-transparent`} />
</div>
)}
</span>
);
}