271 lines
22 KiB
Python
271 lines
22 KiB
Python
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})
|