diff --git a/docs/adr/ADR-009-konzernplattform-modul-monolith.md b/docs/adr/ADR-009-konzernplattform-modul-monolith.md new file mode 100755 index 0000000..983cb38 --- /dev/null +++ b/docs/adr/ADR-009-konzernplattform-modul-monolith.md @@ -0,0 +1,30 @@ +# ADR-009: Konzernplattform als Modul-Monolith + +**Datum:** 2026-07-15 · **Status:** Akzeptiert · **Vorgabe:** "Wir machen aus dem ITSM eine Konzernplattform inkl. HR, CRM, usw." + +## Kontext +Das ITSM soll zur Unternehmensplattform mit Fachmodulen (HR, CRM, weitere) +werden. Es existiert bereits ein Erweiterungs-Muster (AES-Suite unter +/erweiterungen mit eigener Rolle aes_user). Alternativen: separate Services +je Domaene mit SSO vs. Module im bestehenden Binary. + +## Entscheidung +Modul-Monolith: Fachmodule sind Rust-Module im ITSM-Binary und teilen sich +Mandantentrennung, Auth/Sessions, CSRF, Audit-Log, Retention, UI-Shell und +Deployment. Je Modul: eigenes src/.rs, eigene Tabellen mit Praefix +(hr_*, crm_*), eigene Rolle(n) fuer den Zugriff (hr_manager, crm_agent), +eigene Nav-Gruppe. Der Kern (web.rs/db.rs/security.rs) bleibt modulagnostisch. + +HR-Daten sind besonders schuetzenswert: Zugriff ausschliesslich admin + +hr_manager — operative ITSM-Rollen (change_manager, agent) sehen HR NICHT. +CRM: admin + crm_agent. + +## Konsequenzen ++ Ein Deployment, eine DB, konsistente Sicherheit/Audit ueber alle Module; + schnellste Iterationsgeschwindigkeit bei aktueller Teamgroesse (1). ++ Modul-Verknuepfungen (Kunde<->Ticket, Onboarding->Service Request) sind + einfache Joins statt Service-APIs. +− Ein-Rollen-Modell wird mit wachsender Modulzahl eng — Abloesung durch + Modul-Grants ist als Plattform-phase-004 eingeplant. +− Bei stark divergenter Last oder Team-Skalierung waere ein Service-Split + ein neuer ADR; die Praefix-Trennung der Tabellen haelt diesen Weg offen. diff --git a/schema.sql b/schema.sql index f7cdb53..968749f 100755 --- a/schema.sql +++ b/schema.sql @@ -212,3 +212,85 @@ CREATE TABLE IF NOT EXISTS sessions ( ); CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions (user_id); CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions (expires_at); + +-- ═══════════════════════════════════════════════════════════════════════════════ +-- Migration 2026-07-15b: Konzernplattform phase-001 — Module HR + CRM (ADR-009) +-- HR-Daten: besonders schuetzenswert; Zugriff nur admin + hr_manager (App-RBAC). +-- ═══════════════════════════════════════════════════════════════════════════════ + +-- ── Modul HR ────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS hr_employees ( + id SERIAL PRIMARY KEY, + tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + personalnr TEXT, + vorname TEXT NOT NULL, + nachname TEXT NOT NULL, + email TEXT, + abteilung TEXT, + position TEXT, + eintritt DATE, + austritt DATE, + status TEXT NOT NULL DEFAULT 'Aktiv', -- Aktiv | Onboarding | Ausgeschieden + notizen TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_hr_emp_tenant_status ON hr_employees (tenant_id, status); + +CREATE TABLE IF NOT EXISTS hr_absences ( + id SERIAL PRIMARY KEY, + tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + employee_id INTEGER NOT NULL REFERENCES hr_employees(id) ON DELETE CASCADE, + typ TEXT NOT NULL DEFAULT 'Urlaub', -- Urlaub | Krankheit | Sonderurlaub | Fortbildung + von DATE NOT NULL, + bis DATE NOT NULL, + status TEXT NOT NULL DEFAULT 'Beantragt', -- Beantragt | Genehmigt | Abgelehnt + kommentar TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_hr_abs_tenant ON hr_absences (tenant_id, status); +CREATE INDEX IF NOT EXISTS idx_hr_abs_emp ON hr_absences (employee_id); + +-- ── Modul CRM ───────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS crm_companies ( + id SERIAL PRIMARY KEY, + tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + name TEXT NOT NULL, + branche TEXT, + website TEXT, + telefon TEXT, + adresse TEXT, + status TEXT NOT NULL DEFAULT 'Lead', -- Lead | Kunde | Partner | Inaktiv + notizen TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_crm_comp_tenant_status ON crm_companies (tenant_id, status); + +CREATE TABLE IF NOT EXISTS crm_contacts ( + id SERIAL PRIMARY KEY, + tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + company_id INTEGER NOT NULL REFERENCES crm_companies(id) ON DELETE CASCADE, + vorname TEXT, + nachname TEXT NOT NULL, + email TEXT, + telefon TEXT, + position TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_crm_cont_company ON crm_contacts (company_id); + +CREATE TABLE IF NOT EXISTS crm_deals ( + id SERIAL PRIMARY KEY, + tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + company_id INTEGER NOT NULL REFERENCES crm_companies(id) ON DELETE CASCADE, + titel TEXT NOT NULL, + wert_cent BIGINT NOT NULL DEFAULT 0, + stufe TEXT NOT NULL DEFAULT 'Lead', -- Lead | Qualifiziert | Angebot | Verhandlung | Gewonnen | Verloren + faellig DATE, + notizen TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_crm_deals_tenant_stufe ON crm_deals (tenant_id, stufe); +CREATE INDEX IF NOT EXISTS idx_crm_deals_company ON crm_deals (company_id); diff --git a/src/config.rs b/src/config.rs index cecfc89..98bb987 100755 --- a/src/config.rs +++ b/src/config.rs @@ -14,6 +14,9 @@ //! RETENTION_INTERVAL_SECONDS Intervall Retention-Bereinigung (Default 24h) //! FORGE_BASE_URL Basis-URL des Forge-Git-Servers (optional) //! FORGE_SERVICE_TOKEN API-Token fuer Forge-Contents-Zugriffe (optional) +//! ITSM_AES_URL Basis-URL der AES-Suite (Modul /ext/aes), z.B. +//! http://host.docker.internal:8440 (leer = Modul aus) +//! ITSM_AES_TOKEN Bearer-Token der AES-Suite (== deren AES_TOKEN) //! //! Hinweis: anders als die fruehere Flask-Version braucht der Rust-Server //! kein ITSM_SECRET_KEY mehr -- Sessions liegen serverseitig in PostgreSQL @@ -29,6 +32,17 @@ pub struct Config { pub retention_interval_seconds: u64, pub forge_base_url: String, pub forge_service_token: String, + /// AES-Suite-Erweiterung (Modul unter /ext/aes): Basis-URL + Bearer-Token. + pub aes_ext_url: String, + pub aes_ext_token: String, + /// AES-Dashboard: zusaetzliche Aggregationsquellen (LLM-Balancer, Forge). + pub aes_balancer_url: String, + pub aes_balancer_token: String, + pub aes_forge_url: String, + pub aes_forge_token: String, + pub aes_forge_owner: String, + /// Token zum Absichern der externen Metrics-Endpunkte (/aes/metrics*). + pub aes_metrics_token: String, pub login_max_attempts: i64, pub login_window_minutes: i64, pub password_min_length: usize, @@ -38,6 +52,11 @@ fn env(name: &str) -> String { std::env::var(name).unwrap_or_default() } +fn env_or(name: &str, default: &str) -> String { + let v = std::env::var(name).unwrap_or_default(); + if v.is_empty() { default.to_string() } else { v } +} + fn env_num(name: &str, default: T) -> T { std::env::var(name).ok().and_then(|v| v.parse().ok()).unwrap_or(default) } @@ -54,6 +73,14 @@ impl Config { retention_interval_seconds: env_num("RETENTION_INTERVAL_SECONDS", 24 * 60 * 60u64), forge_base_url: env("FORGE_BASE_URL").trim_end_matches('/').to_string(), forge_service_token: env("FORGE_SERVICE_TOKEN"), + aes_ext_url: env("ITSM_AES_URL").trim_end_matches('/').to_string(), + aes_ext_token: env("ITSM_AES_TOKEN"), + aes_balancer_url: env_or("ITSM_AES_BALANCER_URL", "http://host.docker.internal:8442").trim_end_matches('/').to_string(), + aes_balancer_token: env("ITSM_AES_BALANCER_TOKEN"), + aes_forge_url: env_or("ITSM_AES_FORGE_URL", "http://host.docker.internal:8441").trim_end_matches('/').to_string(), + aes_forge_token: env("ITSM_AES_FORGE_TOKEN"), + aes_forge_owner: env_or("ITSM_AES_FORGE_OWNER", "forge"), + aes_metrics_token: env("ITSM_AES_METRICS_TOKEN"), login_max_attempts: env_num("ITSM_LOGIN_MAX_ATTEMPTS", 5i64), login_window_minutes: env_num("ITSM_LOGIN_WINDOW_MINUTES", 15i64), password_min_length: env_num("ITSM_PASSWORD_MIN_LENGTH", 12usize), diff --git a/src/crm.rs b/src/crm.rs new file mode 100755 index 0000000..2021a8d --- /dev/null +++ b/src/crm.rs @@ -0,0 +1,346 @@ +//! Modul CRM (Konzernplattform phase-001, ADR-009). +//! Firmen, Kontakte, Deals mit Pipeline. Zugriff: admin + crm_agent. + +use askama::Template; +use axum::extract::{Extension, Path, Query, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Redirect}; +use axum::Form; +use chrono::NaiveDate; +use serde::Deserialize; + +use crate::web::{need_auth, need_crm, page_ctx, AppState, PageCtx, ReqCtx, WebResult}; + +pub const COMPANY_STATUS: [&str; 4] = ["Lead", "Kunde", "Partner", "Inaktiv"]; +pub const DEAL_STAGES: [&str; 6] = ["Lead", "Qualifiziert", "Angebot", "Verhandlung", "Gewonnen", "Verloren"]; + +fn opt(s: &str) -> Option<&str> { + let t = s.trim(); + if t.is_empty() { None } else { Some(t) } +} + +fn status_class(s: &str) -> &'static str { + match s { + "Kunde" | "Gewonnen" => "geloest", + "Lead" | "Qualifiziert" => "offen", + "Angebot" | "Verhandlung" | "Partner" => "inbearbeitung", + "Inaktiv" | "Verloren" => "geschlossen", + _ => "offen", + } +} + +fn eur(cent: i64) -> String { + format!("{},{:02} €", cent / 100, (cent % 100).abs()) +} + +// ── Firmenliste ──────────────────────────────────────────────────────────────── +pub struct CompanyRow { + pub id: i32, + pub name: String, + pub branche: String, + pub telefon: String, + pub status: String, + pub status_class: String, +} + +#[derive(Template)] +#[template(path = "crm_list.html")] +pub struct CrmListTemplate { + pub title: String, + pub ctx: PageCtx, + pub kpis: Vec<(String, String)>, + pub tabs: Vec<(String, String, bool)>, + pub rows: Vec, + pub status_opts: Vec, +} + +#[derive(Deserialize)] +pub struct CrmQuery { + pub status: Option, +} + +pub async fn crm_list(State(app): State, Extension(ctx): Extension, + Query(q): Query) -> WebResult { + let auth = match need_auth(&ctx, "/crm") { Ok(a) => a, Err(r) => return Ok(r) }; + if let Err(r) = need_crm(&auth) { return Ok(r); } + let filter = q.status.as_deref().filter(|s| COMPANY_STATUS.contains(s)); + let all = app.db.crm_list_companies(auth.tenant_id, None).await?; + let deals = app.db.crm_list_deals(auth.tenant_id, None).await?; + + let open_stages = ["Lead", "Qualifiziert", "Angebot", "Verhandlung"]; + let pipeline_cent: i64 = deals.iter().filter(|d| open_stages.contains(&d.stufe.as_str())) + .map(|d| d.wert_cent).sum(); + let won_cent: i64 = deals.iter().filter(|d| d.stufe == "Gewonnen").map(|d| d.wert_cent).sum(); + let kpis = vec![ + ("Firmen".into(), all.len().to_string()), + ("Kunden".into(), all.iter().filter(|c| c.status == "Kunde").count().to_string()), + ("Offene Deals".into(), deals.iter().filter(|d| open_stages.contains(&d.stufe.as_str())).count().to_string()), + ("Pipeline-Wert".into(), eur(pipeline_cent)), + ("Gewonnen (gesamt)".into(), eur(won_cent)), + ]; + let mut tabs = vec![("/crm".to_string(), "Alle".to_string(), filter.is_none())]; + tabs.extend(COMPANY_STATUS.iter().map(|s| { + (format!("/crm?status={}", s), s.to_string(), filter == Some(s)) + })); + let rows = all.iter() + .filter(|c| filter.map(|f| c.status == f).unwrap_or(true)) + .map(|c| CompanyRow { + id: c.id, + name: c.name.clone(), + branche: c.branche.clone().unwrap_or_default(), + telefon: c.telefon.clone().unwrap_or_default(), + status: c.status.clone(), + status_class: status_class(&c.status).into(), + }).collect(); + + Ok(CrmListTemplate { + title: "CRM — Firmen".into(), + ctx: page_ctx(&auth, "/crm"), + kpis, tabs, rows, + status_opts: COMPANY_STATUS.iter().map(|s| s.to_string()).collect(), + }.into_response()) +} + +// ── Firma anlegen / Detail ───────────────────────────────────────────────────── +#[derive(Deserialize)] +pub struct CompanyForm { + pub name: String, + #[serde(default)] pub branche: String, + #[serde(default)] pub website: String, + #[serde(default)] pub telefon: String, + #[serde(default)] pub adresse: String, + #[serde(default)] pub status: String, + #[serde(default)] pub notizen: String, +} + +pub async fn crm_new(State(app): State, Extension(ctx): Extension, + Form(f): Form) -> WebResult { + let auth = match need_auth(&ctx, "/crm") { Ok(a) => a, Err(r) => return Ok(r) }; + if let Err(r) = need_crm(&auth) { return Ok(r); } + let name = f.name.trim(); + if name.is_empty() { + return Ok(Redirect::to("/crm").into_response()); + } + let status = if COMPANY_STATUS.contains(&f.status.as_str()) { f.status.as_str() } else { "Lead" }; + let id = app.db.crm_create_company(auth.tenant_id, name, opt(&f.branche), opt(&f.website), + opt(&f.telefon), opt(&f.adresse), status, opt(&f.notizen)).await?; + app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "crm_company_created", Some("crm_company"), + Some(&id.to_string()), Some(serde_json::json!({"name": name})), + ctx.ip.as_deref()).await?; + Ok(Redirect::to(&format!("/crm/{}", id)).into_response()) +} + +pub struct ContactRow { + pub name: String, + pub email: String, + pub telefon: String, + pub position: String, +} + +pub struct DealRow { + pub id: i32, + pub titel: String, + pub company: String, + pub wert: String, + pub stufe: String, + pub stufe_class: String, + pub faellig: String, +} + +#[derive(Template)] +#[template(path = "crm_detail.html")] +pub struct CrmDetailTemplate { + pub title: String, + pub ctx: PageCtx, + pub id: i32, + pub name: String, + pub branche: String, + pub website: String, + pub telefon: String, + pub adresse: String, + pub status: String, + pub notizen: String, + pub status_opts: Vec<(String, bool)>, + pub contacts: Vec, + pub deals: Vec, + pub stage_opts: Vec, +} + +pub async fn crm_detail(State(app): State, Extension(ctx): Extension, + Path(id): Path) -> WebResult { + let auth = match need_auth(&ctx, "/crm") { Ok(a) => a, Err(r) => return Ok(r) }; + if let Err(r) = need_crm(&auth) { return Ok(r); } + let Some(c) = app.db.crm_get_company(auth.tenant_id, id).await? else { + return Ok(StatusCode::NOT_FOUND.into_response()); + }; + let contacts = app.db.crm_list_contacts(auth.tenant_id, id).await?; + let deals = app.db.crm_list_deals(auth.tenant_id, Some(id)).await?; + Ok(CrmDetailTemplate { + title: c.name.clone(), + ctx: page_ctx(&auth, "/crm"), + id: c.id, + name: c.name.clone(), + branche: c.branche.clone().unwrap_or_default(), + website: c.website.clone().unwrap_or_default(), + telefon: c.telefon.clone().unwrap_or_default(), + adresse: c.adresse.clone().unwrap_or_default(), + status: c.status.clone(), + notizen: c.notizen.clone().unwrap_or_default(), + status_opts: COMPANY_STATUS.iter().map(|s| (s.to_string(), *s == c.status)).collect(), + contacts: contacts.iter().map(|k| ContactRow { + name: format!("{} {}", k.vorname.clone().unwrap_or_default(), k.nachname).trim().to_string(), + email: k.email.clone().unwrap_or_default(), + telefon: k.telefon.clone().unwrap_or_default(), + position: k.position.clone().unwrap_or_default(), + }).collect(), + deals: deals.iter().map(|d| DealRow { + id: d.id, + titel: d.titel.clone(), + company: d.company_name.clone(), + wert: eur(d.wert_cent), + stufe: d.stufe.clone(), + stufe_class: status_class(&d.stufe).into(), + faellig: d.faellig.map(|x| x.format("%d.%m.%Y").to_string()).unwrap_or_default(), + }).collect(), + stage_opts: DEAL_STAGES.iter().map(|s| s.to_string()).collect(), + }.into_response()) +} + +pub async fn crm_update(State(app): State, Extension(ctx): Extension, + Path(id): Path, Form(f): Form) -> WebResult { + let auth = match need_auth(&ctx, "/crm") { Ok(a) => a, Err(r) => return Ok(r) }; + if let Err(r) = need_crm(&auth) { return Ok(r); } + if app.db.crm_get_company(auth.tenant_id, id).await?.is_none() { + return Ok(StatusCode::NOT_FOUND.into_response()); + } + let status = if COMPANY_STATUS.contains(&f.status.as_str()) { f.status.as_str() } else { "Lead" }; + app.db.crm_update_company(auth.tenant_id, id, f.name.trim(), opt(&f.branche), opt(&f.website), + opt(&f.telefon), opt(&f.adresse), status, opt(&f.notizen)).await?; + app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "crm_company_updated", Some("crm_company"), + Some(&id.to_string()), None, ctx.ip.as_deref()).await?; + Ok(Redirect::to(&format!("/crm/{}", id)).into_response()) +} + +// ── Kontakte + Deals ─────────────────────────────────────────────────────────── +#[derive(Deserialize)] +pub struct ContactForm { + #[serde(default)] pub vorname: String, + pub nachname: String, + #[serde(default)] pub email: String, + #[serde(default)] pub telefon: String, + #[serde(default)] pub position: String, +} + +pub async fn crm_contact_new(State(app): State, Extension(ctx): Extension, + Path(id): Path, Form(f): Form) -> WebResult { + let auth = match need_auth(&ctx, "/crm") { Ok(a) => a, Err(r) => return Ok(r) }; + if let Err(r) = need_crm(&auth) { return Ok(r); } + if app.db.crm_get_company(auth.tenant_id, id).await?.is_none() { + return Ok(StatusCode::NOT_FOUND.into_response()); + } + if !f.nachname.trim().is_empty() { + let kid = app.db.crm_create_contact(auth.tenant_id, id, opt(&f.vorname), f.nachname.trim(), + opt(&f.email), opt(&f.telefon), opt(&f.position)).await?; + app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "crm_contact_created", Some("crm_contact"), + Some(&kid.to_string()), None, ctx.ip.as_deref()).await?; + } + Ok(Redirect::to(&format!("/crm/{}", id)).into_response()) +} + +#[derive(Deserialize)] +pub struct DealForm { + pub titel: String, + #[serde(default)] pub wert_eur: String, + #[serde(default)] pub stufe: String, + #[serde(default)] pub faellig: String, +} + +pub async fn crm_deal_new(State(app): State, Extension(ctx): Extension, + Path(id): Path, Form(f): Form) -> WebResult { + let auth = match need_auth(&ctx, "/crm") { Ok(a) => a, Err(r) => return Ok(r) }; + if let Err(r) = need_crm(&auth) { return Ok(r); } + if app.db.crm_get_company(auth.tenant_id, id).await?.is_none() { + return Ok(StatusCode::NOT_FOUND.into_response()); + } + let titel = f.titel.trim(); + if titel.is_empty() { + return Ok(Redirect::to(&format!("/crm/{}", id)).into_response()); + } + // "1234,56" oder "1234.56" oder "1234" -> Cent + let cleaned = f.wert_eur.trim().replace('.', "").replace(',', "."); + let wert_cent = (cleaned.parse::().unwrap_or(0.0) * 100.0).round() as i64; + let stufe = if DEAL_STAGES.contains(&f.stufe.as_str()) { f.stufe.as_str() } else { "Lead" }; + let faellig = NaiveDate::parse_from_str(f.faellig.trim(), "%Y-%m-%d").ok(); + let did = app.db.crm_create_deal(auth.tenant_id, id, titel, wert_cent.max(0), stufe, faellig).await?; + app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "crm_deal_created", Some("crm_deal"), + Some(&did.to_string()), Some(serde_json::json!({"titel": titel, "wert_cent": wert_cent})), + ctx.ip.as_deref()).await?; + Ok(Redirect::to(&format!("/crm/{}", id)).into_response()) +} + +#[derive(Deserialize)] +pub struct DealStageForm { + pub stufe: String, + #[serde(default)] pub zurueck: String, +} + +pub async fn crm_deal_stage(State(app): State, Extension(ctx): Extension, + Path(did): Path, Form(f): Form) -> WebResult { + let auth = match need_auth(&ctx, "/crm") { Ok(a) => a, Err(r) => return Ok(r) }; + if let Err(r) = need_crm(&auth) { return Ok(r); } + if !DEAL_STAGES.contains(&f.stufe.as_str()) { + return Ok(Redirect::to("/crm/pipeline").into_response()); + } + app.db.crm_set_deal_stage(auth.tenant_id, did, &f.stufe).await?; + app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "crm_deal_stage_changed", Some("crm_deal"), + Some(&did.to_string()), Some(serde_json::json!({"stufe": f.stufe})), + ctx.ip.as_deref()).await?; + let back = if f.zurueck.starts_with('/') && !f.zurueck.starts_with("//") { f.zurueck } else { "/crm/pipeline".into() }; + Ok(Redirect::to(&back).into_response()) +} + +// ── Pipeline-Board ───────────────────────────────────────────────────────────── +pub struct PipelineColumn { + pub title: String, + pub count: usize, + pub summe: String, + pub deals: Vec, +} + +#[derive(Template)] +#[template(path = "crm_pipeline.html")] +pub struct PipelineTemplate { + pub title: String, + pub ctx: PageCtx, + pub columns: Vec, + pub stage_opts: Vec, +} + +pub async fn crm_pipeline(State(app): State, Extension(ctx): Extension) -> WebResult { + let auth = match need_auth(&ctx, "/crm/pipeline") { Ok(a) => a, Err(r) => return Ok(r) }; + if let Err(r) = need_crm(&auth) { return Ok(r); } + let deals = app.db.crm_list_deals(auth.tenant_id, None).await?; + let columns = DEAL_STAGES.iter().map(|stage| { + let ds: Vec<&crate::db::Deal> = deals.iter().filter(|d| d.stufe == *stage).collect(); + PipelineColumn { + title: stage.to_string(), + count: ds.len(), + summe: eur(ds.iter().map(|d| d.wert_cent).sum()), + deals: ds.iter().map(|d| DealRow { + id: d.id, + titel: d.titel.clone(), + company: d.company_name.clone(), + wert: eur(d.wert_cent), + stufe: d.stufe.clone(), + stufe_class: status_class(&d.stufe).into(), + faellig: d.faellig.map(|x| x.format("%d.%m.%Y").to_string()).unwrap_or_default(), + }).collect(), + } + }).collect(); + Ok(PipelineTemplate { + title: "CRM — Pipeline".into(), + ctx: page_ctx(&auth, "/crm/pipeline"), + columns, + stage_opts: DEAL_STAGES.iter().map(|s| s.to_string()).collect(), + }.into_response()) +} diff --git a/src/db.rs b/src/db.rs index 9b0a6c5..4695369 100755 --- a/src/db.rs +++ b/src/db.rs @@ -908,3 +908,299 @@ fn ci_from_row(r: &tokio_postgres::Row) -> Ci { attribute: r.get("attribute"), } } + +// ═════════════════════════════════════════════════════════════════════════════ +// Konzernplattform phase-001 (ADR-009): Module HR + CRM +// HR-Daten sind besonders schuetzenswert — Aufrufer MUESSEN need_hr() pruefen. +// ═════════════════════════════════════════════════════════════════════════════ + +use chrono::NaiveDate; + +#[derive(Debug, Clone)] +pub struct Employee { + pub id: i32, + pub personalnr: Option, + pub vorname: String, + pub nachname: String, + pub email: Option, + pub abteilung: Option, + pub position: Option, + pub eintritt: Option, + pub austritt: Option, + pub status: String, + pub notizen: Option, +} + +#[derive(Debug, Clone)] +pub struct Absence { + pub id: i32, + pub employee_id: i32, + pub employee_name: String, + pub typ: String, + pub von: NaiveDate, + pub bis: NaiveDate, + pub status: String, + pub kommentar: Option, +} + +#[derive(Debug, Clone)] +pub struct Company { + pub id: i32, + pub name: String, + pub branche: Option, + pub website: Option, + pub telefon: Option, + pub adresse: Option, + pub status: String, + pub notizen: Option, +} + +#[derive(Debug, Clone)] +pub struct Contact { + pub id: i32, + pub vorname: Option, + pub nachname: String, + pub email: Option, + pub telefon: Option, + pub position: Option, +} + +#[derive(Debug, Clone)] +pub struct Deal { + pub id: i32, + pub company_id: i32, + pub company_name: String, + pub titel: String, + pub wert_cent: i64, + pub stufe: String, + pub faellig: Option, +} + +fn employee_from_row(r: &tokio_postgres::Row) -> Employee { + Employee { + id: r.get("id"), + personalnr: r.get("personalnr"), + vorname: r.get("vorname"), + nachname: r.get("nachname"), + email: r.get("email"), + abteilung: r.get("abteilung"), + position: r.get("position"), + eintritt: r.get("eintritt"), + austritt: r.get("austritt"), + status: r.get("status"), + notizen: r.get("notizen"), + } +} + +impl Db { + // ── HR ───────────────────────────────────────────────────────────────────── + #[allow(clippy::too_many_arguments)] + pub async fn hr_create_employee(&self, tenant_id: i32, personalnr: Option<&str>, vorname: &str, + nachname: &str, email: Option<&str>, abteilung: Option<&str>, + position: Option<&str>, eintritt: Option, + status: &str, notizen: Option<&str>) -> DbResult { + let c = self.conn().await?; + let row = c.query_one( + "INSERT INTO hr_employees (tenant_id, personalnr, vorname, nachname, email, abteilung, + position, eintritt, status, notizen) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING id", + &[&tenant_id, &personalnr, &vorname, &nachname, &email, &abteilung, + &position, &eintritt, &status, ¬izen]).await?; + Ok(row.get(0)) + } + + pub async fn hr_list_employees(&self, tenant_id: i32, status: Option<&str>) -> DbResult> { + let c = self.conn().await?; + let rows = match status { + Some(s) => c.query("SELECT * FROM hr_employees WHERE tenant_id=$1 AND status=$2 ORDER BY nachname, vorname", + &[&tenant_id, &s]).await?, + None => c.query("SELECT * FROM hr_employees WHERE tenant_id=$1 ORDER BY nachname, vorname", + &[&tenant_id]).await?, + }; + Ok(rows.iter().map(employee_from_row).collect()) + } + + pub async fn hr_get_employee(&self, tenant_id: i32, id: i32) -> DbResult> { + let c = self.conn().await?; + Ok(c.query_opt("SELECT * FROM hr_employees WHERE tenant_id=$1 AND id=$2", &[&tenant_id, &id]) + .await?.map(|r| employee_from_row(&r))) + } + + #[allow(clippy::too_many_arguments)] + pub async fn hr_update_employee(&self, tenant_id: i32, id: i32, personalnr: Option<&str>, + vorname: &str, nachname: &str, email: Option<&str>, + abteilung: Option<&str>, position: Option<&str>, + eintritt: Option, austritt: Option, + status: &str, notizen: Option<&str>) -> DbResult<()> { + let c = self.conn().await?; + c.execute( + "UPDATE hr_employees SET personalnr=$1, vorname=$2, nachname=$3, email=$4, abteilung=$5, + position=$6, eintritt=$7, austritt=$8, status=$9, notizen=$10, updated_at=now() + WHERE tenant_id=$11 AND id=$12", + &[&personalnr, &vorname, &nachname, &email, &abteilung, &position, + &eintritt, &austritt, &status, ¬izen, &tenant_id, &id]).await?; + Ok(()) + } + + pub async fn hr_create_absence(&self, tenant_id: i32, employee_id: i32, typ: &str, + von: NaiveDate, bis: NaiveDate, kommentar: Option<&str>) -> DbResult { + let c = self.conn().await?; + let row = c.query_one( + "INSERT INTO hr_absences (tenant_id, employee_id, typ, von, bis, kommentar) + VALUES ($1,$2,$3,$4,$5,$6) RETURNING id", + &[&tenant_id, &employee_id, &typ, &von, &bis, &kommentar]).await?; + Ok(row.get(0)) + } + + pub async fn hr_list_absences(&self, tenant_id: i32, employee_id: Option) -> DbResult> { + let c = self.conn().await?; + let sql = "SELECT a.id, a.employee_id, a.typ, a.von, a.bis, a.status, a.kommentar, + (e.vorname || ' ' || e.nachname) AS employee_name + FROM hr_absences a JOIN hr_employees e ON e.id = a.employee_id + WHERE a.tenant_id=$1"; + let rows = match employee_id { + Some(eid) => c.query(&format!("{} AND a.employee_id=$2 ORDER BY a.von DESC", sql), + &[&tenant_id, &eid]).await?, + None => c.query(&format!("{} ORDER BY a.von DESC LIMIT 200", sql), &[&tenant_id]).await?, + }; + Ok(rows.iter().map(|r| Absence { + id: r.get("id"), + employee_id: r.get("employee_id"), + employee_name: r.get("employee_name"), + typ: r.get("typ"), + von: r.get("von"), + bis: r.get("bis"), + status: r.get("status"), + kommentar: r.get("kommentar"), + }).collect()) + } + + pub async fn hr_set_absence_status(&self, tenant_id: i32, absence_id: i32, status: &str) -> DbResult<()> { + let c = self.conn().await?; + c.execute("UPDATE hr_absences SET status=$1 WHERE tenant_id=$2 AND id=$3", + &[&status, &tenant_id, &absence_id]).await?; + Ok(()) + } + + // ── CRM ──────────────────────────────────────────────────────────────────── + #[allow(clippy::too_many_arguments)] + pub async fn crm_create_company(&self, tenant_id: i32, name: &str, branche: Option<&str>, + website: Option<&str>, telefon: Option<&str>, adresse: Option<&str>, + status: &str, notizen: Option<&str>) -> DbResult { + let c = self.conn().await?; + let row = c.query_one( + "INSERT INTO crm_companies (tenant_id, name, branche, website, telefon, adresse, status, notizen) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id", + &[&tenant_id, &name, &branche, &website, &telefon, &adresse, &status, ¬izen]).await?; + Ok(row.get(0)) + } + + pub async fn crm_list_companies(&self, tenant_id: i32, status: Option<&str>) -> DbResult> { + let c = self.conn().await?; + let rows = match status { + Some(s) => c.query("SELECT * FROM crm_companies WHERE tenant_id=$1 AND status=$2 ORDER BY name", + &[&tenant_id, &s]).await?, + None => c.query("SELECT * FROM crm_companies WHERE tenant_id=$1 ORDER BY name", &[&tenant_id]).await?, + }; + Ok(rows.iter().map(|r| Company { + id: r.get("id"), + name: r.get("name"), + branche: r.get("branche"), + website: r.get("website"), + telefon: r.get("telefon"), + adresse: r.get("adresse"), + status: r.get("status"), + notizen: r.get("notizen"), + }).collect()) + } + + pub async fn crm_get_company(&self, tenant_id: i32, id: i32) -> DbResult> { + let c = self.conn().await?; + Ok(c.query_opt("SELECT * FROM crm_companies WHERE tenant_id=$1 AND id=$2", &[&tenant_id, &id]) + .await?.map(|r| Company { + id: r.get("id"), + name: r.get("name"), + branche: r.get("branche"), + website: r.get("website"), + telefon: r.get("telefon"), + adresse: r.get("adresse"), + status: r.get("status"), + notizen: r.get("notizen"), + })) + } + + #[allow(clippy::too_many_arguments)] + pub async fn crm_update_company(&self, tenant_id: i32, id: i32, name: &str, branche: Option<&str>, + website: Option<&str>, telefon: Option<&str>, adresse: Option<&str>, + status: &str, notizen: Option<&str>) -> DbResult<()> { + let c = self.conn().await?; + c.execute( + "UPDATE crm_companies SET name=$1, branche=$2, website=$3, telefon=$4, adresse=$5, + status=$6, notizen=$7, updated_at=now() WHERE tenant_id=$8 AND id=$9", + &[&name, &branche, &website, &telefon, &adresse, &status, ¬izen, &tenant_id, &id]).await?; + Ok(()) + } + + pub async fn crm_create_contact(&self, tenant_id: i32, company_id: i32, vorname: Option<&str>, + nachname: &str, email: Option<&str>, telefon: Option<&str>, + position: Option<&str>) -> DbResult { + let c = self.conn().await?; + let row = c.query_one( + "INSERT INTO crm_contacts (tenant_id, company_id, vorname, nachname, email, telefon, position) + VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id", + &[&tenant_id, &company_id, &vorname, &nachname, &email, &telefon, &position]).await?; + Ok(row.get(0)) + } + + pub async fn crm_list_contacts(&self, tenant_id: i32, company_id: i32) -> DbResult> { + let c = self.conn().await?; + let rows = c.query( + "SELECT * FROM crm_contacts WHERE tenant_id=$1 AND company_id=$2 ORDER BY nachname", + &[&tenant_id, &company_id]).await?; + Ok(rows.iter().map(|r| Contact { + id: r.get("id"), + vorname: r.get("vorname"), + nachname: r.get("nachname"), + email: r.get("email"), + telefon: r.get("telefon"), + position: r.get("position"), + }).collect()) + } + + pub async fn crm_create_deal(&self, tenant_id: i32, company_id: i32, titel: &str, + wert_cent: i64, stufe: &str, faellig: Option) -> DbResult { + let c = self.conn().await?; + let row = c.query_one( + "INSERT INTO crm_deals (tenant_id, company_id, titel, wert_cent, stufe, faellig) + VALUES ($1,$2,$3,$4,$5,$6) RETURNING id", + &[&tenant_id, &company_id, &titel, &wert_cent, &stufe, &faellig]).await?; + Ok(row.get(0)) + } + + pub async fn crm_list_deals(&self, tenant_id: i32, company_id: Option) -> DbResult> { + let c = self.conn().await?; + let sql = "SELECT d.id, d.company_id, d.titel, d.wert_cent, d.stufe, d.faellig, + c.name AS company_name FROM crm_deals d + JOIN crm_companies c ON c.id = d.company_id WHERE d.tenant_id=$1"; + let rows = match company_id { + Some(cid) => c.query(&format!("{} AND d.company_id=$2 ORDER BY d.updated_at DESC", sql), + &[&tenant_id, &cid]).await?, + None => c.query(&format!("{} ORDER BY d.updated_at DESC", sql), &[&tenant_id]).await?, + }; + Ok(rows.iter().map(|r| Deal { + id: r.get("id"), + company_id: r.get("company_id"), + company_name: r.get("company_name"), + titel: r.get("titel"), + wert_cent: r.get("wert_cent"), + stufe: r.get("stufe"), + faellig: r.get("faellig"), + }).collect()) + } + + pub async fn crm_set_deal_stage(&self, tenant_id: i32, deal_id: i32, stufe: &str) -> DbResult<()> { + let c = self.conn().await?; + c.execute("UPDATE crm_deals SET stufe=$1, updated_at=now() WHERE tenant_id=$2 AND id=$3", + &[&stufe, &tenant_id, &deal_id]).await?; + Ok(()) + } +} diff --git a/src/extensions.rs b/src/extensions.rs new file mode 100755 index 0000000..1868cfa --- /dev/null +++ b/src/extensions.rs @@ -0,0 +1,1431 @@ +//! Erweiterungen -> AES-Suite (Dashboard im ITSM). +//! +//! Aggregiert server-seitig mehrere Quellen und rendert das AES-Dashboard im +//! ITSM-Look: +//! - /ext/aes (8440): Manifest, Projekte, Phasen, Runs, Events, Runner-Status +//! - LLM-Balancer (8442): /_status (Backends, Modelle, Agent-Metriken) +//! - Forge (8441): Repos + Pull Requests (Gitea-kompatible API v1) +//! Zugriff nur fuer admin + Rolle `aes_user`. Zusaetzlich externe, Token- +//! geschuetzte Metrics-Endpunkte (/aes/metrics, /aes/metrics.json). + +use std::time::Duration; + +use askama::Template; +use axum::extract::{Extension, Form, Path, Query, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Redirect, Response}; +use serde::Deserialize; +use serde_json::Value; + +use crate::web::{need_aes, need_auth, page_ctx, AppState, PageCtx, ReqCtx, WebResult}; + +// ── HTTP-Client (aes-suite + Balancer + Forge) ───────────────────────────── + +pub struct AesClient { + aes_url: String, + aes_token: String, + bal_url: String, + bal_token: String, + forge_url: String, + forge_token: String, + forge_owner: String, + http: reqwest::Client, +} + +#[derive(Debug)] +pub struct AesError(pub String); +impl std::fmt::Display for AesError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} +impl std::error::Error for AesError {} + +impl AesClient { + pub fn from_config(cfg: &crate::config::Config) -> Option { + if cfg.aes_ext_url.is_empty() { + return None; + } + let http = reqwest::Client::builder() + .timeout(Duration::from_secs(8)) + .build() + .ok()?; + // Forge nutzt dasselbe Token wie AES; ist ITSM_AES_FORGE_TOKEN leer, + // greifen wir automatisch auf den AES-Token zurueck -> kein separater + // Token noetig. + let forge_token = if cfg.aes_forge_token.is_empty() { + cfg.aes_ext_token.clone() + } else { + cfg.aes_forge_token.clone() + }; + Some(AesClient { + aes_url: cfg.aes_ext_url.trim_end_matches('/').to_string(), + aes_token: cfg.aes_ext_token.clone(), + bal_url: cfg.aes_balancer_url.trim_end_matches('/').to_string(), + bal_token: cfg.aes_balancer_token.clone(), + forge_url: cfg.aes_forge_url.trim_end_matches('/').to_string(), + forge_token, + forge_owner: cfg.aes_forge_owner.clone(), + http, + }) + } + + async fn send(&self, req: reqwest::RequestBuilder) -> Result { + let resp = req + .send() + .await + .map_err(|e| AesError(format!("nicht erreichbar: {e}")))?; + let status = resp.status(); + match status.as_u16() { + 401 => Err(AesError("nicht autorisiert (Token)".into())), + 402 => Err(AesError("AES-Modul nicht gebucht (Lizenz)".into())), + c if !(200..300).contains(&c) => { + let b = resp.text().await.unwrap_or_default(); + Err(AesError(format!("HTTP {c}: {}", b.chars().take(120).collect::()))) + } + _ => resp + .json::() + .await + .map_err(|e| AesError(format!("ungueltige Antwort: {e}"))), + } + } + + /// Wie send, aber ohne JSON-Parsing (fuer POST/DELETE ohne Body-Bedarf). + async fn send_ok(&self, req: reqwest::RequestBuilder) -> Result<(), AesError> { + let resp = req + .send() + .await + .map_err(|e| AesError(format!("nicht erreichbar: {e}")))?; + let s = resp.status(); + if s.is_success() { + Ok(()) + } else { + let b = resp.text().await.unwrap_or_default(); + Err(AesError(format!("HTTP {}: {}", s.as_u16(), b.chars().take(120).collect::()))) + } + } + + fn aes(&self, path: &str) -> reqwest::RequestBuilder { + self.http.get(format!("{}/ext/aes{}", self.aes_url, path)).bearer_auth(&self.aes_token) + } + fn forge_hdr(&self, rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + rb.header("Authorization", format!("token {}", self.forge_token)) + } + + // ── /ext/aes ── + pub async fn manifest(&self) -> Result { + self.send(self.aes("/manifest")).await + } + pub async fn projects(&self) -> Result, AesError> { + let v = self.send(self.aes("/projects")).await?; + Ok(v.as_array() + .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()) + .unwrap_or_default()) + } + pub async fn create_project(&self, name: &str) -> Result { + self.send( + self.http + .post(format!("{}/ext/aes/projects", self.aes_url)) + .bearer_auth(&self.aes_token) + .json(&serde_json::json!({ "name": name })), + ) + .await + } + pub async fn phases(&self, name: &str) -> Result { + self.send(self.aes(&format!("/projects/{name}/phases"))).await + } + pub async fn runs(&self, name: &str) -> Result { + self.send(self.aes(&format!("/projects/{name}/runs"))).await + } + pub async fn events(&self, name: &str) -> Result { + self.send(self.aes(&format!("/projects/{name}/events"))).await + } + pub async fn run_phase(&self, name: &str, phase: &str) -> Result { + self.send( + self.http + .post(format!("{}/ext/aes/projects/{name}/phases/{phase}/run", self.aes_url)) + .bearer_auth(&self.aes_token) + .timeout(Duration::from_secs(180)), + ) + .await + } + pub async fn validate_phase(&self, name: &str, phase: &str) -> Result { + self.send(self.aes(&format!("/projects/{name}/phases/{phase}/validate"))).await + } + pub async fn runner(&self) -> Result { + self.send(self.aes("/runner")).await + } + pub async fn settings(&self) -> Result { + self.send(self.aes("/settings")).await + } + pub async fn settings_save(&self, file: &str, changes: Value, restart: bool) -> Result { + self.send( + self.http + .post(format!("{}/ext/aes/settings", self.aes_url)) + .bearer_auth(&self.aes_token) + .json(&serde_json::json!({ "file": file, "changes": changes, "restart": restart })), + ) + .await + } + pub async fn llm_connections(&self) -> Result { + self.send(self.aes("/llm-connections")).await + } + pub async fn llm_connections_save(&self, conns: Vec) -> Result { + self.send( + self.http + .post(format!("{}/ext/aes/llm-connections", self.aes_url)) + .bearer_auth(&self.aes_token) + .timeout(Duration::from_secs(20)) + .json(&conns), + ) + .await + } + + // ── Balancer (8442) ── + pub async fn balancer_status(&self) -> Result, AesError> { + let v = self + .send(self.http.get(format!("{}/_status", self.bal_url)).bearer_auth(&self.bal_token)) + .await?; + Ok(v.as_array().cloned().unwrap_or_default()) + } + pub async fn balancer_health(&self) -> Result { + self.send(self.http.get(format!("{}/_health", self.bal_url))).await + } + /// Inferenz ueber den Balancer (Ollama /api/generate, stream:false; + /// faellt bei fehlendem Backend auf Cloud zurueck, falls aktiviert). + pub async fn balancer_generate(&self, model: &str, prompt: &str) -> Result { + let resp = self + .http + .post(format!("{}/api/generate", self.bal_url)) + .bearer_auth(&self.bal_token) + .timeout(Duration::from_secs(180)) + .json(&serde_json::json!({ "model": model, "prompt": prompt, "stream": false })) + .send() + .await + .map_err(|e| AesError(format!("nicht erreichbar: {e}")))?; + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(AesError(format!("HTTP {}: {}", status.as_u16(), text.chars().take(300).collect::()))); + } + if let Ok(v) = serde_json::from_str::(&text) { + if let Some(r) = v.get("response").and_then(|x| x.as_str()) { + return Ok(r.to_string()); + } + if let Some(c) = v.get("choices").and_then(|x| x.as_array()).and_then(|a| a.first()) { + if let Some(m) = c.get("message").and_then(|m| m.get("content")).and_then(|x| x.as_str()) { + return Ok(m.to_string()); + } + } + } + Ok(text) + } + /// Modell laden (Ollama /api/pull ueber den Balancer). + pub async fn balancer_pull(&self, model: &str) -> Result<(), AesError> { + self.send_ok( + self.http + .post(format!("{}/api/pull", self.bal_url)) + .bearer_auth(&self.bal_token) + .timeout(Duration::from_secs(300)) + .json(&serde_json::json!({ "name": model, "stream": false })), + ) + .await + } + /// Modell loeschen (Ollama /api/delete ueber den Balancer). + pub async fn balancer_delete_model(&self, model: &str) -> Result<(), AesError> { + self.send_ok( + self.http + .delete(format!("{}/api/delete", self.bal_url)) + .bearer_auth(&self.bal_token) + .json(&serde_json::json!({ "name": model })), + ) + .await + } + + // ── Forge (8441) ── + pub async fn forge_repos(&self) -> Result, AesError> { + let v = self + .send(self.forge_hdr( + self.http.get(format!("{}/api/v1/orgs/{}/repos", self.forge_url, self.forge_owner)), + )) + .await?; + Ok(v.as_array().cloned().unwrap_or_default()) + } + pub async fn forge_repo(&self, repo: &str) -> Result { + self.send(self.forge_hdr( + self.http.get(format!("{}/api/v1/repos/{}/{}", self.forge_url, self.forge_owner, repo)), + )) + .await + } + pub async fn forge_pulls(&self, repo: &str) -> Result, AesError> { + let v = self + .send(self.forge_hdr(self.http.get(format!( + "{}/api/v1/repos/{}/{}/pulls", + self.forge_url, self.forge_owner, repo + )))) + .await?; + Ok(v.as_array().cloned().unwrap_or_default()) + } + pub async fn forge_create_repo(&self, name: &str) -> Result<(), AesError> { + self.send_ok(self.forge_hdr( + self.http + .post(format!("{}/api/v1/user/repos", self.forge_url)) + .json(&serde_json::json!({ "name": name, "private": true })), + )) + .await + } + pub async fn forge_delete_repo(&self, name: &str) -> Result<(), AesError> { + self.send_ok(self.forge_hdr( + self.http.delete(format!("{}/api/repos/{}", self.forge_url, name)), + )) + .await + } + pub async fn forge_create_pull(&self, repo: &str, head: &str, base: &str, title: &str) -> Result<(), AesError> { + self.send_ok(self.forge_hdr( + self.http + .post(format!("{}/api/v1/repos/{}/{}/pulls", self.forge_url, self.forge_owner, repo)) + .json(&serde_json::json!({ "head": head, "base": base, "title": title })), + )) + .await + } + pub async fn forge_merge_pull(&self, repo: &str, number: u64) -> Result<(), AesError> { + self.send_ok(self.forge_hdr(self.http.post(format!( + "{}/api/v1/repos/{}/{}/pulls/{}/merge", + self.forge_url, self.forge_owner, repo, number + )))) + .await + } +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +fn jstr(v: &Value, key: &str) -> String { + v.get(key).and_then(|x| x.as_str()).unwrap_or("").to_string() +} +fn jbool(v: &Value, key: &str) -> bool { + v.get(key).and_then(|x| x.as_bool()).unwrap_or(false) +} +fn jf(v: &Value, key: &str) -> f64 { + v.get(key).and_then(|x| x.as_f64()).unwrap_or(0.0) +} +fn ji(v: &Value, key: &str) -> i64 { + v.get(key).and_then(|x| x.as_i64()).unwrap_or(0) +} +fn jarr_str(v: &Value, key: &str) -> Vec { + v.get(key) + .and_then(|x| x.as_array()) + .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()) + .unwrap_or_default() +} + +// ── View-Modelle ─────────────────────────────────────────────────────────── + +pub struct Card { + pub label: String, + pub value: String, + pub sub: String, + pub class: String, +} +pub struct KV { + pub k: String, + pub v: String, + pub class: String, +} +pub struct SettItem { + pub key: String, + pub label: String, + pub secret: bool, + pub set: bool, + pub value: String, +} +pub struct SettGroup { + pub name: String, + pub items: Vec, +} +pub struct ConnRow { + pub idx: usize, + pub name: String, + pub kind: String, + pub url: String, + pub agent_url: String, + pub base_url: String, + pub model: String, + pub key_set: bool, + pub enabled: bool, +} +pub struct ProjRow { + pub name: String, + pub status_class: String, + pub status: String, +} +pub struct ActItem { + pub when: String, + pub text: String, + pub class: String, +} +pub struct BackendRow { + pub url: String, + pub badge_class: String, + pub badge: String, + pub cpu: i64, + pub ram: i64, + pub vram: i64, + pub latency: i64, + pub models: String, + pub agent: String, +} +pub struct RunItem { + pub repo: String, + pub branch: String, + pub sha: String, + pub ok_class: String, + pub ok_label: String, + pub finished_at: String, +} +pub struct PhaseView { + pub id: String, + pub name: String, + pub description: String, +} +pub struct RunView { + pub phase_id: String, + pub badge_class: String, + pub badge_label: String, + pub degraded: bool, + pub retries_used: i64, + pub failed_step: String, + pub finished_at: String, +} +pub struct PullRow { + pub number: i64, + pub title: String, + pub head: String, + pub base: String, + pub state: String, +} + +#[derive(Template)] +#[template(path = "aes_dashboard.html")] +pub struct AesView { + pub title: String, + pub ctx: PageCtx, + pub section: String, + pub configured: bool, + pub note: String, + pub error: String, + pub health: Vec, + pub kpis: Vec, + pub projects: Vec, + pub activity: Vec, + pub backends: Vec, + pub models: Vec, + pub bal_summary: String, + pub repos: Vec, + pub logs: Vec, + pub version: String, + pub mode: String, + pub licensed: bool, + pub capabilities: Vec, + // Runner + pub runner_available: bool, + pub runner_version: String, + pub runner_instance: String, + pub runner_updated: String, + pub runner_poll: i64, + pub runner_update_repo: String, + pub runner_watched: Vec, + pub runner_runs: Vec, + pub runner_hint: String, + // Admin / Endpunkte + pub admin_rows: Vec, + pub metrics_rows: Vec, + // LLM Inferenz / Modelle + Flash + pub hinweis: String, + pub fehler: String, + pub infer_shown: bool, + pub infer_model: String, + pub infer_prompt: String, + pub infer_result: String, + // Einstellungen + pub sett_aes: Vec, + pub sett_runner: Vec, + pub comp: String, + // LLM-Verbindungen + pub conns: Vec, + pub conn_rows: usize, +} + +impl AesView { + fn base(auth: &crate::db::AuthUser, section: &str) -> AesView { + AesView { + title: "AES-Suite".into(), + ctx: page_ctx(auth, "/erweiterungen/aes"), + section: section.into(), + configured: false, + note: String::new(), + error: String::new(), + health: Vec::new(), + kpis: Vec::new(), + projects: Vec::new(), + activity: Vec::new(), + backends: Vec::new(), + models: Vec::new(), + bal_summary: String::new(), + repos: Vec::new(), + logs: Vec::new(), + version: String::new(), + mode: String::new(), + licensed: false, + capabilities: Vec::new(), + runner_available: false, + runner_version: String::new(), + runner_instance: String::new(), + runner_updated: String::new(), + runner_poll: 0, + runner_update_repo: String::new(), + runner_watched: Vec::new(), + runner_runs: Vec::new(), + runner_hint: String::new(), + admin_rows: Vec::new(), + metrics_rows: Vec::new(), + hinweis: String::new(), + fehler: String::new(), + infer_shown: false, + infer_model: String::new(), + infer_prompt: String::new(), + infer_result: String::new(), + sett_aes: Vec::new(), + sett_runner: Vec::new(), + comp: String::new(), + conns: Vec::new(), + conn_rows: 0, + } + } +} + +#[derive(Template)] +#[template(path = "ext_aes_project.html")] +pub struct AesProjectTemplate { + pub title: String, + pub ctx: PageCtx, + pub section: String, + pub name: String, + pub phases: Vec, + pub runs: Vec, + pub events: Vec, + pub hinweis: String, + pub fehler: String, +} + +#[derive(Template)] +#[template(path = "aes_forge_repo.html")] +pub struct AesForgeRepoTemplate { + pub title: String, + pub ctx: PageCtx, + pub section: String, + pub name: String, + pub default_branch: String, + pub private: bool, + pub clone_url: String, + pub pulls: Vec, + pub hinweis: String, + pub fehler: String, +} + +#[derive(Deserialize)] +pub struct CreateForm { + pub name: String, +} +#[derive(Deserialize)] +pub struct PullForm { + pub head: String, + pub base: String, + pub title: String, +} +#[derive(Deserialize)] +pub struct InferForm { + pub model: String, + pub prompt: String, +} +#[derive(Deserialize)] +pub struct ModelForm { + pub model: String, +} +#[derive(Deserialize)] +pub struct FlashQuery { + pub hinweis: Option, + pub fehler: Option, + pub comp: Option, +} + +const ACTIVE: &str = "/erweiterungen/aes"; + +// ── Section-Renderer ─────────────────────────────────────────────────────── + +async fn build(app: &AppState, auth: &crate::db::AuthUser, section: &str) -> AesView { + let mut v = AesView::base(auth, section); + let client = match app.aes.as_ref() { + Some(c) => { + v.configured = true; + c + } + None => { + v.error = "AES-Suite ist nicht konfiguriert (ITSM_AES_URL fehlt).".into(); + return v; + } + }; + + if let Ok(m) = client.manifest().await { + v.version = jstr(&m, "version"); + v.mode = jstr(&m, "mode"); + v.licensed = jbool(&m, "licensed"); + v.capabilities = jarr_str(&m, "capabilities"); + } + + match section { + "dashboard" => build_dashboard(client, &mut v).await, + "projekte" => build_projekte(client, &mut v).await, + "forge" => build_forge(client, &mut v).await, + "llm" => build_llm(client, &mut v).await, + "agenten" => build_llm(client, &mut v).await, + "monitoring" => build_monitoring(client, &mut v).await, + "runner" => build_runner(client, &mut v).await, + "admin" => build_admin(app, client, &mut v).await, + "einstellungen" => build_einstellungen(client, &mut v).await, + _ => {} + } + v +} + +async fn build_einstellungen(client: &AesClient, v: &mut AesView) { + match client.settings().await { + Ok(s) => { + let arr = s.get("settings").and_then(|x| x.as_array()).cloned().unwrap_or_default(); + for it in &arr { + let file = jstr(it, "file"); + let group = jstr(it, "group"); + let item = SettItem { + key: jstr(it, "key"), + label: jstr(it, "label"), + secret: jbool(it, "secret"), + set: jbool(it, "set"), + value: jstr(it, "value"), + }; + let target = if file == "runner" { &mut v.sett_runner } else { &mut v.sett_aes }; + if let Some(g) = target.iter_mut().find(|g| g.name == group) { + g.items.push(item); + } else { + target.push(SettGroup { name: group, items: vec![item] }); + } + } + } + Err(e) => v.error = format!("Einstellungen nicht abrufbar: {}", e.0), + } +} + +async fn build_dashboard(client: &AesClient, v: &mut AesView) { + let bal = client.balancer_health().await; + let (bal_ok, bal_detail) = match &bal { + Ok(h) => ( + true, + format!("{}/{} Backends, {}", ji(h, "healthy_backends"), ji(h, "total_backends"), jstr(h, "status")), + ), + Err(e) => (false, e.0.clone()), + }; + let repos_ok = client.forge_repos().await; + let runner = client.runner().await.unwrap_or(Value::Null); + let runner_ok = jbool(&runner, "available"); + v.health.push(Card { + label: "AES-Kern (/ext/aes)".into(), + value: if v.licensed { "gebucht".into() } else { "aktiv".into() }, + sub: format!("v{} · {}", v.version, v.mode), + class: "ok".into(), + }); + v.health.push(Card { + label: "LLM-Balancer".into(), + value: if bal_ok { "erreichbar".into() } else { "offline".into() }, + sub: bal_detail, + class: if bal_ok { "ok".into() } else { "err".into() }, + }); + v.health.push(Card { + label: "Forge".into(), + value: if repos_ok.is_ok() { "erreichbar".into() } else { "offline".into() }, + sub: match &repos_ok { + Ok(r) => format!("{} Repos", r.len()), + Err(e) => e.0.clone(), + }, + class: if repos_ok.is_ok() { "ok".into() } else { "err".into() }, + }); + v.health.push(Card { + label: "Runner".into(), + value: if runner_ok { "aktiv".into() } else { "offline".into() }, + sub: if runner_ok { + format!("{} Repos beobachtet", jarr_str(&runner, "watched_repos").len()) + } else { + "kein Status".into() + }, + class: if runner_ok { "ok".into() } else { "unknown".into() }, + }); + + let names = client.projects().await.unwrap_or_default(); + let mut total_runs = 0i64; + let mut passed = 0i64; + let mut degraded = 0i64; + let mut activity: Vec<(String, String, String)> = Vec::new(); + for name in names.iter().take(12) { + let runs = client.runs(name).await.unwrap_or(Value::Null); + let arr = runs.as_array().cloned().unwrap_or_default(); + let (mut last_class, mut last_label) = ("unknown".to_string(), "kein Lauf".to_string()); + if let Some(last) = arr.last() { + let p = jbool(last, "passed"); + last_class = if p { "ok".into() } else { "err".into() }; + last_label = if p { "bestanden".into() } else { "fehlgeschlagen".into() }; + } + for r in &arr { + total_runs += 1; + let p = jbool(r, "passed"); + if p { + passed += 1; + } + if jbool(r, "degraded") { + degraded += 1; + } + activity.push(( + jstr(r, "finished_at"), + format!("{} · Phase {}", name, jstr(r, "phase_id")), + if p { "ok".into() } else { "err".into() }, + )); + } + v.projects.push(ProjRow { name: name.clone(), status_class: last_class, status: last_label }); + } + let rate = if total_runs > 0 { (passed * 100) / total_runs } else { 0 }; + v.kpis.push(Card { label: "Projekte".into(), value: names.len().to_string(), sub: "gesamt".into(), class: "".into() }); + v.kpis.push(Card { label: "Laeufe".into(), value: total_runs.to_string(), sub: "gesamt".into(), class: "".into() }); + v.kpis.push(Card { label: "Erfolgsquote".into(), value: format!("{rate}%"), sub: format!("{passed} bestanden"), class: if rate >= 50 { "ok".into() } else { "err".into() } }); + v.kpis.push(Card { label: "Degraded".into(), value: degraded.to_string(), sub: "ohne LLM-Provider".into(), class: if degraded > 0 { "unknown".into() } else { "ok".into() } }); + + activity.sort_by(|a, b| b.0.cmp(&a.0)); + for (when, text, class) in activity.into_iter().take(15) { + v.activity.push(ActItem { when, text, class }); + } + + if let Ok(bs) = client.balancer_status().await { + fill_backends(v, &bs); + } +} + +fn fill_backends(v: &mut AesView, bs: &[Value]) { + let mut healthy = 0; + for b in bs { + let h = jbool(b, "healthy"); + if h { + healthy += 1; + } + let models: Vec = jarr_str(b, "models"); + for m in &models { + if !v.models.contains(m) { + v.models.push(m.clone()); + } + } + v.backends.push(BackendRow { + url: jstr(b, "url"), + badge_class: if h { "ok".into() } else { "err".into() }, + badge: if h { "healthy".into() } else { "offline".into() }, + cpu: jf(b, "cpu_percent") as i64, + ram: jf(b, "ram_percent") as i64, + vram: jf(b, "vram_percent") as i64, + latency: ji(b, "latency_ms"), + models: if models.is_empty() { "-".into() } else { models.join(", ") }, + agent: if jbool(b, "agent_online") { "online".into() } else { "offline".into() }, + }); + } + v.bal_summary = format!("{}/{} Backends healthy, {} Modell(e)", healthy, bs.len(), v.models.len()); +} + +async fn build_llm(client: &AesClient, v: &mut AesView) { + match client.balancer_status().await { + Ok(bs) => fill_backends(v, &bs), + Err(e) => v.error = format!("LLM-Balancer: {}", e.0), + } + // Verbindungen (Multi-Provider) laden + Leerzeilen zum Hinzufuegen. + if let Ok(cv) = client.llm_connections().await { + if let Some(arr) = cv.get("connections").and_then(|x| x.as_array()) { + for (i, c) in arr.iter().enumerate() { + v.conns.push(ConnRow { + idx: i, + name: jstr(c, "name"), + kind: jstr(c, "type"), + url: jstr(c, "url"), + agent_url: jstr(c, "agent_url"), + base_url: jstr(c, "base_url"), + model: jstr(c, "model"), + key_set: jbool(c, "key_set"), + enabled: c.get("enabled").and_then(|x| x.as_bool()).unwrap_or(true), + }); + } + } + } + let base = v.conns.len(); + for j in 0..3 { + v.conns.push(ConnRow { + idx: base + j, + name: String::new(), + kind: "ollama".into(), + url: String::new(), + agent_url: String::new(), + base_url: String::new(), + model: String::new(), + key_set: false, + enabled: true, + }); + } + v.conn_rows = v.conns.len(); +} + +async fn build_forge(client: &AesClient, v: &mut AesView) { + match client.forge_repos().await { + Ok(repos) => { + for r in &repos { + v.repos.push(Card { + label: jstr(r, "name"), + value: jstr(r, "default_branch"), + sub: if jbool(r, "private") { "privat".into() } else { "oeffentlich".into() }, + class: "ok".into(), + }); + } + if repos.is_empty() { + v.note = "Keine Repositories. Lege unten das erste an.".into(); + } + } + Err(e) => v.error = format!("Forge nicht erreichbar: {}", e.0), + } +} + +async fn build_projekte(client: &AesClient, v: &mut AesView) { + match client.projects().await { + Ok(names) => { + for name in names { + v.projects.push(ProjRow { name, status_class: "".into(), status: String::new() }); + } + } + Err(e) => v.error = e.0, + } +} + +async fn build_runner(client: &AesClient, v: &mut AesView) { + match client.runner().await { + Ok(r) => { + v.runner_available = jbool(&r, "available"); + if v.runner_available { + v.runner_version = jstr(&r, "version"); + v.runner_instance = jstr(&r, "instance"); + v.runner_updated = jstr(&r, "updated_at"); + v.runner_poll = ji(&r, "poll_secs"); + v.runner_update_repo = jstr(&r, "update_repo"); + v.runner_watched = jarr_str(&r, "watched_repos"); + if let Some(arr) = r.get("recent").and_then(|x| x.as_array()) { + for it in arr { + let ok = jbool(it, "success"); + let sha = jstr(it, "sha"); + v.runner_runs.push(RunItem { + repo: jstr(it, "repo"), + branch: jstr(it, "branch"), + sha: sha.chars().take(8).collect(), + ok_class: if ok { "ok".into() } else { "err".into() }, + ok_label: if ok { "OK".into() } else { "FEHLER".into() }, + finished_at: jstr(it, "finished_at"), + }); + } + } + } else { + v.runner_hint = jstr(&r, "hint"); + } + } + Err(e) => v.error = format!("Runner-Status nicht abrufbar: {}", e.0), + } +} + +async fn build_monitoring(client: &AesClient, v: &mut AesView) { + // Erststand (das Live-JS aktualisiert danach ueber monitor.json). + let names = client.projects().await.unwrap_or_default(); + for name in names.iter().take(10) { + if let Ok(ev) = client.events(name).await { + if let Some(arr) = ev.as_array() { + for e in arr.iter().rev().take(10) { + v.logs.push(format!("[{}] {}", name, serde_json::to_string(e).unwrap_or_default())); + } + } + } + } + v.logs.truncate(60); + if let Ok(bs) = client.balancer_status().await { + fill_backends(v, &bs); + } +} + +async fn build_admin(app: &AppState, client: &AesClient, v: &mut AesView) { + // Dienst-Erreichbarkeit + let aes_ok = client.manifest().await.is_ok(); + let bal_ok = client.balancer_health().await.is_ok(); + let forge_ok = client.forge_repos().await.is_ok(); + let runner_ok = client.runner().await.map(|r| jbool(&r, "available")).unwrap_or(false); + let row = |k: &str, v: &str, ok: bool| KV { k: k.into(), v: v.into(), class: if ok { "ok".into() } else { "err".into() } }; + v.admin_rows.push(row("AES-Kern /ext/aes", if aes_ok { "erreichbar" } else { "offline" }, aes_ok)); + v.admin_rows.push(row("LLM-Balancer /_status", if bal_ok { "erreichbar" } else { "offline" }, bal_ok)); + v.admin_rows.push(row("Forge /api/v1", if forge_ok { "erreichbar" } else { "offline" }, forge_ok)); + v.admin_rows.push(row("Runner /ext/aes/runner", if runner_ok { "aktiv" } else { "offline" }, runner_ok)); + v.admin_rows.push(KV { k: "Modul-Version".into(), v: format!("v{} ({})", v.version, v.mode), class: "".into() }); + v.admin_rows.push(KV { k: "Lizenz".into(), v: if v.licensed { "gebucht".into() } else { "nicht gebucht".into() }, class: if v.licensed { "ok".into() } else { "unknown".into() } }); + v.admin_rows.push(KV { k: "Faehigkeiten".into(), v: v.capabilities.join(", "), class: "".into() }); + + // Metrics-Endpunkte (extern anbindbar) + let has_tok = !app.cfg.aes_metrics_token.is_empty(); + v.metrics_rows.push(KV { + k: "Prometheus".into(), + v: "/aes/metrics".into(), + class: if has_tok { "ok".into() } else { "unknown".into() }, + }); + v.metrics_rows.push(KV { k: "JSON".into(), v: "/aes/metrics.json".into(), class: if has_tok { "ok".into() } else { "unknown".into() } }); + v.metrics_rows.push(KV { + k: "Authentifizierung".into(), + v: if has_tok { "Bearer-Token gesetzt (ITSM_AES_METRICS_TOKEN)".into() } else { "deaktiviert — ITSM_AES_METRICS_TOKEN setzen".into() }, + class: if has_tok { "ok".into() } else { "unknown".into() }, + }); +} + +// ── Snapshot fuer Live-Monitoring & Metrics ──────────────────────────────── + +async fn snapshot(client: &AesClient) -> Value { + let manifest = client.manifest().await.unwrap_or(Value::Null); + let bal_health = client.balancer_health().await.ok(); + let backends = client.balancer_status().await.unwrap_or_default(); + let forge = client.forge_repos().await; + let runner = client.runner().await.unwrap_or(Value::Null); + let projects = client.projects().await.unwrap_or_default(); + + let services = serde_json::json!([ + {"name": "AES-Kern", "ok": !manifest.is_null(), "detail": format!("v{}", jstr(&manifest, "version"))}, + {"name": "LLM-Balancer", "ok": bal_health.is_some(), "detail": bal_health.as_ref().map(|h| format!("{}/{} healthy", ji(h,"healthy_backends"), ji(h,"total_backends"))).unwrap_or_else(|| "offline".into())}, + {"name": "Forge", "ok": forge.is_ok(), "detail": forge.as_ref().map(|r| format!("{} Repos", r.len())).unwrap_or_else(|_| "offline".into())}, + {"name": "Runner", "ok": jbool(&runner, "available"), "detail": format!("{} Repos", jarr_str(&runner, "watched_repos").len())}, + ]); + let be: Vec = backends + .iter() + .map(|b| serde_json::json!({ + "url": jstr(b, "url"), + "healthy": jbool(b, "healthy"), + "cpu": jf(b, "cpu_percent"), + "ram": jf(b, "ram_percent"), + "vram": jf(b, "vram_percent"), + "latency": ji(b, "latency_ms"), + "models": jarr_str(b, "models"), + "agent_online": jbool(b, "agent_online"), + })) + .collect(); + serde_json::json!({ + "services": services, + "backends": be, + "runner": {"available": jbool(&runner, "available"), "runs": runner.get("recent").and_then(|x| x.as_array()).map(|a| a.len()).unwrap_or(0), "watched": jarr_str(&runner, "watched_repos")}, + "projects_total": projects.len(), + }) +} + +// ── Handler ──────────────────────────────────────────────────────────────── + +macro_rules! guarded { + ($ctx:expr) => {{ + let auth = match need_auth(&$ctx, ACTIVE) { + Ok(a) => a, + Err(r) => return Ok(r), + }; + if let Err(r) = need_aes(&auth) { + return Ok(r); + } + auth + }}; +} + +pub async fn aes_dashboard(State(app): State, Extension(ctx): Extension) -> WebResult { + let auth = guarded!(ctx); + Ok(build(&app, &auth, "dashboard").await.into_response()) +} +pub async fn aes_projekte(State(app): State, Extension(ctx): Extension) -> WebResult { + let auth = guarded!(ctx); + Ok(build(&app, &auth, "projekte").await.into_response()) +} +pub async fn aes_forge(State(app): State, Extension(ctx): Extension) -> WebResult { + let auth = guarded!(ctx); + Ok(build(&app, &auth, "forge").await.into_response()) +} +pub async fn aes_runner(State(app): State, Extension(ctx): Extension) -> WebResult { + let auth = guarded!(ctx); + Ok(build(&app, &auth, "runner").await.into_response()) +} +pub async fn aes_llm(State(app): State, Extension(ctx): Extension, Query(q): Query) -> WebResult { + let auth = guarded!(ctx); + let mut v = build(&app, &auth, "llm").await; + v.hinweis = q.hinweis.unwrap_or_default(); + v.fehler = q.fehler.unwrap_or_default(); + Ok(v.into_response()) +} +pub async fn aes_agenten(State(app): State, Extension(ctx): Extension) -> WebResult { + let auth = guarded!(ctx); + Ok(build(&app, &auth, "agenten").await.into_response()) +} +pub async fn aes_monitoring(State(app): State, Extension(ctx): Extension) -> WebResult { + let auth = guarded!(ctx); + Ok(build(&app, &auth, "monitoring").await.into_response()) +} +pub async fn aes_admin(State(app): State, Extension(ctx): Extension) -> WebResult { + let auth = guarded!(ctx); + Ok(build(&app, &auth, "admin").await.into_response()) +} +pub async fn aes_einstellungen(State(app): State, Extension(ctx): Extension, Query(q): Query) -> WebResult { + let auth = guarded!(ctx); + let mut v = build(&app, &auth, "einstellungen").await; + v.hinweis = q.hinweis.unwrap_or_default(); + v.fehler = q.fehler.unwrap_or_default(); + v.comp = q.comp.unwrap_or_default(); + Ok(v.into_response()) +} +pub async fn aes_einstellungen_save( + State(app): State, + Extension(ctx): Extension, + Form(mut form): Form>, +) -> WebResult { + let _auth = guarded!(ctx); + let target = "/erweiterungen/aes/einstellungen"; + form.remove("_csrf"); + let file = form.remove("file").unwrap_or_default(); + let Some(client) = app.aes.as_ref() else { + return Ok(Redirect::to(target).into_response()); + }; + let changes = serde_json::to_value(&form).unwrap_or_else(|_| serde_json::json!({})); + match client.settings_save(&file, changes, true).await { + Ok(o) => { + let n = o.get("changed").and_then(|x| x.as_i64()).unwrap_or(0); + let svc = if file == "runner" { "aes-runner" } else { "aes-suite" }; + Ok(Redirect::to(&flash(target, Some(&format!("{n} Einstellung(en) gespeichert. {svc} startet neu — kurz warten und neu laden.")), None)).into_response()) + } + Err(e) => Ok(Redirect::to(&flash(target, None, Some(&e.0))).into_response()), + } +} + +/// Live-Daten fuer das Monitoring-JS (Session-geschuetzt). +pub async fn aes_monitor_json(State(app): State, Extension(ctx): Extension) -> WebResult { + let _auth = guarded!(ctx); + let Some(client) = app.aes.as_ref() else { + return Ok(axum::Json(serde_json::json!({"error": "nicht konfiguriert"})).into_response()); + }; + Ok(axum::Json(snapshot(client).await).into_response()) +} + +// ── Externe Metrics (Token-geschuetzt, kein Session-Login) ───────────────── + +fn metrics_authorized(app: &AppState, headers: &HeaderMap) -> bool { + let tok = &app.cfg.aes_metrics_token; + if tok.is_empty() { + return false; + } + headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("token "))) + .map(|t| t == tok) + .unwrap_or(false) +} + +pub async fn aes_metrics(State(app): State, headers: HeaderMap) -> Response { + if !metrics_authorized(&app, &headers) { + return (StatusCode::UNAUTHORIZED, "unauthorized (Bearer-Token noetig; ITSM_AES_METRICS_TOKEN)").into_response(); + } + let Some(client) = app.aes.as_ref() else { + return (StatusCode::SERVICE_UNAVAILABLE, "aes nicht konfiguriert").into_response(); + }; + let snap = snapshot(client).await; + let mut out = String::new(); + out.push_str("# HELP aes_service_up Dienst erreichbar (1/0)\n# TYPE aes_service_up gauge\n"); + if let Some(services) = snap.get("services").and_then(|x| x.as_array()) { + for s in services { + let name = jstr(s, "name").to_lowercase().replace(['-', ' ', '/'], "_"); + out.push_str(&format!("aes_service_up{{service=\"{}\"}} {}\n", name, if jbool(s, "ok") { 1 } else { 0 })); + } + } + out.push_str("# HELP aes_backend_healthy Balancer-Backend gesund (1/0)\n# TYPE aes_backend_healthy gauge\n"); + if let Some(bes) = snap.get("backends").and_then(|x| x.as_array()) { + for b in bes { + let url = jstr(b, "url"); + out.push_str(&format!("aes_backend_healthy{{url=\"{}\"}} {}\n", url, if jbool(b, "healthy") { 1 } else { 0 })); + out.push_str(&format!("aes_backend_cpu_percent{{url=\"{}\"}} {}\n", url, jf(b, "cpu"))); + out.push_str(&format!("aes_backend_ram_percent{{url=\"{}\"}} {}\n", url, jf(b, "ram"))); + out.push_str(&format!("aes_backend_vram_percent{{url=\"{}\"}} {}\n", url, jf(b, "vram"))); + out.push_str(&format!("aes_backend_latency_ms{{url=\"{}\"}} {}\n", url, ji(b, "latency"))); + } + } + out.push_str("# HELP aes_projects_total Anzahl AES-Projekte\n# TYPE aes_projects_total gauge\n"); + out.push_str(&format!("aes_projects_total {}\n", ji(&snap, "projects_total"))); + if let Some(r) = snap.get("runner") { + out.push_str(&format!("aes_runner_up {}\n", if jbool(r, "available") { 1 } else { 0 })); + out.push_str(&format!("aes_runner_watched_repos {}\n", r.get("watched").and_then(|x| x.as_array()).map(|a| a.len()).unwrap_or(0))); + } + (StatusCode::OK, [("content-type", "text/plain; version=0.0.4")], out).into_response() +} + +pub async fn aes_metrics_json(State(app): State, headers: HeaderMap) -> Response { + if !metrics_authorized(&app, &headers) { + return (StatusCode::UNAUTHORIZED, "unauthorized").into_response(); + } + let Some(client) = app.aes.as_ref() else { + return (StatusCode::SERVICE_UNAVAILABLE, "aes nicht konfiguriert").into_response(); + }; + axum::Json(snapshot(client).await).into_response() +} + +// ── Projekt-Handler ──────────────────────────────────────────────────────── + +pub async fn aes_project_create( + State(app): State, + Extension(ctx): Extension, + Form(f): Form, +) -> WebResult { + let _auth = guarded!(ctx); + let name = f.name.trim().to_string(); + let Some(client) = app.aes.as_ref() else { + return Ok(Redirect::to("/erweiterungen/aes/projekte").into_response()); + }; + match client.create_project(&name).await { + Ok(_) => Ok(Redirect::to(&format!("/erweiterungen/aes/projekte/{name}")).into_response()), + Err(e) => Ok(Redirect::to(&flash("/erweiterungen/aes/projekte", None, Some(&e.0))).into_response()), + } +} + +pub async fn aes_project_detail( + State(app): State, + Extension(ctx): Extension, + Path(name): Path, + Query(q): Query, +) -> WebResult { + let auth = guarded!(ctx); + let mut t = AesProjectTemplate { + title: format!("AES · {name}"), + ctx: page_ctx(&auth, ACTIVE), + section: "projekte".into(), + name: name.clone(), + phases: Vec::new(), + runs: Vec::new(), + events: Vec::new(), + hinweis: q.hinweis.unwrap_or_default(), + fehler: q.fehler.unwrap_or_default(), + }; + let Some(client) = app.aes.as_ref() else { + t.fehler = "AES-Suite nicht konfiguriert.".into(); + return Ok(t.into_response()); + }; + match client.phases(&name).await { + Ok(vv) => { + if let Some(arr) = vv.as_array() { + for p in arr { + t.phases.push(PhaseView { id: jstr(p, "id"), name: jstr(p, "name"), description: jstr(p, "description") }); + } + } + } + Err(e) => { + if t.fehler.is_empty() { + t.fehler = e.0; + } + } + } + if let Ok(vv) = client.runs(&name).await { + if let Some(arr) = vv.as_array() { + for r in arr.iter().rev() { + let p = jbool(r, "passed"); + t.runs.push(RunView { + phase_id: jstr(r, "phase_id"), + badge_class: if p { "ok".into() } else { "err".into() }, + badge_label: if p { "bestanden".into() } else { "fehlgeschlagen".into() }, + degraded: jbool(r, "degraded"), + retries_used: ji(r, "retries_used"), + failed_step: jstr(r, "failed_step"), + finished_at: jstr(r, "finished_at"), + }); + } + } + } + if let Ok(vv) = client.events(&name).await { + if let Some(arr) = vv.as_array() { + let mut lines: Vec = arr.iter().map(|e| serde_json::to_string(e).unwrap_or_default()).collect(); + lines.reverse(); + lines.truncate(100); + t.events = lines; + } + } + Ok(t.into_response()) +} + +pub async fn aes_phase_run( + State(app): State, + Extension(ctx): Extension, + Path((name, phase)): Path<(String, String)>, +) -> WebResult { + let _auth = guarded!(ctx); + let target = format!("/erweiterungen/aes/projekte/{name}"); + let Some(client) = app.aes.as_ref() else { + return Ok(Redirect::to(&target).into_response()); + }; + match client.run_phase(&name, &phase).await { + Ok(o) => { + let passed = jbool(&o, "passed"); + let degraded = jbool(&o, "degraded"); + let msg = format!("Phase {phase}: {}{}.", if passed { "bestanden" } else { "fehlgeschlagen" }, if degraded { " (degraded)" } else { "" }); + Ok(Redirect::to(&flash(&target, Some(&msg), None)).into_response()) + } + Err(e) => Ok(Redirect::to(&flash(&target, None, Some(&e.0))).into_response()), + } +} + +pub async fn aes_phase_validate( + State(app): State, + Extension(ctx): Extension, + Path((name, phase)): Path<(String, String)>, +) -> WebResult { + let _auth = guarded!(ctx); + let target = format!("/erweiterungen/aes/projekte/{name}"); + let Some(client) = app.aes.as_ref() else { + return Ok(Redirect::to(&target).into_response()); + }; + match client.validate_phase(&name, &phase).await { + Ok(vv) => { + let passed = vv.get("passed").or_else(|| vv.get("ok")).and_then(|x| x.as_bool()).unwrap_or(false); + let findings = vv.get("findings").and_then(|x| x.as_array()).map(|a| a.len()).unwrap_or(0); + let msg = format!("Validierung {phase}: {} ({findings} Befund(e)).", if passed { "OK" } else { "FEHLER" }); + Ok(Redirect::to(&flash(&target, Some(&msg), None)).into_response()) + } + Err(e) => Ok(Redirect::to(&flash(&target, None, Some(&e.0))).into_response()), + } +} + +// ── Forge-Handler ────────────────────────────────────────────────────────── + +pub async fn aes_forge_repo( + State(app): State, + Extension(ctx): Extension, + Path(name): Path, + Query(q): Query, +) -> WebResult { + let auth = guarded!(ctx); + let mut t = AesForgeRepoTemplate { + title: format!("Forge · {name}"), + ctx: page_ctx(&auth, ACTIVE), + section: "forge".into(), + name: name.clone(), + default_branch: String::new(), + private: false, + clone_url: String::new(), + pulls: Vec::new(), + hinweis: q.hinweis.unwrap_or_default(), + fehler: q.fehler.unwrap_or_default(), + }; + let Some(client) = app.aes.as_ref() else { + t.fehler = "AES-Suite nicht konfiguriert.".into(); + return Ok(t.into_response()); + }; + match client.forge_repo(&name).await { + Ok(r) => { + t.default_branch = jstr(&r, "default_branch"); + t.private = jbool(&r, "private"); + t.clone_url = jstr(&r, "clone_url"); + } + Err(e) => { + if t.fehler.is_empty() { + t.fehler = e.0; + } + } + } + if let Ok(pulls) = client.forge_pulls(&name).await { + for p in &pulls { + t.pulls.push(PullRow { + number: ji(p, "number"), + title: jstr(p, "title"), + head: jstr(p, "head"), + base: jstr(p, "base"), + state: { + let s = jstr(p, "state"); + if s.is_empty() { "open".into() } else { s } + }, + }); + } + } + Ok(t.into_response()) +} + +pub async fn aes_forge_create( + State(app): State, + Extension(ctx): Extension, + Form(f): Form, +) -> WebResult { + let _auth = guarded!(ctx); + let name = f.name.trim().to_string(); + let Some(client) = app.aes.as_ref() else { + return Ok(Redirect::to("/erweiterungen/aes/forge").into_response()); + }; + match client.forge_create_repo(&name).await { + Ok(_) => Ok(Redirect::to(&flash("/erweiterungen/aes/forge", Some(&format!("Repo {name} angelegt.")), None)).into_response()), + Err(e) => Ok(Redirect::to(&flash("/erweiterungen/aes/forge", None, Some(&e.0))).into_response()), + } +} + +pub async fn aes_forge_delete( + State(app): State, + Extension(ctx): Extension, + Path(name): Path, +) -> WebResult { + let _auth = guarded!(ctx); + let Some(client) = app.aes.as_ref() else { + return Ok(Redirect::to("/erweiterungen/aes/forge").into_response()); + }; + match client.forge_delete_repo(&name).await { + Ok(_) => Ok(Redirect::to(&flash("/erweiterungen/aes/forge", Some(&format!("Repo {name} geloescht.")), None)).into_response()), + Err(e) => Ok(Redirect::to(&flash("/erweiterungen/aes/forge", None, Some(&e.0))).into_response()), + } +} + +pub async fn aes_forge_pull_create( + State(app): State, + Extension(ctx): Extension, + Path(name): Path, + Form(f): Form, +) -> WebResult { + let _auth = guarded!(ctx); + let target = format!("/erweiterungen/aes/forge/{name}"); + let Some(client) = app.aes.as_ref() else { + return Ok(Redirect::to(&target).into_response()); + }; + match client.forge_create_pull(&name, f.head.trim(), f.base.trim(), f.title.trim()).await { + Ok(_) => Ok(Redirect::to(&flash(&target, Some("Pull Request angelegt."), None)).into_response()), + Err(e) => Ok(Redirect::to(&flash(&target, None, Some(&e.0))).into_response()), + } +} + +pub async fn aes_forge_pull_merge( + State(app): State, + Extension(ctx): Extension, + Path((name, number)): Path<(String, u64)>, +) -> WebResult { + let _auth = guarded!(ctx); + let target = format!("/erweiterungen/aes/forge/{name}"); + let Some(client) = app.aes.as_ref() else { + return Ok(Redirect::to(&target).into_response()); + }; + match client.forge_merge_pull(&name, number).await { + Ok(_) => Ok(Redirect::to(&flash(&target, Some(&format!("PR #{number} gemerged.")), None)).into_response()), + Err(e) => Ok(Redirect::to(&flash(&target, None, Some(&e.0))).into_response()), + } +} + +// ── LLM Inferenz / Modellverwaltung ──────────────────────────────────────── + +pub async fn aes_llm_infer( + State(app): State, + Extension(ctx): Extension, + Form(f): Form, +) -> WebResult { + let auth = guarded!(ctx); + let mut v = build(&app, &auth, "llm").await; + v.infer_shown = true; + v.infer_model = f.model.trim().to_string(); + v.infer_prompt = f.prompt.clone(); + if let Some(client) = app.aes.as_ref() { + match client.balancer_generate(f.model.trim(), f.prompt.trim()).await { + Ok(r) => v.infer_result = r, + Err(e) => v.fehler = e.0, + } + } + Ok(v.into_response()) +} + +pub async fn aes_llm_pull( + State(app): State, + Extension(ctx): Extension, + Form(f): Form, +) -> WebResult { + let _auth = guarded!(ctx); + let target = "/erweiterungen/aes/llm"; + let Some(client) = app.aes.as_ref() else { + return Ok(Redirect::to(target).into_response()); + }; + match client.balancer_pull(f.model.trim()).await { + Ok(_) => Ok(Redirect::to(&flash(target, Some(&format!("Modell {} geladen.", f.model.trim())), None)).into_response()), + Err(e) => Ok(Redirect::to(&flash(target, None, Some(&e.0))).into_response()), + } +} + +pub async fn aes_llm_delete( + State(app): State, + Extension(ctx): Extension, + Form(f): Form, +) -> WebResult { + let _auth = guarded!(ctx); + let target = "/erweiterungen/aes/llm"; + let Some(client) = app.aes.as_ref() else { + return Ok(Redirect::to(target).into_response()); + }; + match client.balancer_delete_model(f.model.trim()).await { + Ok(_) => Ok(Redirect::to(&flash(target, Some(&format!("Modell {} geloescht.", f.model.trim())), None)).into_response()), + Err(e) => Ok(Redirect::to(&flash(target, None, Some(&e.0))).into_response()), + } +} + +/// Speichert die LLM-Verbindungsliste (Multi-Provider) und startet aes-suite neu. +pub async fn aes_llm_conn_save( + State(app): State, + Extension(ctx): Extension, + Form(form): Form>, +) -> WebResult { + let _auth = guarded!(ctx); + let target = "/erweiterungen/aes/llm"; + let Some(client) = app.aes.as_ref() else { + return Ok(Redirect::to(target).into_response()); + }; + let rows: usize = form.get("rows").and_then(|s| s.parse().ok()).unwrap_or(0); + let mut conns = Vec::new(); + for i in 0..rows { + let g = |k: &str| form.get(&format!("{k}_{i}")).cloned().unwrap_or_default(); + let name = g("name"); + if name.trim().is_empty() { + continue; + } + conns.push(serde_json::json!({ + "name": name.trim(), + "type": g("type").trim(), + "url": g("url").trim(), + "agent_url": g("agent_url").trim(), + "base_url": g("base_url").trim(), + "api_key": g("api_key"), + "model": g("model").trim(), + "enabled": form.contains_key(&format!("enabled_{i}")), + })); + } + match client.llm_connections_save(conns).await { + Ok(o) => { + let n = o.get("count").and_then(|x| x.as_i64()).unwrap_or(0); + Ok(Redirect::to(&flash(target, Some(&format!("{n} Verbindung(en) gespeichert. aes-suite startet neu — kurz warten und neu laden.")), None)).into_response()) + } + Err(e) => Ok(Redirect::to(&flash(target, None, Some(&e.0))).into_response()), + } +} + +fn flash(base: &str, hinweis: Option<&str>, fehler: Option<&str>) -> String { + let mut ser = form_urlencoded::Serializer::new(String::new()); + if let Some(h) = hinweis { + ser.append_pair("hinweis", &h.chars().take(300).collect::()); + } + if let Some(f) = fehler { + ser.append_pair("fehler", &f.chars().take(300).collect::()); + } + let qs = ser.finish(); + if qs.is_empty() { + base.to_string() + } else { + format!("{base}?{qs}") + } +} diff --git a/src/hr.rs b/src/hr.rs new file mode 100755 index 0000000..47d1a7c --- /dev/null +++ b/src/hr.rs @@ -0,0 +1,292 @@ +//! Modul HR (Konzernplattform phase-001, ADR-009). +//! Mitarbeiterakte + Abwesenheiten mit Genehmigungsworkflow. +//! Zugriff AUSSCHLIESSLICH admin + hr_manager (besonders schuetzenswerte +//! Daten, DSGVO/BDSG §26) — jede Route prueft need_hr(). + +use askama::Template; +use axum::extract::{Extension, Path, Query, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Redirect}; +use axum::Form; +use chrono::NaiveDate; +use serde::Deserialize; + +use crate::web::{need_auth, need_hr, page_ctx, AppState, PageCtx, ReqCtx, WebResult}; + +pub const HR_STATUS: [&str; 3] = ["Aktiv", "Onboarding", "Ausgeschieden"]; +pub const ABSENCE_TYPES: [&str; 4] = ["Urlaub", "Krankheit", "Sonderurlaub", "Fortbildung"]; + +fn parse_date(s: &str) -> Option { + NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d").ok() +} + +fn opt(s: &str) -> Option<&str> { + let t = s.trim(); + if t.is_empty() { None } else { Some(t) } +} + +fn status_class(s: &str) -> &'static str { + match s { + "Aktiv" | "Genehmigt" => "geloest", + "Onboarding" | "Beantragt" => "inbearbeitung", + "Ausgeschieden" | "Abgelehnt" => "geschlossen", + _ => "offen", + } +} + +// ── Liste ────────────────────────────────────────────────────────────────────── +pub struct EmployeeRow { + pub id: i32, + pub name: String, + pub initials: String, + pub personalnr: String, + pub abteilung: String, + pub position: String, + pub status: String, + pub status_class: String, +} + +pub struct AbsenceRow { + pub id: i32, + pub employee: String, + pub typ: String, + pub zeitraum: String, + pub status: String, + pub status_class: String, + pub kommentar: String, + pub pending: bool, +} + +#[derive(Template)] +#[template(path = "hr_list.html")] +pub struct HrListTemplate { + pub title: String, + pub ctx: PageCtx, + pub kpis: Vec<(String, String)>, + pub tabs: Vec<(String, String, bool)>, // href, label, active + pub rows: Vec, + pub absences: Vec, +} + +#[derive(Deserialize)] +pub struct HrQuery { + pub status: Option, +} + +fn emp_initials(v: &str, n: &str) -> String { + format!("{}{}", + v.chars().next().map(|c| c.to_uppercase().to_string()).unwrap_or_default(), + n.chars().next().map(|c| c.to_uppercase().to_string()).unwrap_or_default()) +} + +pub async fn hr_list(State(app): State, Extension(ctx): Extension, + Query(q): Query) -> WebResult { + let auth = match need_auth(&ctx, "/hr") { Ok(a) => a, Err(r) => return Ok(r) }; + if let Err(r) = need_hr(&auth) { return Ok(r); } + let filter = q.status.as_deref().filter(|s| HR_STATUS.contains(s)); + let all = app.db.hr_list_employees(auth.tenant_id, None).await?; + let absences = app.db.hr_list_absences(auth.tenant_id, None).await?; + + let count = |s: &str| all.iter().filter(|e| e.status == s).count(); + let pending = absences.iter().filter(|a| a.status == "Beantragt").count(); + let kpis = vec![ + ("Mitarbeiter gesamt".into(), all.len().to_string()), + ("Aktiv".into(), count("Aktiv").to_string()), + ("Onboarding".into(), count("Onboarding").to_string()), + ("Offene Antraege".into(), pending.to_string()), + ]; + let mut tabs = vec![("/hr".to_string(), "Alle".to_string(), filter.is_none())]; + tabs.extend(HR_STATUS.iter().map(|s| { + (format!("/hr?status={}", s), s.to_string(), filter == Some(s)) + })); + let rows = all.iter() + .filter(|e| filter.map(|f| e.status == f).unwrap_or(true)) + .map(|e| EmployeeRow { + id: e.id, + name: format!("{} {}", e.vorname, e.nachname), + initials: emp_initials(&e.vorname, &e.nachname), + personalnr: e.personalnr.clone().unwrap_or_default(), + abteilung: e.abteilung.clone().unwrap_or_default(), + position: e.position.clone().unwrap_or_default(), + status: e.status.clone(), + status_class: status_class(&e.status).into(), + }).collect(); + let absences = absences.iter().take(30).map(|a| AbsenceRow { + id: a.id, + employee: a.employee_name.clone(), + typ: a.typ.clone(), + zeitraum: format!("{} – {}", a.von.format("%d.%m.%Y"), a.bis.format("%d.%m.%Y")), + status: a.status.clone(), + status_class: status_class(&a.status).into(), + kommentar: a.kommentar.clone().unwrap_or_default(), + pending: a.status == "Beantragt", + }).collect(); + + Ok(HrListTemplate { + title: "HR — Mitarbeiter".into(), + ctx: page_ctx(&auth, "/hr"), + kpis, tabs, rows, absences, + }.into_response()) +} + +// ── Anlegen / Bearbeiten ─────────────────────────────────────────────────────── +#[derive(Deserialize)] +pub struct EmployeeForm { + #[serde(default)] pub personalnr: String, + pub vorname: String, + pub nachname: String, + #[serde(default)] pub email: String, + #[serde(default)] pub abteilung: String, + #[serde(default)] pub position: String, + #[serde(default)] pub eintritt: String, + #[serde(default)] pub austritt: String, + #[serde(default)] pub status: String, + #[serde(default)] pub notizen: String, +} + +pub async fn hr_new(State(app): State, Extension(ctx): Extension, + Form(f): Form) -> WebResult { + let auth = match need_auth(&ctx, "/hr") { Ok(a) => a, Err(r) => return Ok(r) }; + if let Err(r) = need_hr(&auth) { return Ok(r); } + let (vorname, nachname) = (f.vorname.trim(), f.nachname.trim()); + if vorname.is_empty() || nachname.is_empty() { + return Ok(Redirect::to("/hr").into_response()); + } + let status = if HR_STATUS.contains(&f.status.as_str()) { f.status.as_str() } else { "Onboarding" }; + let id = app.db.hr_create_employee( + auth.tenant_id, opt(&f.personalnr), vorname, nachname, opt(&f.email), + opt(&f.abteilung), opt(&f.position), parse_date(&f.eintritt), status, opt(&f.notizen)).await?; + app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "hr_employee_created", Some("hr_employee"), + Some(&id.to_string()), Some(serde_json::json!({"name": format!("{} {}", vorname, nachname)})), + ctx.ip.as_deref()).await?; + Ok(Redirect::to(&format!("/hr/{}", id)).into_response()) +} + +#[derive(Template)] +#[template(path = "hr_detail.html")] +pub struct HrDetailTemplate { + pub title: String, + pub ctx: PageCtx, + pub id: i32, + pub personalnr: String, + pub vorname: String, + pub nachname: String, + pub email: String, + pub abteilung: String, + pub position: String, + pub eintritt: String, + pub austritt: String, + pub status: String, + pub notizen: String, + pub status_opts: Vec<(String, bool)>, + pub absence_types: Vec, + pub absences: Vec, +} + +pub async fn hr_detail(State(app): State, Extension(ctx): Extension, + Path(id): Path) -> WebResult { + let auth = match need_auth(&ctx, "/hr") { Ok(a) => a, Err(r) => return Ok(r) }; + if let Err(r) = need_hr(&auth) { return Ok(r); } + let Some(e) = app.db.hr_get_employee(auth.tenant_id, id).await? else { + return Ok(StatusCode::NOT_FOUND.into_response()); + }; + let absences = app.db.hr_list_absences(auth.tenant_id, Some(id)).await?; + let fmt = |d: Option| d.map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_default(); + Ok(HrDetailTemplate { + title: format!("{} {}", e.vorname, e.nachname), + ctx: page_ctx(&auth, "/hr"), + id: e.id, + personalnr: e.personalnr.clone().unwrap_or_default(), + vorname: e.vorname.clone(), + nachname: e.nachname.clone(), + email: e.email.clone().unwrap_or_default(), + abteilung: e.abteilung.clone().unwrap_or_default(), + position: e.position.clone().unwrap_or_default(), + eintritt: fmt(e.eintritt), + austritt: fmt(e.austritt), + status: e.status.clone(), + notizen: e.notizen.clone().unwrap_or_default(), + status_opts: HR_STATUS.iter().map(|s| (s.to_string(), *s == e.status)).collect(), + absence_types: ABSENCE_TYPES.iter().map(|s| s.to_string()).collect(), + absences: absences.iter().map(|a| AbsenceRow { + id: a.id, + employee: a.employee_name.clone(), + typ: a.typ.clone(), + zeitraum: format!("{} – {}", a.von.format("%d.%m.%Y"), a.bis.format("%d.%m.%Y")), + status: a.status.clone(), + status_class: status_class(&a.status).into(), + kommentar: a.kommentar.clone().unwrap_or_default(), + pending: a.status == "Beantragt", + }).collect(), + }.into_response()) +} + +pub async fn hr_update(State(app): State, Extension(ctx): Extension, + Path(id): Path, Form(f): Form) -> WebResult { + let auth = match need_auth(&ctx, "/hr") { Ok(a) => a, Err(r) => return Ok(r) }; + if let Err(r) = need_hr(&auth) { return Ok(r); } + if app.db.hr_get_employee(auth.tenant_id, id).await?.is_none() { + return Ok(StatusCode::NOT_FOUND.into_response()); + } + let status = if HR_STATUS.contains(&f.status.as_str()) { f.status.as_str() } else { "Aktiv" }; + app.db.hr_update_employee( + auth.tenant_id, id, opt(&f.personalnr), f.vorname.trim(), f.nachname.trim(), + opt(&f.email), opt(&f.abteilung), opt(&f.position), + parse_date(&f.eintritt), parse_date(&f.austritt), status, opt(&f.notizen)).await?; + app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "hr_employee_updated", Some("hr_employee"), + Some(&id.to_string()), None, ctx.ip.as_deref()).await?; + Ok(Redirect::to(&format!("/hr/{}", id)).into_response()) +} + +// ── Abwesenheiten ────────────────────────────────────────────────────────────── +#[derive(Deserialize)] +pub struct AbsenceForm { + #[serde(default)] pub typ: String, + pub von: String, + pub bis: String, + #[serde(default)] pub kommentar: String, +} + +pub async fn hr_absence_new(State(app): State, Extension(ctx): Extension, + Path(id): Path, Form(f): Form) -> WebResult { + let auth = match need_auth(&ctx, "/hr") { Ok(a) => a, Err(r) => return Ok(r) }; + if let Err(r) = need_hr(&auth) { return Ok(r); } + if app.db.hr_get_employee(auth.tenant_id, id).await?.is_none() { + return Ok(StatusCode::NOT_FOUND.into_response()); + } + let (Some(von), Some(bis)) = (parse_date(&f.von), parse_date(&f.bis)) else { + return Ok(Redirect::to(&format!("/hr/{}", id)).into_response()); + }; + if bis < von { + return Ok(Redirect::to(&format!("/hr/{}", id)).into_response()); + } + let typ = if ABSENCE_TYPES.contains(&f.typ.as_str()) { f.typ.as_str() } else { "Urlaub" }; + let aid = app.db.hr_create_absence(auth.tenant_id, id, typ, von, bis, opt(&f.kommentar)).await?; + app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "hr_absence_created", Some("hr_absence"), + Some(&aid.to_string()), Some(serde_json::json!({"employee": id, "typ": typ})), + ctx.ip.as_deref()).await?; + Ok(Redirect::to(&format!("/hr/{}", id)).into_response()) +} + +#[derive(Deserialize)] +pub struct AbsenceStatusForm { + pub status: String, + #[serde(default)] pub zurueck: String, +} + +pub async fn hr_absence_status(State(app): State, Extension(ctx): Extension, + Path(aid): Path, Form(f): Form) -> WebResult { + let auth = match need_auth(&ctx, "/hr") { Ok(a) => a, Err(r) => return Ok(r) }; + if let Err(r) = need_hr(&auth) { return Ok(r); } + let status = match f.status.as_str() { + "Genehmigt" => "Genehmigt", + "Abgelehnt" => "Abgelehnt", + _ => return Ok(Redirect::to("/hr").into_response()), + }; + app.db.hr_set_absence_status(auth.tenant_id, aid, status).await?; + app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "hr_absence_status_changed", Some("hr_absence"), + Some(&aid.to_string()), Some(serde_json::json!({"status": status})), + ctx.ip.as_deref()).await?; + let back = if f.zurueck.starts_with('/') && !f.zurueck.starts_with("//") { f.zurueck } else { "/hr".into() }; + Ok(Redirect::to(&back).into_response()) +} diff --git a/src/itil.rs b/src/itil.rs index b6648bb..fbf5517 100755 --- a/src/itil.rs +++ b/src/itil.rs @@ -14,7 +14,13 @@ /// - change_manager genehmigt Changes (CAB), darf Repo-Aenderungen aus Changes /// - agent Service Desk: Tickets, KB-Entwuerfe, CMDB /// - user Requester/Self-Service: eigene Tickets, freigegebene KB -pub const ROLES: [&str; 4] = ["admin", "change_manager", "agent", "user"]; +/// - aes_user AES-Suite-Erweiterung: darf die AES-Suite unter +/// "Erweiterungen" nutzen (keine operativen ITSM-Rechte) +/// - hr_manager Modul HR: Mitarbeiterakten + Abwesenheiten (besonders +/// schuetzenswerte Daten — KEIN Zugriff fuer operative +/// ITSM-Rollen, siehe ADR-009) +/// - crm_agent Modul CRM: Firmen, Kontakte, Deals +pub const ROLES: [&str; 7] = ["admin", "change_manager", "agent", "user", "aes_user", "hr_manager", "crm_agent"]; pub fn role_label(role: &str) -> &'static str { match role { @@ -22,6 +28,9 @@ pub fn role_label(role: &str) -> &'static str { "change_manager" => "Change Manager", "agent" => "Service-Desk-Agent", "user" => "Anwender", + "aes_user" => "AES-User", + "hr_manager" => "HR-Manager", + "crm_agent" => "CRM-Agent", _ => "Unbekannt", } } @@ -36,6 +45,23 @@ pub fn is_change_approver(role: &str) -> bool { matches!(role, "admin" | "change_manager") } +/// Zugriff auf die AES-Suite-Erweiterung ("Erweiterungen" -> AES-Suite). +/// Bewusst getrennt von `is_operative`: die AES-Rolle ist spezialisiert und +/// erhaelt dadurch keine operativen ITSM-Rechte (Tickets/CMDB/KB-Entwuerfe). +/// Modul HR (ADR-009): nur admin + hr_manager. +pub fn is_hr(role: &str) -> bool { + matches!(role, "admin" | "hr_manager") +} + +/// Modul CRM (ADR-009): admin + crm_agent. +pub fn is_crm(role: &str) -> bool { + matches!(role, "admin" | "crm_agent") +} + +pub fn is_aes_user(role: &str) -> bool { + matches!(role, "admin" | "aes_user") +} + /// Kategorien (v4-Practices: Incident, Service Request, Problem, Change, ...). pub const CATEGORIES: [&str; 6] = ["Incident", "Service Request", "Problem", "Task", "Change", "Release"]; /// Kategorien, die die Rolle 'user' im Self-Service anlegen darf. diff --git a/src/main.rs b/src/main.rs index 4cdf907..af5eed8 100755 --- a/src/main.rs +++ b/src/main.rs @@ -17,8 +17,11 @@ mod admin; mod auth; mod cmdb; mod config; +mod crm; mod dashboard; +mod hr; mod db; +mod extensions; mod forge; mod itil; mod kb; @@ -64,10 +67,16 @@ async fn main() -> anyhow::Result<()> { tracing::info!("FORGE_BASE_URL nicht gesetzt -- Repo-Bearbeitung aus Tickets deaktiviert."); } + let aes = Arc::new(extensions::AesClient::from_config(&cfg)); + if aes.is_none() { + tracing::info!("ITSM_AES_URL nicht gesetzt -- AES-Suite-Erweiterung deaktiviert."); + } + let state = AppState { cfg: cfg.clone(), db: db.clone(), forge, + aes, http: reqwest::Client::builder() .timeout(std::time::Duration::from_secs(5)) .build()?, @@ -114,6 +123,20 @@ async fn main() -> anyhow::Result<()> { .route("/tickets/:id/repo-edit", post(tickets::ticket_repo_edit)) .route("/api/tickets/:id", get(tickets::api_ticket)) .route("/api/tickets/:id/repo-file", get(tickets::api_repo_file)) + // Modul HR (Konzernplattform phase-001, nur admin + hr_manager) + .route("/hr", get(hr::hr_list)) + .route("/hr/neu", post(hr::hr_new)) + .route("/hr/:id", get(hr::hr_detail).post(hr::hr_update)) + .route("/hr/:id/abwesenheiten", post(hr::hr_absence_new)) + .route("/hr/abwesenheiten/:id/status", post(hr::hr_absence_status)) + // Modul CRM (Konzernplattform phase-001, nur admin + crm_agent) + .route("/crm", get(crm::crm_list)) + .route("/crm/neu", post(crm::crm_new)) + .route("/crm/pipeline", get(crm::crm_pipeline)) + .route("/crm/:id", get(crm::crm_detail).post(crm::crm_update)) + .route("/crm/:id/kontakte", post(crm::crm_contact_new)) + .route("/crm/:id/deals", post(crm::crm_deal_new)) + .route("/crm/deals/:id/stufe", post(crm::crm_deal_stage)) // Service-Katalog .route("/services", get(services::services_page)) .route("/services/new", post(services::service_new)) @@ -130,6 +153,32 @@ async fn main() -> anyhow::Result<()> { .route("/assets/:id", get(cmdb::ci_detail_get).post(cmdb::ci_detail_post)) .route("/assets/:id/relationships", post(cmdb::ci_rel_add)) .route("/assets/:id/relationships/:rel_id/delete", post(cmdb::ci_rel_delete)) + // Erweiterungen -> AES-Suite Dashboard (admin + Rolle aes_user) + .route("/erweiterungen/aes", get(extensions::aes_dashboard)) + .route("/erweiterungen/aes/projekte", get(extensions::aes_projekte).post(extensions::aes_project_create)) + .route("/erweiterungen/aes/projekte/:name", get(extensions::aes_project_detail)) + .route("/erweiterungen/aes/projekte/:name/phasen/:phase/run", post(extensions::aes_phase_run)) + .route("/erweiterungen/aes/projekte/:name/phasen/:phase/validate", post(extensions::aes_phase_validate)) + .route("/erweiterungen/aes/forge", get(extensions::aes_forge).post(extensions::aes_forge_create)) + .route("/erweiterungen/aes/forge/:name", get(extensions::aes_forge_repo)) + .route("/erweiterungen/aes/forge/:name/delete", post(extensions::aes_forge_delete)) + .route("/erweiterungen/aes/forge/:name/pulls", post(extensions::aes_forge_pull_create)) + .route("/erweiterungen/aes/forge/:name/pulls/:number/merge", post(extensions::aes_forge_pull_merge)) + .route("/erweiterungen/aes/runner", get(extensions::aes_runner)) + .route("/erweiterungen/aes/llm", get(extensions::aes_llm)) + .route("/erweiterungen/aes/llm/infer", post(extensions::aes_llm_infer)) + .route("/erweiterungen/aes/llm/pull", post(extensions::aes_llm_pull)) + .route("/erweiterungen/aes/llm/delete", post(extensions::aes_llm_delete)) + .route("/erweiterungen/aes/llm/connections", post(extensions::aes_llm_conn_save)) + .route("/erweiterungen/aes/agenten", get(extensions::aes_agenten)) + .route("/erweiterungen/aes/monitoring", get(extensions::aes_monitoring)) + .route("/erweiterungen/aes/monitor.json", get(extensions::aes_monitor_json)) + .route("/erweiterungen/aes/admin", get(extensions::aes_admin)) + .route("/erweiterungen/aes/einstellungen", get(extensions::aes_einstellungen)) + .route("/erweiterungen/aes/einstellungen/save", post(extensions::aes_einstellungen_save)) + // Externe Metrics-Schnittstelle (Token-geschuetzt, kein Session-Login) + .route("/aes/metrics", get(extensions::aes_metrics)) + .route("/aes/metrics.json", get(extensions::aes_metrics_json)) // Administration .route("/admin", get(admin::settings_get).post(admin::settings_post)) .route("/admin/retention/run", post(admin::retention_run)) diff --git a/src/web.rs b/src/web.rs index b43371b..a2b04d3 100755 --- a/src/web.rs +++ b/src/web.rs @@ -17,6 +17,7 @@ use tokio::sync::Mutex; use crate::config::Config; use crate::db::{AuthUser, Db}; +use crate::extensions::AesClient; use crate::forge::ForgeClient; use crate::{itil, security}; @@ -27,6 +28,8 @@ pub struct AppState { pub cfg: Arc, pub db: Db, pub forge: Arc>, + /// AES-Suite-Erweiterung (Modul /ext/aes); None = nicht konfiguriert. + pub aes: Arc>, pub http: reqwest::Client, /// Cache fuer Service-Erreichbarkeit (service_id -> (Zeitpunkt, Ergebnis)). pub svc_status_cache: Arc)>>>, @@ -94,6 +97,22 @@ pub fn need_admin(auth: &AuthUser) -> Result<(), Response> { } } +pub fn need_hr(auth: &AuthUser) -> Result<(), Response> { + if itil::is_hr(&auth.role) { + Ok(()) + } else { + Err(forbidden("das HR-Modul ist der Rolle HR-Manager vorbehalten.")) + } +} + +pub fn need_crm(auth: &AuthUser) -> Result<(), Response> { + if itil::is_crm(&auth.role) { + Ok(()) + } else { + Err(forbidden("das CRM-Modul ist der Rolle CRM-Agent vorbehalten.")) + } +} + pub fn need_change_approver(auth: &AuthUser) -> Result<(), Response> { if itil::is_change_approver(&auth.role) { Ok(()) @@ -102,6 +121,14 @@ pub fn need_change_approver(auth: &AuthUser) -> Result<(), Response> { } } +pub fn need_aes(auth: &AuthUser) -> Result<(), Response> { + if itil::is_aes_user(&auth.role) { + Ok(()) + } else { + Err(forbidden("erforderliche Rolle: AES-User oder Administrator.")) + } +} + // ── Seiten-Kontext fuer Templates ───────────────────────────────────────────── pub struct PageCtx { pub email: String, @@ -111,6 +138,11 @@ pub struct PageCtx { pub csrf: String, pub operative: bool, pub admin: bool, + /// Darf die "Erweiterungen"-Gruppe (AES-Suite) sehen. + pub aes_ext: bool, + /// Modul-Sichtbarkeit (ADR-009 Konzernplattform). + pub hr: bool, + pub crm: bool, pub active: String, } @@ -126,6 +158,9 @@ pub fn page_ctx(auth: &AuthUser, active: &str) -> PageCtx { csrf: auth.csrf_token.clone(), operative: itil::is_operative(&auth.role), admin: auth.role == "admin", + aes_ext: itil::is_aes_user(&auth.role), + hr: itil::is_hr(&auth.role), + crm: itil::is_crm(&auth.role), active: active.to_string(), } } diff --git a/static/aes-ext.js b/static/aes-ext.js new file mode 100755 index 0000000..2a0e7bf --- /dev/null +++ b/static/aes-ext.js @@ -0,0 +1,22 @@ +/* AES-Erweiterung: CSP-konforme Bestaetigungsdialoge fuer Formulare mit + data-confirm="..." (ersetzt inline onsubmit). Von /static ausgeliefert. */ +(function () { + "use strict"; + function wire() { + var forms = document.querySelectorAll("form[data-confirm]"); + for (var i = 0; i < forms.length; i++) { + (function (f) { + f.addEventListener("submit", function (e) { + if (!window.confirm(f.getAttribute("data-confirm"))) { + e.preventDefault(); + } + }); + })(forms[i]); + } + } + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", wire); + } else { + wire(); + } +})(); diff --git a/static/aes-monitor.js b/static/aes-monitor.js new file mode 100755 index 0000000..f64a766 --- /dev/null +++ b/static/aes-monitor.js @@ -0,0 +1,120 @@ +/* AES-Suite Live-Monitoring — pollt monitor.json und rendert Dienste + + Backends grafisch. Von /static ausgeliefert (CSP script-src 'self'). */ +(function () { + "use strict"; + var root = document.getElementById("aes-monitor"); + if (!root) return; + var src = root.getAttribute("data-src") || "/erweiterungen/aes/monitor.json"; + var INTERVAL = 5000; + + function el(tag, cls, text) { + var e = document.createElement(tag); + if (cls) e.className = cls; + if (text != null) e.textContent = text; + return e; + } + + function bar(label, pct) { + var wrap = el("div", "sz"); + wrap.style.margin = "2px 0"; + var v = Math.max(0, Math.min(100, Math.round(pct || 0))); + wrap.appendChild(document.createTextNode(label + " ")); + var track = el("span"); + track.style.display = "inline-block"; + track.style.width = "120px"; + track.style.height = "8px"; + track.style.borderRadius = "4px"; + track.style.background = "var(--border, #333)"; + track.style.verticalAlign = "middle"; + track.style.margin = "0 6px"; + var fill = el("span"); + fill.style.display = "block"; + fill.style.height = "8px"; + fill.style.borderRadius = "4px"; + fill.style.width = v + "%"; + fill.style.background = v > 85 ? "#d9534f" : (v > 60 ? "#e0a800" : "#3fb950"); + track.appendChild(fill); + wrap.appendChild(track); + wrap.appendChild(document.createTextNode(v + "%")); + return wrap; + } + + function render(data) { + root.textContent = ""; + + // Dienst-Ampeln + var svcRow = el("div"); + svcRow.style.display = "flex"; + svcRow.style.flexWrap = "wrap"; + svcRow.style.gap = "10px"; + (data.services || []).forEach(function (s) { + var card = el("div", "card"); + card.style.flex = "1"; + card.style.minWidth = "180px"; + var head = el("div"); + head.style.display = "flex"; + head.style.justifyContent = "space-between"; + head.style.alignItems = "center"; + head.appendChild(el("b", null, s.name)); + head.appendChild(el("span", "badge " + (s.ok ? "ok" : "err"), s.ok ? "up" : "down")); + card.appendChild(head); + card.appendChild(el("div", "sz", s.detail || "")); + svcRow.appendChild(card); + }); + root.appendChild(svcRow); + + // Backends + var h = el("h3", null, "Backends / Agenten"); + h.style.margin = "16px 0 6px"; + root.appendChild(h); + var bes = data.backends || []; + if (!bes.length) { + root.appendChild(el("p", "sz", "Keine Backends online.")); + } else { + bes.forEach(function (b) { + var card = el("div", "card"); + var head = el("div"); + head.style.display = "flex"; + head.style.justifyContent = "space-between"; + head.appendChild(el("b", null, b.url)); + head.appendChild(el("span", "badge " + (b.healthy ? "ok" : "err"), b.healthy ? "healthy" : "offline")); + card.appendChild(head); + card.appendChild(bar("CPU", b.cpu)); + card.appendChild(bar("RAM", b.ram)); + card.appendChild(bar("VRAM", b.vram)); + var meta = el("div", "sz"); + meta.textContent = "Latenz " + (b.latency || 0) + "ms · Agent " + (b.agent_online ? "online" : "offline") + + " · Modelle: " + ((b.models && b.models.length) ? b.models.join(", ") : "-"); + card.appendChild(meta); + root.appendChild(card); + }); + } + + // Runner + Projekte + Zeitstempel + var foot = el("p", "sz"); + foot.style.marginTop = "10px"; + var r = data.runner || {}; + foot.textContent = "Runner: " + (r.available ? "aktiv" : "offline") + + " · beobachtete Repos: " + ((r.watched && r.watched.length) || 0) + + " · Projekte: " + (data.projects_total || 0) + + " · Stand: " + new Date().toLocaleTimeString(); + root.appendChild(foot); + } + + function tick() { + fetch(src, { headers: { "Accept": "application/json" }, credentials: "same-origin" }) + .then(function (r) { return r.json(); }) + .then(render) + .catch(function () { + var p = document.getElementById("aes-monitor-err"); + if (!p) { + p = el("p", "err", "Live-Daten momentan nicht abrufbar — naechster Versuch laeuft."); + p.id = "aes-monitor-err"; + root.appendChild(p); + } + }); + } + + tick(); + setInterval(tick, INTERVAL); +})(); diff --git a/static/style.css b/static/style.css index 16c25ae..579dbb7 100755 --- a/static/style.css +++ b/static/style.css @@ -173,3 +173,6 @@ td.chk input,th.chk input{width:auto} .sla-chip{display:inline-block;padding:2px 9px;border-radius:6px;font-size:11px;font-weight:600;background:#1a4a35;color:var(--ok)} .sla-chip.breach{background:#4a1e22;color:var(--bad)} .dt-footer{display:flex;gap:8px;margin-top:16px;position:sticky;bottom:0;background:var(--panel);padding-top:10px} + +.board.pipeline{grid-template-columns:repeat(6,1fr)} +.board-card-foot select{width:auto;font-size:11px;padding:3px 6px} diff --git a/templates/aes_dashboard.html b/templates/aes_dashboard.html new file mode 100755 index 0000000..57c94fd --- /dev/null +++ b/templates/aes_dashboard.html @@ -0,0 +1,278 @@ +{% extends "base.html" %} +{% block content %} +{% include "aes_nav.html" %} + +{% if !configured %} +

