feat: surface admin load errors

This commit is contained in:
Artem Kokos
2026-04-23 20:11:37 +07:00
parent 5d2d0ac4a7
commit 90a86e932d
4 changed files with 516 additions and 191 deletions

View File

@@ -622,36 +622,42 @@ class ScenesNotifier extends Notifier<List<dynamic>> {
// ─── Расписания ──────────────────────────────────────────────
final tasksProvider = NotifierProvider<TasksNotifier, List<dynamic>>(
final tasksProvider = NotifierProvider<TasksNotifier, LoadState<List<dynamic>>>(
() => TasksNotifier(),
);
class TasksNotifier extends Notifier<List<dynamic>> {
class TasksNotifier extends Notifier<LoadState<List<dynamic>>> {
@override
List<dynamic> build() => [];
LoadState<List<dynamic>> build() => const LoadState.idle(<dynamic>[]);
Future<void> load() async {
state = LoadState.loading(state.data);
try {
final api = ref.read(apiProvider);
final res = await api.getTasks();
final data = res.data;
late final List<dynamic> tasks;
if (data is List) {
state = data;
tasks = List<dynamic>.from(data);
} else if (data is Map) {
state = data['tasks'] ?? data['data'] ?? data.values.toList();
final value = data['tasks'] ?? data['data'] ?? data.values.toList();
if (value is! List) {
throw FormatException('tasks должен быть списком расписаний');
}
tasks = List<dynamic>.from(value);
} else {
throw FormatException('tasks должен быть списком расписаний');
}
state = tasks.isEmpty ? LoadState.empty(tasks) : LoadState.data(tasks);
} catch (e) {
debugPrint("Ошибка загрузки расписаний: $e");
state = LoadState.error(state.data, describeLoadError(e));
}
}
Future<void> cancel(String jobId) async {
try {
await ref.read(apiProvider).cancelTask(jobId);
await load();
} catch (e) {
debugPrint("Ошибка отмены задачи: $e");
}
}
/// Создать одноразовый таймер
@@ -765,60 +771,61 @@ class EventLogNotifier extends Notifier<LoadState<List<dynamic>>> {
// ─── API-ключи ───────────────────────────────────────────────
final apiKeysProvider = NotifierProvider<ApiKeysNotifier, List<dynamic>>(
final apiKeysProvider =
NotifierProvider<ApiKeysNotifier, LoadState<List<dynamic>>>(
() => ApiKeysNotifier(),
);
class ApiKeysNotifier extends Notifier<List<dynamic>> {
class ApiKeysNotifier extends Notifier<LoadState<List<dynamic>>> {
@override
List<dynamic> build() => [];
LoadState<List<dynamic>> build() => const LoadState.idle(<dynamic>[]);
Future<void> load() async {
state = LoadState.loading(state.data);
try {
final api = ref.read(apiProvider);
final res = await api.getApiKeys();
final data = res.data;
late final List<dynamic> keys;
if (data is List) {
state = data;
keys = List<dynamic>.from(data);
} else if (data is Map) {
state = data['data'] ?? data['keys'] ?? data.values.toList();
final value = data['data'] ?? data['keys'] ?? data.values.toList();
if (value is! List) {
throw FormatException('api-keys должен быть списком ключей');
}
keys = List<dynamic>.from(value);
} else {
throw FormatException('api-keys должен быть списком ключей');
}
state = keys.isEmpty ? LoadState.empty(keys) : LoadState.data(keys);
} catch (e) {
debugPrint("Ошибка загрузки API-ключей: $e");
state = LoadState.error(state.data, describeLoadError(e));
}
}
Future<String?> create(String name, {bool isAdmin = false}) async {
try {
Future<String> create(String name, {bool isAdmin = false}) async {
final api = ref.read(apiProvider);
final res = await api.createApiKey(name, isAdmin: isAdmin);
await load();
if (res.data is Map) {
return res.data['key']?.toString();
final key = res.data['key']?.toString();
if (key != null && key.isNotEmpty) {
return key;
}
return null;
} catch (e) {
debugPrint("Ошибка создания ключа: $e");
return null;
}
throw const FormatException('backend не вернул созданный API key');
}
Future<void> revoke(String key) async {
try {
await ref.read(apiProvider).revokeApiKey(key);
await load();
} catch (e) {
debugPrint("Ошибка отзыва ключа: $e");
}
}
Future<void> activate(String key) async {
try {
await ref.read(apiProvider).activateApiKey(key);
await load();
} catch (e) {
debugPrint("Ошибка активации ключа: $e");
}
}
}

View File

@@ -1,7 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../app/error_message.dart';
import '../app/load_state.dart';
import '../providers/providers.dart';
import '../widgets/load_error_view.dart';
/// Экран управления гостевыми API-ключами.
/// Доступен только администраторам.
@@ -13,7 +16,6 @@ class ApiKeysScreen extends ConsumerStatefulWidget {
}
class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
bool _loading = true;
String? _lastCreatedKey;
@override
@@ -24,98 +26,85 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
Future<void> _load() async {
await ref.read(apiKeysProvider.notifier).load();
if (mounted) setState(() => _loading = false);
}
@override
Widget build(BuildContext context) {
final keys = ref.watch(apiKeysProvider);
final keysState = ref.watch(apiKeysProvider);
final keys = keysState.data;
return Scaffold(
appBar: AppBar(title: const Text('API-КЛЮЧИ')),
body: _loading
? const Center(
child: CircularProgressIndicator(color: Colors.deepOrange),
)
: Column(
body: Column(
children: [
// ─── Последний созданный ключ (для копирования) ───
if (_lastCreatedKey != null)
Container(
margin: const EdgeInsets.all(12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.deepOrange.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.deepOrange.withValues(alpha: 0.3),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Новый ключ создан! Скопируйте его сейчас:',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: Colors.deepOrange,
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Text(
_lastCreatedKey!,
style: const TextStyle(
fontSize: 12,
fontFamily: 'monospace',
color: Colors.white70,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(Icons.copy, size: 20),
onPressed: () {
Clipboard.setData(
ClipboardData(text: _lastCreatedKey!),
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Ключ скопирован'),
duration: Duration(seconds: 1),
),
);
},
),
],
),
_LastCreatedKeyBanner(keyValue: _lastCreatedKey!),
Expanded(child: _buildContent(keysState, keys)),
],
),
floatingActionButton: FloatingActionButton(
backgroundColor: Colors.deepOrange,
onPressed: () => _showCreateDialog(context),
child: const Icon(Icons.add),
),
);
}
// ─── Список ключей ───
Expanded(
child: keys.isEmpty
? const Center(
Widget _buildContent(LoadState<List<dynamic>> keysState, List<dynamic> keys) {
if ((keysState.isIdle || keysState.isLoading) && keys.isEmpty) {
return const Center(
child: CircularProgressIndicator(color: Colors.deepOrange),
);
}
if (keysState.hasError && keys.isEmpty) {
return LoadErrorView(
title: 'Не удалось загрузить API-ключи',
message: keysState.errorMessage,
icon: Icons.vpn_key,
onRetry: _load,
);
}
if (keys.isEmpty) {
return const Center(
child: Text(
'Нет гостевых ключей',
style: TextStyle(color: Colors.white54),
),
)
: RefreshIndicator(
);
}
final hasStatusHeader = keysState.isLoading || keysState.hasError;
final statusHeaderCount = hasStatusHeader ? 1 : 0;
return RefreshIndicator(
color: Colors.deepOrange,
onRefresh: _load,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(12),
itemCount: keys.length,
itemCount: keys.length + statusHeaderCount,
itemBuilder: (context, index) {
final k = keys[index];
final map = k is Map
? Map<String, dynamic>.from(k)
if (hasStatusHeader && index == 0) {
if (keysState.isLoading) {
return const Padding(
padding: EdgeInsets.only(bottom: 12),
child: LinearProgressIndicator(color: Colors.deepOrange),
);
}
return LoadErrorBanner(
title: 'Не удалось обновить API-ключи',
message: keysState.errorMessage,
onRetry: _load,
);
}
final keyIndex = index - statusHeaderCount;
final key = keys[keyIndex];
final map = key is Map
? Map<String, dynamic>.from(key)
: <String, dynamic>{};
return _ApiKeyCard(
data: map,
@@ -124,15 +113,6 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
);
},
),
),
),
],
),
floatingActionButton: FloatingActionButton(
backgroundColor: Colors.deepOrange,
onPressed: () => _showCreateDialog(context),
child: const Icon(Icons.add),
),
);
}
@@ -179,11 +159,22 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
final name = nameCtrl.text.trim();
if (name.isEmpty) return;
Navigator.of(ctx).pop();
final messenger = ScaffoldMessenger.of(context);
try {
final key = await ref
.read(apiKeysProvider.notifier)
.create(name, isAdmin: isAdmin);
if (key != null && mounted) {
if (!mounted) return;
setState(() => _lastCreatedKey = key);
} catch (e) {
if (!mounted) return;
messenger.showSnackBar(
SnackBar(
content: Text(
'Ошибка создания ключа: ${describeLoadError(e)}',
),
),
);
}
},
child: const Text('Создать'),
@@ -218,12 +209,22 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
),
);
if (confirmed == true) {
try {
await ref.read(apiKeysProvider.notifier).revoke(key);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка отзыва ключа: ${describeLoadError(e)}'),
),
);
}
}
}
Future<void> _activateKey(Map<String, dynamic> data) async {
final key = (data['key'] ?? data['token'] ?? '').toString();
try {
await ref.read(apiKeysProvider.notifier).activate(key);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -233,6 +234,75 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
),
);
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка активации ключа: ${describeLoadError(e)}'),
),
);
}
}
}
class _LastCreatedKeyBanner extends StatelessWidget {
final String keyValue;
const _LastCreatedKeyBanner({required this.keyValue});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.all(12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.deepOrange.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.deepOrange.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Новый ключ создан! Скопируйте его сейчас:',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: Colors.deepOrange,
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Text(
keyValue,
style: const TextStyle(
fontSize: 12,
fontFamily: 'monospace',
color: Colors.white70,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(Icons.copy, size: 20),
onPressed: () {
Clipboard.setData(ClipboardData(text: keyValue));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Ключ скопирован'),
duration: Duration(seconds: 1),
),
);
},
),
],
),
],
),
);
}
}

