openvsx-webui-test 0.18.0-dev.0 → 0.18.0-dev.2
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/lib/extension-registry-service.d.ts +22 -1
- package/lib/extension-registry-service.d.ts.map +1 -1
- package/lib/extension-registry-service.js +151 -1
- package/lib/extension-registry-service.js.map +1 -1
- package/lib/extension-registry-types.d.ts +41 -0
- package/lib/extension-registry-types.d.ts.map +1 -1
- package/lib/extension-registry-types.js +16 -0
- package/lib/extension-registry-types.js.map +1 -1
- package/lib/pages/admin-dashboard/admin-dashboard.d.ts +3 -0
- package/lib/pages/admin-dashboard/admin-dashboard.d.ts.map +1 -1
- package/lib/pages/admin-dashboard/admin-dashboard.js +33 -3
- package/lib/pages/admin-dashboard/admin-dashboard.js.map +1 -1
- package/lib/pages/admin-dashboard/components/data-grid-filter-operators.d.ts +28 -0
- package/lib/pages/admin-dashboard/components/data-grid-filter-operators.d.ts.map +1 -0
- package/lib/pages/admin-dashboard/components/data-grid-filter-operators.js +93 -0
- package/lib/pages/admin-dashboard/components/data-grid-filter-operators.js.map +1 -0
- package/lib/pages/admin-dashboard/components/index.d.ts +2 -0
- package/lib/pages/admin-dashboard/components/index.d.ts.map +1 -0
- package/lib/pages/admin-dashboard/components/index.js +14 -0
- package/lib/pages/admin-dashboard/components/index.js.map +1 -0
- package/lib/pages/admin-dashboard/customers/customer-form-dialog.d.ts +23 -0
- package/lib/pages/admin-dashboard/customers/customer-form-dialog.d.ts.map +1 -0
- package/lib/pages/admin-dashboard/customers/customer-form-dialog.js +225 -0
- package/lib/pages/admin-dashboard/customers/customer-form-dialog.js.map +1 -0
- package/lib/pages/admin-dashboard/customers/customers.d.ts +15 -0
- package/lib/pages/admin-dashboard/customers/customers.d.ts.map +1 -0
- package/lib/pages/admin-dashboard/customers/customers.js +175 -0
- package/lib/pages/admin-dashboard/customers/customers.js.map +1 -0
- package/lib/pages/admin-dashboard/customers/delete-customer-dialog.d.ts +23 -0
- package/lib/pages/admin-dashboard/customers/delete-customer-dialog.d.ts.map +1 -0
- package/lib/pages/admin-dashboard/customers/delete-customer-dialog.js +64 -0
- package/lib/pages/admin-dashboard/customers/delete-customer-dialog.js.map +1 -0
- package/lib/pages/admin-dashboard/publisher-admin.js +4 -4
- package/lib/pages/admin-dashboard/publisher-admin.js.map +1 -1
- package/lib/pages/admin-dashboard/tiers/delete-tier-dialog.d.ts +23 -0
- package/lib/pages/admin-dashboard/tiers/delete-tier-dialog.d.ts.map +1 -0
- package/lib/pages/admin-dashboard/tiers/delete-tier-dialog.js +55 -0
- package/lib/pages/admin-dashboard/tiers/delete-tier-dialog.js.map +1 -0
- package/lib/pages/admin-dashboard/tiers/tier-form-dialog.d.ts +23 -0
- package/lib/pages/admin-dashboard/tiers/tier-form-dialog.d.ts.map +1 -0
- package/lib/pages/admin-dashboard/tiers/tier-form-dialog.js +215 -0
- package/lib/pages/admin-dashboard/tiers/tier-form-dialog.js.map +1 -0
- package/lib/pages/admin-dashboard/tiers/tiers.d.ts +15 -0
- package/lib/pages/admin-dashboard/tiers/tiers.d.ts.map +1 -0
- package/lib/pages/admin-dashboard/tiers/tiers.js +174 -0
- package/lib/pages/admin-dashboard/tiers/tiers.js.map +1 -0
- package/lib/pages/admin-dashboard/usage-stats/usage-stats-chart.d.ts +23 -0
- package/lib/pages/admin-dashboard/usage-stats/usage-stats-chart.d.ts.map +1 -0
- package/lib/pages/admin-dashboard/usage-stats/usage-stats-chart.js +106 -0
- package/lib/pages/admin-dashboard/usage-stats/usage-stats-chart.js.map +1 -0
- package/lib/pages/admin-dashboard/usage-stats/usage-stats-search.d.ts +26 -0
- package/lib/pages/admin-dashboard/usage-stats/usage-stats-search.d.ts.map +1 -0
- package/lib/pages/admin-dashboard/usage-stats/usage-stats-search.js +50 -0
- package/lib/pages/admin-dashboard/usage-stats/usage-stats-search.js.map +1 -0
- package/lib/pages/admin-dashboard/usage-stats/usage-stats-utils.d.ts +14 -0
- package/lib/pages/admin-dashboard/usage-stats/usage-stats-utils.d.ts.map +1 -0
- package/lib/pages/admin-dashboard/usage-stats/usage-stats-utils.js +16 -0
- package/lib/pages/admin-dashboard/usage-stats/usage-stats-utils.js.map +1 -0
- package/lib/pages/admin-dashboard/usage-stats/usage-stats.d.ts +15 -0
- package/lib/pages/admin-dashboard/usage-stats/usage-stats.d.ts.map +1 -0
- package/lib/pages/admin-dashboard/usage-stats/usage-stats.js +103 -0
- package/lib/pages/admin-dashboard/usage-stats/usage-stats.js.map +1 -0
- package/lib/pages/admin-dashboard/welcome.d.ts.map +1 -1
- package/lib/pages/admin-dashboard/welcome.js +4 -1
- package/lib/pages/admin-dashboard/welcome.js.map +1 -1
- package/lib/pages/extension-detail/extension-detail-reviews.d.ts.map +1 -1
- package/lib/pages/extension-detail/extension-detail-reviews.js +30 -18
- package/lib/pages/extension-detail/extension-detail-reviews.js.map +1 -1
- package/lib/server-request.d.ts +1 -1
- package/lib/server-request.d.ts.map +1 -1
- package/lib/server-request.js +3 -3
- package/lib/server-request.js.map +1 -1
- package/package.json +8 -1
- package/src/extension-registry-service.ts +157 -1
- package/src/extension-registry-types.ts +50 -0
- package/src/pages/admin-dashboard/admin-dashboard.tsx +45 -2
- package/src/pages/admin-dashboard/components/data-grid-filter-operators.tsx +126 -0
- package/src/pages/admin-dashboard/components/index.ts +18 -0
- package/src/pages/admin-dashboard/customers/customer-form-dialog.tsx +348 -0
- package/src/pages/admin-dashboard/customers/customers.tsx +279 -0
- package/src/pages/admin-dashboard/customers/delete-customer-dialog.tsx +94 -0
- package/src/pages/admin-dashboard/publisher-admin.tsx +4 -4
- package/src/pages/admin-dashboard/tiers/delete-tier-dialog.tsx +83 -0
- package/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx +372 -0
- package/src/pages/admin-dashboard/tiers/tiers.tsx +254 -0
- package/src/pages/admin-dashboard/usage-stats/usage-stats-chart.tsx +189 -0
- package/src/pages/admin-dashboard/usage-stats/usage-stats-search.tsx +83 -0
- package/src/pages/admin-dashboard/usage-stats/usage-stats-utils.ts +16 -0
- package/src/pages/admin-dashboard/usage-stats/usage-stats.tsx +127 -0
- package/src/pages/admin-dashboard/welcome.tsx +3 -0
- package/src/pages/extension-detail/extension-detail-reviews.tsx +49 -38
- package/src/server-request.ts +3 -3
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
|
|
2
|
+
/******************************************************************************
|
|
3
|
+
* Copyright (c) 2026 Contributors to the Eclipse Foundation.
|
|
4
|
+
*
|
|
5
|
+
* See the NOTICE file(s) distributed with this work for additional
|
|
6
|
+
* information regarding copyright ownership.
|
|
7
|
+
*
|
|
8
|
+
* This program and the accompanying materials are made available under the
|
|
9
|
+
* terms of the Eclipse Public License 2.0 which is available at
|
|
10
|
+
* https://www.eclipse.org/legal/epl-2.0.
|
|
11
|
+
*
|
|
12
|
+
* SPDX-License-Identifier: EPL-2.0
|
|
13
|
+
*****************************************************************************/
|
|
14
|
+
|
|
15
|
+
import React, { FC, useState, useEffect, useRef } from 'react';
|
|
16
|
+
import {
|
|
17
|
+
Dialog,
|
|
18
|
+
DialogTitle,
|
|
19
|
+
DialogContent,
|
|
20
|
+
DialogActions,
|
|
21
|
+
TextField,
|
|
22
|
+
Button,
|
|
23
|
+
FormControl,
|
|
24
|
+
InputLabel,
|
|
25
|
+
Select,
|
|
26
|
+
MenuItem,
|
|
27
|
+
CircularProgress,
|
|
28
|
+
Alert,
|
|
29
|
+
Box,
|
|
30
|
+
Autocomplete,
|
|
31
|
+
FormHelperText,
|
|
32
|
+
styled
|
|
33
|
+
} from '@mui/material';
|
|
34
|
+
import type { SelectChangeEvent } from '@mui/material';
|
|
35
|
+
import { type Customer, EnforcementState, type Tier } from "../../../extension-registry-types";
|
|
36
|
+
import { MainContext } from "../../../context";
|
|
37
|
+
import { handleError } from "../../../utils";
|
|
38
|
+
|
|
39
|
+
interface CustomerFormDialogProps {
|
|
40
|
+
open: boolean;
|
|
41
|
+
customer?: Customer;
|
|
42
|
+
onClose: () => void;
|
|
43
|
+
onSubmit: (formData: Customer) => Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
const Code = styled('code')(({ theme }) => ({
|
|
48
|
+
fontFamily: 'source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace',
|
|
49
|
+
backgroundColor: theme.palette.action.hover, // Subtle gray background
|
|
50
|
+
padding: '2px 6px',
|
|
51
|
+
borderRadius: '4px',
|
|
52
|
+
fontSize: '0.9em',
|
|
53
|
+
color: theme.palette.text.primary,
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
export const CustomerFormDialog: FC<CustomerFormDialogProps> = ({ open, customer, onClose, onSubmit }) => {
|
|
57
|
+
const abortController = useRef<AbortController>(new AbortController());
|
|
58
|
+
const { service } = React.useContext(MainContext);
|
|
59
|
+
const [formData, setFormData] = useState<Customer>({
|
|
60
|
+
name: '',
|
|
61
|
+
tier: undefined,
|
|
62
|
+
state: EnforcementState.ENFORCEMENT,
|
|
63
|
+
cidrBlocks: []
|
|
64
|
+
});
|
|
65
|
+
const [tiers, setTiers] = useState<Tier[]>([]);
|
|
66
|
+
const [loading, setLoading] = useState(false);
|
|
67
|
+
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
68
|
+
const [touched, setTouched] = useState<Record<string, boolean>>({});
|
|
69
|
+
|
|
70
|
+
const loadTiers = async () => {
|
|
71
|
+
try {
|
|
72
|
+
const data = await service.admin.getTiers(abortController.current);
|
|
73
|
+
setTiers(data.tiers);
|
|
74
|
+
} catch (err: any) {
|
|
75
|
+
console.error('Failed to load tiers:', err);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
loadTiers();
|
|
81
|
+
return () => abortController.current.abort();
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (customer) {
|
|
86
|
+
setFormData({
|
|
87
|
+
name: customer.name,
|
|
88
|
+
tier: customer.tier,
|
|
89
|
+
state: customer.state,
|
|
90
|
+
cidrBlocks: customer.cidrBlocks
|
|
91
|
+
});
|
|
92
|
+
} else {
|
|
93
|
+
setFormData({
|
|
94
|
+
name: '',
|
|
95
|
+
tier: tiers.length > 0 ? tiers[0] : undefined,
|
|
96
|
+
state: EnforcementState.ENFORCEMENT,
|
|
97
|
+
cidrBlocks: []
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
setErrors({});
|
|
101
|
+
setTouched({});
|
|
102
|
+
}, [open, customer, tiers]);
|
|
103
|
+
|
|
104
|
+
const clearFieldError = (fieldName: string) => {
|
|
105
|
+
if (errors[fieldName]) {
|
|
106
|
+
setErrors(prev => {
|
|
107
|
+
const newErrors = { ...prev };
|
|
108
|
+
delete newErrors[fieldName];
|
|
109
|
+
return newErrors;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | SelectChangeEvent) => {
|
|
115
|
+
const { name, value } = e.target;
|
|
116
|
+
clearFieldError(name);
|
|
117
|
+
|
|
118
|
+
if (name === 'tierName') {
|
|
119
|
+
const tier = tiers.find((tier) => tier.name === value);
|
|
120
|
+
setFormData(prev => ({
|
|
121
|
+
...prev,
|
|
122
|
+
tier: tier,
|
|
123
|
+
}));
|
|
124
|
+
} else {
|
|
125
|
+
setFormData(prev => ({ ...prev, [name]: value }));
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const handleBlur = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
|
130
|
+
const { name } = e.target;
|
|
131
|
+
setTouched(prev => ({ ...prev, [name]: true }));
|
|
132
|
+
|
|
133
|
+
// Validate the specific field on blur
|
|
134
|
+
validateField(name);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const fieldValidators: Record<string, () => string | undefined> = {
|
|
138
|
+
name: () => {
|
|
139
|
+
if (formData.name === undefined) {
|
|
140
|
+
return "Customer name is required";
|
|
141
|
+
} else if (formData.name.trim() !== formData.name) {
|
|
142
|
+
return "Customer name must not contain trailing whitespace";
|
|
143
|
+
} else {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
tierName: () => formData.tier?.name ? undefined : 'Tier selection is required',
|
|
148
|
+
state: () => formData.state ? undefined : 'State is required',
|
|
149
|
+
cidrBlocks: () => {
|
|
150
|
+
if (formData.cidrBlocks && formData.cidrBlocks.length > 0) {
|
|
151
|
+
const invalidEntries = formData.cidrBlocks.filter(cidr => !isValidCIDR(cidr.trim()));
|
|
152
|
+
if (invalidEntries.length > 0) {
|
|
153
|
+
return `Invalid CIDR block(s): ${invalidEntries.join(', ')}`;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return undefined;
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const validateField = (fieldName: string): string | undefined => {
|
|
161
|
+
const validator = fieldValidators[fieldName];
|
|
162
|
+
const error = validator?.();
|
|
163
|
+
|
|
164
|
+
if (error) {
|
|
165
|
+
setErrors(prev => ({ ...prev, [fieldName]: error }));
|
|
166
|
+
}
|
|
167
|
+
return error;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const isValidCIDR = (cidr: string): boolean => {
|
|
171
|
+
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/;
|
|
172
|
+
const ipv6Regex = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}(\/\d{1,3})?$/;
|
|
173
|
+
return ipv4Regex.test(cidr) || ipv6Regex.test(cidr);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const handleCidrBlocksChange = (event: any, value: string[]) => {
|
|
177
|
+
clearFieldError('cidrBlocks');
|
|
178
|
+
|
|
179
|
+
// Validate all entries
|
|
180
|
+
const invalidEntries = value.filter(cidr => !isValidCIDR(cidr.trim()));
|
|
181
|
+
|
|
182
|
+
if (invalidEntries.length > 0) {
|
|
183
|
+
setTouched(prev => ({ ...prev, cidrBlocks: true }));
|
|
184
|
+
setErrors(prev => ({
|
|
185
|
+
...prev,
|
|
186
|
+
cidrBlocks: `Invalid CIDR block(s): ${invalidEntries.join(', ')}. Please enter valid IPv4 or IPv6 CIDR notation.`
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Always update the value so the user can see and correct invalid entries
|
|
191
|
+
setFormData(prev => ({
|
|
192
|
+
...prev,
|
|
193
|
+
cidrBlocks: value.map(cidr => cidr.trim()),
|
|
194
|
+
}));
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const validateForm = (): boolean => {
|
|
198
|
+
// Mark all fields as touched on submit
|
|
199
|
+
setTouched({
|
|
200
|
+
name: true,
|
|
201
|
+
tierName: true,
|
|
202
|
+
state: true,
|
|
203
|
+
cidrBlocks: true,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const newErrors: Record<string, string> = {};
|
|
207
|
+
for (const key of Object.keys(formData)) {
|
|
208
|
+
const error = validateField(key);
|
|
209
|
+
if (error !== undefined) {
|
|
210
|
+
newErrors[key] = error;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
setErrors(newErrors);
|
|
215
|
+
return Object.keys(newErrors).length === 0;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const handleSubmit = async () => {
|
|
219
|
+
if (!validateForm()) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
setLoading(true);
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
await onSubmit(formData);
|
|
227
|
+
onClose();
|
|
228
|
+
} catch (err: any) {
|
|
229
|
+
setErrors({ submit: handleError(err) });
|
|
230
|
+
} finally {
|
|
231
|
+
setLoading(false);
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const isEditMode = !!customer;
|
|
236
|
+
const title = isEditMode ? 'Edit Customer' : 'Create New Customer';
|
|
237
|
+
|
|
238
|
+
return (
|
|
239
|
+
<Dialog open={open} onClose={onClose} maxWidth='sm' fullWidth>
|
|
240
|
+
<DialogTitle>{title}</DialogTitle>
|
|
241
|
+
<DialogContent>
|
|
242
|
+
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
|
|
243
|
+
{errors.submit && (
|
|
244
|
+
<Alert severity='error'>{errors.submit}</Alert>
|
|
245
|
+
)}
|
|
246
|
+
|
|
247
|
+
<TextField
|
|
248
|
+
label='Customer Name'
|
|
249
|
+
name='name'
|
|
250
|
+
value={formData.name}
|
|
251
|
+
onChange={handleChange}
|
|
252
|
+
onBlur={handleBlur}
|
|
253
|
+
fullWidth
|
|
254
|
+
placeholder='e.g., Acme Corp, TechStart Inc'
|
|
255
|
+
required={true}
|
|
256
|
+
disabled={loading}
|
|
257
|
+
error={touched.name && !!errors.name}
|
|
258
|
+
helperText={touched.name && errors.name}
|
|
259
|
+
/>
|
|
260
|
+
|
|
261
|
+
<FormControl fullWidth disabled={loading} required={true} error={touched.tierName && !!errors.tierName}>
|
|
262
|
+
<InputLabel>Tier</InputLabel>
|
|
263
|
+
<Select
|
|
264
|
+
name='tierName'
|
|
265
|
+
value={formData.tier?.name ?? ''}
|
|
266
|
+
onChange={handleChange}
|
|
267
|
+
onBlur={(e) => {
|
|
268
|
+
setTouched(prev => ({ ...prev, tierName: true }));
|
|
269
|
+
validateField('tierName');
|
|
270
|
+
}}
|
|
271
|
+
label='Tier'
|
|
272
|
+
>
|
|
273
|
+
{tiers
|
|
274
|
+
.filter(tier => tier.tierType === 'NON_FREE')
|
|
275
|
+
.map(tier => (
|
|
276
|
+
<MenuItem key={tier.name} value={tier.name}>
|
|
277
|
+
{tier.name}
|
|
278
|
+
</MenuItem>
|
|
279
|
+
))}
|
|
280
|
+
</Select>
|
|
281
|
+
{touched.tierName && errors.tierName && <FormHelperText>{errors.tierName}</FormHelperText>}
|
|
282
|
+
</FormControl>
|
|
283
|
+
|
|
284
|
+
<FormControl fullWidth disabled={loading} required={true} error={touched.state && !!errors.state}>
|
|
285
|
+
<InputLabel>State</InputLabel>
|
|
286
|
+
<Select
|
|
287
|
+
name='state'
|
|
288
|
+
value={formData.state}
|
|
289
|
+
onChange={handleChange}
|
|
290
|
+
onBlur={(e) => {
|
|
291
|
+
setTouched(prev => ({ ...prev, state: true }));
|
|
292
|
+
validateField('state');
|
|
293
|
+
}}
|
|
294
|
+
label='State'
|
|
295
|
+
>
|
|
296
|
+
{Object.keys(EnforcementState).map(key => (
|
|
297
|
+
<MenuItem key={key} value={EnforcementState[key as keyof typeof EnforcementState]}>
|
|
298
|
+
{key}
|
|
299
|
+
</MenuItem>
|
|
300
|
+
))}
|
|
301
|
+
</Select>
|
|
302
|
+
{touched.state && errors.state && <FormHelperText>{errors.state}</FormHelperText>}
|
|
303
|
+
</FormControl>
|
|
304
|
+
|
|
305
|
+
<Autocomplete
|
|
306
|
+
multiple
|
|
307
|
+
freeSolo
|
|
308
|
+
limitTags={5}
|
|
309
|
+
disabled={loading}
|
|
310
|
+
options={[]}
|
|
311
|
+
value={formData.cidrBlocks || []}
|
|
312
|
+
onChange={handleCidrBlocksChange}
|
|
313
|
+
onBlur={() => {
|
|
314
|
+
setTouched(prev => ({ ...prev, cidrBlocks: true }));
|
|
315
|
+
validateField('cidrBlocks');
|
|
316
|
+
}}
|
|
317
|
+
renderInput={(params) => (
|
|
318
|
+
<TextField
|
|
319
|
+
{...params}
|
|
320
|
+
label='CIDR Blocks'
|
|
321
|
+
placeholder='e.g., 192.168.1.0/24'
|
|
322
|
+
error={touched.cidrBlocks && !!errors.cidrBlocks}
|
|
323
|
+
helperText={(touched.cidrBlocks && errors.cidrBlocks) || (
|
|
324
|
+
<>Enter CIDR blocks and press <Code>Enter</Code> to add each one</>
|
|
325
|
+
)}
|
|
326
|
+
/>
|
|
327
|
+
)}
|
|
328
|
+
/>
|
|
329
|
+
|
|
330
|
+
</Box>
|
|
331
|
+
</DialogContent>
|
|
332
|
+
|
|
333
|
+
<DialogActions sx={{ p: 2 }}>
|
|
334
|
+
<Button onClick={onClose} disabled={loading}>
|
|
335
|
+
Cancel
|
|
336
|
+
</Button>
|
|
337
|
+
<Button
|
|
338
|
+
onClick={handleSubmit}
|
|
339
|
+
variant='contained'
|
|
340
|
+
disabled={loading || Object.keys(errors).length > 0}
|
|
341
|
+
startIcon={loading ? <CircularProgress size={20} /> : undefined}
|
|
342
|
+
>
|
|
343
|
+
{isEditMode ? 'Update' : 'Create'}
|
|
344
|
+
</Button>
|
|
345
|
+
</DialogActions>
|
|
346
|
+
</Dialog>
|
|
347
|
+
);
|
|
348
|
+
};
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
|
|
2
|
+
/******************************************************************************
|
|
3
|
+
* Copyright (c) 2026 Contributors to the Eclipse Foundation.
|
|
4
|
+
*
|
|
5
|
+
* See the NOTICE file(s) distributed with this work for additional
|
|
6
|
+
* information regarding copyright ownership.
|
|
7
|
+
*
|
|
8
|
+
* This program and the accompanying materials are made available under the
|
|
9
|
+
* terms of the Eclipse Public License 2.0 which is available at
|
|
10
|
+
* https://www.eclipse.org/legal/epl-2.0.
|
|
11
|
+
*
|
|
12
|
+
* SPDX-License-Identifier: EPL-2.0
|
|
13
|
+
*****************************************************************************/
|
|
14
|
+
|
|
15
|
+
import React, { FC, useState, useEffect, useRef, useCallback, useMemo } from "react";
|
|
16
|
+
import {
|
|
17
|
+
Box,
|
|
18
|
+
Button,
|
|
19
|
+
Paper,
|
|
20
|
+
Typography,
|
|
21
|
+
CircularProgress,
|
|
22
|
+
Alert,
|
|
23
|
+
IconButton,
|
|
24
|
+
Stack,
|
|
25
|
+
Chip
|
|
26
|
+
} from "@mui/material";
|
|
27
|
+
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
|
28
|
+
import EditIcon from "@mui/icons-material/Edit";
|
|
29
|
+
import DeleteIcon from "@mui/icons-material/Delete";
|
|
30
|
+
import AddIcon from "@mui/icons-material/Add";
|
|
31
|
+
import BarChartIcon from "@mui/icons-material/BarChart";
|
|
32
|
+
import { MainContext } from "../../../context";
|
|
33
|
+
import type { Customer } from "../../../extension-registry-types";
|
|
34
|
+
import { CustomerFormDialog } from "./customer-form-dialog";
|
|
35
|
+
import { DeleteCustomerDialog } from "./delete-customer-dialog";
|
|
36
|
+
import { handleError } from "../../../utils";
|
|
37
|
+
import { createMultiSelectFilterOperators, createArrayContainsFilterOperators } from "../components";
|
|
38
|
+
import { AdminDashboardRoutes } from "../admin-dashboard";
|
|
39
|
+
import { Link } from "react-router-dom";
|
|
40
|
+
|
|
41
|
+
export const Customers: FC = () => {
|
|
42
|
+
const abortController = useRef<AbortController>(new AbortController());
|
|
43
|
+
const { service } = React.useContext(MainContext);
|
|
44
|
+
const [customers, setCustomers] = useState<Customer[]>([]);
|
|
45
|
+
const [loading, setLoading] = useState(true);
|
|
46
|
+
const [error, setError] = useState<string | null>(null);
|
|
47
|
+
const [formDialogOpen, setFormDialogOpen] = useState(false);
|
|
48
|
+
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
49
|
+
const [selectedCustomer, setSelectedCustomer] = useState<Customer | undefined>();
|
|
50
|
+
|
|
51
|
+
// Load all customers
|
|
52
|
+
const loadCustomers = useCallback(async () => {
|
|
53
|
+
try {
|
|
54
|
+
setLoading(true);
|
|
55
|
+
setError(null);
|
|
56
|
+
const data = await service.admin.getCustomers(abortController.current);
|
|
57
|
+
setCustomers(data.customers);
|
|
58
|
+
} catch (err: any) {
|
|
59
|
+
setError(handleError(err));
|
|
60
|
+
} finally {
|
|
61
|
+
setLoading(false);
|
|
62
|
+
}
|
|
63
|
+
}, [service]);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
loadCustomers();
|
|
67
|
+
return () => abortController.current.abort();
|
|
68
|
+
}, []);
|
|
69
|
+
|
|
70
|
+
const handleCreateClick = () => {
|
|
71
|
+
setSelectedCustomer(undefined);
|
|
72
|
+
setFormDialogOpen(true);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const handleEditClick = (customer: Customer) => {
|
|
76
|
+
setSelectedCustomer(customer);
|
|
77
|
+
setFormDialogOpen(true);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const handleDeleteClick = (customer: Customer) => {
|
|
81
|
+
setSelectedCustomer(customer);
|
|
82
|
+
setDeleteDialogOpen(true);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const handleFormSubmit = async (customer: Customer) => {
|
|
86
|
+
if (selectedCustomer) {
|
|
87
|
+
// update existing customer
|
|
88
|
+
await service.admin.updateCustomer(abortController.current, selectedCustomer.name, customer);
|
|
89
|
+
} else {
|
|
90
|
+
// create new customer
|
|
91
|
+
await service.admin.createCustomer(abortController.current, customer);
|
|
92
|
+
}
|
|
93
|
+
await loadCustomers();
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const handleDeleteConfirm = async () => {
|
|
97
|
+
if (selectedCustomer) {
|
|
98
|
+
await service.admin.deleteCustomer(abortController.current, selectedCustomer.name);
|
|
99
|
+
await loadCustomers();
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const handleFormDialogClose = () => {
|
|
104
|
+
setFormDialogOpen(false);
|
|
105
|
+
setSelectedCustomer(undefined);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const handleDeleteDialogClose = () => {
|
|
109
|
+
setDeleteDialogOpen(false);
|
|
110
|
+
setSelectedCustomer(undefined);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Extract unique values for filter dropdowns
|
|
114
|
+
const tierOptions = useMemo(() =>
|
|
115
|
+
[...new Set(customers.map(c => c.tier?.name).filter(Boolean))] as string[],
|
|
116
|
+
[customers]
|
|
117
|
+
);
|
|
118
|
+
const stateOptions = useMemo(() =>
|
|
119
|
+
[...new Set(customers.map(c => c.state).filter(Boolean))],
|
|
120
|
+
[customers]
|
|
121
|
+
);
|
|
122
|
+
const cidrBlockOptions = useMemo(() => {
|
|
123
|
+
const allCidrs = customers.reduce<string[]>((acc, c) => acc.concat(c.cidrBlocks), []);
|
|
124
|
+
return [...new Set(allCidrs)];
|
|
125
|
+
}, [customers]);
|
|
126
|
+
|
|
127
|
+
const columns: GridColDef[] = [
|
|
128
|
+
{ field: 'name', headerName: 'Name', flex: 1, minWidth: 150 },
|
|
129
|
+
{
|
|
130
|
+
field: 'tier',
|
|
131
|
+
headerName: 'Tier',
|
|
132
|
+
flex: 1,
|
|
133
|
+
minWidth: 120,
|
|
134
|
+
valueGetter: (value: Customer['tier']) => value?.name || '',
|
|
135
|
+
filterOperators: createMultiSelectFilterOperators(tierOptions)
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
field: 'state',
|
|
139
|
+
headerName: 'State',
|
|
140
|
+
flex: 1,
|
|
141
|
+
minWidth: 100,
|
|
142
|
+
filterOperators: createMultiSelectFilterOperators(stateOptions)
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
field: 'cidrBlocks',
|
|
146
|
+
headerName: 'CIDR Blocks',
|
|
147
|
+
flex: 2,
|
|
148
|
+
minWidth: 200,
|
|
149
|
+
sortable: false,
|
|
150
|
+
filterOperators: createArrayContainsFilterOperators(cidrBlockOptions),
|
|
151
|
+
renderCell: (params: GridRenderCellParams<Customer>) => {
|
|
152
|
+
const cidrBlocks = params.row.cidrBlocks;
|
|
153
|
+
const maxVisible = 2;
|
|
154
|
+
const visibleCidrs = cidrBlocks.slice(0, maxVisible);
|
|
155
|
+
const remainingCount = cidrBlocks.length - maxVisible;
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<Stack direction='row' spacing={0.5} alignItems='center' height='100%' sx={{ py: 0.5 }}>
|
|
159
|
+
{visibleCidrs.map((cidr: string) => (
|
|
160
|
+
<Chip key={cidr} label={cidr} size='small' variant='filled' />
|
|
161
|
+
))}
|
|
162
|
+
{remainingCount > 0 && (
|
|
163
|
+
<Chip
|
|
164
|
+
label={`+${remainingCount}`}
|
|
165
|
+
size='small'
|
|
166
|
+
variant='outlined'
|
|
167
|
+
title={cidrBlocks.slice(maxVisible).join(', ')}
|
|
168
|
+
/>
|
|
169
|
+
)}
|
|
170
|
+
</Stack>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
field: 'actions',
|
|
176
|
+
headerName: 'Actions',
|
|
177
|
+
width: 160,
|
|
178
|
+
sortable: false,
|
|
179
|
+
filterable: false,
|
|
180
|
+
renderCell: (params: GridRenderCellParams<Customer>) => (
|
|
181
|
+
<>
|
|
182
|
+
<IconButton
|
|
183
|
+
size='small'
|
|
184
|
+
component={Link}
|
|
185
|
+
to={`${AdminDashboardRoutes.USAGE_STATS}/${params.row.name}`}
|
|
186
|
+
title='View Usage Stats'
|
|
187
|
+
>
|
|
188
|
+
<BarChartIcon />
|
|
189
|
+
</IconButton>
|
|
190
|
+
<IconButton
|
|
191
|
+
size='small'
|
|
192
|
+
onClick={() => handleEditClick(params.row)}
|
|
193
|
+
title='Edit'
|
|
194
|
+
>
|
|
195
|
+
<EditIcon />
|
|
196
|
+
</IconButton>
|
|
197
|
+
<IconButton
|
|
198
|
+
size='small'
|
|
199
|
+
onClick={() => handleDeleteClick(params.row)}
|
|
200
|
+
title='Delete'
|
|
201
|
+
color='error'
|
|
202
|
+
>
|
|
203
|
+
<DeleteIcon />
|
|
204
|
+
</IconButton>
|
|
205
|
+
</>
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
];
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<Box sx={{ p: 3, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
|
212
|
+
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 3 }}>
|
|
213
|
+
<Typography variant='h4' component='h1'>
|
|
214
|
+
Customer Management
|
|
215
|
+
</Typography>
|
|
216
|
+
<Button
|
|
217
|
+
variant='contained'
|
|
218
|
+
startIcon={<AddIcon />}
|
|
219
|
+
onClick={handleCreateClick}
|
|
220
|
+
disabled={loading}
|
|
221
|
+
>
|
|
222
|
+
Create Customer
|
|
223
|
+
</Button>
|
|
224
|
+
</Box>
|
|
225
|
+
|
|
226
|
+
{error && (
|
|
227
|
+
<Alert severity='error' sx={{ mb: 2 }} onClose={() => setError(null)}>
|
|
228
|
+
{error}
|
|
229
|
+
</Alert>
|
|
230
|
+
)}
|
|
231
|
+
|
|
232
|
+
{loading && (
|
|
233
|
+
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
|
|
234
|
+
<CircularProgress/>
|
|
235
|
+
</Box>
|
|
236
|
+
)}
|
|
237
|
+
|
|
238
|
+
{ !loading && customers.length === 0 && (
|
|
239
|
+
<Paper elevation={0} sx={{ p: 3, textAlign: "center" }}>
|
|
240
|
+
<Typography color='textSecondary' gutterBottom>
|
|
241
|
+
No customers found. Create one to get started.
|
|
242
|
+
</Typography>
|
|
243
|
+
</Paper>
|
|
244
|
+
)}
|
|
245
|
+
|
|
246
|
+
{ !loading && customers.length > 0 && (
|
|
247
|
+
<Paper elevation={0} sx={{ flex: 1, minHeight: 400, width: '100%', display: 'flex', flexDirection: 'column' }}>
|
|
248
|
+
<DataGrid
|
|
249
|
+
rows={customers}
|
|
250
|
+
columns={columns}
|
|
251
|
+
getRowId={(row) => row.name}
|
|
252
|
+
pageSizeOptions={[20, 35, 50]}
|
|
253
|
+
initialState={{
|
|
254
|
+
pagination: { paginationModel: { pageSize: 20 } },
|
|
255
|
+
}}
|
|
256
|
+
disableRowSelectionOnClick
|
|
257
|
+
sx={{
|
|
258
|
+
flex: 1,
|
|
259
|
+
}}
|
|
260
|
+
/>
|
|
261
|
+
</Paper>
|
|
262
|
+
)}
|
|
263
|
+
|
|
264
|
+
<CustomerFormDialog
|
|
265
|
+
open={formDialogOpen}
|
|
266
|
+
customer={selectedCustomer}
|
|
267
|
+
onClose={handleFormDialogClose}
|
|
268
|
+
onSubmit={handleFormSubmit}
|
|
269
|
+
/>
|
|
270
|
+
|
|
271
|
+
<DeleteCustomerDialog
|
|
272
|
+
open={deleteDialogOpen}
|
|
273
|
+
customer={selectedCustomer}
|
|
274
|
+
onClose={handleDeleteDialogClose}
|
|
275
|
+
onConfirm={handleDeleteConfirm}
|
|
276
|
+
/>
|
|
277
|
+
</Box>
|
|
278
|
+
);
|
|
279
|
+
};
|