{{ error }}

+{% else %} +{% if !error.is_empty() %}

{{ error }}

{% endif %} +{% if !note.is_empty() %}

{{ note }}

{% endif %} + +{% if section == "dashboard" %} +
+ {% for c in health %} +
+
{{ c.label }}
{{ c.sub }}
{{ c.value }}
+
+ {% endfor %} +
+ +
+ {% for k in kpis %} +
+
{{ k.label }}
+
{{ k.value }}
+
{{ k.sub }}
+
+ {% endfor %} +
+ +

Projekte

+ {% if projects.is_empty() %}

Keine Projekte.

{% else %} + + {% for p in projects %}{% endfor %} +
ProjektLetzter Lauf
{{ p.name }}{{ p.status }}
{% endif %} + +
+
+

Live Activity

+ {% if activity.is_empty() %}

Keine Aktivitaet.

{% else %} +
{% for a in activity %}
{{ a.text }}{{ a.when }}
{% endfor %}
{% endif %} +
+
+

Infrastruktur

+

{{ bal_summary }}

+ {% if backends.is_empty() %}

Keine Backends gemeldet.

{% endif %} + {% for b in backends %}
{{ b.url }}
CPU {{ b.cpu }}% · RAM {{ b.ram }}% · VRAM {{ b.vram }}% · {{ b.latency }}ms
{{ b.badge }}
{% endfor %} +
+
+ +{% else if section == "projekte" %} +