View File

@@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../app/error_message.dart';
import '../app/load_state.dart';
import '../providers/providers.dart';
import '../widgets/load_error_view.dart';
/// Экран управления расписаниями.
/// Показывает все задачи (one-shot и cron), позволяет создавать и удалять.
@@ -12,8 +15,6 @@ class SchedulesScreen extends ConsumerStatefulWidget {
}
class _SchedulesScreenState extends ConsumerState<SchedulesScreen> {
bool _loading = true;
@override
void initState() {
super.initState();
@@ -22,45 +23,16 @@ class _SchedulesScreenState extends ConsumerState<SchedulesScreen> {
Future<void> _load() async {
await ref.read(tasksProvider.notifier).load();
if (mounted) setState(() => _loading = false);
}
@override
Widget build(BuildContext context) {
final tasks = ref.watch(tasksProvider);
final tasksState = ref.watch(tasksProvider);
final tasks = tasksState.data;
return Scaffold(
appBar: AppBar(title: const Text('РАСПИСАНИЯ')),
body: _loading
? const Center(
child: CircularProgressIndicator(color: Colors.deepOrange),
)
: tasks.isEmpty
? const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.schedule, size: 64, color: Colors.white24),
SizedBox(height: 16),
Text(
'Нет активных расписаний',
style: TextStyle(color: Colors.white54, fontSize: 16),
),
],
),
)
: RefreshIndicator(
color: Colors.deepOrange,
onRefresh: () => ref.read(tasksProvider.notifier).load(),
child: ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: tasks.length,
itemBuilder: (context, index) {
final task = tasks[index];
return _TaskCard(task: task);
},
),
),
body: _buildContent(tasksState, tasks),
floatingActionButton: FloatingActionButton(
backgroundColor: Colors.deepOrange,
onPressed: () => _showAddDialog(context),
@@ -81,6 +53,74 @@ class _SchedulesScreenState extends ConsumerState<SchedulesScreen> {
builder: (ctx) => const _AddScheduleSheet(),
);
}
Widget _buildContent(
LoadState<List<dynamic>> tasksState,
List<dynamic> tasks,
) {
if ((tasksState.isIdle || tasksState.isLoading) && tasks.isEmpty) {
return const Center(
child: CircularProgressIndicator(color: Colors.deepOrange),
);
}
if (tasksState.hasError && tasks.isEmpty) {
return LoadErrorView(
title: 'Не удалось загрузить расписания',
message: tasksState.errorMessage,
icon: Icons.schedule,
onRetry: _load,
);
}
if (tasks.isEmpty) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.schedule, size: 64, color: Colors.white24),
SizedBox(height: 16),
Text(
'Нет активных расписаний',
style: TextStyle(color: Colors.white54, fontSize: 16),
),
],
),
);
}
final hasStatusHeader = tasksState.isLoading || tasksState.hasError;
final statusHeaderCount = hasStatusHeader ? 1 : 0;
return RefreshIndicator(
color: Colors.deepOrange,
onRefresh: _load,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(12),
itemCount: tasks.length + statusHeaderCount,
itemBuilder: (context, index) {
if (hasStatusHeader && index == 0) {
if (tasksState.isLoading) {
return const Padding(
padding: EdgeInsets.only(bottom: 12),
child: LinearProgressIndicator(color: Colors.deepOrange),
);
}
return LoadErrorBanner(
title: 'Не удалось обновить расписания',
message: tasksState.errorMessage,
onRetry: _load,
);
}
final taskIndex = index - statusHeaderCount;
return _TaskCard(task: tasks[taskIndex]);
},
),
);
}
}
/// Карточка одной задачи расписания
@@ -151,9 +191,20 @@ class _TaskCard extends ConsumerWidget {
child: const Text('Нет'),
),
TextButton(
onPressed: () {
onPressed: () async {
Navigator.of(ctx).pop();
ref.read(tasksProvider.notifier).cancel(jobId);
try {
await ref.read(tasksProvider.notifier).cancel(jobId);
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Ошибка отмены задачи: ${describeLoadError(e)}',
),
),
);
}
},
child: const Text('Да', style: TextStyle(color: Colors.redAccent)),
),
@@ -363,9 +414,9 @@ class _AddScheduleSheetState extends ConsumerState<_AddScheduleSheet> {
if (mounted) Navigator.of(context).pop();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Ошибка: $e')));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка: ${describeLoadError(e)}')),
);
}
}
}

