481 lines
16 KiB
Dart
481 lines
16 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import '../../../../core/config/api_endpoints.dart';
|
||
import '../../../../core/network/dio_client.dart';
|
||
import '../../../../core/theme/app_colors.dart';
|
||
import '../../../../core/widgets/empty_state.dart';
|
||
import '../../../../core/widgets/status_badge.dart';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Provider – fetch servers from inventory API
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Fetches the full list of servers from the backend.
|
||
final serversProvider =
|
||
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 [];
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Servers page – ConsumerStatefulWidget (needs local filter state)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
class ServersPage extends ConsumerStatefulWidget {
|
||
const ServersPage({super.key});
|
||
|
||
@override
|
||
ConsumerState<ServersPage> createState() => _ServersPageState();
|
||
}
|
||
|
||
class _ServersPageState extends ConsumerState<ServersPage> {
|
||
String _envFilter = 'all';
|
||
|
||
static const _environments = ['all', 'dev', 'staging', 'prod'];
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final serversAsync = ref.watch(serversProvider);
|
||
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: const Text('服务器'),
|
||
actions: [
|
||
IconButton(
|
||
icon: const Icon(Icons.refresh),
|
||
onPressed: () => ref.invalidate(serversProvider),
|
||
),
|
||
],
|
||
),
|
||
body: RefreshIndicator(
|
||
onRefresh: () async => ref.invalidate(serversProvider),
|
||
child: Column(
|
||
children: [
|
||
// Environment filter chips
|
||
_buildFilterBar(),
|
||
// Server list
|
||
Expanded(
|
||
child: serversAsync.when(
|
||
data: (servers) {
|
||
final filtered = _filterServers(servers);
|
||
if (filtered.isEmpty) {
|
||
return const EmptyState(
|
||
icon: Icons.dns_outlined,
|
||
title: '未找到服务器',
|
||
subtitle: '没有匹配当前筛选条件的服务器',
|
||
);
|
||
}
|
||
return ListView.builder(
|
||
padding: const EdgeInsets.all(12),
|
||
itemCount: filtered.length,
|
||
itemBuilder: (context, index) {
|
||
final server = filtered[index];
|
||
return _ServerCard(
|
||
server: server,
|
||
onTap: () => _showServerDetails(context, server),
|
||
);
|
||
},
|
||
);
|
||
},
|
||
loading: () =>
|
||
const Center(child: CircularProgressIndicator()),
|
||
error: (e, _) => Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(32),
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
const Icon(Icons.error_outline,
|
||
color: AppColors.error, size: 48),
|
||
const SizedBox(height: 12),
|
||
const Text(
|
||
'加载服务器失败',
|
||
style: TextStyle(
|
||
fontSize: 16, fontWeight: FontWeight.w600),
|
||
),
|
||
const SizedBox(height: 6),
|
||
Text(
|
||
e.toString(),
|
||
style: const TextStyle(
|
||
color: AppColors.textSecondary, fontSize: 13),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
const SizedBox(height: 16),
|
||
OutlinedButton.icon(
|
||
onPressed: () => ref.invalidate(serversProvider),
|
||
icon: const Icon(Icons.refresh),
|
||
label: const Text('重试'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ---- Environment filter bar -----------------------------------------------
|
||
|
||
Widget _buildFilterBar() {
|
||
return SingleChildScrollView(
|
||
scrollDirection: Axis.horizontal,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
child: Row(
|
||
children: _environments.map((env) {
|
||
final selected = _envFilter == env;
|
||
return Padding(
|
||
padding: const EdgeInsets.only(right: 8),
|
||
child: FilterChip(
|
||
label: Text(env == 'all' ? '全部' : env),
|
||
selected: selected,
|
||
onSelected: (_) => setState(() => _envFilter = env),
|
||
selectedColor: _envColor(env).withOpacity(0.25),
|
||
checkmarkColor: _envColor(env),
|
||
labelStyle: TextStyle(
|
||
color: selected ? _envColor(env) : AppColors.textSecondary,
|
||
fontWeight: selected ? FontWeight.w600 : FontWeight.normal,
|
||
),
|
||
side: BorderSide(
|
||
color: selected
|
||
? _envColor(env).withOpacity(0.5)
|
||
: AppColors.surfaceLight,
|
||
),
|
||
backgroundColor: AppColors.surface,
|
||
),
|
||
);
|
||
}).toList(),
|
||
),
|
||
);
|
||
}
|
||
|
||
List<Map<String, dynamic>> _filterServers(
|
||
List<Map<String, dynamic>> servers) {
|
||
if (_envFilter == 'all') return servers;
|
||
return servers.where((s) {
|
||
final env = (s['environment'] as String? ?? '').toLowerCase();
|
||
return env == _envFilter;
|
||
}).toList();
|
||
}
|
||
|
||
// ---- Server details bottom sheet ------------------------------------------
|
||
|
||
void _showServerDetails(
|
||
BuildContext context, Map<String, dynamic> server) {
|
||
final hostname =
|
||
server['hostname'] as String? ?? server['name'] as String? ?? '未知';
|
||
final ip = server['ip_address'] as String? ??
|
||
server['ipAddress'] as String? ??
|
||
server['ip'] as String? ??
|
||
'N/A';
|
||
final env = server['environment'] as String? ?? 'unknown';
|
||
final status = server['status'] as String? ?? 'unknown';
|
||
final role = server['role'] as String? ?? '';
|
||
final os = server['os'] as String? ??
|
||
server['operating_system'] as String? ??
|
||
'';
|
||
final cpu = server['cpu'] as String? ?? server['cpu_cores']?.toString() ?? '';
|
||
final memory =
|
||
server['memory'] as String? ?? server['memory_gb']?.toString() ?? '';
|
||
final region = server['region'] as String? ?? '';
|
||
final provider = server['provider'] as String? ??
|
||
server['cloud_provider'] as String? ??
|
||
'';
|
||
final createdAt =
|
||
server['created_at'] as String? ?? server['createdAt'] as String? ?? '';
|
||
|
||
showModalBottomSheet(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
backgroundColor: AppColors.surface,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||
),
|
||
builder: (context) => DraggableScrollableSheet(
|
||
initialChildSize: 0.55,
|
||
minChildSize: 0.3,
|
||
maxChildSize: 0.85,
|
||
expand: false,
|
||
builder: (context, scrollController) => SingleChildScrollView(
|
||
controller: scrollController,
|
||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 32),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// Handle bar
|
||
Center(
|
||
child: Container(
|
||
width: 40,
|
||
height: 4,
|
||
decoration: BoxDecoration(
|
||
color: AppColors.textMuted,
|
||
borderRadius: BorderRadius.circular(2),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
|
||
// Hostname + status
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
hostname,
|
||
style: const TextStyle(
|
||
fontSize: 20, fontWeight: FontWeight.bold),
|
||
),
|
||
),
|
||
StatusBadge(status: status),
|
||
],
|
||
),
|
||
const SizedBox(height: 16),
|
||
|
||
// Environment + Role row
|
||
Row(
|
||
children: [
|
||
_EnvironmentTag(environment: env),
|
||
if (role.isNotEmpty) ...[
|
||
const SizedBox(width: 8),
|
||
StatusBadge(status: role, color: AppColors.info),
|
||
],
|
||
],
|
||
),
|
||
const SizedBox(height: 20),
|
||
|
||
// Detail rows
|
||
_DetailRow(label: 'IP 地址', value: ip),
|
||
if (os.isNotEmpty) _DetailRow(label: '操作系统', value: os),
|
||
if (cpu.isNotEmpty) _DetailRow(label: 'CPU', value: cpu),
|
||
if (memory.isNotEmpty)
|
||
_DetailRow(label: '内存', value: memory),
|
||
if (region.isNotEmpty)
|
||
_DetailRow(label: '区域', value: region),
|
||
if (provider.isNotEmpty)
|
||
_DetailRow(label: '云厂商', value: provider),
|
||
if (createdAt.isNotEmpty)
|
||
_DetailRow(label: '创建时间', value: createdAt),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ---- Helpers --------------------------------------------------------------
|
||
|
||
static Color _envColor(String env) {
|
||
switch (env.toLowerCase()) {
|
||
case 'dev':
|
||
return AppColors.info;
|
||
case 'staging':
|
||
return AppColors.warning;
|
||
case 'prod':
|
||
return AppColors.error;
|
||
default:
|
||
return AppColors.primary;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Server card widget
|
||
// ---------------------------------------------------------------------------
|
||
|
||
class _ServerCard extends StatelessWidget {
|
||
final Map<String, dynamic> server;
|
||
final VoidCallback onTap;
|
||
|
||
const _ServerCard({required this.server, required this.onTap});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final hostname =
|
||
server['hostname'] as String? ?? server['name'] as String? ?? '未知';
|
||
final ip = server['ip_address'] as String? ??
|
||
server['ipAddress'] as String? ??
|
||
server['ip'] as String? ??
|
||
'N/A';
|
||
final env = server['environment'] as String? ?? 'unknown';
|
||
final status = server['status'] as String? ?? 'unknown';
|
||
final role = server['role'] as String? ?? '';
|
||
final isOnline = _isOnline(status);
|
||
|
||
return Card(
|
||
color: AppColors.surface,
|
||
margin: const EdgeInsets.only(bottom: 10),
|
||
child: InkWell(
|
||
onTap: onTap,
|
||
borderRadius: BorderRadius.circular(12),
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(14),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// Hostname + status dot
|
||
Row(
|
||
children: [
|
||
// Status indicator dot
|
||
Container(
|
||
width: 10,
|
||
height: 10,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: isOnline ? AppColors.success : AppColors.error,
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: (isOnline ? AppColors.success : AppColors.error)
|
||
.withOpacity(0.4),
|
||
blurRadius: 6,
|
||
spreadRadius: 1,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Text(
|
||
hostname,
|
||
style: const TextStyle(
|
||
fontSize: 15, fontWeight: FontWeight.w600),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
_EnvironmentTag(environment: env),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
|
||
// IP + role row
|
||
Row(
|
||
children: [
|
||
const Icon(Icons.lan_outlined,
|
||
size: 14, color: AppColors.textMuted),
|
||
const SizedBox(width: 4),
|
||
Text(
|
||
ip,
|
||
style: const TextStyle(
|
||
color: AppColors.textSecondary, fontSize: 13),
|
||
),
|
||
if (role.isNotEmpty) ...[
|
||
const Spacer(),
|
||
Icon(Icons.workspaces_outline,
|
||
size: 14, color: AppColors.textMuted),
|
||
const SizedBox(width: 4),
|
||
Text(
|
||
role,
|
||
style: const TextStyle(
|
||
color: AppColors.textSecondary, fontSize: 13),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
bool _isOnline(String status) {
|
||
final lower = status.toLowerCase();
|
||
return lower == 'online' ||
|
||
lower == 'running' ||
|
||
lower == 'active' ||
|
||
lower == 'healthy';
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Environment tag chip
|
||
// ---------------------------------------------------------------------------
|
||
|
||
class _EnvironmentTag extends StatelessWidget {
|
||
final String environment;
|
||
const _EnvironmentTag({required this.environment});
|
||
|
||
Color get _color {
|
||
switch (environment.toLowerCase()) {
|
||
case 'dev':
|
||
return AppColors.info;
|
||
case 'staging':
|
||
return AppColors.warning;
|
||
case 'prod':
|
||
return AppColors.error;
|
||
default:
|
||
return AppColors.textMuted;
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: _color.withOpacity(0.15),
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(color: _color.withOpacity(0.3)),
|
||
),
|
||
child: Text(
|
||
environment.toUpperCase(),
|
||
style: TextStyle(
|
||
color: _color,
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Detail row for bottom sheet
|
||
// ---------------------------------------------------------------------------
|
||
|
||
class _DetailRow extends StatelessWidget {
|
||
final String label;
|
||
final String value;
|
||
const _DetailRow({required this.label, required this.value});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 12),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
SizedBox(
|
||
width: 100,
|
||
child: Text(
|
||
label,
|
||
style: const TextStyle(
|
||
color: AppColors.textMuted,
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w500),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: Text(
|
||
value,
|
||
style: const TextStyle(
|
||
color: AppColors.textPrimary, fontSize: 13),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|