This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new 9921a0e8 feat: implement format utilities and enhance MiniLine chart
(#660)
9921a0e8 is described below
commit 9921a0e8bb993b861d4a95ee533e0d701cd5507f
Author: zhaohai <[email protected]>
AuthorDate: Fri Jul 31 16:08:24 2026 +0800
feat: implement format utilities and enhance MiniLine chart (#660)
* [studio] Implement format utility functions (date, bytes, number, delay,
percent)
Replace TODO stub implementations in format.ts with full-featured utilities:
- formatDateTime / formatDate: proper date formatting with padding
- formatBytes: human-readable 1024-based byte formatting
- formatNumber: thousands separator formatting
- formatDelay: duration formatting with i18n support (zh/en)
- formatPercent: fixed-decimal percentage formatting
* [studio] Enhance MiniLine chart component
Smooth cardinal-spline path, gradient area fill, glow on last-point dot,
configurable strokeWidth/showDot/animated, and responsive SVG mode.
The MiniBar zero-value change from the original PR is dropped: trunk
intentionally renders no visible bar at zero throughput (#629).
---
web/src/components/MiniLine.tsx | 127 ++++++++++++++++++++++++++++++++--------
web/src/utils/format.ts | 85 +++++++++++++++++++++++++--
2 files changed, 184 insertions(+), 28 deletions(-)
diff --git a/web/src/components/MiniLine.tsx b/web/src/components/MiniLine.tsx
index 8a483ba8..a7658d24 100644
--- a/web/src/components/MiniLine.tsx
+++ b/web/src/components/MiniLine.tsx
@@ -15,12 +15,25 @@
* limitations under the License.
*/
+import { useMemo } from 'react';
+
+let _lineId = 0;
+
interface MiniLineProps {
data: number[];
color?: string;
height?: number;
width?: number;
+ /** Fill area under the curve */
fill?: boolean;
+ /** Stroke width */
+ strokeWidth?: number;
+ /** Show dot on last data point */
+ showDot?: boolean;
+ /** Animate on mount */
+ animated?: boolean;
+ /** Make SVG responsive (width=100%, preserves aspect ratio) */
+ responsive?: boolean;
}
const MiniLine = ({
@@ -29,44 +42,112 @@ const MiniLine = ({
height = 32,
width = 120,
fill = true,
+ strokeWidth = 2,
+ showDot = true,
+ animated = true,
+ responsive = false,
}: MiniLineProps) => {
- if (data.length < 2) return null;
-
const max = Math.max(...data, 1);
const min = Math.min(...data, 0);
const range = max - min || 1;
- const padding = 2;
- const innerW = width - padding * 2;
- const innerH = height - padding * 2;
+ const pad = 4;
+ const innerW = width - pad * 2;
+ const innerH = height - pad * 2;
+
+ const gradientId = useMemo(() => `ml-grad-${++_lineId}`, []);
+ const glowId = useMemo(() => `ml-glow-${++_lineId}`, []);
- const points = data.map((v, i) => {
- const x = padding + (i / (data.length - 1)) * innerW;
- const y = padding + innerH - ((v - min) / range) * innerH;
- return `${x},${y}`;
- });
+ if (data.length < 2) return null;
+
+ // Build smooth Catmull-Rom → Bezier control points
+ const points = data.map((v, i) => ({
+ x: pad + (i / (data.length - 1)) * innerW,
+ y: pad + innerH - ((v - min) / range) * innerH,
+ }));
+
+ // Convert points to a smooth SVG path using cardinal spline
+ const smoothPath = (() => {
+ if (points.length < 2) return '';
+ let d = `M${points[0].x},${points[0].y}`;
+ for (let i = 0; i < points.length - 1; i++) {
+ const p0 = points[Math.max(0, i - 1)];
+ const p1 = points[i];
+ const p2 = points[i + 1];
+ const p3 = points[Math.min(points.length - 1, i + 2)];
+ const tension = 0.3;
+ const cp1x = p1.x + (p2.x - p0.x) * tension;
+ const cp1y = p1.y + (p2.y - p0.y) * tension;
+ const cp2x = p2.x - (p3.x - p1.x) * tension;
+ const cp2y = p2.y - (p3.y - p1.y) * tension;
+ d += ` C${cp1x},${cp1y} ${cp2x},${cp2y} ${p2.x},${p2.y}`;
+ }
+ return d;
+ })();
- const linePath = `M${points.join(' L')}`;
- const areaPath = `${linePath} L${padding + innerW},${height - padding}
L${padding},${height - padding} Z`;
+ const areaPath = `${smoothPath} L${pad + innerW},${height - pad}
L${pad},${height - pad} Z`;
+
+ const lastPoint = points[points.length - 1];
return (
- <svg width={width} height={height} style={{ display: 'block' }}>
- {fill && <path d={areaPath} fill={color} opacity={0.1} />}
+ <svg
+ width={responsive ? '100%' : width}
+ height={height}
+ viewBox={responsive ? `0 0 ${width} ${height}` : undefined}
+ preserveAspectRatio={responsive ? 'none' : undefined}
+ style={{ display: 'block', overflow: 'visible' }}
+ >
+ {' '}
+ <defs>
+ <linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
+ <stop offset="0%" stopColor={color} stopOpacity={0.3} />
+ <stop offset="100%" stopColor={color} stopOpacity={0.02} />
+ </linearGradient>
+ <filter id={glowId}>
+ <feGaussianBlur stdDeviation="2" result="blur" />
+ <feMerge>
+ <feMergeNode in="blur" />
+ <feMergeNode in="SourceGraphic" />
+ </feMerge>
+ </filter>
+ </defs>
+ {fill && <path d={areaPath} fill={`url(#${gradientId})`} />}
<path
- d={linePath}
+ d={smoothPath}
fill="none"
stroke={color}
- strokeWidth={1.5}
+ strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
+ filter={`url(#${glowId})`}
+ style={
+ animated
+ ? {
+ strokeDasharray: 500,
+ strokeDashoffset: 500,
+ animation: 'miniLineDraw 1s ease-out forwards',
+ }
+ : undefined
+ }
/>
- {/* Last point dot */}
- <circle
- cx={padding + innerW}
- cy={padding + innerH - ((data[data.length - 1] - min) / range) *
innerH}
- r={2.5}
- fill={color}
- />
+ {showDot && (
+ <>
+ <circle cx={lastPoint.x} cy={lastPoint.y} r={4} fill={color}
opacity={0.2} />
+ <circle
+ cx={lastPoint.x}
+ cy={lastPoint.y}
+ r={2.5}
+ fill="#fff"
+ stroke={color}
+ strokeWidth={1.5}
+ />
+ </>
+ )}
+ <style>{`
+ @keyframes miniLineDraw {
+ to { stroke-dashoffset: 0; }
+ }
+ `}</style>
</svg>
);
};
diff --git a/web/src/utils/format.ts b/web/src/utils/format.ts
index c3db0e94..4df61936 100644
--- a/web/src/utils/format.ts
+++ b/web/src/utils/format.ts
@@ -15,12 +15,87 @@
* limitations under the License.
*/
+const pad = (n: number, width = 2): string => String(n).padStart(width, '0');
+
+/**
+ * Format a date string or Date object to 'YYYY-MM-DD HH:mm:ss'.
+ */
+export function formatDateTime(date: string | Date): string {
+ const d = typeof date === 'string' ? new Date(date) : date;
+ if (isNaN(d.getTime())) return String(date);
+ return (
+ `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
+ `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
+ );
+}
+
+/**
+ * Format a date string or Date object to 'YYYY-MM-DD'.
+ */
export function formatDate(date: string | Date): string {
- // TODO: implement
- return String(date);
+ const d = typeof date === 'string' ? new Date(date) : date;
+ if (isNaN(d.getTime())) return String(date);
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
-export function formatBytes(bytes: number): string {
- // TODO: implement
- return `${bytes} B`;
+/**
+ * Format bytes into human-readable string (1024-based).
+ * e.g. 1536 → '1.5 KB', 1048576 → '1 MB'
+ */
+export function formatBytes(bytes: number, decimals = 1): string {
+ if (bytes === 0) return '0 B';
+ if (bytes < 0) return `-${formatBytes(-bytes, decimals)}`;
+
+ const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
+ const k = 1024;
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ const value = bytes / Math.pow(k, i);
+ return `${value.toFixed(decimals)} ${units[i]}`;
+}
+
+/**
+ * Format a number with thousands separators.
+ * e.g. 1234567 → '1,234,567'
+ */
+export function formatNumber(num: number): string {
+ return num.toLocaleString('en-US');
+}
+
+/**
+ * Format delay seconds into human-readable duration.
+ * Supports i18n via the lang parameter.
+ * e.g. 82500 → zh: "22小时55分钟", en: "22h 55m"
+ */
+export function formatDelay(totalSeconds: number, lang: 'zh' | 'en' = 'zh'):
string {
+ if (totalSeconds <= 0) return lang === 'zh' ? '0秒' : '0s';
+
+ const days = Math.floor(totalSeconds / 86400);
+ let remaining = totalSeconds % 86400;
+ const hours = Math.floor(remaining / 3600);
+ remaining %= 3600;
+ const minutes = Math.floor(remaining / 60);
+ const seconds = remaining % 60;
+
+ if (lang === 'en') {
+ const parts: string[] = [];
+ if (days > 0) parts.push(`${days}d`);
+ if (hours > 0) parts.push(`${hours}h`);
+ if (minutes > 0) parts.push(`${minutes}m`);
+ if (seconds > 0 && parts.length < 3) parts.push(`${seconds}s`);
+ return parts.length > 0 ? parts.join(' ') : '0s';
+ }
+
+ const parts: string[] = [];
+ if (days > 0) parts.push(`${days}天`);
+ if (hours > 0) parts.push(`${hours}小时`);
+ if (minutes > 0) parts.push(`${minutes}分钟`);
+ if (seconds > 0 && parts.length < 3) parts.push(`${seconds}秒`);
+ return parts.length > 0 ? parts.join('') : '0秒';
+}
+
+/**
+ * Format a percentage value (0-100) with fixed decimals.
+ */
+export function formatPercent(value: number, decimals = 1): string {
+ return `${value.toFixed(decimals)}%`;
}