Projekte

+ {% if projects.is_empty() %}

Noch keine Projekte.

{% else %} + + {% for p in projects %}{% endfor %} +
Projekt
{{ p.name }}Oeffnen →
{% endif %} +
+

Projekt anlegen

+
+ +
+ +
+
+ +{% else if section == "forge" %} +
+

Forge

+ ⚙ Einstellungen +
+

Git-Server der AES-Suite. Zugriff erfolgt serverseitig mit dem AES-Token — kein eigenes Token noetig.

+ {% if repos.is_empty() %}

Keine Repositories.

{% else %} + + {% for r in repos %} + + + + + {% endfor %} +
RepositoryBranchSichtbarkeit
{{ r.label }}{{ r.value }}{{ r.sub }} +
+ + +
+
{% endif %} +
+

Repository anlegen

+
+ +
+ +
+
+ +{% else if section == "runner" %} +
+

Runner

+ ⚙ Einstellungen +
+ {% if !runner_available %} +

Runner-Status nicht verfuegbar. {{ runner_hint }}

+ {% else %} +
+
Version
v{{ runner_version }}
+
Poll-Intervall
{{ runner_poll }}s
+
Zuletzt aktiv
{{ runner_updated }}
+
+
Forge/Gitea: {{ runner_instance }} · Self-Update-Repo: {{ runner_update_repo }}
+

