Compare commits
13 Commits
174a6148bf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e1a3b498e5 | |||
| 9e1ebebeb2 | |||
| 2af30f3a32 | |||
| f3ccaaefd8 | |||
| a15aee1da6 | |||
| 353f073d12 | |||
| 10e2322882 | |||
| a6475f2c3b | |||
| 72e3ede52f | |||
| fb4d5c2929 | |||
| 1983cc3b1e | |||
| acc8575d70 | |||
| 1b010ecb61 |
12
README.md
12
README.md
@@ -1,6 +1,6 @@
|
|||||||
# survBot
|
# survBot
|
||||||
|
|
||||||
version: 0.1
|
version: 0.2
|
||||||
|
|
||||||
survBot is a small program used to track station quality channels of DSEBRA stations via PowBox output over SOH channels
|
survBot is a small program used to track station quality channels of DSEBRA stations via PowBox output over SOH channels
|
||||||
by analysing contents of a Seiscomp3 datapath.
|
by analysing contents of a Seiscomp3 datapath.
|
||||||
@@ -40,8 +40,16 @@ The GUI can be loaded via
|
|||||||
python survBotGui.py
|
python survBotGui.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Version Changes
|
||||||
|
- surveillance of mass, clock and gaps
|
||||||
|
- individual mailing lists for different stations
|
||||||
|
- html mail with recent status information
|
||||||
|
- updated web page design
|
||||||
|
- restructured parameter file
|
||||||
|
- recognize if PBox is disconnected
|
||||||
|
|
||||||
## Staff
|
## Staff
|
||||||
|
|
||||||
Original author: M.Paffrath (marcel.paffrath@rub.de)
|
Original author: M.Paffrath (marcel.paffrath@rub.de)
|
||||||
|
|
||||||
November 2022
|
June 2023
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# survBot is a small program used to track station quality channels of DSEBRA stations via PowBox output
|
||||||
|
# over SOH channels by analysing contents of a Seiscomp3 datapath.
|
||||||
|
__version__ = "0.2"
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
# Parameters file for Surveillance Bot
|
# Parameters file for Surveillance Bot
|
||||||
datapath: "/data/SDS/" # SC3 Datapath
|
datapath: "/data/SDS/" # SC3 Datapath
|
||||||
networks: ["1Y", "HA"] # select networks, list or str
|
networks: ["1Y", "HA", "MK"] # select networks, list or str
|
||||||
stations: "*" # select stations, list or str
|
stations: "*" # select stations, list or str
|
||||||
locations: "*" # select locations, list or str
|
locations: "*" # select locations, list or str
|
||||||
stations_blacklist: ["TEST", "EREA"] # exclude these stations
|
stations_blacklist: ["TEST", "EREA", "DOMV"] # exclude these stations
|
||||||
networks_blacklist: [] # exclude these networks
|
networks_blacklist: [] # exclude these networks
|
||||||
interval: 60 # Perform checks every x seconds
|
interval: 60 # Perform checks every x seconds
|
||||||
n_track: 300 # wait n_track * intervals before performing an action (i.e. send mail/end highlight status)
|
n_track: 360 # wait n_track * intervals before performing an action (i.e. send mail/end highlight status)
|
||||||
timespan: 3 # Check data of the recent x days
|
timespan: 3 # Check data of the recent x days
|
||||||
verbosity: 0 # verbosity flag
|
verbosity: 0 # verbosity flag
|
||||||
track_changes: True # tracks all changes since GUI startup by text highlighting (GUI only)
|
track_changes: True # tracks all changes since GUI startup by text highlighting (GUI only)
|
||||||
warn_count: False # show number of warnings and errors in table
|
warn_count: False # show number of warnings and errors in table
|
||||||
min_sample: 3 # minimum samples for raising Warn/FAIL
|
min_sample: 5 # minimum samples for raising Warn/FAIL
|
||||||
dt_thresh: [300, 1800] # threshold (s) for timing delay colourisation (yellow/red)
|
dt_thresh: [300, 1800] # threshold (s) for timing delay colourisation (yellow/red)
|
||||||
html_figures: True # Create html figure directory and links
|
html_figures: True # Create html figure directory and links
|
||||||
reread_parameters: True # reread parameters file (change parameters on runtime, not for itself/GUI refresh/datapath)
|
reread_parameters: True # reread parameters file (change parameters on runtime, not for itself/GUI refresh/datapath)
|
||||||
@@ -72,12 +72,12 @@ CHANNELS:
|
|||||||
EX2:
|
EX2:
|
||||||
unit: 1e-6
|
unit: 1e-6
|
||||||
name: "PowBox 230V/12V (V)"
|
name: "PowBox 230V/12V (V)"
|
||||||
ticks: [1, 5, 1]
|
ticks: [0, 5, 1]
|
||||||
warn: [2, 3, 4, 4.5, 5]
|
warn: [2, 3, 4, 4.5, 5]
|
||||||
EX3:
|
EX3:
|
||||||
unit: 1e-6
|
unit: 1e-6
|
||||||
name: "PowBox Router/Charger (V)"
|
name: "PowBox Router/Charger (V)"
|
||||||
ticks: [1, 5, 1]
|
ticks: [0, 5, 1]
|
||||||
warn: [2, 2.5, 3, 4, 5]
|
warn: [2, 2.5, 3, 4, 5]
|
||||||
VEI:
|
VEI:
|
||||||
unit: 1e-3
|
unit: 1e-3
|
||||||
@@ -122,6 +122,15 @@ add_links:
|
|||||||
slmon: {"URL": "../slmon/{nw}_{st}.html", "text": "show"}
|
slmon: {"URL": "../slmon/{nw}_{st}.html", "text": "show"}
|
||||||
24h-plot: {"URL": "../scheli/{nw}/{st}.png", "text": "plot"}
|
24h-plot: {"URL": "../scheli/{nw}/{st}.png", "text": "plot"}
|
||||||
|
|
||||||
|
# add station-independent links below html table (list items separated with -)
|
||||||
|
add_global_links:
|
||||||
|
# for example: - {"text": "our homepage", "URL": "https://www.rub.de"}
|
||||||
|
- {"text": "show recent events on map",
|
||||||
|
"URL": "https://fdsnws.geophysik.ruhr-uni-bochum.de/map/?lat=39.5&lon=21&zoom=7&baselayer=mapnik"}
|
||||||
|
|
||||||
|
# html logo at page bottom (path relative to html directory)
|
||||||
|
html_logo: "figures/Logo_RUB_BLAU_rgb.png"
|
||||||
|
|
||||||
# E-mail notifications
|
# E-mail notifications
|
||||||
EMAIL:
|
EMAIL:
|
||||||
mailserver: "localhost"
|
mailserver: "localhost"
|
||||||
|
|||||||
@@ -2,26 +2,36 @@ body {
|
|||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
padding-bottom: 30px;
|
||||||
|
font-family: "Helvetica", "sans-serif";
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
position: relative
|
||||||
}
|
}
|
||||||
|
|
||||||
td {
|
td {
|
||||||
border-radius: 4px;
|
border-radius: 2px;
|
||||||
padding: 0px;
|
padding: 0px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
background-color: #999;
|
background-color: #999;
|
||||||
border-radius: 4px;
|
color: #fff;
|
||||||
|
border-radius: 2px;
|
||||||
padding: 3px 1px;
|
padding: 3px 1px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
a:link, a:visited {
|
a:link, a:visited {
|
||||||
background-color: #ccc;
|
background-color: #e8e8e8;
|
||||||
color: #000;
|
color: #000;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
display: block;
|
display: block;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid #bbb;
|
border: 1px solid #ccc;
|
||||||
}
|
}
|
||||||
|
|
||||||
a:hover {
|
a:hover {
|
||||||
@@ -37,3 +47,12 @@ a:hover {
|
|||||||
50% { background-color: #ff3200;}
|
50% { background-color: #ff3200;}
|
||||||
100% { background-color: #ffcc00;}
|
100% { background-color: #ffcc00;}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 50px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,26 +2,36 @@ body {
|
|||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
padding-bottom: 30px;
|
||||||
|
font-family: "Helvetica", "sans-serif";
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
position: relative
|
||||||
}
|
}
|
||||||
|
|
||||||
td {
|
td {
|
||||||
border-radius: 4px;
|
border-radius: 3px;
|
||||||
padding: 10px 2px;
|
padding: 10px 2px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
background-color: #999;
|
background-color: #999;
|
||||||
border-radius: 4px;
|
color: #fff;
|
||||||
|
border-radius: 3px;
|
||||||
padding: 10px, 2px;
|
padding: 10px, 2px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
a:link {
|
a:link, a:visited {
|
||||||
background-color: #ccc;
|
background-color: #e8e8e8;
|
||||||
color: #000;
|
color: #000;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
display: block;
|
display: block;
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
border: 1px solid #bbb;
|
border: 1px solid #ccc;
|
||||||
}
|
}
|
||||||
|
|
||||||
a:hover {
|
a:hover {
|
||||||
@@ -37,7 +47,16 @@ a:hover {
|
|||||||
animation: blinkingBackground 2s infinite;
|
animation: blinkingBackground 2s infinite;
|
||||||
}
|
}
|
||||||
@keyframes blinkingBackground{
|
@keyframes blinkingBackground{
|
||||||
0% { background-color: #ffee00;}
|
0% { background-color: #ffcc00;}
|
||||||
50% { background-color: #ff3200;}
|
50% { background-color: #ff3200;}
|
||||||
100% { background-color: #ffee00;}
|
100% { background-color: #ffcc00;}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 50px;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|||||||
31
survBot.py
31
survBot.py
@@ -20,9 +20,9 @@ import matplotlib.pyplot as plt
|
|||||||
from obspy import read, UTCDateTime, Stream
|
from obspy import read, UTCDateTime, Stream
|
||||||
from obspy.clients.filesystem.sds import Client
|
from obspy.clients.filesystem.sds import Client
|
||||||
|
|
||||||
from write_utils import get_html_text, get_html_row, html_footer, get_html_header, get_print_title_str, \
|
from write_utils import get_html_text, get_html_link, get_html_row, html_footer, get_html_header, get_print_title_str, \
|
||||||
init_html_table, finish_html_table, get_mail_html_header, add_html_image
|
init_html_table, finish_html_table, get_mail_html_header, add_html_image
|
||||||
from utils import get_bg_color, modify_stream_for_plot, set_axis_yticks, set_axis_color, plot_axis_thresholds
|
from utils import get_bg_color, get_font_color, modify_stream_for_plot, set_axis_yticks, set_axis_color, plot_axis_thresholds
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import smtplib
|
import smtplib
|
||||||
@@ -120,9 +120,16 @@ class SurveillanceBot(object):
|
|||||||
self.networks_blacklist = self.parameters.get('networks_blacklist')
|
self.networks_blacklist = self.parameters.get('networks_blacklist')
|
||||||
self.refresh_period = self.parameters.get('interval')
|
self.refresh_period = self.parameters.get('interval')
|
||||||
self.transform_parameters()
|
self.transform_parameters()
|
||||||
|
|
||||||
add_links = self.parameters.get('add_links')
|
add_links = self.parameters.get('add_links')
|
||||||
self.add_links = add_links if add_links else {}
|
self.add_links = add_links if add_links else {}
|
||||||
|
|
||||||
|
add_global_links = self.parameters.get('add_global_links')
|
||||||
|
# in case user forgets "-" in parameters file
|
||||||
|
if isinstance(add_global_links, dict):
|
||||||
|
add_global_links = [add_global_links]
|
||||||
|
self.add_global_links = add_global_links if add_global_links else []
|
||||||
|
|
||||||
def transform_parameters(self):
|
def transform_parameters(self):
|
||||||
for key in ['networks', 'stations', 'locations', 'channels']:
|
for key in ['networks', 'stations', 'locations', 'channels']:
|
||||||
parameter = self.parameters.get(key)
|
parameter = self.parameters.get(key)
|
||||||
@@ -434,7 +441,7 @@ class SurveillanceBot(object):
|
|||||||
fig_name = self.get_fig_path_rel(nwst_id)
|
fig_name = self.get_fig_path_rel(nwst_id)
|
||||||
nwst_id_str = nwst_id.rstrip('.')
|
nwst_id_str = nwst_id.rstrip('.')
|
||||||
col_items = [dict(text=nwst_id_str, color=default_color, hyperlink=fig_name if hyperlinks else None,
|
col_items = [dict(text=nwst_id_str, color=default_color, hyperlink=fig_name if hyperlinks else None,
|
||||||
bold=True, tooltip=f'Show plot of {nwst_id_str}')]
|
bold=True, tooltip=f'Show plot of {nwst_id_str}', font_color='#000000')]
|
||||||
|
|
||||||
for check_key in header:
|
for check_key in header:
|
||||||
if check_key in self.keys:
|
if check_key in self.keys:
|
||||||
@@ -446,6 +453,7 @@ class SurveillanceBot(object):
|
|||||||
bg_color = get_bg_color(check_key, status, dt_thresh, hex=True)
|
bg_color = get_bg_color(check_key, status, dt_thresh, hex=True)
|
||||||
if not bg_color:
|
if not bg_color:
|
||||||
bg_color = default_color
|
bg_color = default_color
|
||||||
|
font_color = get_font_color(bg_color, hex=True)
|
||||||
|
|
||||||
# add degree sign for temp
|
# add degree sign for temp
|
||||||
if check_key == 'temp':
|
if check_key == 'temp':
|
||||||
@@ -454,7 +462,7 @@ class SurveillanceBot(object):
|
|||||||
|
|
||||||
html_class = self.get_html_class(hide_keys_mobile, status=status, check_key=check_key)
|
html_class = self.get_html_class(hide_keys_mobile, status=status, check_key=check_key)
|
||||||
item = dict(text=str(message), tooltip=str(detailed_message), color=bg_color,
|
item = dict(text=str(message), tooltip=str(detailed_message), color=bg_color,
|
||||||
html_class=html_class)
|
html_class=html_class, font_color=font_color)
|
||||||
elif check_key in self.add_links:
|
elif check_key in self.add_links:
|
||||||
value = self.add_links.get(check_key).get('URL')
|
value = self.add_links.get(check_key).get('URL')
|
||||||
link_text = self.add_links.get(check_key).get('text')
|
link_text = self.add_links.get(check_key).get('text')
|
||||||
@@ -496,8 +504,21 @@ class SurveillanceBot(object):
|
|||||||
|
|
||||||
outfile.write(finish_html_table())
|
outfile.write(finish_html_table())
|
||||||
|
|
||||||
|
# add optional links below html table
|
||||||
|
for dct in self.add_global_links:
|
||||||
|
link_str = get_html_link(dct.get('text'), dct.get('URL'))
|
||||||
|
outfile.write(get_html_text(link_str))
|
||||||
|
|
||||||
|
# add status message
|
||||||
outfile.write(get_html_text(self.status_message))
|
outfile.write(get_html_text(self.status_message))
|
||||||
outfile.write(html_footer())
|
|
||||||
|
# write footer with optional logo
|
||||||
|
logo_file = self.parameters.get('html_logo')
|
||||||
|
if not os.path.isfile(pjoin(self.outpath_html, logo_file)):
|
||||||
|
print(f'Specified file {logo_file} not found.')
|
||||||
|
logo_file = None
|
||||||
|
|
||||||
|
outfile.write(html_footer(footer_logo=logo_file))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f'Could not write HTML table to {fnout}:')
|
print(f'Could not write HTML table to {fnout}:')
|
||||||
|
|||||||
44
utils.py
44
utils.py
@@ -34,13 +34,19 @@ def get_bg_color(check_key, status, dt_thresh=None, hex=False):
|
|||||||
|
|
||||||
|
|
||||||
def get_color(key):
|
def get_color(key):
|
||||||
# some GUI default colors
|
# some old GUI default colors
|
||||||
colors_dict = {'FAIL': (255, 50, 0, 255),
|
# colors_dict = {'FAIL': (255, 85, 50, 255),
|
||||||
|
# 'NO DATA': (255, 255, 125, 255),
|
||||||
|
# 'WARN': (255, 255, 80, 255),
|
||||||
|
# 'OK': (173, 255, 133, 255),
|
||||||
|
# 'undefined': (230, 230, 230, 255),
|
||||||
|
# 'disc': (255, 160, 40, 255),}
|
||||||
|
colors_dict = {'FAIL': (195, 29, 14, 255),
|
||||||
'NO DATA': (255, 255, 125, 255),
|
'NO DATA': (255, 255, 125, 255),
|
||||||
'WARN': (255, 255, 80, 255),
|
'WARN': (250, 192, 63, 255),
|
||||||
'OK': (125, 255, 125, 255),
|
'OK': (185, 245, 145, 255),
|
||||||
'undefined': (230, 230, 230, 255),
|
'undefined': (240, 240, 240, 255),
|
||||||
'disc': (255, 160, 40, 255),}
|
'disc': (126, 127, 131, 255), }
|
||||||
return colors_dict.get(key)
|
return colors_dict.get(key)
|
||||||
|
|
||||||
|
|
||||||
@@ -59,9 +65,11 @@ def get_time_delay_color(dt, dt_thresh):
|
|||||||
return get_color('FAIL')
|
return get_color('FAIL')
|
||||||
|
|
||||||
|
|
||||||
def get_warn_color(count):
|
def get_warn_color(count, n_colors=20):
|
||||||
color = (min([255, 200 + count ** 2]), 255, 80, 255)
|
if count >= n_colors:
|
||||||
return color
|
count = -1
|
||||||
|
gradient = np.linspace((240, 245, 110, 255), (250, 192, 63, 255), n_colors, dtype=int)
|
||||||
|
return tuple(gradient[count])
|
||||||
|
|
||||||
|
|
||||||
def get_mass_color(message):
|
def get_mass_color(message):
|
||||||
@@ -81,6 +89,24 @@ def get_temp_color(temp, vmin=-10, vmax=60, cmap='coolwarm'):
|
|||||||
return rgba
|
return rgba
|
||||||
|
|
||||||
|
|
||||||
|
def get_font_color(bg_color, hex=False):
|
||||||
|
if hex:
|
||||||
|
bg_color = matplotlib.colors.to_rgb(bg_color)
|
||||||
|
bg_color_hsv = matplotlib.colors.rgb_to_hsv(bg_color)
|
||||||
|
bg_color_hsl = hsv_to_hsl(bg_color_hsv)
|
||||||
|
font_color = (255, 255, 255, 255) if bg_color_hsl[2] < 0.6 else (0, 0, 0, 255)
|
||||||
|
if hex:
|
||||||
|
font_color = '#{:02x}{:02x}{:02x}'.format(*font_color[:3])
|
||||||
|
return font_color
|
||||||
|
|
||||||
|
|
||||||
|
def hsv_to_hsl(hsv):
|
||||||
|
hue, saturation, value = hsv
|
||||||
|
lightness = value * (1 - saturation / 2)
|
||||||
|
saturation = 0 if lightness in (0, 1) else (value - lightness) / min(lightness, 1 - lightness)
|
||||||
|
return hue, saturation, lightness
|
||||||
|
|
||||||
|
|
||||||
def modify_stream_for_plot(input_stream, parameters):
|
def modify_stream_for_plot(input_stream, parameters):
|
||||||
""" copy (if necessary) and modify stream for plotting """
|
""" copy (if necessary) and modify stream for plotting """
|
||||||
|
|
||||||
|
|||||||
@@ -47,9 +47,15 @@ def finish_html_table():
|
|||||||
return '</table>\n'
|
return '</table>\n'
|
||||||
|
|
||||||
|
|
||||||
def html_footer():
|
def html_footer(footer_logo=None):
|
||||||
footer = ['</body>',
|
footer = ['</body>']
|
||||||
'</html>']
|
if footer_logo:
|
||||||
|
logo_items = [f'<div class="footer">',
|
||||||
|
f' <img style="float: right; padding: 10px;" src="{footer_logo}" height=30px>',
|
||||||
|
f'</div>']
|
||||||
|
footer += logo_items
|
||||||
|
footer.append('</html>\n')
|
||||||
|
|
||||||
footer = _convert_to_textstring(footer)
|
footer = _convert_to_textstring(footer)
|
||||||
return footer
|
return footer
|
||||||
|
|
||||||
@@ -58,6 +64,10 @@ def add_html_image(img_data, img_format='png'):
|
|||||||
return f"""<br>\n<img width="100%" src="data:image/{img_format};base64, {b64encode(img_data).decode('ascii')}">"""
|
return f"""<br>\n<img width="100%" src="data:image/{img_format};base64, {b64encode(img_data).decode('ascii')}">"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_html_link(text, link):
|
||||||
|
return f'<a href="{link}"> {text} </a>'
|
||||||
|
|
||||||
|
|
||||||
def get_html_row(items, html_key='td'):
|
def get_html_row(items, html_key='td'):
|
||||||
row_string = ''
|
row_string = ''
|
||||||
default_space = ' '
|
default_space = ' '
|
||||||
@@ -69,15 +79,14 @@ def get_html_row(items, html_key='td'):
|
|||||||
if item.get('italic'):
|
if item.get('italic'):
|
||||||
text = '<i>' + text + '</i>'
|
text = '<i>' + text + '</i>'
|
||||||
tooltip = item.get('tooltip')
|
tooltip = item.get('tooltip')
|
||||||
color = item.get('color')
|
font_color = item.get('font_color')
|
||||||
# check for black background of headers (shouldnt happen anymore)
|
|
||||||
color = '#e6e6e6' if color == '#000000' else color
|
|
||||||
hyperlink = item.get('hyperlink')
|
hyperlink = item.get('hyperlink')
|
||||||
image_str = f'<a href="{hyperlink}">' if hyperlink else ''
|
color = 'transparent' if hyperlink else item.get('color')
|
||||||
|
text_str = get_html_link(text, hyperlink) if hyperlink else text
|
||||||
html_class = item.get('html_class')
|
html_class = item.get('html_class')
|
||||||
class_str = f' class="{html_class}"' if html_class else ''
|
class_str = f' class="{html_class}"' if html_class else ''
|
||||||
row_string += 2 * default_space + f'<{html_key}{class_str} bgcolor="{color}" title="{tooltip}"> {image_str}'\
|
row_string += 2 * default_space + f'<{html_key}{class_str} bgcolor="{color}" title="{tooltip}"' \
|
||||||
+ text + f'</{html_key}>\n'
|
+ f'style="color:{font_color}"> {text_str}</{html_key}>\n'
|
||||||
row_string += default_space + '</tr>\n'
|
row_string += default_space + '</tr>\n'
|
||||||
return row_string
|
return row_string
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user