@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,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { Card, CardContent } from "@/components/ui/card";
|
|
4
|
-
import MetricCard from "./
|
|
4
|
+
import MetricCard from "./metrics/stat-card";
|
|
5
5
|
|
|
6
6
|
// Types
|
|
7
7
|
interface TrendState {
|
|
@@ -49,20 +49,24 @@ function ClientCard({
|
|
|
49
49
|
name,
|
|
50
50
|
subtitle,
|
|
51
51
|
isEmpty,
|
|
52
|
+
testId,
|
|
52
53
|
}: {
|
|
53
54
|
label: string;
|
|
54
55
|
name: string;
|
|
55
56
|
subtitle: string;
|
|
56
57
|
isEmpty: boolean;
|
|
58
|
+
testId?: string;
|
|
57
59
|
}) {
|
|
58
60
|
return (
|
|
59
61
|
<Card
|
|
60
|
-
className={`bg-white/5 border-white/10 backdrop-blur-md text-white shadow-lg overflow-hidden ${isEmpty ? "opacity-60" : ""}`}
|
|
62
|
+
className={`card bg-white/5 border-white/10 backdrop-blur-md text-white shadow-lg overflow-hidden ${isEmpty ? "opacity-60" : ""}`}
|
|
63
|
+
data-testid={testId}
|
|
61
64
|
>
|
|
62
65
|
<CardContent className="pt-6 pb-6 relative">
|
|
63
66
|
<div className="text-sm font-medium text-slate-400 mb-2">{label}</div>
|
|
64
67
|
<div
|
|
65
68
|
className={`text-4xl font-bold mb-1 truncate ${isEmpty ? "text-slate-500" : "text-white"}`}
|
|
69
|
+
data-testid="stat-value"
|
|
66
70
|
>
|
|
67
71
|
{name}
|
|
68
72
|
</div>
|
|
@@ -146,6 +150,7 @@ export default function MetricsGrid({
|
|
|
146
150
|
) : (
|
|
147
151
|
<ClientCard
|
|
148
152
|
label="Top Writer"
|
|
153
|
+
testId="card-top-writer"
|
|
149
154
|
name={stats.topWriter.count > 0 ? stats.topWriter.name : "N/A"}
|
|
150
155
|
subtitle={
|
|
151
156
|
stats.topWriter.count > 0
|
|
@@ -162,6 +167,7 @@ export default function MetricsGrid({
|
|
|
162
167
|
) : (
|
|
163
168
|
<ClientCard
|
|
164
169
|
label="Top Reader"
|
|
170
|
+
testId="card-top-reader"
|
|
165
171
|
name={stats.topReader.count > 0 ? stats.topReader.name : "N/A"}
|
|
166
172
|
subtitle={
|
|
167
173
|
stats.topReader.count > 0
|
|
@@ -178,6 +184,7 @@ export default function MetricsGrid({
|
|
|
178
184
|
) : (
|
|
179
185
|
<ClientCard
|
|
180
186
|
label="Last Writer"
|
|
187
|
+
testId="card-last-writer"
|
|
181
188
|
name={stats.lastWriter.name !== "N/A" ? stats.lastWriter.name : "N/A"}
|
|
182
189
|
subtitle={formatTimestamp(stats.lastWriter.timestamp)}
|
|
183
190
|
isEmpty={
|
|
@@ -192,6 +199,7 @@ export default function MetricsGrid({
|
|
|
192
199
|
) : (
|
|
193
200
|
<ClientCard
|
|
194
201
|
label="Last Reader"
|
|
202
|
+
testId="card-last-reader"
|
|
195
203
|
name={stats.lastReader.name !== "N/A" ? stats.lastReader.name : "N/A"}
|
|
196
204
|
subtitle={formatTimestamp(stats.lastReader.timestamp)}
|
|
197
205
|
isEmpty={
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Input } from "@/components/ui/input";
|
|
4
|
+
import { Label } from "@/components/ui/label";
|
|
5
|
+
import { TintButton } from "@/components/ui/tint-button";
|
|
6
|
+
import { Check, Copy, Eye, EyeOff, RotateCcw, Shield } from "lucide-react";
|
|
7
|
+
|
|
8
|
+
interface AccessTokenSectionProps {
|
|
9
|
+
apiKey: string;
|
|
10
|
+
showApiKey: boolean;
|
|
11
|
+
setShowApiKey: (show: boolean) => void;
|
|
12
|
+
copiedId: string | null;
|
|
13
|
+
copyToClipboard: (text: string, id: string) => void;
|
|
14
|
+
setShowRegenConfirm: (show: boolean) => void;
|
|
15
|
+
isManaged: boolean;
|
|
16
|
+
instanceType: "local" | "rpi" | "vps";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default function AccessTokenSection({
|
|
20
|
+
apiKey,
|
|
21
|
+
showApiKey,
|
|
22
|
+
setShowApiKey,
|
|
23
|
+
copiedId,
|
|
24
|
+
copyToClipboard,
|
|
25
|
+
setShowRegenConfirm,
|
|
26
|
+
isManaged,
|
|
27
|
+
instanceType,
|
|
28
|
+
}: AccessTokenSectionProps) {
|
|
29
|
+
return (
|
|
30
|
+
<section>
|
|
31
|
+
<h3 className="text-sm font-medium text-neutral-400 uppercase tracking-widest mb-4 flex items-center gap-2">
|
|
32
|
+
<Shield className="w-4 h-4" />
|
|
33
|
+
Access Token
|
|
34
|
+
</h3>
|
|
35
|
+
<div className="bg-white/[0.032] border-[0.5px] border-white/10 rounded-2xl p-6 space-y-4">
|
|
36
|
+
{/* Token Display */}
|
|
37
|
+
<div className="space-y-2">
|
|
38
|
+
<Label htmlFor="access-token">Your Access Token</Label>
|
|
39
|
+
<div className="flex gap-2">
|
|
40
|
+
<div className="relative flex-1">
|
|
41
|
+
<Input
|
|
42
|
+
id="access-token"
|
|
43
|
+
value={apiKey || "Token not generated yet"}
|
|
44
|
+
readOnly
|
|
45
|
+
className="bg-black/40 border-[0.5px] border-white/10 text-white font-mono text-sm pr-10"
|
|
46
|
+
type={showApiKey ? "text" : "password"}
|
|
47
|
+
/>
|
|
48
|
+
<button
|
|
49
|
+
data-testid="toggle-visibility"
|
|
50
|
+
onClick={() => setShowApiKey(!showApiKey)}
|
|
51
|
+
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-white"
|
|
52
|
+
>
|
|
53
|
+
{showApiKey ? (
|
|
54
|
+
<EyeOff className="w-4 h-4" />
|
|
55
|
+
) : (
|
|
56
|
+
<Eye className="w-4 h-4" />
|
|
57
|
+
)}
|
|
58
|
+
</button>
|
|
59
|
+
</div>
|
|
60
|
+
<TintButton
|
|
61
|
+
tint="neutral"
|
|
62
|
+
variant="ghost"
|
|
63
|
+
size="icon"
|
|
64
|
+
onClick={() => copyToClipboard(apiKey, "accesstoken")}
|
|
65
|
+
title="Copy token"
|
|
66
|
+
>
|
|
67
|
+
{copiedId === "accesstoken" ? (
|
|
68
|
+
<Check className="h-4 w-4 text-emerald-400" />
|
|
69
|
+
) : (
|
|
70
|
+
<Copy className="h-4 w-4" />
|
|
71
|
+
)}
|
|
72
|
+
</TintButton>
|
|
73
|
+
<TintButton
|
|
74
|
+
tint="yellow"
|
|
75
|
+
variant="ghost"
|
|
76
|
+
size="icon"
|
|
77
|
+
onClick={() => setShowRegenConfirm(true)}
|
|
78
|
+
title="Regenerate token"
|
|
79
|
+
>
|
|
80
|
+
<RotateCcw className="w-4 h-4" />
|
|
81
|
+
</TintButton>
|
|
82
|
+
</div>
|
|
83
|
+
<p className="text-xs text-neutral-500">
|
|
84
|
+
Use this token to connect MCP clients from other devices
|
|
85
|
+
</p>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
{/* Auth Status */}
|
|
89
|
+
<div className="pt-4 border-t border-white/5">
|
|
90
|
+
<div className="flex items-center gap-3 p-3 bg-white/10 rounded-xl border-[0.5px] border-white/10">
|
|
91
|
+
{isManaged ? (
|
|
92
|
+
<>
|
|
93
|
+
<div className="w-2 h-2 bg-emerald-400 rounded-full animate-pulse" />
|
|
94
|
+
<div className="flex-1">
|
|
95
|
+
<p className="text-sm text-emerald-300 font-medium">
|
|
96
|
+
{instanceType === "local"
|
|
97
|
+
? "Local Mode Active"
|
|
98
|
+
: "LAN / RPi Mode Active"}
|
|
99
|
+
</p>
|
|
100
|
+
<p className="text-xs text-white/70">
|
|
101
|
+
{instanceType === "local"
|
|
102
|
+
? "No token needed for local connections"
|
|
103
|
+
: "Connect from other devices using the secure token"}
|
|
104
|
+
</p>
|
|
105
|
+
</div>
|
|
106
|
+
</>
|
|
107
|
+
) : (
|
|
108
|
+
<>
|
|
109
|
+
<div className="w-2 h-2 bg-yellow-400 rounded-full" />
|
|
110
|
+
<div className="flex-1">
|
|
111
|
+
<p className="text-sm text-yellow-300 font-medium">
|
|
112
|
+
{instanceType === "rpi"
|
|
113
|
+
? "LAN / RPi Mode"
|
|
114
|
+
: instanceType === "vps"
|
|
115
|
+
? "Cloud Mode"
|
|
116
|
+
: "Remote Mode"}
|
|
117
|
+
</p>
|
|
118
|
+
<p className="text-xs text-white/70">
|
|
119
|
+
{instanceType === "rpi"
|
|
120
|
+
? "Connecting from your laptop to RPi"
|
|
121
|
+
: "Token required for remote MCP connections"}
|
|
122
|
+
</p>
|
|
123
|
+
</div>
|
|
124
|
+
</>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
</section>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { TintButton } from "@/components/ui/tint-button";
|
|
4
|
+
import {
|
|
5
|
+
Check,
|
|
6
|
+
Database,
|
|
7
|
+
Download,
|
|
8
|
+
Loader2,
|
|
9
|
+
Trash2,
|
|
10
|
+
Upload,
|
|
11
|
+
X,
|
|
12
|
+
} from "lucide-react";
|
|
13
|
+
|
|
14
|
+
interface DataManagementSectionProps {
|
|
15
|
+
handleBackup: () => void;
|
|
16
|
+
isBackingUp: boolean;
|
|
17
|
+
handleRestore: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
|
18
|
+
isRestoring: boolean;
|
|
19
|
+
setShowResetConfirm: (show: boolean) => void;
|
|
20
|
+
isResetting: boolean;
|
|
21
|
+
operationStatus: { type: "success" | "error"; message: string } | null;
|
|
22
|
+
setOperationStatus: (
|
|
23
|
+
status: { type: "success" | "error"; message: string } | null,
|
|
24
|
+
) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default function DataManagementSection({
|
|
28
|
+
handleBackup,
|
|
29
|
+
isBackingUp,
|
|
30
|
+
handleRestore,
|
|
31
|
+
isRestoring,
|
|
32
|
+
setShowResetConfirm,
|
|
33
|
+
isResetting,
|
|
34
|
+
operationStatus,
|
|
35
|
+
setOperationStatus,
|
|
36
|
+
}: DataManagementSectionProps) {
|
|
37
|
+
return (
|
|
38
|
+
<section>
|
|
39
|
+
<h3 className="text-sm font-medium text-neutral-400 uppercase tracking-widest mb-4 flex items-center gap-2">
|
|
40
|
+
<Database className="w-4 h-4" />
|
|
41
|
+
Data Management
|
|
42
|
+
</h3>
|
|
43
|
+
<div className="bg-white/[0.032] border-[0.5px] border-white/10 rounded-2xl p-6 flex flex-col gap-4">
|
|
44
|
+
<div className="flex items-center gap-3">
|
|
45
|
+
<TintButton
|
|
46
|
+
tint="neutral"
|
|
47
|
+
variant="solid"
|
|
48
|
+
className="flex-1 h-11"
|
|
49
|
+
onClick={handleBackup}
|
|
50
|
+
disabled={isBackingUp}
|
|
51
|
+
>
|
|
52
|
+
{isBackingUp ? (
|
|
53
|
+
<Loader2 className="w-4 h-4 animate-spin" />
|
|
54
|
+
) : (
|
|
55
|
+
<Download className="w-4 h-4" />
|
|
56
|
+
)}
|
|
57
|
+
Backup
|
|
58
|
+
</TintButton>
|
|
59
|
+
|
|
60
|
+
<div className="flex-1 relative">
|
|
61
|
+
<input
|
|
62
|
+
type="file"
|
|
63
|
+
id="restore-file"
|
|
64
|
+
className="hidden"
|
|
65
|
+
accept=".tar.gz,.tgz"
|
|
66
|
+
onChange={handleRestore}
|
|
67
|
+
disabled={isRestoring}
|
|
68
|
+
/>
|
|
69
|
+
<TintButton
|
|
70
|
+
tint="neutral"
|
|
71
|
+
variant="solid"
|
|
72
|
+
className="w-full h-11"
|
|
73
|
+
onClick={() => document.getElementById("restore-file")?.click()}
|
|
74
|
+
disabled={isRestoring}
|
|
75
|
+
>
|
|
76
|
+
{isRestoring ? (
|
|
77
|
+
<Loader2 className="w-4 h-4 animate-spin" />
|
|
78
|
+
) : (
|
|
79
|
+
<Upload className="w-4 h-4" />
|
|
80
|
+
)}
|
|
81
|
+
Restore
|
|
82
|
+
</TintButton>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<TintButton
|
|
86
|
+
tint="red"
|
|
87
|
+
variant="solid"
|
|
88
|
+
className="flex-1 h-11"
|
|
89
|
+
onClick={() => setShowResetConfirm(true)}
|
|
90
|
+
disabled={isResetting}
|
|
91
|
+
>
|
|
92
|
+
<Trash2 className="w-4 h-4" />
|
|
93
|
+
Reset DB
|
|
94
|
+
</TintButton>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
{operationStatus && (
|
|
98
|
+
<div
|
|
99
|
+
className={`p-3 rounded-xl text-sm flex items-center gap-3 animate-in fade-in slide-in-from-top-1 ${
|
|
100
|
+
operationStatus.type === "success"
|
|
101
|
+
? "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
|
|
102
|
+
: "bg-red-500/10 text-red-400 border border-red-500/20"
|
|
103
|
+
}`}
|
|
104
|
+
>
|
|
105
|
+
{operationStatus.type === "success" ? (
|
|
106
|
+
<Check className="w-4 h-4" />
|
|
107
|
+
) : (
|
|
108
|
+
<X className="w-4 h-4" />
|
|
109
|
+
)}
|
|
110
|
+
<span className="flex-1">{operationStatus.message}</span>
|
|
111
|
+
<button
|
|
112
|
+
onClick={() => setOperationStatus(null)}
|
|
113
|
+
className="opacity-50 hover:opacity-100 p-1"
|
|
114
|
+
>
|
|
115
|
+
<X className="w-4 h-4" />
|
|
116
|
+
</button>
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
</section>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { TintButton } from "@/components/ui/tint-button";
|
|
4
|
+
import { Loader2, RotateCcw, Server } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
interface SystemInfoSectionProps {
|
|
7
|
+
settings: any;
|
|
8
|
+
handleRestart: () => void;
|
|
9
|
+
isRestarting: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default function SystemInfoSection({
|
|
13
|
+
settings,
|
|
14
|
+
handleRestart,
|
|
15
|
+
isRestarting,
|
|
16
|
+
}: SystemInfoSectionProps) {
|
|
17
|
+
return (
|
|
18
|
+
<section className="pb-4">
|
|
19
|
+
<h3 className="text-sm font-medium text-neutral-400 uppercase tracking-widest mb-4 flex items-center gap-2">
|
|
20
|
+
<Server className="w-4 h-4" />
|
|
21
|
+
System Information
|
|
22
|
+
</h3>
|
|
23
|
+
<div className="bg-white/[0.032] border-[0.5px] border-white/10 rounded-2xl p-6 space-y-6">
|
|
24
|
+
<div className="grid grid-cols-2 gap-12">
|
|
25
|
+
<div className="space-y-4">
|
|
26
|
+
<span className="text-[10px] uppercase text-neutral-500 font-bold tracking-[0.2em] block mb-2">
|
|
27
|
+
Versions
|
|
28
|
+
</span>
|
|
29
|
+
<div className="space-y-3">
|
|
30
|
+
<div className="flex justify-between items-center group/version">
|
|
31
|
+
<span className="text-xs text-neutral-400 group-hover/version:text-neutral-300 transition-colors">
|
|
32
|
+
Dashboard
|
|
33
|
+
</span>
|
|
34
|
+
<code className="text-[13px] font-mono text-neutral-200 bg-white/5 px-2 py-0.5 rounded border border-white/10 group-hover/version:border-emerald-500/30 group-hover/version:text-emerald-400 transition-all">
|
|
35
|
+
{settings?.dashboardVersion || "v0.7.5"}
|
|
36
|
+
</code>
|
|
37
|
+
</div>
|
|
38
|
+
<div className="flex justify-between items-center group/version">
|
|
39
|
+
<span className="text-xs text-neutral-400 group-hover/version:text-neutral-300 transition-colors">
|
|
40
|
+
MCP Server
|
|
41
|
+
</span>
|
|
42
|
+
<code className="text-[13px] font-mono text-neutral-200 bg-white/5 px-2 py-0.5 rounded border border-white/10 group-hover/version:border-emerald-500/30 group-hover/version:text-emerald-400 transition-all">
|
|
43
|
+
{settings?.mcpVersion || "v0.7.5"}
|
|
44
|
+
</code>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
<div className="border-l border-white/5 pl-8">
|
|
49
|
+
<span className="text-[10px] uppercase text-neutral-500 font-bold tracking-[0.2em] block mb-2">
|
|
50
|
+
Environment
|
|
51
|
+
</span>
|
|
52
|
+
<div className="space-y-3">
|
|
53
|
+
<div className="flex justify-between items-center">
|
|
54
|
+
<span className="text-xs text-neutral-400">Status</span>
|
|
55
|
+
<code
|
|
56
|
+
className={`text-[13px] font-mono px-2 py-0.5 rounded border ${
|
|
57
|
+
settings?.env === "staging"
|
|
58
|
+
? "text-yellow-400 bg-yellow-500/10 border-yellow-500/20"
|
|
59
|
+
: "text-emerald-400 bg-emerald-500/10 border-emerald-500/20"
|
|
60
|
+
}`}
|
|
61
|
+
>
|
|
62
|
+
{settings?.env === "staging" ? "Staging" : "Production"}
|
|
63
|
+
</code>
|
|
64
|
+
</div>
|
|
65
|
+
<div className="flex justify-between items-center">
|
|
66
|
+
<span className="text-xs text-neutral-400">Instance</span>
|
|
67
|
+
<code className="text-[13px] font-mono text-neutral-200 bg-white/5 px-2 py-0.5 rounded border border-white/10">
|
|
68
|
+
{settings?.instanceType === "rpi"
|
|
69
|
+
? "Raspberry Pi"
|
|
70
|
+
: settings?.instanceType === "vps"
|
|
71
|
+
? "Cloud / VPS"
|
|
72
|
+
: "Local Machine"}
|
|
73
|
+
</code>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<div className="pt-2 border-t border-white/5">
|
|
80
|
+
<TintButton
|
|
81
|
+
tint="sky"
|
|
82
|
+
variant="solid"
|
|
83
|
+
className="w-full h-10"
|
|
84
|
+
onClick={handleRestart}
|
|
85
|
+
disabled={isRestarting}
|
|
86
|
+
>
|
|
87
|
+
{isRestarting ? (
|
|
88
|
+
<Loader2 className="w-4 h-4 animate-spin" />
|
|
89
|
+
) : (
|
|
90
|
+
<RotateCcw className="w-4 h-4" />
|
|
91
|
+
)}
|
|
92
|
+
{isRestarting ? "Restarting..." : "Restart Service"}
|
|
93
|
+
</TintButton>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</section>
|
|
97
|
+
);
|
|
98
|
+
}
|