Beobachtete Repositories

+ {% if runner_watched.is_empty() %}

Keine.

{% else %} +
{% for w in runner_watched %}{{ w }}{% endfor %}
+ {% endif %} +

Letzte CI-Laeufe

+ {% if runner_runs.is_empty() %}

Noch keine Laeufe aufgezeichnet (erscheinen beim naechsten Commit auf einem beobachteten Repo).

{% else %} + + {% for r in runner_runs %}{% endfor %} +
RepositoryBranchCommitErgebnisBeendet
{{ r.repo }}{{ r.branch }}{{ r.sha }}{{ r.ok_label }}{{ r.finished_at }}
{% endif %} + {% endif %} + +{% else if section == "agenten" %} +

Agenten

+

Agenten sind llm-agent-Instanzen (je LLM-Host, mit lokalem Ollama), die sich beim Balancer anmelden und Inferenz + Host-Metriken liefern.

+

{{ bal_summary }}

+ {% if backends.is_empty() %} +

Kein Agent online. Starte einen Agenten auf einem GPU/CPU-Host: aes-suite agent --token <AGENT_TOKEN> (Ollama vorausgesetzt) und trage seine URL im Balancer (OLLAMA_HOSTS) ein.

+ {% else %} + + {% for b in backends %}{% endfor %} +
HostAgentBackendCPURAMVRAMLatenz
{{ b.url }}{{ b.agent }}{{ b.badge }}{{ b.cpu }}%{{ b.ram }}%{{ b.vram }}%{{ b.latency }}ms
{% endif %} + +{% else if section == "llm" %} +
+

