diff --git a/db/const.go b/db/const.go index 36fd9c2..9ad33f4 100644 --- a/db/const.go +++ b/db/const.go @@ -19,6 +19,7 @@ const ( IndexerTorznabImpl = "torznab" DataPath = "./data" ImgPath = DataPath + "/img" + LogPath = DataPath + "/logs" ) const ( diff --git a/pkg/uptime/uptime.go b/pkg/uptime/uptime.go new file mode 100644 index 0000000..1e0aeb6 --- /dev/null +++ b/pkg/uptime/uptime.go @@ -0,0 +1,13 @@ +package uptime + +import "time" + +var startTime time.Time + +func Uptime() time.Duration { + return time.Since(startTime) +} + +func init() { + startTime = time.Now() +} diff --git a/server/server.go b/server/server.go index 45cfc33..73ae84c 100644 --- a/server/server.go +++ b/server/server.go @@ -50,12 +50,13 @@ func (s *Server) Serve() error { s.r.Use(ginzap.RecoveryWithZap(log.Logger().Desugar(), true)) log.SetLogLevel(s.db.GetSetting(db.SettingLogLevel)) //restore log level - + s.r.POST("/api/login", HttpHandler(s.Login)) api := s.r.Group("/api/v1") api.Use(s.authModdleware) api.StaticFS("/img", http.Dir(db.ImgPath)) + api.StaticFS("/logs", http.Dir(db.LogPath)) api.Any("/posters/*proxyPath", s.proxyPosters) setting := api.Group("/setting") @@ -64,6 +65,8 @@ func (s *Server) Serve() error { setting.GET("/general", HttpHandler(s.GetSetting)) setting.POST("/auth", HttpHandler(s.EnableAuth)) setting.GET("/auth", HttpHandler(s.GetAuthSetting)) + setting.GET("/logfiles", HttpHandler(s.GetAllLogs)) + setting.GET("/about", HttpHandler(s.About)) } activity := api.Group("/activity") { diff --git a/server/setting.go b/server/setting.go index 6b379ca..cbd3e44 100644 --- a/server/setting.go +++ b/server/setting.go @@ -50,7 +50,7 @@ func (s *Server) GetSetting(c *gin.Context) (interface{}, error) { return &GeneralSettings{ TmdbApiKey: tmdb, DownloadDir: downloadDir, - LogLevel: logLevel, + LogLevel: logLevel, }, nil } diff --git a/server/systems.go b/server/systems.go new file mode 100644 index 0000000..8b3c8b5 --- /dev/null +++ b/server/systems.go @@ -0,0 +1,52 @@ +package server + +import ( + "os" + "polaris/db" + "polaris/log" + "polaris/pkg/uptime" + "runtime" + + "github.com/gin-gonic/gin" + "github.com/pkg/errors" +) + +type LogFile struct { + Name string `json:"name"` + Size int64 `json:"size"` +} + +func (s *Server) GetAllLogs(c *gin.Context) (interface{}, error) { + fs, err := os.ReadDir(db.LogPath) + if err != nil { + return nil, errors.Wrap(err, "read log dir") + } + var logs []LogFile + for _, f := range fs { + if f.IsDir() { + continue + } + info, err := f.Info() + if err != nil { + log.Warnf("get log file error: %v", err) + continue + } + l := LogFile{ + Name: f.Name(), + Size: info.Size(), + } + logs = append(logs, l) + } + return logs, nil +} + +func (s *Server) About(c *gin.Context) (interface{}, error) { + + return gin.H{ + "intro": "Polaris © Simon Ding", + "homepage": "https://github.com/simon-ding/polaris", + "uptime": uptime.Uptime(), + "chat_group": "https://t.me/+8R2nzrlSs2JhMDgx", + "go_version": runtime.Version(), + }, nil +} diff --git a/ui/lib/main.dart b/ui/lib/main.dart index 771e714..781d937 100644 --- a/ui/lib/main.dart +++ b/ui/lib/main.dart @@ -8,7 +8,8 @@ import 'package:ui/login_page.dart'; import 'package:ui/movie_watchlist.dart'; import 'package:ui/providers/APIs.dart'; import 'package:ui/search.dart'; -import 'package:ui/system_settings.dart'; +import 'package:ui/settings.dart'; +import 'package:ui/system_page.dart'; import 'package:ui/tv_details.dart'; import 'package:ui/welcome_page.dart'; @@ -26,8 +27,6 @@ class MyApp extends ConsumerStatefulWidget { } class _MyAppState extends ConsumerState { - - // This widget is the root of your application. @override Widget build(BuildContext context) { @@ -35,8 +34,9 @@ class _MyAppState extends ConsumerState { final shellRoute = ShellRoute( builder: (BuildContext context, GoRouterState state, Widget child) { return SelectionArea( - child: MainSkeleton(body: Padding(padding: const EdgeInsets.all(20), child: child), - ), + child: MainSkeleton( + body: Padding(padding: const EdgeInsets.all(20), child: child), + ), ); }, routes: [ @@ -74,6 +74,10 @@ class _MyAppState extends ConsumerState { GoRoute( path: ActivityPage.route, builder: (context, state) => const ActivityPage(), + ), + GoRoute( + path: SystemPage.route, + builder: (context, state) => const SystemPage(), ) ], ); @@ -95,7 +99,9 @@ class _MyAppState extends ConsumerState { theme: ThemeData( fontFamily: "NotoSansSC", colorScheme: ColorScheme.fromSeed( - seedColor: Colors.blueAccent, brightness: Brightness.dark, surface: Colors.black54), + seedColor: Colors.blueAccent, + brightness: Brightness.dark, + surface: Colors.black54), useMaterial3: true, //scaffoldBackgroundColor: Color.fromARGB(255, 26, 24, 24) ), @@ -103,7 +109,6 @@ class _MyAppState extends ConsumerState { ), ); } - } class MainSkeleton extends StatefulWidget { @@ -130,85 +135,85 @@ class _MainSkeletonState extends State { _selectedTab = 2; } else if (uri.contains(SystemSettingsPage.route)) { _selectedTab = 3; + } else if (uri.contains(SystemPage.route)) { + _selectedTab = 4; } return AdaptiveScaffold( appBarBreakpoint: Breakpoints.standard, appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: const Row( - children: [ - Text("Polaris"), - ], + // TRY THIS: Try changing the color here to a specific color (to + // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar + // change color while the other colors stay the same. + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + // Here we take the value from the MyHomePage object that was created by + // the App.build method, and use it to set our appbar title. + title: const Row( + children: [ + Text("Polaris"), + ], + ), + actions: [ + SearchAnchor( + builder: (BuildContext context, SearchController controller) { + return Container( + constraints: const BoxConstraints(maxWidth: 300, maxHeight: 40), + child: Opacity( + opacity: 0.8, + child: SearchBar( + hintText: "搜索...", + leading: const Icon(Icons.search), + controller: controller, + shadowColor: WidgetStateColor.transparent, + backgroundColor: WidgetStatePropertyAll( + Theme.of(context).colorScheme.primaryContainer), + onSubmitted: (value) => context.go(Uri( + path: SearchPage.route, + queryParameters: {'query': value}).toString()), ), - actions: [ - SearchAnchor(builder: - (BuildContext context, SearchController controller) { - return Container( - constraints: - const BoxConstraints(maxWidth: 300, maxHeight: 40), - child: Opacity( - opacity: 0.8, - child: SearchBar( - hintText: "搜索...", - leading: const Icon(Icons.search), - controller: controller, - shadowColor: WidgetStateColor.transparent, - backgroundColor: WidgetStatePropertyAll( - Theme.of(context).colorScheme.primaryContainer - ), - onSubmitted: (value) => context.go(Uri( - path: SearchPage.route, - queryParameters: {'query': value}).toString()), - ), - ), - ); - }, suggestionsBuilder: - (BuildContext context, SearchController controller) { - return [Text("dadada")]; - }), - FutureBuilder( - future: APIs.isLoggedIn(), - builder: (context, snapshot) { - if (snapshot.hasData && snapshot.data == true) { - return MenuAnchor( - menuChildren: [ - MenuItemButton( - leadingIcon: const Icon(Icons.exit_to_app), - child: const Text("登出"), - onPressed: () async { - final SharedPreferences prefs = - await SharedPreferences.getInstance(); - await prefs.remove('token'); - if (context.mounted) { - context.go(LoginScreen.route); - } - }, - ), - ], - builder: (context, controller, child) { - return TextButton( - onPressed: () { - if (controller.isOpen) { - controller.close(); - } else { - controller.open(); - } - }, - child: const Icon(Icons.account_circle), - ); - }, - ); - } - return Container(); - }) - ], ), + ); + }, suggestionsBuilder: + (BuildContext context, SearchController controller) { + return [Text("dadada")]; + }), + FutureBuilder( + future: APIs.isLoggedIn(), + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data == true) { + return MenuAnchor( + menuChildren: [ + MenuItemButton( + leadingIcon: const Icon(Icons.exit_to_app), + child: const Text("登出"), + onPressed: () async { + final SharedPreferences prefs = + await SharedPreferences.getInstance(); + await prefs.remove('token'); + if (context.mounted) { + context.go(LoginScreen.route); + } + }, + ), + ], + builder: (context, controller, child) { + return TextButton( + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: const Icon(Icons.account_circle), + ); + }, + ); + } + return Container(); + }) + ], + ), useDrawer: false, selectedIndex: _selectedTab, onSelectedIndexChange: (int index) { @@ -223,6 +228,8 @@ class _MainSkeletonState extends State { context.go(ActivityPage.route); } else if (index == 3) { context.go(SystemSettingsPage.route); + } else if (index == 4) { + context.go(SystemPage.route); } }, destinations: const [ @@ -242,6 +249,10 @@ class _MainSkeletonState extends State { icon: Icon(Icons.settings), label: '设置', ), + NavigationDestination( + icon: Icon(Icons.computer_rounded), + label: '系统', + ), ], body: (context) => widget.body, // Define a default secondaryBody. diff --git a/ui/lib/navdrawer.dart b/ui/lib/navdrawer.dart index 9b86a98..53b6a95 100644 --- a/ui/lib/navdrawer.dart +++ b/ui/lib/navdrawer.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:ui/activity.dart'; -import 'package:ui/system_settings.dart'; +import 'package:ui/settings.dart'; import 'package:ui/welcome_page.dart'; class NavDrawer extends StatefulWidget { diff --git a/ui/lib/providers/APIs.dart b/ui/lib/providers/APIs.dart index 7d01df5..829d9c9 100644 --- a/ui/lib/providers/APIs.dart +++ b/ui/lib/providers/APIs.dart @@ -29,6 +29,9 @@ class APIs { static final activityUrl = "$_baseUrl/api/v1/activity/"; static final activityMediaUrl = "$_baseUrl/api/v1/activity/media/"; static final imagesUrl = "$_baseUrl/api/v1/img"; + static final logsBaseUrl = "$_baseUrl/api/v1/logs/"; + static final logFilesUrl = "$_baseUrl/api/v1/setting/logfiles"; + static final aboutUrl = "$_baseUrl/api/v1/setting/about"; static final tmdbImgBaseUrl = "$_baseUrl/api/v1/posters"; diff --git a/ui/lib/providers/settings.dart b/ui/lib/providers/settings.dart index e20aefd..952bc42 100644 --- a/ui/lib/providers/settings.dart +++ b/ui/lib/providers/settings.dart @@ -292,3 +292,65 @@ class Storage { "default": isDefault, }; } + +final logFileDataProvider = FutureProvider.autoDispose((ref) async { + final dio = await APIs.getDio(); + var resp = await dio.get(APIs.logFilesUrl); + var sp = ServerResponse.fromJson(resp.data); + if (sp.code != 0) { + throw sp.message; + } + List favList = List.empty(growable: true); + for (var item in sp.data as List) { + var tv = LogFile.fromJson(item); + favList.add(tv); + } + return favList; +}); + +final aboutDataProvider = FutureProvider.autoDispose((ref) async { + final dio = await APIs.getDio(); + var resp = await dio.get(APIs.aboutUrl); + var sp = ServerResponse.fromJson(resp.data); + if (sp.code != 0) { + throw sp.message; + } + return About.fromJson(sp.data); +}); + +class LogFile { + String? name; + int? size; + + LogFile({this.name, this.size}); + + factory LogFile.fromJson(Map json1) { + return LogFile(name: json1["name"], size: json1["size"]); + } +} + +class About { + About({ + required this.chatGroup, + required this.goVersion, + required this.homepage, + required this.intro, + required this.uptime, + }); + + final String? chatGroup; + final String? goVersion; + final String? homepage; + final String? intro; + final Duration? uptime; + + factory About.fromJson(Map json) { + return About( + chatGroup: json["chat_group"], + goVersion: json["go_version"], + homepage: json["homepage"], + intro: json["intro"], + uptime: Duration(microseconds: (json["uptime"]/1000.0 as double).round()), + ); + } +} diff --git a/ui/lib/system_settings.dart b/ui/lib/settings.dart similarity index 100% rename from ui/lib/system_settings.dart rename to ui/lib/settings.dart diff --git a/ui/lib/system_page.dart b/ui/lib/system_page.dart new file mode 100644 index 0000000..5202077 --- /dev/null +++ b/ui/lib/system_page.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:ui/providers/APIs.dart'; + +import 'package:ui/providers/settings.dart'; +import 'package:ui/widgets/progress_indicator.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class SystemPage extends ConsumerStatefulWidget { + static const route = "/system"; + + const SystemPage({super.key}); + @override + ConsumerState createState() { + return _SystemPageState(); + } +} + +class _SystemPageState extends ConsumerState { + @override + Widget build(BuildContext context) { + final logs = ref.watch(logFileDataProvider); + final about = ref.watch(aboutDataProvider); + return SingleChildScrollView( + child: Column( + children: [ + ExpansionTile( + expandedCrossAxisAlignment: CrossAxisAlignment.stretch, + initiallyExpanded: true, + childrenPadding: EdgeInsets.all(20), + title: Text("日志"), + children: [ + logs.when( + data: (list) { + return DataTable( + columns: const [ + DataColumn(label: Text("日志")), + DataColumn(label: Text("大小")), + DataColumn(label: Text("*")) + ], + rows: List.generate(list.length, (i) { + final item = list[i]; + final uri = + Uri.parse("${APIs.logsBaseUrl}${item.name}"); + + return DataRow(cells: [ + DataCell(Text(item.name ?? "")), + DataCell(Text("${item.size ?? 0}")), + DataCell(InkWell( + child: Icon(Icons.download), + onTap: () => launchUrl(uri, + webViewConfiguration: WebViewConfiguration( + headers: APIs.authHeaders)), + )) + ]); + })); + }, + error: (err, trace) => Text("$err"), + loading: () => const MyProgressIndicator()) + ], + ), + ExpansionTile( + title: Text("关于"), + expandedCrossAxisAlignment: CrossAxisAlignment.center, + initiallyExpanded: true, + children: [ + about.when( + data: (v) { + final uri = Uri.parse(v.chatGroup ?? ""); + final homepage = Uri.parse(v.homepage ?? ""); + return Row( + children: [ + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + SizedBox( + height: 20, + ), + Text( + "#", + style: TextStyle(height: 2.5), + ), + Text("主页", style: TextStyle(height: 2.5)), + Text("讨论组", style: TextStyle(height: 2.5)), + Text("go version", style: TextStyle(height: 2.5)), + Text("uptime", style: TextStyle(height: 2.5)), + SizedBox( + height: 20, + ), + ], + )), + const SizedBox( + width: 20, + ), + Expanded( + flex: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox( + height: 20, + ), + Text(v.intro ?? "", + style: const TextStyle(height: 2.5)), + InkWell( + child: Text(v.homepage ?? "", + style: const TextStyle(height: 2.5)), + onTap: () => launchUrl(homepage), + ), + InkWell( + child: const Text("Telegram", + style: TextStyle(height: 2.5)), + onTap: () => launchUrl(uri), + ), + Text("${v.goVersion}", + style: const TextStyle(height: 2.5)), + Text("${v.uptime}", + style: const TextStyle(height: 2.5)), + const SizedBox( + height: 20, + ), + ], + )), + ], + ); + }, + error: (err, trace) => Text("$err"), + loading: () => const MyProgressIndicator()) + ], + ) + ], + ), + ); + } +} diff --git a/ui/pubspec.lock b/ui/pubspec.lock index df849dc..e8f9982 100644 --- a/ui/pubspec.lock +++ b/ui/pubspec.lock @@ -539,7 +539,7 @@ packages: source: hosted version: "1.3.2" url_launcher: - dependency: transitive + dependency: "direct main" description: name: url_launcher sha256: "21b704ce5fa560ea9f3b525b43601c678728ba46725bab9b01187b4831377ed3" diff --git a/ui/pubspec.yaml b/ui/pubspec.yaml index 785a0ca..f79abd5 100644 --- a/ui/pubspec.yaml +++ b/ui/pubspec.yaml @@ -46,6 +46,7 @@ dependencies: flutter_adaptive_scaffold: ^0.1.11+1 flutter_form_builder: ^9.3.0 form_builder_validators: ^11.0.0 + url_launcher: ^6.3.0 dev_dependencies: flutter_test: