Compare commits

...

5 Commits

Author SHA1 Message Date
renovate[bot]
a837c26e80 Update dependency aiomqtt to v2.4.0 2025-05-04 17:19:58 +00:00
Stefan Allius
888e1475e4 S allius/issue397 (#405)
* add Dashboards log handler to all known loggers

* add list of last 3 warnings/errors to page

* add note list to page

* create LogHandler for the dashboard

- simple memory log handler which stores the last
  64 warnings/errors for the dashboard

* render warnings/errors as note list

* add page for warnings and errors

* fix double defined build target

* add well done message if no errors in the logs

* translate page titles

* more translations

* add Notes page and table for important messages

* add unit tests
2025-05-04 18:50:31 +02:00
Stefan Allius
e15db8c92a S allius/issue393 (#403)
* display proxy version on dashboard

* add MQTT page

* styles adjusted on the different pages

- use same colors
- add bordered shadow to all cards and tables

* fix unit tests

* migrate the conn table to a general table

- rename the template file
- get headline from table description

* remove footer from index page

* make version string translateable

* cleanup

* remove stripped table rows

* add mqtt info table

* translate mqtt page

* don't fetch notes list for the log-page

* fix Mqtt init call for unit tests

* add mqtt-fetch test

* check received counter in unit test
2025-05-03 23:45:10 +02:00
Stefan Allius
41515f4be3 S allius/issue401 (#402)
* add route for log file deletion

* add modal for senity check before file deletion

* add trash icon which unhide the modal

* add more translations

* increase test coverage

* cleanup
2025-05-02 19:47:16 +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
29 changed files with 693 additions and 68 deletions

View File

@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [unreleased] ## [unreleased]
- Dashboard: add Notes page and table for important messages
- 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

@@ -6,7 +6,7 @@ babel:
build: build:
$(MAKE) -C ha_addons $@ $(MAKE) -C ha_addons $@
clean build: clean:
$(MAKE) -C app $@ $(MAKE) -C app $@
$(MAKE) -C ha_addons $@ $(MAKE) -C ha_addons $@

View File

@@ -1,4 +1,4 @@
aiomqtt==2.3.2 aiomqtt==2.4.0
schema==0.7.7 schema==0.7.7
aiocron==2.1 aiocron==2.1
quart==0.20 quart==0.20

View File

@@ -162,12 +162,13 @@ class Config():
) )
@classmethod @classmethod
def init(cls, def_reader: ConfigIfc) -> None | str: def init(cls, def_reader: ConfigIfc, log_path: str = '') -> 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
@@ -247,3 +248,7 @@ 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

@@ -7,13 +7,18 @@ from modbus import Modbus
from messages import Message from messages import Message
from cnf.config import Config from cnf.config import Config
from singleton import Singleton from singleton import Singleton
from datetime import datetime
logger_mqtt = logging.getLogger('mqtt') logger_mqtt = logging.getLogger('mqtt')
class Mqtt(metaclass=Singleton): class Mqtt(metaclass=Singleton):
__client = None __client: aiomqtt.Client = None
__cb_mqtt_is_up = None __cb_mqtt_is_up = None
ctime = None
published: int = 0
received: int = 0
def __init__(self, cb_mqtt_is_up): def __init__(self, cb_mqtt_is_up):
logger_mqtt.debug('MQTT: __init__') logger_mqtt.debug('MQTT: __init__')
@@ -52,6 +57,7 @@ class Mqtt(metaclass=Singleton):
| int | float | None = None) -> None: | int | float | None = None) -> None:
if self.__client: if self.__client:
await self.__client.publish(topic, payload) await self.__client.publish(topic, payload)
self.published += 1
async def __loop(self) -> None: async def __loop(self) -> None:
mqtt = Config.get('mqtt') mqtt = Config.get('mqtt')
@@ -69,6 +75,9 @@ class Mqtt(metaclass=Singleton):
try: try:
async with self.__client: async with self.__client:
logger_mqtt.info('MQTT broker connection established') logger_mqtt.info('MQTT broker connection established')
self.ctime = datetime.now()
self.published = 0
self.received = 0
if self.__cb_mqtt_is_up: if self.__cb_mqtt_is_up:
await self.__cb_mqtt_is_up() await self.__cb_mqtt_is_up()
@@ -84,6 +93,8 @@ class Mqtt(metaclass=Singleton):
await self.dispatch_msg(message) await self.dispatch_msg(message)
except aiomqtt.MqttError: except aiomqtt.MqttError:
self.ctime = None
if Config.is_default('mqtt'): if Config.is_default('mqtt'):
logger_mqtt.info( logger_mqtt.info(
"MQTT is unconfigured; Check your config.toml!") "MQTT is unconfigured; Check your config.toml!")
@@ -101,11 +112,14 @@ class Mqtt(metaclass=Singleton):
return return
except Exception: except Exception:
# self.inc_counter('SW_Exception') # fixme # self.inc_counter('SW_Exception') # fixme
self.ctime = None
logger_mqtt.error( logger_mqtt.error(
f"Exception:\n" f"Exception:\n"
f"{traceback.format_exc()}") f"{traceback.format_exc()}")
async def dispatch_msg(self, message): async def dispatch_msg(self, message):
self.received += 1
if message.topic.matches(self.ha_status_topic): if message.topic.matches(self.ha_status_topic):
status = message.payload.decode("UTF-8") status = message.payload.decode("UTF-8")
logger_mqtt.info('Home-Assistant Status:' logger_mqtt.info('Home-Assistant Status:'

View File

@@ -145,6 +145,10 @@ def main(): # pragma: no cover
serv_name = os.getenv('SERVICE_NAME', 'proxy') serv_name = os.getenv('SERVICE_NAME', 'proxy')
version = os.getenv('VERSION', 'unknown') version = os.getenv('VERSION', 'unknown')
@app.context_processor
def utility_processor():
return dict(version=version)
setattr(logging.handlers, "log_path", args.log_path) setattr(logging.handlers, "log_path", args.log_path)
setattr(logging.handlers, "log_backups", args.log_backups) setattr(logging.handlers, "log_backups", args.log_backups)
os.makedirs(args.log_path, exist_ok=True) os.makedirs(args.log_path, exist_ok=True)
@@ -179,7 +183,8 @@ 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")
@@ -214,7 +219,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=True,) debug=log_level == logging.DEBUG)
logging.info("Quart stopped") logging.info("Quart stopped")
except KeyboardInterrupt: except KeyboardInterrupt:

View File

@@ -7,6 +7,8 @@ Usage:
from quart import Quart, Blueprint from quart import Quart, Blueprint
from quart_babel import Babel from quart_babel import Babel
from utils import load_modules from utils import load_modules
from .log_handler import LogHandler
import logging
web = Blueprint('web', __name__) web = Blueprint('web', __name__)
@@ -30,3 +32,8 @@ class Web:
locale_selector=get_locale, locale_selector=get_locale,
timezone_selector=get_tz, timezone_selector=get_tz,
default_translation_directories=translation_directories) default_translation_directories=translation_directories)
h = LogHandler()
logging.getLogger().addHandler(h)
for name in logging.root.manager.loggerDict:
logging.getLogger(name).addHandler(h)

View File

@@ -4,6 +4,7 @@ from quart_babel import format_datetime, _
from infos import Infos from infos import Infos
from . import web from . import web
from .log_handler import LogHandler
def _get_device_icon(client_mode: bool): def _get_device_icon(client_mode: bool):
@@ -48,6 +49,7 @@ def _get_row(inv: InverterBase):
def get_table_data(): def get_table_data():
'''build the connection table''' '''build the connection table'''
table = { table = {
"headline": _('Connections'),
"col_classes": [ "col_classes": [
"w3-hide-small w3-hide-medium", "w3-hide-large", "w3-hide-small w3-hide-medium", "w3-hide-large",
"", "",
@@ -75,8 +77,11 @@ 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('conn_table.html.j2', data["conn-table"] = await render_template('templ_table.html.j2',
table=get_table_data()) 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',
notes=LogHandler().get_buffer(3),
hide_if_empty=True)
return data return data

View File

@@ -38,5 +38,8 @@ 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)

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

@@ -0,0 +1,60 @@
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())
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

@@ -0,0 +1,24 @@
from logging import Handler
from logging import LogRecord
import logging
from collections import deque
from singleton import Singleton
class LogHandler(Handler, metaclass=Singleton):
def __init__(self, capacity=64):
super().__init__(logging.WARNING)
self.capacity = capacity
self.buffer = deque(maxlen=capacity)
def emit(self, record: LogRecord):
self.buffer.append({
'ctime': record.created,
'level': record.levelno,
'lname': record.levelname,
'msg': record.getMessage()
})
def get_buffer(self, elms=0) -> list:
return list(self.buffer)[-elms:]

64
app/src/web/mqtt_table.py Normal file
View File

@@ -0,0 +1,64 @@
from inverter_base import InverterBase
from quart import render_template
from quart_babel import format_datetime, _
from mqtt import Mqtt
from . import web
from .log_handler import LogHandler
def _get_row(inv: InverterBase):
'''build one row for the connection table'''
entity_prfx = inv.entity_prfx
inv_serial = inv.local.stream.inv_serial
node_id = inv.local.stream.node_id
sug_area = inv.local.stream.sug_area
row = []
row.append(inv_serial)
row.append(entity_prfx+node_id)
row.append(sug_area)
return row
def get_table_data():
'''build the connection table'''
table = {
"headline": _('MQTT devices'),
"col_classes": [
"",
"",
"",
],
"thead": [[
_("Serial-No"),
_('Node-ID'),
_('HA-Area'),
]],
"tbody": []
}
for inverter in InverterBase:
table['tbody'].append(_get_row(inverter))
return table
@web.route('/mqtt-fetch')
async def mqtt_fetch():
mqtt = Mqtt(None)
ctime = format_datetime(dt=mqtt.ctime, format='short')
data = {
"update-time": format_datetime(format="medium"),
"mqtt-ctime": f"<h3>{ctime}</h3>",
"mqtt-tx": f"<h3>{mqtt.published}</h3>",
"mqtt-rx": f"<h3>{mqtt.received}</h3>",
}
data["mqtt-table"] = await render_template('templ_table.html.j2',
table=get_table_data())
data["notes-list"] = await render_template(
'templ_notes_list.html.j2',
notes=LogHandler().get_buffer(3),
hide_if_empty=True)
return data

19
app/src/web/notes_list.py Normal file
View File

@@ -0,0 +1,19 @@
from quart import render_template
from quart_babel import format_datetime
from . import web
from .log_handler import LogHandler
@web.route('/notes-fetch')
async def notes_fetch():
data = {
"update-time": format_datetime(format="medium"),
}
data["notes-list"] = await render_template(
'templ_notes_list.html.j2',
notes=LogHandler().get_buffer(),
hide_if_empty=False)
return data

View File

@@ -7,10 +7,26 @@ from . import web
@web.route('/') @web.route('/')
async def index(): async def index():
return await render_template( return await render_template(
'index.html.j2', 'page_index.html.j2',
fetch_url=url_for('web.data_fetch')) fetch_url=url_for('.data_fetch'))
@web.route('/page') @web.route('/mqtt')
async def empty(): async def mqtt():
return await render_template('empty.html.j2') return await render_template(
'page_mqtt.html.j2',
fetch_url=url_for('.mqtt_fetch'))
@web.route('/notes')
async def notes():
return await render_template(
'page_notes.html.j2',
fetch_url=url_for('.notes_fetch'))
@web.route('/logging')
async def logging():
return await render_template(
'page_logging.html.j2',
fetch_url=url_for('.file_fetch'))

View File

@@ -41,12 +41,12 @@
<!-- Sidebar/menu --> <!-- Sidebar/menu -->
<nav class="w3-sidebar w3-collapse w3-white" style="z-index:3;width:250px;" id="mySidebar"><br> <nav class="w3-sidebar w3-collapse w3-white" style="z-index:3;width:250px;" id="mySidebar"><br>
<div class="w3-container w3-row"> <div class="w3-container w3-cell-row">
<div class="w3-col s4"> <div class="w3-cell w3-cell-middle">
<img src="{{url_for('static', filename= 'images/favicon.svg') }}" alt="" class="w3-circle w3-margin-right" style="width:60px"> <img src="{{url_for('static', filename= 'images/favicon.svg') }}" alt="" class="w3-circle w3-margin-right" style="width:60px">
</div> </div>
<div class="w3-col s8 w3-bar"> <div class="w3-cell">
<h3>TSUN-Proxy</h3><br> <span><b class="w3-xlarge">TSUN-Proxy</b><br>{{_('Version:')}} {{version}}</span>
</div> </div>
</div> </div>
<hr> <hr>
@@ -55,16 +55,17 @@
</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('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('.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('.mqtt')}}" 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('.notes')}}" class="w3-bar-item w3-button w3-padding {% block menu3_class %}{% endblock %}"><i class="fa fa-exclamation-triangle fa-fw"></i>  {{_('Important Messages')}}</a>
<a href="{{ url_for('.logging')}}" class="w3-bar-item w3-button w3-padding {% block menu4_class %}{% endblock %}"><i class="fa fa-file-export fa-fw"></i>  {{_('Log Files')}}</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

@@ -1,9 +0,0 @@
{% extends 'base.html.j2' %}
{% block title %} TSUN Proxy - View {% endblock title%}
{% block menu2_class %}w3-blue{% endblock %}
{% block content %}
{% endblock content%}
{% block footer %}{% endblock footer %}

View File

@@ -1,12 +1,13 @@
{% extends 'base.html.j2' %} {% extends 'base.html.j2' %}
{% block title %} TSUN Proxy - Connections {% endblock title%} {% block title %}{{_("TSUN Proxy - Connections")}}{% endblock title %}
{% block menu1_class %}w3-blue{% endblock %} {% block menu1_class %}w3-blue{% endblock %}
{% block headline %}<i class="fa fa-network-wired"></i>  {{_('Proxy Connection Overview')}}{% endblock headline %} {% block headline %}<i class="fa fa-network-wired"></i>  {{_('Proxy Connection Overview')}}{% endblock headline %}
{% block content %} {% block content %}
<div class="w3-row-padding w3-margin-bottom"> <div class="w3-row-padding w3-margin-bottom">
<div class="w3-quarter"> <div class="w3-quarter">
<div class="w3-card-4">
<div class="w3-container w3-indigo w3-padding-16"> <div class="w3-container w3-indigo w3-padding-16">
<div class="w3-left"><i class="fa fa-upload w3-xxxlarge fa-rotate-180"></i></div> <div class="w3-left"><i class="fa fa-upload w3-xxxlarge fa-rotate-180"></i></div>
<div id = "server-cnt" class="w3-right"> <div id = "server-cnt" class="w3-right">
@@ -16,8 +17,10 @@
<h4>{{_('Server Mode')}}</h4> <h4>{{_('Server Mode')}}</h4>
<div class="w3-hide-small w3-hide-medium" style="min-height:50px">{{_('Established from device to proxy')}}</div> <div class="w3-hide-small w3-hide-medium" style="min-height:50px">{{_('Established from device to proxy')}}</div>
</div> </div>
</div>
</div> </div>
<div class="w3-quarter"> <div class="w3-quarter">
<div class="w3-card-4">
<div class="w3-container w3-purple w3-padding-16"> <div class="w3-container w3-purple w3-padding-16">
<div class="w3-left"><i class="fa fa-download w3-xxxlarge fa-rotate-180"></i></div> <div class="w3-left"><i class="fa fa-download w3-xxxlarge fa-rotate-180"></i></div>
<div id = "client-cnt" class="w3-right"> <div id = "client-cnt" class="w3-right">
@@ -27,8 +30,10 @@
<h4>{{_('Client Mode')}}</h4> <h4>{{_('Client Mode')}}</h4>
<div class="w3-hide-small w3-hide-medium" style="min-height:50px">{{_('Established from proxy to device')}}</div> <div class="w3-hide-small w3-hide-medium" style="min-height:50px">{{_('Established from proxy to device')}}</div>
</div> </div>
</div>
</div> </div>
<div class="w3-quarter"> <div class="w3-quarter">
<div class="w3-card-4">
<div class="w3-container w3-orange w3-text-white w3-padding-16"> <div class="w3-container w3-orange w3-text-white w3-padding-16">
<div class="w3-left"><i class="fa fa-cloud w3-xxxlarge"></i></div> <div class="w3-left"><i class="fa fa-cloud w3-xxxlarge"></i></div>
<div id = "proxy-cnt" class="w3-right"> <div id = "proxy-cnt" class="w3-right">
@@ -38,8 +43,10 @@
<h4>{{_('Proxy Mode')}}</h4> <h4>{{_('Proxy Mode')}}</h4>
<div class="w3-hide-small w3-hide-medium" style="min-height:50px">{{_('Forwarding data to cloud')}}</div> <div class="w3-hide-small w3-hide-medium" style="min-height:50px">{{_('Forwarding data to cloud')}}</div>
</div> </div>
</div>
</div> </div>
<div class="w3-quarter"> <div class="w3-quarter">
<div class="w3-card-4">
<div class="w3-container w3-teal w3-padding-16"> <div class="w3-container w3-teal w3-padding-16">
<div class="w3-left"><i class="fa fa-cloud-arrow-up-alt w3-xxxlarge"></i></div> <div class="w3-left"><i class="fa fa-cloud-arrow-up-alt w3-xxxlarge"></i></div>
<div id = "emulation-cnt" class="w3-right"> <div id = "emulation-cnt" class="w3-right">
@@ -49,9 +56,12 @@
<h4>{{_('Emu Mode')}}</h4> <h4>{{_('Emu Mode')}}</h4>
<div class="w3-hide-small w3-hide-medium" style="min-height:50px">{{_('Emulation sends data to cloud')}}</div> <div class="w3-hide-small w3-hide-medium" style="min-height:50px">{{_('Emulation sends data to cloud')}}</div>
</div> </div>
</div>
</div> </div>
</div> </div>
<div class="w3-container" id="notes-list"></div> <div id="notes-list"></div>
<div class="w3-container" id="conn-table"></div> <div id="conn-table"></div>
{% endblock content%} {% endblock content%}
{% block footer %}{% endblock footer %}

View File

@@ -0,0 +1,30 @@
{% extends 'base.html.j2' %}
{% block title %}{{_("TSUN Proxy - Log Files")}}{% endblock title %}
{% block menu4_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 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

@@ -0,0 +1,52 @@
{% extends 'base.html.j2' %}
{% block title %}{{_("TSUN Proxy - MQTT Status")}}{% endblock title %}
{% block menu2_class %}w3-blue{% endblock %}
{% block headline %}<i class="fa fa-database"></i>  {{_('MQTT Overview')}}{% endblock headline %}
{% block content %}
<div class="w3-row-padding w3-margin-bottom">
<div class="w3-third">
<div class="w3-card-4">
<div class="w3-container w3-indigo w3-padding-16">
<div class="w3-left"><i class="fa fa-link w3-xxxlarge"></i></div>
<div id = "mqtt-ctime" class="w3-right">
<h3>-</h3>
</div>
<div class="w3-clear"></div>
<h4>{{_('Connection Time')}}</h4>
<div class="w3-hide-small w3-hide-medium" style="min-height:50px">{{_('Time at which the connection was established')}}</div>
</div>
</div>
</div>
<div class="w3-third">
<div class="w3-card-4">
<div class="w3-container w3-purple w3-padding-16">
<div class="w3-left"><i class="fa fa-server w3-xxxlarge"></i></div>
<div id = "mqtt-tx" class="w3-right">
<h3>-</h3>
</div>
<div class="w3-clear"></div>
<h4>{{_('Published Topics')}}</h4>
<div class="w3-hide-small w3-hide-medium" style="min-height:50px">{{_('Number of published topics')}}</div>
</div>
</div>
</div>
<div class="w3-third">
<div class="w3-card-4">
<div class="w3-container w3-orange w3-text-white w3-padding-16">
<div class="w3-left"><i class="fa fa-user w3-xxxlarge"></i></div>
<div id = "mqtt-rx" class="w3-right">
<h3>-</h3>
</div>
<div class="w3-clear"></div>
<h4>{{_('Received Topics')}}</h4>
<div class="w3-hide-small w3-hide-medium" style="min-height:50px">{{_('Number of topics received')}}</div>
</div>
</div>
</div>
</div>
<div id="notes-list"></div>
<div id="mqtt-table"></div>
{% endblock content%}
{% block footer %}{% endblock footer %}

View File

@@ -0,0 +1,10 @@
{% extends 'base.html.j2' %}
{% block title %}{{_("TSUN Proxy - Important Messages")}}{% endblock title %}
{% block menu3_class %}w3-blue{% endblock %}
{% block headline %}<i class="fa fa-exclamation-triangle fa-fw"></i>  {{_('Important Messages')}}{% endblock headline %}
{% block content %}
<div id="notes-list"></div>
{% endblock content%}
{% block footer %}{% endblock footer %}

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-teal" 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-teal">
<a href="{{ url_for('.send',file=file.name)}}" class="w3-button w3-hover-teal w3-hover-text-black"><i class="fa fa-file-download"></i>  {{_('Download File')}}</a>
<a class="w3-button w3-right w3-hover-teal 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

@@ -0,0 +1,23 @@
{% if notes|length > 0 %}
<div class="w3-container w3-margin-bottom">
<h5>{{_("Warnings and error messages")}} </h5>
<ul class="w3-ul w3-card-4">
{% for note in notes %}
<li class="{% if note.level is le(30) %}w3-leftbar w3-rightbar w3-pale-blue w3-border-blue{% else %}w3-leftbar w3-rightbar w3-pale-red w3-border-red{% endif %}">
<span class="w3-col" style="width:150px">{{note.ctime|datetimeformat(format='short')}}</span>
<span class="w3-col w3-hide-small" style="width:100px">{{note.lname|e}}</span>
<span class="w3-rest">{{note.msg|e}}</span>
</li>
{% endfor %}
</ul>
</div>
{% elif not hide_if_empty %}
<div class="w3-container w3-margin-bottom">
<div class="w3-leftbar w3-rightbar w3-pale-green w3-border-green">
<div class="w3-container">
<h2>{{_("Well done!")}}</h2>
<p>{{_("No warnings or errors have been logged since the last proxy start.")}}</p>
</div>
</div>
</div>
{% endif %}

View File

@@ -12,8 +12,11 @@
{% endif %} {% endif %}
{%- endmacro%} {%- endmacro%}
<h5>Connections</h5> <div class="w3-container w3-margin-bottom">
<table class="w3-table w3-striped w3-bordered w3-border w3-hoverable w3-white"> <h5>{{table.headline}}</h5>
<div class="w3-card-4">
<table class="w3-table w3-bordered w3-hoverable w3-white">
{% if table.thead is defined%} {% if table.thead is defined%}
<thead> <thead>
{% for row in table.thead %} {% for row in table.thead %}
@@ -35,3 +38,5 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
</div>

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} '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

@@ -140,6 +140,7 @@ async def test_ha_reconnect(config_mqtt_conn):
assert on_connect.is_set() assert on_connect.is_set()
finally: finally:
assert m.received == 2
await m.close() await m.close()
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@@ -5,6 +5,10 @@ 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
from proxy import Proxy
import os, errno
pytest_plugins = ('pytest_asyncio',) pytest_plugins = ('pytest_asyncio',)
@@ -50,20 +54,34 @@ async def test_home(client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_page(client): async def test_page(client):
"""Test the empty page route.""" """Test the mqtt page route."""
response = await client.get('/page') response = await client.get('/mqtt')
assert response.status_code == 200 assert response.status_code == 200
assert response.mimetype == 'text/html' assert response.mimetype == 'text/html'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_rel_page(client): async def test_rel_page(client):
"""Test the empty page route.""" """Test the mqtt route."""
web.build_relative_urls = True web.build_relative_urls = True
response = await client.get('/page') response = await client.get('/mqtt')
assert response.status_code == 200 assert response.status_code == 200
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_notes(client):
"""Test the notes page route."""
response = await client.get('/notes')
assert response.status_code == 200
assert response.mimetype == 'text/html'
@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."""
@@ -101,7 +119,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 healthy route.""" """Test the data-fetch 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')
@@ -112,7 +130,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 healthy route.""" """Test the data-fetch route with server connection."""
_ = 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')
@@ -123,7 +141,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 healthy route.""" """Test the data-fetch route with client connection."""
_ = 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')
@@ -134,26 +152,122 @@ 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.""" """Test the language/en route and cookie."""
response = await client.get('/language/en') response = await client.get('/language/en', headers={'referer': '/index'})
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')
response = await client.get('/page') response = await client.get('/mqtt')
assert response.status_code == 200 assert response.status_code == 200
assert response.mimetype == 'text/html' assert response.mimetype == 'text/html'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_language_de(client): async def test_language_de(client):
"""Test the language/en route.""" """Test the language/de route."""
response = await client.get('/language/de') response = await client.get('/language/de', headers={'referer': '/'})
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/en route.""" """Test the language/unknown route."""
response = await client.get('/language/unknonw') response = await client.get('/language/unknown')
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_mqtt_fetch(client, create_inverter):
"""Test the mqtt-fetch route."""
_ = create_inverter
Proxy.class_init()
response = await client.get('/mqtt-fetch')
assert response.status_code == 200
@pytest.mark.asyncio
async def test_notes_fetch(client, config_conn):
"""Test the notes-fetch route."""
_ = create_inverter
response = await client.get('/notes-fetch')
assert response.status_code == 200
@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-04-28 21:00+0200\n" "POT-Creation-Date: 2025-05-04 18:16+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"
@@ -19,67 +19,177 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.17.0\n" "Generated-By: Babel 2.17.0\n"
#: src/web/conn_table.py:57 #: src/web/conn_table.py:52 src/web/templates/base.html.j2:58
msgid "Connections"
msgstr "Verbindungen"
#: src/web/conn_table.py:59
msgid "Device-IP:Port" msgid "Device-IP:Port"
msgstr "Geräte-IP:Port" msgstr "Geräte-IP:Port"
#: src/web/conn_table.py:57 #: src/web/conn_table.py:59
msgid "Device-IP" msgid "Device-IP"
msgstr "Geräte-IP" msgstr "Geräte-IP"
#: src/web/conn_table.py:58 #: src/web/conn_table.py:60 src/web/mqtt_table.py:34
msgid "Serial-No" msgid "Serial-No"
msgstr "Seriennummer" msgstr "Seriennummer"
#: src/web/conn_table.py:59 #: src/web/conn_table.py:61
msgid "Cloud-IP:Port" msgid "Cloud-IP:Port"
msgstr "Cloud-IP:Port" msgstr "Cloud-IP:Port"
#: src/web/conn_table.py:59 #: src/web/conn_table.py:61
msgid "Cloud-IP" msgid "Cloud-IP"
msgstr "Cloud-IP" msgstr "Cloud-IP"
#: src/web/mqtt_table.py:27
msgid "MQTT devices"
msgstr "MQTT Geräte"
#: src/web/mqtt_table.py:35
msgid "Node-ID"
msgstr ""
#: src/web/mqtt_table.py:36
msgid "HA-Area"
msgstr ""
#: src/web/templates/base.html.j2:37 #: src/web/templates/base.html.j2:37
msgid "Updated:" msgid "Updated:"
msgstr "Aktualisiert:" msgstr "Aktualisiert:"
#: src/web/templates/base.html.j2:58 #: src/web/templates/base.html.j2:49
msgid "Connections" msgid "Version:"
msgstr "Verbindungen" msgstr ""
#: src/web/templates/index.html.j2:5 #: src/web/templates/base.html.j2:60 src/web/templates/page_notes.html.j2:5
msgid "Important Messages"
msgstr "Wichtige Hinweise"
#: src/web/templates/base.html.j2:61 src/web/templates/page_logging.html.j2:5
msgid "Log Files"
msgstr "Log Dateien"
#: src/web/templates/page_index.html.j2:3
msgid "TSUN Proxy - Connections"
msgstr "TSUN Proxy - Verbindungen"
#: 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/index.html.j2:16 #: src/web/templates/page_index.html.j2:17
msgid "Server Mode" msgid "Server Mode"
msgstr "Server Modus" msgstr "Server Modus"
#: src/web/templates/index.html.j2:17 #: src/web/templates/page_index.html.j2:18
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/index.html.j2:27 #: src/web/templates/page_index.html.j2:30
msgid "Client Mode" msgid "Client Mode"
msgstr "Client Modus" msgstr "Client Modus"
#: src/web/templates/index.html.j2:28 #: src/web/templates/page_index.html.j2:31
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/index.html.j2:38 #: src/web/templates/page_index.html.j2:43
msgid "Proxy Mode" msgid "Proxy Mode"
msgstr "Proxy Modus" msgstr "Proxy Modus"
#: src/web/templates/index.html.j2:39 #: src/web/templates/page_index.html.j2:44
msgid "Forwarding data to cloud" msgid "Forwarding data to cloud"
msgstr "Weiterleitung in die Cloud" msgstr "Weiterleitung in die Cloud"
#: src/web/templates/index.html.j2:49 #: src/web/templates/page_index.html.j2:56
msgid "Emu Mode" msgid "Emu Mode"
msgstr "Emu Modus" msgstr "Emu Modus"
#: src/web/templates/index.html.j2:50 #: src/web/templates/page_index.html.j2:57
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:3
msgid "TSUN Proxy - Log Files"
msgstr "TSUN Proxy - Log Dateien"
#: 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/page_mqtt.html.j2:3
msgid "TSUN Proxy - MQTT Status"
msgstr ""
#: src/web/templates/page_mqtt.html.j2:5
msgid "MQTT Overview"
msgstr "MQTT Überblick"
#: src/web/templates/page_mqtt.html.j2:16
msgid "Connection Time"
msgstr "Verbindungszeit"
#: src/web/templates/page_mqtt.html.j2:17
msgid "Time at which the connection was established"
msgstr "Zeitpunkt des Verbindungsaufbaus"
#: src/web/templates/page_mqtt.html.j2:29
msgid "Published Topics"
msgstr "Gesendete Topics"
#: src/web/templates/page_mqtt.html.j2:30
msgid "Number of published topics"
msgstr "Anzahl der veröffentlichten Topics"
#: src/web/templates/page_mqtt.html.j2:42
msgid "Received Topics"
msgstr "Empfangene Topics"
#: src/web/templates/page_mqtt.html.j2:43
msgid "Number of topics received"
msgstr "Anzahl der empfangenen Topics"
#: src/web/templates/page_notes.html.j2:3
msgid "TSUN Proxy - Important Messages"
msgstr "TSUN Proxy - Wichtige Hinweise"
#: 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"
#: src/web/templates/templ_notes_list.html.j2:3
msgid "Warnings and error messages"
msgstr "Warnungen und Fehlermeldungen"
#: src/web/templates/templ_notes_list.html.j2:18
msgid "Well done!"
msgstr "Gut gemacht!"
#: src/web/templates/templ_notes_list.html.j2:19
msgid "No warnings or errors have been logged since the last proxy start."
msgstr ""
"Seit dem letzten Proxystart wurden keine Warnungen oder Fehler "
"protokolliert."