From aadbe6855e92a4ac1af40c1e58a3991c52e2e436 Mon Sep 17 00:00:00 2001 From: Stefan Allius <122395479+s-allius@users.noreply.github.com> Date: Thu, 1 May 2025 19:34:46 +0200 Subject: [PATCH] S allius/issue394 (#400) * store logging path in Config class * rename template files and page files * jump to referer page - after changing the language, we jump to the referer page, if the attribute exists * build and send list of log-files * rename Download page into Log files * initialize log-path in test config * improve dashboard unit tests - add log file tests - check content-languages after language switch * initialize config structure for log-file tests * add test log file to project * add sub_dir to test log path - non files must be skipped. To test this we add a sub directory to the test log directory * add german translations * set quart debug flag for debug versions * update changelog --- CHANGELOG.md | 2 + app/src/cnf/config.py | 7 +- app/src/server.py | 5 +- app/src/web/conn_table.py | 4 +- app/src/web/i18n.py | 5 +- app/src/web/log_files.py | 52 ++++++++++++++ app/src/web/pages.py | 11 ++- app/src/web/templates/base.html.j2 | 8 +-- .../{index.html.j2 => page_index.html.j2} | 0 app/src/web/templates/page_logging.html.j2 | 11 +++ ...table.html.j2 => templ_conn_table.html.j2} | 2 +- .../templates/templ_log_files_list.html.j2 | 31 +++++++++ ..._list.html.j2 => templ_notes_list.html.j2} | 0 app/tests/log/sub_dir/not_reachable.txt | 0 app/tests/log/test.txt | 19 ++++++ app/tests/test_inverter_g3p.py | 1 + app/tests/test_web_route.py | 67 ++++++++++++++++--- app/translations/de/LC_MESSAGES/messages.po | 42 +++++++++--- 18 files changed, 235 insertions(+), 32 deletions(-) create mode 100644 app/src/web/log_files.py rename app/src/web/templates/{index.html.j2 => page_index.html.j2} (100%) create mode 100644 app/src/web/templates/page_logging.html.j2 rename app/src/web/templates/{conn_table.html.j2 => templ_conn_table.html.j2} (97%) create mode 100644 app/src/web/templates/templ_log_files_list.html.j2 rename app/src/web/templates/{notes_list.html.j2 => templ_notes_list.html.j2} (100%) create mode 100644 app/tests/log/sub_dir/not_reachable.txt create mode 100644 app/tests/log/test.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 01bf55c..eedb850 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] +- Dashboard: add Log-File page +- Dashboard: add Connection page - add web UI to add-on - allow `Y00` serial numbers for GEN3PLUS devices diff --git a/app/src/cnf/config.py b/app/src/cnf/config.py index 88a0ab0..5207c7d 100644 --- a/app/src/cnf/config.py +++ b/app/src/cnf/config.py @@ -162,12 +162,13 @@ class Config(): ) @classmethod - def init(cls, def_reader: ConfigIfc) -> None | str: + def init(cls, def_reader: ConfigIfc, log_path: str = '') -> None | str: '''Initialise the Proxy-Config Copy the internal default config file into the config directory and initialise the Config with the default configuration ''' cls.err = None + cls.log_path = log_path cls.def_config = {} try: # make the default config transparaent by copying it @@ -247,3 +248,7 @@ here. The default config reader is handled in the Config.init method''' '''Check if the member is the default value''' return cls.act_config.get(member) == cls.def_config.get(member) + + @classmethod + def get_log_path(cls) -> str: + return cls.log_path diff --git a/app/src/server.py b/app/src/server.py index d1e62b0..32c06aa 100644 --- a/app/src/server.py +++ b/app/src/server.py @@ -179,7 +179,8 @@ def main(): # pragma: no cover asyncio.set_event_loop(loop) # read config file - Config.init(ConfigReadToml(src_dir + "cnf/default_config.toml")) + Config.init(ConfigReadToml(src_dir + "cnf/default_config.toml"), + log_path=args.log_path) ConfigReadEnv() ConfigReadJson(args.config_path + "config.json") ConfigReadToml(args.config_path + "config.toml") @@ -214,7 +215,7 @@ def main(): # pragma: no cover ProxyState.set_up(True) logging.info("Start Quart") app.run(host='0.0.0.0', port=8127, use_reloader=False, loop=loop, - debug=True,) + debug=log_level == logging.DEBUG) logging.info("Quart stopped") except KeyboardInterrupt: diff --git a/app/src/web/conn_table.py b/app/src/web/conn_table.py index 278227b..62c52f7 100644 --- a/app/src/web/conn_table.py +++ b/app/src/web/conn_table.py @@ -75,8 +75,8 @@ async def data_fetch(): "proxy-cnt": f"

{Infos.get_counter('ProxyMode_Cnt')}

", "emulation-cnt": f"

{Infos.get_counter('EmuMode_Cnt')}

", } - data["conn-table"] = await render_template('conn_table.html.j2', + data["conn-table"] = await render_template('templ_conn_table.html.j2', table=get_table_data()) - data["notes-list"] = await render_template('notes_list.html.j2') + data["notes-list"] = await render_template('templ_notes_list.html.j2') return data diff --git a/app/src/web/i18n.py b/app/src/web/i18n.py index c25fd1d..3520983 100644 --- a/app/src/web/i18n.py +++ b/app/src/web/i18n.py @@ -38,5 +38,8 @@ def utility_processor(): async def set_language(language=None): if language in LANGUAGES: session['language'] = language - return redirect('../#') + + rsp = redirect(request.referrer if request.referrer else '../#') + rsp.content_language = language + return rsp return abort(404) diff --git a/app/src/web/log_files.py b/app/src/web/log_files.py new file mode 100644 index 0000000..82fe0c4 --- /dev/null +++ b/app/src/web/log_files.py @@ -0,0 +1,52 @@ +from quart import render_template +from quart_babel import format_datetime, format_decimal +from quart.helpers import send_from_directory +from werkzeug.utils import secure_filename +from cnf.config import Config +import os + +from . import web + + +def _get_file(file): + '''build one row for the connection table''' + entry = {} + entry['name'] = file.name + stat = file.stat() + entry['size'] = format_decimal(stat.st_size) + entry['date'] = stat.st_mtime + entry['created'] = format_datetime(stat.st_ctime, format="short") + entry['modified'] = format_datetime(stat.st_mtime, format="short") + return entry + + +def get_list_data(): + '''build the connection table''' + file_list = [] + with os.scandir(Config.get_log_path()) as it: + for entry in it: + if entry.is_file(): + file_list.append(_get_file(entry)) + + file_list.sort(key=lambda x: x['date'], reverse=True) + return file_list + + +@web.route('/file-fetch') +async def file_fetch(): + data = { + "update-time": format_datetime(format="medium"), + } + data["file-list"] = await render_template('templ_log_files_list.html.j2', + dir_list=get_list_data()) + + data["notes-list"] = await render_template('templ_notes_list.html.j2') + return data + + +@web.route('/send-file/') +async def send(file): + return await send_from_directory( + directory=Config.get_log_path(), + file_name=secure_filename(file), + as_attachment=True) diff --git a/app/src/web/pages.py b/app/src/web/pages.py index d2da0e4..b16c189 100644 --- a/app/src/web/pages.py +++ b/app/src/web/pages.py @@ -7,10 +7,17 @@ from . import web @web.route('/') async def index(): return await render_template( - 'index.html.j2', - fetch_url=url_for('web.data_fetch')) + 'page_index.html.j2', + fetch_url=url_for('.data_fetch')) @web.route('/page') async def empty(): return await render_template('empty.html.j2') + + +@web.route('/logging') +async def logging(): + return await render_template( + 'page_logging.html.j2', + fetch_url=url_for('.file_fetch')) diff --git a/app/src/web/templates/base.html.j2 b/app/src/web/templates/base.html.j2 index d1cf711..55f8e3c 100644 --- a/app/src/web/templates/base.html.j2 +++ b/app/src/web/templates/base.html.j2 @@ -55,16 +55,16 @@
-   {{_('Connections')}} -   MQTT -   Downloads +   {{_('Connections')}} +   MQTT +   {{_('Log Files')}}
- +
diff --git a/app/src/web/templates/index.html.j2 b/app/src/web/templates/page_index.html.j2 similarity index 100% rename from app/src/web/templates/index.html.j2 rename to app/src/web/templates/page_index.html.j2 diff --git a/app/src/web/templates/page_logging.html.j2 b/app/src/web/templates/page_logging.html.j2 new file mode 100644 index 0000000..701b3b5 --- /dev/null +++ b/app/src/web/templates/page_logging.html.j2 @@ -0,0 +1,11 @@ +{% extends 'base.html.j2' %} + +{% block title %} TSUN Proxy - Downloads {% endblock title%} +{% block menu3_class %}w3-blue{% endblock %} +{% block headline %}  {{_('Log Files')}}{% endblock headline %} +{% block content %} +
+{% endblock content%} + +{% block footer %}{% endblock footer %} + diff --git a/app/src/web/templates/conn_table.html.j2 b/app/src/web/templates/templ_conn_table.html.j2 similarity index 97% rename from app/src/web/templates/conn_table.html.j2 rename to app/src/web/templates/templ_conn_table.html.j2 index c6c5f62..6dd69b1 100644 --- a/app/src/web/templates/conn_table.html.j2 +++ b/app/src/web/templates/templ_conn_table.html.j2 @@ -12,7 +12,7 @@ {% endif %} {%- endmacro%} -
Connections
+
{{_('Connections')}}
{% if table.thead is defined%} diff --git a/app/src/web/templates/templ_log_files_list.html.j2 b/app/src/web/templates/templ_log_files_list.html.j2 new file mode 100644 index 0000000..9df7ee5 --- /dev/null +++ b/app/src/web/templates/templ_log_files_list.html.j2 @@ -0,0 +1,31 @@ +
+{% for file in dir_list %} +
+ +
+
+

{{file.name}}

+
+ +
+ {% for idx, name in [('created',_('Created')), ('modified', _('Modified')), ('size', _('Size'))]%} + + + + + {% endfor %} +
{{_(name)}}:{{file[idx]}}
+ + +
+ + +{% if 0 == (loop.index%4) and not last %} + +
+{% endif %} +{% endfor %} +
+ diff --git a/app/src/web/templates/notes_list.html.j2 b/app/src/web/templates/templ_notes_list.html.j2 similarity index 100% rename from app/src/web/templates/notes_list.html.j2 rename to app/src/web/templates/templ_notes_list.html.j2 diff --git a/app/tests/log/sub_dir/not_reachable.txt b/app/tests/log/sub_dir/not_reachable.txt new file mode 100644 index 0000000..e69de29 diff --git a/app/tests/log/test.txt b/app/tests/log/test.txt new file mode 100644 index 0000000..d7c6d5e --- /dev/null +++ b/app/tests/log/test.txt @@ -0,0 +1,19 @@ +2025-04-30 00:01:23 INFO | root | Server "proxy - unknown" will be started +2025-04-30 00:01:23 INFO | root | current dir: /Users/sallius/tsun/tsun-gen3-proxy +2025-04-30 00:01:23 INFO | root | config_path: ./config/ +2025-04-30 00:01:23 INFO | root | json_config: None +2025-04-30 00:01:23 INFO | root | toml_config: None +2025-04-30 00:01:23 INFO | root | trans_path: ../translations/ +2025-04-30 00:01:23 INFO | root | rel_urls: False +2025-04-30 00:01:23 INFO | root | log_path: ./log/ +2025-04-30 00:01:23 INFO | root | log_backups: unlimited +2025-04-30 00:01:23 INFO | root | LOG_LVL : None +2025-04-30 00:01:23 INFO | root | ****** +2025-04-30 00:01:23 INFO | root | Read from /Users/sallius/tsun/tsun-gen3-proxy/app/src/cnf/default_config.toml => ok +2025-04-30 00:01:23 INFO | root | Read from environment => ok +2025-04-30 00:01:23 INFO | root | Read from ./config/config.json => n/a +2025-04-30 00:01:23 INFO | root | Read from ./config/config.toml => n/a +2025-04-30 00:01:23 INFO | root | ****** +2025-04-30 00:01:23 INFO | root | listen on port: 5005 for inverters +2025-04-30 00:01:23 INFO | root | listen on port: 10000 for inverters +2025-04-30 00:01:23 INFO | root | Start Quart \ No newline at end of file diff --git a/app/tests/test_inverter_g3p.py b/app/tests/test_inverter_g3p.py index f16a2d8..f1bb398 100644 --- a/app/tests/test_inverter_g3p.py +++ b/app/tests/test_inverter_g3p.py @@ -37,6 +37,7 @@ def config_conn(): }, 'solarman':{'enabled': True, 'host': 'test_cloud.local', 'port': 1234}, 'inverters':{'allow_all':True} } + Config.log_path='app/tests/log/' @pytest.fixture(scope="module", autouse=True) def module_init(): diff --git a/app/tests/test_web_route.py b/app/tests/test_web_route.py index 966f57e..9b36649 100644 --- a/app/tests/test_web_route.py +++ b/app/tests/test_web_route.py @@ -5,6 +5,7 @@ from web import Web, web from async_stream import AsyncStreamClient from gen3plus.inverter_g3p import InverterG3P from test_inverter_g3p import FakeReader, FakeWriter, config_conn +from cnf.config import Config pytest_plugins = ('pytest_asyncio',) @@ -64,6 +65,13 @@ async def test_rel_page(client): assert response.mimetype == 'text/html' web.build_relative_urls = False +@pytest.mark.asyncio +async def test_logging(client): + """Test the logging page route.""" + response = await client.get('/logging') + assert response.status_code == 200 + assert response.mimetype == 'text/html' + @pytest.mark.asyncio async def test_favicon96(client): """Test the favicon-96x96.png route.""" @@ -101,7 +109,7 @@ async def test_manifest(client): @pytest.mark.asyncio async def test_data_fetch(create_inverter): - """Test the healthy route.""" + """Test the data-fetch route.""" _ = create_inverter client = app.test_client() response = await client.get('/data-fetch') @@ -112,7 +120,7 @@ async def test_data_fetch(create_inverter): @pytest.mark.asyncio async def test_data_fetch1(create_inverter_server): - """Test the healthy route.""" + """Test the data-fetch route with server connection.""" _ = create_inverter_server client = app.test_client() response = await client.get('/data-fetch') @@ -123,7 +131,7 @@ async def test_data_fetch1(create_inverter_server): @pytest.mark.asyncio async def test_data_fetch2(create_inverter_client): - """Test the healthy route.""" + """Test the data-fetch route with client connection.""" _ = create_inverter_client client = app.test_client() response = await client.get('/data-fetch') @@ -134,9 +142,11 @@ async def test_data_fetch2(create_inverter_client): @pytest.mark.asyncio async def test_language_en(client): - """Test the language/en route.""" - response = await client.get('/language/en') + """Test the language/en route and cookie.""" + response = await client.get('/language/en', headers={'referer': '/index'}) assert response.status_code == 302 + assert response.content_language.pop() == 'en' + assert response.location == '/index' assert response.mimetype == 'text/html' client.set_cookie('test', key='language', value='de') @@ -146,14 +156,53 @@ async def test_language_en(client): @pytest.mark.asyncio async def test_language_de(client): - """Test the language/en route.""" - response = await client.get('/language/de') + """Test the language/de route.""" + response = await client.get('/language/de', headers={'referer': '/'}) assert response.status_code == 302 + assert response.content_language.pop() == 'de' + assert response.location == '/' assert response.mimetype == 'text/html' + @pytest.mark.asyncio async def test_language_unknown(client): - """Test the language/en route.""" - response = await client.get('/language/unknonw') + """Test the language/unknown route.""" + response = await client.get('/language/unknown') assert response.status_code == 404 assert response.mimetype == 'text/html' + + +@pytest.mark.asyncio +async def test_file_fetch(client, config_conn): + """Test the data-fetch route.""" + _ = config_conn + assert Config.log_path == 'app/tests/log/' + response = await client.get('/file-fetch') + assert response.status_code == 200 + + +@pytest.mark.asyncio +async def test_send_file(client, config_conn): + """Test the send-file route.""" + _ = config_conn + assert Config.log_path == 'app/tests/log/' + response = await client.get('/send-file/test.txt') + assert response.status_code == 200 + + +@pytest.mark.asyncio +async def test_missing_send_file(client, config_conn): + """Test the send-file route (file not found).""" + _ = config_conn + assert Config.log_path == 'app/tests/log/' + response = await client.get('/send-file/no_file.log') + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_invalid_send_file(client, config_conn): + """Test the send-file route (invalid filename).""" + _ = config_conn + assert Config.log_path == 'app/tests/log/' + response = await client.get('/send-file/../test_web_route.py') + assert response.status_code == 404 diff --git a/app/translations/de/LC_MESSAGES/messages.po b/app/translations/de/LC_MESSAGES/messages.po index a18f69e..b09eca8 100644 --- a/app/translations/de/LC_MESSAGES/messages.po +++ b/app/translations/de/LC_MESSAGES/messages.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: tsun-gen3-proxy 0.14.0\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2025-04-28 21:00+0200\n" +"POT-Creation-Date: 2025-05-01 17:48+0200\n" "PO-Revision-Date: 2025-04-18 16:24+0200\n" "Last-Translator: FULL NAME \n" "Language: de\n" @@ -44,42 +44,64 @@ msgid "Updated:" msgstr "Aktualisiert:" #: src/web/templates/base.html.j2:58 +#: src/web/templates/templ_conn_table.html.j2:15 msgid "Connections" msgstr "Verbindungen" -#: src/web/templates/index.html.j2:5 +#: src/web/templates/base.html.j2:60 src/web/templates/page_logging.html.j2:5 +msgid "Log Files" +msgstr "Log Dateien" + +#: src/web/templates/page_index.html.j2:5 msgid "Proxy Connection Overview" msgstr "Proxy Verbindungen" -#: src/web/templates/index.html.j2:16 +#: src/web/templates/page_index.html.j2:16 msgid "Server Mode" msgstr "Server Modus" -#: src/web/templates/index.html.j2:17 +#: src/web/templates/page_index.html.j2:17 msgid "Established from device to proxy" msgstr "Vom Gerät zum Proxy aufgebaut" -#: src/web/templates/index.html.j2:27 +#: src/web/templates/page_index.html.j2:27 msgid "Client Mode" msgstr "Client Modus" -#: src/web/templates/index.html.j2:28 +#: src/web/templates/page_index.html.j2:28 msgid "Established from proxy to device" msgstr "Vom Proxy zum Gerät aufgebaut" -#: src/web/templates/index.html.j2:38 +#: src/web/templates/page_index.html.j2:38 msgid "Proxy Mode" msgstr "Proxy Modus" -#: src/web/templates/index.html.j2:39 +#: src/web/templates/page_index.html.j2:39 msgid "Forwarding data to cloud" msgstr "Weiterleitung in die Cloud" -#: src/web/templates/index.html.j2:49 +#: src/web/templates/page_index.html.j2:49 msgid "Emu Mode" msgstr "Emu Modus" -#: src/web/templates/index.html.j2:50 +#: src/web/templates/page_index.html.j2:50 msgid "Emulation sends data to cloud" msgstr "Emulation sendet in die Cloud" +#: src/web/templates/templ_log_files_list.html.j2:11 +msgid "Created" +msgstr "Erzeugt" + +#: src/web/templates/templ_log_files_list.html.j2:11 +msgid "Modified" +msgstr "Modifiziert" + +#: src/web/templates/templ_log_files_list.html.j2:11 +msgid "Size" +msgstr "Größe" + +#: src/web/templates/templ_log_files_list.html.j2:20 +msgid "Download File" +msgstr "Datei Download" + +