From a087189e54c949eda97d481c3f85bf90ef4236e9 Mon Sep 17 00:00:00 2001 From: LmeSzinc <37934724+LmeSzinc@users.noreply.github.com> Date: Tue, 29 Aug 2023 22:35:13 +0800 Subject: [PATCH] Add: Dashboard on GUI --- assets/gui/css/alas-mobile.css | 9 ++- assets/gui/css/alas-pc.css | 3 +- assets/gui/css/alas.css | 52 +++++++++++- assets/gui/css/dark-alas.css | 4 + assets/gui/css/light-alas.css | 4 + module/config/argument/args.json | 8 +- module/config/argument/argument.yaml | 4 + module/config/argument/gui.yaml | 10 +++ module/config/argument/stored.json | 84 ++++++++++++++++++++ module/config/config_updater.py | 28 +++++++ module/config/i18n/en-US.json | 9 +++ module/config/i18n/ja-JP.json | 9 +++ module/config/i18n/zh-CN.json | 9 +++ module/config/i18n/zh-TW.json | 9 +++ module/config/stored/classes.py | 35 +++------ module/webui/app.py | 113 ++++++++++++++++++--------- module/webui/base.py | 26 ++++++ module/webui/lang.py | 33 +++++++- 18 files changed, 380 insertions(+), 69 deletions(-) create mode 100644 module/config/argument/stored.json diff --git a/assets/gui/css/alas-mobile.css b/assets/gui/css/alas-mobile.css index 9acad6eb7..3e3d6f75c 100644 --- a/assets/gui/css/alas-mobile.css +++ b/assets/gui/css/alas-mobile.css @@ -59,4 +59,11 @@ #pywebio-scope-waiting, #pywebio-scope-log { overflow-y: auto; -} \ No newline at end of file +} + +[id^="pywebio-scope-dashboard-row-"] { + display: flex; + flex-grow: 1; + min-width: 4rem; + max-width: 50%; +} diff --git a/assets/gui/css/alas-pc.css b/assets/gui/css/alas-pc.css index 57d95088e..0adfa2591 100644 --- a/assets/gui/css/alas-pc.css +++ b/assets/gui/css/alas-pc.css @@ -26,7 +26,6 @@ overflow-y: auto; } -#pywebio-scope-scheduler-bar, #pywebio-scope-log-bar, #pywebio-scope-log, #pywebio-scope-daemon-overview #pywebio-scope-groups { @@ -45,4 +44,4 @@ display: grid; grid-auto-flow: column; grid-template-columns: auto auto; -} \ No newline at end of file +} diff --git a/assets/gui/css/alas.css b/assets/gui/css/alas.css index 735dc2012..5fc57f5d4 100644 --- a/assets/gui/css/alas.css +++ b/assets/gui/css/alas.css @@ -383,17 +383,65 @@ pre.rich-traceback-code { } #pywebio-scope-scheduler-bar, -#pywebio-scope-log-bar { +#pywebio-scope-log-title { display: flex; align-items: center; justify-content: space-between; } -#pywebio-scope-log-bar-btns { +#pywebio-scope-log-title-btns { display: grid; grid-auto-flow: column; } +#pywebio-scope-log-bar { + height: 11.5rem; +} + +#pywebio-scope-dashboard { + display: flex; + align-content: flex-start; + flex-flow: row wrap; + overflow: auto; +} + +.dashboard-icon { + margin: .6rem .8rem 0 .6rem; + width: .5rem; + height: .5rem; + border-radius: 50%; +} + +[id^="pywebio-scope-dashboard-row-"] { + display: flex; + flex-grow: 1; + min-width: 4rem; + max-width: 10rem; +} + +*[style*="--dashboard-value--"] { + font-size: 1rem; + font-weight: 500; + overflow-wrap: break-word; +} + +*[style*="--dashboard-time--"] { + font-size: 0.8rem; + font-weight: 400; + overflow-wrap: break-word; +} + +[id^="pywebio-scope-dashboard-row-"] p { + margin-bottom: 0; +} + +[id^="pywebio-scope-dashboard-value-"] { + display: flex; + align-items: flex-end; + height: 1.5rem; +} + + #pywebio-scope-log { line-height: 1.2; font-size: 0.85rem; diff --git a/assets/gui/css/dark-alas.css b/assets/gui/css/dark-alas.css index c36fa31b0..c2d76e7bc 100644 --- a/assets/gui/css/dark-alas.css +++ b/assets/gui/css/dark-alas.css @@ -152,4 +152,8 @@ pre.rich-traceback-code { *[style*="--arg-help--"], [id^="pywebio-scope-group_"] > p + p { color: #adb5bd; +} + +*[style*="--dashboard-time--"] { + color: #adb5bd; } \ No newline at end of file diff --git a/assets/gui/css/light-alas.css b/assets/gui/css/light-alas.css index b65067f11..171d565af 100644 --- a/assets/gui/css/light-alas.css +++ b/assets/gui/css/light-alas.css @@ -151,4 +151,8 @@ pre.rich-traceback-code { *[style*="--arg-help--"], [id^="pywebio-scope-group_"] > p + p { color: #777777; +} + +*[style*="--dashboard-time--"] { + color: #777777; } \ No newline at end of file diff --git a/module/config/argument/args.json b/module/config/argument/args.json index 5afb63928..97694e31c 100644 --- a/module/config/argument/args.json +++ b/module/config/argument/args.json @@ -364,7 +364,9 @@ "type": "stored", "value": {}, "display": "hide", - "stored": "StoredTrailblazePower" + "stored": "StoredTrailblazePower", + "order": 1, + "color": "#eb8efe" }, "DungeonDouble": { "type": "stored", @@ -782,7 +784,9 @@ "type": "stored", "value": {}, "display": "hide", - "stored": "StoredDailyActivity" + "stored": "StoredDailyActivity", + "order": 2, + "color": "#ffcf70" }, "DailyQuest": { "type": "stored", diff --git a/module/config/argument/argument.yaml b/module/config/argument/argument.yaml index 2be96a989..7d0c54b74 100644 --- a/module/config/argument/argument.yaml +++ b/module/config/argument/argument.yaml @@ -111,6 +111,8 @@ DungeonSupport: DungeonStorage: TrailblazePower: stored: StoredTrailblazePower + order: 1 + color: "#eb8efe" DungeonDouble: stored: StoredDungeonDouble @@ -124,6 +126,8 @@ AchievableQuest: DailyStorage: DailyActivity: stored: StoredDailyActivity + order: 2 + color: "#ffcf70" DailyQuest: stored: StoredDaily diff --git a/module/config/argument/gui.yaml b/module/config/argument/gui.yaml index 5866dd269..419167b0c 100644 --- a/module/config/argument/gui.yaml +++ b/module/config/argument/gui.yaml @@ -52,6 +52,16 @@ Overview: Waiting: NoTask: +Dashboard: + # From lang.readable_time() + NoData: + TimeError: + JustNow: + MinutesAgo: + HoursAgo: + DaysAgo: + LongTimeAgo: + AddAlas: PopupTitle: NewName: diff --git a/module/config/argument/stored.json b/module/config/argument/stored.json new file mode 100644 index 000000000..a42f73750 --- /dev/null +++ b/module/config/argument/stored.json @@ -0,0 +1,84 @@ +{ + "TrailblazePower": { + "name": "TrailblazePower", + "path": "Dungeon.DungeonStorage.TrailblazePower", + "i18n": "DungeonStorage.TrailblazePower.name", + "stored": "StoredTrailblazePower", + "attrs": { + "time": "2020-01-01 00:00:00", + "total": 180, + "value": 0 + }, + "order": 1, + "color": "#eb8efe" + }, + "DailyActivity": { + "name": "DailyActivity", + "path": "DailyQuest.DailyStorage.DailyActivity", + "i18n": "DailyStorage.DailyActivity.name", + "stored": "StoredDailyActivity", + "attrs": { + "time": "2020-01-01 00:00:00", + "total": 500, + "value": 0 + }, + "order": 2, + "color": "#ffcf70" + }, + "Assignment": { + "name": "Assignment", + "path": "Assignment.Assignment.Assignment", + "i18n": "Assignment.Assignment.name", + "stored": "StoredAssignment", + "attrs": { + "time": "2020-01-01 00:00:00", + "total": 0, + "value": 0 + }, + "order": 3, + "color": "#deba95" + }, + "SimulatedUniverse": { + "name": "SimulatedUniverse", + "path": "Dungeon.DungeonStorage.SimulatedUniverse", + "i18n": "DungeonStorage.SimulatedUniverse.name", + "stored": "StoredSimulatedUniverse", + "attrs": { + "time": "2020-01-01 00:00:00", + "total": 0, + "value": 0 + }, + "order": 5, + "color": "#8fb5fe" + }, + "DungeonDouble": { + "name": "DungeonDouble", + "path": "Dungeon.DungeonStorage.DungeonDouble", + "i18n": "DungeonStorage.DungeonDouble.name", + "stored": "StoredDungeonDouble", + "attrs": { + "time": "2020-01-01 00:00:00", + "calyx": 0, + "relic": 0 + }, + "order": 0, + "color": "#777777" + }, + "DailyQuest": { + "name": "DailyQuest", + "path": "DailyQuest.DailyStorage.DailyQuest", + "i18n": "DailyStorage.DailyQuest.name", + "stored": "StoredDaily", + "attrs": { + "time": "2020-01-01 00:00:00", + "quest1": "", + "quest2": "", + "quest3": "", + "quest4": "", + "quest5": "", + "quest6": "" + }, + "order": 0, + "color": "#777777" + } +} \ No newline at end of file diff --git a/module/config/config_updater.py b/module/config/config_updater.py index 3b0dbeea9..99d49603d 100644 --- a/module/config/config_updater.py +++ b/module/config/config_updater.py @@ -441,6 +441,32 @@ class ConfigGenerator: return data + @cached_property + def stored(self): + import module.config.stored.classes as classes + data = {} + for path, value in deep_iter(self.args, depth=3): + if value.get('type') != 'stored': + continue + name = path[-1] + stored = value.get('stored') + stored_class = getattr(classes, stored) + row = { + 'name': name, + 'path': '.'.join(path), + 'i18n': f'{path[1]}.{path[2]}.name', + 'stored': stored, + 'attrs': stored_class('')._attrs, + 'order': value.get('order', 0), + 'color': value.get('color', '#777777') + } + data[name] = row + + # sort by `order` ascending, but `order`==0 at last + data = sorted(data.items(), key=lambda kv: (kv[1]['order'] == 0, kv[1]['order'])) + data = {k: v for k, v in data} + return data + @staticmethod def generate_deploy_template(): template = poor_yaml_read(DEPLOY_TEMPLATE) @@ -495,12 +521,14 @@ class ConfigGenerator: def generate(self): _ = self.args _ = self.menu + _ = self.stored # _ = self.event self.insert_assignment() self.insert_package() # self.insert_server() write_file(filepath_args(), self.args) write_file(filepath_args('menu'), self.menu) + write_file(filepath_args('stored'), self.stored) self.generate_code() self.generate_stored() for lang in LANGUAGES: diff --git a/module/config/i18n/en-US.json b/module/config/i18n/en-US.json index b4fad33df..c3eba38d1 100644 --- a/module/config/i18n/en-US.json +++ b/module/config/i18n/en-US.json @@ -695,6 +695,15 @@ "Waiting": "Waiting", "NoTask": "No Task" }, + "Dashboard": { + "NoData": "no data", + "TimeError": "time error", + "JustNow": "just now", + "MinutesAgo": "{time}min ago", + "HoursAgo": "{time}h ago", + "DaysAgo": "{time}d ago", + "LongTimeAgo": "long time ago" + }, "AddAlas": { "PopupTitle": "Add new config", "NewName": "New name", diff --git a/module/config/i18n/ja-JP.json b/module/config/i18n/ja-JP.json index 01c0bb4ba..7e6199cc9 100644 --- a/module/config/i18n/ja-JP.json +++ b/module/config/i18n/ja-JP.json @@ -695,6 +695,15 @@ "Waiting": "Waiting", "NoTask": "No Task" }, + "Dashboard": { + "NoData": "Gui.Dashboard.NoData", + "TimeError": "Gui.Dashboard.TimeError", + "JustNow": "Gui.Dashboard.JustNow", + "MinutesAgo": "Gui.Dashboard.MinutesAgo", + "HoursAgo": "Gui.Dashboard.HoursAgo", + "DaysAgo": "Gui.Dashboard.DaysAgo", + "LongTimeAgo": "Gui.Dashboard.LongTimeAgo" + }, "AddAlas": { "PopupTitle": "新しいコンフィグを追加", "NewName": "コンフィグ名", diff --git a/module/config/i18n/zh-CN.json b/module/config/i18n/zh-CN.json index 2d352e35f..c7ecf1396 100644 --- a/module/config/i18n/zh-CN.json +++ b/module/config/i18n/zh-CN.json @@ -695,6 +695,15 @@ "Waiting": "等待中", "NoTask": "无任务" }, + "Dashboard": { + "NoData": "无数据", + "TimeError": "时间错误", + "JustNow": "刚刚", + "MinutesAgo": "{time}分钟前", + "HoursAgo": "{time}小时前", + "DaysAgo": "{time}天前", + "LongTimeAgo": "很久以前" + }, "AddAlas": { "PopupTitle": "添加新配置", "NewName": "新的配置文件名", diff --git a/module/config/i18n/zh-TW.json b/module/config/i18n/zh-TW.json index 300bf11ca..aa2a45450 100644 --- a/module/config/i18n/zh-TW.json +++ b/module/config/i18n/zh-TW.json @@ -695,6 +695,15 @@ "Waiting": "等待中", "NoTask": "無任務" }, + "Dashboard": { + "NoData": "無數據", + "TimeError": "時間錯誤", + "JustNow": "剛剛", + "MinutesAgo": "{time}分鐘前", + "HoursAgo": "{time}小時前", + "DaysAgo": "{time}天前", + "LongTimeAgo": "很久以前" + }, "AddAlas": { "PopupTitle": "添加新的設定", "NewName": "新設定的檔案名", diff --git a/module/config/stored/classes.py b/module/config/stored/classes.py index 227fb3a00..c1df02fb9 100644 --- a/module/config/stored/classes.py +++ b/module/config/stored/classes.py @@ -1,4 +1,3 @@ -import time from datetime import datetime from functools import cached_property as functools_cached_property @@ -111,29 +110,6 @@ class StoredBase: from module.logger import logger logger.attr(self._name, self._stored) - def dashboard(self) -> str: - """ - Return a string to show on GUI - """ - return 'None' - - def readable_time(self): - diff = self.time.timestamp() - time.time() - if diff < -1: - return '', 'TimeError' - elif diff < 60: - # < 1 min - return '', 'JustNow' - elif diff < 3600: - return str(int(diff // 60)), 'MinutesAgo' - elif diff < 86400: - return str(int(diff // 86400)), 'HoursAgo' - elif diff < 129600: - return str(int(diff // 129600)), 'DaysAgo' - else: - # > 15 days - return '', 'LongTimeAgo' - class StoredExpiredAt0400(StoredBase): def is_expired(self): @@ -154,11 +130,11 @@ class StoredCounter(StoredBase): FIXED_TOTAL = 0 - def set(self, current, total=0): + def set(self, value, total=0): if self.FIXED_TOTAL: total = self.FIXED_TOTAL with self._config.multi_set(): - self.value = current + self.value = value self.total = total def to_counter(self) -> str: @@ -170,6 +146,13 @@ class StoredCounter(StoredBase): def get_remain(self) -> int: return self.total - self.value + @cached_property + def _attrs(self) -> dict: + attrs = super()._attrs + if self.FIXED_TOTAL: + attrs['total'] = self.FIXED_TOTAL + return attrs + @functools_cached_property def _stored(self): stored = super()._stored diff --git a/module/webui/app.py b/module/webui/app.py index 181d966f0..5c25cdd05 100644 --- a/module/webui/app.py +++ b/module/webui/app.py @@ -88,11 +88,13 @@ task_handler = TaskHandler() class AlasGUI(Frame): ALAS_MENU: Dict[str, Dict[str, List[str]]] ALAS_ARGS: Dict[str, Dict[str, Dict[str, Dict[str, str]]]] + ALAS_STORED: Dict[str, Dict[str, Dict[str, str]]] theme = "default" def initial(self) -> None: self.ALAS_MENU = read_file(filepath_args("menu", self.alas_mod)) self.ALAS_ARGS = read_file(filepath_args("args", self.alas_mod)) + self.ALAS_STORED = read_file(filepath_args("stored", self.alas_mod)) self._init_alas_config_watcher() def __init__(self) -> None: @@ -318,6 +320,35 @@ class AlasGUI(Frame): color="navigator", ) + def set_dashboard(self, arg, arg_dict, config): + i18n = arg_dict.get('i18n') + if i18n: + name = t(i18n) + else: + name = arg + color = arg_dict.get("color", "#777777") + nodata = t("Gui.Dashboard.NoData") + + def set_value(dic): + if "total" in dic.get("attrs", []) and config.get("total") is not None: + return [ + put_text(config.get("value", nodata)).style("--dashboard-value--"), + put_text(f' / {config.get("total", "")}').style("--dashboard-time--"), + ] + else: + return [ + put_text(config.get("value", nodata)).style("--dashboard-value--"), + ] + + with use_scope(f"dashboard-row-{arg}", clear=True): + put_html(f'
'), + put_scope(f"dashboard-content-{arg}", [ + put_scope(f"dashboard-value-{arg}", set_value(arg_dict)), + put_scope(f"dashboard-time-{arg}", [ + put_text(f"{name} - {lang.readable_time(config.get('time', ''))}").style("--dashboard-time--"), + ]) + ]) + @use_scope("content", clear=True) def alas_overview(self) -> None: self.init_menu(name="Overview") @@ -374,20 +405,19 @@ class AlasGUI(Frame): log = RichLog("log") with use_scope("logs"): - put_scope( - "log-bar", - [ - put_text(t("Gui.Overview.Log")).style( - "font-size: 1.25rem; margin: auto .5rem auto;" - ), - put_scope( - "log-bar-btns", - [ - put_scope("log_scroll_btn"), - ], - ), - ], - ), + put_scope("log-bar", [ + put_scope("log-title", [ + put_text(t("Gui.Overview.Log")).style("font-size: 1.25rem; margin: auto .5rem auto;"), + put_scope("log-title-btns", [ + put_scope("log_scroll_btn"), + ]), + ]), + put_html('
'), + put_scope("dashboard", [ + # Empty dashboard, values will be updated in alas_update_overview_task() + put_scope(f"dashboard-row-{arg}", []) for arg in self.ALAS_STORED.keys() + ]) + ]) put_scope("log", [put_html("")]) log.console.width = log.get_width() @@ -501,6 +531,7 @@ class AlasGUI(Frame): self.alas_config.load() self.alas_config.get_next_task() + alive = self.alas.alive if len(self.alas_config.pending_task) >= 1: if self.alas.alive: running = self.alas_config.pending_task[:1] @@ -528,27 +559,39 @@ class AlasGUI(Frame): color="off", ) - clear("running_tasks") - clear("pending_tasks") - clear("waiting_tasks") - with use_scope("running_tasks"): - if running: - for task in running: - put_task(task) - else: - put_text(t("Gui.Overview.NoTask")).style("--overview-notask-text--") - with use_scope("pending_tasks"): - if pending: - for task in pending: - put_task(task) - else: - put_text(t("Gui.Overview.NoTask")).style("--overview-notask-text--") - with use_scope("waiting_tasks"): - if waiting: - for task in waiting: - put_task(task) - else: - put_text(t("Gui.Overview.NoTask")).style("--overview-notask-text--") + if self.scope_expired("scheduler_alive", alive) \ + or self.scope_expired("pending_task", self.alas_config.pending_task): + clear("running_tasks") + clear("pending_tasks") + clear("waiting_tasks") + with use_scope("running_tasks"): + if running: + for task in running: + put_task(task) + else: + put_text(t("Gui.Overview.NoTask")).style("--overview-notask-text--") + with use_scope("pending_tasks"): + if pending: + for task in pending: + put_task(task) + else: + put_text(t("Gui.Overview.NoTask")).style("--overview-notask-text--") + with use_scope("waiting_tasks"): + if waiting: + for task in waiting: + put_task(task) + else: + put_text(t("Gui.Overview.NoTask")).style("--overview-notask-text--") + self.scope_add("scheduler_alive", alive) + self.scope_add("pending_task", self.alas_config.pending_task) + + for arg, arg_dict in self.ALAS_STORED.items(): + path = arg_dict["path"] + if self.scope_expired_then_add( + key=f"dashboard-time-value-{arg}", + value=lang.readable_time(deep_get(self.alas_config.data, keys=f"{path}.time")) + ): + self.set_dashboard(arg, arg_dict, deep_get(self.alas_config.data, keys=path, default={})) @use_scope("content", clear=True) def alas_daemon_overview(self, task: str) -> None: diff --git a/module/webui/base.py b/module/webui/base.py index 0f43cbeba..29619551b 100644 --- a/module/webui/base.py +++ b/module/webui/base.py @@ -1,3 +1,5 @@ +from typing import Any, Dict + from pywebio.output import clear, put_html, put_scope, put_text, use_scope from pywebio.session import defer_call, info, run_js @@ -13,12 +15,34 @@ class Base: self.is_mobile = info.user_agent.is_mobile # Task handler self.task_handler = WebIOTaskHandler() + # Record scopes to reduce data transfer to frontend + # Key: scope name, value: last update time + self.scope: Dict[str, Any] = {} defer_call(self.stop) def stop(self) -> None: self.alive = False self.task_handler.stop() + def scope_clear(self): + self.scope = {} + + def scope_add(self, key, value): + self.scope[key] = value + + def scope_expired(self, key, value) -> bool: + try: + return self.scope[key] != value + except KeyError: + return True + + def scope_expired_then_add(self, key, value) -> bool: + if self.scope_expired(key, value): + self.scope_add(key, value) + return True + else: + return False + class Frame(Base): def __init__(self) -> None: @@ -33,6 +57,7 @@ class Frame(Base): name: button name(label) to be highlight """ self.visible = True + self.scope_clear() self.task_handler.remove_pending_task() clear("menu") if expand_menu: @@ -50,6 +75,7 @@ class Frame(Base): """ self.visible = True self.page = name + self.scope_clear() self.task_handler.remove_pending_task() clear("content") if collapse_menu: diff --git a/module/webui/lang.py b/module/webui/lang.py index 06c5c5dd5..c1134f034 100644 --- a/module/webui/lang.py +++ b/module/webui/lang.py @@ -1,8 +1,9 @@ +import time from typing import Dict from module.config.utils import * -from module.webui.setting import State from module.webui.fake import list_mod +from module.webui.setting import State LANG = "zh-CN" TRANSLATE_MODE = False @@ -67,3 +68,33 @@ def reload(): for key in dic_lang["ja-JP"].keys(): if dic_lang["ja-JP"][key] == key: dic_lang["ja-JP"][key] = dic_lang["en-US"][key] + + +def readable_time(before: str) -> str: + """ + Convert "2023-08-29 12:30:53" to "3 Minutes Ago" + """ + if not before: + return t("Gui.Dashboard.NoData") + try: + ti = datetime.fromisoformat(before) + except ValueError: + return t("Gui.Dashboard.TimeError") + if ti == DEFAULT_TIME: + return t("Gui.Dashboard.NoData") + + diff = time.time() - ti.timestamp() + if diff < -1: + return t("Gui.Dashboard.TimeError") + elif diff < 60: + # < 1 min + return t("Gui.Dashboard.JustNow") + elif diff < 3600: + return t("Gui.Dashboard.MinutesAgo", time=int(diff // 60)) + elif diff < 86400: + return t("Gui.Dashboard.HoursAgo", time=int(diff // 3600)) + elif diff < 129600: + return t("Gui.Dashboard.DaysAgo", time=int(diff // 86400)) + else: + # > 15 days + return t("Gui.Dashboard.LongTimeAgo")