Files
geutebruck/geutebruck_app/lib/presentation/screens/servers/servers_management_screen.dart
Administrator 14893e62a5 feat: Geutebruck GeViScope/GeViSoft Action Mapping System - MVP
This MVP release provides a complete full-stack solution for managing action mappings
in Geutebruck's GeViScope and GeViSoft video surveillance systems.

## Features

### Flutter Web Application (Port 8081)
- Modern, responsive UI for managing action mappings
- Action picker dialog with full parameter configuration
- Support for both GSC (GeViScope) and G-Core server actions
- Consistent UI for input and output actions with edit/delete capabilities
- Real-time action mapping creation, editing, and deletion
- Server categorization (GSC: prefix for GeViScope, G-Core: prefix for G-Core servers)

### FastAPI REST Backend (Port 8000)
- RESTful API for action mapping CRUD operations
- Action template service with comprehensive action catalog (247 actions)
- Server management (G-Core and GeViScope servers)
- Configuration tree reading and writing
- JWT authentication with role-based access control
- PostgreSQL database integration

### C# SDK Bridge (gRPC, Port 50051)
- Native integration with GeViSoft SDK (GeViProcAPINET_4_0.dll)
- Action mapping creation with correct binary format
- Support for GSC and G-Core action types
- Proper Camera parameter inclusion in action strings (fixes CrossSwitch bug)
- Action ID lookup table with server-specific action IDs
- Configuration reading/writing via SetupClient

## Bug Fixes
- **CrossSwitch Bug**: GSC and G-Core actions now correctly display camera/PTZ head parameters in GeViSet
- Action strings now include Camera parameter: `@ PanLeft (Comment: "", Camera: 101028)`
- Proper filter flags and VideoInput=0 for action mappings
- Correct action ID assignment (4198 for GSC, 9294 for G-Core PanLeft)

## Technical Stack
- **Frontend**: Flutter Web, Dart, Dio HTTP client
- **Backend**: Python FastAPI, PostgreSQL, Redis
- **SDK Bridge**: C# .NET 8.0, gRPC, GeViSoft SDK
- **Authentication**: JWT tokens
- **Configuration**: GeViSoft .set files (binary format)

## Credentials
- GeViSoft/GeViScope: username=sysadmin, password=masterkey
- Default admin: username=admin, password=admin123

## Deployment
All services run on localhost:
- Flutter Web: http://localhost:8081
- FastAPI: http://localhost:8000
- SDK Bridge gRPC: localhost:50051
- GeViServer: localhost (default port)

