Compare commits

..

13 Commits

Author SHA1 Message Date
Stefan Allius
bd31dfc15d fix unit test, increase coverage 2025-04-29 00:02:20 +02:00
Stefan Allius
81745313b7 disable french language for now 2025-04-28 23:34:26 +02:00
Stefan Allius
61191f43ba remove debug print 2025-04-28 23:28:17 +02:00
Stefan Allius
b8a39aac55 build relative urls for HA ingress 2025-04-28 23:06:13 +02:00
Stefan Allius
8375679777 translate connection table, fix icon 2025-04-28 21:06:33 +02:00
Stefan Allius
0c981f214d - clean target erases the *.pot
- don't modify the resurt of url_for() calls
- don't translate the language description
2025-04-28 20:45:19 +02:00
Stefan Allius
e51f54381f add translations to docker container 2025-04-27 22:21:47 +02:00
Stefan Allius
8942ad936f set a default key for secure cookies 2025-04-27 17:08:51 +02:00
Stefan Allius
8dbf51df49 increase unit test coverage 2025-04-27 16:55:25 +02:00
Stefan Allius
79c4981edb rename file, since only favicon routes are defined 2025-04-27 12:42:04 +02:00
Stefan Allius
0ef0c210ce move routes into the proper python src file 2025-04-27 12:39:05 +02:00
Stefan Allius
da04e700c7 build a web module for the dashboard
- load all python module from local dir
- initialize Blueprint and Babel
2025-04-27 12:19:33 +02:00
Stefan Allius
5e0aea3364 add button for languages setting 2025-04-27 01:26:34 +02:00
18 changed files with 32 additions and 315 deletions

View File

@@ -7,8 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [unreleased] ## [unreleased]
- Dashboard: add Log-File page
- Dashboard: add Connection page
- add web UI to add-on - add web UI to add-on
- allow `Y00` serial numbers for GEN3PLUS devices - allow `Y00` serial numbers for GEN3PLUS devices

View File

