mirror of
https://github.com/RGJorge/ContainerFlow.git
synced 2026-08-03 07:21:42 +02:00
v0.0.30
This commit is contained in:
@@ -4,7 +4,7 @@ import {
|
||||
LayoutDashboard, Activity, Settings, Bell,
|
||||
Play, Square, RotateCcw,
|
||||
} from "lucide-react";
|
||||
import type { Service, DockerEvent } from "../../shared/types";
|
||||
import type { Service, DockerEvent, NotificationLogEntry } from "../../shared/types";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
export type Page = "dashboard" | "monitoring" | "settings";
|
||||
|
||||
@@ -68,6 +68,28 @@ export function Sparkline({
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Clip canvas drawing to a rounded rectangle matching the `rounded-lg`
|
||||
// (8px radius) of the parent wrapper. This is more robust than relying
|
||||
// on CSS overflow:hidden alone — guarantees no fill/stroke leaks past
|
||||
// the rounded shape due to subpixel/anti-aliasing artifacts.
|
||||
const RADIUS = 8;
|
||||
ctx.beginPath();
|
||||
if (typeof (ctx as any).roundRect === "function") {
|
||||
(ctx as any).roundRect(0, 0, w, h, RADIUS);
|
||||
} else {
|
||||
// Fallback for older browsers
|
||||
ctx.moveTo(RADIUS, 0);
|
||||
ctx.lineTo(w - RADIUS, 0);
|
||||
ctx.quadraticCurveTo(w, 0, w, RADIUS);
|
||||
ctx.lineTo(w, h - RADIUS);
|
||||
ctx.quadraticCurveTo(w, h, w - RADIUS, h);
|
||||
ctx.lineTo(RADIUS, h);
|
||||
ctx.quadraticCurveTo(0, h, 0, h - RADIUS);
|
||||
ctx.lineTo(0, RADIUS);
|
||||
ctx.quadraticCurveTo(0, 0, RADIUS, 0);
|
||||
}
|
||||
ctx.clip();
|
||||
|
||||
if (data.length === 0) {
|
||||
ctx.fillStyle = "#64748b";
|
||||
ctx.font = "11px sans-serif";
|
||||
@@ -79,7 +101,16 @@ export function Sparkline({
|
||||
const plotW = w - PAD.left - PAD.right;
|
||||
const plotH = h - PAD.top - PAD.bottom;
|
||||
const avg = data.reduce((a, b) => a + b, 0) / data.length;
|
||||
const max = Math.max(...data, threshold ?? 0, avg, 1);
|
||||
|
||||
// Auto-scale Y to data range with 30% headroom for visual breathing room.
|
||||
// Floor at 0.1 (not 1) so values like 0.1% don't get pancaked against the bottom.
|
||||
// Threshold is included in the scale ONLY when data is reasonably close to it
|
||||
// (≥30% of threshold); otherwise low values would get pancaked at the bottom.
|
||||
const dataMax = Math.max(...data);
|
||||
let max = Math.max(dataMax * 1.3, 0.1);
|
||||
if (threshold !== undefined && threshold > 0 && dataMax >= threshold * 0.3) {
|
||||
max = Math.max(max, threshold * 1.1);
|
||||
}
|
||||
const range = max || 1;
|
||||
const xStep = data.length > 1 ? plotW / (data.length - 1) : plotW;
|
||||
|
||||
@@ -302,7 +333,7 @@ export function Sparkline({
|
||||
ref={canvasRef}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className="cursor-crosshair"
|
||||
className="cursor-crosshair block"
|
||||
/>
|
||||
</div>
|
||||
{/* Tooltip — below the chart */}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function StatsCard({
|
||||
<div className="flex items-center gap-3 bg-slate-800/60 border border-slate-700/40 rounded-lg px-3 py-2.5 overflow-visible">
|
||||
{/* Left: label + value + limit */}
|
||||
<div className="shrink-0 min-w-[52px]">
|
||||
<span className="text-[10px] uppercase tracking-wider text-slate-500 block leading-tight">{label}</span>
|
||||
<span className="text-[10px] uppercase tracking-wider text-slate-500 block leading-tight whitespace-pre-line">{label}</span>
|
||||
<span className="text-sm font-mono font-semibold block leading-tight mt-0.5" style={{ color }}>
|
||||
{value}
|
||||
</span>
|
||||
@@ -53,7 +53,7 @@ export function StatsCard({
|
||||
)}
|
||||
</div>
|
||||
{/* Center: sparkline */}
|
||||
<div className="flex-1 min-w-0 bg-slate-900/60 rounded-lg pt-1 overflow-visible">
|
||||
<div className="flex-1 min-w-0 bg-slate-900/60 rounded-lg overflow-visible">
|
||||
<Sparkline
|
||||
data={data}
|
||||
timestamps={timestamps}
|
||||
|
||||
@@ -65,11 +65,15 @@ export function ThresholdBar({ label, value, threshold, isCustom, showThreshold,
|
||||
className={`relative ${showThreshold ? "h-3" : "h-2"} bg-slate-800 rounded-full group ${showThreshold ? "cursor-pointer" : ""}`}
|
||||
onClick={(e) => { if (showThreshold && !dragging) onThresholdChange(calcPercent(e.clientX)); }}
|
||||
>
|
||||
{/* Usage fill */}
|
||||
<div
|
||||
className={`absolute inset-y-0 left-0 rounded-full transition-all duration-500 ${barColor}`}
|
||||
style={{ width: `${Math.min(value, 100)}%` }}
|
||||
/>
|
||||
{/* Clip wrapper — ensures the fill always respects the track's rounded shape,
|
||||
even at very low values (otherwise rounded-full on the inner fill creates
|
||||
a tiny pill that looks detached from the track edge). */}
|
||||
<div className="absolute inset-0 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`absolute inset-y-0 left-0 transition-all duration-500 ${barColor}`}
|
||||
style={{ width: `${Math.min(value, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
{/* Threshold handle — only when notifications enabled */}
|
||||
{showThreshold && (
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useRef, useLayoutEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { HelpCircle } from "lucide-react";
|
||||
|
||||
interface TooltipProps {
|
||||
@@ -8,36 +9,139 @@ interface TooltipProps {
|
||||
/** Icon size. Default: 13 */
|
||||
size?: number;
|
||||
/** Where the popover opens relative to the icon. Default: "top" */
|
||||
placement?: "top" | "bottom";
|
||||
placement?: "top" | "bottom" | "right";
|
||||
/** Keep text on a single line (no wrapping). Width grows to fit content. */
|
||||
nowrap?: boolean;
|
||||
}
|
||||
|
||||
export function Tooltip({ text, width = "w-56", size = 13, placement = "top" }: TooltipProps) {
|
||||
const GAP = 8; // px between icon and popover
|
||||
|
||||
export function Tooltip({ text, width = "w-56", size = 13, placement = "top", nowrap = false }: 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";
|
||||
const [pos, setPos] = useState<{ left: number; top: number; arrowLeft: number; arrowTop: number; flippedTo: "top" | "bottom" | "right" } | null>(null);
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!show || !btnRef.current) return;
|
||||
|
||||
const compute = () => {
|
||||
const btn = btnRef.current;
|
||||
const pop = popRef.current;
|
||||
if (!btn || !pop) return;
|
||||
const btnRect = btn.getBoundingClientRect();
|
||||
const popRect = pop.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
const btnCenterX = btnRect.left + btnRect.width / 2;
|
||||
const btnCenterY = btnRect.top + btnRect.height / 2;
|
||||
|
||||
let left: number;
|
||||
let top: number;
|
||||
let arrowLeft = 0;
|
||||
let arrowTop = 0;
|
||||
let actual: "top" | "bottom" | "right" = placement;
|
||||
|
||||
if (placement === "right") {
|
||||
// Popover to the right of icon, vertically centered
|
||||
left = btnRect.right + GAP;
|
||||
top = btnCenterY - popRect.height / 2;
|
||||
// Flip to left/bottom if no room to the right
|
||||
if (left + popRect.width + 8 > vw) {
|
||||
// fallback to bottom
|
||||
actual = "bottom";
|
||||
left = Math.max(8, Math.min(btnCenterX - popRect.width / 2, vw - popRect.width - 8));
|
||||
top = btnRect.bottom + GAP;
|
||||
arrowLeft = btnCenterX - left;
|
||||
} else {
|
||||
top = Math.max(8, Math.min(top, vh - popRect.height - 8));
|
||||
arrowTop = btnCenterY - top;
|
||||
}
|
||||
} else {
|
||||
// Center horizontally on icon, clamp to viewport
|
||||
left = btnCenterX - popRect.width / 2;
|
||||
left = Math.max(8, Math.min(left, vw - popRect.width - 8));
|
||||
arrowLeft = btnCenterX - left;
|
||||
|
||||
if (placement === "top") {
|
||||
const candidateTop = btnRect.top - popRect.height - GAP;
|
||||
if (candidateTop < 8 && btnRect.bottom + popRect.height + GAP < vh) {
|
||||
actual = "bottom";
|
||||
top = btnRect.bottom + GAP;
|
||||
} else {
|
||||
top = candidateTop;
|
||||
}
|
||||
} else {
|
||||
// bottom
|
||||
const candidateTop = btnRect.bottom + GAP;
|
||||
if (candidateTop + popRect.height + 8 > vh && btnRect.top - popRect.height - GAP > 0) {
|
||||
actual = "top";
|
||||
top = btnRect.top - popRect.height - GAP;
|
||||
} else {
|
||||
top = candidateTop;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setPos({ left, top, arrowLeft, arrowTop, flippedTo: actual });
|
||||
};
|
||||
|
||||
compute();
|
||||
window.addEventListener("scroll", compute, true);
|
||||
window.addEventListener("resize", compute);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", compute, true);
|
||||
window.removeEventListener("resize", compute);
|
||||
};
|
||||
}, [show, placement]);
|
||||
|
||||
return (
|
||||
<span className="relative inline-flex">
|
||||
<>
|
||||
<button
|
||||
ref={btnRef}
|
||||
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"
|
||||
className="text-slate-500 hover:text-slate-300 transition-colors inline-flex items-center"
|
||||
>
|
||||
<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`}>
|
||||
{show && createPortal(
|
||||
<div
|
||||
ref={popRef}
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: pos?.left ?? -9999,
|
||||
top: pos?.top ?? -9999,
|
||||
visibility: pos ? "visible" : "hidden",
|
||||
zIndex: 99999,
|
||||
}}
|
||||
className={`px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-xs text-slate-200 ${nowrap ? "whitespace-nowrap" : `${width} whitespace-pre-line`} text-left shadow-xl leading-relaxed`}
|
||||
>
|
||||
{text}
|
||||
<div className={`absolute ${arrowPos} left-1/2 -translate-x-1/2 border-4 border-transparent`} />
|
||||
</div>
|
||||
{pos && pos.flippedTo === "right" && (
|
||||
<div
|
||||
className="absolute right-full border-4 border-transparent border-r-slate-700"
|
||||
style={{ top: pos.arrowTop - 4 }}
|
||||
/>
|
||||
)}
|
||||
{pos && pos.flippedTo === "top" && (
|
||||
<div
|
||||
className="absolute top-full border-4 border-transparent border-t-slate-700"
|
||||
style={{ left: pos.arrowLeft - 4 }}
|
||||
/>
|
||||
)}
|
||||
{pos && pos.flippedTo === "bottom" && (
|
||||
<div
|
||||
className="absolute bottom-full border-4 border-transparent border-b-slate-700"
|
||||
style={{ left: pos.arrowLeft - 4 }}
|
||||
/>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user