Generated with Claude Code (https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-31 18:10:54 +01:00

423 lines
15 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../domain/entities/server.dart';
import '../../blocs/auth/auth_bloc.dart';
import '../../blocs/auth/auth_event.dart';
import '../../blocs/auth/auth_state.dart';
import '../../blocs/server/server_bloc.dart';
import '../../blocs/server/server_event.dart';
import '../../blocs/server/server_state.dart';
import '../../widgets/app_drawer.dart';
class ServersManagementScreen extends StatefulWidget {
const ServersManagementScreen({super.key});
@override
State<ServersManagementScreen> createState() => _ServersManagementScreenState();
}
class _ServersManagementScreenState extends State<ServersManagementScreen> {
String _filterType = 'all'; // 'all', 'gcore', 'geviscope'
@override
Widget build(BuildContext context) {
return Scaffold(
drawer: const AppDrawer(currentRoute: '/servers'),
appBar: AppBar(
title: Row(
children: [
const Icon(Icons.dns, size: 24),
const SizedBox(width: 8),
const Text('Server Management'),
],
),
actions: [
// Sync button with dirty count badge
BlocBuilder<ServerBloc, ServerState>(
builder: (context, state) {
final dirtyCount = state is ServerLoaded ? state.dirtyCount : 0;
return Stack(
children: [
IconButton(
icon: const Icon(Icons.sync),
onPressed: dirtyCount > 0
? () {
context.read<ServerBloc>().add(const SyncServersEvent());
}
: null,
tooltip: dirtyCount > 0
? 'Sync $dirtyCount unsaved change${dirtyCount != 1 ? 's' : ''}'
: 'No changes to sync',
),
if (dirtyCount > 0)
Positioned(
right: 4,
top: 4,
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
child: Text(
'$dirtyCount',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
),
],
);
},
),
// Download/refresh button
Builder(
builder: (context) => IconButton(
icon: const Icon(Icons.cloud_download),
onPressed: () {
context.read<ServerBloc>().add(const DownloadServersEvent());
},
tooltip: 'Download latest from server',
),
),
const SizedBox(width: 8),
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
if (state is Authenticated) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
children: [
Icon(
state.user.role == 'Administrator'
? Icons.admin_panel_settings
: Icons.person,
size: 20,
),
const SizedBox(width: 8),
Text(state.user.username),
const SizedBox(width: 16),
IconButton(
icon: const Icon(Icons.logout),
onPressed: () {
context.read<AuthBloc>().add(const LogoutRequested());
},
tooltip: 'Logout',
),
],
),
);
}
return const SizedBox.shrink();
},
),
],
),
body: Column(
children: [
// Filter tabs
Container(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
_buildFilterChip('All Servers', 'all'),
const SizedBox(width: 8),
_buildFilterChip('G-Core', 'gcore'),
const SizedBox(width: 8),
_buildFilterChip('GeViScope', 'geviscope'),
const Spacer(),
ElevatedButton.icon(
onPressed: () {
_showAddServerDialog(context);
},
icon: const Icon(Icons.add),
label: const Text('Add Server'),
),
],
),
),
// Server list
Expanded(
child: BlocConsumer<ServerBloc, ServerState>(
listener: (context, state) {
if (state is ServerError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: Colors.red,
),
);
} else if (state is ServerOperationSuccess) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: Colors.green,
),
);
} else if (state is ServerSyncSuccess) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: Colors.green,
),
);
}
},
builder: (context, state) {
if (state is ServerLoading) {
return const Center(child: CircularProgressIndicator());
} else if (state is ServerSyncing) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(
state.message,
style: Theme.of(context).textTheme.titleMedium,
),
],
),
);
} else if (state is ServerDownloading) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Downloading servers...'),
],
),
);
} else if (state is ServerLoaded) {
final filteredServers = _filterServers(state.servers);
if (filteredServers.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.dns_outlined, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'No servers found',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.grey[600],
),
),
const SizedBox(height: 8),
Text(
'Add a server to get started',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey[500],
),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(16.0),
itemCount: filteredServers.length,
itemBuilder: (context, index) {
final server = filteredServers[index];
return _buildServerCard(context, server);
},
);
} else if (state is ServerError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 64, color: Colors.red[300]),
const SizedBox(height: 16),
Text(
'Error loading servers',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
state.message,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: () {
context.read<ServerBloc>().add(const LoadServers());
},
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
);
}
// Handle ServerInitial or any other unknown states with a loading indicator
// instead of "No data" to prevent confusion during state transitions
return const Center(child: CircularProgressIndicator());
},
),
),
],
),
);
}
List<Server> _filterServers(List<Server> servers) {
if (_filterType == 'all') {
return servers;
} else if (_filterType == 'gcore') {
return servers.where((s) => s.type == ServerType.gcore).toList();
} else {
return servers.where((s) => s.type == ServerType.geviscope).toList();
}
}
Widget _buildFilterChip(String label, String value) {
final isSelected = _filterType == value;
return FilterChip(
label: Text(label),
selected: isSelected,
onSelected: (selected) {
setState(() {
_filterType = value;
});
},
);
}
Widget _buildServerCard(BuildContext context, Server server) {
return Card(
margin: const EdgeInsets.only(bottom: 12.0),
child: ListTile(
leading: CircleAvatar(
backgroundColor: server.type == ServerType.gcore
? Colors.green.withOpacity(0.2)
: Colors.purple.withOpacity(0.2),
child: Icon(
Icons.dns,
color: server.type == ServerType.gcore ? Colors.green : Colors.purple,
),
),
title: Row(
children: [
Text(
server.alias,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: server.enabled ? Colors.green : Colors.grey,
borderRadius: BorderRadius.circular(12),
),
child: Text(
server.enabled ? 'Enabled' : 'Disabled',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
),
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text('Host: ${server.host}'),
Text('User: ${server.user}'),
Text('Type: ${server.type == ServerType.gcore ? "G-Core" : "GeViScope"}'),
],
),
isThreeLine: true,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () {
context.push('/servers/edit/${server.id}', extra: server);
},
tooltip: 'Edit',
),
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () {
_showDeleteConfirmation(context, server);
},
tooltip: 'Delete',
),
],
),
),
);
}
void _showDeleteConfirmation(BuildContext context, Server server) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Delete Server'),
content: Text('Are you sure you want to delete "${server.alias}"?'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
context.read<ServerBloc>().add(DeleteServerEvent(server.id, server.type));
Navigator.of(dialogContext).pop();
},
child: const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
),
);
}
void _showAddServerDialog(BuildContext context) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Add Server'),
content: const Text('Choose the server type:'),
actions: [
TextButton(
onPressed: () {
Navigator.of(dialogContext).pop();
context.push('/servers/create?type=gcore');
},
child: const Text('G-Core Server'),
),
TextButton(
onPressed: () {
Navigator.of(dialogContext).pop();
context.push('/servers/create?type=geviscope');
},
child: const Text('GeViScope Server'),
),
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
],
),
);
}
}