@cybermem/dashboard 0.5.12 → 0.5.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/api/health/route.ts +60 -46
- package/components/dashboard/audit-log-table.tsx +209 -130
- package/components/dashboard/chart-card.tsx +151 -76
- package/components/dashboard/header.tsx +131 -50
- package/components/dashboard/mcp-config-modal.tsx +1 -1
- package/components/dashboard/metric-card.tsx +46 -10
- package/components/dashboard/metrics-chart.tsx +137 -110
- package/components/dashboard/metrics-grid.tsx +138 -73
- package/components/dashboard/settings-modal.tsx +548 -114
- package/e2e/ui-elements.spec.ts +267 -0
- package/package.json +1 -1
|
@@ -1,23 +1,59 @@
|
|
|
1
|
-
"use client"
|
|
1
|
+
"use client";
|
|
2
2
|
|
|
3
|
-
import { Card, CardContent } from "@/components/ui/card"
|
|
3
|
+
import { Card, CardContent } from "@/components/ui/card";
|
|
4
4
|
|
|
5
5
|
interface MetricCardProps {
|
|
6
|
-
label: string
|
|
7
|
-
value: string
|
|
6
|
+
label: string;
|
|
7
|
+
value: string;
|
|
8
8
|
// Keep these props for API compatibility but don't use them
|
|
9
|
-
change?: string
|
|
10
|
-
trend?: "up" | "down" | "neutral"
|
|
11
|
-
hasData?: boolean
|
|
9
|
+
change?: string;
|
|
10
|
+
trend?: "up" | "down" | "neutral";
|
|
11
|
+
hasData?: boolean;
|
|
12
|
+
loading?: boolean;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
export default function MetricCard({
|
|
15
|
+
export default function MetricCard({
|
|
16
|
+
label,
|
|
17
|
+
value,
|
|
18
|
+
loading = false,
|
|
19
|
+
hasData = true,
|
|
20
|
+
}: MetricCardProps) {
|
|
21
|
+
const isEmpty =
|
|
22
|
+
!hasData || value === "N/A" || value === "0" || value === "0.0%";
|
|
23
|
+
|
|
24
|
+
// Skeleton state
|
|
25
|
+
if (loading) {
|
|
26
|
+
return (
|
|
27
|
+
<Card className="bg-white/5 border-white/10 backdrop-blur-md text-white shadow-lg overflow-hidden relative">
|
|
28
|
+
<CardContent className="p-6 relative z-10">
|
|
29
|
+
<div className="h-4 w-24 bg-white/10 rounded animate-pulse mb-3" />
|
|
30
|
+
<div className="h-10 w-20 bg-white/10 rounded animate-pulse" />
|
|
31
|
+
</CardContent>
|
|
32
|
+
</Card>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Empty state
|
|
37
|
+
if (isEmpty) {
|
|
38
|
+
return (
|
|
39
|
+
<Card className="bg-white/5 border-white/10 backdrop-blur-md text-white shadow-lg overflow-hidden relative opacity-60">
|
|
40
|
+
<CardContent className="p-6 relative z-10">
|
|
41
|
+
<div className="text-sm font-medium text-slate-500 mb-2">{label}</div>
|
|
42
|
+
<div className="text-4xl font-bold text-slate-500">
|
|
43
|
+
{value === "N/A" ? "N/A" : "—"}
|
|
44
|
+
</div>
|
|
45
|
+
</CardContent>
|
|
46
|
+
</Card>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Normal state
|
|
15
51
|
return (
|
|
16
|
-
<Card className="bg-white/5 border-white/10 backdrop-blur-md text-white shadow-lg overflow-hidden relative">
|
|
52
|
+
<Card className="bg-white/5 border-white/10 backdrop-blur-md text-white shadow-lg overflow-hidden relative group hover:bg-white/[0.07] transition-colors">
|
|
17
53
|
<CardContent className="p-6 relative z-10">
|
|
18
54
|
<div className="text-sm font-medium text-slate-400 mb-2">{label}</div>
|
|
19
55
|
<div className="text-4xl font-bold text-white">{value}</div>
|
|
20
56
|
</CardContent>
|
|
21
57
|
</Card>
|
|
22
|
-
)
|
|
58
|
+
);
|
|
23
59
|
}
|
|
@@ -1,6 +1,15 @@
|
|
|
1
|
-
"use client"
|
|
1
|
+
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
Area,
|
|
5
|
+
AreaChart,
|
|
6
|
+
CartesianGrid,
|
|
7
|
+
Legend,
|
|
8
|
+
ResponsiveContainer,
|
|
9
|
+
Tooltip,
|
|
10
|
+
XAxis,
|
|
11
|
+
YAxis,
|
|
12
|
+
} from "recharts";
|
|
4
13
|
|
|
5
14
|
// Fallback color generator
|
|
6
15
|
function stringToColor(str: string): string {
|
|
@@ -8,127 +17,145 @@ function stringToColor(str: string): string {
|
|
|
8
17
|
for (let i = 0; i < str.length; i++) {
|
|
9
18
|
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
|
10
19
|
}
|
|
11
|
-
let color =
|
|
20
|
+
let color = "#";
|
|
12
21
|
for (let i = 0; i < 3; i++) {
|
|
13
|
-
const value = (hash >> (i * 8)) &
|
|
14
|
-
color += (
|
|
22
|
+
const value = (hash >> (i * 8)) & 0xff;
|
|
23
|
+
color += ("00" + value.toString(16)).substr(-2);
|
|
15
24
|
}
|
|
16
25
|
return color;
|
|
17
26
|
}
|
|
18
27
|
|
|
19
28
|
interface MetricsChartProps {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
29
|
+
data: any[];
|
|
30
|
+
isMultiSeries: boolean;
|
|
31
|
+
clientNames: string[];
|
|
32
|
+
clientConfigs: any[];
|
|
33
|
+
hovered: string | null;
|
|
34
|
+
setHovered: (id: string | null) => void;
|
|
26
35
|
}
|
|
27
36
|
|
|
28
37
|
export default function MetricsChart({
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
38
|
+
data,
|
|
39
|
+
isMultiSeries,
|
|
40
|
+
clientNames,
|
|
41
|
+
clientConfigs,
|
|
42
|
+
hovered,
|
|
43
|
+
setHovered,
|
|
35
44
|
}: MetricsChartProps) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
45
|
+
return (
|
|
46
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
47
|
+
<AreaChart
|
|
48
|
+
data={data}
|
|
49
|
+
margin={{ top: 10, right: 20, left: 0, bottom: 0 }}
|
|
50
|
+
>
|
|
51
|
+
<CartesianGrid
|
|
52
|
+
strokeDasharray="0"
|
|
53
|
+
stroke="#2D3135"
|
|
54
|
+
opacity={0.3}
|
|
55
|
+
vertical={false}
|
|
56
|
+
horizontal={true}
|
|
57
|
+
/>
|
|
58
|
+
<XAxis
|
|
59
|
+
dataKey="time"
|
|
60
|
+
stroke="#6B7280"
|
|
61
|
+
fontSize={11}
|
|
62
|
+
tickLine={false}
|
|
63
|
+
axisLine={false}
|
|
64
|
+
minTickGap={40}
|
|
65
|
+
tick={{ fill: "#6B7280" }}
|
|
66
|
+
/>
|
|
67
|
+
<YAxis
|
|
68
|
+
stroke="#6B7280"
|
|
69
|
+
fontSize={11}
|
|
70
|
+
tickLine={false}
|
|
71
|
+
axisLine={false}
|
|
72
|
+
tickFormatter={(value) => `${value}`}
|
|
73
|
+
tick={{ fill: "#6B7280" }}
|
|
74
|
+
width={40}
|
|
75
|
+
/>
|
|
76
|
+
<Tooltip
|
|
77
|
+
contentStyle={{
|
|
78
|
+
backgroundColor: "rgba(11, 17, 22, 0.8)",
|
|
79
|
+
border: "1px solid rgba(255, 255, 255, 0.1)",
|
|
80
|
+
borderRadius: "12px",
|
|
81
|
+
color: "#fff",
|
|
82
|
+
backdropFilter: "blur(12px)",
|
|
83
|
+
boxShadow: "0 4px 20px rgba(0, 0, 0, 0.5)",
|
|
84
|
+
}}
|
|
85
|
+
itemStyle={{ color: "#fff", fontSize: "12px", padding: "2px 0" }}
|
|
86
|
+
labelStyle={{
|
|
87
|
+
color: "#9ca3af",
|
|
88
|
+
marginBottom: "8px",
|
|
89
|
+
fontSize: "12px",
|
|
90
|
+
fontWeight: 500,
|
|
91
|
+
}}
|
|
92
|
+
cursor={{ stroke: "rgba(255,255,255,0.2)", strokeWidth: 1 }}
|
|
93
|
+
/>
|
|
94
|
+
{isMultiSeries && (
|
|
95
|
+
<Legend
|
|
96
|
+
verticalAlign="bottom"
|
|
97
|
+
height={36}
|
|
98
|
+
iconType="circle"
|
|
99
|
+
onMouseEnter={(e: any) => {
|
|
100
|
+
if (e.dataKey) setHovered(e.dataKey.toString());
|
|
101
|
+
}}
|
|
102
|
+
onMouseLeave={() => setHovered(null)}
|
|
103
|
+
formatter={(value: any, entry: any) => {
|
|
104
|
+
// value is usually the name set on the Area, which we set below.
|
|
105
|
+
// But if that fails, we fallback to finding config.
|
|
106
|
+
return <span className="text-white">{value}</span>;
|
|
107
|
+
}}
|
|
108
|
+
/>
|
|
109
|
+
)}
|
|
110
|
+
{isMultiSeries ? (
|
|
111
|
+
clientNames.map((client, i) => {
|
|
112
|
+
// Find matching config using regex
|
|
113
|
+
const keyLower = client.toLowerCase();
|
|
114
|
+
const config = clientConfigs.find((c: any) =>
|
|
115
|
+
new RegExp(c.match, "i").test(keyLower),
|
|
116
|
+
);
|
|
92
117
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
118
|
+
// Use config if found, otherwise fallback
|
|
119
|
+
const name = config?.name || client;
|
|
120
|
+
const color = config?.color || stringToColor(client);
|
|
96
121
|
|
|
97
|
-
|
|
98
|
-
|
|
122
|
+
const isHovered = hovered === client;
|
|
123
|
+
const isAnyHovered = hovered !== null;
|
|
99
124
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
type="monotone"
|
|
104
|
-
dataKey={client}
|
|
105
|
-
name={name}
|
|
106
|
-
stroke={color}
|
|
107
|
-
strokeWidth={isHovered ? 2.5 : 1.5}
|
|
108
|
-
fillOpacity={isHovered ? 0.5 : (isAnyHovered ? 0.1 : 0.2)}
|
|
109
|
-
fill={color}
|
|
110
|
-
stackId="1"
|
|
111
|
-
activeDot={{ r: 4, strokeWidth: 0 }}
|
|
112
|
-
dot={false}
|
|
113
|
-
onMouseEnter={() => setHovered(client)}
|
|
114
|
-
onMouseLeave={() => setHovered(null)}
|
|
115
|
-
/>
|
|
116
|
-
)
|
|
117
|
-
})
|
|
118
|
-
) : (
|
|
119
|
-
<Area
|
|
125
|
+
return (
|
|
126
|
+
<Area
|
|
127
|
+
key={client}
|
|
120
128
|
type="monotone"
|
|
121
|
-
dataKey=
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
129
|
+
dataKey={client}
|
|
130
|
+
stackId="1"
|
|
131
|
+
isAnimationActive={false}
|
|
132
|
+
name={name}
|
|
133
|
+
stroke={color}
|
|
134
|
+
strokeWidth={isHovered ? 2.5 : 1.5}
|
|
135
|
+
fillOpacity={isHovered ? 0.6 : isAnyHovered ? 0.1 : 0.4}
|
|
136
|
+
fill={color}
|
|
137
|
+
activeDot={{ r: 4, strokeWidth: 0 }}
|
|
127
138
|
dot={false}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
139
|
+
onMouseEnter={() => setHovered(client)}
|
|
140
|
+
onMouseLeave={() => setHovered(null)}
|
|
141
|
+
/>
|
|
142
|
+
);
|
|
143
|
+
})
|
|
144
|
+
) : (
|
|
145
|
+
<Area
|
|
146
|
+
type="monotone"
|
|
147
|
+
dataKey="value"
|
|
148
|
+
stroke="#10b981"
|
|
149
|
+
strokeWidth={1.5}
|
|
150
|
+
fillOpacity={0.2}
|
|
151
|
+
fill="#10b981"
|
|
152
|
+
isAnimationActive={false}
|
|
153
|
+
activeDot={{ r: 3, strokeWidth: 0 }}
|
|
154
|
+
dot={false}
|
|
155
|
+
// No hover effect needed for single series as there's nothing to distinguish from
|
|
156
|
+
/>
|
|
157
|
+
)}
|
|
158
|
+
</AreaChart>
|
|
159
|
+
</ResponsiveContainer>
|
|
160
|
+
);
|
|
134
161
|
}
|
|
@@ -1,51 +1,102 @@
|
|
|
1
|
-
"use client"
|
|
1
|
+
"use client";
|
|
2
2
|
|
|
3
|
-
import { Card, CardContent } from "@/components/ui/card"
|
|
4
|
-
import MetricCard from "./metric-card"
|
|
3
|
+
import { Card, CardContent } from "@/components/ui/card";
|
|
4
|
+
import MetricCard from "./metric-card";
|
|
5
5
|
|
|
6
6
|
// Types
|
|
7
7
|
interface TrendState {
|
|
8
|
-
change: string
|
|
9
|
-
trend: "up" | "down" | "neutral"
|
|
10
|
-
hasData: boolean
|
|
8
|
+
change: string;
|
|
9
|
+
trend: "up" | "down" | "neutral";
|
|
10
|
+
hasData: boolean;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
interface MetricsGridProps {
|
|
14
14
|
stats: {
|
|
15
|
-
memoryRecords: number
|
|
16
|
-
totalClients: number
|
|
17
|
-
successRate: number
|
|
18
|
-
totalRequests: number
|
|
19
|
-
topWriter: { name: string; count: number }
|
|
20
|
-
topReader: { name: string; count: number }
|
|
21
|
-
lastWriter: { name: string; timestamp: number }
|
|
22
|
-
lastReader: { name: string; timestamp: number }
|
|
23
|
-
}
|
|
15
|
+
memoryRecords: number;
|
|
16
|
+
totalClients: number;
|
|
17
|
+
successRate: number;
|
|
18
|
+
totalRequests: number;
|
|
19
|
+
topWriter: { name: string; count: number };
|
|
20
|
+
topReader: { name: string; count: number };
|
|
21
|
+
lastWriter: { name: string; timestamp: number };
|
|
22
|
+
lastReader: { name: string; timestamp: number };
|
|
23
|
+
};
|
|
24
24
|
trends: {
|
|
25
|
-
memory: TrendState
|
|
26
|
-
clients: TrendState
|
|
27
|
-
success: TrendState
|
|
28
|
-
requests: TrendState
|
|
29
|
-
}
|
|
25
|
+
memory: TrendState;
|
|
26
|
+
clients: TrendState;
|
|
27
|
+
success: TrendState;
|
|
28
|
+
requests: TrendState;
|
|
29
|
+
};
|
|
30
|
+
loading?: boolean;
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
// Client card skeleton component
|
|
34
|
+
function ClientCardSkeleton({ label }: { label: string }) {
|
|
35
|
+
return (
|
|
36
|
+
<Card className="bg-white/5 border-white/10 backdrop-blur-md text-white shadow-lg overflow-hidden">
|
|
37
|
+
<CardContent className="pt-6 pb-6 relative">
|
|
38
|
+
<div className="text-sm font-medium text-slate-400 mb-2">{label}</div>
|
|
39
|
+
<div className="h-10 w-32 bg-white/10 rounded animate-pulse mb-2" />
|
|
40
|
+
<div className="h-5 w-20 bg-white/10 rounded animate-pulse" />
|
|
41
|
+
</CardContent>
|
|
42
|
+
</Card>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Client card with empty state
|
|
47
|
+
function ClientCard({
|
|
48
|
+
label,
|
|
49
|
+
name,
|
|
50
|
+
subtitle,
|
|
51
|
+
isEmpty,
|
|
52
|
+
}: {
|
|
53
|
+
label: string;
|
|
54
|
+
name: string;
|
|
55
|
+
subtitle: string;
|
|
56
|
+
isEmpty: boolean;
|
|
57
|
+
}) {
|
|
58
|
+
return (
|
|
59
|
+
<Card
|
|
60
|
+
className={`bg-white/5 border-white/10 backdrop-blur-md text-white shadow-lg overflow-hidden ${isEmpty ? "opacity-60" : ""}`}
|
|
61
|
+
>
|
|
62
|
+
<CardContent className="pt-6 pb-6 relative">
|
|
63
|
+
<div className="text-sm font-medium text-slate-400 mb-2">{label}</div>
|
|
64
|
+
<div
|
|
65
|
+
className={`text-4xl font-bold mb-1 truncate ${isEmpty ? "text-slate-500" : "text-white"}`}
|
|
66
|
+
>
|
|
67
|
+
{name}
|
|
68
|
+
</div>
|
|
69
|
+
<div
|
|
70
|
+
className={`text-xl whitespace-nowrap ${isEmpty ? "text-slate-600" : "text-white/80"}`}
|
|
71
|
+
>
|
|
72
|
+
{subtitle}
|
|
73
|
+
</div>
|
|
74
|
+
</CardContent>
|
|
75
|
+
</Card>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
32
78
|
|
|
33
|
-
export default function MetricsGrid({
|
|
79
|
+
export default function MetricsGrid({
|
|
80
|
+
stats,
|
|
81
|
+
trends,
|
|
82
|
+
loading = false,
|
|
83
|
+
}: MetricsGridProps) {
|
|
34
84
|
// Note: Client names are now normalized by backend API, no frontend transformation needed
|
|
35
85
|
|
|
36
86
|
const formatTimestamp = (timestamp: number) => {
|
|
37
|
-
if (timestamp <= 0) return "No activity"
|
|
38
|
-
const date = new Date(timestamp)
|
|
39
|
-
const now = new Date()
|
|
40
|
-
const isToday =
|
|
41
|
-
|
|
42
|
-
|
|
87
|
+
if (timestamp <= 0) return "No activity";
|
|
88
|
+
const date = new Date(timestamp);
|
|
89
|
+
const now = new Date();
|
|
90
|
+
const isToday =
|
|
91
|
+
date.getDate() === now.getDate() &&
|
|
92
|
+
date.getMonth() === now.getMonth() &&
|
|
93
|
+
date.getFullYear() === now.getFullYear();
|
|
43
94
|
|
|
44
95
|
if (isToday) {
|
|
45
|
-
return date.toLocaleTimeString()
|
|
96
|
+
return date.toLocaleTimeString();
|
|
46
97
|
}
|
|
47
|
-
return date.toLocaleString()
|
|
48
|
-
}
|
|
98
|
+
return date.toLocaleString();
|
|
99
|
+
};
|
|
49
100
|
|
|
50
101
|
return (
|
|
51
102
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
|
@@ -56,6 +107,7 @@ export default function MetricsGrid({ stats, trends }: MetricsGridProps) {
|
|
|
56
107
|
change={trends.memory.change}
|
|
57
108
|
trend={trends.memory.trend}
|
|
58
109
|
hasData={trends.memory.hasData}
|
|
110
|
+
loading={loading}
|
|
59
111
|
/>
|
|
60
112
|
|
|
61
113
|
{/* 2. Total Clients */}
|
|
@@ -65,6 +117,7 @@ export default function MetricsGrid({ stats, trends }: MetricsGridProps) {
|
|
|
65
117
|
change={trends.clients.change}
|
|
66
118
|
trend={trends.clients.trend}
|
|
67
119
|
hasData={trends.clients.hasData}
|
|
120
|
+
loading={loading}
|
|
68
121
|
/>
|
|
69
122
|
|
|
70
123
|
{/* 3. Success Rate */}
|
|
@@ -74,6 +127,7 @@ export default function MetricsGrid({ stats, trends }: MetricsGridProps) {
|
|
|
74
127
|
change={trends.success.change}
|
|
75
128
|
trend={trends.success.trend} // Trend UP is good (green) for success rate
|
|
76
129
|
hasData={trends.success.hasData}
|
|
130
|
+
loading={loading}
|
|
77
131
|
/>
|
|
78
132
|
|
|
79
133
|
{/* 4. Total Requests */}
|
|
@@ -83,57 +137,68 @@ export default function MetricsGrid({ stats, trends }: MetricsGridProps) {
|
|
|
83
137
|
change={trends.requests.change}
|
|
84
138
|
trend={trends.requests.trend}
|
|
85
139
|
hasData={trends.requests.hasData}
|
|
140
|
+
loading={loading}
|
|
86
141
|
/>
|
|
87
142
|
|
|
88
143
|
{/* 5. Top Writer */}
|
|
89
|
-
|
|
90
|
-
<
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
144
|
+
{loading ? (
|
|
145
|
+
<ClientCardSkeleton label="Top Writer" />
|
|
146
|
+
) : (
|
|
147
|
+
<ClientCard
|
|
148
|
+
label="Top Writer"
|
|
149
|
+
name={stats.topWriter.count > 0 ? stats.topWriter.name : "N/A"}
|
|
150
|
+
subtitle={
|
|
151
|
+
stats.topWriter.count > 0
|
|
152
|
+
? `${stats.topWriter.count.toLocaleString()} writes`
|
|
153
|
+
: ""
|
|
154
|
+
}
|
|
155
|
+
isEmpty={stats.topWriter.count <= 0}
|
|
156
|
+
/>
|
|
157
|
+
)}
|
|
98
158
|
|
|
99
159
|
{/* 6. Top Reader */}
|
|
100
|
-
|
|
101
|
-
<
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
160
|
+
{loading ? (
|
|
161
|
+
<ClientCardSkeleton label="Top Reader" />
|
|
162
|
+
) : (
|
|
163
|
+
<ClientCard
|
|
164
|
+
label="Top Reader"
|
|
165
|
+
name={stats.topReader.count > 0 ? stats.topReader.name : "N/A"}
|
|
166
|
+
subtitle={
|
|
167
|
+
stats.topReader.count > 0
|
|
168
|
+
? `${stats.topReader.count.toLocaleString()} reads`
|
|
169
|
+
: ""
|
|
170
|
+
}
|
|
171
|
+
isEmpty={stats.topReader.count <= 0}
|
|
172
|
+
/>
|
|
173
|
+
)}
|
|
111
174
|
|
|
112
175
|
{/* 7. Last Writer */}
|
|
113
|
-
|
|
114
|
-
<
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
176
|
+
{loading ? (
|
|
177
|
+
<ClientCardSkeleton label="Last Writer" />
|
|
178
|
+
) : (
|
|
179
|
+
<ClientCard
|
|
180
|
+
label="Last Writer"
|
|
181
|
+
name={stats.lastWriter.name !== "N/A" ? stats.lastWriter.name : "N/A"}
|
|
182
|
+
subtitle={formatTimestamp(stats.lastWriter.timestamp)}
|
|
183
|
+
isEmpty={
|
|
184
|
+
stats.lastWriter.name === "N/A" || stats.lastWriter.timestamp <= 0
|
|
185
|
+
}
|
|
186
|
+
/>
|
|
187
|
+
)}
|
|
124
188
|
|
|
125
189
|
{/* 8. Last Reader */}
|
|
126
|
-
|
|
127
|
-
<
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
190
|
+
{loading ? (
|
|
191
|
+
<ClientCardSkeleton label="Last Reader" />
|
|
192
|
+
) : (
|
|
193
|
+
<ClientCard
|
|
194
|
+
label="Last Reader"
|
|
195
|
+
name={stats.lastReader.name !== "N/A" ? stats.lastReader.name : "N/A"}
|
|
196
|
+
subtitle={formatTimestamp(stats.lastReader.timestamp)}
|
|
197
|
+
isEmpty={
|
|
198
|
+
stats.lastReader.name === "N/A" || stats.lastReader.timestamp <= 0
|
|
199
|
+
}
|
|
200
|
+
/>
|
|
201
|
+
)}
|
|
137
202
|
</div>
|
|
138
|
-
)
|
|
203
|
+
);
|
|
139
204
|
}
|