@cybermem/dashboard 0.9.12 → 0.13.4
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/Dockerfile +3 -3
- package/app/api/audit-logs/route.ts +12 -6
- package/app/api/health/route.ts +2 -1
- package/app/api/mcp-config/route.ts +128 -0
- package/app/api/metrics/route.ts +22 -70
- package/app/api/settings/route.ts +125 -30
- package/app/page.tsx +105 -127
- package/components/dashboard/{chart-card.tsx → charts/chart-card.tsx} +13 -19
- package/components/dashboard/{metrics-chart.tsx → charts/memory-chart.tsx} +1 -1
- package/components/dashboard/charts-section.tsx +3 -3
- package/components/dashboard/header.tsx +177 -176
- package/components/dashboard/{audit-log-table.tsx → logs/log-viewer.tsx} +12 -7
- package/components/dashboard/mcp/config-preview.tsx +246 -0
- package/components/dashboard/mcp/platform-selector.tsx +96 -0
- package/components/dashboard/mcp-config-modal.tsx +97 -503
- package/components/dashboard/{metric-card.tsx → metrics/stat-card.tsx} +4 -2
- package/components/dashboard/metrics-grid.tsx +10 -2
- package/components/dashboard/settings/access-token-section.tsx +131 -0
- package/components/dashboard/settings/data-management-section.tsx +122 -0
- package/components/dashboard/settings/system-info-section.tsx +98 -0
- package/components/dashboard/settings-modal.tsx +55 -299
- package/e2e/api.spec.ts +219 -0
- package/e2e/routing.spec.ts +39 -0
- package/e2e/ui.spec.ts +373 -0
- package/lib/data/dashboard-context.tsx +96 -29
- package/lib/data/types.ts +32 -38
- package/middleware.ts +31 -13
- package/package.json +6 -1
- package/playwright.config.ts +23 -58
- package/public/clients.json +5 -3
- package/release-reports/assets/local/1_dashboard.png +0 -0
- package/release-reports/assets/local/2_audit_logs.png +0 -0
- package/release-reports/assets/local/3_charts.png +0 -0
- package/release-reports/assets/local/4_mcp_modal.png +0 -0
- package/release-reports/assets/local/5_settings_modal.png +0 -0
- package/lib/data/demo-strategy.ts +0 -110
- package/lib/data/production-strategy.ts +0 -191
- package/lib/prometheus/client.ts +0 -58
- package/lib/prometheus/index.ts +0 -6
- package/lib/prometheus/metrics.ts +0 -234
- package/lib/prometheus/sparklines.ts +0 -71
- package/lib/prometheus/timeseries.ts +0 -305
- package/lib/prometheus/utils.ts +0 -176
|
@@ -1,235 +1,236 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { Button } from "@/components/ui/button";
|
|
4
|
-
import { useDashboard } from "@/lib/data/dashboard-context";
|
|
5
4
|
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
} from "lucide-react";
|
|
5
|
+
Popover,
|
|
6
|
+
PopoverContent,
|
|
7
|
+
PopoverTrigger,
|
|
8
|
+
} from "@/components/ui/popover";
|
|
9
|
+
import { useDashboard } from "@/lib/data/dashboard-context";
|
|
10
|
+
import { cn } from "@/lib/utils";
|
|
11
|
+
import { Activity, Book, Loader2, Settings, Shield, Zap } from "lucide-react";
|
|
13
12
|
import Image from "next/image";
|
|
14
13
|
import { useEffect, useState } from "react";
|
|
15
14
|
|
|
16
|
-
export
|
|
17
|
-
onShowMCPConfig,
|
|
18
|
-
onShowSettings,
|
|
19
|
-
}: {
|
|
15
|
+
export interface HeaderProps {
|
|
20
16
|
onShowMCPConfig: () => void;
|
|
21
17
|
onShowSettings: () => void;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
memoryCount?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default function Header({
|
|
22
|
+
onShowMCPConfig,
|
|
23
|
+
onShowSettings,
|
|
24
|
+
memoryCount = 0,
|
|
25
|
+
}: HeaderProps) {
|
|
25
26
|
const { systemHealth } = useDashboard();
|
|
27
|
+
const [isScrolled, setIsScrolled] = useState(false);
|
|
28
|
+
const [instanceLabel, setInstanceLabel] = useState<string>("checking...");
|
|
26
29
|
|
|
27
30
|
useEffect(() => {
|
|
28
|
-
//
|
|
31
|
+
// Initial check
|
|
29
32
|
setIsScrolled(window.scrollY > 10);
|
|
30
33
|
|
|
31
34
|
const handleScroll = () => {
|
|
32
35
|
setIsScrolled(window.scrollY > 10);
|
|
33
36
|
};
|
|
34
37
|
window.addEventListener("scroll", handleScroll);
|
|
38
|
+
|
|
39
|
+
fetch("/api/settings")
|
|
40
|
+
.then((res) => res.json())
|
|
41
|
+
.then((data) => {
|
|
42
|
+
const { env, instance, tailscale } = data;
|
|
43
|
+
let prefix = instance || "local";
|
|
44
|
+
if (instance === "local") prefix = "localhost";
|
|
45
|
+
else if (instance === "rpi") prefix = tailscale ? "rpi-ts" : "rpi-lan";
|
|
46
|
+
else if (instance === "vps") prefix = "vps";
|
|
47
|
+
setInstanceLabel(`${prefix}-${env || "prod"}`);
|
|
48
|
+
})
|
|
49
|
+
.catch(() => {});
|
|
50
|
+
|
|
35
51
|
return () => window.removeEventListener("scroll", handleScroll);
|
|
36
52
|
}, []);
|
|
37
53
|
|
|
38
54
|
const getStatusConfig = () => {
|
|
39
55
|
if (!systemHealth) {
|
|
40
56
|
return {
|
|
41
|
-
|
|
42
|
-
text: "text-neutral-400",
|
|
43
|
-
border: "border-neutral-500/20",
|
|
57
|
+
text: "text-zinc-400",
|
|
44
58
|
icon: Loader2,
|
|
45
|
-
label: "
|
|
59
|
+
label: "probing",
|
|
46
60
|
};
|
|
47
61
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
icon: CheckCircle2,
|
|
55
|
-
label: "All Systems OK",
|
|
56
|
-
};
|
|
57
|
-
case "degraded":
|
|
58
|
-
return {
|
|
59
|
-
bg: "bg-amber-500/10",
|
|
60
|
-
text: "text-amber-400",
|
|
61
|
-
border: "border-amber-500/20",
|
|
62
|
-
icon: AlertCircle,
|
|
63
|
-
label: "Degraded",
|
|
64
|
-
};
|
|
65
|
-
case "error":
|
|
66
|
-
return {
|
|
67
|
-
bg: "bg-red-500/10",
|
|
68
|
-
text: "text-red-400",
|
|
69
|
-
border: "border-red-500/20",
|
|
70
|
-
icon: XCircle,
|
|
71
|
-
label: "System Error",
|
|
72
|
-
};
|
|
62
|
+
if (systemHealth.overall === "ok") {
|
|
63
|
+
return {
|
|
64
|
+
text: "text-emerald-400",
|
|
65
|
+
icon: Zap,
|
|
66
|
+
label: "healthy",
|
|
67
|
+
};
|
|
73
68
|
}
|
|
69
|
+
return {
|
|
70
|
+
text: "text-red-400",
|
|
71
|
+
icon: Zap,
|
|
72
|
+
label: "degraded",
|
|
73
|
+
};
|
|
74
74
|
};
|
|
75
75
|
|
|
76
|
-
const
|
|
77
|
-
const StatusIcon =
|
|
76
|
+
const status = getStatusConfig();
|
|
77
|
+
const StatusIcon = status.icon;
|
|
78
78
|
|
|
79
79
|
return (
|
|
80
80
|
<header
|
|
81
|
-
className={
|
|
81
|
+
className={cn(
|
|
82
|
+
"fixed top-0 left-0 right-0 z-50 transition-all duration-300",
|
|
82
83
|
isScrolled
|
|
83
|
-
? "border-b border-white/10 backdrop-blur-xl bg-neutral-900/30"
|
|
84
|
-
: "border-b border-transparent bg-transparent"
|
|
85
|
-
}
|
|
84
|
+
? "border-b border-white/10 backdrop-blur-xl bg-neutral-900/30 shadow-2xl"
|
|
85
|
+
: "border-b border-transparent bg-transparent",
|
|
86
|
+
)}
|
|
86
87
|
>
|
|
87
|
-
<div className="px-6 py-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
<
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
CyberMem
|
|
103
|
-
</h1>
|
|
88
|
+
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
|
89
|
+
{/* Left Section: Logo & Integrated Info/Status */}
|
|
90
|
+
<div className="flex items-center gap-6">
|
|
91
|
+
<div className="h-10 w-10 relative">
|
|
92
|
+
<Image
|
|
93
|
+
src="/logo.png"
|
|
94
|
+
alt="CyberMem"
|
|
95
|
+
fill
|
|
96
|
+
className="object-contain opacity-80"
|
|
97
|
+
/>
|
|
98
|
+
</div>
|
|
99
|
+
<div className="flex flex-col">
|
|
100
|
+
<h1 className="text-2xl font-bold tracking-tighter text-white font-[family-name:var(--font-exo2)]">
|
|
101
|
+
CyberMem
|
|
102
|
+
</h1>
|
|
104
103
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
onMouseEnter={() => setShowHealthPopup(true)}
|
|
109
|
-
onMouseLeave={() => setShowHealthPopup(false)}
|
|
110
|
-
>
|
|
111
|
-
{!systemHealth ? (
|
|
112
|
-
/* Shimmer loading state */
|
|
113
|
-
<div className="px-3 py-[2px] rounded-full bg-white/5 border border-white/10 animate-pulse">
|
|
114
|
-
<div className="flex items-center gap-1">
|
|
115
|
-
<div className="w-3 h-3 rounded-full bg-white/10" />
|
|
116
|
-
<div className="w-16 h-3 rounded bg-white/10" />
|
|
117
|
-
</div>
|
|
118
|
-
</div>
|
|
119
|
-
) : (
|
|
120
|
-
<div
|
|
121
|
-
className={`px-2 py-[2px] rounded-full text-[10px] font-medium flex items-center gap-1 cursor-pointer ${statusConfig.bg} ${statusConfig.text} border ${statusConfig.border}`}
|
|
122
|
-
>
|
|
123
|
-
<StatusIcon className="w-3 h-3" />
|
|
124
|
-
{statusConfig.label}
|
|
125
|
-
</div>
|
|
126
|
-
)}
|
|
104
|
+
<div className="flex items-center gap-2 text-[12px] font-normal tracking-normal text-white">
|
|
105
|
+
<span className="opacity-90 lowercase">{instanceLabel}</span>
|
|
106
|
+
<span className="opacity-50 text-[10px]">•</span>
|
|
127
107
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
108
|
+
<Popover>
|
|
109
|
+
<PopoverTrigger asChild>
|
|
110
|
+
<button
|
|
111
|
+
className={cn(
|
|
112
|
+
"flex items-center gap-1 transition-colors hover:opacity-80 active:opacity-100",
|
|
113
|
+
status.text,
|
|
114
|
+
)}
|
|
115
|
+
>
|
|
116
|
+
<StatusIcon
|
|
117
|
+
className={cn(
|
|
118
|
+
"h-3.5 w-3.5 flex-shrink-0",
|
|
119
|
+
status.label === "probing" && "animate-spin",
|
|
120
|
+
)}
|
|
121
|
+
/>
|
|
122
|
+
<span className="font-normal lowercase truncate">
|
|
123
|
+
{status.label}
|
|
124
|
+
</span>
|
|
125
|
+
</button>
|
|
126
|
+
</PopoverTrigger>
|
|
127
|
+
<PopoverContent className="w-80 bg-zinc-950/95 backdrop-blur-2xl border-white/10 p-6 rounded-[2rem] shadow-[0_20px_50px_rgba(0,0,0,0.5)] animate-in slide-in-from-top-2">
|
|
128
|
+
<div className="space-y-6">
|
|
129
|
+
<div className="flex items-center justify-between pb-4 border-b border-white/5">
|
|
130
|
+
<h4 className="text-xs font-black uppercase tracking-widest text-zinc-400">
|
|
131
|
+
System Health
|
|
132
|
+
</h4>
|
|
133
|
+
<Activity className="h-4 w-4 text-emerald-400" />
|
|
134
|
+
</div>
|
|
135
|
+
{systemHealth?.services ? (
|
|
136
|
+
<div className="space-y-4">
|
|
143
137
|
{systemHealth.services.map((service, i) => (
|
|
144
138
|
<div
|
|
145
139
|
key={i}
|
|
146
|
-
className="flex items-center justify-between
|
|
140
|
+
className="flex items-center justify-between group"
|
|
147
141
|
>
|
|
148
|
-
<
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
<AlertCircle className="w-3 h-3 text-amber-400" />
|
|
161
|
-
) : (
|
|
162
|
-
<XCircle className="w-3 h-3 text-red-400" />
|
|
163
|
-
)}
|
|
142
|
+
<div className="flex items-center gap-3">
|
|
143
|
+
<div
|
|
144
|
+
className={cn(
|
|
145
|
+
"h-2 w-2 rounded-full",
|
|
146
|
+
service.status === "ok"
|
|
147
|
+
? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.4)]"
|
|
148
|
+
: "bg-red-500",
|
|
149
|
+
)}
|
|
150
|
+
/>
|
|
151
|
+
<span className="text-[11px] font-bold text-zinc-300 group-hover:text-white transition-colors">
|
|
152
|
+
{service.name}
|
|
153
|
+
</span>
|
|
164
154
|
</div>
|
|
155
|
+
<span className="text-[10px] font-black text-zinc-600 uppercase tracking-tighter">
|
|
156
|
+
{service.latencyMs
|
|
157
|
+
? `${service.latencyMs}ms`
|
|
158
|
+
: service.status === "ok"
|
|
159
|
+
? "Online"
|
|
160
|
+
: "Error"}
|
|
161
|
+
</span>
|
|
165
162
|
</div>
|
|
166
163
|
))}
|
|
167
|
-
{systemHealth.services.some(
|
|
168
|
-
(s) => s.status !== "ok" && s.message,
|
|
169
|
-
) && (
|
|
170
|
-
<div className="mt-2 p-2 rounded bg-red-500/10 border border-red-500/20">
|
|
171
|
-
<p className="text-xs text-red-300 font-medium">
|
|
172
|
-
Issues:
|
|
173
|
-
</p>
|
|
174
|
-
{systemHealth.services
|
|
175
|
-
.filter((s) => s.status !== "ok" && s.message)
|
|
176
|
-
.map((s, i) => (
|
|
177
|
-
<p
|
|
178
|
-
key={i}
|
|
179
|
-
className="text-[10px] text-red-400 mt-1"
|
|
180
|
-
>
|
|
181
|
-
• {s.name}: {s.message}
|
|
182
|
-
</p>
|
|
183
|
-
))}
|
|
184
|
-
</div>
|
|
185
|
-
)}
|
|
186
164
|
</div>
|
|
165
|
+
) : (
|
|
166
|
+
<div className="flex items-center gap-3 text-zinc-500">
|
|
167
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
168
|
+
<span className="text-xs font-bold">
|
|
169
|
+
Synchronizing telemetry...
|
|
170
|
+
</span>
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
<div className="pt-4 border-t border-white/5 flex items-center justify-between">
|
|
174
|
+
<span className="text-[9px] font-black text-zinc-600 uppercase">
|
|
175
|
+
Latency: Minimal
|
|
176
|
+
</span>
|
|
177
|
+
<span className="text-[10px] font-medium opacity-40 ml-auto mr-2">
|
|
178
|
+
{memoryCount} memories
|
|
179
|
+
</span>
|
|
180
|
+
<Shield className="h-3 w-3 text-zinc-700" />
|
|
187
181
|
</div>
|
|
188
|
-
|
|
189
|
-
</
|
|
190
|
-
</
|
|
191
|
-
<p className="text-sm text-neutral-400 mt-1">Memory MCP Server</p>
|
|
182
|
+
</div>
|
|
183
|
+
</PopoverContent>
|
|
184
|
+
</Popover>
|
|
192
185
|
</div>
|
|
193
186
|
</div>
|
|
187
|
+
</div>
|
|
194
188
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
189
|
+
{/* Right Section: Actions */}
|
|
190
|
+
<div className="flex items-center gap-3">
|
|
191
|
+
<Button
|
|
192
|
+
data-testid="mcp-button"
|
|
193
|
+
onClick={onShowMCPConfig}
|
|
194
|
+
className="bg-emerald-500/20 hover:bg-emerald-500/30 border border-emerald-500/20 text-emerald-400 text-sm font-medium transition-colors h-9 px-3 rounded-lg flex items-center gap-2"
|
|
195
|
+
>
|
|
196
|
+
<div className="relative h-4 w-4">
|
|
202
197
|
<Image
|
|
203
198
|
src="/icons/mcp.png"
|
|
204
199
|
alt="MCP"
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
className="mr-2"
|
|
200
|
+
fill
|
|
201
|
+
className="object-contain"
|
|
208
202
|
/>
|
|
209
|
-
|
|
210
|
-
|
|
203
|
+
</div>
|
|
204
|
+
Connect MCP
|
|
205
|
+
</Button>
|
|
211
206
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
207
|
+
<Button
|
|
208
|
+
variant="ghost"
|
|
209
|
+
size="sm"
|
|
210
|
+
className="text-white/50 hover:text-white hover:bg-white/5 transition-all h-9 px-4 rounded-lg group/docs border border-transparent hover:border-white/10"
|
|
211
|
+
asChild
|
|
212
|
+
>
|
|
213
|
+
<a
|
|
214
|
+
href="https://docs.cybermem.dev"
|
|
215
|
+
target="_blank"
|
|
216
|
+
rel="noopener noreferrer"
|
|
217
|
+
className="flex items-center"
|
|
217
218
|
>
|
|
218
|
-
<
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
</Button>
|
|
219
|
+
<Book className="h-4 w-4 mr-2 opacity-50 group-hover/docs:opacity-100 transition-opacity" />
|
|
220
|
+
Docs
|
|
221
|
+
</a>
|
|
222
|
+
</Button>
|
|
223
223
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
224
|
+
<Button
|
|
225
|
+
data-testid="settings-button"
|
|
226
|
+
variant="ghost"
|
|
227
|
+
size="icon"
|
|
228
|
+
onClick={onShowSettings}
|
|
229
|
+
className="text-white/50 hover:text-white hover:bg-white/10 transition-all h-9 w-9 rounded-lg border border-transparent hover:border-white/10"
|
|
230
|
+
title="Settings"
|
|
231
|
+
>
|
|
232
|
+
<Settings className="h-5 w-5" />
|
|
233
|
+
</Button>
|
|
233
234
|
</div>
|
|
234
235
|
</div>
|
|
235
236
|
</header>
|
|
@@ -11,9 +11,10 @@ import {
|
|
|
11
11
|
Download,
|
|
12
12
|
RefreshCw,
|
|
13
13
|
} from "lucide-react";
|
|
14
|
+
import Image from "next/image";
|
|
14
15
|
import { useState } from "react";
|
|
15
16
|
|
|
16
|
-
interface
|
|
17
|
+
interface LogViewerProps {
|
|
17
18
|
logs: any[];
|
|
18
19
|
loading: boolean;
|
|
19
20
|
currentPage: number;
|
|
@@ -58,7 +59,7 @@ const periods = [
|
|
|
58
59
|
{ label: "All Time", value: "all" },
|
|
59
60
|
];
|
|
60
61
|
|
|
61
|
-
export default function
|
|
62
|
+
export default function LogViewer({
|
|
62
63
|
logs,
|
|
63
64
|
loading,
|
|
64
65
|
currentPage,
|
|
@@ -67,7 +68,7 @@ export default function AuditLogTable({
|
|
|
67
68
|
sortField,
|
|
68
69
|
sortDirection,
|
|
69
70
|
onSort,
|
|
70
|
-
}:
|
|
71
|
+
}: LogViewerProps) {
|
|
71
72
|
const [period, setPeriod] = useState("all");
|
|
72
73
|
const [showExportMenu, setShowExportMenu] = useState(false);
|
|
73
74
|
const { clientConfigs } = useDashboard();
|
|
@@ -260,15 +261,19 @@ export default function AuditLogTable({
|
|
|
260
261
|
key={log.id}
|
|
261
262
|
className="border-b border-white/5 hover:bg-white/10 transition-colors even:bg-white/[0.02] group/row"
|
|
262
263
|
>
|
|
263
|
-
<td className="py-4 px-3 text-neutral-300
|
|
264
|
-
{log.date
|
|
264
|
+
<td className="py-4 px-3 text-neutral-300">
|
|
265
|
+
{log.date
|
|
266
|
+
? new Date(log.date).toLocaleString()
|
|
267
|
+
: "N/A"}
|
|
265
268
|
</td>
|
|
266
269
|
<td className="py-4 px-3 text-white font-medium">
|
|
267
270
|
<div className="flex items-center gap-2">
|
|
268
271
|
{icon && (
|
|
269
|
-
<
|
|
272
|
+
<Image
|
|
270
273
|
src={icon}
|
|
271
274
|
alt={displayName}
|
|
275
|
+
width={20}
|
|
276
|
+
height={20}
|
|
272
277
|
className="w-5 h-5 object-contain"
|
|
273
278
|
/>
|
|
274
279
|
)}
|
|
@@ -283,7 +288,7 @@ export default function AuditLogTable({
|
|
|
283
288
|
</td>
|
|
284
289
|
<td className="py-4 px-3">
|
|
285
290
|
<span
|
|
286
|
-
className={`px-3 py-1 rounded-full text-xs font-medium border ${config.bg} ${config.text} ${config.border}`}
|
|
291
|
+
className={`status-pill px-3 py-1 rounded-full text-xs font-medium border ${config.bg} ${config.text} ${config.border}`}
|
|
287
292
|
>
|
|
288
293
|
{log.status}
|
|
289
294
|
</span>
|