feat: écran détail serveur (stats CPU/RAM + image importable RustFS)
Build APK / build (push) Successful in 2m36s

- carte cliquable → ServerDetailScreen : bannière (image ou thème), statut,
  connexion/ouvrir, joueurs/MOTD, jauges CPU/RAM/réseau (docker stats),
  import/suppression d'image (galerie → backend → RustFS).
- api_service: stats(), server(), imageBytes(), uploadImage(), deleteImage().
- deps: image_picker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
neckfire
2026-07-13 12:05:09 +02:00
co-authored by Claude Opus 4.8
parent a971204b80
commit 2862fdd408
7 changed files with 504 additions and 2 deletions
+3
View File
@@ -16,6 +16,7 @@ class GameServer {
final int? maxPlayers;
final String? motd;
final bool hasIcon;
final bool hasImage;
const GameServer({
required this.id,
@@ -32,6 +33,7 @@ class GameServer {
this.maxPlayers,
this.motd,
this.hasIcon = false,
this.hasImage = false,
});
factory GameServer.fromJson(Map<String, dynamic> json) => GameServer(
@@ -49,6 +51,7 @@ class GameServer {
maxPlayers: json['max_players'] as int?,
motd: json['motd'] as String?,
hasIcon: json['has_icon'] as bool? ?? false,
hasImage: json['has_image'] as bool? ?? false,
);
bool get isUp => status == 'online' || status == 'running';
+311
View File
@@ -0,0 +1,311 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:url_launcher/url_launcher.dart';
import '../models.dart';
import '../services/api_service.dart';
/// Détail d'un serveur : bannière (image importée ou thème), statut, connexion,
/// stats d'utilisation (CPU/RAM/réseau) et gestion de l'image (import/suppression).
class ServerDetailScreen extends StatefulWidget {
const ServerDetailScreen({super.key, required this.server, required this.api});
final GameServer server;
final ApiService api;
@override
State<ServerDetailScreen> createState() => _ServerDetailScreenState();
}
class _ServerDetailScreenState extends State<ServerDetailScreen> {
GameServer get s => _server;
late GameServer _server = widget.server;
Map<String, dynamic>? _stats;
Uint8List? _image;
bool _busy = false;
bool _imgBusy = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final results = await Future.wait([
widget.api.stats(s.id).catchError((_) => <String, dynamic>{'running': false}),
widget.api.imageBytes(s.id).catchError((_) => null),
widget.api.server(s.id).catchError((_) => _server),
]);
if (!mounted) return;
setState(() {
_stats = results[0] as Map<String, dynamic>;
_image = results[1] as Uint8List?;
_server = results[2] as GameServer;
});
}
Future<void> _toggle() async {
setState(() => _busy = true);
try {
s.isUp ? await widget.api.stop(s.id) : await widget.api.start(s.id);
await Future.delayed(const Duration(seconds: 2));
await _load();
} catch (e) {
_snack('$e');
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _pickImage() async {
final x = await ImagePicker().pickImage(source: ImageSource.gallery, maxWidth: 1600);
if (x == null) return;
setState(() => _imgBusy = true);
try {
await widget.api.uploadImage(s.id, x.path);
final bytes = await widget.api.imageBytes(s.id);
if (mounted) setState(() => _image = bytes);
} catch (e) {
_snack('$e');
} finally {
if (mounted) setState(() => _imgBusy = false);
}
}
Future<void> _deleteImage() async {
setState(() => _imgBusy = true);
try {
await widget.api.deleteImage(s.id);
if (mounted) setState(() => _image = null);
} catch (e) {
_snack('$e');
} finally {
if (mounted) setState(() => _imgBusy = false);
}
}
void _snack(String m) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(m)));
}
@override
Widget build(BuildContext context) {
final t = s.theme;
final badge = s.statusBadge;
return Scaffold(
appBar: AppBar(title: Text(s.name)),
body: RefreshIndicator(
onRefresh: _load,
child: ListView(
children: [
_banner(t),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
_Pill(label: badge.label, color: badge.color),
const SizedBox(width: 8),
_Pill(label: s.isPreprod ? 'Preprod' : 'Production',
color: s.isPreprod ? const Color(0xFFFF9F0A) : const Color(0xFF34C759)),
const Spacer(),
if (s.web && s.openUrl != null)
OutlinedButton.icon(
onPressed: () =>
launchUrl(Uri.parse(s.openUrl!), mode: LaunchMode.externalApplication),
icon: const Icon(Icons.open_in_new, size: 18),
label: const Text('Ouvrir'),
),
],
),
const SizedBox(height: 16),
_toggleButton(),
const SizedBox(height: 20),
if (s.connect != null) _info(Icons.link, 'Connexion', s.connect!),
if (s.status == 'online')
_info(Icons.people, 'Joueurs', '${s.players ?? 0} / ${s.maxPlayers ?? 0}'),
if (s.motd != null) _info(Icons.chat_bubble_outline, 'MOTD', s.motd!),
const SizedBox(height: 20),
_statsCard(),
const SizedBox(height: 20),
_imageCard(),
],
),
),
],
),
),
);
}
Widget _banner(({Color start, Color end, IconData icon}) t) {
return SizedBox(
height: 190,
width: double.infinity,
child: _image != null
? Image.memory(_image!, fit: BoxFit.cover)
: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [t.start, t.end]),
),
child: Center(child: Icon(t.icon, size: 72, color: Colors.white70)),
),
);
}
Widget _toggleButton() {
return SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _busy ? null : _toggle,
style: FilledButton.styleFrom(
backgroundColor: s.isUp ? const Color(0xFFB3261E) : const Color(0xFF1B5E20),
padding: const EdgeInsets.symmetric(vertical: 14),
),
icon: _busy
? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: Icon(s.isUp ? Icons.stop : Icons.play_arrow),
label: Text(s.isUp ? 'Éteindre le serveur' : 'Démarrer le serveur'),
),
);
}
Widget _info(IconData icon, String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: Theme.of(context).textTheme.labelMedium),
Text(value, style: Theme.of(context).textTheme.bodyLarge),
],
),
),
],
),
);
}
Widget _statsCard() {
final st = _stats;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Utilisation', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
if (st == null)
const Center(child: Padding(padding: EdgeInsets.all(8), child: CircularProgressIndicator()))
else if (st['running'] != true)
const Text('Serveur éteint — pas de stats.')
else ...[
_gauge('CPU', (st['cpu_percent'] as num?)?.toDouble() ?? 0,
'${st['cpu_percent']} %'),
const SizedBox(height: 12),
_gauge('RAM', (st['mem_percent'] as num?)?.toDouble() ?? 0,
'${st['mem_used_mb']} Mo${st['mem_limit_mb'] != null ? ' / ${_fmtMb(st['mem_limit_mb'])}' : ''}'),
const SizedBox(height: 12),
Text('Réseau : ↓ ${st['net_rx_mb']} Mo ↑ ${st['net_tx_mb']} Mo',
style: Theme.of(context).textTheme.bodyMedium),
],
],
),
),
);
}
String _fmtMb(dynamic mb) {
final v = (mb as num).toDouble();
return v >= 1024 ? '${(v / 1024).toStringAsFixed(1)} Go' : '${v.round()} Mo';
}
Widget _gauge(String label, double percent, String value) {
final p = (percent / 100).clamp(0.0, 1.0);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
Text(value, style: Theme.of(context).textTheme.bodyMedium),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(value: p, minHeight: 8),
),
],
);
}
Widget _imageCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Image', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 6),
Text('Screenshot ou visuel du serveur (stocké sur RustFS).',
style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 12),
Row(
children: [
FilledButton.tonalIcon(
onPressed: _imgBusy ? null : _pickImage,
icon: _imgBusy
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.upload),
label: Text(_image == null ? 'Importer' : 'Remplacer'),
),
const SizedBox(width: 8),
if (_image != null)
OutlinedButton.icon(
onPressed: _imgBusy ? null : _deleteImage,
icon: const Icon(Icons.delete_outline),
label: const Text('Supprimer'),
),
],
),
],
),
),
);
}
}
class _Pill extends StatelessWidget {
const _Pill({required this.label, required this.color});
final String label;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: color.withOpacity(0.18),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: color),
),
child: Text(label, style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.w700)),
);
}
}
+8
View File
@@ -5,6 +5,7 @@ import '../services/api_service.dart';
import '../services/auth_service.dart';
import '../services/update_service.dart';
import '../widgets/server_card.dart';
import 'server_detail_screen.dart';
import 'settings_screen.dart';
class ServersScreen extends StatefulWidget {
@@ -110,6 +111,12 @@ class _ServersScreenState extends State<ServersScreen> {
}
}
Future<void> _openDetail(GameServer s) async {
await Navigator.push(context,
MaterialPageRoute(builder: (_) => ServerDetailScreen(server: s, api: widget.api)));
_refresh();
}
Future<void> _openSettings() async {
await Navigator.push(context,
MaterialPageRoute(builder: (_) => SettingsScreen(api: widget.api)));
@@ -191,6 +198,7 @@ class _ServersScreenState extends State<ServersScreen> {
iconUrl: snap.data ?? '',
busy: _busy.contains(s.id),
onToggle: () => _toggle(s),
onTap: () => _openDetail(s),
),
),
);
+45
View File
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
@@ -36,6 +37,14 @@ class ApiService {
return list.map((e) => GameServer.fromJson(e as Map<String, dynamic>)).toList();
}
Future<GameServer> server(String id) async {
final base = await baseUrl;
final res = await http.get(Uri.parse('$base/api/servers/$id'), headers: await _headers());
if (res.statusCode == 401) throw UnauthorizedException();
if (res.statusCode != 200) throw ApiException('Erreur ${res.statusCode}');
return GameServer.fromJson(jsonDecode(res.body) as Map<String, dynamic>);
}
Future<void> start(String id) => _action(id, 'start');
Future<void> stop(String id) => _action(id, 'stop');
@@ -50,6 +59,42 @@ class ApiService {
}
Future<String> iconUrl(String id) async => '${await baseUrl}/api/servers/$id/icon';
/// Stats d'utilisation du conteneur (CPU/RAM/réseau). `{running:false}` si éteint.
Future<Map<String, dynamic>> stats(String id) async {
final base = await baseUrl;
final res = await http.get(Uri.parse('$base/api/servers/$id/stats'), headers: await _headers());
if (res.statusCode == 401) throw UnauthorizedException();
if (res.statusCode != 200) throw ApiException('Erreur ${res.statusCode}');
return jsonDecode(res.body) as Map<String, dynamic>;
}
/// Image importée du serveur (bytes), null si aucune.
Future<Uint8List?> imageBytes(String id) async {
final base = await baseUrl;
final res = await http.get(Uri.parse('$base/api/servers/$id/image'), headers: await _headers());
if (res.statusCode == 401) throw UnauthorizedException();
if (res.statusCode != 200) return null;
return res.bodyBytes;
}
Future<void> uploadImage(String id, String filePath) async {
final base = await baseUrl;
final token = await _auth.validToken();
final req = http.MultipartRequest('POST', Uri.parse('$base/api/servers/$id/image'))
..headers['Authorization'] = 'Bearer ${token ?? ''}'
..files.add(await http.MultipartFile.fromPath('file', filePath));
final res = await http.Response.fromStream(await req.send());
if (res.statusCode == 401) throw UnauthorizedException();
if (res.statusCode >= 400) throw ApiException('Envoi refusé (${res.statusCode})');
}
Future<void> deleteImage(String id) async {
final base = await baseUrl;
final res = await http.delete(Uri.parse('$base/api/servers/$id/image'), headers: await _headers());
if (res.statusCode == 401) throw UnauthorizedException();
if (res.statusCode >= 400) throw ApiException('Suppression refusée (${res.statusCode})');
}
}
class UnauthorizedException implements Exception {}
+7 -1
View File
@@ -10,19 +10,24 @@ class ServerCard extends StatelessWidget {
required this.iconUrl,
required this.busy,
required this.onToggle,
this.onTap,
});
final GameServer server;
final String iconUrl;
final bool busy;
final VoidCallback onToggle;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final t = server.theme;
final badge = server.statusBadge;
return Card(
child: Container(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
@@ -101,6 +106,7 @@ class ServerCard extends StatelessWidget {
],
),
),
),
);
}
}