197 lines
7.1 KiB
Dart
197 lines
7.1 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
import '../../core/models/models.dart';
|
|
import '../../core/models/severity_mapping.dart';
|
|
import '../../core/network/api_exception.dart';
|
|
import '../../core/routing/route_paths.dart';
|
|
import '../../core/theme/theme_x.dart';
|
|
import '../../core/widgets/error_banner.dart';
|
|
import '../../core/widgets/slide_over_panel.dart';
|
|
import '../../core/widgets/status_pill.dart';
|
|
import '../../data/repositories/services_repository.dart';
|
|
import 'widgets/service_detail_body.dart';
|
|
import 'widgets/service_form_dialog.dart';
|
|
import 'widgets/services_table.dart';
|
|
|
|
class ServicesScreen extends ConsumerStatefulWidget {
|
|
const ServicesScreen({super.key, this.serviceId});
|
|
|
|
/// Present when routed via `/services?id=...` — renders the slide-over
|
|
/// detail panel for this service alongside the still-visible table.
|
|
final String? serviceId;
|
|
|
|
@override
|
|
ConsumerState<ServicesScreen> createState() => _ServicesScreenState();
|
|
}
|
|
|
|
class _ServicesScreenState extends ConsumerState<ServicesScreen> {
|
|
final _busyIds = <String>{};
|
|
|
|
Future<void> _run(String id, Future<void> Function() action, {String? successMessage}) async {
|
|
setState(() => _busyIds.add(id));
|
|
try {
|
|
await action();
|
|
ref.invalidate(servicesListProvider);
|
|
if (mounted && successMessage != null) {
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(successMessage)));
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
final message = e is ApiException ? e.userMessage : e.toString();
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _busyIds.remove(id));
|
|
}
|
|
}
|
|
|
|
void _toggleRunning(Service service) {
|
|
final repo = ref.read(servicesRepositoryProvider);
|
|
if (repo == null) return;
|
|
_run(service.id, () => service.status == 'up' ? repo.stop(service.id) : repo.start(service.id));
|
|
}
|
|
|
|
void _backupNow(Service service) {
|
|
final repo = ref.read(servicesRepositoryProvider);
|
|
if (repo == null) return;
|
|
_run(service.id, () => repo.backupNow(service.id), successMessage: 'Backup started');
|
|
}
|
|
|
|
Future<void> _delete(Service service) async {
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Delete service?'),
|
|
content: Text('This removes "${service.name}" from NodeMaster. It does not stop or delete the container.'),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')),
|
|
FilledButton(
|
|
style: FilledButton.styleFrom(backgroundColor: context.status.critical),
|
|
onPressed: () => Navigator.of(context).pop(true),
|
|
child: const Text('Delete'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (confirmed != true) return;
|
|
|
|
final repo = ref.read(servicesRepositoryProvider);
|
|
if (repo == null) return;
|
|
await _run(service.id, () => repo.delete(service.id));
|
|
if (mounted) context.go(RoutePaths.services);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final servicesAsync = ref.watch(servicesListProvider);
|
|
|
|
return Stack(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
servicesAsync.maybeWhen(
|
|
data: (services) => '${services.length} SERVICES',
|
|
orElse: () => 'SERVICES',
|
|
),
|
|
style: context.text.titleSmall,
|
|
),
|
|
FilledButton.icon(
|
|
onPressed: () => showServiceFormDialog(context),
|
|
icon: const Icon(Icons.add, size: 17),
|
|
label: const Text('Add service'),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Expanded(
|
|
child: servicesAsync.when(
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (error, stackTrace) => Align(
|
|
alignment: Alignment.topCenter,
|
|
child: ErrorBanner(
|
|
message: error is ApiException ? error.userMessage : error.toString(),
|
|
onRetry: () => ref.invalidate(servicesListProvider),
|
|
),
|
|
),
|
|
data: (services) => SingleChildScrollView(
|
|
child: ServicesTable(
|
|
services: services,
|
|
busyServiceIds: _busyIds,
|
|
onRowTap: (service) => context.go(RoutePaths.serviceDetail(service.id)),
|
|
onToggleRunning: _toggleRunning,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
_buildDetailPanel(servicesAsync),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildDetailPanel(AsyncValue<List<Service>> servicesAsync) {
|
|
final id = widget.serviceId;
|
|
final service = servicesAsync.maybeWhen(
|
|
data: (services) {
|
|
for (final s in services) {
|
|
if (s.id == id) return s;
|
|
}
|
|
return null;
|
|
},
|
|
orElse: () => null,
|
|
);
|
|
|
|
return SlideOverPanel(
|
|
open: id != null,
|
|
onClose: () => context.go(RoutePaths.services),
|
|
title: service?.name ?? '',
|
|
subtitle: service == null
|
|
? null
|
|
: StatusPill(label: service.status, severity: severityForServiceStatus(service.status)),
|
|
body: service == null
|
|
? const SizedBox.shrink()
|
|
: ServiceDetailBody(service: service),
|
|
headerActions: service == null
|
|
? const []
|
|
: [
|
|
IconButton(
|
|
tooltip: 'Edit',
|
|
icon: const Icon(Icons.edit_outlined, size: 18),
|
|
onPressed: () => showServiceFormDialog(context, existing: service),
|
|
),
|
|
],
|
|
actions: service == null
|
|
? const []
|
|
: [
|
|
OutlinedButton.icon(
|
|
onPressed: () => _toggleRunning(service),
|
|
icon: Icon(service.status == 'up' ? Icons.stop_rounded : Icons.play_arrow_rounded, size: 17),
|
|
label: Text(service.status == 'up' ? 'Stop' : 'Start'),
|
|
),
|
|
OutlinedButton.icon(
|
|
onPressed: service.backup == null ? null : () => _backupNow(service),
|
|
icon: const Icon(Icons.cloud_outlined, size: 17),
|
|
label: const Text('Backup'),
|
|
),
|
|
OutlinedButton.icon(
|
|
style: OutlinedButton.styleFrom(foregroundColor: context.status.critical),
|
|
onPressed: () => _delete(service),
|
|
icon: const Icon(Icons.delete_outline, size: 17),
|
|
label: const Text('Delete'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|