OhMyDitzzy commited on
Commit
f7d65f7
·
1 Parent(s): f6d3526

Persistance stats tracker

Browse files
src/components/PluginCard.tsx CHANGED
@@ -37,7 +37,7 @@ export function PluginCard({ plugin }: PluginCardProps) {
37
 
38
  const handleExecute = async () => {
39
  setLoading(true);
40
-
41
  try {
42
  let url = "/api" + plugin.endpoint;
43
  let fullUrl = getApiUrl(plugin.endpoint);
@@ -57,14 +57,12 @@ export function PluginCard({ plugin }: PluginCardProps) {
57
  }
58
  }
59
 
60
- // Store the request URL for display
61
  setRequestUrl(fullUrl);
62
 
63
  const fetchOptions: RequestInit = {
64
  method: plugin.method,
65
  };
66
 
67
- // Add body for POST/PUT/PATCH
68
  if (["POST", "PUT", "PATCH"].includes(plugin.method) && plugin.parameters?.body) {
69
  const bodyData: Record<string, any> = {};
70
  plugin.parameters.body.forEach((param) => {
@@ -82,7 +80,6 @@ export function PluginCard({ plugin }: PluginCardProps) {
82
  const res = await fetch(url, fetchOptions);
83
  const data = await res.json();
84
 
85
- // Capture response headers
86
  const headers: Record<string, string> = {};
87
  res.headers.forEach((value, key) => {
88
  headers[key] = value;
@@ -118,24 +115,24 @@ export function PluginCard({ plugin }: PluginCardProps) {
118
  setCopiedRequestUrl(true);
119
  setTimeout(() => setCopiedRequestUrl(false), 2000);
120
  };
121
-
122
  const hasQueryParams = plugin.parameters?.query && plugin.parameters.query.length > 0;
123
  const hasBodyParams = plugin.parameters?.body && plugin.parameters.body.length > 0;
124
  const hasPathParams = plugin.parameters?.path && plugin.parameters.path.length > 0;
125
  const hasAnyParams = hasQueryParams || hasBodyParams || hasPathParams;
126
-
127
  const generateCurlExample = () => {
128
  let curl = `curl -X ${plugin.method} "${getApiUrl(plugin.endpoint)}`;
129
-
130
  if (hasQueryParams) {
131
  const exampleParams = plugin.parameters!.query!
132
  .map((p) => `${p.name}=${p.example || 'value'}`)
133
  .join('&');
134
  curl += `?${exampleParams}`;
135
  }
136
-
137
  curl += '"';
138
-
139
  if (hasBodyParams) {
140
  curl += ' \\\n -H "Content-Type: application/json" \\\n -d \'';
141
  const bodyExample: Record<string, any> = {};
@@ -145,22 +142,22 @@ export function PluginCard({ plugin }: PluginCardProps) {
145
  curl += JSON.stringify(bodyExample, null, 2);
146
  curl += "'";
147
  }
148
-
149
  return curl;
150
  };
151
 
152
  const generateNodeExample = () => {
153
  let code = `const response = await fetch("${getApiUrl(plugin.endpoint)}`;
154
-
155
  if (hasQueryParams) {
156
  const exampleParams = plugin.parameters!.query!
157
  .map((p) => `${p.name}=${p.example || 'value'}`)
158
  .join('&');
159
  code += `?${exampleParams}`;
160
  }
161
-
162
  code += '", {\n method: "' + plugin.method + '"';
163
-
164
  if (hasBodyParams) {
165
  code += ',\n headers: {\n "Content-Type": "application/json"\n },\n body: JSON.stringify(';
166
  const bodyExample: Record<string, any> = {};
@@ -170,76 +167,77 @@ export function PluginCard({ plugin }: PluginCardProps) {
170
  code += JSON.stringify(bodyExample, null, 2);
171
  code += ')';
172
  }
173
-
174
  code += '\n});\n\nconst data = await response.json();\nconsole.log(data);';
175
  return code;
176
  };
177
 
178
  return (
179
- <Card className="bg-white/[0.02] border-white/10 overflow-hidden">
180
  {/* Collapsible Header */}
181
- <div
182
- className="p-6 border-b border-white/10 cursor-pointer hover:bg-white/[0.02] transition-colors"
183
  onClick={() => setIsExpanded(!isExpanded)}
184
  >
185
- <div className="flex items-start justify-between gap-4">
186
- <div className="flex-1 min-w-0">
187
- <div className="flex items-center gap-3 mb-3 flex-wrap">
188
- <Badge className={`${methodColors[plugin.method]} border font-bold px-3 py-1 flex-shrink-0`}>
189
- {plugin.method}
190
- </Badge>
191
- <code className="text-sm text-purple-400 font-mono break-all">{plugin.endpoint}</code>
192
- <button
193
- onClick={(e) => {
194
- e.stopPropagation();
195
- copyApiUrl();
196
- }}
197
- className="text-gray-400 hover:text-white transition-colors p-1 flex-shrink-0"
198
- title="Copy API URL"
199
- >
200
- {copiedUrl ? (
201
- <Check className="w-4 h-4 text-green-400" />
202
- ) : (
203
- <Copy className="w-4 h-4" />
204
- )}
205
- </button>
206
- </div>
207
- <h3 className="text-xl font-bold text-white mb-2 break-words">{plugin.name}</h3>
208
- <p className="text-gray-400 text-sm break-words">{plugin.description}</p>
209
-
210
- {/* Tags */}
211
- {plugin.tags && plugin.tags.length > 0 && (
212
- <div className="flex flex-wrap gap-2 mt-3">
213
- {plugin.tags.map((tag) => (
214
- <Badge key={tag} variant="outline" className="bg-white/5 text-gray-400 border-white/10 text-xs">
215
- {tag}
216
- </Badge>
217
- ))}
218
- </div>
219
- )}
220
 
221
- {/* API URL Display */}
222
- <div className="mt-3 flex items-start gap-2">
223
- <span className="text-xs text-gray-500 flex-shrink-0">API URL:</span>
224
- <code className="text-xs text-gray-300 bg-black/30 px-2 py-1 rounded break-all">
225
- {getApiUrl(plugin.endpoint)}
226
- </code>
227
- </div>
 
 
 
 
 
 
228
  </div>
 
229
 
230
- <button
231
- className="text-gray-400 hover:text-white transition-colors flex-shrink-0 p-2 hover:bg-white/5 rounded-lg"
232
- onClick={(e) => {
233
- e.stopPropagation();
234
- setIsExpanded(!isExpanded);
235
- }}
236
- >
237
- {isExpanded ? (
238
- <ChevronUp className="w-6 h-6" />
239
- ) : (
240
- <ChevronDown className="w-6 h-6" />
241
- )}
242
- </button>
 
 
 
 
 
 
 
 
 
243
  </div>
244
  </div>
245
 
@@ -330,21 +328,19 @@ export function PluginCard({ plugin }: PluginCardProps) {
330
  <div className="space-y-3">
331
  {Object.entries(plugin.responses).map(([status, response]) => (
332
  <div key={status} className="border border-white/10 rounded-lg overflow-hidden">
333
- <div className={`px-4 py-2 flex items-center gap-3 ${
334
- parseInt(status) >= 200 && parseInt(status) < 300
335
- ? "bg-green-500/10"
336
- : parseInt(status) >= 400 && parseInt(status) < 500
337
  ? "bg-yellow-500/10"
338
  : "bg-red-500/10"
339
- }`}>
340
  <Badge
341
- className={`${
342
- parseInt(status) >= 200 && parseInt(status) < 300
343
- ? "bg-green-500/20 text-green-400 border-green-500/50"
344
- : parseInt(status) >= 400 && parseInt(status) < 500
345
  ? "bg-yellow-500/20 text-yellow-400 border-yellow-500/50"
346
  : "bg-red-500/20 text-red-400 border-red-500/50"
347
- } border font-bold`}
348
  >
349
  {status}
350
  </Badge>
@@ -369,7 +365,7 @@ export function PluginCard({ plugin }: PluginCardProps) {
369
  </div>
370
  <CodeBlock code={generateCurlExample()} language="bash" />
371
  </div>
372
-
373
  <div>
374
  <div className="mb-2">
375
  <span className="text-xs text-gray-400">Node.js (fetch)</span>
@@ -403,7 +399,7 @@ export function PluginCard({ plugin }: PluginCardProps) {
403
  <p className="text-xs text-gray-500 mt-1">{param.description}</p>
404
  </div>
405
  ))}
406
-
407
  {/* Body Parameters */}
408
  {plugin.parameters?.body?.map((param) => (
409
  <div key={param.name}>
@@ -464,11 +460,10 @@ export function PluginCard({ plugin }: PluginCardProps) {
464
  {/* Response Status */}
465
  <div className="flex items-center justify-between">
466
  <span className="text-sm text-gray-400">Response Status</span>
467
- <Badge className={`${
468
- response.status >= 200 && response.status < 300
469
- ? "bg-green-500/20 text-green-400"
470
- : "bg-red-500/20 text-red-400"
471
- }`}>
472
  {response.status} {response.statusText}
473
  </Badge>
474
  </div>
@@ -491,8 +486,8 @@ export function PluginCard({ plugin }: PluginCardProps) {
491
  {/* Response Body with Syntax Highlighting */}
492
  <div>
493
  <h5 className="text-sm text-gray-400 mb-2">Response Body</h5>
494
- <CodeBlock
495
- code={JSON.stringify(response.data, null, 2)}
496
  language="json"
497
  />
498
  </div>
@@ -503,4 +498,4 @@ export function PluginCard({ plugin }: PluginCardProps) {
503
  )}
504
  </Card>
505
  );
506
- }
 
37
 
38
  const handleExecute = async () => {
39
  setLoading(true);
40
+
41
  try {
42
  let url = "/api" + plugin.endpoint;
43
  let fullUrl = getApiUrl(plugin.endpoint);
 
57
  }
58
  }
59
 
 
60
  setRequestUrl(fullUrl);
61
 
62
  const fetchOptions: RequestInit = {
63
  method: plugin.method,
64
  };
65
 
 
66
  if (["POST", "PUT", "PATCH"].includes(plugin.method) && plugin.parameters?.body) {
67
  const bodyData: Record<string, any> = {};
68
  plugin.parameters.body.forEach((param) => {
 
80
  const res = await fetch(url, fetchOptions);
81
  const data = await res.json();
82
 
 
83
  const headers: Record<string, string> = {};
84
  res.headers.forEach((value, key) => {
85
  headers[key] = value;
 
115
  setCopiedRequestUrl(true);
116
  setTimeout(() => setCopiedRequestUrl(false), 2000);
117
  };
118
+
119
  const hasQueryParams = plugin.parameters?.query && plugin.parameters.query.length > 0;
120
  const hasBodyParams = plugin.parameters?.body && plugin.parameters.body.length > 0;
121
  const hasPathParams = plugin.parameters?.path && plugin.parameters.path.length > 0;
122
  const hasAnyParams = hasQueryParams || hasBodyParams || hasPathParams;
123
+
124
  const generateCurlExample = () => {
125
  let curl = `curl -X ${plugin.method} "${getApiUrl(plugin.endpoint)}`;
126
+
127
  if (hasQueryParams) {
128
  const exampleParams = plugin.parameters!.query!
129
  .map((p) => `${p.name}=${p.example || 'value'}`)
130
  .join('&');
131
  curl += `?${exampleParams}`;
132
  }
133
+
134
  curl += '"';
135
+
136
  if (hasBodyParams) {
137
  curl += ' \\\n -H "Content-Type: application/json" \\\n -d \'';
138
  const bodyExample: Record<string, any> = {};
 
142
  curl += JSON.stringify(bodyExample, null, 2);
143
  curl += "'";
144
  }
145
+
146
  return curl;
147
  };
148
 
149
  const generateNodeExample = () => {
150
  let code = `const response = await fetch("${getApiUrl(plugin.endpoint)}`;
151
+
152
  if (hasQueryParams) {
153
  const exampleParams = plugin.parameters!.query!
154
  .map((p) => `${p.name}=${p.example || 'value'}`)
155
  .join('&');
156
  code += `?${exampleParams}`;
157
  }
158
+
159
  code += '", {\n method: "' + plugin.method + '"';
160
+
161
  if (hasBodyParams) {
162
  code += ',\n headers: {\n "Content-Type": "application/json"\n },\n body: JSON.stringify(';
163
  const bodyExample: Record<string, any> = {};
 
167
  code += JSON.stringify(bodyExample, null, 2);
168
  code += ')';
169
  }
170
+
171
  code += '\n});\n\nconst data = await response.json();\nconsole.log(data);';
172
  return code;
173
  };
174
 
175
  return (
176
+ <Card className="bg-white/[0.02] border-white/10 overflow-hidden w-full">
177
  {/* Collapsible Header */}
178
+ <div
179
+ className="p-4 border-b border-white/10 cursor-pointer hover:bg-white/[0.02] transition-colors"
180
  onClick={() => setIsExpanded(!isExpanded)}
181
  >
182
+ <div className="flex items-center gap-2 mb-3 flex-wrap">
183
+ <Badge className={`${methodColors[plugin.method]} border font-bold px-3 py-1 flex-shrink-0`}>
184
+ {plugin.method}
185
+ </Badge>
186
+ <code className="text-sm text-purple-400 font-mono flex-1 min-w-0 break-all">{plugin.endpoint}</code>
187
+ <div className="flex items-center gap-1 flex-shrink-0">
188
+ <button
189
+ onClick={(e) => {
190
+ e.stopPropagation();
191
+ copyApiUrl();
192
+ }}
193
+ className="text-gray-400 hover:text-white transition-colors p-1.5"
194
+ title="Copy API URL"
195
+ >
196
+ {copiedUrl ? (
197
+ <Check className="w-4 h-4 text-green-400" />
198
+ ) : (
199
+ <Copy className="w-4 h-4" />
200
+ )}
201
+ </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
 
203
+ <button
204
+ className="text-gray-400 hover:text-white transition-colors p-1.5"
205
+ onClick={(e) => {
206
+ e.stopPropagation();
207
+ setIsExpanded(!isExpanded);
208
+ }}
209
+ >
210
+ {isExpanded ? (
211
+ <ChevronUp className="w-5 h-5" />
212
+ ) : (
213
+ <ChevronDown className="w-5 h-5" />
214
+ )}
215
+ </button>
216
  </div>
217
+ </div>
218
 
219
+ <div className="w-full">
220
+ <h3 className="text-xl font-bold text-white mb-2">{plugin.name}</h3>
221
+ <p className="text-gray-400 text-sm leading-relaxed">{plugin.description}</p>
222
+
223
+ {/* Tags */}
224
+ {plugin.tags && plugin.tags.length > 0 && (
225
+ <div className="flex flex-wrap gap-2 mt-3">
226
+ {plugin.tags.map((tag) => (
227
+ <Badge key={tag} variant="outline" className="bg-white/5 text-gray-400 border-white/10 text-xs">
228
+ {tag}
229
+ </Badge>
230
+ ))}
231
+ </div>
232
+ )}
233
+
234
+ {/* API URL Display */}
235
+ <div className="mt-3 flex items-start gap-2">
236
+ <span className="text-xs text-gray-500 flex-shrink-0">API URL:</span>
237
+ <code className="text-xs text-gray-300 bg-black/30 px-2 py-1 rounded break-all flex-1">
238
+ {getApiUrl(plugin.endpoint)}
239
+ </code>
240
+ </div>
241
  </div>
242
  </div>
243
 
 
328
  <div className="space-y-3">
329
  {Object.entries(plugin.responses).map(([status, response]) => (
330
  <div key={status} className="border border-white/10 rounded-lg overflow-hidden">
331
+ <div className={`px-4 py-2 flex items-center gap-3 ${parseInt(status) >= 200 && parseInt(status) < 300
332
+ ? "bg-green-500/10"
333
+ : parseInt(status) >= 400 && parseInt(status) < 500
 
334
  ? "bg-yellow-500/10"
335
  : "bg-red-500/10"
336
+ }`}>
337
  <Badge
338
+ className={`${parseInt(status) >= 200 && parseInt(status) < 300
339
+ ? "bg-green-500/20 text-green-400 border-green-500/50"
340
+ : parseInt(status) >= 400 && parseInt(status) < 500
 
341
  ? "bg-yellow-500/20 text-yellow-400 border-yellow-500/50"
342
  : "bg-red-500/20 text-red-400 border-red-500/50"
343
+ } border font-bold`}
344
  >
345
  {status}
346
  </Badge>
 
365
  </div>
366
  <CodeBlock code={generateCurlExample()} language="bash" />
367
  </div>
368
+
369
  <div>
370
  <div className="mb-2">
371
  <span className="text-xs text-gray-400">Node.js (fetch)</span>
 
399
  <p className="text-xs text-gray-500 mt-1">{param.description}</p>
400
  </div>
401
  ))}
402
+
403
  {/* Body Parameters */}
404
  {plugin.parameters?.body?.map((param) => (
405
  <div key={param.name}>
 
460
  {/* Response Status */}
461
  <div className="flex items-center justify-between">
462
  <span className="text-sm text-gray-400">Response Status</span>
463
+ <Badge className={`${response.status >= 200 && response.status < 300
464
+ ? "bg-green-500/20 text-green-400"
465
+ : "bg-red-500/20 text-red-400"
466
+ }`}>
 
467
  {response.status} {response.statusText}
468
  </Badge>
469
  </div>
 
486
  {/* Response Body with Syntax Highlighting */}
487
  <div>
488
  <h5 className="text-sm text-gray-400 mb-2">Response Body</h5>
489
+ <CodeBlock
490
+ code={JSON.stringify(response.data, null, 2)}
491
  language="json"
492
  />
493
  </div>
 
498
  )}
499
  </Card>
500
  );
501
+ }
src/components/VisitorChart.tsx CHANGED
@@ -11,16 +11,17 @@ interface VisitorData {
11
  export function VisitorChart() {
12
  const [data, setData] = useState<VisitorData[]>([]);
13
  const [loading, setLoading] = useState(true);
 
14
 
15
  useEffect(() => {
16
  fetchVisitorData();
17
  const interval = setInterval(fetchVisitorData, 5 * 60 * 1000);
18
  return () => clearInterval(interval);
19
- }, []);
20
 
21
  const fetchVisitorData = async () => {
22
  try {
23
- const res = await fetch("/api/stats/visitors");
24
  const json = await res.json();
25
  if (json.success) {
26
  setData(json.data);
@@ -34,23 +35,29 @@ export function VisitorChart() {
34
 
35
  const formatXAxis = (timestamp: number) => {
36
  const date = new Date(timestamp);
37
- return date.getHours().toString().padStart(2, '0') + ':00';
 
 
 
 
 
 
38
  };
39
 
40
  const CustomTooltip = ({ active, payload }: any) => {
41
  if (active && payload && payload.length) {
42
  const data = payload[0].payload;
43
  const date = new Date(data.timestamp);
44
- const timeStr = date.toLocaleString('en-US', {
 
45
  month: 'short',
46
  day: 'numeric',
47
- hour: '2-digit',
48
- minute: '2-digit',
49
  });
50
 
51
  return (
52
  <div className="bg-black/90 border border-white/20 rounded-lg p-3 backdrop-blur-sm">
53
- <p className="text-xs text-gray-400 mb-1">{timeStr}</p>
54
  <p className="text-sm font-semibold text-purple-400">
55
  {data.count} visitor{data.count !== 1 ? 's' : ''}
56
  </p>
@@ -60,11 +67,53 @@ export function VisitorChart() {
60
  return null;
61
  };
62
 
 
 
63
  return (
64
  <Card className="p-6 bg-white/[0.02] border-white/10">
65
- <div className="flex items-center gap-2 mb-4">
66
- <Users className="w-5 h-5 text-purple-400" />
67
- <h3 className="text-lg font-semibold text-white">Visitor Activity (Last 24 Hours)</h3>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  </div>
69
 
70
  {loading ? (
 
11
  export function VisitorChart() {
12
  const [data, setData] = useState<VisitorData[]>([]);
13
  const [loading, setLoading] = useState(true);
14
+ const [days, setDays] = useState(30);
15
 
16
  useEffect(() => {
17
  fetchVisitorData();
18
  const interval = setInterval(fetchVisitorData, 5 * 60 * 1000);
19
  return () => clearInterval(interval);
20
+ }, [days]);
21
 
22
  const fetchVisitorData = async () => {
23
  try {
24
+ const res = await fetch(`/api/stats/visitors?days=${days}`);
25
  const json = await res.json();
26
  if (json.success) {
27
  setData(json.data);
 
35
 
36
  const formatXAxis = (timestamp: number) => {
37
  const date = new Date(timestamp);
38
+ if (days <= 7) {
39
+ return date.toLocaleDateString('id-ID', { month: 'short', day: 'numeric' });
40
+ } else if (days <= 30) {
41
+ return date.toLocaleDateString('id-ID', { month: 'short', day: 'numeric' });
42
+ } else {
43
+ return date.toLocaleDateString('id-ID', { month: 'short', day: 'numeric' });
44
+ }
45
  };
46
 
47
  const CustomTooltip = ({ active, payload }: any) => {
48
  if (active && payload && payload.length) {
49
  const data = payload[0].payload;
50
  const date = new Date(data.timestamp);
51
+ const dateStr = date.toLocaleDateString('id-ID', {
52
+ weekday: 'short',
53
  month: 'short',
54
  day: 'numeric',
55
+ year: 'numeric',
 
56
  });
57
 
58
  return (
59
  <div className="bg-black/90 border border-white/20 rounded-lg p-3 backdrop-blur-sm">
60
+ <p className="text-xs text-gray-400 mb-1">{dateStr}</p>
61
  <p className="text-sm font-semibold text-purple-400">
62
  {data.count} visitor{data.count !== 1 ? 's' : ''}
63
  </p>
 
67
  return null;
68
  };
69
 
70
+ const totalVisitors = data.reduce((sum, d) => sum + d.count, 0);
71
+
72
  return (
73
  <Card className="p-6 bg-white/[0.02] border-white/10">
74
+ <div className="flex items-center justify-between mb-4">
75
+ <div className="flex items-center gap-2">
76
+ <Users className="w-5 h-5 text-purple-400" />
77
+ <h3 className="text-lg font-semibold text-white">Visitor Activity</h3>
78
+ </div>
79
+
80
+ <div className="flex gap-2">
81
+ <button
82
+ onClick={() => setDays(7)}
83
+ className={`px-3 py-1 rounded text-sm transition-colors ${
84
+ days === 7
85
+ ? 'bg-purple-500 text-white'
86
+ : 'bg-white/5 text-gray-400 hover:bg-white/10'
87
+ }`}
88
+ >
89
+ 7D
90
+ </button>
91
+ <button
92
+ onClick={() => setDays(30)}
93
+ className={`px-3 py-1 rounded text-sm transition-colors ${
94
+ days === 30
95
+ ? 'bg-purple-500 text-white'
96
+ : 'bg-white/5 text-gray-400 hover:bg-white/10'
97
+ }`}
98
+ >
99
+ 30D
100
+ </button>
101
+ <button
102
+ onClick={() => setDays(90)}
103
+ className={`px-3 py-1 rounded text-sm transition-colors ${
104
+ days === 90
105
+ ? 'bg-purple-500 text-white'
106
+ : 'bg-white/5 text-gray-400 hover:bg-white/10'
107
+ }`}
108
+ >
109
+ 90D
110
+ </button>
111
+ </div>
112
+ </div>
113
+
114
+ <div className="mb-4">
115
+ <p className="text-2xl font-bold text-white">{totalVisitors}</p>
116
+ <p className="text-sm text-gray-400">Total visitors in last {days} days</p>
117
  </div>
118
 
119
  {loading ? (
src/server/index.ts CHANGED
@@ -25,7 +25,7 @@ app.use(
25
  app.use(express.urlencoded({ extended: false }));
26
 
27
  export function log(message: string, source = "express") {
28
- const formattedTime = new Date().toLocaleTimeString("en-US", {
29
  hour: "numeric",
30
  minute: "2-digit",
31
  second: "2-digit",
@@ -140,8 +140,9 @@ app.use((req, res, next) => {
140
  });
141
 
142
  (async () => {
143
- initStatsTracker();
144
- log("Stats tracker initialized");
 
145
 
146
  const pluginsDir = join(process.cwd(), "src/server/plugins");
147
  const pluginLoader = initPluginLoader(pluginsDir);
@@ -186,8 +187,9 @@ app.use((req, res, next) => {
186
  });
187
  });
188
 
189
- app.get("/api/stats/visitors", (_req, res) => {
190
- const chartData = getStatsTracker().getVisitorChartData();
 
191
 
192
  res.json({
193
  success: true,
@@ -253,13 +255,27 @@ app.use((req, res, next) => {
253
  },
254
  );
255
 
256
- process.on('uncaughtException', (error: Error) => {
 
 
 
 
 
 
 
 
 
 
 
 
257
  log(`Uncaught Exception: ${error.message}`, 'error');
258
  console.error(error.stack);
 
259
  });
260
 
261
- process.on('unhandledRejection', (reason: any, promise: Promise<any>) => {
262
  log(`Unhandled Rejection at: ${promise}, reason: ${reason}`, 'error');
263
  console.error(reason);
 
264
  });
265
- })();
 
25
  app.use(express.urlencoded({ extended: false }));
26
 
27
  export function log(message: string, source = "express") {
28
+ const formattedTime = new Date().toLocaleTimeString("id-ID", {
29
  hour: "numeric",
30
  minute: "2-digit",
31
  second: "2-digit",
 
140
  });
141
 
142
  (async () => {
143
+ const statsFilePath = join(process.cwd(), "stats-data.json");
144
+ await initStatsTracker(statsFilePath);
145
+ log("Stats tracker initialized with persistence");
146
 
147
  const pluginsDir = join(process.cwd(), "src/server/plugins");
148
  const pluginLoader = initPluginLoader(pluginsDir);
 
187
  });
188
  });
189
 
190
+ app.get("/api/stats/visitors", (req, res) => {
191
+ const days = parseInt(req.query.days as string) || 30;
192
+ const chartData = getStatsTracker().getVisitorChartData(days);
193
 
194
  res.json({
195
  success: true,
 
255
  },
256
  );
257
 
258
+ process.on('SIGTERM', async () => {
259
+ log('SIGTERM received, saving stats...', 'shutdown');
260
+ await getStatsTracker().shutdown();
261
+ process.exit(0);
262
+ });
263
+
264
+ process.on('SIGINT', async () => {
265
+ log('SIGINT received, saving stats...', 'shutdown');
266
+ await getStatsTracker().shutdown();
267
+ process.exit(0);
268
+ });
269
+
270
+ process.on('uncaughtException', async (error: Error) => {
271
  log(`Uncaught Exception: ${error.message}`, 'error');
272
  console.error(error.stack);
273
+ await getStatsTracker().shutdown();
274
  });
275
 
276
+ process.on('unhandledRejection', async (reason: any, promise: Promise<any>) => {
277
  log(`Unhandled Rejection at: ${promise}, reason: ${reason}`, 'error');
278
  console.error(reason);
279
+ await getStatsTracker().shutdown();
280
  });
281
+ })();
src/server/lib/stats-tracker.ts CHANGED
@@ -1,3 +1,6 @@
 
 
 
1
  interface EndpointStats {
2
  totalRequests: number;
3
  successRequests: number;
@@ -15,6 +18,10 @@ interface IPFailureTracking {
15
  resetTime: number;
16
  }
17
 
 
 
 
 
18
  interface GlobalStats {
19
  totalRequests: number;
20
  totalSuccess: number;
@@ -22,7 +29,17 @@ interface GlobalStats {
22
  uniqueVisitors: Set<string>;
23
  endpoints: Map<string, EndpointStats>;
24
  startTime: number;
25
- visitorsByHour: Map<number, Set<string>>;
 
 
 
 
 
 
 
 
 
 
26
  }
27
 
28
  class StatsTracker {
@@ -30,8 +47,11 @@ class StatsTracker {
30
  private ipFailures: Map<string, IPFailureTracking>;
31
  private readonly MAX_FAILS_PER_IP = 1;
32
  private readonly FAIL_WINDOW_MS = 12 * 60 * 60 * 1000;
 
 
33
 
34
- constructor() {
 
35
  this.stats = {
36
  totalRequests: 0,
37
  totalSuccess: 0,
@@ -39,7 +59,7 @@ class StatsTracker {
39
  uniqueVisitors: new Set(),
40
  endpoints: new Map(),
41
  startTime: Date.now(),
42
- visitorsByHour: new Map(),
43
  };
44
  this.ipFailures = new Map();
45
 
@@ -53,6 +73,81 @@ class StatsTracker {
53
  }, 5 * 60 * 1000);
54
  }
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  trackRequest(endpoint: string, statusCode: number, clientIp: string): boolean {
57
  const now = Date.now();
58
 
@@ -87,18 +182,11 @@ class StatsTracker {
87
  this.stats.totalRequests++;
88
  this.stats.uniqueVisitors.add(clientIp);
89
 
90
- const currentHour = Math.floor(now / (1000 * 60 * 60));
91
- if (!this.stats.visitorsByHour.has(currentHour)) {
92
- this.stats.visitorsByHour.set(currentHour, new Set());
93
  }
94
- this.stats.visitorsByHour.get(currentHour)!.add(clientIp);
95
-
96
- const cutoffHour = currentHour - 24;
97
- Array.from(this.stats.visitorsByHour.keys()).forEach(hour => {
98
- if (hour < cutoffHour) {
99
- this.stats.visitorsByHour.delete(hour);
100
- }
101
- });
102
 
103
  if (statusCode >= 200 && statusCode < 400) {
104
  this.stats.totalSuccess++;
@@ -125,6 +213,8 @@ class StatsTracker {
125
  endpointStats.failedRequests++;
126
  }
127
 
 
 
128
  return true;
129
  }
130
 
@@ -152,14 +242,17 @@ class StatsTracker {
152
  };
153
  }
154
 
155
- getVisitorChartData(): VisitorData[] {
156
- const currentHour = Math.floor(Date.now() / (1000 * 60 * 60));
157
  const data: VisitorData[] = [];
158
 
159
- for (let i = 23; i >= 0; i--) {
160
- const hour = currentHour - i;
161
- const visitors = this.stats.visitorsByHour.get(hour);
162
- const timestamp = hour * 1000 * 60 * 60;
 
 
 
163
 
164
  data.push({
165
  timestamp,
@@ -189,7 +282,7 @@ class StatsTracker {
189
  .slice(0, limit);
190
  }
191
 
192
- reset() {
193
  this.stats = {
194
  totalRequests: 0,
195
  totalSuccess: 0,
@@ -197,16 +290,25 @@ class StatsTracker {
197
  uniqueVisitors: new Set(),
198
  endpoints: new Map(),
199
  startTime: Date.now(),
200
- visitorsByHour: new Map(),
201
  };
202
  this.ipFailures.clear();
 
 
 
 
 
 
 
 
203
  }
204
  }
205
 
206
  let statsTracker: StatsTracker;
207
 
208
- export function initStatsTracker() {
209
- statsTracker = new StatsTracker();
 
210
  return statsTracker;
211
  }
212
 
 
1
+ import { promises as fs } from 'fs';
2
+ import { join } from 'path';
3
+
4
  interface EndpointStats {
5
  totalRequests: number;
6
  successRequests: number;
 
18
  resetTime: number;
19
  }
20
 
21
+ interface DailyVisitors {
22
+ [date: string]: Set<string>;
23
+ }
24
+
25
  interface GlobalStats {
26
  totalRequests: number;
27
  totalSuccess: number;
 
29
  uniqueVisitors: Set<string>;
30
  endpoints: Map<string, EndpointStats>;
31
  startTime: number;
32
+ visitorsByDay: Map<string, Set<string>>;
33
+ }
34
+
35
+ interface SerializedStats {
36
+ totalRequests: number;
37
+ totalSuccess: number;
38
+ totalFailed: number;
39
+ uniqueVisitors: string[];
40
+ endpoints: Record<string, EndpointStats>;
41
+ startTime: number;
42
+ visitorsByDay: Record<string, string[]>;
43
  }
44
 
45
  class StatsTracker {
 
47
  private ipFailures: Map<string, IPFailureTracking>;
48
  private readonly MAX_FAILS_PER_IP = 1;
49
  private readonly FAIL_WINDOW_MS = 12 * 60 * 60 * 1000;
50
+ private readonly STATS_FILE_PATH: string;
51
+ private saveTimeout: NodeJS.Timeout | null = null;
52
 
53
+ constructor(statsFilePath?: string) {
54
+ this.STATS_FILE_PATH = statsFilePath || join(process.cwd(), 'stats-data.json');
55
  this.stats = {
56
  totalRequests: 0,
57
  totalSuccess: 0,
 
59
  uniqueVisitors: new Set(),
60
  endpoints: new Map(),
61
  startTime: Date.now(),
62
+ visitorsByDay: new Map(),
63
  };
64
  this.ipFailures = new Map();
65
 
 
73
  }, 5 * 60 * 1000);
74
  }
75
 
76
+ async loadStats(): Promise<void> {
77
+ try {
78
+ const data = await fs.readFile(this.STATS_FILE_PATH, 'utf-8');
79
+ const parsed: SerializedStats = JSON.parse(data);
80
+
81
+ this.stats.totalRequests = parsed.totalRequests || 0;
82
+ this.stats.totalSuccess = parsed.totalSuccess || 0;
83
+ this.stats.totalFailed = parsed.totalFailed || 0;
84
+ this.stats.uniqueVisitors = new Set(parsed.uniqueVisitors || []);
85
+ this.stats.startTime = parsed.startTime || Date.now();
86
+ this.stats.endpoints = new Map();
87
+
88
+ if (parsed.endpoints) {
89
+ Object.entries(parsed.endpoints).forEach(([endpoint, stats]) => {
90
+ this.stats.endpoints.set(endpoint, stats);
91
+ });
92
+ }
93
+
94
+ this.stats.visitorsByDay = new Map();
95
+ if (parsed.visitorsByDay) {
96
+ Object.entries(parsed.visitorsByDay).forEach(([date, ips]) => {
97
+ this.stats.visitorsByDay.set(date, new Set(ips));
98
+ });
99
+ }
100
+
101
+ console.log('Stats loaded successfully from', this.STATS_FILE_PATH);
102
+ } catch (error: any) {
103
+ if (error.code === 'ENOENT') {
104
+ console.log('No existing stats file found, starting fresh');
105
+ } else {
106
+ console.error('Error loading stats:', error);
107
+ }
108
+ }
109
+ }
110
+
111
+ private async saveStats(): Promise<void> {
112
+ try {
113
+ const serialized: SerializedStats = {
114
+ totalRequests: this.stats.totalRequests,
115
+ totalSuccess: this.stats.totalSuccess,
116
+ totalFailed: this.stats.totalFailed,
117
+ uniqueVisitors: Array.from(this.stats.uniqueVisitors),
118
+ startTime: this.stats.startTime,
119
+ endpoints: {},
120
+ visitorsByDay: {},
121
+ };
122
+
123
+ this.stats.endpoints.forEach((stats, endpoint) => {
124
+ serialized.endpoints[endpoint] = stats;
125
+ });
126
+
127
+ this.stats.visitorsByDay.forEach((ips, date) => {
128
+ serialized.visitorsByDay[date] = Array.from(ips);
129
+ });
130
+
131
+ await fs.writeFile(
132
+ this.STATS_FILE_PATH,
133
+ JSON.stringify(serialized, null, 2),
134
+ 'utf-8'
135
+ );
136
+ } catch (error) {
137
+ console.error('Error saving stats:', error);
138
+ }
139
+ }
140
+
141
+ private scheduleSave(): void {
142
+ if (this.saveTimeout) {
143
+ clearTimeout(this.saveTimeout);
144
+ }
145
+
146
+ this.saveTimeout = setTimeout(() => {
147
+ this.saveStats();
148
+ }, 5000);
149
+ }
150
+
151
  trackRequest(endpoint: string, statusCode: number, clientIp: string): boolean {
152
  const now = Date.now();
153
 
 
182
  this.stats.totalRequests++;
183
  this.stats.uniqueVisitors.add(clientIp);
184
 
185
+ const dateKey = new Date(now).toISOString().split('T')[0];
186
+ if (!this.stats.visitorsByDay.has(dateKey)) {
187
+ this.stats.visitorsByDay.set(dateKey, new Set());
188
  }
189
+ this.stats.visitorsByDay.get(dateKey)!.add(clientIp);
 
 
 
 
 
 
 
190
 
191
  if (statusCode >= 200 && statusCode < 400) {
192
  this.stats.totalSuccess++;
 
213
  endpointStats.failedRequests++;
214
  }
215
 
216
+ this.scheduleSave();
217
+
218
  return true;
219
  }
220
 
 
242
  };
243
  }
244
 
245
+ getVisitorChartData(days: number = 30): VisitorData[] {
246
+ const now = new Date();
247
  const data: VisitorData[] = [];
248
 
249
+ for (let i = days - 1; i >= 0; i--) {
250
+ const date = new Date(now);
251
+ date.setDate(date.getDate() - i);
252
+ const dateKey = date.toISOString().split('T')[0];
253
+
254
+ const visitors = this.stats.visitorsByDay.get(dateKey);
255
+ const timestamp = date.getTime();
256
 
257
  data.push({
258
  timestamp,
 
282
  .slice(0, limit);
283
  }
284
 
285
+ async reset() {
286
  this.stats = {
287
  totalRequests: 0,
288
  totalSuccess: 0,
 
290
  uniqueVisitors: new Set(),
291
  endpoints: new Map(),
292
  startTime: Date.now(),
293
+ visitorsByDay: new Map(),
294
  };
295
  this.ipFailures.clear();
296
+ await this.saveStats();
297
+ }
298
+
299
+ async shutdown(): Promise<void> {
300
+ if (this.saveTimeout) {
301
+ clearTimeout(this.saveTimeout);
302
+ }
303
+ await this.saveStats();
304
  }
305
  }
306
 
307
  let statsTracker: StatsTracker;
308
 
309
+ export async function initStatsTracker(statsFilePath?: string) {
310
+ statsTracker = new StatsTracker(statsFilePath);
311
+ await statsTracker.loadStats();
312
  return statsTracker;
313
  }
314