LLM & Verbindungen

+ ⚙ Einstellungen +
+ {% if !hinweis.is_empty() %}

{{ hinweis }}

{% endif %} + {% if !fehler.is_empty() %}

{{ fehler }}

{% endif %} +

Der Balancer proxyt Inferenz OpenAI-/Ollama-kompatibel auf die Verbindungen; ohne gesundes Ollama-Backend greift der passende Cloud-/OpenAI-Provider.

+ +
+

LLM-Verbindungen (Provider)

+

Ollama-Hosts und OpenAI-kompatible Provider (OpenAI, InferX, Together, Groq, Infercom, …). base_url = OpenAI-Basis (…/v1). API-Key leer lassen = unveraendert. Speichern startet die aes-suite neu.

+

Presets base_url: InferX https://model.inferx.net/<endpoint>/v1 · Together https://api.together.xyz/v1 · Groq https://api.groq.com/openai/v1 · OpenAI https://api.openai.com/v1

+
+ + +
+ {% for c in conns %} + + + + + + + + + + {% endfor %} +
AktivNameTypURL (ollama) / base_url (cloud)Agent-URLModellAPI-Key
 
+ +
+
+ +
+

Inferenz testen

+
+ +
+
+ +
+ {% if infer_shown %} +

Antwort{% if !infer_model.is_empty() %} ({{ infer_model }}){% endif %}

