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
1991 lines
71 KiB
Python
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
|