@@ -162,13 +162,12 @@ class Config():
) )
@classmethod @classmethod
def init(cls, def_reader: ConfigIfc, log_path: str = '') -> None | str: def init(cls, def_reader: ConfigIfc) -> None | str:
'''Initialise the Proxy-Config '''Initialise the Proxy-Config
Copy the internal default config file into the config directory Copy the internal default config file into the config directory
and initialise the Config with the default configuration ''' and initialise the Config with the default configuration '''
cls.err = None cls.err = None
cls.log_path = log_path
cls.def_config = {} cls.def_config = {}
try: try:
# make the default config transparaent by copying it # make the default config transparaent by copying it
@@ -248,7 +247,3 @@ here. The default config reader is handled in the Config.init method'''
'''Check if the member is the default value''' '''Check if the member is the default value'''
return cls.act_config.get(member) == cls.def_config.get(member) 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,8 +179,7 @@ def main(): # pragma: no cover
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
# read config file # 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() ConfigReadEnv()
ConfigReadJson(args.config_path + "config.json") ConfigReadJson(args.config_path + "config.json")
ConfigReadToml(args.config_path + "config.toml") ConfigReadToml(args.config_path + "config.toml")
@@ -215,7 +214,7 @@ def main(): # pragma: no cover
ProxyState.set_up(True) ProxyState.set_up(True)
logging.info("Start Quart") logging.info("Start Quart")
app.run(host='0.0.0.0', port=8127, use_reloader=False, loop=loop, app.run(host='0.0.0.0', port=8127, use_reloader=False, loop=loop,
debug=log_level == logging.DEBUG) debug=True,)
logging.info("Quart stopped") logging.info("Quart stopped")
except KeyboardInterrupt: except KeyboardInterrupt:

View File

@@ -75,8 +75,8 @@ async def data_fetch():
"proxy-cnt": f"<h3>{Infos.get_counter('ProxyMode_Cnt')}</h3>", "proxy-cnt": f"<h3>{Infos.get_counter('ProxyMode_Cnt')}</h3>",
"emulation-cnt": f"<h3>{Infos.get_counter('EmuMode_Cnt')}</h3>", "emulation-cnt": f"<h3>{Infos.get_counter('EmuMode_Cnt')}</h3>",
} }
data["conn-table"] = await render_template('templ_conn_table.html.j2', data["conn-table"] = await render_template('conn_table.html.j2',
table=get_table_data()) table=get_table_data())
data["notes-list"] = await render_template('templ_notes_list.html.j2') data["notes-list"] = await render_template('notes_list.html.j2')
return data return data

View File

@@ -38,8 +38,5 @@ def utility_processor():
async def set_language(language=None): async def set_language(language=None):
if language in LANGUAGES: if language in LANGUAGES:
session['language'] = language session['language'] = language
return redirect('../#')
rsp = redirect(request.referrer if request.referrer else '../#')
rsp.content_language = language
return rsp
return abort(404) return abort(404)

View File

@@ -1,61 +0,0 @@
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,17 +7,10 @@ from . import web
@web.route('/') @web.route('/')
async def index(): async def index():
return await render_template( return await render_template(
'page_index.html.j2', 'index.html.j2',
fetch_url=url_for('.data_fetch')) fetch_url=url_for('web.data_fetch'))
@web.route('/page') @web.route('/page')
async def empty(): async def empty():
return await render_template('empty.html.j2') 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>
<div class="w3-bar-block"> <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> <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('.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.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('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('.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> <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>
</div> </div>
</nav> </nav>
<!-- Overlay effect when opening sidebar on small screens --> <!-- 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> <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! --> <!-- !PAGE CONTENT! -->
<div class="w3-main" style="margin-left:250px;margin-top:43px;"> <div class="w3-main" style="margin-left:250px;margin-top:43px;">

View File

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

View File

@@ -1,31 +0,0 @@
{% 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

@@ -1,33 +0,0 @@
<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

@@ -1,19 +0,0 @@
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,7 +37,6 @@ def config_conn():
}, },
'solarman':{'enabled': True, 'host': 'test_cloud.local', 'port': 1234}, 'inverters':{'allow_all':True} '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) @pytest.fixture(scope="module", autouse=True)
def module_init(): def module_init():

View File

@@ -5,9 +5,6 @@ from web import Web, web
from async_stream import AsyncStreamClient from async_stream import AsyncStreamClient
from gen3plus.inverter_g3p import InverterG3P from gen3plus.inverter_g3p import InverterG3P
from test_inverter_g3p import FakeReader, FakeWriter, config_conn 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',) pytest_plugins = ('pytest_asyncio',)
@@ -67,13 +64,6 @@ async def test_rel_page(client):
assert response.mimetype == 'text/html' assert response.mimetype == 'text/html'
web.build_relative_urls = False 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 @pytest.mark.asyncio
async def test_favicon96(client): async def test_favicon96(client):
"""Test the favicon-96x96.png route.""" """Test the favicon-96x96.png route."""
@@ -111,7 +101,7 @@ async def test_manifest(client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_data_fetch(create_inverter): async def test_data_fetch(create_inverter):
"""Test the data-fetch route.""" """Test the healthy route."""
_ = create_inverter _ = create_inverter
client = app.test_client() client = app.test_client()
response = await client.get('/data-fetch') response = await client.get('/data-fetch')
@@ -122,7 +112,7 @@ async def test_data_fetch(create_inverter):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_data_fetch1(create_inverter_server): async def test_data_fetch1(create_inverter_server):
"""Test the data-fetch route with server connection.""" """Test the healthy route."""
_ = create_inverter_server _ = create_inverter_server
client = app.test_client() client = app.test_client()
response = await client.get('/data-fetch') response = await client.get('/data-fetch')
@@ -133,7 +123,7 @@ async def test_data_fetch1(create_inverter_server):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_data_fetch2(create_inverter_client): async def test_data_fetch2(create_inverter_client):
"""Test the data-fetch route with client connection.""" """Test the healthy route."""
_ = create_inverter_client _ = create_inverter_client
client = app.test_client() client = app.test_client()
response = await client.get('/data-fetch') response = await client.get('/data-fetch')
@@ -144,11 +134,9 @@ async def test_data_fetch2(create_inverter_client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_language_en(client): async def test_language_en(client):
"""Test the language/en route and cookie.""" """Test the language/en route."""
response = await client.get('/language/en', headers={'referer': '/index'}) response = await client.get('/language/en')
assert response.status_code == 302 assert response.status_code == 302
assert response.content_language.pop() == 'en'
assert response.location == '/index'
assert response.mimetype == 'text/html' assert response.mimetype == 'text/html'
client.set_cookie('test', key='language', value='de') client.set_cookie('test', key='language', value='de')
@@ -158,89 +146,14 @@ async def test_language_en(client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_language_de(client): async def test_language_de(client):
"""Test the language/de route.""" """Test the language/en route."""
response = await client.get('/language/de', headers={'referer': '/'}) response = await client.get('/language/de')
assert response.status_code == 302 assert response.status_code == 302
assert response.content_language.pop() == 'de'
assert response.location == '/'
assert response.mimetype == 'text/html' assert response.mimetype == 'text/html'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_language_unknown(client): async def test_language_unknown(client):
"""Test the language/unknown route.""" """Test the language/en route."""
response = await client.get('/language/unknown') response = await client.get('/language/unknonw')
assert response.status_code == 404 assert response.status_code == 404
assert response.mimetype == 'text/html' 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 "" msgstr ""
"Project-Id-Version: tsun-gen3-proxy 0.14.0\n" "Project-Id-Version: tsun-gen3-proxy 0.14.0\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-05-02 17:00+0200\n" "POT-Creation-Date: 2025-04-28 21:00+0200\n"
"PO-Revision-Date: 2025-04-18 16:24+0200\n" "PO-Revision-Date: 2025-04-18 16:24+0200\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: de\n" "Language: de\n"
@@ -44,75 +44,42 @@ msgid "Updated:"
msgstr "Aktualisiert:" msgstr "Aktualisiert:"
#: src/web/templates/base.html.j2:58 #: src/web/templates/base.html.j2:58
#: src/web/templates/templ_conn_table.html.j2:15
msgid "Connections" msgid "Connections"
msgstr "Verbindungen" msgstr "Verbindungen"
#: src/web/templates/base.html.j2:60 src/web/templates/page_logging.html.j2:5 #: src/web/templates/index.html.j2:5
msgid "Log Files"
msgstr "Log Dateien"
#: src/web/templates/page_index.html.j2:5
msgid "Proxy Connection Overview" msgid "Proxy Connection Overview"
msgstr "Proxy Verbindungen" msgstr "Proxy Verbindungen"
#: src/web/templates/page_index.html.j2:16 #: src/web/templates/index.html.j2:16
msgid "Server Mode" msgid "Server Mode"
msgstr "Server Modus" msgstr "Server Modus"
#: src/web/templates/page_index.html.j2:17 #: src/web/templates/index.html.j2:17
msgid "Established from device to proxy" msgid "Established from device to proxy"
msgstr "Vom Gerät zum Proxy aufgebaut" msgstr "Vom Gerät zum Proxy aufgebaut"
#: src/web/templates/page_index.html.j2:27 #: src/web/templates/index.html.j2:27
msgid "Client Mode" msgid "Client Mode"
msgstr "Client Modus" msgstr "Client Modus"
#: src/web/templates/page_index.html.j2:28 #: src/web/templates/index.html.j2:28
msgid "Established from proxy to device" msgid "Established from proxy to device"
msgstr "Vom Proxy zum Gerät aufgebaut" msgstr "Vom Proxy zum Gerät aufgebaut"
#: src/web/templates/page_index.html.j2:38 #: src/web/templates/index.html.j2:38
msgid "Proxy Mode" msgid "Proxy Mode"
msgstr "Proxy Modus" msgstr "Proxy Modus"
#: src/web/templates/page_index.html.j2:39 #: src/web/templates/index.html.j2:39
msgid "Forwarding data to cloud" msgid "Forwarding data to cloud"
msgstr "Weiterleitung in die Cloud" msgstr "Weiterleitung in die Cloud"
#: src/web/templates/page_index.html.j2:49 #: src/web/templates/index.html.j2:49
msgid "Emu Mode" msgid "Emu Mode"
msgstr "Emu Modus" msgstr "Emu Modus"
#: src/web/templates/page_index.html.j2:50 #: src/web/templates/index.html.j2:50
msgid "Emulation sends data to cloud" msgid "Emulation sends data to cloud"
msgstr "Emulation sendet in die 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"