+ {% if infer_result.is_empty() %}

Keine Antwort — Ursache siehe Fehlermeldung oben (kein gesundes Backend und kein Cloud-Fallback aktiv).

+ {% else %}
{{ infer_result }}
{% endif %} + {% endif %} +
+ +
+

Modelle verwalten

+
+ +
+ +
+ {% if models.is_empty() %}

Keine Modelle geladen (Backend offline oder leer).

{% else %} + + {% for m in models %}{% endfor %} +
Modell
{{ m }} +
+ + + +
{% endif %} +
+ +

Backends

+

{{ bal_summary }}

+ {% if backends.is_empty() %}

Keine Backends. Sobald ein llm-agent online ist, erscheinen hier Auslastung und Modelle.

{% else %} + + {% for b in backends %}{% endfor %} +
BackendStatusCPURAMVRAMLatenzModelle
{{ b.url }}{{ b.badge }}{{ b.cpu }}%{{ b.ram }}%{{ b.vram }}%{{ b.latency }}ms{{ b.models }}
{% endif %} + +{% else if section == "monitoring" %} +

Monitoring

+

Live-Ansicht aller Dienste (aktualisiert sich automatisch). Externe Anbindung siehe Admin → Metrics.

+
+

Lade Live-Daten… (aktiviere JavaScript fuer die Live-Ansicht)

