From 1235d6c75ea0cd3dd5b6de218b863282417c92b1 Mon Sep 17 00:00:00 2001 From: Juan Gilsanz Polo Date: Fri, 30 Sep 2022 23:33:57 +0200 Subject: [PATCH] Added logs screen and list --- lib/config/app_screens.dart | 8 ++ lib/functions/format_time.dart | 12 ++ lib/l10n/app_en.arb | 7 +- lib/l10n/app_es.arb | 7 +- lib/models/logs.dart | 216 ++++++++++++++++++++++++++++++++ lib/screens/logs/appbar.dart | 17 +++ lib/screens/logs/log_tile.dart | 139 ++++++++++++++++++++ lib/screens/logs/logs.dart | 189 ++++++++++++++++++++++++++++ lib/services/http_requests.dart | 70 +++++++++++ lib/widgets/bottom_nav_bar.dart | 3 + 10 files changed, 666 insertions(+), 2 deletions(-) create mode 100644 lib/functions/format_time.dart create mode 100644 lib/models/logs.dart create mode 100644 lib/screens/logs/appbar.dart create mode 100644 lib/screens/logs/log_tile.dart create mode 100644 lib/screens/logs/logs.dart diff --git a/lib/config/app_screens.dart b/lib/config/app_screens.dart index a73d5ff..06bc56b 100644 --- a/lib/config/app_screens.dart +++ b/lib/config/app_screens.dart @@ -4,7 +4,9 @@ import 'package:adguard_home_manager/screens/connect/fab.dart'; import 'package:adguard_home_manager/screens/home/appbar.dart'; import 'package:adguard_home_manager/screens/connect/appbar.dart'; import 'package:adguard_home_manager/screens/clients/fab.dart'; +import 'package:adguard_home_manager/screens/logs/logs.dart'; import 'package:adguard_home_manager/screens/connect/connect.dart'; +import 'package:adguard_home_manager/screens/logs/appbar.dart'; import 'package:adguard_home_manager/screens/home/home.dart'; import 'package:adguard_home_manager/screens/clients/clients.dart'; import 'package:adguard_home_manager/screens/settings/appbar.dart'; @@ -43,6 +45,12 @@ List screensServerConnected = [ body: Clients(), fab: ClientsFab() ), + const AppScreen( + name: "logs", + icon: Icons.list_alt_rounded, + body: Logs(), + appBar: LogsAppBar() + ), const AppScreen( name: "settings", icon: Icons.settings_rounded, diff --git a/lib/functions/format_time.dart b/lib/functions/format_time.dart new file mode 100644 index 0000000..3ee3b63 --- /dev/null +++ b/lib/functions/format_time.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +String formatTimestamp(DateTime timestamp, String format) { + DateFormat f = DateFormat(format); + return f.format(timestamp); +} + +String formatTimeOfDay(TimeOfDay timestamp, String format) { + DateFormat f = DateFormat(format); + return f.format(DateTime(0, 0, 0, timestamp.hour, timestamp.minute)); +} \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d05df4b..fca314e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -106,5 +106,10 @@ "advancedSetupDescription": "Advanced options", "settingsUpdatedSuccessfully": "Settings updated successfully.", "cannotUpdateSettings": "Settings cannot be updated.", - "restartAppTakeEffect": "Restart the application" + "restartAppTakeEffect": "Restart the application", + "loadingLogs": "Loading logs...", + "logsNotLoaded": "Logs list could not be loaded", + "processed": "Processed (no list)", + "blockedBlacklist": "Blocked (blacklist)", + "processedWhitelist": "Processed (whitelist)" } \ No newline at end of file diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 3bbd735..902bdcf 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -106,5 +106,10 @@ "advancedSetupDescription": "Opciones avanzadas", "settingsUpdatedSuccessfully": "Ajustes actualizados correctamente.", "cannotUpdateSettings": "No se han podido actualizar los ajustes.", - "restartAppTakeEffect": "Reinicia la aplicación para que se apliquen los cambios." + "restartAppTakeEffect": "Reinicia la aplicación para que se apliquen los cambios.", + "loadingLogs": "Cargando registros...", + "logsNotLoaded": "No se pudieron cargar los registros", + "processed": "Procesada (sin lista)", + "blockedBlacklist": "Bloqueada (lista negra)", + "processedWhitelist": "Procesada (lista blanca)" } \ No newline at end of file diff --git a/lib/models/logs.dart b/lib/models/logs.dart new file mode 100644 index 0000000..13f286b --- /dev/null +++ b/lib/models/logs.dart @@ -0,0 +1,216 @@ +import 'dart:convert'; + +class LogsList { + int loadStatus = 0; + LogsData? logsData; + + LogsList({ + required this.loadStatus, + this.logsData + }); +} + +LogsData logsFromJson(String str) => LogsData.fromJson(json.decode(str)); + +String logsToJson(LogsData data) => json.encode(data.toJson()); + +class LogsData { + List data; + final DateTime oldest; + + LogsData({ + required this.data, + required this.oldest, + }); + + factory LogsData.fromJson(Map json) => LogsData( + data: List.from(json["data"].map((x) => Log.fromJson(x))), + oldest: DateTime.parse(json["oldest"]), + ); + + Map toJson() => { + "data": List.from(data.map((x) => x.toJson())), + "oldest": oldest.toIso8601String(), + }; +} + +class Log { + final bool answerDnssec; + final bool cached; + final String client; + final ClientInfo clientInfo; + final String clientProto; + final String elapsedMs; + final Question question; + final String reason; + final List rules; + final String status; + final DateTime time; + final String upstream; + final List? answer; + final int? filterId; + final String? rule; + final List? originalAnswer; + + Log({ + required this.answerDnssec, + required this.cached, + required this.client, + required this.clientInfo, + required this.clientProto, + required this.elapsedMs, + required this.question, + required this.reason, + required this.rules, + required this.status, + required this.time, + required this.upstream, + this.answer, + this.filterId, + this.rule, + this.originalAnswer, + }); + + factory Log.fromJson(Map json) => Log( + answerDnssec: json["answer_dnssec"], + cached: json["cached"], + client: json["client"], + clientInfo: ClientInfo.fromJson(json["client_info"]), + clientProto: json["client_proto"], + elapsedMs: json["elapsedMs"], + question: Question.fromJson(json["question"]), + reason: json["reason"], + rules: List.from(json["rules"].map((x) => Rule.fromJson(x))), + status: json["status"], + time: DateTime.parse(json["time"]), + upstream: json["upstream"], + answer: json["answer"] == null ? null : List.from(json["answer"].map((x) => Answer.fromJson(x))), + filterId: json["filterId"], + rule: json["rule"], + originalAnswer: json["original_answer"] == null ? null : List.from(json["original_answer"].map((x) => Answer.fromJson(x))), + ); + + Map toJson() => { + "answer_dnssec": answerDnssec, + "cached": cached, + "client": client, + "client_info": clientInfo.toJson(), + "client_proto": clientProto, + "elapsedMs": elapsedMs, + "question": question.toJson(), + "reason":reason, + "rules": List.from(rules.map((x) => x.toJson())), + "status": status, + "time": time.toIso8601String(), + "upstream": upstream, + "answer": answer == null ? null : List.from(answer!.map((x) => x.toJson())), + "filterId": filterId, + "rule": rule, + "original_answer": originalAnswer == null ? null : List.from(originalAnswer!.map((x) => x.toJson())), + }; +} + +class Answer { + final String type; + final String value; + final int ttl; + + Answer({ + required this.type, + required this.value, + required this.ttl, + }); + + factory Answer.fromJson(Map json) => Answer( + type: json["type"], + value: json["value"], + ttl: json["ttl"], + ); + + Map toJson() => { + "type": type, + "value": value, + "ttl": ttl, + }; +} + +class ClientInfo { + final Whois whois; + final String name; + final String disallowedRule; + final bool disallowed; + + ClientInfo({ + required this.whois, + required this.name, + required this.disallowedRule, + required this.disallowed, + }); + + factory ClientInfo.fromJson(Map json) => ClientInfo( + whois: Whois.fromJson(json["whois"]), + name: json["name"], + disallowedRule: json["disallowed_rule"], + disallowed: json["disallowed"], + ); + + Map toJson() => { + "whois": whois.toJson(), + "name": name, + "disallowed_rule": disallowedRule, + "disallowed": disallowed, + }; +} + +class Whois { + Whois(); + + factory Whois.fromJson(Map json) => Whois(); + + Map toJson() => {}; +} + +class Question { + final String questionClass; + final String name; + final String type; + + Question({ + required this.questionClass, + required this.name, + required this.type, + }); + + factory Question.fromJson(Map json) => Question( + questionClass: json["class"], + name: json["name"], + type: json["type"], + ); + + Map toJson() => { + "class": questionClass, + "name": name, + "type": type, + }; +} + +class Rule { + final int filterListId; + final String text; + + Rule({ + required this.filterListId, + required this.text, + }); + + + factory Rule.fromJson(Map json) => Rule( + filterListId: json["filter_list_id"], + text: json["text"], + ); + + Map toJson() => { + "filter_list_id": filterListId, + "text": text, + }; +} \ No newline at end of file diff --git a/lib/screens/logs/appbar.dart b/lib/screens/logs/appbar.dart new file mode 100644 index 0000000..5a45546 --- /dev/null +++ b/lib/screens/logs/appbar.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class LogsAppBar extends StatelessWidget with PreferredSizeWidget { + const LogsAppBar({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return AppBar( + title: Text(AppLocalizations.of(context)!.logs), + centerTitle: true, + ); + } + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); +} \ No newline at end of file diff --git a/lib/screens/logs/log_tile.dart b/lib/screens/logs/log_tile.dart new file mode 100644 index 0000000..f073a78 --- /dev/null +++ b/lib/screens/logs/log_tile.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import 'package:adguard_home_manager/models/logs.dart'; +import 'package:adguard_home_manager/functions/format_time.dart'; + +class LogTile extends StatelessWidget { + final Log log; + final int length; + final int index; + + const LogTile({ + Key? key, + required this.log, + required this.length, + required this.index + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final width = MediaQuery.of(context).size.width; + + Widget logStatusWidget({ + required IconData icon, + required Color color, + required String text + }) { + return Row( + children: [ + Icon( + icon, + color: color, + size: 14, + ), + const SizedBox(width: 10), + Text( + text, + style: TextStyle( + color: color, + fontWeight: FontWeight.bold, + fontSize: 12 + ), + ) + ] + ); + } + + Widget generateLogStatus() { + switch (log.reason) { + case "NotFilteredNotFound": + return logStatusWidget( + icon: Icons.verified_user_rounded, + color: Colors.green, + text: AppLocalizations.of(context)!.processed, + ); + + case "FilteredBlackList": + return logStatusWidget( + icon: Icons.verified_user_rounded, + color: Colors.red, + text:AppLocalizations.of(context)!.blockedBlacklist, + ); + + case "NotFilteredWhiteList": + return logStatusWidget( + icon: Icons.verified_user_rounded, + color: Colors.green, + text: AppLocalizations.of(context)!.processedWhitelist, + ); + + default: + return logStatusWidget( + icon: Icons.shield_rounded, + color: Colors.grey, + text: "Unknwon" + ); + } + } + + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () => {}, + child: Container( + width: double.maxFinite, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + border: index < length + ? Border( + bottom: BorderSide( + color: Theme.of(context).dividerColor + ) + ) + : null + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + generateLogStatus(), + const SizedBox(height: 10), + SizedBox( + width: width-100, + child: Text( + log.question.name, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 16 + ), + ), + ), + const SizedBox(height: 10), + SizedBox( + width: width-100, + child: Text( + log.client, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.grey, + fontSize: 13 + ), + ), + ) + ], + ), + Text( + formatTimestamp(log.time, 'HH:mm:ss') + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/logs/logs.dart b/lib/screens/logs/logs.dart new file mode 100644 index 0000000..343c1e9 --- /dev/null +++ b/lib/screens/logs/logs.dart @@ -0,0 +1,189 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import 'package:adguard_home_manager/screens/logs/log_tile.dart'; + +import 'package:adguard_home_manager/models/app_log.dart'; +import 'package:adguard_home_manager/providers/app_config_provider.dart'; +import 'package:adguard_home_manager/services/http_requests.dart'; +import 'package:adguard_home_manager/models/logs.dart'; +import 'package:adguard_home_manager/models/server.dart'; +import 'package:adguard_home_manager/providers/servers_provider.dart'; + +class Logs extends StatelessWidget { + const Logs({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final serversProvider = Provider.of(context); + final appConfigProvider = Provider.of(context); + + return LogsWidget( + server: serversProvider.selectedServer!, + createLog: appConfigProvider.addLog + ); + } +} + +class LogsWidget extends StatefulWidget { + final Server server; + final void Function(AppLog) createLog; + + const LogsWidget({ + Key? key, + required this.server, + required this.createLog + }) : super(key: key); + + @override + State createState() => _LogsWidgetState(); +} + +class _LogsWidgetState extends State { + LogsList logsList = LogsList( + loadStatus: 0, + logsData: null + ); + + int itemsPerLoad = 100; + int offset = 0; + + late ScrollController scrollController; + + bool isLoadingMore = false; + + Future fetchLogs({ + int? inOffset, + bool? loadingMore + }) async { + int offst = inOffset ?? offset; + + if (loadingMore != null && loadingMore == true) { + setState(() => isLoadingMore = true); + } + + final result = await getLogs(server: widget.server, count: itemsPerLoad, offset: offst); + + if (loadingMore != null && loadingMore == true) { + setState(() => isLoadingMore = false); + } + + if (result['result'] == 'success') { + setState(() { + offset = inOffset != null ? inOffset+itemsPerLoad : offset+itemsPerLoad; + if (loadingMore != null && loadingMore == true) { + logsList.logsData!.data = [...logsList.logsData!.data, ...result['data'].data]; + } + else { + logsList.logsData = result['data']; + } + logsList.loadStatus = 1; + }); + } + else { + setState(() { + logsList.loadStatus = 2; + }); + widget.createLog(result['log']); + } + } + + void scrollListener() { + if (scrollController.position.extentAfter < 500 && isLoadingMore == false) { + fetchLogs(loadingMore: true); + } + } + + @override + void initState() { + scrollController = ScrollController()..addListener(scrollListener); + fetchLogs(inOffset: 0); + super.initState(); + } + + @override + Widget build(BuildContext context) { + switch (logsList.loadStatus) { + case 0: + return SizedBox( + width: double.maxFinite, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 30), + Text( + AppLocalizations.of(context)!.loadingLogs, + style: const TextStyle( + fontSize: 22, + color: Colors.grey, + fontWeight: FontWeight.w500 + ), + ) + ], + ), + ); + + case 1: + return RefreshIndicator( + onRefresh: () async { + await fetchLogs(inOffset: 0); + }, + child: ListView.builder( + controller: scrollController, + padding: const EdgeInsets.only(top: 0), + itemCount: isLoadingMore == true + ? logsList.logsData!.data.length+1 + : logsList.logsData!.data.length, + itemBuilder: (context, index) { + if (isLoadingMore == true && index == logsList.logsData!.data.length) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 20), + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + else { + return LogTile( + log: logsList.logsData!.data[index], + index: index, + length: logsList.logsData!.data.length, + ); + } + } + ), + ); + + case 2: + return SizedBox( + width: double.maxFinite, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon( + Icons.error, + color: Colors.red, + size: 50, + ), + const SizedBox(height: 30), + Text( + AppLocalizations.of(context)!.logsNotLoaded, + style: const TextStyle( + fontSize: 22, + color: Colors.grey, + fontWeight: FontWeight.w500 + ), + ) + ], + ), + ); + + default: + return const SizedBox(); + } + } +} \ No newline at end of file diff --git a/lib/services/http_requests.dart b/lib/services/http_requests.dart index ec952fe..bad94f6 100644 --- a/lib/services/http_requests.dart +++ b/lib/services/http_requests.dart @@ -5,6 +5,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:http/http.dart' as http; +import 'package:adguard_home_manager/models/logs.dart'; import 'package:adguard_home_manager/models/app_log.dart'; import 'package:adguard_home_manager/models/server_status.dart'; import 'package:adguard_home_manager/models/clients.dart'; @@ -355,4 +356,73 @@ Future requestAllowedBlockedClientsHosts(Server server, Map } catch (e) { return {'result': 'error'}; } +} + +Future getLogs({ + required Server server, + required int count, + int? offset +}) async { + final result = await getRequest( + urlPath: '/querylog?limit=$count${offset != null ? '&offset=$offset' : ''}', + server: server + ); + + if (result.statusCode == 200) { + return { + 'result': 'success', + 'data': LogsData.fromJson(jsonDecode(result.body)) + }; + } + else { + return { + 'result': 'error', + 'log': AppLog( + type: 'logs', + dateTime: DateTime.now(), + message: 'error_code_not_expected', + statusCode: result.statusCode, + resBody: result.body + ) + }; + } + try { + } on SocketException { + return { + 'result': 'no_connection', + 'log': AppLog( + type: 'logs', + dateTime: DateTime.now(), + message: 'SocketException' + ) + }; + } on TimeoutException { + return { + 'result': 'no_connection', + 'log': AppLog( + type: 'logs', + dateTime: DateTime.now(), + message: 'TimeoutException' + ) + }; + } on HandshakeException { + return { + 'result': 'ssl_error', + 'message': 'HandshakeException', + 'log': AppLog( + type: 'logs', + dateTime: DateTime.now(), + message: 'TimeoutException' + ) + }; + } catch (e) { + return { + 'result': 'error', + 'log': AppLog( + type: 'logs', + dateTime: DateTime.now(), + message: e.toString() + ) + }; + } } \ No newline at end of file diff --git a/lib/widgets/bottom_nav_bar.dart b/lib/widgets/bottom_nav_bar.dart index 3f95096..61545fd 100644 --- a/lib/widgets/bottom_nav_bar.dart +++ b/lib/widgets/bottom_nav_bar.dart @@ -35,6 +35,9 @@ class BottomNavBar extends StatelessWidget { case 'clients': return AppLocalizations.of(context)!.clients; + case 'logs': + return AppLocalizations.of(context)!.logs; + default: return ''; }