diff --git a/AdminUi/apps/admin_ui/lib/home/tier_detail/modals/add_quota.dart b/AdminUi/apps/admin_ui/lib/core/modals/add_quota_dialog.dart similarity index 70% rename from AdminUi/apps/admin_ui/lib/home/tier_detail/modals/add_quota.dart rename to AdminUi/apps/admin_ui/lib/core/modals/add_quota_dialog.dart index 1ec4810879..c8e021a0ac 100644 --- a/AdminUi/apps/admin_ui/lib/home/tier_detail/modals/add_quota.dart +++ b/AdminUi/apps/admin_ui/lib/core/modals/add_quota_dialog.dart @@ -1,28 +1,51 @@ -import 'dart:async'; - import 'package:admin_api_sdk/admin_api_sdk.dart'; import 'package:admin_api_types/admin_api_types.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get_it/get_it.dart'; -import '/core/core.dart'; +import '../constants.dart'; +import '../extensions.dart'; + +Future showAddQuotaDialog({ + required BuildContext context, + required VoidCallback onQuotaAdded, + String? tierId, + String? identityAddress, +}) async { + assert(tierId != null || identityAddress != null, 'Either tierId or address must be provided'); + assert(tierId == null || identityAddress == null, 'Only one of tierId or address can be provided'); + + final metrics = await GetIt.I.get().quotas.getMetrics(); + + if (!context.mounted) return; -Future showAddQuotaDialog({required BuildContext context, required String tierId, required VoidCallback onQuotaAdded}) async { await showDialog( context: context, builder: (BuildContext context) => _AddQuotaDialog( - tierId: tierId, + availableMetrics: metrics.data, + addQuota: ({required String metricKey, required int max, required String period}) { + if (tierId != null) { + return GetIt.I.get().quotas.createTierQuota(tierId: tierId, metricKey: metricKey, max: max, period: period); + } + + return GetIt.I.get().quotas.createIdentityQuota(address: identityAddress!, metricKey: metricKey, max: max, period: period); + }, onQuotaAdded: onQuotaAdded, ), ); } class _AddQuotaDialog extends StatefulWidget { - final String tierId; + final List availableMetrics; + final Future> Function({required String metricKey, required int max, required String period}) addQuota; final VoidCallback onQuotaAdded; - const _AddQuotaDialog({required this.tierId, required this.onQuotaAdded}); + const _AddQuotaDialog({ + required this.availableMetrics, + required this.addQuota, + required this.onQuotaAdded, + }); @override State<_AddQuotaDialog> createState() => _AddQuotaDialogState(); @@ -30,7 +53,6 @@ class _AddQuotaDialog extends StatefulWidget { class _AddQuotaDialogState extends State<_AddQuotaDialog> { final _maxAmountController = TextEditingController(); - List _availableMetrics = []; bool _saving = false; String? _errorMessage; @@ -38,6 +60,7 @@ class _AddQuotaDialogState extends State<_AddQuotaDialog> { String? _selectedMetric; int? _maxAmount; String? _selectedPeriod; + bool get _isValid => _selectedMetric != null && _maxAmount != null && _selectedPeriod != null; @override @@ -45,8 +68,6 @@ class _AddQuotaDialogState extends State<_AddQuotaDialog> { super.initState(); _maxAmountController.addListener(() => setState(() => _maxAmount = int.tryParse(_maxAmountController.text))); - - _loadMetrics(); } @override @@ -68,7 +89,8 @@ class _AddQuotaDialogState extends State<_AddQuotaDialog> { mainAxisSize: MainAxisSize.min, children: [ DropdownButtonFormField( - items: _availableMetrics.map((metric) => DropdownMenuItem(value: metric.key, child: Text(metric.displayName))).toList(), + value: _selectedMetric, + items: widget.availableMetrics.map((metric) => DropdownMenuItem(value: metric.key, child: Text(metric.displayName))).toList(), onChanged: _saving ? null : (String? selected) => setState(() => _selectedMetric = selected), decoration: const InputDecoration( border: OutlineInputBorder(), @@ -89,6 +111,7 @@ class _AddQuotaDialogState extends State<_AddQuotaDialog> { ), Gaps.h24, DropdownButtonFormField( + value: _selectedPeriod, items: const [ DropdownMenuItem(value: 'Hour', child: Text('Hour')), DropdownMenuItem(value: 'Day', child: Text('Day')), @@ -130,12 +153,13 @@ class _AddQuotaDialogState extends State<_AddQuotaDialog> { Future _addQuota() async { setState(() => _saving = true); - final response = await GetIt.I.get().quotas.createTierQuota( - tierId: widget.tierId, - metricKey: _selectedMetric!, - max: _maxAmount!, - period: _selectedPeriod!, - ); + assert(_selectedMetric != null && _maxAmount != null && _selectedPeriod != null, 'Invalid state'); + + final response = await widget.addQuota( + metricKey: _selectedMetric!, + max: _maxAmount!, + period: _selectedPeriod!, + ); if (response.hasError) { setState(() { @@ -149,9 +173,4 @@ class _AddQuotaDialogState extends State<_AddQuotaDialog> { if (mounted) Navigator.of(context, rootNavigator: true).pop(); widget.onQuotaAdded(); } - - Future _loadMetrics() async { - final metrics = await GetIt.I.get().quotas.getMetrics(); - setState(() => _availableMetrics = metrics.data); - } } diff --git a/AdminUi/apps/admin_ui/lib/core/modals/modals.dart b/AdminUi/apps/admin_ui/lib/core/modals/modals.dart index a427c5d1d1..605a274b70 100644 --- a/AdminUi/apps/admin_ui/lib/core/modals/modals.dart +++ b/AdminUi/apps/admin_ui/lib/core/modals/modals.dart @@ -1,2 +1,3 @@ +export 'add_quota_dialog.dart'; export 'confirmation_dialog.dart'; export 'settings_dialog.dart'; diff --git a/AdminUi/apps/admin_ui/lib/core/widgets/identities_data_table/identities_data_table_source.dart b/AdminUi/apps/admin_ui/lib/core/widgets/identities_data_table/identities_data_table_source.dart index 137d71476b..dc8a63f0b0 100644 --- a/AdminUi/apps/admin_ui/lib/core/widgets/identities_data_table/identities_data_table_source.dart +++ b/AdminUi/apps/admin_ui/lib/core/widgets/identities_data_table/identities_data_table_source.dart @@ -8,7 +8,6 @@ import 'package:logger/logger.dart'; class IdentityDataTableSource extends AsyncDataTableSource { Pagination? _pagination; var _sortingSettings = (sortColumnIndex: 0, sortAscending: true); - IdentityOverviewFilter? _filter; set filter(IdentityOverviewFilter? newFilter) { if (_filter != newFilter) { @@ -30,18 +29,14 @@ class IdentityDataTableSource extends AsyncDataTableSource { @override bool get isRowCountApproximate => false; - @override int get rowCount => _pagination?.totalRecords ?? 0; - @override int get selectedRowCount => 0; - @override Future getRows(int startIndex, int count) async { final pageNumber = startIndex ~/ count; final orderBy = _getODataOrderBy(); - try { final response = await GetIt.I.get().identities.getIdentities( pageNumber: pageNumber, @@ -83,11 +78,9 @@ class IdentityDataTableSource extends AsyncDataTableSource { ), ) .toList(); - return AsyncRowsResponse(response.pagination.totalRecords, rows); } catch (e) { GetIt.I.get().e('Failed to load data: $e'); - throw Exception('Failed to load data: $e'); } } diff --git a/AdminUi/apps/admin_ui/lib/core/widgets/quotas_button_group.dart b/AdminUi/apps/admin_ui/lib/core/widgets/quotas_button_group.dart new file mode 100644 index 0000000000..9c86397c3e --- /dev/null +++ b/AdminUi/apps/admin_ui/lib/core/widgets/quotas_button_group.dart @@ -0,0 +1,104 @@ +import 'package:admin_api_sdk/admin_api_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; + +import '../constants.dart'; +import '../modals/modals.dart'; + +class QuotasButtonGroup extends StatefulWidget { + final List selectedQuotas; + final VoidCallback onQuotasChanged; + final String? identityAddress; + final String? tierId; + + const QuotasButtonGroup({ + required this.selectedQuotas, + required this.onQuotasChanged, + this.identityAddress, + this.tierId, + super.key, + }) : assert(identityAddress != null || tierId != null, 'Either identityAddress or tierId must be provided'), + assert(identityAddress == null || tierId == null, 'Only one of identityAddress or tierId can be provided'); + + @override + State createState() => _QuotasButtonGroupState(); +} + +class _QuotasButtonGroupState extends State { + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton( + icon: Icon( + Icons.delete, + color: widget.selectedQuotas.isNotEmpty ? Theme.of(context).colorScheme.onError : null, + ), + style: ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith((states) { + return widget.selectedQuotas.isNotEmpty ? Theme.of(context).colorScheme.error : null; + }), + ), + onPressed: widget.selectedQuotas.isNotEmpty ? _removeSelectedQuotas : null, + ), + Gaps.w8, + IconButton.filled( + icon: const Icon(Icons.add), + onPressed: () => showAddQuotaDialog( + context: context, + identityAddress: widget.identityAddress, + tierId: widget.tierId, + onQuotaAdded: widget.onQuotasChanged, + ), + ), + ], + ), + ); + } + + Future _removeSelectedQuotas() async { + final confirmed = await showConfirmationDialog( + context: context, + title: 'Remove Quotas', + message: + 'Are you sure you want to remove the selected quotas from ${widget.identityAddress != null ? 'the identity "${widget.identityAddress}"' : 'the tier "${widget.tierId}"'}?', + ); + + if (!confirmed) return; + + for (final quota in widget.selectedQuotas) { + final result = await _deleteQuota(quota); + if (result.hasError && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('An error occurred while deleting the quota(s). Please try again.'), + showCloseIcon: true, + ), + ); + + return; + } + } + + widget.onQuotasChanged(); + widget.selectedQuotas.clear(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Selected quota(s) have been removed.'), + showCloseIcon: true, + ), + ); + } + } + + Future> _deleteQuota(String quota) { + final client = GetIt.I.get(); + + if (widget.identityAddress != null) return client.quotas.deleteIdentityQuota(address: widget.identityAddress!, individualQuotaId: quota); + return client.quotas.deleteTierQuota(tierId: widget.tierId!, tierQuotaDefinitionId: quota); + } +} diff --git a/AdminUi/apps/admin_ui/lib/core/widgets/widgets.dart b/AdminUi/apps/admin_ui/lib/core/widgets/widgets.dart index 1c70636ccc..82b850c485 100644 --- a/AdminUi/apps/admin_ui/lib/core/widgets/widgets.dart +++ b/AdminUi/apps/admin_ui/lib/core/widgets/widgets.dart @@ -2,3 +2,4 @@ export 'app_title.dart'; export 'copy_to_clipboard_button.dart'; export 'filters/filters.dart'; export 'identities_data_table/identities_data_table.dart'; +export 'quotas_button_group.dart'; diff --git a/AdminUi/apps/admin_ui/lib/home/identity_details/identity_details.dart b/AdminUi/apps/admin_ui/lib/home/identity_details/identity_details.dart index c908ee9ac4..43f3ed5f1f 100644 --- a/AdminUi/apps/admin_ui/lib/home/identity_details/identity_details.dart +++ b/AdminUi/apps/admin_ui/lib/home/identity_details/identity_details.dart @@ -7,6 +7,7 @@ import 'package:get_it/get_it.dart'; import 'package:intl/intl.dart'; import '/core/core.dart'; +import 'identity_quotas_table/identity_quotas_table.dart'; import 'modals/change_tier.dart'; class IdentityDetails extends StatefulWidget { @@ -66,6 +67,8 @@ class _IdentityDetailsState extends State { availableTiers: _tiers!, updateTierOfIdentity: _reloadIdentity, ), + Gaps.h16, + IdentityQuotaList(identityDetails, _reloadIdentity), ], ), ), diff --git a/AdminUi/apps/admin_ui/lib/home/identity_details/identity_quotas_table/identity_quotas_table.dart b/AdminUi/apps/admin_ui/lib/home/identity_details/identity_quotas_table/identity_quotas_table.dart new file mode 100644 index 0000000000..1256aa1833 --- /dev/null +++ b/AdminUi/apps/admin_ui/lib/home/identity_details/identity_quotas_table/identity_quotas_table.dart @@ -0,0 +1,167 @@ +import 'package:admin_api_types/admin_api_types.dart'; +import 'package:data_table_2/data_table_2.dart'; +import 'package:flutter/material.dart'; + +import '/core/core.dart'; + +class IdentityQuotaList extends StatefulWidget { + final Identity identityDetails; + final VoidCallback onQuotasChanged; + + const IdentityQuotaList(this.identityDetails, this.onQuotasChanged, {super.key}); + + @override + State createState() => IdentityQuotaListState(); +} + +class IdentityQuotaListState extends State { + final List _selectedQuotas = []; + + bool get isQueuedForDeletionTier => widget.identityDetails.tierId == 'TIR00000000000000001'; + + @override + Widget build(BuildContext context) { + final groupedQuotas = _groupQuotas(); + + return Theme( + data: Theme.of(context).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + title: const Text('Quotas'), + subtitle: const Text('View and assign quotas for this identity.'), + children: [ + Card( + child: Column( + children: [ + if (!isQueuedForDeletionTier) + QuotasButtonGroup( + selectedQuotas: _selectedQuotas, + identityAddress: widget.identityDetails.address, + onQuotasChanged: widget.onQuotasChanged, + ), + SizedBox( + width: double.infinity, + height: 500, + child: DataTable2( + columns: const [ + DataColumn2(label: Text('Metric')), + DataColumn2(label: Text('Source'), size: ColumnSize.S), + DataColumn2(label: Text('Usage (Used/Max)'), size: ColumnSize.L), + DataColumn2(label: Text('Period'), size: ColumnSize.S), + DataColumn2(label: Text(''), size: ColumnSize.S), + ], + empty: const Text('No quotas applied for this identity.'), + rows: groupedQuotas.entries.expand((entry) { + final metricName = entry.key; + final quotas = entry.value; + + final hasIndividualQuota = quotas.any((quota) => quota.source == 'Individual'); + + return [ + DataRow2( + color: WidgetStateProperty.all(Theme.of(context).colorScheme.surfaceBright), + cells: [ + DataCell(Text(metricName)), + const DataCell(Text('')), + const DataCell(Text('')), + const DataCell(Text('')), + const DataCell(Text('')), + ], + ), + ...quotas.map( + (quota) { + final isTierQuota = quota.source == 'Tier'; + final shouldDisable = isTierQuota && hasIndividualQuota; + final tooltipMessage = shouldDisable ? 'Tier quotas do not take effect if there is an individual quota.' : null; + + return DataRow2( + selected: _selectedQuotas.contains(quota.id), + color: shouldDisable ? WidgetStateProperty.all(Theme.of(context).colorScheme.surfaceBright) : null, + onSelectChanged: shouldDisable || isTierQuota ? null : (_) => _toggleSelection(quota.id), + cells: [ + DataCell(Container()), + DataCell( + Text( + quota.source, + style: TextStyle(color: shouldDisable ? Colors.grey : null), + ), + ), + DataCell( + Row( + children: [ + Text( + '${quota.usage}/${quota.max}', + style: TextStyle(color: shouldDisable ? Colors.grey : null), + ), + const SizedBox(width: 8), + Expanded( + child: LinearProgressIndicator( + value: quota.max > 0 ? quota.usage / quota.max : 0, + backgroundColor: shouldDisable ? Colors.grey : Theme.of(context).colorScheme.inversePrimary, + valueColor: + AlwaysStoppedAnimation(shouldDisable ? Colors.grey : Theme.of(context).colorScheme.primary), + minHeight: 8, + ), + ), + ], + ), + ), + DataCell( + Text( + quota.period, + style: TextStyle(color: shouldDisable ? Colors.grey : null), + ), + ), + DataCell( + Tooltip( + message: tooltipMessage ?? '', + child: isTierQuota && shouldDisable + ? Icon( + Icons.info, + color: shouldDisable ? Colors.grey : null, + ) + : null, + ), + ), + ], + ); + }, + ), + ]; + }).toList(), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Map> _groupQuotas() { + final groupedQuotas = >{}; + + if (widget.identityDetails.quotas != null) { + for (final quota in widget.identityDetails.quotas!) { + if (groupedQuotas.containsKey(quota.metric.displayName)) { + groupedQuotas[quota.metric.displayName]!.add(quota); + } else { + groupedQuotas[quota.metric.displayName] = [quota]; + } + } + } + + return groupedQuotas; + } + + void _toggleSelection(String id) { + setState(() { + if (_selectedQuotas.contains(id)) { + _selectedQuotas.remove(id); + return; + } + + _selectedQuotas.add(id); + }); + } +} diff --git a/AdminUi/apps/admin_ui/lib/home/tier_detail/modals/modals.dart b/AdminUi/apps/admin_ui/lib/home/tier_detail/modals/modals.dart deleted file mode 100644 index 310e6102cd..0000000000 --- a/AdminUi/apps/admin_ui/lib/home/tier_detail/modals/modals.dart +++ /dev/null @@ -1 +0,0 @@ -export 'add_quota.dart'; diff --git a/AdminUi/apps/admin_ui/lib/home/tier_detail/tier_detail.dart b/AdminUi/apps/admin_ui/lib/home/tier_detail/tier_detail.dart index 0ca1567834..33b47ab0a6 100644 --- a/AdminUi/apps/admin_ui/lib/home/tier_detail/tier_detail.dart +++ b/AdminUi/apps/admin_ui/lib/home/tier_detail/tier_detail.dart @@ -8,7 +8,6 @@ import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; import '/core/core.dart'; -import 'modals/modals.dart'; class TierDetail extends StatefulWidget { final String tierId; @@ -129,30 +128,10 @@ class _QuotaListState extends State<_QuotaList> { child: Column( children: [ if (!isQueuedForDeletionTier) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - IconButton( - icon: Icon( - Icons.delete, - color: _selectedQuotas.isNotEmpty ? Theme.of(context).colorScheme.onError : null, - ), - style: ButtonStyle( - backgroundColor: WidgetStateProperty.resolveWith((states) { - return _selectedQuotas.isNotEmpty ? Theme.of(context).colorScheme.error : null; - }), - ), - onPressed: _selectedQuotas.isNotEmpty ? _removeSelectedQuotas : null, - ), - Gaps.w8, - IconButton.filled( - icon: const Icon(Icons.add), - onPressed: () => showAddQuotaDialog(context: context, tierId: widget.tierDetails.id, onQuotaAdded: widget.onQuotasChanged), - ), - ], - ), + QuotasButtonGroup( + selectedQuotas: _selectedQuotas, + onQuotasChanged: widget.onQuotasChanged, + tierId: widget.tierDetails.id, ), SizedBox( width: double.infinity, @@ -172,7 +151,7 @@ class _QuotaListState extends State<_QuotaList> { DataCell(Text(quota.max.toString())), DataCell(Text(quota.period)), ], - onSelectChanged: widget.tierDetails.id == 'TIR00000000000000001' ? null : (_) => _toggleSelection(quota.id), + onSelectChanged: isQueuedForDeletionTier ? null : (_) => _toggleSelection(quota.id), selected: _selectedQuotas.contains(quota.id), ), ) @@ -197,41 +176,6 @@ class _QuotaListState extends State<_QuotaList> { _selectedQuotas.add(id); }); } - - Future _removeSelectedQuotas() async { - final confirmed = await showConfirmationDialog( - context: context, - title: 'Remove Quotas', - message: 'Are you sure you want to remove the selected quotas from the tier "${widget.tierDetails.name}"?', - ); - - if (!confirmed) return; - - for (final quota in _selectedQuotas) { - final result = await GetIt.I.get().quotas.deleteTierQuota(tierId: widget.tierDetails.id, tierQuotaDefinitionId: quota); - if (result.hasError && mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('An error occurred while deleting the quota(s). Please try again.'), - showCloseIcon: true, - ), - ); - return; - } - - widget.onQuotasChanged(); - } - - _selectedQuotas.clear(); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Selected quotas have been removed.'), - showCloseIcon: true, - ), - ); - } - } } class _IdentitiesList extends StatefulWidget { diff --git a/AdminUi/packages/admin_api_sdk/lib/src/endpoints/quotas_endpoint.dart b/AdminUi/packages/admin_api_sdk/lib/src/endpoints/quotas_endpoint.dart index a6baf1d429..b8ed173f35 100644 --- a/AdminUi/packages/admin_api_sdk/lib/src/endpoints/quotas_endpoint.dart +++ b/AdminUi/packages/admin_api_sdk/lib/src/endpoints/quotas_endpoint.dart @@ -34,13 +34,13 @@ class QuotasEndpoint extends Endpoint { ); Future> createIdentityQuota({ - required String identityId, + required String address, required String metricKey, required int max, required String period, }) => post( - '/api/v1/Identities/$identityId/Quotas', + '/api/v1/Identities/$address/Quotas', data: { 'metricKey': metricKey, 'max': max, @@ -50,11 +50,11 @@ class QuotasEndpoint extends Endpoint { ); Future> deleteIdentityQuota({ - required String tierId, + required String address, required String individualQuotaId, }) => delete( - '/api/v1/Tiers/$tierId/Quotas/$individualQuotaId', + '/api/v1/Identities/$address/Quotas/$individualQuotaId', expectedStatus: 204, transformer: (e) {}, allowEmptyResponse: true,