Add: Dashboard on GUI

This commit is contained in:
LmeSzinc 2023-08-29 22:35:13 +08:00
parent 9e2445d5b1
commit a087189e54
18 changed files with 380 additions and 69 deletions

View File

@ -59,4 +59,11 @@
#pywebio-scope-waiting,
#pywebio-scope-log {
overflow-y: auto;
}
}
[id^="pywebio-scope-dashboard-row-"] {
display: flex;
flex-grow: 1;
min-width: 4rem;
max-width: 50%;
}

View File

@ -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;
}
}

View File

@ -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;

View File

@ -152,4 +152,8 @@ pre.rich-traceback-code {
*[style*="--arg-help--"],
[id^="pywebio-scope-group_"] > p + p {
color: #adb5bd;
}
*[style*="--dashboard-time--"] {
color: #adb5bd;
}

View File

@ -151,4 +151,8 @@ pre.rich-traceback-code {
*[style*="--arg-help--"],
[id^="pywebio-scope-group_"] > p + p {
color: #777777;
}
*[style*="--dashboard-time--"] {
color: #777777;
}

View File

@ -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",

View File

@ -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

View File

@ -52,6 +52,16 @@ Overview:
Waiting:
NoTask:
Dashboard:
# From lang.readable_time()
NoData:
TimeError:
JustNow:
MinutesAgo:
HoursAgo:
DaysAgo:
LongTimeAgo:
AddAlas:
PopupTitle:
NewName:

View File

@ -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"
}
}

View File

@ -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:

View File

@ -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",

View File

@ -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": "コンフィグ名",

View File

@ -695,6 +695,15 @@
"Waiting": "等待中",
"NoTask": "无任务"
},
"Dashboard": {
"NoData": "无数据",
"TimeError": "时间错误",
"JustNow": "刚刚",
"MinutesAgo": "{time}分钟前",
"HoursAgo": "{time}小时前",
"DaysAgo": "{time}天前",
"LongTimeAgo": "很久以前"
},
"AddAlas": {
"PopupTitle": "添加新配置",
"NewName": "新的配置文件名",

View File

@ -695,6 +695,15 @@
"Waiting": "等待中",
"NoTask": "無任務"
},
"Dashboard": {
"NoData": "無數據",
"TimeError": "時間錯誤",
"JustNow": "剛剛",
"MinutesAgo": "{time}分鐘前",
"HoursAgo": "{time}小時前",
"DaysAgo": "{time}天前",
"LongTimeAgo": "很久以前"
},
"AddAlas": {
"PopupTitle": "添加新的設定",
"NewName": "新設定的檔案名",

View File

@ -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

View File

@ -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'<div><div class="dashboard-icon" style="background-color:{color}"></div>'),
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('<hr class="hr-group">'),
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:

View File

@ -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:

View File

@ -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")