init
PostgreSQL integration / tests (push) Has been cancelled

This commit is contained in:
ms
2026-11-09 13:15:48 +01:00
parent 4fb7613689
commit 2c1c48df0c
78 changed files with 2707 additions and 176 deletions
View File
+270
View File
@@ -0,0 +1,270 @@
import asyncio,json,os
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
import httpx
from dotenv import load_dotenv
from nicegui import app,ui
load_dotenv(os.getenv('DASHBOARD_ENV_FILE',str(Path(__file__).resolve().parents[1]/'.env')))
BASE=os.getenv('API_BASE_URL','http://127.0.0.1:8000/api').rstrip('/')
NAV=[('Übersicht','space_dashboard'),('Server','dns'),('Celery & Tasks','account_tree'),('Flower','local_florist'),('Historie','history'),('Alarme','notifications'),('dilibAI','auto_awesome'),('Einstellungen','settings')]
CSS="""
body{background:#f5f7f9;color:#253244;font-family:Inter,ui-sans-serif,system-ui,sans-serif}.q-page{padding:0!important}.q-drawer{border-right:1px solid #e7ecf0!important;background:white}.q-header{background:white!important;color:#788592!important;border-bottom:1px solid #e7ecf0;box-shadow:none!important}.panel{background:white;border:1px solid #e6ebef;border-radius:12px;padding:22px;box-shadow:none!important}.muted{color:#8994a2}.q-table th{background:#fafbfc;color:#8a96a5;text-transform:uppercase;font-size:10px;letter-spacing:.6px}.q-table td{font-size:12px;height:58px}.q-table__card{box-shadow:none;border:0}.q-table__title{font-size:15px;font-weight:600}.navbtn{width:100%;justify-content:flex-start;padding:12px 16px;color:#7d8b98;text-transform:none!important;border-radius:8px}.navactive{background:#eaf4ef!important;color:#298164!important}.q-btn{text-transform:none!important;letter-spacing:0}.q-field--outlined .q-field__control:before{border-color:#e3e9ed}.metricvalue{font-size:32px;font-weight:650;letter-spacing:-1px}.brand{font-size:34px;font-weight:800;letter-spacing:-2px}.q-dialog__inner>.q-card{border-radius:14px}.pagewrap{max-width:1700px;margin:auto;padding:30px 34px}.q-badge{font-size:11px;padding:5px 8px}@media(max-width:700px){.pagewrap{padding:20px 14px}.panel{padding:16px}}
"""
def when(v):
if not v:return '—'
try:return datetime.fromisoformat(str(v).replace('Z','+00:00')).astimezone(ZoneInfo('Europe/Berlin')).strftime('%d.%m. %H:%M:%S')
except ValueError:return str(v)
async def api(method,path,**kwargs):
token=app.storage.user.get('token','')
async with httpx.AsyncClient(timeout=60) as client:
r=await client.request(method,BASE+path,headers={'Authorization':'Bearer '+token},**kwargs)
if r.status_code==401:
app.storage.user.clear();ui.navigate.to('/login');raise RuntimeError('Bitte erneut anmelden')
if r.is_error:
try:detail=r.json().get('detail',r.json())
except ValueError:detail='Backend nicht erreichbar'
raise RuntimeError(str(detail))
return r
def metric(label,value,icon,sub):
with ui.card().classes('panel w-full'):
with ui.row().classes('w-full justify-between items-center'):
ui.label(label).classes('text-xs muted');ui.icon(icon).classes('text-lg text-emerald-700')
ui.label(str(value)).classes('metricvalue mt-2')
ui.label(sub).classes('text-xs muted')
def chart(data,metric_mode=False):
labels=[when(r['bucket'])[:12] for r in data]
series= [('CPU','cpu','#31896c'),('RAM','ram','#8daecb'),('Disk','disk','#c6a96e')] if metric_mode else [('Erfolgreich','success','#31896c'),('Fehlgeschlagen','failed','#d78174')]
ui.echart({'tooltip':{'trigger':'axis'},'legend':{'bottom':0},'grid':{'left':40,'right':10,'top':20,'bottom':50},'xAxis':{'type':'category','data':labels,'axisLine':{'show':False},'axisTick':{'show':False},'axisLabel':{'color':'#9aa4b0'}},'yAxis':{'type':'value','max':100 if metric_mode else None,'splitLine':{'lineStyle':{'color':'#eff2f5'}},'axisLabel':{'color':'#9aa4b0'}},'series':[{'name':label,'type':'line','smooth':True,'showSymbol':False,'data':[round(r.get(key,0) or 0,2) for r in data],'lineStyle':{'width':2,'color':color},'itemStyle':{'color':color},'areaStyle':{'opacity':.06}} for label,key,color in series]}).classes('w-full h-64')
@ui.page('/login')
async def login_page():
ui.add_css(CSS)
with ui.column().classes('w-full min-h-screen items-center justify-center'):
with ui.card().classes('panel w-full max-w-sm gap-5'):
ui.label('dilib°').classes('brand text-emerald-800')
ui.label('OPERATIONS').classes('text-xs tracking-widest muted')
ui.label('Ihre Infrastruktur. Ein Überblick.').classes('text-lg font-semibold')
username=ui.input('Benutzername').props('outlined').classes('w-full')
password=ui.input('Passwort',password=True,password_toggle_button=True).props('outlined').classes('w-full')
error=ui.label().classes('text-red-600 text-sm')
async def sign_in():
button.disable()
try:
async with httpx.AsyncClient(timeout=15) as client:
r=await client.post(BASE+'/auth/login/',json={'username':username.value,'password':password.value})
if r.is_error:raise RuntimeError(r.json().get('detail','Anmeldung fehlgeschlagen'))
app.storage.user.update(r.json());password.value='';ui.navigate.to('/')
except Exception as exc:error.set_text(str(exc) if isinstance(exc,RuntimeError) else 'API nicht erreichbar')
finally:button.enable()
button=ui.button('Anmelden',on_click=sign_in).classes('w-full').props('unelevated')
password.on('keydown.enter',sign_in)
ui.label('Geschützter Zugang · dilibworld').classes('muted text-xs')
@ui.page('/')
async def dashboard():
if not app.storage.user.get('token'):
ui.navigate.to('/login');return
try:me=(await api('GET','/auth/me/')).json()
except Exception:
ui.navigate.to('/login');return
ui.add_css(CSS)
ui.colors(primary='#2d8368',secondary='#89a8bf',positive='#278467',negative='#c96e64',warning='#c5984d')
state={'page':'Übersicht','segment':'Alle Netzsegmente','hours':24,'q':'','offset':0,'paused':False,'busy':False}
navbuttons={}
async def logout():
try:await api('POST','/auth/logout/')
finally:app.storage.user.clear();ui.navigate.to('/login')
with ui.left_drawer(value=None).props('width=220 breakpoint=900').classes('p-5') as drawer:
ui.label('dilib°').classes('brand text-emerald-800 mt-3 ml-3')
ui.label('OPERATIONS').classes('text-[10px] tracking-[3px] muted ml-3')
with ui.card().classes('bg-slate-50 shadow-none border w-full mt-8 mb-4 p-3'):
ui.label('dilibworld').classes('font-semibold text-sm');ui.label('Infrastructure Workspace').classes('muted text-[10px]')
ui.label('WORKSPACE').classes('muted text-[10px] tracking-widest ml-3 mt-3')
for title,icon in NAV:
async def select_page(t=title):
state.update(page=t,q='',offset=0);heading.set_text('Alles im Blick.' if t=='Übersicht' else t);crumb.set_text(t)
for n,b in navbuttons.items():b.classes(add='navactive' if n==t else '',remove='' if n==t else 'navactive')
search.value='';await refresh()
navbuttons[title]=ui.button(title,icon=icon,on_click=select_page).props('flat align=left').classes('navbtn'+(' navactive' if title=='Übersicht' else ''))
ui.space()
with ui.column().classes('w-full border-t pt-4 mt-8'):
ui.label(me['username']).classes('text-sm font-semibold')
ui.label('Administrator' if me['is_staff'] else 'Lesender Zugriff').classes('text-xs muted')
ui.button('Abmelden',icon='logout',on_click=logout).props('flat size=sm')
with ui.header().classes('h-16 px-6 flex-row items-center justify-between'):
with ui.row().classes('items-center'):
ui.button(icon='menu',on_click=drawer.toggle).props('flat round').classes('lg:hidden')
ui.label('Workspace /').classes('text-xs muted');crumb=ui.label('Übersicht').classes('text-xs')
with ui.row().classes('items-center gap-3'):
status=ui.label('Verbindung wird hergestellt …').classes('text-xs')
ui.badge('POSTGRESQL',color='grey-2',text_color='grey-7')
with ui.column().classes('pagewrap w-full gap-6'):
with ui.row().classes('w-full justify-between items-center'):
with ui.column().classes('gap-1'):
heading=ui.label('Alles im Blick.').classes('text-3xl font-semibold tracking-tight')
ui.label('Ihre Infrastruktur. Alle Netzwerke. Ein zentraler Überblick.').classes('muted text-xs')
async def export():
try:
r=await api('GET','/export/',params=params());ui.download.content(r.content,'dilib-server.csv','text/csv')
except Exception as e:ui.notify(str(e),type='negative')
ui.button('Bericht exportieren',icon='download',on_click=export).props('outline')
with ui.row().classes('w-full items-center gap-3'):
async def filter_change():
state.update(segment=segment.value,hours=period.value,q=search.value or '',offset=0);await refresh()
segment=ui.select(['Alle Netzsegmente'],value='Alle Netzsegmente',on_change=filter_change).props('outlined dense').classes('min-w-48')
period=ui.select({1:'Letzte Stunde',24:'Letzte 24 Stunden',168:'Letzte 7 Tage',720:'Letzte 30 Tage'},value=24,on_change=filter_change).props('outlined dense')
search=ui.input(placeholder='Server / Tasks / Ereignisse suchen').props('outlined dense debounce=450').classes('w-64')
search.on_value_change(filter_change)
ui.space()
async def pause():
state['paused']=not state['paused'];pausebtn.set_text('Fortsetzen' if state['paused'] else 'Live pausieren')
pausebtn=ui.button('Live pausieren',icon='pause',on_click=pause).props('flat size=sm')
body=ui.column().classes('w-full gap-5')
ui.label('dilib Operations · Zentrale Ereignishistorie · Anzeige in Europe/Berlin').classes('muted text-[10px]')
def params():
p={'hours':state['hours'],'offset':state['offset'],'q':state['q']}
if state['segment']!='Alle Netzsegmente':p['segment']=state['segment']
return p
async def detail(sid):
try:d=(await api('GET',f'/servers/{sid}/',params=params())).json()
except Exception as e:ui.notify(str(e),type='negative');return
with ui.dialog() as dialog,ui.card().classes('panel w-[800px] max-w-full'):
with ui.row().classes('w-full justify-between'):
ui.label(d['server']['name']).classes('text-xl font-semibold');ui.button(icon='close',on_click=dialog.close).props('flat round')
ui.label(d['server']['segment']+' · '+d['server']['address']).classes('muted text-xs')
ui.label('Letzte Messung: '+when(d['server']['sample_at'])).classes('text-xs')
chart(d['metrics'],True)
ui.label('Dienstprüfungen').classes('font-semibold')
for n,ok in d['server']['services'].items():ui.badge(n+(' · erreichbar' if ok else ' · nicht erreichbar'),color='positive' if ok else 'negative')
ui.label('Worker dieses Collectors').classes('font-semibold mt-2')
for w in d['workers']:ui.label(f"{w['hostname']} · {w['active']} aktiv · {when(w['last_seen'])}").classes('text-xs')
if me['is_staff']:
async def maintenance(e):
try:await api('PATCH',f'/servers/{sid}/',json={'maintenance':e.value});ui.notify('Wartungsmodus gespeichert')
except Exception as exc:ui.notify(str(exc),type='negative')
ui.switch('Wartungsmodus – Alarmierung aussetzen',value=d['server']['maintenance'],on_change=maintenance)
dialog.open()
def server_table(rows):
rows=[dict(r,cpu='—' if r['cpu'] is None else f"{r['cpu']:.1f} %",ram='—' if r['ram'] is None else f"{r['ram']:.1f} %",disk='—' if r['disk'] is None else f"{r['disk']:.1f} %") for r in rows if state['q'].lower() in r['name'].lower()]
cols=[{'name':k,'label':v,'field':k,'align':'left','sortable':True} for k,v in [('name','Server / Host'),('segment','Netzsegment'),('status','Status'),('cpu','CPU'),('ram','RAM'),('disk','Disk')]]
t=ui.table(columns=cols,rows=rows,row_key='id',pagination=10).classes('w-full')
t.add_slot('body-cell-status',"""<q-td :props="props"><q-badge :color="props.value === 'Online' ? 'positive' : props.value === 'Offline' ? 'negative' : 'warning'">{{ props.value }}</q-badge></q-td>""")
async def clicked(e):await detail(e.args[1]['id'])
t.on('rowClick',clicked)
async def refresh():
if state['busy']:return
state['busy']=True
try:
page=state['page'];p=params()
summary=(await api('GET','/summary/',params=p)).json()
opts=['Alle Netzsegmente']+[s['name'] for s in summary['segments']]
if segment.options!=opts:segment.options=opts;segment.update()
data=None
routes={'Celery & Tasks':'tasks','Flower':'flower','Historie':'events','Alarme':'alarms','Einstellungen':'settings'}
if page in routes:data=(await api('GET','/'+routes[page]+'/',params=p)).json()
status.set_text(('Pausiert · ' if state['paused'] else '● Verbunden · ')+when(summary['updated_at']))
body.clear()
with body:
if page=='Übersicht':
with ui.grid().classes('grid-cols-2 xl:grid-cols-4 w-full gap-4'):
metric('Erreichbare Server',f"{summary['online']} / {len(summary['servers'])}",'dns','Aktuelle Host-Messungen')
metric('Aktive Worker',summary['workers'],'account_tree','Frische Worker-Heartbeats')
metric('Tasks abgeschlossen',f"{summary['completed']:,}",'task_alt','Im gewählten Zeitraum')
metric('Erfolgsquote','—' if summary['success_rate'] is None else f"{summary['success_rate']} %",'verified','Bezogen auf abgeschlossene Tasks')
with ui.grid().classes('grid-cols-1 xl:grid-cols-3 w-full gap-5'):
with ui.card().classes('panel xl:col-span-2 w-full'):
ui.label('Task-Abschlüsse').classes('font-semibold');ui.label('Stündliche Aggregation · gewählter Zeitraum').classes('muted text-xs')
if summary['chart']:chart(summary['chart'])
else:ui.label('Noch keine Tasks empfangen. Collector und Worker-Events prüfen.').classes('muted py-12')
with ui.card().classes('panel w-full'):
ui.label('Netzsegmente').classes('font-semibold')
for s in summary['segments']:
with ui.row().classes('w-full py-3 border-b justify-between items-center'):
with ui.row().classes('items-center'):
ui.icon('hub').classes('text-emerald-700');ui.label(s['name']).classes('text-sm')
ui.label(s['cidr'] or '—').classes('muted text-xs')
ui.label(f"{summary['open_alarms']} offene Alarme").classes('text-amber-700 mt-4')
with ui.card().classes('panel w-full'):
ui.label('Serverübersicht').classes('font-semibold');server_table(summary['servers'])
elif page=='Server':
with ui.card().classes('panel w-full'):
ui.label('Serverübersicht · Zeile für Details auswählen').classes('font-semibold');server_table(summary['servers'])
elif page in ('Celery & Tasks','Historie','Alarme'):
rows=data['results']
keys= [('name','Task'),('server','Server'),('state','Status'),('runtime','Laufzeit (s)'),('at','Zeit')] if page=='Celery & Tasks' else [('server','Server'),('kind','Ereignis'),('at','Zeit'),('received_at','Empfangen')] if page=='Historie' else [('server','Server'),('severity','Priorität'),('message','Meldung'),('opened_at','Seit'),('by','Quittiert von')]
formatted=[{**r,**{k:when(r[k]) for k in ('at','received_at','opened_at') if k in r}} for r in rows]
with ui.card().classes('panel w-full'):
ui.label(f"{data['count']} Einträge · Zeile für Details auswählen").classes('font-semibold')
t=ui.table(columns=[{'name':k,'field':k,'label':label,'align':'left'} for k,label in keys],rows=formatted,row_key='id',pagination=0).classes('w-full')
async def row_detail(e):
original=next(r for r in rows if r['id']==e.args[1]['id'])
with ui.dialog() as dlg,ui.card().classes('panel w-[700px] max-w-full'):
ui.label('Eintragsdetails').classes('font-semibold text-lg')
ui.code(json.dumps(original,ensure_ascii=False,indent=2),language='json').classes('w-full')
if page=='Alarme' and me['is_staff'] and not original['acknowledged_at']:
async def ack():
try:await api('POST',f"/alarms/{original['id']}/ack/");dlg.close();await refresh()
except Exception as exc:ui.notify(str(exc),type='negative')
ui.button('Alarm quittieren',icon='done_all',on_click=ack)
ui.button('Schließen',on_click=dlg.close).props('flat')
dlg.open()
t.on('rowClick',row_detail)
with ui.row().classes('w-full justify-between'):
async def previous():state['offset']=max(0,state['offset']-100);await refresh()
async def following():state['offset']+=100;await refresh()
prev=ui.button('Zurück',on_click=previous).props('flat');prev.set_enabled(state['offset']>0)
ui.label(f"{state['offset']+1 if rows else 0}–{state['offset']+len(rows)} / {data['count']}").classes('text-xs muted')
nxt=ui.button('Weiter',on_click=following).props('flat');nxt.set_enabled(state['offset']+100<data['count'])
elif page=='Flower':
with ui.card().classes('panel w-full'):
ui.label('Flower-Instanzen').classes('text-lg font-semibold')
ui.label('Lokaler Flower-Zustand und zentrale PostgreSQL-Historie werden getrennt gespeichert.').classes('muted text-sm')
if not data:ui.label('Noch keine Flower-Prüfungen empfangen. AGENT_FLOWER_URL konfigurieren.').classes('py-8 muted')
for f in data:
with ui.row().classes('w-full border-b py-4 justify-between'):
ui.label(f['server']);ui.badge('Erreichbar' if f['online'] else 'Nicht erreichbar',color='positive' if f['online'] else 'negative');ui.label(when(f['at'])).classes('muted text-xs')
ui.label('Der Zeitstempel zeigt die letzte Prüfung. Veraltete Prüfungen lösen einen Alarm aus.').classes('muted text-xs')
elif page=='Einstellungen':
with ui.card().classes('panel w-full max-w-2xl'):
ui.label('Erfassung & Aufbewahrung').classes('font-semibold text-lg')
fields={}
for k,label in [('retention_days','Aufbewahrung (Tage)'),('heartbeat_timeout','Heartbeat-Timeout (Sekunden)'),('cpu_warning','CPU-Warnschwelle (%)'),('ram_warning','RAM-Warnschwelle (%)'),('disk_warning','Disk-Warnschwelle (%)')]:
fields[k]=ui.number(label,value=data[k],precision=0).props('outlined').classes('w-full')
async def save():
try:await api('PUT','/settings/',json={k:v.value for k,v in fields.items()});ui.notify('Einstellungen gespeichert')
except Exception as exc:ui.notify(str(exc),type='negative')
button=ui.button('Speichern',on_click=save);button.set_enabled(me['is_staff'])
ui.label('Retention löscht ältere Messungen, Tasks und Ereignisse stündlich.').classes('muted text-xs')
ui.link('Benutzer und Server verwalten','/admin/',new_tab=True)
elif page=='dilibAI':
with ui.card().classes('panel w-full'):
ui.label('Infrastruktur analysieren').classes('font-semibold text-xl')
ui.label('Sendet die offenen Alarmmeldungen an den administrativ konfigurierten dilibAI-Endpunkt. Keine Task-Argumente oder Ergebnisse.').classes('muted text-sm')
answer=ui.label('Noch keine Analyse gestartet.').classes('whitespace-pre-wrap leading-7')
async def analyze():
btn.disable();answer.set_text('Analyse läuft …')
try:answer.set_text((await api('POST','/ai/')).json()['answer'])
except Exception as exc:answer.set_text(str(exc))
finally:btn.enable()
btn=ui.button('Mit dilibAI analysieren',icon='auto_awesome',on_click=analyze);btn.set_enabled(me['is_staff'])
ui.separator()
async def heatmap():
try:r=await api('GET','/report/',params=params());ui.download.content(r.content,'dilib-ressourcen.png','image/png')
except Exception as exc:ui.notify(str(exc),type='negative')
ui.button('Ressourcen-Heatmap herunterladen',icon='grid_on',on_click=heatmap).props('outline')
except Exception as exc:
status.set_text('Verbindung unterbrochen · letzte Anzeige kann veraltet sein')
ui.notify(str(exc),type='negative',timeout=5000)
finally:state['busy']=False
await refresh()
async def live():
# Do not destroy unsaved settings or AI responses during a refresh.
if not state['paused'] and state['page'] not in ('Einstellungen','dilibAI'):await refresh()
ui.timer(5,live)
if __name__ in ('__main__','__mp_main__'):
secret=os.getenv('NICEGUI_STORAGE_SECRET','')
if not secret or secret.startswith('CHANGE_ME'):raise RuntimeError('Set NICEGUI_STORAGE_SECRET')
ui.run(host=os.getenv('UI_HOST','127.0.0.1'),port=int(os.getenv('UI_PORT','8080')),title='dilib Operations',favicon='◈',storage_secret=secret,reload=False,show=False,session_middleware_kwargs={'https_only':os.getenv('COOKIE_SECURE','1')=='1','same_site':'lax','max_age':28800})