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',"""{{ props.value }}""") 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