bmc_hub/app/modules/drift/backend/router.py
Christian be68148448 feat(email): add RETURNING id to email activity log insert
feat(ollama): implement case creation rewrite with strict rules

fix(sync): include phone number in economic customer sync

fix(migrations): clean up orphan links before enforcing foreign keys in email threading schema

feat(migrations): add support for physical patch-panel layouts and update related constraints

feat(migrations): add start port number for physical patch panels

feat(migrations): introduce display order for physical panels

feat(migrations): link wall outlets to switch hardware

feat(migrations): allow optional label and customer for wall outlets

feat(migrations): create hardware network links table

feat(migrations): add location display order for hardware assets

feat(migrations): create UISP devices and link to hardware assets
2026-07-18 10:10:31 +02:00

1991 lines
71 KiB
Python

import logging
import json
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Set
from urllib.parse import urlparse
import httpx
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from psycopg2.extras import Json
from app.core.database import execute_query, execute_query_single
logger = logging.getLogger(__name__)
router = APIRouter()
class DriftTicketPayload(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
customer_id: Optional[int] = None
class DriftTicketLinkPayload(BaseModel):
sag_id: int
class DriftSyncPayload(BaseModel):
source: Optional[str] = "uptime-kuma"
class DriftCustomerMappingPayload(BaseModel):
monitor_key: Optional[str] = None
monitor_name: Optional[str] = None
customer_id: int
source: Optional[str] = None
class DriftBlacklistPayload(BaseModel):
value: Optional[str] = None
def run_uptime_kuma_sync() -> Dict[str, Any]:
"""Run a live Uptime Kuma sync and return summary data for jobs/endpoints."""
_ensure_schema()
_purge_demo_events()
source = _ensure_source()
config = _get_drift_connector_config()
base_url = config.get("base_url") or ""
api_key = config.get("api_key") or ""
if not base_url or not api_key:
logger.info("⚠️ Drift Uptime Kuma sync skipped: connector not configured")
return {
"synced": 0,
"source": source.get("name"),
"mode": "live",
"warning": "Uptime Kuma connector mangler base_url eller api_key. Ingen demo-data oprettes.",
}
metrics_text = _fetch_uptime_kuma_metrics(base_url, api_key)
events = _build_events_from_metrics(metrics_text, source.get("id"))
if not events:
return {
"synced": 0,
"source": source.get("name"),
"mode": "live",
"warning": "Ingen monitor-metrics fundet i /metrics respons.",
}
for item in events:
_upsert_event_from_payload(source.get("id"), item)
return {
"synced": len(events),
"source": source.get("name"),
"mode": "live",
}
def _normalize_status(value: Optional[str]) -> str:
if not value:
return "new"
value = str(value).strip().lower()
mapping = {
"down": "active",
"up": "resolved",
"active": "active",
"resolved": "resolved",
"acknowledged": "acknowledged",
"warning": "warning",
"critical": "critical",
"info": "info",
}
return mapping.get(value, value)
def _normalize_severity(value: Optional[str]) -> str:
if not value:
return "info"
value = str(value).strip().lower()
mapping = {"critical": "critical", "high": "critical", "warning": "warning", "warn": "warning", "info": "info", "down": "critical"}
return mapping.get(value, value)
def _normalize_external_link(value: Optional[str], base_url: Optional[str] = None) -> Optional[str]:
raw = str(value or "").strip()
if not raw or raw.lower() == "null":
return None
if raw.startswith(("http://", "https://")):
return raw
if not base_url:
return None
base = str(base_url or "").strip().rstrip("/")
if not base:
return None
if raw.startswith("/"):
return f"{base}{raw}"
return f"{base}/{raw.lstrip('/')}"
def _extract_device_link(item: Dict[str, Any], base_url: Optional[str] = None, external_id: Optional[str] = None) -> Optional[str]:
if not isinstance(item, dict):
return None
candidates: List[Optional[str]] = []
for key in ("url", "href", "link"):
candidates.append(item.get(key))
nested = item.get("identification") if isinstance(item.get("identification"), dict) else {}
for key in ("url", "href", "link"):
candidates.append(nested.get(key))
overview = item.get("overview") if isinstance(item.get("overview"), dict) else {}
for key in ("url", "href", "link"):
candidates.append(overview.get(key))
for candidate in candidates:
normalized = _normalize_external_link(candidate, base_url)
if normalized:
return normalized
if base_url and external_id:
for suffix in (f"/nms/devices/{external_id}", f"/devices/{external_id}", f"/nms/devices/{external_id}/"):
normalized = _normalize_external_link(suffix, base_url)
if normalized:
return normalized
return None
def _row_to_event(row: Dict[str, Any]) -> Dict[str, Any]:
def _coerce_dt(value: Any) -> Optional[datetime]:
if isinstance(value, datetime):
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
raw_value = str(value or "").strip()
if not raw_value:
return None
try:
return datetime.fromisoformat(raw_value.replace("Z", "+00:00"))
except ValueError:
return None
started = row.get("started_at")
updated = row.get("updated_at")
resolved = row.get("resolved_at")
raw_payload = row.get("raw_json")
if isinstance(raw_payload, str):
try:
raw_payload = json.loads(raw_payload)
except (TypeError, ValueError):
raw_payload = {}
raw = raw_payload if isinstance(raw_payload, dict) else {}
raw_item = raw.get("raw_item") if isinstance(raw.get("raw_item"), dict) else {}
raw_overview = raw_item.get("overview") if isinstance(raw_item.get("overview"), dict) else {}
nested_overview = raw.get("overview") if isinstance(raw.get("overview"), dict) else {}
identification = raw_item.get("identification") if isinstance(raw_item.get("identification"), dict) else {}
if not isinstance(identification, dict):
identification = raw.get("identification") if isinstance(raw.get("identification"), dict) else {}
last_seen = (
raw.get("last_seen")
or raw_overview.get("lastSeen")
or raw_overview.get("last_seen")
or nested_overview.get("lastSeen")
or nested_overview.get("last_seen")
or updated
)
duration_minutes = row.get("duration_minutes")
if duration_minutes is None:
started_dt = _coerce_dt(started)
end_dt = _coerce_dt(resolved) or _coerce_dt(updated) or datetime.now(timezone.utc)
if started_dt:
duration_minutes = max(int((end_dt - started_dt).total_seconds() // 60), 0)
source_event_id = row.get("source_event_id") or ""
monitor_key = source_event_id[5:] if source_event_id.startswith("kuma-") else source_event_id
ip_value = None
for candidate in (
raw.get("ip"),
raw.get("ip_address"),
raw.get("ipAddress"),
raw_item.get("ip"),
raw_item.get("ip_address"),
raw_item.get("ipAddress"),
raw_item.get("overview", {}).get("ip") if isinstance(raw_item.get("overview"), dict) else None,
raw_item.get("overview", {}).get("ip_address") if isinstance(raw_item.get("overview"), dict) else None,
raw_item.get("overview", {}).get("ipAddress") if isinstance(raw_item.get("overview"), dict) else None,
identification.get("ip"),
identification.get("ip_address"),
identification.get("ipAddress"),
identification.get("address"),
raw_item.get("address"),
raw_item.get("addresses"),
raw_item.get("network"),
):
if candidate is None:
continue
value = str(candidate).strip()
if value:
ip_value = value
break
site_client_value = None
def _normalize_text_value(value: Any) -> Optional[str]:
if value is None:
return None
if isinstance(value, dict):
for key in ("name", "title", "client_name", "clientName", "site_name", "siteName", "value"):
nested = value.get(key)
if isinstance(nested, str) and nested.strip():
return nested.strip()
return None
if isinstance(value, list):
for item in value:
normalized = _normalize_text_value(item)
if normalized:
return normalized
return None
if isinstance(value, (str, int, float, bool)):
text = str(value).strip()
return text or None
return None
for candidate in (
raw.get("site_client_name"),
raw.get("client_name"),
raw.get("clientName"),
raw.get("client"),
raw_item.get("client_name"),
raw_item.get("clientName"),
raw_item.get("client"),
raw_item.get("site_client_name"),
raw_item.get("site_name"),
raw_item.get("site"),
raw_item.get("siteName"),
raw.get("site"),
raw.get("site_name"),
raw.get("siteName"),
identification.get("site_client_name"),
identification.get("client_name"),
identification.get("clientName"),
identification.get("client"),
identification.get("site"),
identification.get("site_name"),
identification.get("siteName"),
row.get("site_name"),
row.get("customer_name"),
):
normalized = _normalize_text_value(candidate)
if normalized:
site_client_value = normalized
break
raw_source_link = raw.get("source_link") or raw.get("device_link")
if not raw_source_link and isinstance(raw.get("raw_item"), dict):
raw_source_link = _extract_device_link(raw.get("raw_item"), None, source_event_id[5:] if source_event_id.startswith(("uisp-", "kuma-")) else None)
return {
"id": row.get("id"),
"source": row.get("source") or row.get("source_name") or "Ukendt",
"source_event_id": source_event_id,
"monitor_key": monitor_key,
"severity": row.get("severity"),
"status": row.get("status"),
"customer": row.get("customer_name"),
"customer_id": raw.get("customer_id"),
"site": row.get("site_name"),
"site_client_name": site_client_value or row.get("site_name") or row.get("customer_name"),
"device": row.get("device_name"),
"service": row.get("service_name"),
"ip": ip_value,
"source_link": raw_source_link,
"device_link": raw_source_link,
"message": row.get("message"),
"started": started,
"updated": updated,
"resolved": resolved,
"last_seen": last_seen,
"duration_minutes": duration_minutes,
"raw_json": row.get("raw_json"),
"ticket_id": row.get("ticket_id"),
"created_at": row.get("created_at"),
"updated_at": row.get("updated_at"),
"tags": row.get("tags") or [],
"history": row.get("history") or [],
}
def _ensure_schema() -> None:
execute_query(
"""
CREATE TABLE IF NOT EXISTS drift_sources (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
connector_type VARCHAR(50) NOT NULL,
config_json JSONB DEFAULT '{}'::jsonb,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
)
execute_query(
"""
CREATE TABLE IF NOT EXISTS drift_devices (
id SERIAL PRIMARY KEY,
source_id INTEGER REFERENCES drift_sources(id) ON DELETE SET NULL,
external_id VARCHAR(200),
name VARCHAR(200),
customer_name VARCHAR(200),
site_name VARCHAR(200),
service_name VARCHAR(200),
metadata_json JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (source_id, external_id)
)
"""
)
execute_query(
"""
CREATE TABLE IF NOT EXISTS drift_events (
id SERIAL PRIMARY KEY,
source_id INTEGER REFERENCES drift_sources(id) ON DELETE SET NULL,
source_event_id VARCHAR(200),
severity VARCHAR(50) NOT NULL DEFAULT 'info',
status VARCHAR(50) NOT NULL DEFAULT 'new',
customer_name VARCHAR(200),
site_name VARCHAR(200),
device_name VARCHAR(200),
service_name VARCHAR(200),
message TEXT,
started_at TIMESTAMP,
resolved_at TIMESTAMP,
duration_minutes INTEGER,
raw_json JSONB DEFAULT '{}'::jsonb,
tags JSONB DEFAULT '[]'::jsonb,
ticket_id INTEGER,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
)
execute_query(
"""
CREATE TABLE IF NOT EXISTS drift_event_history (
id SERIAL PRIMARY KEY,
event_id INTEGER REFERENCES drift_events(id) ON DELETE CASCADE,
status VARCHAR(50) NOT NULL,
message TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
)
execute_query(
"""
CREATE TABLE IF NOT EXISTS drift_ticket_links (
id SERIAL PRIMARY KEY,
event_id INTEGER REFERENCES drift_events(id) ON DELETE CASCADE,
sag_id INTEGER NOT NULL,
link_type VARCHAR(50) NOT NULL DEFAULT 'created',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (event_id, sag_id)
)
"""
)
execute_query(
"""
CREATE TABLE IF NOT EXISTS drift_customer_mappings (
id SERIAL PRIMARY KEY,
source_id INTEGER REFERENCES drift_sources(id) ON DELETE CASCADE,
monitor_key VARCHAR(255),
monitor_name VARCHAR(255),
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (source_id, monitor_key)
)
"""
)
def _get_setting_value(key: str, default: Optional[str] = None) -> Optional[str]:
row = execute_query_single("SELECT value FROM settings WHERE key = %s", (key,))
if not row:
return default
value = row.get("value")
if value is None:
return default
return str(value)
def _get_drift_connector_config() -> Dict[str, str]:
return {
"base_url": (_get_setting_value("drift_uptime_kuma_base_url") or "").strip(),
"api_key": (_get_setting_value("drift_uptime_kuma_api_key") or "").strip(),
}
def _get_uisp_connector_config() -> Dict[str, str]:
return {
"base_url": (_get_setting_value("drift_uisp_base_url") or "").strip(),
"api_token": (_get_setting_value("drift_uisp_api_token") or "").strip(),
}
def _parse_blacklist_value(raw_value: Optional[str]) -> List[str]:
if raw_value is None:
return []
raw_text = str(raw_value).strip()
if not raw_text:
return []
values: List[str] = []
try:
parsed = json.loads(raw_text)
if isinstance(parsed, list):
values = [str(item or "") for item in parsed]
elif isinstance(parsed, str):
values = [parsed]
except (TypeError, ValueError, json.JSONDecodeError):
values = [chunk for chunk in raw_text.replace(";", "\n").replace(",", "\n").splitlines()]
cleaned: List[str] = []
seen: Set[str] = set()
for value in values:
normalized = str(value or "").strip().lower()
if not normalized or normalized in seen:
continue
seen.add(normalized)
cleaned.append(normalized)
return cleaned
def _get_drift_device_blacklist() -> List[str]:
return _parse_blacklist_value(_get_setting_value("drift_device_blacklist", ""))
def _save_drift_device_blacklist(values: List[str]) -> None:
normalized = _parse_blacklist_value(json.dumps(values, ensure_ascii=False))
value_json = json.dumps(normalized, ensure_ascii=False)
execute_query(
"""
INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (key)
DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP
""",
(
"drift_device_blacklist",
value_json,
"integrations",
"JSON array of Drift device identifiers/names to ignore during sync",
"text",
False,
),
)
def _event_blacklist_candidates(row: Dict[str, Any]) -> List[str]:
raw_json = row.get("raw_json") if isinstance(row.get("raw_json"), dict) else {}
raw_item = raw_json.get("raw_item") if isinstance(raw_json.get("raw_item"), dict) else {}
identification = raw_item.get("identification") if isinstance(raw_item.get("identification"), dict) else {}
candidates = [
row.get("device_name"),
row.get("source_event_id"),
identification.get("id"),
identification.get("name"),
identification.get("hostname"),
identification.get("mac"),
]
values: List[str] = []
seen: set[str] = set()
for candidate in candidates:
normalized = str(candidate or "").strip().lower()
if not normalized or normalized in seen:
continue
if normalized.startswith("uisp-"):
seen.add(normalized[5:])
values.append(normalized[5:])
if normalized.startswith("kuma-"):
seen.add(normalized[5:])
values.append(normalized[5:])
seen.add(normalized)
values.append(normalized)
return values
def _event_is_blacklisted(payload: Dict[str, Any], blacklist: List[str]) -> bool:
if not blacklist:
return False
token_set: Set[str] = set()
def _add(value: Any) -> None:
normalized = str(value or "").strip().lower()
if not normalized:
return
token_set.add(normalized)
source_event_id = str(payload.get("source_event_id") or "").strip()
_add(source_event_id)
if source_event_id.startswith("kuma-"):
_add(source_event_id[5:])
if source_event_id.startswith("uisp-"):
_add(source_event_id[5:])
_add(payload.get("device"))
raw_json = payload.get("raw_json") if isinstance(payload.get("raw_json"), dict) else {}
labels = raw_json.get("labels") if isinstance(raw_json.get("labels"), dict) else {}
for key in ("monitor_name", "name", "monitor_id", "id", "monitor_hostname"):
_add(labels.get(key))
raw_item = raw_json.get("raw_item") if isinstance(raw_json.get("raw_item"), dict) else {}
identification = raw_item.get("identification") if isinstance(raw_item.get("identification"), dict) else {}
for key in ("id", "name", "hostname", "mac"):
_add(identification.get(key))
return any(item in token_set for item in blacklist)
def _filter_out_blacklisted_rows(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
blacklist = _get_drift_device_blacklist()
if not blacklist:
return rows
filtered: List[Dict[str, Any]] = []
blacklist_set = set(blacklist)
for row in rows:
candidates = set(_event_blacklist_candidates(row))
if any(item in candidates for item in blacklist_set):
continue
filtered.append(row)
return filtered
def _is_uptime_kuma_configured() -> bool:
cfg = _get_drift_connector_config()
return bool(cfg.get("base_url") and cfg.get("api_key"))
def _ensure_source(connector_type: str = "uptime-kuma") -> Dict[str, Any]:
config = _get_drift_connector_config() if connector_type == "uptime-kuma" else _get_uisp_connector_config()
row = execute_query_single("SELECT id, name, config_json FROM drift_sources WHERE connector_type = %s LIMIT 1", (connector_type,))
if row:
current_config = row.get("config_json") or {}
if str(current_config.get("base_url") or "") != config.get("base_url", "") or str(current_config.get("api_key") or current_config.get("api_token") or "") != config.get("api_key") or str(current_config.get("api_token") or "") != config.get("api_token", ""):
execute_query(
"UPDATE drift_sources SET config_json = %s::jsonb, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
(json.dumps(config, ensure_ascii=False), row.get("id")),
)
return {"id": row.get("id"), "name": row.get("name") or ("UISP" if connector_type == "uisp" else "Uptime Kuma")}
result = execute_query(
"""
INSERT INTO drift_sources (name, connector_type, config_json, enabled)
VALUES (%s, %s, %s::jsonb, %s)
RETURNING id, name
""",
(("UISP" if connector_type == "uisp" else "Uptime Kuma"), connector_type, json.dumps(config, ensure_ascii=False), True),
)
return result[0] if result else {"id": None, "name": "UISP" if connector_type == "uisp" else "Uptime Kuma"}
def _upsert_event_from_payload(source_id: Optional[int], payload: Dict[str, Any]) -> Dict[str, Any]:
def _parse_dt(value: Any) -> Optional[datetime]:
if isinstance(value, datetime):
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
raw = str(value or "").strip()
if not raw:
return None
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
except ValueError:
return None
def _duration_from_times(started_value: Any, end_value: Any) -> Optional[int]:
started_dt = _parse_dt(started_value)
end_dt = _parse_dt(end_value) or datetime.now(timezone.utc)
if not started_dt:
return None
minutes = int((end_dt - started_dt).total_seconds() // 60)
return max(minutes, 0)
existing = execute_query_single(
"""
SELECT id, started_at, status FROM drift_events
WHERE source_id = %s AND source_event_id = %s
LIMIT 1
""",
(source_id, payload.get("source_event_id")),
)
now = payload.get("updated") or payload.get("started") or datetime.now(timezone.utc).isoformat()
row = None
incoming_status = str(payload.get("status") or "").strip().lower()
incoming_started = payload.get("started")
incoming_resolved = payload.get("resolved")
started_for_db = incoming_started
if existing:
existing_status = str(existing.get("status") or "").strip().lower()
if incoming_status == "active" and existing_status == "active" and existing.get("started_at"):
started_for_db = existing.get("started_at")
duration_value = payload.get("duration_minutes")
if duration_value is None:
duration_value = _duration_from_times(started_for_db, incoming_resolved or now)
if existing:
execute_query(
"""
UPDATE drift_events
SET severity = %s,
status = %s,
customer_name = %s,
site_name = %s,
device_name = %s,
service_name = %s,
message = %s,
started_at = %s,
updated_at = CURRENT_TIMESTAMP,
resolved_at = %s,
duration_minutes = %s,
raw_json = %s::jsonb,
tags = %s::jsonb
WHERE id = %s
""",
(
payload.get("severity", "info"),
payload.get("status", "new"),
payload.get("customer"),
payload.get("site"),
payload.get("device"),
payload.get("service"),
payload.get("message"),
started_for_db,
incoming_resolved,
duration_value,
json.dumps(payload.get("raw_json") or payload, ensure_ascii=False),
json.dumps(payload.get("tags") or [], ensure_ascii=False),
existing["id"],
),
)
row = execute_query_single("SELECT * FROM drift_events WHERE id = %s", (existing["id"],))
else:
row = execute_query_single(
"""
INSERT INTO drift_events (
source_id, source_event_id, severity, status, customer_name, site_name, device_name, service_name,
message, started_at, updated_at, resolved_at, duration_minutes, raw_json, tags
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s::jsonb)
RETURNING *
""",
(
source_id,
payload.get("source_event_id"),
payload.get("severity", "info"),
payload.get("status", "new"),
payload.get("customer"),
payload.get("site"),
payload.get("device"),
payload.get("service"),
payload.get("message"),
started_for_db,
now,
incoming_resolved,
duration_value,
json.dumps(payload.get("raw_json") or payload, ensure_ascii=False),
json.dumps(payload.get("tags") or [], ensure_ascii=False),
),
)
return row or {}
def _purge_demo_events() -> None:
# Remove legacy demo/sample rows created by earlier drift versions.
execute_query(
"""
DELETE FROM drift_events
WHERE source_event_id LIKE 'demo-%'
OR source_event_id LIKE 'sample-%'
OR COALESCE(raw_json->>'mode', '') = 'demo'
OR (tags::text ILIKE '%"demo"%')
"""
)
def _fetch_json_from_candidates(base_url: str, token: str, candidates: List[str]) -> Any:
normalized = base_url.rstrip('/')
headers = {"Accept": "application/json"}
if token:
headers.update({
"Authorization": f"Bearer {token}",
"X-Auth-Token": token,
"X-API-Key": token,
})
for candidate in candidates:
if candidate.startswith("http"):
url = candidate
else:
url = f"{normalized}/{candidate.lstrip('/')}"
try:
response = httpx.get(url, headers=headers, timeout=15.0, follow_redirects=True)
if response.status_code == 404:
continue
response.raise_for_status()
payload = response.json()
return payload
except (httpx.HTTPError, ValueError, TypeError):
continue
return []
def _fetch_uisp_device_detail(base_url: str, token: str, external_id: str) -> Any:
"""Fetch interface telemetry for one linked UISP device."""
return _fetch_json_from_candidates(
base_url,
token,
[
f"nms/api/v2.1/devices/{external_id}/detail",
f"nms/api/v2/devices/{external_id}/detail",
],
)
def _parse_prometheus_labels(raw: str) -> Dict[str, str]:
labels: Dict[str, str] = {}
if not raw.strip():
return labels
for part in raw.split(','):
if '=' not in part:
continue
key, value = part.split('=', 1)
key = key.strip()
value = value.strip().strip('"')
labels[key] = value
return labels
def _parse_prometheus_line(line: str) -> Optional[Dict[str, Any]]:
line = line.strip()
if not line or line.startswith('#') or ' ' not in line:
return None
left, raw_value = line.split(None, 1)
left = left.strip()
raw_value = raw_value.strip()
labels: Dict[str, str] = {}
metric_name = left
if '{' in left and left.endswith('}'):
metric_name = left[: left.index('{')]
label_str = left[left.index('{') + 1 : -1]
labels = _parse_prometheus_labels(label_str)
try:
value = float(raw_value)
except ValueError:
return None
return {
"name": metric_name,
"labels": labels,
"value": value,
}
def _monitor_key(labels: Dict[str, str]) -> str:
return (
labels.get("monitor_id")
or labels.get("id")
or labels.get("monitor_name")
or labels.get("name")
or labels.get("monitor_url")
or "unknown"
)
def _fetch_uptime_kuma_metrics(base_url: str, api_key: str) -> str:
metrics_url = f"{base_url.rstrip('/')}/metrics"
with httpx.Client(timeout=15.0, follow_redirects=True) as client:
response = client.get(metrics_url, auth=("", api_key))
response.raise_for_status()
return response.text
_customer_column_cache: Dict[str, bool] = {}
def _customers_column_exists(column_name: str) -> bool:
if column_name in _customer_column_cache:
return _customer_column_cache[column_name]
result = execute_query_single(
"""
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'customers'
AND column_name = %s
LIMIT 1
""",
(column_name,),
)
exists = bool(result)
_customer_column_cache[column_name] = exists
return exists
def _safe_host_from_url(value: str) -> str:
raw = str(value or "").strip()
if not raw or raw.lower() == "null":
return ""
candidate = raw if "://" in raw else f"https://{raw}"
try:
host = (urlparse(candidate).hostname or "").strip().lower()
except Exception:
host = ""
return host
def _extract_email_domain(value: str) -> str:
raw = str(value or "").strip().lower()
if "@" not in raw:
return ""
return raw.split("@", 1)[1].strip()
def _load_customer_match_index() -> List[Dict[str, Any]]:
select_parts = [
"c.id",
"c.name",
"COALESCE(NULLIF(TRIM(c.email), ''), '') AS email_value",
]
if _customers_column_exists("email_domain"):
select_parts.append("COALESCE(NULLIF(TRIM(c.email_domain), ''), '') AS email_domain_value")
else:
select_parts.append("'' AS email_domain_value")
if _customers_column_exists("website"):
select_parts.append("COALESCE(NULLIF(TRIM(c.website), ''), '') AS website_value")
else:
select_parts.append("'' AS website_value")
query = """
SELECT {select_parts}
FROM customers c
WHERE c.deleted_at IS NULL
ORDER BY c.name ASC
""".format(select_parts=", ".join(select_parts))
rows = execute_query(query) or []
result: List[Dict[str, Any]] = []
for row in rows:
raw_domains = [
row.get("email_domain_value"),
row.get("website_value"),
row.get("email_value"),
]
domains: List[str] = []
for entry in raw_domains:
as_host = _safe_host_from_url(str(entry or ""))
if as_host:
domains.append(as_host)
else:
email_domain = _extract_email_domain(str(entry or ""))
if email_domain:
domains.append(email_domain)
result.append(
{
"id": row.get("id"),
"name": row.get("name") or "",
"domains": sorted(set(d for d in domains if d)),
}
)
return result
def _resolve_customer_for_monitor(labels: Dict[str, str], monitor_name: str, customers: List[Dict[str, Any]]) -> str:
host_candidates = [
_safe_host_from_url(labels.get("monitor_hostname") or ""),
_safe_host_from_url(labels.get("monitor_url") or ""),
]
host_candidates = [h for h in host_candidates if h]
best_name = ""
best_score = -1
for customer in customers:
cname = str(customer.get("name") or "")
for domain in customer.get("domains") or []:
for host in host_candidates:
if host == domain or host.endswith(f".{domain}"):
score = len(domain)
if score > best_score:
best_score = score
best_name = cname
if best_name:
return best_name
monitor_name_lower = str(monitor_name or "").strip().lower()
if monitor_name_lower:
for customer in customers:
cname = str(customer.get("name") or "").strip()
ckey = cname.lower()
if len(ckey) >= 4 and ckey in monitor_name_lower:
return cname
return "Uptime Kuma"
def _build_source_link(labels: Dict[str, str]) -> Optional[str]:
monitor_url = str(labels.get("monitor_url") or labels.get("url") or "").strip()
if monitor_url and _safe_host_from_url(monitor_url):
return monitor_url if "://" in monitor_url else f"https://{monitor_url}"
monitor_hostname = str(labels.get("monitor_hostname") or "").strip()
if monitor_hostname and monitor_hostname.lower() != "null":
return f"https://{monitor_hostname}"
return None
def _load_manual_customer_mappings(source_id: Optional[int]) -> Dict[str, Dict[str, Any]]:
if not source_id:
return {"by_key": {}, "by_name": {}}
rows = execute_query(
"""
SELECT
m.monitor_key,
m.monitor_name,
m.customer_id,
c.name AS customer_name
FROM drift_customer_mappings m
JOIN customers c ON c.id = m.customer_id
WHERE m.source_id = %s
AND c.deleted_at IS NULL
""",
(source_id,),
) or []
by_key: Dict[str, Dict[str, Any]] = {}
by_name: Dict[str, Dict[str, Any]] = {}
for row in rows:
monitor_key = str(row.get("monitor_key") or "").strip().lower()
monitor_name = str(row.get("monitor_name") or "").strip().lower()
info = {
"customer_id": row.get("customer_id"),
"customer_name": row.get("customer_name") or "",
}
if monitor_key:
by_key[monitor_key] = info
if monitor_name:
by_name[monitor_name] = info
return {"by_key": by_key, "by_name": by_name}
def _build_events_from_metrics(metrics_text: str, source_id: Optional[int]) -> List[Dict[str, Any]]:
parsed = [_parse_prometheus_line(line) for line in metrics_text.splitlines()]
parsed = [entry for entry in parsed if entry is not None]
monitors: Dict[str, Dict[str, Any]] = {}
for entry in parsed:
name = str(entry.get("name") or "")
labels = entry.get("labels") or {}
value = entry.get("value")
key = _monitor_key(labels)
monitor = monitors.setdefault(key, {"labels": labels})
if name == "monitor_status":
monitor["status_value"] = value
elif name == "monitor_response_time":
monitor["response_time"] = value
now_iso = datetime.now(timezone.utc).isoformat()
events: List[Dict[str, Any]] = []
customers = _load_customer_match_index()
manual_mappings = _load_manual_customer_mappings(source_id)
for key, monitor in monitors.items():
labels = monitor.get("labels") or {}
status_value = monitor.get("status_value")
if status_value is None:
continue
is_up = float(status_value) >= 1.0
status = "resolved" if is_up else "active"
severity = "info" if is_up else "critical"
monitor_name = labels.get("monitor_name") or labels.get("name") or key
monitor_url = labels.get("monitor_url") or labels.get("url") or ""
source_link = _build_source_link(labels)
monitor_key_lower = str(key or "").strip().lower()
monitor_name_lower = str(monitor_name or "").strip().lower()
manual = manual_mappings["by_key"].get(monitor_key_lower) or manual_mappings["by_name"].get(monitor_name_lower)
if manual:
customer_name = str(manual.get("customer_name") or "Uptime Kuma")
customer_id = manual.get("customer_id")
else:
customer_name = _resolve_customer_for_monitor(labels, monitor_name, customers)
customer_id = None
response_time = monitor.get("response_time")
response_text = ""
if response_time is not None:
response_text = f" Response: {int(float(response_time))} ms."
events.append(
{
"source_event_id": f"kuma-{key}",
"severity": severity,
"status": status,
"customer": customer_name,
"site": "Monitoring",
"device": monitor_name,
"service": monitor_url or "HTTP",
"message": f"Monitor '{monitor_name}' is {'UP' if is_up else 'DOWN'}.{response_text}".strip(),
"started": now_iso,
"updated": now_iso,
"resolved": now_iso if is_up else None,
"duration_minutes": None,
"tags": ["uptime-kuma", "metrics"],
"raw_json": {
"labels": labels,
"status_value": status_value,
"response_time": response_time,
"source_link": source_link,
"customer_id": customer_id,
},
}
)
return events
def _parse_uisp_payload(payload: Any) -> List[Dict[str, Any]]:
if isinstance(payload, list):
return [item for item in payload if isinstance(item, dict)]
if isinstance(payload, dict):
for key in ("data", "items", "devices", "results", "sites"):
value = payload.get(key)
if isinstance(value, list):
return [item for item in value if isinstance(item, dict)]
if isinstance(value, dict):
return [value]
return [payload]
return []
def _uisp_device_record(item: Dict[str, Any], base_url: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Normalize the useful UISP fields while retaining the complete source payload."""
identification = item.get("identification") if isinstance(item.get("identification"), dict) else {}
overview = item.get("overview") if isinstance(item.get("overview"), dict) else {}
external_id = identification.get("id") or item.get("id") or item.get("device_id")
if not external_id:
return None
def text(*values: Any) -> Optional[str]:
for value in values:
value = str(value or "").strip()
if value:
return value
return None
ips: List[str] = []
for value in (item.get("ipAddress"), item.get("ip"), identification.get("ipAddress"), overview.get("ipAddress")):
value = str(value or "").strip()
if value and value not in ips:
ips.append(value)
for key in ("ipAddressList", "ipv6AddressList", "ipv6LinkLocalList"):
for value in item.get(key) or []:
value = str(value or "").strip()
if value and value not in ips:
ips.append(value)
last_seen = overview.get("lastSeen")
last_seen_dt = None
if last_seen:
try:
last_seen_dt = datetime.fromisoformat(str(last_seen).replace("Z", "+00:00"))
except ValueError:
pass
return {
"external_id": str(external_id),
"name": text(identification.get("name"), identification.get("displayName"), item.get("name")),
"display_name": text(identification.get("displayName"), identification.get("name")),
"hostname": text(identification.get("hostname"), identification.get("systemName")),
"mac_address": text(identification.get("mac"), item.get("mac")),
"serial_number": text(identification.get("serialNumber"), item.get("serialNumber")),
"vendor": text(identification.get("vendorName"), identification.get("vendor")),
"model": text(identification.get("modelName"), identification.get("model")),
"platform": text(identification.get("platformName"), identification.get("platformId")),
"device_type": text(identification.get("type"), identification.get("category")),
"device_role": text(identification.get("role")),
"ip_addresses": ips,
"status": text(overview.get("status"), identification.get("status"), item.get("status")),
"last_seen": last_seen_dt,
"device_link": _extract_device_link(item, base_url, str(external_id)),
"raw_json": item,
}
def _upsert_uisp_devices(payload: Any, base_url: Optional[str] = None) -> int:
"""Cache UISP devices and enrich every hardware asset explicitly linked to one."""
count = 0
for item in _parse_uisp_payload(payload):
device = _uisp_device_record(item, base_url)
if not device:
continue
rows = execute_query(
"""INSERT INTO uisp_devices
(external_id, name, display_name, hostname, mac_address, serial_number, vendor, model,
platform, device_type, device_role, ip_addresses, status, last_seen, device_link, raw_json, synced_at, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
ON CONFLICT (external_id) DO UPDATE SET
name = EXCLUDED.name, display_name = EXCLUDED.display_name, hostname = EXCLUDED.hostname,
mac_address = EXCLUDED.mac_address, serial_number = EXCLUDED.serial_number, vendor = EXCLUDED.vendor,
model = EXCLUDED.model, platform = EXCLUDED.platform, device_type = EXCLUDED.device_type,
device_role = EXCLUDED.device_role, ip_addresses = EXCLUDED.ip_addresses, status = EXCLUDED.status,
last_seen = EXCLUDED.last_seen, device_link = EXCLUDED.device_link, raw_json = EXCLUDED.raw_json,
synced_at = NOW(), updated_at = NOW()
RETURNING id""",
(
device["external_id"], device["name"], device["display_name"], device["hostname"], device["mac_address"],
device["serial_number"], device["vendor"], device["model"], device["platform"], device["device_type"],
device["device_role"], Json(device["ip_addresses"]), device["status"], device["last_seen"], device["device_link"], Json(device["raw_json"]),
),
) or []
if not rows:
continue
device_id = rows[0]["id"]
overview = item.get("overview") if isinstance(item.get("overview"), dict) else {}
uisp_specs = {
"uisp_device_id": device["external_id"], "name": device["name"], "hostname": device["hostname"],
"mac_address": device["mac_address"], "ip_addresses": device["ip_addresses"], "platform": device["platform"],
"type": device["device_type"], "role": device["device_role"], "firmware": (item.get("firmware") or {}).get("version") if isinstance(item.get("firmware"), dict) else item.get("firmware"),
"status": device["status"], "last_seen": str(device["last_seen"] or ""),
"overview": overview,
}
execute_query(
"""UPDATE hardware_assets h
SET brand = COALESCE(NULLIF(%s, ''), h.brand),
model = COALESCE(NULLIF(%s, ''), h.model),
serial_number = COALESCE(NULLIF(%s, ''), h.serial_number),
hardware_specs = COALESCE(h.hardware_specs, '{}'::jsonb) || %s::jsonb,
updated_at = NOW()
FROM hardware_uisp_links link
WHERE link.hardware_id = h.id AND link.uisp_device_id = %s""",
(device["vendor"] or "", device["model"] or "", device["serial_number"] or "", Json({"uisp": uisp_specs}), device_id),
)
count += 1
return count
def _build_events_from_uisp_payload(payload: Any, source_id: Optional[int], base_url: Optional[str] = None) -> List[Dict[str, Any]]:
items = _parse_uisp_payload(payload)
events: List[Dict[str, Any]] = []
for item in items:
identification = item.get("identification") if isinstance(item, dict) else {}
identification = identification if isinstance(identification, dict) else {}
overview = item.get("overview") if isinstance(item, dict) else {}
overview = overview if isinstance(overview, dict) else {}
site_obj = identification.get("site") if isinstance(identification, dict) else {}
site_obj = site_obj if isinstance(site_obj, dict) else {}
external_id = (
identification.get("id")
or item.get("id")
or item.get("device_id")
or item.get("mac")
or identification.get("name")
or item.get("name")
or "uisp-device"
)
name = (
identification.get("name")
or item.get("name")
or item.get("hostname")
or item.get("device_name")
or str(external_id)
)
site = site_obj.get("name") or item.get("site_name") or item.get("site") or item.get("siteName") or "UISP"
ip_value = None
for candidate in (
item.get("ip"),
item.get("ip_address"),
item.get("ipAddress"),
identification.get("ip"),
identification.get("ip_address"),
identification.get("ipAddress"),
overview.get("ip"),
overview.get("ip_address"),
overview.get("ipAddress"),
item.get("overview", {}).get("ip") if isinstance(item.get("overview"), dict) else None,
item.get("overview", {}).get("ip_address") if isinstance(item.get("overview"), dict) else None,
item.get("overview", {}).get("ipAddress") if isinstance(item.get("overview"), dict) else None,
):
if candidate is None:
continue
value = str(candidate).strip()
if value:
ip_value = value
break
site_client_name = None
for candidate in (
item.get("site_client_name"),
item.get("client_name"),
item.get("clientName"),
item.get("client"),
identification.get("site_client_name"),
identification.get("client_name"),
identification.get("clientName"),
identification.get("client"),
site_obj.get("name"),
site,
):
if candidate is None:
continue
value = str(candidate).strip()
if value:
site_client_name = value
break
state_value = (
overview.get("status")
or identification.get("status")
or item.get("state")
or item.get("status")
or item.get("statusText")
or item.get("connectionState")
or "unknown"
)
state_text = str(state_value).strip().lower()
online = any(token in state_text for token in ("online", "connected", "up", "active", "ok")) and not any(
token in state_text for token in ("disconnected", "offline", "down", "inactive")
)
status = "resolved" if online else "active"
severity = "info" if online else "critical"
last_seen = overview.get("lastSeen")
now_dt = datetime.now(timezone.utc)
started_iso = now_dt.isoformat()
if not online and last_seen:
started_iso = str(last_seen)
resolved_iso = now_dt.isoformat() if online else None
duration_minutes: Optional[int] = None
if started_iso:
try:
started_dt = datetime.fromisoformat(str(started_iso).replace("Z", "+00:00"))
end_dt = datetime.fromisoformat(str(resolved_iso).replace("Z", "+00:00")) if resolved_iso else now_dt
duration_minutes = max(int((end_dt - started_dt).total_seconds() // 60), 0)
except ValueError:
duration_minutes = None
message = f"UISP device '{name}' is {'ONLINE' if online else 'OFFLINE'}"
device_link = _extract_device_link(item, base_url, external_id)
events.append(
{
"source_event_id": f"uisp-{external_id}",
"severity": severity,
"status": status,
"customer": "UISP",
"site": site,
"device": name,
"service": "UISP",
"message": message,
"started": started_iso,
"updated": now_dt.isoformat(),
"resolved": resolved_iso,
"duration_minutes": duration_minutes,
"tags": ["uisp", "monitoring"],
"raw_json": {
"source_link": device_link,
"device_link": device_link,
"state": state_value,
"overview_status": overview.get("status"),
"last_seen": last_seen,
"site": site,
"site_client_name": site_client_name or site,
"ip": ip_value,
"ip_address": ip_value,
"raw_item": item,
},
}
)
return events
def _run_uptime_kuma_sync_internal() -> Dict[str, Any]:
_ensure_schema()
_purge_demo_events()
source = _ensure_source("uptime-kuma")
config = _get_drift_connector_config()
base_url = config.get("base_url") or ""
api_key = config.get("api_key") or ""
if not base_url or not api_key:
return {
"synced": 0,
"source": source.get("name"),
"mode": "live",
"warning": "Uptime Kuma connector mangler base_url eller api_key.",
}
try:
metrics_text = _fetch_uptime_kuma_metrics(base_url, api_key)
events = _build_events_from_metrics(metrics_text, source.get("id"))
except httpx.HTTPError as exc:
logger.warning("⚠️ Drift Uptime Kuma sync failed: %s", exc)
return {
"synced": 0,
"source": source.get("name"),
"mode": "live",
"warning": str(exc),
}
if not events:
return {
"synced": 0,
"source": source.get("name"),
"mode": "live",
"warning": "Ingen monitor-metrics fundet i /metrics respons.",
}
blacklist = _get_drift_device_blacklist()
filtered_events = [event for event in events if not _event_is_blacklisted(event, blacklist)]
skipped = len(events) - len(filtered_events)
for item in filtered_events:
_upsert_event_from_payload(source.get("id"), item)
return {
"synced": len(filtered_events),
"source": source.get("name"),
"mode": "live",
"blacklisted_skipped": skipped,
}
def _run_uisp_sync_internal() -> Dict[str, Any]:
_ensure_schema()
_purge_demo_events()
source = _ensure_source("uisp")
config = _get_uisp_connector_config()
base_url = config.get("base_url") or ""
token = config.get("api_token") or ""
if not base_url or not token:
return {
"synced": 0,
"source": source.get("name"),
"mode": "live",
"warning": "UISP connector mangler base_url eller api_token.",
}
try:
payload = _fetch_json_from_candidates(
base_url,
token,
[
"nms/api/v2.1/devices",
"nms/api/v2.1/sites",
"nms/api/v2/devices",
"nms/api/v2/sites",
"api/v2.1/devices",
"api/v2/devices",
"api/v2.1/sites",
"api/v2/sites",
],
)
cached_devices = _upsert_uisp_devices(payload, base_url)
# The inventory endpoint does not contain switch interface telemetry. Fetch
# details only for explicitly linked hardware, keeping the 2-minute sync light.
linked_devices = execute_query(
"""SELECT d.external_id FROM hardware_uisp_links link
JOIN uisp_devices d ON d.id = link.uisp_device_id"""
) or []
detailed_devices = 0
for linked in linked_devices:
external_id = str(linked.get("external_id") or "").strip()
if not external_id:
continue
detail = _fetch_uisp_device_detail(base_url, token, external_id)
if isinstance(detail, dict) and detail:
detailed_devices += _upsert_uisp_devices([detail], base_url)
events = _build_events_from_uisp_payload(payload, source.get("id"), base_url)
except httpx.HTTPError as exc:
logger.warning("⚠️ Drift UISP sync failed: %s", exc)
return {
"synced": 0,
"source": source.get("name"),
"mode": "live",
"warning": str(exc),
}
if not events:
return {
"synced": 0,
"source": source.get("name"),
"mode": "live",
"warning": "Ingen UISP-enheder fundet i API-responsen.",
}
blacklist = _get_drift_device_blacklist()
filtered_events = [event for event in events if not _event_is_blacklisted(event, blacklist)]
skipped = len(events) - len(filtered_events)
for item in filtered_events:
_upsert_event_from_payload(source.get("id"), item)
return {
"synced": len(filtered_events),
"source": source.get("name"),
"mode": "live",
"blacklisted_skipped": skipped,
"cached_devices": cached_devices,
"detailed_devices": detailed_devices,
}
def run_uptime_kuma_sync() -> Dict[str, Any]:
kuma_result = _run_uptime_kuma_sync_internal()
uisp_result = _run_uisp_sync_internal()
return {
"synced": int(kuma_result.get("synced") or 0) + int(uisp_result.get("synced") or 0),
"source": "Uptime Kuma + UISP",
"mode": "live",
"connectors": {
"uptime_kuma": kuma_result,
"uisp": uisp_result,
},
}
@router.get("/drift/summary")
async def get_drift_summary():
_ensure_schema()
_purge_demo_events()
source = _ensure_source()
def _coerce_dt(value: Any) -> Optional[datetime]:
if isinstance(value, datetime):
return value
raw_value = str(value or "").strip()
if not raw_value:
return None
try:
return datetime.fromisoformat(raw_value.replace("Z", "+00:00"))
except ValueError:
return None
rows = execute_query(
"""
SELECT
id,
source_event_id,
status,
severity,
ticket_id,
resolved_at,
updated_at,
device_name,
raw_json
FROM drift_events
"""
) or []
filtered_rows = _filter_out_blacklisted_rows(rows)
active_statuses = {"active", "warning", "critical", "new"}
today = datetime.now(timezone.utc).date()
active_alerts = 0
critical = 0
warnings = 0
resolved_today = 0
unassigned = 0
uisp_total = 0
uisp_active = 0
uisp_last_sync: Optional[datetime] = None
for row in filtered_rows:
status_value = str(row.get("status") or "").strip().lower()
severity_value = str(row.get("severity") or "").strip().lower()
source_event_id = str(row.get("source_event_id") or "").strip().lower()
if status_value in active_statuses:
active_alerts += 1
if row.get("ticket_id") is None:
unassigned += 1
if severity_value == "critical":
critical += 1
if severity_value == "warning":
warnings += 1
resolved_at = _coerce_dt(row.get("resolved_at"))
if resolved_at and resolved_at.date() == today:
resolved_today += 1
if source_event_id.startswith("uisp-"):
uisp_total += 1
if status_value in active_statuses:
uisp_active += 1
updated_at = _coerce_dt(row.get("updated_at"))
if updated_at and (uisp_last_sync is None or updated_at > uisp_last_sync):
uisp_last_sync = updated_at
uisp_config = _get_uisp_connector_config()
return {
"source": source.get("name"),
"active_alerts": active_alerts,
"critical": critical,
"warnings": warnings,
"resolved_today": resolved_today,
"unassigned": unassigned,
"uisp": {
"configured": bool((uisp_config.get("base_url") or "").strip() and (uisp_config.get("api_token") or "").strip()),
"total_events": uisp_total,
"active_events": uisp_active,
"last_sync": uisp_last_sync,
},
}
@router.get("/drift/events")
async def list_events(
status: Optional[str] = Query(None),
severity: Optional[str] = Query(None),
source: Optional[str] = Query(None),
customer: Optional[str] = Query(None),
device: Optional[str] = Query(None),
limit: int = Query(default=100, ge=1, le=200),
):
_ensure_schema()
_purge_demo_events()
query = """
SELECT
e.*,
src.name AS source_name,
COALESCE(c.name, e.customer_name) AS customer_name,
COALESCE(s.name, e.site_name) AS site_name,
COALESCE(d.name, e.device_name) AS device_name,
COALESCE(svc.name, e.service_name) AS service_name
FROM drift_events e
LEFT JOIN drift_sources src ON src.id = e.source_id
LEFT JOIN drift_devices d ON d.source_id = e.source_id AND d.external_id = e.device_name
LEFT JOIN drift_devices c ON c.source_id = e.source_id AND c.external_id = e.customer_name
LEFT JOIN drift_devices s ON s.source_id = e.source_id AND s.external_id = e.site_name
LEFT JOIN drift_devices svc ON svc.source_id = e.source_id AND svc.external_id = e.service_name
WHERE 1=1
"""
params: List[Any] = []
filters = []
if status:
filters.append("e.status = %s")
params.append(status)
if severity:
filters.append("e.severity = %s")
params.append(severity)
if source:
filters.append("e.source_id IN (SELECT id FROM drift_sources WHERE LOWER(name) LIKE %s)")
params.append(f"%{source.lower()}%")
if customer:
filters.append("LOWER(COALESCE(e.customer_name, '')) LIKE %s")
params.append(f"%{customer.lower()}%")
if device:
filters.append("LOWER(COALESCE(e.device_name, '')) LIKE %s")
params.append(f"%{device.lower()}%")
if filters:
query += " AND " + " AND ".join(filters)
query += " ORDER BY e.started_at DESC NULLS LAST, e.id DESC LIMIT %s"
params.append(limit)
rows = execute_query(query, tuple(params)) or []
rows = _filter_out_blacklisted_rows(rows)
return [_row_to_event(row) for row in rows]
@router.get("/drift/events/{event_id:int}")
async def get_event(event_id: int):
_ensure_schema()
row = execute_query_single("SELECT * FROM drift_events WHERE id = %s", (event_id,))
if not row:
raise HTTPException(status_code=404, detail="Event not found")
return _row_to_event(row)
@router.get("/drift/customer-mappings")
async def list_customer_mappings():
_ensure_schema()
rows = execute_query(
"""
SELECT
m.id,
m.source_id,
m.monitor_key,
m.monitor_name,
m.customer_id,
c.name AS customer_name,
m.updated_at
FROM drift_customer_mappings m
JOIN customers c ON c.id = m.customer_id
WHERE c.deleted_at IS NULL
ORDER BY m.updated_at DESC
"""
) or []
return rows
@router.get("/drift/customers-lite")
async def list_customers_lite():
rows = execute_query(
"""
SELECT id, name
FROM customers
WHERE deleted_at IS NULL
ORDER BY name ASC
"""
) or []
return rows
@router.get("/drift/unmapped-monitors")
async def list_unmapped_monitors(limit: int = Query(default=200, ge=1, le=1000)):
_ensure_schema()
rows = execute_query(
"""
SELECT
REPLACE(e.source_event_id, 'kuma-', '') AS monitor_key,
COALESCE(NULLIF(TRIM(e.device_name), ''), REPLACE(e.source_event_id, 'kuma-', '')) AS monitor_name,
COALESCE(e.raw_json->'labels'->>'monitor_url', e.raw_json->'labels'->>'url', '') AS monitor_url,
COALESCE(e.raw_json->'labels'->>'monitor_hostname', '') AS monitor_hostname,
COALESCE(e.customer_name, '') AS inferred_customer
FROM drift_events e
WHERE e.source_event_id LIKE %s
AND NOT EXISTS (
SELECT 1
FROM drift_customer_mappings m
WHERE m.source_id = e.source_id
AND LOWER(COALESCE(m.monitor_key, '')) = LOWER(REPLACE(e.source_event_id, 'kuma-', ''))
)
ORDER BY COALESCE(e.started_at, e.created_at) DESC NULLS LAST, e.id DESC
LIMIT %s
""",
("kuma-%", limit),
) or []
seen: Dict[str, bool] = {}
unique_rows: List[Dict[str, Any]] = []
for row in rows:
key = str(row.get("monitor_key") or "").strip().lower()
if not key or key in seen:
continue
seen[key] = True
unique_rows.append(row)
return unique_rows
def _customer_drift_filter_sql() -> str:
return """
(
COALESCE(e.raw_json->>'customer_id', '') = %s
OR LOWER(COALESCE(e.customer_name, '')) = LOWER(%s)
)
"""
@router.get("/drift/customers/{customer_id:int}/summary")
async def get_customer_drift_summary(customer_id: int):
_ensure_schema()
customer = execute_query_single(
"SELECT id, name FROM customers WHERE id = %s AND deleted_at IS NULL",
(customer_id,),
)
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
customer_name = str(customer.get("name") or "")
cid_str = str(customer_id)
where_sql = _customer_drift_filter_sql()
active = execute_query_single(
f"SELECT COUNT(*) AS count FROM drift_events e WHERE {where_sql} AND LOWER(COALESCE(e.status, '')) IN ('active','warning','critical','new')",
(cid_str, customer_name),
) or {}
resolved = execute_query_single(
f"SELECT COUNT(*) AS count FROM drift_events e WHERE {where_sql} AND LOWER(COALESCE(e.status, '')) = 'resolved'",
(cid_str, customer_name),
) or {}
acknowledged = execute_query_single(
f"SELECT COUNT(*) AS count FROM drift_events e WHERE {where_sql} AND LOWER(COALESCE(e.status, '')) = 'acknowledged'",
(cid_str, customer_name),
) or {}
critical = execute_query_single(
f"SELECT COUNT(*) AS count FROM drift_events e WHERE {where_sql} AND LOWER(COALESCE(e.severity, '')) = 'critical'",
(cid_str, customer_name),
) or {}
def _to_int(row: Dict[str, Any]) -> int:
return int(row.get("count") or 0)
return {
"customer_id": customer_id,
"customer_name": customer_name,
"active": _to_int(active),
"resolved": _to_int(resolved),
"acknowledged": _to_int(acknowledged),
"critical": _to_int(critical),
}
@router.get("/drift/customers/{customer_id:int}/events")
async def list_customer_drift_events(customer_id: int, limit: int = Query(default=50, ge=1, le=500)):
_ensure_schema()
customer = execute_query_single(
"SELECT id, name FROM customers WHERE id = %s AND deleted_at IS NULL",
(customer_id,),
)
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
customer_name = str(customer.get("name") or "")
cid_str = str(customer_id)
where_sql = _customer_drift_filter_sql()
rows = execute_query(
f"""
SELECT e.*, src.name AS source_name
FROM drift_events e
LEFT JOIN drift_sources src ON src.id = e.source_id
WHERE {where_sql}
ORDER BY COALESCE(e.started_at, e.created_at) DESC NULLS LAST, e.id DESC
LIMIT %s
""",
(cid_str, customer_name, limit),
) or []
rows = _filter_out_blacklisted_rows(rows)
return [_row_to_event(row) for row in rows]
@router.put("/drift/customer-mappings")
async def upsert_customer_mapping(payload: DriftCustomerMappingPayload):
_ensure_schema()
monitor_key = str(payload.monitor_key or "").strip()
monitor_name = str(payload.monitor_name or "").strip()
requested_source = str(payload.source or "").strip().lower()
if requested_source in {"uisp", "uptime-kuma", "uptime_kuma", "kuma"}:
connector_type = "uisp" if requested_source == "uisp" else "uptime-kuma"
elif monitor_key.startswith("uisp-") or monitor_name.startswith("uisp-"):
connector_type = "uisp"
else:
connector_type = "uptime-kuma"
source = _ensure_source(connector_type)
source_id = source.get("id")
if not source_id:
raise HTTPException(status_code=500, detail="Drift source kunne ikke initialiseres")
if not monitor_key and not monitor_name:
raise HTTPException(status_code=400, detail="monitor_key eller monitor_name er paakraevet")
customer = execute_query_single(
"SELECT id, name FROM customers WHERE id = %s AND deleted_at IS NULL",
(payload.customer_id,),
)
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
result = execute_query(
"""
INSERT INTO drift_customer_mappings (source_id, monitor_key, monitor_name, customer_id)
VALUES (%s, %s, %s, %s)
ON CONFLICT (source_id, monitor_key)
DO UPDATE SET
monitor_name = EXCLUDED.monitor_name,
customer_id = EXCLUDED.customer_id,
updated_at = CURRENT_TIMESTAMP
RETURNING id, source_id, monitor_key, monitor_name, customer_id, updated_at
""",
(
source_id,
monitor_key or monitor_name,
monitor_name or monitor_key,
payload.customer_id,
),
)
if not result:
raise HTTPException(status_code=500, detail="Failed to save mapping")
# Apply the mapping to existing events right away so the UI reflects the new customer
# immediately without waiting for the next sync cycle.
mapped_key = monitor_key or monitor_name
mapped_name = monitor_name or monitor_key
execute_query(
"""
UPDATE drift_events
SET customer_name = %s,
raw_json = jsonb_set(COALESCE(raw_json, '{}'::jsonb), '{customer_id}', to_jsonb(%s::int), true),
updated_at = CURRENT_TIMESTAMP
WHERE source_id = %s
AND (
LOWER(REPLACE(COALESCE(source_event_id, ''), 'kuma-', '')) = LOWER(%s)
OR LOWER(COALESCE(device_name, '')) = LOWER(%s)
)
""",
(
customer.get("name"),
int(payload.customer_id),
source_id,
mapped_key,
mapped_name,
),
)
row = result[0]
row["customer_name"] = customer.get("name")
return row
@router.post("/drift/events/{event_id:int}/ticket")
async def create_ticket_for_event(event_id: int, payload: DriftTicketPayload):
_ensure_schema()
event = execute_query_single("SELECT * FROM drift_events WHERE id = %s", (event_id,))
if not event:
raise HTTPException(status_code=404, detail="Event not found")
title = payload.title or f"Drift: {event.get('device_name') or 'Ukendt enhed'}"
description = payload.description or (
f"Kilde: {event.get('source_name') or 'Uptime Kuma'}\n"
f"Device: {event.get('device_name') or '-'}\n"
f"Status: {event.get('status') or '-'}\n"
f"Start: {event.get('started_at') or '-'}\n"
f"Automatisk oprettet fra Drift."
)
created = execute_query(
"""
INSERT INTO sag_sager (titel, beskrivelse, status, created_by_user_id, customer_id)
VALUES (%s, %s, %s, %s, %s)
RETURNING id, titel, beskrivelse, status
""",
(title, description, "open", 1, None),
)
if not created:
raise HTTPException(status_code=500, detail="Failed to create case")
sag = created[0]
execute_query("UPDATE drift_events SET ticket_id = %s WHERE id = %s", (int(sag["id"]), event_id))
execute_query(
"INSERT INTO drift_ticket_links (event_id, sag_id, link_type) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
(event_id, int(sag["id"]), "created"),
)
return {"sag_id": int(sag["id"]), "title": title, "description": description}
@router.post("/drift/events/{event_id:int}/ticket/link")
async def link_existing_ticket(event_id: int, payload: DriftTicketLinkPayload):
_ensure_schema()
event = execute_query_single("SELECT * FROM drift_events WHERE id = %s", (event_id,))
if not event:
raise HTTPException(status_code=404, detail="Event not found")
execute_query("UPDATE drift_events SET ticket_id = %s WHERE id = %s", (payload.sag_id, event_id))
execute_query(
"INSERT INTO drift_ticket_links (event_id, sag_id, link_type) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
(event_id, payload.sag_id, "linked"),
)
return {"sag_id": payload.sag_id}
@router.post("/drift/events/{event_id:int}/acknowledge")
async def acknowledge_event(event_id: int):
_ensure_schema()
event = execute_query_single("SELECT id, status FROM drift_events WHERE id = %s", (event_id,))
if not event:
raise HTTPException(status_code=404, detail="Event not found")
current_status = str(event.get("status") or "").strip().lower()
if current_status in {"resolved", "acknowledged"}:
return {"id": event_id, "status": current_status}
execute_query(
"""
UPDATE drift_events
SET status = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
("acknowledged", event_id),
)
execute_query(
"""
INSERT INTO drift_event_history (event_id, status, message)
VALUES (%s, %s, %s)
""",
(event_id, "acknowledged", "Alarm godkendt af tekniker"),
)
return {"id": event_id, "status": "acknowledged"}
@router.post("/drift/events/{event_id:int}/blacklist")
async def blacklist_event(event_id: int, payload: Optional[DriftBlacklistPayload] = None):
_ensure_schema()
event = execute_query_single("SELECT * FROM drift_events WHERE id = %s", (event_id,))
if not event:
raise HTTPException(status_code=404, detail="Event not found")
current = _get_drift_device_blacklist()
explicit = str((payload.value if payload else "") or "").strip().lower()
to_add = [explicit] if explicit else _event_blacklist_candidates(event)
merged = list(current)
for item in to_add:
if item and item not in merged:
merged.append(item)
_save_drift_device_blacklist(merged)
current_status = str(event.get("status") or "").strip().lower()
auto_acknowledged = False
if current_status in {"active", "warning", "critical", "new"}:
execute_query(
"""
UPDATE drift_events
SET status = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
("acknowledged", event_id),
)
execute_query(
"""
INSERT INTO drift_event_history (event_id, status, message)
VALUES (%s, %s, %s)
""",
(event_id, "acknowledged", "Alarm auto-godkendt via blacklist"),
)
auto_acknowledged = True
return {
"id": event_id,
"blacklisted": to_add,
"blacklist_size": len(merged),
"status": "acknowledged" if auto_acknowledged else current_status,
"auto_acknowledged": auto_acknowledged,
}
@router.post("/drift/sync/uptime-kuma")
async def sync_uptime_kuma(payload: DriftSyncPayload):
try:
result = run_uptime_kuma_sync()
except Exception as exc:
logger.error("❌ Drift sync failed: %s", exc)
raise HTTPException(status_code=502, detail=f"Drift sync failed: {exc}")
return result