├── .gitignore ├── assets ├── favicon.ico ├── favicon.png ├── logo.svg ├── script.js └── style.css ├── docs └── img │ ├── App Home Beispiel.png │ ├── TI-Monitoring Mockup.png │ ├── App Statistik Beispiel.png │ ├── Mail Beispiel Störung.png │ ├── App Home Beispiel Störung.png │ └── Push-Benachrichtigungen Beispiel.png ├── notifications.json ├── cron.py ├── myconfig.py ├── LICENSE ├── pages ├── plot.py ├── alerts.py └── home.py ├── app.py ├── examples └── data_analysis │ └── data_analysis.py ├── README.md └── mylibrary.py /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | test.py 3 | __pycache__ 4 | data.hdf5 -------------------------------------------------------------------------------- /assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lsr-dev/ti-monitoring/HEAD/assets/favicon.ico -------------------------------------------------------------------------------- /assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lsr-dev/ti-monitoring/HEAD/assets/favicon.png -------------------------------------------------------------------------------- /docs/img/App Home Beispiel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lsr-dev/ti-monitoring/HEAD/docs/img/App Home Beispiel.png -------------------------------------------------------------------------------- /docs/img/TI-Monitoring Mockup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lsr-dev/ti-monitoring/HEAD/docs/img/TI-Monitoring Mockup.png -------------------------------------------------------------------------------- /docs/img/App Statistik Beispiel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lsr-dev/ti-monitoring/HEAD/docs/img/App Statistik Beispiel.png -------------------------------------------------------------------------------- /docs/img/Mail Beispiel Störung.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lsr-dev/ti-monitoring/HEAD/docs/img/Mail Beispiel Störung.png -------------------------------------------------------------------------------- /docs/img/App Home Beispiel Störung.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lsr-dev/ti-monitoring/HEAD/docs/img/App Home Beispiel Störung.png -------------------------------------------------------------------------------- /docs/img/Push-Benachrichtigungen Beispiel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lsr-dev/ti-monitoring/HEAD/docs/img/Push-Benachrichtigungen Beispiel.png -------------------------------------------------------------------------------- /notifications.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "", 4 | "recipients": [], 5 | "ci_list": [], 6 | "type": "", 7 | "push_topic": "" 8 | }, 9 | { 10 | "name": "", 11 | "recipients": [], 12 | "ci_list": [], 13 | "type": "", 14 | "push_topic": "" 15 | } 16 | ] -------------------------------------------------------------------------------- /cron.py: -------------------------------------------------------------------------------- 1 | # Import packages 2 | from mylibrary import * 3 | from myconfig import * 4 | 5 | def main(): 6 | initialize_data_file(file_name) 7 | update_file(file_name, url) 8 | if notifications: 9 | send_notifications(file_name, notifications_config_file, smtp_settings, home_url) 10 | if push_notifications: 11 | send_push_notifications(file_name, notifications_config_file, home_url, ntfy_url, ntfy_token) 12 | 13 | if __name__ == '__main__': 14 | main() -------------------------------------------------------------------------------- /myconfig.py: -------------------------------------------------------------------------------- 1 | # URL for API 2 | url = "https://ti-lage.prod.ccs.gematik.solutions/lageapi/v1/tilage/bu/PU" 3 | 4 | # path to hdf5 file for saving the availability data 5 | file_name = "data.hdf5" 6 | 7 | # switching email notofications on/off 8 | notifications = False 9 | 10 | # configuration for notofications 11 | notifications_config_file = 'notifications.json' 12 | 13 | # smtp settings for email notifications 14 | smtp_settings = { 15 | 'host' : '********', 16 | 'port' : 587, 17 | 'user' : '********', 18 | 'password' : '********', 19 | 'from' : '********' 20 | } 21 | 22 | # home url for dash app 23 | home_url = 'https://ti-monitoring.de' 24 | 25 | # time frame for statistics in web app 26 | stats_delta_hours = 12 27 | 28 | # switching push notofications on/off 29 | push_notifications = False 30 | 31 | # base url for push notifications 32 | ntfy_url = 'https://push.ti-monitoring.de' 33 | 34 | # access token for push notifications 35 | ntfy_token = '********' 36 | 37 | def main(): 38 | return 39 | 40 | if __name__ == '__main__': 41 | main() -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 lsr-dev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | L 8 | 9 | 10 | 11 | 12 | S 13 | 14 | 15 | 16 | 17 | R 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /pages/plot.py: -------------------------------------------------------------------------------- 1 | import dash 2 | from dash import html, dcc 3 | import plotly.express as px 4 | from mylibrary import * 5 | from myconfig import * 6 | 7 | dash.register_page(__name__) 8 | 9 | 10 | def serve_layout(ci=None, **other_unknown_query_strings): 11 | cutoff = (pd.Timestamp.now() - pd.Timedelta(hours=stats_delta_hours)).tz_localize(get_localzone()) 12 | ci_data = get_availability_data_of_ci(file_name, ci) 13 | ci_data = ci_data[ci_data['times']>=cutoff] 14 | number_of_values = len(ci_data['values']) 15 | mean_availability = np.mean(ci_data['values'].values) 16 | first_timestamp = ci_data['times'].iloc[0].strftime('%d.%m.%Y %H:%M:%S Uhr') 17 | last_timestamp = ci_data['times'].iloc[-1].strftime('%d.%m.%Y %H:%M:%S Uhr') 18 | ci_data = ci_data.rename(columns={ 19 | 'times': 'Zeit', 20 | 'values': 'Verfügbarkeit' 21 | }) 22 | custom_colors = ['red' if v == 0 else 'green' for v in ci_data['Verfügbarkeit']] 23 | fig = px.scatter( 24 | ci_data, 25 | x = 'Zeit', 26 | y = 'Verfügbarkeit', 27 | ) 28 | fig.update_traces(marker=dict(color=custom_colors)) 29 | fig.update_yaxes(tickvals=[0, 1], ticktext=['0', '1']) 30 | fig.update_layout(yaxis=dict(range=[-0.1, 1.1])) 31 | ci_info = get_data_of_ci(file_name, ci) 32 | layout = [ 33 | html.H2('Verfügbarkeit der Komponente ' + str(ci)), 34 | html.H3(ci_info['product'] + ', ' + ci_info['name'] + ', ' + ci_info['organization']), 35 | html.A(href=home_url, children = [ 36 | html.Button('Zurück', className = 'button') 37 | ]), 38 | html.Div(id = 'statistics', className = 'box', children = [ 39 | html.H3('Statistik'), 40 | html.Ul([ 41 | html.Li('Anzahl der Werte: ' + str(number_of_values)), 42 | html.Li('Erster Wert: ' + str(first_timestamp)), 43 | html.Li('Letzter Wert: ' + str(last_timestamp)), 44 | html.Li('Verfügbarkeit in diesem Zeitraum: ' + str(mean_availability * 100) + ' %') 45 | ]) 46 | ]), 47 | dcc.Graph(figure=fig) 48 | ] 49 | return layout 50 | 51 | layout = serve_layout -------------------------------------------------------------------------------- /pages/alerts.py: -------------------------------------------------------------------------------- 1 | import dash 2 | from dash import html, dcc 3 | import plotly.express as px 4 | from mylibrary import * 5 | from myconfig import * 6 | 7 | dash.register_page(__name__) 8 | 9 | def serve_layout(): 10 | layout = [ 11 | html.H2('Push-Benachrichtigungen'), 12 | html.P('Sie können sich per Push-Nachricht über Änderungen des Verfügbarkeitsstatus benachrichtigen lassen. Wichtig: Die hier angebotenen Push-Nachrichten basieren auf einem generischen Benachrichtigungsprofil, d.h. es werden Benachrichtigungen zu allen Komponenten versendet'), 13 | html.H3('Option 1 (empfohlen)'), 14 | html.P( 15 | children = [ 16 | html.Span('Laden Sie hierzu die App „ntfy“ aus dem App Store herunter ('), 17 | html.A('Apple App Store', href='https://apps.apple.com/de/app/ntfy/id1625396347'), 18 | html.Span(', '), 19 | html.A('Google Play Store', href='https://play.google.com/store/apps/details?id=io.heckel.ntfy'), 20 | html.Span(') und richten Sie diese wie folgt ein:') 21 | ] 22 | ), 23 | html.Ol( 24 | children = [ 25 | html.Li('Akzeptieren Sie, dass die App Ihnen Mitteilungen sendet, falls danach gefragt wird.'), 26 | html.Li('Klicken Sie auf das „+“, um eine neue Subscription hinzuzufügen.'), 27 | html.Li('Geben Sie als topic „TI“ ein.'), 28 | html.Li('Aktivieren Sie die Option „Use another server“ und geben Sie als Service URL „https://push.ti-monitoring.de“ ein.'), 29 | html.Li('Tippen Sie auf „Subscribe“.') 30 | ] 31 | ), 32 | html.H3('Option 2'), 33 | html.P( 34 | children = [ 35 | html.P('Alternativ können Sie sich auf Ihrem Deskop-Gerät über Ihren Browser beanchrichtigen lassen. Rufen Sie hierzu die folgende Seite auf und erlauben Sie Ihrem Browser, Desktop-Benachrichtigungen anzuzeigen: '), 36 | html.A('Benachrichtigungen im Browser', href='https://push.ti-monitoring.de/TI', target="_self"), 37 | html.P('Anschließend können Sie auf die Seiten des TI-Monitorings zurückkehren.') 38 | ] 39 | ) 40 | ] 41 | return layout 42 | 43 | layout = serve_layout -------------------------------------------------------------------------------- /assets/script.js: -------------------------------------------------------------------------------- 1 | function toggleAccordion(clickedAccordion) { 2 | var currentContentHeight = window.getComputedStyle(clickedAccordion.parentElement.getElementsByClassName('accordion-element-content')[0]).height; 3 | // close clicked accordion element 4 | if (currentContentHeight != '0px') { 5 | clickedAccordion.parentElement.getElementsByClassName('accordion-element-content')[0].style.height = '0px'; 6 | clickedAccordion.style.backgroundColor = ''; 7 | clickedAccordion.getElementsByClassName('expand-collapse-icon')[0].textContent = '+'; 8 | } 9 | // open clicked accordion element and close all other accordion elements 10 | else { 11 | var accordionElements = document.getElementsByClassName('accordion-element'); 12 | Array.from(accordionElements).forEach(function(element) { 13 | title = element.getElementsByClassName('accordion-element-title')[0]; 14 | content = element.getElementsByClassName('accordion-element-content')[0]; 15 | if (title == clickedAccordion) { 16 | title.style.backgroundColor = 'lightgrey'; 17 | content.style.height = content.scrollHeight + 'px'; 18 | title.getElementsByClassName('expand-collapse-icon')[0].textContent = '–'; 19 | } 20 | else { 21 | title.style.backgroundColor = ''; 22 | content.style.height = '0px'; 23 | title.getElementsByClassName('expand-collapse-icon')[0].textContent = '+'; 24 | } 25 | }); 26 | } 27 | } 28 | 29 | window.addEventListener('click', function(event) { 30 | const clickedElement = event.target; 31 | if (clickedElement.classList.contains('accordion-element-title')) { 32 | toggleAccordion(clickedElement); 33 | } 34 | }); 35 | 36 | // add favicon to head 37 | var favicon_png = document.createElement('link') 38 | favicon_png.rel = 'apple-touch-icon' 39 | favicon_png.type = 'image/png' 40 | favicon_png.href = 'assets/favicon.png' 41 | document.getElementsByTagName('head')[0].appendChild(favicon_png) 42 | var favicon_svg = document.createElement('icon') 43 | favicon_svg.rel = 'icon' 44 | favicon_svg.type = 'image/svg+xml' 45 | favicon_svg.href = 'assets/logo.svg' 46 | document.getElementsByTagName('head')[0].appendChild(favicon_svg) -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import dash 2 | from dash import Dash, html, dcc 3 | from mylibrary import * 4 | from myconfig import * 5 | 6 | app = Dash(__name__, use_pages=True, title='TI-Monitoring') 7 | server = app.server 8 | 9 | def serve_layout(): 10 | layout = [ 11 | html.Header(children = [ 12 | html.Div(id='logo-wrapper', children = [ 13 | html.A(href=home_url, children = [ 14 | html.Img(id='logo', src='assets/logo.svg') 15 | ]) 16 | ]), 17 | html.H1(children='TI-Monitoring') 18 | ]), 19 | html.Main(children = [ 20 | html.Div(id='page-container', children=[ 21 | dcc.Loading( 22 | id = 'spinner', 23 | overlay_style = {"visibility":"visible", "filter": "blur(2px)"}, 24 | type = "circle", 25 | children = [dash.page_container] 26 | ) 27 | ]), 28 | html.Div(className = 'box', children = [ 29 | html.H3('Disclaimer'), 30 | html.Span('Die Bereitstellung der abgebildeten Informationen erfolgt ohne Gewähr. Als Grundlage dienen Daten der gematik GmbH, die sich über eine öffentlich erreichbare Schnittstelle abrufen lassen. Weitere Informationen dazu hier: '), 31 | html.A('https://github.com/gematik/api-tilage', href='https://github.com/gematik/api-tilage', target='_blank'), 32 | html.Span('.') 33 | ]), 34 | ]), 35 | html.Div(id = 'footer', children = [ 36 | html.Div([html.A('Home', href=home_url, target='_self')]), 37 | html.Div([html.A('Alerts', href=home_url + '/alerts', target='_self')]), 38 | html.Div([html.A('Dokumentation', href='https://github.com/lsr-dev/ti-monitoring', target='_blank')]), 39 | html.Div([html.A('Presse', href='https://heise.de/-10625281', target='_blank')]), 40 | html.Div([html.A('Datenschutz', href='https://lukas-schmidt-russnak.de/datenschutz/', target='_blank')]), 41 | html.Div([html.A('Impressum', href='https://lukas-schmidt-russnak.de/impressum/', target='_blank')]), 42 | html.Div('© Lukas Schmidt-Russnak'), 43 | html.Iframe(id='ntfy_footer', src=ntfy_url) 44 | ]) 45 | ] 46 | return layout 47 | 48 | app.layout = serve_layout 49 | 50 | if __name__ == '__main__': 51 | app.run(debug=False) -------------------------------------------------------------------------------- /pages/home.py: -------------------------------------------------------------------------------- 1 | import dash 2 | from dash import html 3 | from mylibrary import * 4 | from myconfig import * 5 | 6 | dash.register_page(__name__, path='/') 7 | 8 | def serve_layout(): 9 | cis = get_data_of_all_cis(file_name) 10 | grouped = cis.groupby('product') 11 | products = [] 12 | for index, row in cis.iterrows(): 13 | product = row['product'] 14 | if product not in products: 15 | products.append(product) 16 | layout = html.Div([ 17 | html.P('Hier finden Sie eine nach Produkten gruppierte Übersicht zentraler Komponenten der Telematikinfrastruktur (TI). Neue Daten werden alle 5 Minuten bereitgestellt. Laden Sie die Seite neu, um die Ansicht zu aktualisieren.'), 18 | html.Div(className='accordion', children = [ 19 | html.Div(className='accordion-element', children = [ 20 | html.Div( 21 | className='accordion-element-title', 22 | children = [ 23 | html.Span(className = 'availability-icon ' + 24 | 'available' if sum(group['current_availability']) == len(group) 25 | else 'availability-icon unavailable' if sum(group['current_availability']) == 0 26 | else 'availability-icon impaired', 27 | ), 28 | html.Span( 29 | className = 'group-name', 30 | children = group_name + ' (' + str(sum(group['current_availability'] == 1)) + '/' + str(len(group)) + ')' 31 | ), 32 | html.Span(className = 'expand-collapse-icon', children='+') 33 | ] 34 | ), 35 | html.Div(className='accordion-element-content', children = [ 36 | html.Ul(children = [ 37 | html.Li([ 38 | html.Span(className = 'availability-icon ' + 'available' if row['current_availability'] == 1 else 'availability-icon unavailable'), 39 | html.Div([ 40 | html.A(str(row['ci']), href='/plot?ci=' + str(row['ci'])), 41 | ': ' + row['name'] + ', ' + row['organization'] + ', ' + pretty_timestamp(row['time']) 42 | ]) 43 | ]) for _, row in group.iterrows() 44 | ]) 45 | ]) 46 | ]) for group_name, group in grouped 47 | ]) 48 | ]) 49 | return layout 50 | 51 | layout = serve_layout -------------------------------------------------------------------------------- /assets/style.css: -------------------------------------------------------------------------------- 1 | html { 2 | font-family: Helvetica; 3 | } 4 | body { 5 | display: flex; 6 | justify-content: center; 7 | margin: 0px; 8 | } 9 | #react-entry-point { 10 | box-shadow: 0 0 20px 5px lightgrey; 11 | } 12 | main, header, #footer { 13 | max-width: 1200px; 14 | width: calc(100vw - 40px); 15 | padding: 5px 20px; 16 | } 17 | main { 18 | margin-top: 90px; 19 | } 20 | header { 21 | display: flex; 22 | position: fixed; 23 | background-color: white; 24 | z-index: 1; 25 | } 26 | #logo-wrapper { 27 | display: flex; 28 | flex-direction: column; 29 | justify-content: center; 30 | margin: 0px 40px 0px 0px; 31 | } 32 | #logo { 33 | cursor: pointer; 34 | height: 50px; 35 | width: 50px; 36 | } 37 | #_pages_content { 38 | min-height: 100px; 39 | } 40 | #footer { 41 | display: flex; 42 | justify-content: space-between; 43 | flex-wrap: wrap; 44 | background-color: white; 45 | } 46 | #footer div { 47 | flex-grow: 1; 48 | text-align: center; 49 | margin: 15px; 50 | } 51 | #ntfy_footer { 52 | width: 0; 53 | height: 0; 54 | border: none; 55 | } 56 | .box { 57 | margin: 12px 0px; 58 | padding: 10px; 59 | background-color: lightgrey; 60 | } 61 | .box h3 { 62 | margin: 0.5em 0em; 63 | } 64 | #statistics ul { 65 | list-style: none; 66 | padding: 0 67 | } 68 | .button { 69 | font-size: 1.5em; 70 | cursor: pointer; 71 | } 72 | .accordion-element ul { 73 | margin: 0px; 74 | } 75 | .accordion li { 76 | display: flex; 77 | align-items: center; 78 | flex-grow: 0; 79 | flex-shrink: 0; 80 | } 81 | .accordion-element-title .availability-icon, .accordion-element-content ul li span { 82 | width: 1em; 83 | height: 1em; 84 | display: inline-block; 85 | border-radius: 50%; 86 | flex-grow: 0; 87 | flex-shrink: 0; 88 | margin-right: 0.75em; 89 | } 90 | .accordion-element-content ul li span { 91 | width: 0.5em; 92 | height: 0.5em; 93 | } 94 | .availability-icon.available { 95 | background-color: green; 96 | } 97 | .availability-icon.impaired { 98 | background-color: orange; 99 | border-radius: 0; 100 | } 101 | .availability-icon.unavailable { 102 | background-color: red; 103 | border-radius: 0; 104 | } 105 | .accordion-element-title { 106 | border-top: 1px solid grey; 107 | padding: 5px 10px; 108 | display: flex; 109 | } 110 | .accordion-element-title * { 111 | pointer-events: none; 112 | } 113 | .accordion-element-title:hover { 114 | background-color: lightgrey; 115 | cursor: pointer; 116 | } 117 | .group-name { 118 | flex-grow: 1; 119 | } 120 | .accordion-element:last-of-type { 121 | border-bottom: 1px solid grey; 122 | } 123 | .accordion-element-content { 124 | transition: 0.2s ease-in-out; 125 | overflow: clip; 126 | height: 0px; 127 | } 128 | .accordion-element-content ul { 129 | padding-top: 20px; 130 | padding-bottom: 20px 131 | } 132 | a:any-link { 133 | color:blue; 134 | } -------------------------------------------------------------------------------- /examples/data_analysis/data_analysis.py: -------------------------------------------------------------------------------- 1 | from mylibrary import * 2 | from myconfig import * 3 | 4 | import matplotlib.pyplot as plt 5 | from matplotlib.ticker import FuncFormatter 6 | 7 | def comma_format(x, pos): 8 | return str(x).replace('.', ',') 9 | 10 | # specify the file name if it differs from the name in the config file 11 | #file_name = 'data_2025_KW35.hdf5' 12 | # get basic data and availability data for all cis 13 | all_data = [] 14 | for index, ci in get_data_of_all_cis(file_name).iterrows(): 15 | availability_data = get_availability_data_of_ci(file_name, ci['ci']) 16 | # selection of time window 17 | #availability_data = availability_data[availability_data['times']>pd.Timestamp('2025-08-25T06:00:0.0').tz_localize('Europe/Berlin')] 18 | #availability_data = availability_data[availability_data['times']0].sort_values(by = 'unavailable') 38 | 39 | data_start = plot_data['first_timestamp'].min().strftime('%d.%m.%Y %H:%M:%S Uhr') 40 | data_end = plot_data['last_timestamp'].max().strftime('%d.%m.%Y %H:%M:%S Uhr') 41 | names = plot_data['ci_name'] 42 | data = plot_data['unavailable'].values*5/60 43 | fig, ax = plt.subplots(figsize = (15, 10)) 44 | ax.tick_params(labelsize = 8) 45 | ax.tick_params(axis='x', labelsize = 12) 46 | barh = ax.barh(names, data, 0.5) 47 | ax.bar_label(barh, labels=[f"{value:,.2f}".replace('.', ',') for value in data]) 48 | ax.text( 49 | data.max()/4, 50 | len(data)/6, 51 | """Die Berechung der Ausfallzeiten erfolgte durch Multiplikation 52 | der Anzahl der Datenpunkte 'nicht verfügbar' mit 5 Minuten, 53 | der Dauer eines Abfrageintervalls. Die Daten wurden von der 54 | öffentlichen TI-Lage-Schnittstelle der gematik GmbH abgerufen. 55 | Bei der Interpretation sind demensprechend die Hinweise in der 56 | Dokumentation der Schnittstelle zu berücksichtigen. Weitere 57 | Informationen unter https://github.com/gematik/api-tilage. 58 | Alle Angaben ohne Gewähr.""" 59 | ) 60 | ax.set_title("Störungen zentraler TI-Komponenten\nim Zeitraum " + data_start + ' bis ' + data_end) 61 | ax.set_xlabel('Störungsdauer in Stunden') 62 | ax.xaxis.set_major_formatter(FuncFormatter(comma_format)) 63 | plt.tight_layout() 64 | #plt.show() 65 | export_file_path = 'examples/data_analysis/plot_unavailable_cis.png' 66 | plt.savefig(export_file_path, dpi = 600) 67 | 68 | # export plot data to csv file 69 | #plot_data.to_csv('data.csv') -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TI-Monitoring 2 | 3 | Dieses Tool dient der Überwachung verschiedener Komponenten der Telematikinfrastruktur (TI). 4 | Es ist modular aufgebaut, sodass sich je nach Bedarf und Systemleistung auch nur einzelne Funktionen nutzen lassen. 5 | 6 | Die Funktionen lassen sich wie folgt zusammenfassen: 7 | 8 | * __Abruf und Archivierung__
9 | Die Kernfunktionalität besteht in der regelmäßigen Abfrage des Verfügbarkeitsstatus sämtlicher zentraler TI-Komponenten über eine öffentliche Schnittstelle der gematik GmbH. Die Ergebnisse werden strukturiert in einer hdf5-Datei gespeichert. So können auch für längere Beobachtungszeiträume statistische Auswertungen durchgeführt werden, um beispielsweise die Einhaltung von SLAs zu beurteilen. 10 | * __Benachrichtigungen__
11 | Bei Änderungen der Verfügbarkeit können Benachrichtigungen per E-Mail und/oder Push-Benachrichtigung versendet werden. Zur Filterung der TI-Komponenten kann wahlweise eine White- oder Blacklist definiert werden. 12 | * __Web-App__
13 | Der aktuelle Status sämtlicher TI-Komponenten lässt sich nach Produkten gruppiert in einer interaktiven Web-App einsehen. Darüber hinaus kann für die einzelnen Komponenten eine Statistik der letzten Stunden aufgerufen werden. 14 | 15 | ## Einrichtung der Python-Umgebung 16 | Das Tool kann beispielweise auf einem (virtuellen) Server, NAS oder (idealerweise permanent laufenden) Rechner installiert werden. Systemanforderungen und Einrichtungsaufwand variieren je nach Umfang der genutzten Funktionen. Für die 17 | Grundfunktionalität (Abruf und Archivierung von Verfügbarkeitsinformationen) sind lediglich die Pakete erforderlich, die in der Datei `mylibrary.py` importiert werden. Nur im Falle der App sind weitere Pakete (z.B. `dash`) zu installieren sowie ein Webserver (z.B. nginx) und ggf. ein Applikationsserver (z.B. uWSGi). Weitere Details zur Funktionsweise und Konfiguration finden sich weiter unten. Allgemein empfiehlt sich die Erstellung einer virtuellen Python-Umgebung. Dies geschieht beispielsweise unter Ubuntu 24.04 LTS mit dem User `lukas` wie folgt: 18 | 19 | ``` 20 | sudo apt update && sudo apt upgrade 21 | sudo apt install python3-venv 22 | python3 -m venv /home/lukas/myenv 23 | source /home/lukas/myenv/bin/activate 24 | ``` 25 | Die erforderlichen Pakete (z.B. `h5py`) können daraufhin mit dem Befehl `pip3 install ` installiert werden, z.B. `pip3 install h5py`. 26 | 27 | ## Abruf und Archivierung 28 | Abruf und Archivierung erfolgen durch das Skript `cron.py`, das alle fünf Minuten durch einen Cronjob ausgeführt werden sollte. Um möglichst die aktuellsten Daten abzugreifen, empfiehlt sich ein minimaler Versatz zum Bereitstellungszeitpunkt der Daten: 29 | ``` 30 | # m h dom mon dow command 31 | 2-59/5 * * * * /bin/bash -c 'source myenv/bin/activate && python cron.py' 32 | ``` 33 | Die Daten werden aufbereitet und in der Datei `data.hdf5` gespeichert. Existiert diese noch nicht, wird sie beim ersten Ausführen des Skriptes `cron.py` automatisch erzeugt. 34 | 35 | Innerhalb der Datei wird folgende Gruppenstruktur aufgebaut: 36 | 37 | ``` 38 | . 39 | +-- availability 40 | | +-- CI-0000001 41 | | +-- CI-0000002 42 | | +-- ... 43 | +-- configuration_items 44 | +-- CI-0000001 45 | +-- CI-0000002 46 | +-- ... 47 | ``` 48 | 49 | Die Gruppen `availability` und `configuration_items` enthalten jeweils für jedes Konfigurationsobjekt (z.B. `CI-0000001`) eine gleichnamige Untergruppe. 50 | 51 | Die Untergruppe des Konfigurationsobjektes in der Gruppe `availability` enthält Datensätze mit der Verfügbarkeit als Integer (0: nicht verfügbar, 1: verfügbar). Der Name des Datensatzes entspricht der Unix-Zeit des Datenpunktes. Bei Aktualisierungen wird ein neuer Datensatz hinzugefügt. 52 | 53 | Die Untergruppe des Konfigurationsobjektes in der Gruppe `configuration_items` enthält mehrere Datensätze mit allegemeinen Eigenschaften wie `name`, `product` und `organization`. Außerdem die aktuelle Verfügbarkeit `current_availability` sowie die Veränderung der Verfügbarkeit `availability_difference` in Bezug auf den vorherigen Wert (-1: nicht mehr verfügbar, 0: keine Veränderung, 1: wieder verfügbar). Bei Aktualisierungen werden die vorhandenen Datensätze überschrieben. 54 | 55 | Je nach Systemleistung kann es sinnvoll sein, die Datei `data.hdf5` von Zeit zu Zeit archivieren. Hierzu kann die Datei beispielsweise per Cronjob in ein Archiv-Verzeichnis verschoben werden. 56 | 57 | ## Benachrichtigungen 58 | 59 | ### E-Mail 60 | Auf Wunsch können bei Änderungen der Verfügbarkeit Benachrichtigungen per E-Mail versendet werden. Dies geschieht ebenfalls über das Skrip `cron.py`, sofern in der Datei `myconfig.py` die Variable `notifications` den Wert `True` besitzt. Die SMTP-Verbindungsdaten werden ebenfalls in der Datei `myconfig.py` hinterlegt. 61 | 62 | In der Datei `notifications.json` können mehrere Profile definiert werden. Ein Profil besteht aus folgenden Eigenschaften: 63 | 64 | | Name | Beschreibung | 65 | | ----------- | ----------- | 66 | | name | Name des Profils (wird in der Anrede verwendet) | 67 | | recipients | Liste mit mindestens einer E-Mail-Adresse (z.B. `["mail1@example.com", "mail2@example.com"]`) | 68 | | ci_list | Liste von Konfigurationsobjekten (z.B. `["CI-000001", "CI-0000002"]`) | 69 | | type | entweder `blacklist` oder `whitelist` (legt fest, wie die Liste der Konfigurationsobjekte behandelt wird) | 70 | | push_topic | Thema für Push-Benachrichtigungen via ntfy (z.B. `TI`)| 71 | 72 | Hier ein Beispiel für eine E-Mail-Benachrichtigung: 73 | ![E-Mail-Benachrichtigung über Störung (Beispiel)](docs/img/Mail%20Beispiel%20Störung.png "E-Mail-Benachrichtigung über Störung (Beispiel)") 74 | 75 | ### Push-Benachrichtigungen via ntfy 76 | Sofern eine [ntfy](https://github.com/binwiederhier/ntfy)-Instanz vorhanden ist, lassen sich optional Push-Benachrichtigungen versenden. Hierfür muss im Benachrichtigungsprofil ein Thema (`push_topic`) angegeben werden. Zudem sind in der Datei `myconfig.py` die URL zur ntfy-Instanz (`ntfy_url`) sowie ein gültiger Access-Token (`ntfy_token`) eines Users mit schreibenden Zugriff auf dieses Thema zu hinterlegen. Sofern die Variable `push_notifications` den Wert `True` besitzt, wird der Versand von Push-Benachrichtigungen über das Skrip `cron.py` angestoßen. Die Benachrichtigungen können über die mobilen ntfy-Apps ([Apple App Store](https://apps.apple.com/de/app/ntfy/id1625396347), [Google Play Store](https://play.google.com/store/apps/details?id=io.heckel.ntfy)) oder über kompatible Browser ausgespielt werden. 77 | ![Push-Benachrichtigungen (Beispiel)](docs/img/Push-Benachrichtigungen%20Beispiel.png "Push-Benachrichtigungen (Beispiel)") 78 | **Hinweise:** 79 | 80 | * Um Push-Benachrichtigungen im Browser auch dann anzuzeigen, wenn lediglich die Seiten des TI-Monitorings geöffnet sind, ist die Adresse der ntfy-Instanz (`ntfy_url`) als unsichtbares Iframe in den Footer integriert. 81 | 82 | * Push-Benachrichtigungen beinhalten standardmäßig einen Action-Button, der zur Startseite der Web-App führt. Soll das TI-Monitoring ohne Web-App genutzt werden, kann in der Datei `myconfig.py` als `home_url` die Adresse einer öffentlichen TI-Monitoring-Instanz angegeben werden. Alternativ sollte der entsprechende Button aus der Funktion `send_push_notifications()` angepasst bzw. entfernt werden. 83 | 84 | * Soll die Web-App ohne Push-Benachrichtigungen genutzt werden, ist die Datei `pages/alerts.py` obsolet. Außerdem sollten der entsprechende Link und das Iframe aus dem Footer (siehe `app.py`) entfernt werden. 85 | 86 | ## Web-App 87 | 88 | Der aktuelle Status verschiedener Komponenten kann optional auch in Form einer Web-App auf Basis des [Dash-Frameworks](https://dash.plotly.com) bereitgestellt werden. Die App kann z.B. in Kombination mit uWSGi und nginx (ähnlich [wie hier beschrieben](https://carpiero.medium.com/host-a-dashboard-using-python-dash-and-linux-in-your-own-linux-server-85d891e960bc) veröffentlicht werden. 89 | 90 | Auf der Startseite der App werden die Komponenten nach Produkt gruppiert dargestellt. Durch Anklicken der Gruppen lassen sich die jeweiligen Komponenten einblenden. 91 | ![Mockup der App (Beispiel)](docs/img/TI-Monitoring%20Mockup.png "Mockup der App (Beispiel)") 92 | ![Screenshot aus der App: Startseite der App (Beispiel)](docs/img/App%20Home%20Beispiel.png "Startseite der App (Beispiel)") 93 | ![Screenshot aus der App: Startseite der App mit Störung (Beispiel)](docs/img/App%20Home%20Beispiel%20Störung.png "Startseite der App mit Störung (Beispiel)") 94 | Per Klick auf die ID einer Komponente lässt sich eine Statistik der letzten Stunden aufrufen. 95 | ![Screenshot aus der App: Statistik für eine Komponente (Beispiel)](docs/img/App%20Statistik%20Beispiel.png "Statistik für eine Komponente (Beispiel)") 96 | Um eine gute Performance zu gewährleisten, kann das Zeitfenster der Statistik über die Variable `stats_delta_hours` in der Datei `myconfig.py` reduziert werden. Zudem kann es ratsam sein, die Datei `data.hdf5` regelmäßig zu archivieren bzw. zu leeren. 97 | 98 | Soll die Web-App überhaupt nicht genutzt werden, sind folgende Ordner bzw. Dateien irrelevant und können entfernt werden: 99 | 100 | * assets 101 | * pages 102 | * app.py 103 | 104 | --- 105 | **DISCLAIMER** 106 | 107 | Es handelt sich um ein privates Projekt ohne offiziellen Support. Jegliche Nutzung erfolgt auf eigene Verantwortung. 108 | 109 | Die Daten werden über eine öffentlich erreichbare Schnittstelle der gematik GmbH abgerufen. Eine ausführliche Beschreibung diser Schnittstelle ist öffentlich auf GitHub verfügbar: [https://github.com/gematik/api-tilage](https://github.com/gematik/api-tilage). 110 | 111 | --- -------------------------------------------------------------------------------- /mylibrary.py: -------------------------------------------------------------------------------- 1 | # Import packages 2 | import numpy as np 3 | import pandas as pd 4 | import h5py as h5 5 | import requests, json, time, pytz, os 6 | from datetime import datetime 7 | from tzlocal import get_localzone 8 | import smtplib 9 | from email.message import EmailMessage 10 | from email.mime.text import MIMEText 11 | 12 | def initialize_data_file(file_name): 13 | """ 14 | Creates hdf5 file if necessary and builds up basic group structure 15 | 16 | Args: 17 | file_name (str): Path to hdf5 file 18 | 19 | Returns: 20 | None 21 | """ 22 | if not(os.path.isfile(file_name)): 23 | with h5.File(file_name, "w") as f: 24 | f.create_group("availability") 25 | f.create_group("configuration_items") 26 | 27 | def update_file(file_name, url): 28 | """ 29 | Gets current data from API and updates hdf5 file 30 | 31 | Args: 32 | file_name (str): Path to hdf5 file 33 | url (str): URL of API 34 | 35 | Returns: 36 | None 37 | """ 38 | data = json.loads(requests.get(url).text) 39 | df = pd.DataFrame(data) 40 | with h5.File(file_name, "a") as f: 41 | for idx in range(len(df)): 42 | ci = df.iloc[idx] 43 | # availybility 44 | group_av = f.require_group("availability/" + str(ci["ci"])) 45 | av = int(ci["availability"]) 46 | utc_time = datetime.strptime(ci["time"], '%Y-%m-%dT%H:%M:%S.%fZ').replace(tzinfo=pytz.UTC) 47 | timestamp = utc_time.timestamp() 48 | ds = group_av.require_dataset(str(timestamp),shape=(),dtype=int) 49 | ds[()] = av 50 | # configuration items 51 | group_ci = f.require_group("configuration_items/" + str(ci["ci"])) 52 | str_256 = h5.string_dtype(encoding='utf-8', length=256) 53 | for property in ["tid", "bu", "organization", "pdt", "product", "name", "comment", "time"]: 54 | ds = group_ci.require_dataset(property, shape=(), dtype=str_256) 55 | ds[()] = ci[property] 56 | if "current_availability" in group_ci: 57 | prev_av = group_ci["current_availability"][()] 58 | av_diff = av - prev_av 59 | else: 60 | av_diff = 0 61 | ds = group_ci.require_dataset("availability_difference",shape=(),dtype=int) 62 | ds[()] = av_diff 63 | ds = group_ci.require_dataset("current_availability",shape=(),dtype=int) 64 | ds[()] = av 65 | 66 | def get_availability_data_of_ci(file_name, ci): 67 | """ 68 | Gets availability data for a specific configuration item from hdf5 file 69 | 70 | Args: 71 | file_name (str): Path to hdf5 file 72 | ci (str): ID of the desired confirguration item 73 | 74 | Returns: 75 | DataFrame: Time series of the availability of the desired configuration item 76 | """ 77 | all_ci_data = [] 78 | with h5.File(file_name, 'r') as f: 79 | group = f["availability/" + ci] 80 | ci_data = {} 81 | times = [] 82 | values = [] 83 | for name, dataset in group.items(): 84 | if isinstance(dataset, h5.Dataset): 85 | time = pd.to_datetime(float(name), unit='s').tz_localize('UTC').tz_convert('Europe/Berlin') 86 | times.append(time) 87 | values.append(int(dataset[()])) 88 | ci_data["times"] = np.array(times) 89 | ci_data["values"] = np.array(values) 90 | return pd.DataFrame(ci_data) 91 | 92 | def get_data_of_all_cis(file_name): 93 | """ 94 | Gets general data for all configuration items from hdf5 file such as organization 95 | and product as well as current availability and availability difference 96 | 97 | Args: 98 | file_name (str): Path to hdf5 file 99 | 100 | Returns: 101 | DataFrame: Basic information about all configuration items 102 | """ 103 | all_ci_data = [] 104 | with h5.File(file_name, 'r') as f: 105 | group = f["configuration_items"] 106 | cis = group.keys() 107 | for ci in cis: 108 | group = f["configuration_items/"+ci] 109 | ci_data = {} 110 | ci_data["ci"] = ci 111 | for name in group: 112 | dataset = group[name] 113 | value = dataset[()] 114 | # Handle scalar bytes (decode) 115 | if isinstance(value, bytes): 116 | value = value.decode('utf-8') 117 | ci_data[name] = value 118 | #df = pd.DataFrame(ci_data) 119 | all_ci_data.append(ci_data) 120 | return pd.DataFrame(all_ci_data) 121 | 122 | def get_data_of_ci(file_name, ci): 123 | """ 124 | Gets general data for a specific configuration item from hdf5 file 125 | 126 | Args: 127 | file_name (str): Path to hdf5 file 128 | ci (str): ID of the desired confirguration item 129 | 130 | Returns: 131 | DataFrame: General data of the desired configuration item 132 | """ 133 | all_ci_data = [] 134 | with h5.File(file_name, 'r') as f: 135 | group = f["configuration_items/"+ci] 136 | ci_data = {} 137 | ci_data["ci"] = ci 138 | for name in group: 139 | dataset = group[name] 140 | value = dataset[()] 141 | # Handle scalar bytes (decode) 142 | if isinstance(value, bytes): 143 | value = value.decode('utf-8') 144 | ci_data[name] = value 145 | return pd.DataFrame([ci_data]) 146 | 147 | def pretty_timestamp(timestamp_str): 148 | """ 149 | Converts UTC timestamp of API to pretty formatted timestamp in local time 150 | 151 | Args: 152 | timestamp_str (str): UTC timestamp from API 153 | 154 | Returns: 155 | str: pretty formatted timestamp in local time 156 | """ 157 | utc_time = datetime.strptime(timestamp_str, '%Y-%m-%dT%H:%M:%S.%fZ') 158 | utc_time = utc_time.replace(tzinfo=pytz.UTC) 159 | berlin_time = utc_time.astimezone(pytz.timezone('Europe/Berlin')) 160 | formatted_time = berlin_time.strftime('%d.%m.%Y %H:%M:%S Uhr') 161 | return formatted_time 162 | 163 | def send_mail(smtp_settings, recipients, subject, html_message): 164 | """ 165 | Sends a html-formatted mail to a specified reciepient using SMTP 166 | 167 | Args: 168 | smtp_settings (dict): host, port, username, password and sender address (from) 169 | recipients (list of str): list of email addresses 170 | subject (str): mail subject 171 | html_message (str): html content of mail 172 | 173 | Returns: 174 | None 175 | """ 176 | msg = EmailMessage() 177 | msg.add_alternative(html_message, subtype='html') 178 | msg['Subject'] = subject 179 | msg['From'] = smtp_settings['from'] 180 | msg['Bcc'] = recipients 181 | s = smtplib.SMTP( 182 | host = smtp_settings['host'], 183 | port = smtp_settings['port'] 184 | ) 185 | s.ehlo() 186 | s.starttls() 187 | s.login( 188 | user = smtp_settings['user'], 189 | password = smtp_settings['password'] 190 | ) 191 | s.send_message(msg) 192 | s.quit() 193 | 194 | def create_html_list_item_for_change(change, home_url): 195 | """ 196 | Creates a html list item element for a configuration item with changed availability status 197 | 198 | Args: 199 | change (DataFrame): data for an individual configuration item containing information 200 | such as organization and product as well as current availability and availability difference 201 | home_url (str): base url of dash app 202 | 203 | Returns: 204 | str: html list item element 205 | """ 206 | if home_url: 207 | href = home_url + '/plot?ci=' + str(change['ci']) 208 | else: 209 | href = '' 210 | html_str = '
  • ' + str(change['ci']) + ': ' + str(change['product']) + ', ' + str(change['name']) + ', ' + str(change['organization']) + ' ' 211 | if change['availability_difference'] == 1: 212 | html_str += 'ist wieder verfügbar' 213 | elif change['availability_difference'] == -1: 214 | html_str += 'ist nicht mehr verfügbar' 215 | else: 216 | html_str += 'keine Veränderung' 217 | html_str += ', Stand: ' + str(pretty_timestamp(change['time'])) + '
  • ' 218 | return html_str 219 | 220 | def send_notifications(file_name, notifications_config_file, smtp_settings, home_url): 221 | """ 222 | Sends email notifications for each notification configuration about all 223 | changes that are relevant for the respective configuration 224 | 225 | Args: 226 | file_name (str): Path to hdf5 file 227 | notifications_config_file (str): Path to json file with notification configurations 228 | smtp_settings (dict): host, port, username, password and sender address (from) 229 | home_url (str): base url of dash app 230 | 231 | Returns: 232 | None 233 | """ 234 | # get notification config 235 | with open(notifications_config_file, 'r', encoding='utf-8') as f: 236 | notification_config = json.load(f) 237 | # get changes 238 | ci_data = get_data_of_all_cis(file_name) 239 | changes = ci_data[ci_data['availability_difference']!=0] 240 | changes_sorted = changes.sort_values(by = 'availability_difference') 241 | # filter relevant changes for each config and send mails 242 | for config in notification_config: 243 | try: 244 | if (config['type'] == 'whitelist'): 245 | relevant_changes = changes_sorted[changes_sorted['ci'].isin(config['ci_list'])] 246 | elif (config['type'] == 'blacklist'): 247 | relevant_changes = changes_sorted[~changes_sorted['ci'].isin(config['ci_list'])] 248 | number_of_relevant_changes = len(relevant_changes) 249 | if number_of_relevant_changes > 0: 250 | message = '

    Hallo ' + str(config['name']) + ',

    ' 251 | message += '

    bei der letzten Überprüfung hat sich die Verfügbarkeit der folgenden von Ihnen abonierten Komponenten geändert:

    Den aktuellen Status aller Komponenten können Sie unter ' + home_url + ' einsehen.

    ' 256 | message += '

    Weitere Hintergrundinformationen finden Sie im Fachportal der gematik GmbH.

    Viele Grüße
    TI-Monitoring

    ' 257 | subject = 'TI-Monitoring: ' + str(number_of_relevant_changes) + ' Änderungen der Verfügbarkeit' 258 | recipients = config['recipients'] 259 | send_mail(smtp_settings, recipients, subject, message) 260 | except: 261 | print('Sending notification for profile failed. Please check notifications config file.') 262 | pass 263 | 264 | def create_markdown_list_item_for_change(change): 265 | """ 266 | Creates a markdown list item element for a configuration item with changed availability status 267 | 268 | Args: 269 | change (DataFrame): data for an individual configuration item containing information 270 | such as organization and product as well as current availability and availability difference 271 | 272 | Returns: 273 | str: html list item element 274 | """ 275 | if change['availability_difference'] == 1: 276 | md_str = '🟢 ' 277 | elif change['availability_difference'] == -1: 278 | md_str = '🔴 ' 279 | else: 280 | md_str = '⚫️ ' 281 | md_str += str(change['ci']) + ': ' + str(change['product']) + ', ' + str(change['name']) + ', ' + str(change['organization']) + ' ' 282 | if change['availability_difference'] == 1: 283 | md_str += 'ist wieder verfügbar' 284 | elif change['availability_difference'] == -1: 285 | md_str += 'ist nicht mehr verfügbar' 286 | else: 287 | md_str += 'keine Veränderung' 288 | md_str += ', Stand: ' + str(pretty_timestamp(change['time'])) + '\n' 289 | return md_str 290 | 291 | def send_push_notifications(file_name, notifications_config_file, home_url, ntfy_url, ntfy_token): 292 | """ 293 | Sends push notifications for each notification configuration about all 294 | changes that are relevant for the respective configuration 295 | 296 | Args: 297 | file_name (str): Path to hdf5 file 298 | notifications_config_file (str): Path to json file with notification configurations 299 | home_url (str): base url of dash app 300 | ntfy_url (str): base url for push notifications 301 | ntfy_token (str): access token for push notifications 302 | 303 | Returns: 304 | None 305 | """ 306 | # get notification config 307 | with open(notifications_config_file, 'r', encoding='utf-8') as f: 308 | notification_config = json.load(f) 309 | # get changes 310 | ci_data = get_data_of_all_cis(file_name) 311 | changes = ci_data[ci_data['availability_difference']!=0] 312 | changes_sorted = changes.sort_values(by = 'availability_difference') 313 | # filter relevant changes for each config and send mails 314 | for config in notification_config: 315 | if (config['push_topic'] != ''): 316 | try: 317 | if (config['type'] == 'whitelist'): 318 | relevant_changes = changes_sorted[changes_sorted['ci'].isin(config['ci_list'])] 319 | elif (config['type'] == 'blacklist'): 320 | relevant_changes = changes_sorted[~changes_sorted['ci'].isin(config['ci_list'])] 321 | number_of_relevant_changes = len(relevant_changes) 322 | if number_of_relevant_changes > 0: 323 | message = "" 324 | for index, change in relevant_changes.iterrows(): 325 | message += create_markdown_list_item_for_change(change) 326 | subject = str(number_of_relevant_changes) + ' Änderung(en) der Verfügbarkeit' 327 | ntfy_topic_url = ntfy_url + '/' + config['push_topic'] 328 | requests.post(ntfy_topic_url, 329 | data=message.encode(encoding='utf-8'), 330 | headers={ 331 | "title": subject.encode(encoding='utf-8'), 332 | "markdown": "yes", 333 | "authorization": "Bearer " + ntfy_token, 334 | "actions": f"view, Übersicht, {home_url}; view, Alerts, {ntfy_topic_url}; view, Fachportal, https://fachportal.gematik.de/ti-status".encode(encoding='utf-8') 335 | } 336 | ) 337 | except: 338 | print('Sending push notification for profile failed. Please check notifications config file.') 339 | pass 340 | 341 | def main(): 342 | return 343 | 344 | if __name__ == '__main__': 345 | main() --------------------------------------------------------------------------------