it0/it0_app/lib/features/terminal/presentation/pages/terminal_page.dart

389 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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),
),
),
),
],
),
);
}
}