View File

@@ -6,14 +6,48 @@ import 'package:ignis_app/providers/providers.dart';
import 'package:ignis_app/services/api_client.dart';
class FakeIgnisApi extends IgnisApi {
Object? tasksData;
Object? statsData;
Object? eventLogData;
Object? apiKeysData;
Object? tasksError;
Object? statsError;
Object? eventLogError;
Object? apiKeysError;
Object? cancelTaskError;
Object? revokeApiKeyError;
int? requestedDays;
int? requestedLimit;
String? cancelledJobId;
String? revokedApiKey;
FakeIgnisApi({this.statsData, this.eventLogData});
FakeIgnisApi({
this.tasksData,
this.statsData,
this.eventLogData,
this.apiKeysData,
});
@override
Future<Response> getTasks() async {
final error = tasksError;
if (error != null) throw error;
return Response(
requestOptions: RequestOptions(path: '/schedules/tasks'),
data: tasksData,
);
}
@override
Future<Response> cancelTask(String jobId) async {
cancelledJobId = jobId;
final error = cancelTaskError;
if (error != null) throw error;
return Response(
requestOptions: RequestOptions(path: '/schedules/$jobId'),
data: <String, dynamic>{'ok': true},
);
}
@override
Future<Response> getStatsSummary({int days = 7}) async {
@@ -36,6 +70,27 @@ class FakeIgnisApi extends IgnisApi {
data: eventLogData,
);
}
@override
Future<Response> getApiKeys() async {
final error = apiKeysError;
if (error != null) throw error;
return Response(
requestOptions: RequestOptions(path: '/api-keys'),
data: apiKeysData,
);
}
@override
Future<Response> revokeApiKey(String key) async {
revokedApiKey = key;
final error = revokeApiKeyError;
if (error != null) throw error;
return Response(
requestOptions: RequestOptions(path: '/api-keys/revoke'),
data: <String, dynamic>{'ok': true},
);
}
}
void main() {
@@ -65,6 +120,57 @@ void main() {
expect(api.requestedDays, 14);
});
test('tasks load exposes data state', () async {
final api = FakeIgnisApi(
tasksData: {
'tasks': [
{'id': 'job-1', 'target_id': 'kitchen'},
],
},
);
final container = containerWith(api);
await container.read(tasksProvider.notifier).load();
final state = container.read(tasksProvider);
expect(state.status, LoadStatus.data);
expect(state.data, hasLength(1));
});
test('tasks load exposes empty state', () async {
final api = FakeIgnisApi(tasksData: {'tasks': <Object>[]});
final container = containerWith(api);
await container.read(tasksProvider.notifier).load();
final state = container.read(tasksProvider);
expect(state.status, LoadStatus.empty);
expect(state.data, isEmpty);
});
test('tasks load error exposes message', () async {
final api = FakeIgnisApi(
tasksData: [
{'id': 'job-1'},
],
);
final container = containerWith(api);
await container.read(tasksProvider.notifier).load();
api.tasksError = DioException(
requestOptions: RequestOptions(path: '/schedules/tasks'),
type: DioExceptionType.connectionError,
message: 'No route to host',
);
await container.read(tasksProvider.notifier).load();
final state = container.read(tasksProvider);
expect(state.status, LoadStatus.error);
expect(state.data, hasLength(1));
expect(state.errorMessage, contains('Backend недоступен'));
});
test('stats load exposes empty state for empty groups', () async {
final api = FakeIgnisApi(statsData: {'groups': <Object>[]});
final container = containerWith(api);
@@ -94,6 +200,57 @@ void main() {
expect(api.requestedLimit, 50);
});
test('api keys load exposes data state', () async {
final api = FakeIgnisApi(
apiKeysData: {
'keys': [
{'name': 'guest', 'key': 'secret'},
],
},
);
final container = containerWith(api);
await container.read(apiKeysProvider.notifier).load();
final state = container.read(apiKeysProvider);
expect(state.status, LoadStatus.data);
expect(state.data, hasLength(1));
});
test('api keys load exposes empty state', () async {
final api = FakeIgnisApi(apiKeysData: {'keys': <Object>[]});
final container = containerWith(api);
await container.read(apiKeysProvider.notifier).load();
final state = container.read(apiKeysProvider);
expect(state.status, LoadStatus.empty);
expect(state.data, isEmpty);
});
test('api keys load error exposes message', () async {
final api = FakeIgnisApi(
apiKeysData: [
{'name': 'guest'},
],
);
final container = containerWith(api);
await container.read(apiKeysProvider.notifier).load();
api.apiKeysError = DioException(
requestOptions: RequestOptions(path: '/api-keys'),
type: DioExceptionType.connectionError,
message: 'No route to host',
);
await container.read(apiKeysProvider.notifier).load();
final state = container.read(apiKeysProvider);
expect(state.status, LoadStatus.error);
expect(state.data, hasLength(1));
expect(state.errorMessage, contains('Backend недоступен'));
});
test('load error keeps previous stats data and exposes message', () async {
final api = FakeIgnisApi(
statsData: {
@@ -118,4 +275,44 @@ void main() {
expect(state.data['groups'], hasLength(1));
expect(state.errorMessage, contains('Backend недоступен'));
});
test('task cancel error is not swallowed', () async {
final api = FakeIgnisApi(tasksData: <Object>[]);
final container = containerWith(api);
final error = DioException(
requestOptions: RequestOptions(path: '/schedules/job-1'),
type: DioExceptionType.badResponse,
response: Response(
requestOptions: RequestOptions(path: '/schedules/job-1'),
statusCode: 500,
),
);
api.cancelTaskError = error;
await expectLater(
container.read(tasksProvider.notifier).cancel('job-1'),
throwsA(same(error)),
);
expect(api.cancelledJobId, 'job-1');
});
test('api key revoke error is not swallowed', () async {
final api = FakeIgnisApi(apiKeysData: <Object>[]);
final container = containerWith(api);
final error = DioException(
requestOptions: RequestOptions(path: '/api-keys/revoke'),
type: DioExceptionType.badResponse,
response: Response(
requestOptions: RequestOptions(path: '/api-keys/revoke'),
statusCode: 500,
),
);
api.revokeApiKeyError = error;
await expectLater(
container.read(apiKeysProvider.notifier).revoke('secret'),
throwsA(same(error)),
);
expect(api.revokedApiKey, 'secret');
});
}