Compare commits

...

7 Commits

Author SHA1 Message Date
Stefan Allius
514c0c4985 cleanup 2025-05-02 19:45:06 +02:00
Stefan Allius
5460f6f6f9 increase test coverage 2025-05-02 19:37:36 +02:00
Stefan Allius
b081060033 add more translations 2025-05-02 17:07:30 +02:00
Stefan Allius
f3e61ba18e add trash icon which unhide the modal 2025-05-02 17:06:32 +02:00
Stefan Allius
14042ef3ac add modal for senity check before file deletion 2025-05-02 17:05:31 +02:00
Stefan Allius
97b19a6ffc add route for log file deletion 2025-05-02 17:04:03 +02:00
Stefan Allius
aadbe6855e 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
2025-05-01 19:34:46 +02:00
18 changed files with 315 additions and 32 deletions

View File

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

View File

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

View File

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

View File

@@ -75,8 +75,8 @@ async def data_fetch():
"proxy-cnt": f"<h3>{Infos.get_counter('ProxyMode_Cnt')}</h3>",
"emulation-cnt": f"<h3>{Infos.get_counter('EmuMode_Cnt')}</h3>",
}
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

View File

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

61
app/src/web/log_files.py Normal file
View File

@@ -0,0 +1,61 @@
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/<file>')
async def send(file):
return await send_from_directory(
directory=Config.get_log_path(),
file_name=secure_filename(file),
as_attachment=True)
@web.route('/del-file/<file>', methods=['DELETE'])
async def delete(file):
try:
os.remove(Config.get_log_path() + secure_filename(file))
except OSError:
return 'File not found', 404
return '', 204

View File

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

View File

@@ -55,16 +55,16 @@
</div>
<div class="w3-bar-block">
<button href="#" class="w3-bar-item w3-button w3-padding-16 w3-hide-large w3-dark-grey w3-hover-black" onclick="w3_close()" title="close menu"><i class="fa fa-remove fa-fw"></i>  Close Menu</button>
<a href="{{ url_for('web.index')}}" class="w3-bar-item w3-button w3-padding {% block menu1_class %}{% endblock %}"><i class="fa fa-network-wired fa-fw"></i>  {{_('Connections')}}</a>
<a href="{{ url_for('web.empty')}}" class="w3-bar-item w3-button w3-padding {% block menu2_class %}{% endblock %}"><i class="fa fa-database fa-fw"></i>  MQTT</a>
<a href="{{ url_for('web.empty')}}" class="w3-bar-item w3-button w3-padding"><i class="fa fa-file-export fa-fw {% block menu3_class %}{% endblock %}"></i>  Downloads</a>
<a href="{{ url_for('.index')}}" class="w3-bar-item w3-button w3-padding {% block menu1_class %}{% endblock %}"><i class="fa fa-network-wired fa-fw"></i>  {{_('Connections')}}</a>
<a href="{{ url_for('.empty')}}" class="w3-bar-item w3-button w3-padding {% block menu2_class %}{% endblock %}"><i class="fa fa-database fa-fw"></i>  MQTT</a>
<a href="{{ url_for('.logging')}}" class="w3-bar-item w3-button w3-padding {% block menu3_class %}{% endblock %}"><i class="fa fa-file-export fa-fw"></i>  {{_('Log Files')}}</a>
</div>
</nav>
<!-- Overlay effect when opening sidebar on small screens -->
<button class="w3-overlay w3-hide-large w3-animate-opacity" onclick="w3_close()" style="cursor:pointer" title="close side menu" id="myOverlay"></button>
<!-- !PAGE CONTENT! -->
<div class="w3-main" style="margin-left:250px;margin-top:43px;">

View File

