add task manager

This commit is contained in:
2024-02-17 15:22:26 +08:00
parent 02b211ab99
commit 7cec3fd320
17 changed files with 430 additions and 21 deletions

View File

@@ -271,6 +271,19 @@ class EHApi extends __EHApi {
return newUri.toString();
}
Uri getTaskUrl() {
final uri = Uri.parse(_combineBaseUrls(_dio.options.baseUrl, baseUrl));
final nuri = uri.resolve("task");
return Uri(
scheme: uri.scheme == "https" ? "wss" : "ws",
userInfo: nuri.userInfo,
host: nuri.host,
port: nuri.port,
path: nuri.path,
query: nuri.query,
);
}
String getThumbnailUrl(int id,
{int? max,
int? width,

View File

@@ -138,11 +138,11 @@ class TaskProgress {
const TaskProgress({
required this.type,
required this.taskId,
required this.progress,
required this.detail,
});
final TaskType type;
final int taskId;
final TaskProgressBasicType progress;
final TaskProgressBasicType detail;
factory TaskProgress.fromJson(Map<String, dynamic> json) {
final type = json['type'] as int;
final taskId = json['task_id'] as int;
@@ -151,29 +151,29 @@ class TaskProgress {
return TaskProgress(
type: TaskType.download,
taskId: taskId,
progress: TaskDownloadProgess.fromJson(
json['progress'] as Map<String, dynamic>),
detail: TaskDownloadProgess.fromJson(
json['detail'] as Map<String, dynamic>),
);
case 1:
return TaskProgress(
type: TaskType.exportZip,
taskId: taskId,
progress: TaskExportZipProgress.fromJson(
json['progress'] as Map<String, dynamic>),
detail: TaskExportZipProgress.fromJson(
json['detail'] as Map<String, dynamic>),
);
case 2:
return TaskProgress(
type: TaskType.updateMeiliSearchData,
taskId: taskId,
progress: TaskUpdateMeiliSearchDataProgress.fromJson(
json['progress'] as Map<String, dynamic>),
detail: TaskUpdateMeiliSearchDataProgress.fromJson(
json['detail'] as Map<String, dynamic>),
);
case 3:
return TaskProgress(
type: TaskType.fixGalleryPage,
taskId: taskId,
progress: TaskFixGalleryPageProgress.fromJson(
json['progress'] as Map<String, dynamic>),
detail: TaskFixGalleryPageProgress.fromJson(
json['detail'] as Map<String, dynamic>),
);
default:
throw ArgumentError.value(type, 'type', 'Invalid task type');
@@ -200,9 +200,37 @@ class TaskDetail {
this.error,
this.fataled,
});
final Task base;
Task base;
TaskProgressBasicType? progress;
TaskStatus status;
String? error;
bool? fataled;
}
@JsonSerializable()
class TaskList {
const TaskList({
required this.tasks,
required this.running,
});
final List<Task> tasks;
final List<int> running;
factory TaskList.fromJson(Map<String, dynamic> json) =>
_$TaskListFromJson(json);
Map<String, dynamic> toJson() => _$TaskListToJson(this);
}
@JsonSerializable()
class TaskError {
const TaskError({
required this.task,
required this.error,
required this.fatal,
});
final Task task;
final String error;
final bool fatal;
factory TaskError.fromJson(Map<String, dynamic> json) =>
_$TaskErrorFromJson(json);
Map<String, dynamic> toJson() => _$TaskErrorToJson(this);
}

View File

@@ -120,3 +120,27 @@ Map<String, dynamic> _$TaskFixGalleryPageProgressToJson(
'total_gallery': instance.totalGallery,
'checked_gallery': instance.checkedGallery,
};
TaskList _$TaskListFromJson(Map<String, dynamic> json) => TaskList(
tasks: (json['tasks'] as List<dynamic>)
.map((e) => Task.fromJson(e as Map<String, dynamic>))
.toList(),
running: (json['running'] as List<dynamic>).map((e) => e as int).toList(),
);
Map<String, dynamic> _$TaskListToJson(TaskList instance) => <String, dynamic>{
'tasks': instance.tasks,
'running': instance.running,
};
TaskError _$TaskErrorFromJson(Map<String, dynamic> json) => TaskError(
task: Task.fromJson(json['task'] as Map<String, dynamic>),
error: json['error'] as String,
fatal: json['fatal'] as bool,
);
Map<String, dynamic> _$TaskErrorToJson(TaskError instance) => <String, dynamic>{
'task': instance.task,
'error': instance.error,
'fatal': instance.fatal,
};

View File

@@ -88,6 +88,9 @@ class AuthInfo {
_log.info(
"Logged in as ${u.username} (${u.id}). isAdmin: ${u.isAdmin}. permissions: ${u.permissions}");
await checkSessionInfo();
if (canManageTasks == true) {
await tasks.connect();
}
} else if (re.status == 401 || re.status == 1 || re.status == 404) {
_user = null;
} else {

View File

@@ -37,9 +37,11 @@ final dio = Dio()
..options.extra['withCredentials'] = true;
Config? _prefs;
EHApi? _api;
PersistCookieJar? _jar;
Future<void> prepareJar() async {
final jar = PersistCookieJar(storage: FileStorage(await getJarPath()));
_jar = jar;
dio.interceptors.add(CookieManager(jar));
}
@@ -104,6 +106,10 @@ EHApi get api {
return _api!;
}
PersistCookieJar? get cookieJar {
return _jar;
}
final AuthInfo auth = AuthInfo();
final Clipboard platformClipboard = Clipboard();
final Display platformDisplay = Display();
@@ -121,6 +127,7 @@ enum MoreVertSettings {
markAsNsfw,
markAsSfw,
serverSettings,
taskManager,
}
void onMoreVertSettingsSelected(BuildContext context, MoreVertSettings value) {
@@ -143,6 +150,9 @@ void onMoreVertSettingsSelected(BuildContext context, MoreVertSettings value) {
case MoreVertSettings.serverSettings:
context.push("/server_settings");
break;
case MoreVertSettings.taskManager:
context.push("/task_manager");
break;
default:
break;
}
@@ -176,6 +186,11 @@ List<PopupMenuEntry<MoreVertSettings>> buildMoreVertSettings(
value: MoreVertSettings.serverSettings,
child: Text(AppLocalizations.of(context)!.serverSettings)));
}
if (path != "/task_manager" && auth.canManageTasks == true) {
list.add(PopupMenuItem(
value: MoreVertSettings.taskManager,
child: Text(AppLocalizations.of(context)!.taskManager)));
}
var showNsfw = prefs.getBool("showNsfw") ?? false;
list.add(PopupMenuItem(
child: StatefulBuilder(

View File

@@ -42,6 +42,16 @@ class HomeDrawer extends StatelessWidget {
},
)
: Container(),
auth.canManageTasks == true
? ListTile(
leading: const Icon(Icons.task),
title: Text(AppLocalizations.of(context)!.taskManager),
onTap: () {
Scaffold.of(context).closeDrawer();
context.push("/task_manager");
},
)
: Container(),
ListTile(
leading: const Icon(Icons.settings),
title: Text(AppLocalizations.of(context)!.settings),

View File

@@ -143,5 +143,11 @@
"redirectToFlutter": "Redirect to Flutter frontend when accessing the root URL.",
"downloadTimeoutCheckInterval": "The interval of checking download timeout",
"downloadTimeoutCheckIntervalHelp": "The smaller the value, the more accurate the timeout detection, but the higher CPU usage.",
"dockerHelper": "The server is running in a Docker container. Unless you know what you are doing, do not change this setting."
"dockerHelper": "The server is running in a Docker container. Unless you know what you are doing, do not change this setting.",
"taskManager": "Task Manager",
"waiting": "Waiting",
"running": "Running",
"finished": "Finished",
"failed": "Failed",
"allTasks": "All Tasks"
}

View File

@@ -143,5 +143,11 @@
"redirectToFlutter": "访问根 URL 时重定向到 flutter 前端。",
"downloadTimeoutCheckInterval": "下载超时检测间隔",
"downloadTimeoutCheckIntervalHelp": "值越小,检测准确性越高,但是消耗更多的 CPU。",
"dockerHelper": "服务器运行在 Docker 容器中。除非你知道你在做什么,否则不要修改这个设置。"
"dockerHelper": "服务器运行在 Docker 容器中。除非你知道你在做什么,否则不要修改这个设置。",
"taskManager": "任务管理器",
"waiting": "等待中",
"running": "运行中",
"finished": "已完成",
"failed": "已失败",
"allTasks": "所有任务"
}

View File

@@ -20,6 +20,7 @@ import 'logs/file.dart';
import 'server_settings.dart';
import 'set_server.dart';
import 'settings.dart';
import 'task_manager.dart';
import 'utils.dart';
import 'viewer/single.dart';
@@ -159,8 +160,12 @@ final _router = GoRouter(
}),
GoRoute(
path: ServerSettingsPage.routeName,
builder: (context, state) => const ServerSettingsPage(),
)
builder: (context, state) => ServerSettingsPage(key: state.pageKey),
),
GoRoute(
path: TaskManagerPage.routeName,
builder: (context, state) => TaskManagerPage(key: state.pageKey),
),
],
);
@@ -225,13 +230,15 @@ class MainApp extends StatefulWidget {
context.findAncestorStateOfType<_MainApp>()!;
}
class _MainApp extends State<MainApp> {
class _MainApp extends State<MainApp> with WidgetsBindingObserver {
ThemeMode _themeMode = ThemeMode.system;
ThemeData _themeData = ThemeData(useMaterial3: true);
ThemeData _darkThemeData = ThemeData.dark(useMaterial3: true);
ThemeMode get themeMode => _themeMode;
Lang _lang = Lang.system;
Lang get lang => _lang;
AppLifecycleState? _lifecycleState;
AppLifecycleState? get lifecycleState => _lifecycleState;
@override
void initState() {
@@ -250,6 +257,23 @@ class _MainApp extends State<MainApp> {
_themeData = _themeData.useSystemChineseFont(Brightness.light);
_darkThemeData = _darkThemeData.useSystemChineseFont(Brightness.dark);
}
WidgetsBinding.instance.addObserver(this);
if (WidgetsBinding.instance.lifecycleState != null) {
_lifecycleState = WidgetsBinding.instance.lifecycleState;
listener.tryEmit("lifecycle", _lifecycleState);
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
_lifecycleState = state;
listener.tryEmit("lifecycle", _lifecycleState);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override

View File

@@ -1,8 +1,127 @@
import 'dart:async';
import 'dart:convert';
import 'package:logging/logging.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'api/task.dart';
import 'globals.dart';
import 'utils/websocket.dart';
final _log = Logger("TaskManager");
class TaskManager {
Map<int, TaskDetail> tasks = {};
WebSocketChannel? _channel;
bool _closed = false;
bool _allowReconnect = true;
Timer? _reconnectTimer;
void clear() {
tasks.clear();
_channel?.stream.drain();
_channel?.sink.close();
_closed = true;
}
Future<void> connect() async {
if (auth.canManageTasks != true) return;
try {
_channel = await connectWebSocket(api.getTaskUrl());
_channel!.stream.listen((event) {
try {
final data = jsonDecode(event) as Map<String, dynamic>;
final type = data["type"] as String;
if (type == "tasks") {
final list = TaskList.fromJson(data);
for (var task in list.tasks) {
tasks[task.id] = TaskDetail(
base: task,
status: list.running.contains(task.id)
? TaskStatus.running
: TaskStatus.wait,
);
}
} else if (type == "new_task") {
final task = Task.fromJson(data["detail"] as Map<String, dynamic>);
tasks[task.id] = TaskDetail(
base: task,
status: TaskStatus.wait,
);
} else if (type == "task_started") {
final task = Task.fromJson(data["detail"] as Map<String, dynamic>);
tasks.update(task.id, (value) {
value.status = TaskStatus.running;
return value;
},
ifAbsent: () => TaskDetail(
base: task,
status: TaskStatus.running,
));
} else if (type == "task_finished") {
final task = Task.fromJson(data["detail"] as Map<String, dynamic>);
if (tasks.containsKey(task.id)) {
tasks.update(task.id, (value) {
value.status = TaskStatus.finished;
return value;
});
}
} else if (type == "task_progress") {
final task =
TaskProgress.fromJson(data["detail"] as Map<String, dynamic>);
if (tasks.containsKey(task.taskId)) {
tasks.update(task.taskId, (value) {
value.progress = task.detail;
return value;
});
}
} else if (type == "task_updated") {
final task = Task.fromJson(data["detail"] as Map<String, dynamic>);
if (tasks.containsKey(task.id)) {
tasks.update(task.id, (value) {
value.base = task;
return value;
});
}
} else if (type == "task_error") {
final info =
TaskError.fromJson(data["detail"] as Map<String, dynamic>);
if (tasks.containsKey(info.task.id)) {
tasks.update(info.task.id, (value) {
value.status = TaskStatus.failed;
value.error = info.error;
value.fataled = info.fatal;
return value;
});
}
}
} catch (e) {
_log.warning("Error processing task message: $e");
}
}, onError: (e) {
_log.warning("Task websocket error: $e");
if (_allowReconnect) {
_log.info("Reconnecting to task server in 5 seconds");
_reconnectTimer = Timer(const Duration(seconds: 5), () {
_reconnectTimer = null;
connect();
});
}
}, cancelOnError: true);
await _channel!.ready;
_closed = false;
sendTaskList();
} catch (e) {
_channel = null;
_log.warning("Failed to connect to task server: $e");
if (_allowReconnect) {
_log.info("Reconnecting to task server in 5 seconds");
_reconnectTimer = Timer(const Duration(seconds: 5), () {
_reconnectTimer = null;
connect();
});
}
}
}
void sendTaskList() {
_channel?.sink.add("{\"type\":\"task_list\"}");
}
}

129
lib/task_manager.dart Normal file
View File

@@ -0,0 +1,129 @@
import 'package:enum_flag/enum_flag.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:go_router/go_router.dart';
import 'globals.dart';
enum TaskStatusFilterFlag with EnumFlag {
wait,
running,
finished,
failed;
String localText(BuildContext context) {
final i18n = AppLocalizations.of(context)!;
switch (this) {
case TaskStatusFilterFlag.wait:
return i18n.waiting;
case TaskStatusFilterFlag.running:
return i18n.running;
case TaskStatusFilterFlag.finished:
return i18n.finished;
case TaskStatusFilterFlag.failed:
return i18n.failed;
}
}
}
const taskStatusFilterFlagAll = 15;
class TaskStatusFilter {
TaskStatusFilter({this.code = taskStatusFilterFlagAll});
int code;
bool has(TaskStatusFilterFlag flag) => code.hasFlag(flag);
bool get isAll => code == taskStatusFilterFlagAll;
void add(TaskStatusFilterFlag flag) {
code |= flag.value;
}
void remove(TaskStatusFilterFlag flag) {
code &= ~flag.value;
}
}
class TaskManagerPage extends StatefulWidget {
const TaskManagerPage({super.key});
static const String routeName = '/task_manager';
@override
State<TaskManagerPage> createState() => _TaskManagerPage();
}
class _TaskManagerPage extends State<TaskManagerPage>
with ThemeModeWidget, IsTopWidget2 {
late TaskStatusFilter _filter;
@override
void initState() {
_filter = TaskStatusFilter();
super.initState();
}
Widget _buildChips() {
final i18n = AppLocalizations.of(context)!;
var list = <FilterChip>[
FilterChip(
label: Text(i18n.allTasks),
selected: _filter.isAll,
onSelected: (bool value) {
setState(() {
if (value) {
_filter.code = taskStatusFilterFlagAll;
} else {
_filter.code = 0;
}
});
},
)
];
for (var flag in TaskStatusFilterFlag.values) {
list.add(FilterChip(
label: Text(flag.localText(context)),
selected: _filter.has(flag),
onSelected: (bool value) {
setState(() {
if (value) {
_filter.add(flag);
} else {
_filter.remove(flag);
}
});
},
));
}
return SliverToBoxAdapter(
child: Wrap(
spacing: 5.0,
children: list,
));
}
@override
Widget build(BuildContext context) {
final i18n = AppLocalizations.of(context)!;
if (isTop(context)) {
setCurrentTitle(i18n.taskManager, Theme.of(context).primaryColor.value);
}
return Scaffold(
body: CustomScrollView(
slivers: <Widget>[
SliverAppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
context.canPop() ? context.pop() : context.go("/");
},
),
title: Text(i18n.taskManager),
actions: [
buildThemeModeIcon(context),
buildMoreVertSettingsButon(context),
],
floating: true,
),
_buildChips(),
],
),
);
}
}

1
lib/utils/websocket.dart Normal file
View File

@@ -0,0 +1 @@
export './websocket_io.dart' if (dart.library.html) './websocket_web.dart';

View File

@@ -0,0 +1,25 @@
import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../globals.dart';
Future<WebSocketChannel> connectWebSocket(Uri uri) async {
final Map<String, String> headers = {};
final jar = cookieJar;
if (jar != null) {
final nuri =Uri(
scheme: uri.scheme == "wss" ? "https" : "http",
userInfo: uri.userInfo,
host: uri.host,
port: uri.port,
path: uri.path,
query: uri.query,
);
final cookies = await jar.loadForRequest(nuri);
final list = <String>[];
for (var cookie in cookies) {
list.add('${cookie.name}=${cookie.value}');
}
headers['cookie'] = list.join('; ');
}
return IOWebSocketChannel.connect(uri, headers: headers);
}

View File

@@ -0,0 +1,6 @@
import 'package:web_socket_channel/html.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
Future<WebSocketChannel> connectWebSocket(Uri uri) async {
return HtmlWebSocketChannel.connect(uri, binaryType: BinaryType.list);
}

View File

@@ -61,12 +61,12 @@ SPEC CHECKSUMS:
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
irondash_engine_context: da62996ee25616d2f01bbeb85dc115d813359478
package_info_plus: 02d7a575e80f194102bef286361c6c326e4c29ce
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c
screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38
shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126
shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695
super_native_extensions: 85efee3a7495b46b04befcfc86ed12069264ebf3
window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8
PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367
COCOAPODS: 1.15.0
COCOAPODS: 1.15.2

View File

@@ -258,7 +258,7 @@
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 0920;
LastUpgradeCheck = 1430;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C80D4294CF70F00263BE5 = {

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"