Files
ms 2c1c48df0c
PostgreSQL integration / tests (push) Has been cancelled
init
2026-11-09 13:15:48 +01:00

271 lines
22 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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})