+
+ +

Logs (Events)

+ {% if logs.is_empty() %}

Keine Events.

{% else %} +
{% for l in logs %}
{{ l }}
{% endfor %}
{% endif %} + +{% else if section == "admin" %} +

Admin

+

Dienste & Modul

+ + {% for r in admin_rows %}{% endfor %} +
{{ r.k }}{% if r.class.is_empty() %}{{ r.v }}{% else %}{{ r.v }}{% endif %}
+

Externe Monitoring-Schnittstelle

+

Zum Anbinden eines externen Monitorings (Prometheus/Grafana o.ae.). Aufruf mit Header Authorization: Bearer <ITSM_AES_METRICS_TOKEN>.

+ + {% for r in metrics_rows %}{% endfor %} +
{{ r.k }}{{ r.v }}
+ +{% else if section == "einstellungen" %} +

Einstellungen{% if !comp.is_empty() %} · {{ comp }}{% endif %}

+ {% if !comp.is_empty() %}

← alle Einstellungen

{% endif %} + {% if !hinweis.is_empty() %}

{{ hinweis }}

{% endif %} + {% if !fehler.is_empty() %}

{{ fehler }}

{% endif %} +

Secrets werden nie im Klartext angezeigt; Feld leer lassen = unveraendert. Speichern startet den betroffenen Dienst automatisch neu.

