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.
Files changed (92) hide show
  1. package/lib/extension-registry-service.d.ts +22 -1
  2. package/lib/extension-registry-service.d.ts.map +1 -1
  3. package/lib/extension-registry-service.js +151 -1
  4. package/lib/extension-registry-service.js.map +1 -1
  5. package/lib/extension-registry-types.d.ts +41 -0
  6. package/lib/extension-registry-types.d.ts.map +1 -1
  7. package/lib/extension-registry-types.js +16 -0
  8. package/lib/extension-registry-types.js.map +1 -1
  9. package/lib/pages/admin-dashboard/admin-dashboard.d.ts +3 -0
  10. package/lib/pages/admin-dashboard/admin-dashboard.d.ts.map +1 -1
  11. package/lib/pages/admin-dashboard/admin-dashboard.js +33 -3
  12. package/lib/pages/admin-dashboard/admin-dashboard.js.map +1 -1
  13. package/lib/pages/admin-dashboard/components/data-grid-filter-operators.d.ts +28 -0
  14. package/lib/pages/admin-dashboard/components/data-grid-filter-operators.d.ts.map +1 -0
  15. package/lib/pages/admin-dashboard/components/data-grid-filter-operators.js +93 -0
  16. package/lib/pages/admin-dashboard/components/data-grid-filter-operators.js.map +1 -0
  17. package/lib/pages/admin-dashboard/components/index.d.ts +2 -0
  18. package/lib/pages/admin-dashboard/components/index.d.ts.map +1 -0
  19. package/lib/pages/admin-dashboard/components/index.js +14 -0
  20. package/lib/pages/admin-dashboard/components/index.js.map +1 -0
  21. package/lib/pages/admin-dashboard/customers/customer-form-dialog.d.ts +23 -0
  22. package/lib/pages/admin-dashboard/customers/customer-form-dialog.d.ts.map +1 -0
  23. package/lib/pages/admin-dashboard/customers/customer-form-dialog.js +225 -0
  24. package/lib/pages/admin-dashboard/customers/customer-form-dialog.js.map +1 -0
  25. package/lib/pages/admin-dashboard/customers/customers.d.ts +15 -0
  26. package/lib/pages/admin-dashboard/customers/customers.d.ts.map +1 -0
  27. package/lib/pages/admin-dashboard/customers/customers.js +175 -0
  28. package/lib/pages/admin-dashboard/customers/customers.js.map +1 -0
  29. package/lib/pages/admin-dashboard/customers/delete-customer-dialog.d.ts +23 -0
  30. package/lib/pages/admin-dashboard/customers/delete-customer-dialog.d.ts.map +1 -0
  31. package/lib/pages/admin-dashboard/customers/delete-customer-dialog.js +64 -0
  32. package/lib/pages/admin-dashboard/customers/delete-customer-dialog.js.map +1 -0
  33. package/lib/pages/admin-dashboard/publisher-admin.js +4 -4
  34. package/lib/pages/admin-dashboard/publisher-admin.js.map +1 -1
  35. package/lib/pages/admin-dashboard/tiers/delete-tier-dialog.d.ts +23 -0
  36. package/lib/pages/admin-dashboard/tiers/delete-tier-dialog.d.ts.map +1 -0
  37. package/lib/pages/admin-dashboard/tiers/delete-tier-dialog.js +55 -0
  38. package/lib/pages/admin-dashboard/tiers/delete-tier-dialog.js.map +1 -0
  39. package/lib/pages/admin-dashboard/tiers/tier-form-dialog.d.ts +23 -0
  40. package/lib/pages/admin-dashboard/tiers/tier-form-dialog.d.ts.map +1 -0
  41. package/lib/pages/admin-dashboard/tiers/tier-form-dialog.js +215 -0
  42. package/lib/pages/admin-dashboard/tiers/tier-form-dialog.js.map +1 -0
  43. package/lib/pages/admin-dashboard/tiers/tiers.d.ts +15 -0
  44. package/lib/pages/admin-dashboard/tiers/tiers.d.ts.map +1 -0
  45. package/lib/pages/admin-dashboard/tiers/tiers.js +174 -0
  46. package/lib/pages/admin-dashboard/tiers/tiers.js.map +1 -0
  47. package/lib/pages/admin-dashboard/usage-stats/usage-stats-chart.d.ts +23 -0
  48. package/lib/pages/admin-dashboard/usage-stats/usage-stats-chart.d.ts.map +1 -0
  49. package/lib/pages/admin-dashboard/usage-stats/usage-stats-chart.js +106 -0
  50. package/lib/pages/admin-dashboard/usage-stats/usage-stats-chart.js.map +1 -0
  51. package/lib/pages/admin-dashboard/usage-stats/usage-stats-search.d.ts +26 -0
  52. package/lib/pages/admin-dashboard/usage-stats/usage-stats-search.d.ts.map +1 -0
  53. package/lib/pages/admin-dashboard/usage-stats/usage-stats-search.js +50 -0
  54. package/lib/pages/admin-dashboard/usage-stats/usage-stats-search.js.map +1 -0
  55. package/lib/pages/admin-dashboard/usage-stats/usage-stats-utils.d.ts +14 -0
  56. package/lib/pages/admin-dashboard/usage-stats/usage-stats-utils.d.ts.map +1 -0
  57. package/lib/pages/admin-dashboard/usage-stats/usage-stats-utils.js +16 -0
  58. package/lib/pages/admin-dashboard/usage-stats/usage-stats-utils.js.map +1 -0
  59. package/lib/pages/admin-dashboard/usage-stats/usage-stats.d.ts +15 -0
  60. package/lib/pages/admin-dashboard/usage-stats/usage-stats.d.ts.map +1 -0
  61. package/lib/pages/admin-dashboard/usage-stats/usage-stats.js +103 -0
  62. package/lib/pages/admin-dashboard/usage-stats/usage-stats.js.map +1 -0
  63. package/lib/pages/admin-dashboard/welcome.d.ts.map +1 -1
  64. package/lib/pages/admin-dashboard/welcome.js +4 -1
  65. package/lib/pages/admin-dashboard/welcome.js.map +1 -1
  66. package/lib/pages/extension-detail/extension-detail-reviews.d.ts.map +1 -1
  67. package/lib/pages/extension-detail/extension-detail-reviews.js +30 -18
  68. package/lib/pages/extension-detail/extension-detail-reviews.js.map +1 -1
  69. package/lib/server-request.d.ts +1 -1
  70. package/lib/server-request.d.ts.map +1 -1
  71. package/lib/server-request.js +3 -3
  72. package/lib/server-request.js.map +1 -1
  73. package/package.json +8 -1
  74. package/src/extension-registry-service.ts +157 -1
  75. package/src/extension-registry-types.ts +50 -0
  76. package/src/pages/admin-dashboard/admin-dashboard.tsx +45 -2
  77. package/src/pages/admin-dashboard/components/data-grid-filter-operators.tsx +126 -0
  78. package/src/pages/admin-dashboard/components/index.ts +18 -0
  79. package/src/pages/admin-dashboard/customers/customer-form-dialog.tsx +348 -0
  80. package/src/pages/admin-dashboard/customers/customers.tsx +279 -0
  81. package/src/pages/admin-dashboard/customers/delete-customer-dialog.tsx +94 -0
  82. package/src/pages/admin-dashboard/publisher-admin.tsx +4 -4
  83. package/src/pages/admin-dashboard/tiers/delete-tier-dialog.tsx +83 -0
  84. package/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx +372 -0
  85. package/src/pages/admin-dashboard/tiers/tiers.tsx +254 -0
  86. package/src/pages/admin-dashboard/usage-stats/usage-stats-chart.tsx +189 -0
  87. package/src/pages/admin-dashboard/usage-stats/usage-stats-search.tsx +83 -0
  88. package/src/pages/admin-dashboard/usage-stats/usage-stats-utils.ts +16 -0
  89. package/src/pages/admin-dashboard/usage-stats/usage-stats.tsx +127 -0
  90. package/src/pages/admin-dashboard/welcome.tsx +3 -0
  91. package/src/pages/extension-detail/extension-detail-reviews.tsx +49 -38
  92. 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
+ };