@@ -0,0 +1,31 @@
{% extends 'base.html.j2' %}
{% block title %} TSUN Proxy - Downloads {% endblock title%}
{% block menu3_class %}w3-blue{% endblock %}
{% block headline %}<i class="fa fa-file-export fa-fw"></i>  {{_('Log Files')}}{% endblock headline %}
{% block content %}
<div id="id01" class="w3-modal">
<div class="w3-modal-content" style="width:600px">
<div class="w3-container w3-padding-24">
<h2>{{_("Do you really want to delete the log file")}}:<br><b><span id="id03"></span></b> ?</h2>
<div class="w3-bar">
<button id="id02" class="w3-button w3-red" onclick="deleteFile(); document.getElementById('id01').style.display='none'">{{_('Delete File</button')}}>
<button class="w3-button w3-grey w3-right" onclick="document.getElementById('id01').style.display='none'">{{_('Abort')}}</button>
</div>
</div>
</div>
</div>
<div class="w3-container" id="file-list"></div>
<script>
function deleteFile() {
fname = document.getElementById('id02').href;
fetch(fname, {method: 'DELETE'})
.then(fetch_data())
}
</script>
{% endblock content%}
{% block footer %}{% endblock footer %}

View File

@@ -12,7 +12,7 @@
{% endif %}
{%- endmacro%}
<h5>Connections</h5>
<h5>{{_('Connections')}}</h5>
<table class="w3-table w3-striped w3-bordered w3-border w3-hoverable w3-white">
{% if table.thead is defined%}
<thead>

View File

@@ -0,0 +1,33 @@
<div class="w3-row-padding w3-margin-bottom">
{% for file in dir_list %}
<div class="w3-quarter w3-margin-bottom">
<div class="w3-card-4">
<header class="w3-container w3-blue" style="min-height:80px">
<h4>{{file.name}}</h4>
</header>
<table class="w3-table">
{% for idx, name in [('created',_('Created')), ('modified', _('Modified')), ('size', _('Size'))]%}
<tr>
<td>{{_(name)}}:</td>
<td>{{file[idx]}}</td>
</tr>
{% endfor %}
</table>
<footer class="w3-blue">
<a href="{{ url_for('.send',file=file.name)}}" class="w3-button w3-hover-blue w3-hover-text-black"><i class="fa fa-file-download"></i>  {{_('Download File')}}</a>
<a class="w3-button w3-right w3-hover-blue w3-hover-text-black"
onclick="document.getElementById('id03').innerHTML='{{file.name}}'; document.getElementById('id02').href='{{ url_for('.delete',file=file.name)}}'; document.getElementById('id01').style.display='block';"><i class="fa fa-trash"></i></a>
</footer>
</div>
</div>
{% if 0 == (loop.index%4) and not last %}
</div>
<div class="w3-row-padding w3-margin-bottom">
{% endif %}
{% endfor %}
</div>

View File

19
app/tests/log/test.txt Normal file
View File

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

View File

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

View File

@@ -5,6 +5,9 @@ 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
from mock import patch
import os, errno
pytest_plugins = ('pytest_asyncio',)
@@ -64,6 +67,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 +111,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 +122,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 +133,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 +144,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 +158,89 @@ 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
@pytest.fixture
def patch_os_remove_err():
def new_remove(file_path: str):
raise OSError(errno.ENOENT, os.strerror(errno.ENOENT), file_path)
with patch.object(os, 'remove', new_remove) as wrapped_os:
yield wrapped_os
@pytest.fixture
def patch_os_remove_ok():
def new_remove(file_path: str):
return
with patch.object(os, 'remove', new_remove) as wrapped_os:
yield wrapped_os
@pytest.mark.asyncio
async def test_del_file_ok(client, config_conn, patch_os_remove_ok):
"""Test the del-file route with no error."""
_ = config_conn
_ = patch_os_remove_ok
assert Config.log_path == 'app/tests/log/'
response = await client.delete ('/del-file/test.txt')
assert response.status_code == 204
@pytest.mark.asyncio
async def test_del_file_err(client, config_conn, patch_os_remove_err):
"""Test the send-file route with OSError."""
_ = config_conn
_ = patch_os_remove_err
assert Config.log_path == 'app/tests/log/'
response = await client.delete ('/del-file/test.txt')
assert response.status_code == 404

View File

@@ -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-02 17:00+0200\n"
"PO-Revision-Date: 2025-04-18 16:24+0200\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: de\n"
@@ -44,42 +44,75 @@ 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/page_logging.html.j2:10
msgid "Do you really want to delete the log file"
msgstr "Soll die Datei wirklich gelöscht werden"
#: src/web/templates/page_logging.html.j2:12
msgid "Delete File</button"
msgstr "File löschen"
#: src/web/templates/page_logging.html.j2:13
msgid "Abort"
msgstr "Abbruch"
#: 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"