389 lines
13 KiB
Dart
389 lines
13 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||
import 'package:xterm/xterm.dart';
|
||
import '../../../../core/config/api_endpoints.dart';
|
||
import '../../../../core/config/app_config.dart';
|
||
import '../../../../core/network/dio_client.dart';
|
||
import '../../../../core/theme/app_colors.dart';
|
||
import '../../../auth/data/providers/auth_provider.dart';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// TODO 2E – Remote Terminal page: xterm + WebSocket
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Fetches the list of servers for the server-selector dropdown.
|
||
final _terminalServersProvider =
|
||
FutureProvider<List<Map<String, dynamic>>>((ref) async {
|
||
final dio = ref.watch(dioClientProvider);
|
||
final response = await dio.get(ApiEndpoints.servers);
|
||
final data = response.data;
|
||
if (data is List) return data.cast<Map<String, dynamic>>();
|
||
if (data is Map && data.containsKey('items')) {
|
||
return (data['items'] as List).cast<Map<String, dynamic>>();
|
||
}
|
||
return [];
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Connection status enum
|
||
// ---------------------------------------------------------------------------
|
||
|
||
enum _ConnectionStatus { disconnected, connecting, connected }
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Terminal page – ConsumerStatefulWidget
|
||
// ---------------------------------------------------------------------------
|
||
|
||
class TerminalPage extends ConsumerStatefulWidget {
|
||
const TerminalPage({super.key});
|
||
|
||
@override
|
||
ConsumerState<TerminalPage> createState() => _TerminalPageState();
|
||
}
|
||
|
||
class _TerminalPageState extends ConsumerState<TerminalPage> {
|
||
late final Terminal _terminal;
|
||
WebSocketChannel? _channel;
|
||
StreamSubscription? _wsSubscription;
|
||
_ConnectionStatus _status = _ConnectionStatus.disconnected;
|
||
String? _selectedServerId;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_terminal = Terminal(maxLines: 10000);
|
||
_terminal.onOutput = _onTerminalInput;
|
||
_terminal.write('iAgent Remote Terminal\r\n');
|
||
_terminal.write('Select a server and press Connect to begin.\r\n');
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_disconnect();
|
||
super.dispose();
|
||
}
|
||
|
||
// ---------- WebSocket lifecycle ------------------------------------------
|
||
|
||
Future<void> _connect() async {
|
||
if (_selectedServerId == null || _selectedServerId!.isEmpty) {
|
||
_terminal.write('\r\n\x1B[33m[!] Please select a server first.\x1B[0m\r\n');
|
||
return;
|
||
}
|
||
|
||
setState(() => _status = _ConnectionStatus.connecting);
|
||
_terminal.write('\r\n\x1B[33m[*] Connecting to server $_selectedServerId...\x1B[0m\r\n');
|
||
|
||
try {
|
||
final config = ref.read(appConfigProvider);
|
||
final storage = ref.read(secureStorageProvider);
|
||
final token = await storage.read(key: 'access_token');
|
||
|
||
final queryParams = <String, String>{
|
||
'serverId': _selectedServerId!,
|
||
};
|
||
if (token != null) {
|
||
queryParams['token'] = token;
|
||
}
|
||
|
||
final uri = Uri.parse(
|
||
'${config.wsBaseUrl}${ApiEndpoints.wsTerminal}',
|
||
).replace(queryParameters: queryParams);
|
||
|
||
_channel = WebSocketChannel.connect(uri);
|
||
|
||
// Wait for the connection to be ready
|
||
await _channel!.ready;
|
||
|
||
_wsSubscription = _channel!.stream.listen(
|
||
_onWsMessage,
|
||
onDone: _onWsDone,
|
||
onError: _onWsError,
|
||
);
|
||
|
||
if (mounted) {
|
||
setState(() => _status = _ConnectionStatus.connected);
|
||
_terminal.write('\x1B[32m[+] Connected.\x1B[0m\r\n');
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
setState(() => _status = _ConnectionStatus.disconnected);
|
||
_terminal.write('\x1B[31m[-] Connection failed: $e\x1B[0m\r\n');
|
||
}
|
||
}
|
||
}
|
||
|
||
void _disconnect() {
|
||
_wsSubscription?.cancel();
|
||
_wsSubscription = null;
|
||
_channel?.sink.close();
|
||
_channel = null;
|
||
if (mounted) {
|
||
setState(() => _status = _ConnectionStatus.disconnected);
|
||
}
|
||
}
|
||
|
||
// ---------- WebSocket message handlers -----------------------------------
|
||
|
||
void _onWsMessage(dynamic data) {
|
||
if (data is String) {
|
||
// Try decoding as JSON first (server may wrap output in a message
|
||
// envelope like {"type":"output","data":"..."}).
|
||
try {
|
||
final decoded = jsonDecode(data) as Map<String, dynamic>;
|
||
final output = decoded['data'] as String? ?? decoded['output'] as String? ?? '';
|
||
if (output.isNotEmpty) {
|
||
_terminal.write(output);
|
||
}
|
||
} catch (_) {
|
||
// Not JSON – treat as raw terminal output.
|
||
_terminal.write(data);
|
||
}
|
||
}
|
||
}
|
||
|
||
void _onWsDone() {
|
||
if (mounted) {
|
||
setState(() => _status = _ConnectionStatus.disconnected);
|
||
_terminal.write('\r\n\x1B[33m[*] Connection closed.\x1B[0m\r\n');
|
||
}
|
||
}
|
||
|
||
void _onWsError(dynamic error) {
|
||
if (mounted) {
|
||
setState(() => _status = _ConnectionStatus.disconnected);
|
||
_terminal.write('\r\n\x1B[31m[-] WebSocket error: $error\x1B[0m\r\n');
|
||
}
|
||
}
|
||
|
||
// ---------- Terminal input handler ----------------------------------------
|
||
|
||
void _onTerminalInput(String data) {
|
||
if (_status != _ConnectionStatus.connected || _channel == null) {
|
||
return;
|
||
}
|
||
// Send raw input to the server via WebSocket.
|
||
_channel!.sink.add(jsonEncode({'type': 'input', 'data': data}));
|
||
}
|
||
|
||
// ---------- UI helpers ---------------------------------------------------
|
||
|
||
Color get _statusColor {
|
||
switch (_status) {
|
||
case _ConnectionStatus.connected:
|
||
return AppColors.success;
|
||
case _ConnectionStatus.connecting:
|
||
return AppColors.warning;
|
||
case _ConnectionStatus.disconnected:
|
||
return AppColors.error;
|
||
}
|
||
}
|
||
|
||
String get _statusLabel {
|
||
switch (_status) {
|
||
case _ConnectionStatus.connected:
|
||
return 'Connected';
|
||
case _ConnectionStatus.connecting:
|
||
return 'Connecting...';
|
||
case _ConnectionStatus.disconnected:
|
||
return 'Disconnected';
|
||
}
|
||
}
|
||
|
||
// ---------- Build --------------------------------------------------------
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final serversAsync = ref.watch(_terminalServersProvider);
|
||
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: const Text('Remote Terminal'),
|
||
actions: [
|
||
// Connection status indicator
|
||
Padding(
|
||
padding: const EdgeInsets.only(right: 8),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(
|
||
width: 10,
|
||
height: 10,
|
||
decoration: BoxDecoration(
|
||
color: _statusColor,
|
||
shape: BoxShape.circle,
|
||
),
|
||
),
|
||
const SizedBox(width: 6),
|
||
Text(
|
||
_statusLabel,
|
||
style: TextStyle(
|
||
color: _statusColor,
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
// Reconnect / Disconnect button
|
||
if (_status == _ConnectionStatus.disconnected)
|
||
IconButton(
|
||
icon: const Icon(Icons.refresh),
|
||
tooltip: 'Reconnect',
|
||
onPressed: _selectedServerId != null ? _connect : null,
|
||
)
|
||
else if (_status == _ConnectionStatus.connected)
|
||
IconButton(
|
||
icon: const Icon(Icons.link_off),
|
||
tooltip: 'Disconnect',
|
||
onPressed: () {
|
||
_disconnect();
|
||
_terminal.write('\r\n\x1B[33m[*] Disconnected by user.\x1B[0m\r\n');
|
||
},
|
||
),
|
||
],
|
||
),
|
||
body: Column(
|
||
children: [
|
||
// ---- Server selector bar ----------------------------------------
|
||
Container(
|
||
color: AppColors.surface,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
child: Row(
|
||
children: [
|
||
const Icon(Icons.dns, size: 20, color: AppColors.textSecondary),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: serversAsync.when(
|
||
data: (servers) {
|
||
if (servers.isEmpty) {
|
||
return const Text(
|
||
'No servers available',
|
||
style: TextStyle(
|
||
color: AppColors.textMuted,
|
||
fontSize: 14,
|
||
),
|
||
);
|
||
}
|
||
return DropdownButtonHideUnderline(
|
||
child: DropdownButton<String>(
|
||
value: _selectedServerId,
|
||
hint: const Text(
|
||
'Select a server...',
|
||
style: TextStyle(
|
||
color: AppColors.textMuted,
|
||
fontSize: 14,
|
||
),
|
||
),
|
||
isExpanded: true,
|
||
dropdownColor: AppColors.surface,
|
||
style: const TextStyle(
|
||
color: AppColors.textPrimary,
|
||
fontSize: 14,
|
||
),
|
||
items: servers.map((s) {
|
||
final id = s['id']?.toString() ?? '';
|
||
final hostname =
|
||
s['hostname'] as String? ?? s['name'] as String? ?? id;
|
||
final ip = s['ip'] as String? ?? s['address'] as String? ?? '';
|
||
return DropdownMenuItem(
|
||
value: id,
|
||
child: Text(
|
||
ip.isNotEmpty ? '$hostname ($ip)' : hostname,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
);
|
||
}).toList(),
|
||
onChanged: _status == _ConnectionStatus.connected
|
||
? null
|
||
: (value) {
|
||
setState(() => _selectedServerId = value);
|
||
},
|
||
),
|
||
);
|
||
},
|
||
loading: () => const SizedBox(
|
||
height: 20,
|
||
child: LinearProgressIndicator(),
|
||
),
|
||
error: (e, _) => Row(
|
||
children: [
|
||
const Icon(Icons.error_outline,
|
||
size: 16, color: AppColors.error),
|
||
const SizedBox(width: 6),
|
||
Expanded(
|
||
child: Text(
|
||
'Failed to load servers',
|
||
style: const TextStyle(
|
||
color: AppColors.error,
|
||
fontSize: 13,
|
||
),
|
||
),
|
||
),
|
||
IconButton(
|
||
icon: const Icon(Icons.refresh, size: 18),
|
||
onPressed: () =>
|
||
ref.invalidate(_terminalServersProvider),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
// Connect button
|
||
SizedBox(
|
||
height: 36,
|
||
child: FilledButton.icon(
|
||
onPressed: _status == _ConnectionStatus.connecting
|
||
? null
|
||
: _status == _ConnectionStatus.connected
|
||
? () {
|
||
_disconnect();
|
||
_terminal.write(
|
||
'\r\n\x1B[33m[*] Disconnected by user.\x1B[0m\r\n');
|
||
}
|
||
: _connect,
|
||
icon: Icon(
|
||
_status == _ConnectionStatus.connected
|
||
? Icons.link_off
|
||
: Icons.play_arrow,
|
||
size: 18,
|
||
),
|
||
label: Text(
|
||
_status == _ConnectionStatus.connected
|
||
? 'Disconnect'
|
||
: _status == _ConnectionStatus.connecting
|
||
? 'Connecting...'
|
||
: 'Connect',
|
||
style: const TextStyle(fontSize: 13),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
// ---- xterm terminal view ----------------------------------------
|
||
Expanded(
|
||
child: Container(
|
||
color: Colors.black,
|
||
child: TerminalView(
|
||
_terminal,
|
||
textStyle: const TerminalStyle(
|
||
fontSize: 14,
|
||
fontFamily: 'monospace',
|
||
),
|
||
padding: const EdgeInsets.all(4),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|