+ + {% if comp != "runner" %} +
+ + +

AES-Suite / Balancer / Cloud / Forge

+ {% for g in sett_aes %} + {% if comp.is_empty() || comp == "all" || (comp == "llm" && g.name == "LLM-Balancer / Cloud") || (comp == "forge" && g.name == "Forge") || (comp == "forge" && g.name == "AES-Kern") %} +

{{ g.name }}

+ {% for it in g.items %} +
+ + {% if it.secret %} + {% else %}{% endif %} +
+ {% endfor %} + {% endif %} + {% endfor %} + +
+ {% endif %} + + {% if comp.is_empty() || comp == "all" || comp == "runner" %} +
+ + +

Runner

+ {% for g in sett_runner %} + {% for it in g.items %} +
+ + {% if it.secret %} + {% else %}{% endif %} +
+ {% endfor %} + {% endfor %} + +
+ {% endif %} + +{% endif %} +{% endif %} +{% endblock %} diff --git a/templates/aes_forge_repo.html b/templates/aes_forge_repo.html new file mode 100755 index 0000000..1add5c8 --- /dev/null +++ b/templates/aes_forge_repo.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block content %} +{% include "aes_nav.html" %} +

← Forge

+

Repository: {{ name }}

+ +{% if !hinweis.is_empty() %}

{{ hinweis }}

{% endif %} +{% if !fehler.is_empty() %}

{{ fehler }}

{% endif %} + +
+
Default-Branch
{{ default_branch }}
+
Sichtbarkeit
{% if private %}privat{% else %}oeffentlich{% endif %}
+
Clone-URL
{{ clone_url }}
+
+ +

Pull Requests

+{% if pulls.is_empty() %} +

Keine Pull Requests.

+{% else %} + + + + {% for p in pulls %} + + + + + + + + {% endfor %} + +
#TitelQuelle → ZielStatus
{{ p.number }}{{ p.title }}{{ p.head }} → {{ p.base }}{{ p.state }} +
+ + +
+
+{% endif %} + +
+

Pull Request eroeffnen

+
+ +
+
+
+ +
+
+{% endblock %} diff --git a/templates/aes_nav.html b/templates/aes_nav.html new file mode 100755 index 0000000..22fceca --- /dev/null +++ b/templates/aes_nav.html @@ -0,0 +1,13 @@ +
+
AES Suite Autonomous Engineering System
+ +
+ diff --git a/templates/base.html b/templates/base.html index 2ea285c..70b2325 100755 --- a/templates/base.html +++ b/templates/base.html @@ -12,7 +12,7 @@