This commit is contained in:
RGJorge
2026-05-10 23:39:12 +00:00
parent 713504dc65
commit 3962e6c319
19 changed files with 1382 additions and 275 deletions
+1 -1
View File
@@ -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";
+33 -2
View File
@@ -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 */}
+2 -2
View File
@@ -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}
+9 -5
View File
@@ -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
+122 -18
View File
@@ -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>
</>
);
}