feat: Implement ESET integration for hardware management

- Added ESET sync functionality to periodically fetch devices and incidents.
- Created new ESET service for API interactions, including authentication and data retrieval.
- Introduced new database tables for storing ESET incidents and hardware contacts.
- Updated hardware assets schema to include ESET-specific fields (UUID, specs, group).
- Developed frontend templates for ESET overview, import, and testing.
- Enhanced existing hardware creation form to auto-generate AnyDesk links.
- Added global logout functionality to clear user session data.
- Improved error handling and logging for ESET API interactions.
This commit is contained in:
Christian 2026-02-11 13:23:32 +01:00
parent 3d7fb1aa48
commit 297a8ef2d6
21 changed files with 2013 additions and 714 deletions

View File

@ -1,253 +1,5 @@
# Implementeringsplan: Sag-modulet (Case Module)
## Oversigt Hvad er “Sag”?
**Sag-modulet** er hjertet i BMC Hubs relation- og proces-styringssystem.
I stedet for separate systemer for tickets, opgaver og ordrer findes der én universel entitet: **en Sag**.
### Kerneidéen (meget vigtig!)
> **Der er kun én ting: en Sag.**
> Tickets, opgaver og ordrer er blot sager med forskellige relationer, tags og moduler.
---
## Eksempler (samme datatype forskellige relationer)
1. **Kunde ringer og skal have ny skærm**
- Dette er en Sag
- Tags: `support`, `urgent`
- Ansvarlig: Support
- Status: åben
2. **Indkøb af skærm hos leverandør**
- Dette er også en Sag
- Tags: `indkøb`
- Relation: afledt fra kundesagen
- Ansvarlig: Indkøb
3. **Ompakning og afsendelse**
- Dette er også en Sag
- Tags: `ompakning`
- Relation: afledt fra indkøbssagen
- Ansvarlig: Lager
- Deadline: i dag
Alle tre er samme datatype i databasen.
Forskellen er udelukkende:
- hvilke tags sagen har
- hvilke relationer den indgår i
- hvem der er ansvarlig
- hvilke moduler der er koblet på
---
## Hvad betyder det for systemet?
**Uden Sag-modulet**
- Separate tickets, tasks og ordrer
- Kompleks synkronisering
- Dubleret data
- Svær historik
**Med Sag-modulet**
- Ét API: `/api/v1/cases`
- Ét UI-område: Sager
- Relationer er førsteklasses data
- Tags styrer processer
- Sager kan vokse og forgrene sig
- Alt er søgbart på tværs
---
## Teknisk arkitektur
### Databasestruktur
Sag-modulet består af tre kerne-tabeller (prefix `sag_`).
---
### **sag_sager Hovedtabel**
```
id (primary key)
titel (VARCHAR)
beskrivelse (TEXT)
template_key (VARCHAR, NULL)
- Bruges kun ved oprettelse
- Har ingen forretningslogik efterfølgende
status (VARCHAR)
- Tilladte værdier: 'åben', 'lukket'
customer_id (foreign key, NULLABLE)
ansvarlig_bruger_id (foreign key, NULLABLE)
created_by_user_id (foreign key, NOT NULL)
deadline (TIMESTAMP, NULLABLE)
created_at (TIMESTAMP)
updated_at (TIMESTAMP)
deleted_at (TIMESTAMP) -- soft-delete
```
**Vigtige regler**
- status er binær (åben/lukket)
- Al proceslogik udtrykkes via tags
- `template_key` må aldrig bruges til business logic
---
### **sag_relationer Relationer mellem sager**
```
id (primary key)
kilde_sag_id (foreign key)
målsag_id (foreign key)
relationstype (VARCHAR)
- f.eks. 'derived', 'blocks', 'executes'
created_at (TIMESTAMP)
deleted_at (TIMESTAMP)
```
**Principper**
- Relationer er retningsbestemte
- Relationer er transitive
- Der oprettes kun én relation pr. sammenhæng
- Begreber som “forælder” og “barn” er UI-views, ikke data
**Eksempel (kæde med flere led)**
Sag A → Sag B → Sag C → Sag D
Databasen indeholder tre relationer intet mere.
---
### **sag_tags Proces og kategorisering**
```
id (primary key)
sag_id (foreign key)
tag_navn (VARCHAR)
state (VARCHAR DEFAULT 'open')
- 'open' = ikke færdigbehandlet
- 'closed' = færdigbehandlet
closed_at (TIMESTAMP, NULLABLE)
created_at (TIMESTAMP)
deleted_at (TIMESTAMP)
```
**Betydning**
- Tags repræsenterer arbejde der skal udføres
- Et tag slettes ikke, når det er færdigt det lukkes
- `deleted_at` bruges kun til teknisk fjernelse / rollback
---
## API-endpoints
**Cases**
- `GET /api/v1/cases`
- `POST /api/v1/cases`
- `GET /api/v1/cases/{id}`
- `PATCH /api/v1/cases/{id}`
- `DELETE /api/v1/cases/{id}` (soft-delete)
**Relationer**
- `GET /api/v1/cases/{id}/relations`
- `POST /api/v1/cases/{id}/relations`
- `DELETE /api/v1/cases/{id}/relations/{relation_id}`
**Tags**
- `GET /api/v1/cases/{id}/tags`
- `POST /api/v1/cases/{id}/tags`
- `PATCH /api/v1/cases/{id}/tags/{tag_id}` (luk tag)
- `DELETE /api/v1/cases/{id}/tags/{tag_id}` (soft-delete)
Alle SELECT-queries skal filtrere på:
```sql
WHERE deleted_at IS NULL
```
---
## UI-koncept
**Sag-liste** (`/cases`)
- Liste over alle relevante sager
- Filtre:
- mine sager
- åbne sager
- sager med tag
- Sortering:
- deadline
- oprettet dato
**Sag-detalje** (`/cases/{id}`)
- Titel, status, deadline
- Tags (åbne vises tydeligt)
- Relaterede sager (afledte, blokerende, udførende)
- Ansvarlig
- Klar navigation mellem sager
---
## Implementeringsprincipper (MÅ IKKE BRYDES)
1. Der findes kun én entitet: Sag
2. `template_key` bruges kun ved oprettelse
3. Status er binær proces styres via tags
4. Tags lukkes, de slettes ikke
5. Relationer er data, ikke implicit logik
6. Alle sletninger er soft-deletes
7. Hvis du tror, du mangler en ny tabel → brug en relation
---
## Tidsestimat
- Database + migration: 30 min
- Backend API: 12 timer
- Frontend (liste + detalje): 12 timer
- Test + dokumentation: 1 time
**Total: 46 timer**
---
## TL;DR for udvikler
- Alt er en sag
- Forskelle = tags + relationer
- Ingen tickets, ingen tasks, ingen orders
- Relationer danner kæder
- Tags styrer arbejdet
- Status er kun åben/lukket
---
Hvis du vil næste skridt, kan vi:
- lave SQL CTE-eksempler til traversal
- definere første reference-workflow
- skrive README “Architectural Laws”
- eller lave et diagram, der matcher præcis dette
Men modellen?
Den er nu færdig og sund.# Implementeringsplan: Sag-modulet (Case Module)
## Oversigt - Hvad er "Sag"?
**Sag-modulet** er hjertet i BMC Hub's nye relation- og proces-styringssystem. I stedet for at have separate systemer for "tickets", "opgaver" og "ordrer", har vi én universel entitet: **en Sag**.

View File

@ -120,6 +120,11 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => {
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('user', JSON.stringify(data.user));
// Set cookie for HTML navigation access (expires in 24 hours)
const d = new Date();
d.setTime(d.getTime() + (24*60*60*1000));
document.cookie = `access_token=${data.access_token};expires=${d.toUTCString()};path=/;SameSite=Lax`;
// Redirect to dashboard
window.location.href = '/';
} else {
@ -153,6 +158,11 @@ if (token) {
})
.then(response => {
if (response.ok) {
// Ensure cookie is set (sync with localStorage)
const d = new Date();
d.setTime(d.getTime() + (24*60*60*1000));
document.cookie = `access_token=${token};expires=${d.toUTCString()};path=/;SameSite=Lax`;
// Redirect to dashboard
window.location.href = '/';
} else {

View File

@ -213,6 +213,22 @@ class Settings(BaseSettings):
ANYDESK_TIMEOUT_SECONDS: int = 30
ANYDESK_AUTO_START_SESSION: bool = True # Auto-start session when requested
# ESET Integration
ESET_ENABLED: bool = False
ESET_API_URL: str = "https://eu.device-management.eset.systems"
ESET_IAM_URL: str = "https://eu.business-account.iam.eset.systems"
ESET_INCIDENTS_URL: str = "https://eu.incident-management.eset.systems"
ESET_USERNAME: str = ""
ESET_PASSWORD: str = ""
ESET_OAUTH_CLIENT_ID: str = ""
ESET_OAUTH_CLIENT_SECRET: str = ""
ESET_OAUTH_SCOPE: str = ""
ESET_READ_ONLY: bool = True
ESET_TIMEOUT_SECONDS: int = 30
ESET_SYNC_ENABLED: bool = True
ESET_SYNC_INTERVAL_MINUTES: int = 120
ESET_INCIDENTS_ENABLED: bool = True
# SMS Integration (CPSMS)
SMS_API_KEY: str = ""
SMS_USERNAME: str = ""

308
app/jobs/eset_sync.py Normal file
View File

@ -0,0 +1,308 @@
"""
ESET sync jobs
"""
import logging
from typing import Any, Dict, List, Optional
from psycopg2.extras import Json
from app.core.config import settings
from app.core.database import execute_query
from app.services.eset_service import eset_service
logger = logging.getLogger(__name__)
def _extract_first_str(payload: Any, keys: List[str]) -> Optional[str]:
if payload is None:
return None
key_set = {k.lower() for k in keys}
stack = [payload]
while stack:
current = stack.pop()
if isinstance(current, dict):
for k, v in current.items():
if k.lower() in key_set and isinstance(v, str) and v.strip():
return v.strip()
if isinstance(v, (dict, list)):
stack.append(v)
elif isinstance(current, list):
for item in current:
if isinstance(item, (dict, list)):
stack.append(item)
return None
def _extract_devices(payload: Any) -> List[Dict[str, Any]]:
if isinstance(payload, list):
return [d for d in payload if isinstance(d, dict)]
if isinstance(payload, dict):
for key in ("devices", "items", "results", "data"):
value = payload.get(key)
if isinstance(value, list):
return [d for d in value if isinstance(d, dict)]
return []
def _extract_company(payload: Any) -> Optional[str]:
company = _extract_first_str(payload, ["company", "organization", "tenant", "customer", "userCompany"])
if company:
return company
group_path = _extract_group_path(payload)
if group_path and "/" in group_path:
return group_path.split("/")[-1].strip() or None
return None
def _extract_group_path(payload: Any) -> Optional[str]:
return _extract_first_str(payload, ["parentGroup", "groupPath", "group", "path"])
def _extract_group_name(payload: Any) -> Optional[str]:
group_path = _extract_group_path(payload)
if group_path and "/" in group_path:
name = group_path.split("/")[-1].strip()
return name or None
return group_path
def _extract_full_name(payload: Any) -> Optional[str]:
name = _extract_first_str(payload, ["realName", "displayName", "userName", "owner", "user", "lastLoggedInUser"])
if name:
return name
first = _extract_first_str(payload, ["firstName", "givenName"])
last = _extract_first_str(payload, ["lastName", "surname", "familyName"])
if first and last:
return f"{first} {last}".strip()
return None
def _detect_asset_type(payload: Any) -> str:
device_type = _extract_first_str(payload, ["deviceType", "type"])
if device_type:
val = device_type.lower()
if "server" in val:
return "server"
if "laptop" in val or "notebook" in val:
return "laptop"
return "pc"
def _match_contact(full_name: str, company: str) -> Optional[int]:
query = """
SELECT id
FROM contacts
WHERE LOWER(TRIM(first_name || ' ' || last_name)) = LOWER(%s)
AND LOWER(COALESCE(user_company, '')) = LOWER(%s)
LIMIT 1
"""
result = execute_query(query, (full_name, company))
if result:
return result[0]["id"]
return None
def _get_contact_customer(contact_id: int) -> Optional[int]:
query = """
SELECT customer_id
FROM contact_companies
WHERE contact_id = %s
ORDER BY is_primary DESC, id ASC
LIMIT 1
"""
result = execute_query(query, (contact_id,))
if result:
return result[0]["customer_id"]
return None
def _match_customer_exact(name: str) -> Optional[int]:
if not name:
return None
query = "SELECT id FROM customers WHERE LOWER(name) = LOWER(%s)"
result = execute_query(query, (name,))
if len(result or []) == 1:
return result[0]["id"]
return None
def _upsert_hardware_contact(hardware_id: int, contact_id: int) -> None:
query = """
INSERT INTO hardware_contacts (hardware_id, contact_id, role, source)
VALUES (%s, %s, %s, %s)
ON CONFLICT (hardware_id, contact_id) DO NOTHING
"""
execute_query(query, (hardware_id, contact_id, "primary", "eset"))
def _upsert_incident(incident: Dict[str, Any]) -> None:
incident_uuid = incident.get("incidentUuid") or incident.get("uuid") or incident.get("id")
if not incident_uuid:
return
severity = incident.get("severity") or incident.get("level")
status = incident.get("status")
device_uuid = incident.get("deviceUuid") or incident.get("device")
detected_at = incident.get("detectedAt") or incident.get("firstSeen")
last_seen = incident.get("lastSeen") or incident.get("lastUpdate")
query = """
INSERT INTO eset_incidents (
incident_uuid, severity, status, device_uuid, detected_at, last_seen, payload, updated_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s, NOW())
ON CONFLICT (incident_uuid) DO UPDATE SET
severity = EXCLUDED.severity,
status = EXCLUDED.status,
device_uuid = EXCLUDED.device_uuid,
detected_at = EXCLUDED.detected_at,
last_seen = EXCLUDED.last_seen,
payload = EXCLUDED.payload,
updated_at = NOW()
"""
execute_query(query, (
incident_uuid,
severity,
status,
device_uuid,
detected_at,
last_seen,
Json(incident)
))
async def sync_eset_hardware() -> None:
if not settings.ESET_ENABLED or not settings.ESET_SYNC_ENABLED:
return
payload = await eset_service.list_devices()
if not payload:
logger.warning("ESET device list empty")
return
devices = _extract_devices(payload)
logger.info("ESET devices fetched: %d", len(devices))
for device in devices:
device_uuid = device.get("deviceUuid") or device.get("uuid") or device.get("id")
if not device_uuid:
continue
details = await eset_service.get_device_details(device_uuid)
if not details:
continue
serial = _extract_first_str(details, ["serialNumber", "serial", "serial_number"])
model = _extract_first_str(details, ["model", "deviceModel", "deviceName", "name"])
brand = _extract_first_str(details, ["manufacturer", "brand", "vendor"])
group_path = _extract_group_path(details)
group_name = _extract_group_name(details)
conditions = []
params = []
conditions.append("eset_uuid = %s")
params.append(device_uuid)
if serial:
conditions.append("serial_number = %s")
params.append(serial)
lookup_query = f"SELECT * FROM hardware_assets WHERE deleted_at IS NULL AND ({' OR '.join(conditions)})"
existing = execute_query(lookup_query, tuple(params))
full_name = _extract_full_name(details)
company = _extract_company(details)
contact_id = _match_contact(full_name, company) if full_name and company else None
customer_id = _get_contact_customer(contact_id) if contact_id else None
if not customer_id:
customer_id = _match_customer_exact(group_name or company) if (group_name or company) else None
if existing:
hardware_id = existing[0]["id"]
update_fields = ["eset_uuid = %s", "hardware_specs = %s", "updated_at = NOW()"]
update_params = [device_uuid, Json(details)]
if group_path:
update_fields.append("eset_group = %s")
update_params.append(group_path)
if not existing[0].get("serial_number") and serial:
update_fields.append("serial_number = %s")
update_params.append(serial)
if not existing[0].get("model") and model:
update_fields.append("model = %s")
update_params.append(model)
if not existing[0].get("brand") and brand:
update_fields.append("brand = %s")
update_params.append(brand)
update_params.append(hardware_id)
update_query = f"""
UPDATE hardware_assets
SET {', '.join(update_fields)}
WHERE id = %s
"""
execute_query(update_query, tuple(update_params))
else:
owner_type = "customer" if customer_id else "bmc"
insert_query = """
INSERT INTO hardware_assets (
asset_type, brand, model, serial_number,
current_owner_type, current_owner_customer_id,
notes, eset_uuid, hardware_specs, eset_group
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
"""
insert_params = (
_detect_asset_type(details),
brand,
model,
serial,
owner_type,
customer_id,
"Auto-created from ESET",
device_uuid,
Json(details),
group_path
)
created = execute_query(insert_query, insert_params)
hardware_id = created[0]["id"] if created else None
if contact_id and hardware_id:
_upsert_hardware_contact(hardware_id, contact_id)
if customer_id:
owner_query = """
UPDATE hardware_assets
SET current_owner_type = %s, current_owner_customer_id = %s, updated_at = NOW()
WHERE id = %s
"""
execute_query(owner_query, ("customer", customer_id, hardware_id))
async def sync_eset_incidents() -> None:
if not settings.ESET_ENABLED or not settings.ESET_INCIDENTS_ENABLED:
return
payload = await eset_service.list_incidents()
if not payload:
logger.warning("ESET incidents list empty")
return
incidents = _extract_devices(payload)
critical = 0
for incident in incidents:
_upsert_incident(incident)
severity = (incident.get("severity") or incident.get("level") or "").lower()
if severity in {"critical", "high", "severe"}:
critical += 1
if critical:
logger.warning("ESET critical incidents: %d", critical)
async def run_eset_sync() -> None:
await sync_eset_hardware()
await sync_eset_incidents()

View File

@ -2,6 +2,8 @@ import logging
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query, UploadFile, File
from app.core.database import execute_query
from app.services.eset_service import eset_service
from psycopg2.extras import Json
from datetime import datetime, date
import os
import uuid
@ -9,6 +11,92 @@ import uuid
logger = logging.getLogger(__name__)
router = APIRouter()
def _eset_extract_first_str(payload: dict, keys: List[str]) -> Optional[str]:
if payload is None:
return None
key_set = {k.lower() for k in keys}
stack = [payload]
while stack:
current = stack.pop()
if isinstance(current, dict):
for k, v in current.items():
if k.lower() in key_set and isinstance(v, str) and v.strip():
return v.strip()
if isinstance(v, (dict, list)):
stack.append(v)
elif isinstance(current, list):
for item in current:
if isinstance(item, (dict, list)):
stack.append(item)
return None
def _eset_extract_group_path(payload: dict) -> Optional[str]:
return _eset_extract_first_str(payload, ["parentGroup", "groupPath", "group", "path"])
def _eset_extract_group_name(payload: dict) -> Optional[str]:
group_path = _eset_extract_group_path(payload)
if group_path and "/" in group_path:
name = group_path.split("/")[-1].strip()
return name or None
return group_path
def _eset_extract_company(payload: dict) -> Optional[str]:
company = _eset_extract_first_str(payload, ["company", "organization", "tenant", "customer", "userCompany"])
if company:
return company
group_path = _eset_extract_group_path(payload)
if group_path and "/" in group_path:
return group_path.split("/")[-1].strip() or None
return None
def _eset_detect_asset_type(payload: dict) -> str:
device_type = _eset_extract_first_str(payload, ["deviceType", "type"])
if device_type:
val = device_type.lower()
if "server" in val:
return "server"
if "laptop" in val or "notebook" in val:
return "laptop"
return "pc"
def _match_customer_exact(name: str) -> Optional[int]:
if not name:
return None
result = execute_query("SELECT id FROM customers WHERE LOWER(name) = LOWER(%s)", (name,))
if len(result or []) == 1:
return result[0]["id"]
return None
def _get_contact_customer(contact_id: int) -> Optional[int]:
query = """
SELECT customer_id
FROM contact_companies
WHERE contact_id = %s
ORDER BY is_primary DESC, id ASC
LIMIT 1
"""
result = execute_query(query, (contact_id,))
if result:
return result[0]["customer_id"]
return None
def _upsert_hardware_contact(hardware_id: int, contact_id: int) -> None:
query = """
INSERT INTO hardware_contacts (hardware_id, contact_id, role, source)
VALUES (%s, %s, %s, %s)
ON CONFLICT (hardware_id, contact_id) DO NOTHING
"""
execute_query(query, (hardware_id, contact_id, "primary", "eset"))
# ============================================================================
# CRUD Endpoints for Hardware Assets
# ============================================================================
@ -80,11 +168,17 @@ async def create_hardware(data: dict):
asset_type, brand, model, serial_number, customer_asset_id,
internal_asset_id, notes, current_owner_type, current_owner_customer_id,
status, status_reason, warranty_until, end_of_life,
anydesk_id, anydesk_link
anydesk_id, anydesk_link,
eset_uuid, hardware_specs, eset_group
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
"""
specs = data.get("hardware_specs")
if specs:
specs = Json(specs)
params = (
data.get("asset_type"),
data.get("brand"),
@ -101,6 +195,9 @@ async def create_hardware(data: dict):
data.get("end_of_life"),
data.get("anydesk_id"),
data.get("anydesk_link"),
data.get("eset_uuid"),
specs,
data.get("eset_group")
)
result = execute_query(query, params)
if not result:
@ -200,13 +297,17 @@ async def update_hardware(hardware_id: int, data: dict):
"asset_type", "brand", "model", "serial_number", "customer_asset_id",
"internal_asset_id", "notes", "current_owner_type", "current_owner_customer_id",
"status", "status_reason", "warranty_until", "end_of_life",
"follow_up_date", "follow_up_owner_user_id", "anydesk_id", "anydesk_link"
"follow_up_date", "follow_up_owner_user_id", "anydesk_id", "anydesk_link",
"eset_uuid", "hardware_specs", "eset_group"
]
for field in allowed_fields:
if field in data:
update_fields.append(f"{field} = %s")
params.append(data[field])
val = data[field]
if field == "hardware_specs" and val:
val = Json(val)
params.append(val)
if not update_fields:
raise HTTPException(status_code=400, detail="No valid fields to update")
@ -586,3 +687,209 @@ async def search_hardware(q: str = Query(..., min_length=1)):
logger.info(f"✅ Search for '{q}' returned {len(result) if result else 0} results")
return result or []
@router.post("/hardware/{hardware_id}/sync-eset", response_model=dict)
async def sync_eset_data(hardware_id: int, eset_uuid: Optional[str] = Query(None)):
"""Sync hardware data from ESET."""
# Get current hardware
check_query = "SELECT * FROM hardware_assets WHERE id = %s AND deleted_at IS NULL"
result = execute_query(check_query, (hardware_id,))
if not result:
raise HTTPException(status_code=404, detail="Hardware not found")
current = result[0]
# Determine UUID
uuid_to_use = eset_uuid or current.get("eset_uuid")
if not uuid_to_use:
raise HTTPException(status_code=400, detail="No ESET UUID provided or found on asset. Please provide 'eset_uuid' query parameter.")
# Fetch from ESET
details = await eset_service.get_device_details(uuid_to_use)
if not details:
raise HTTPException(status_code=404, detail="Device not found in ESET")
# Update hardware asset
update_data = {
"eset_uuid": uuid_to_use,
"hardware_specs": details
}
# We can perform the update directly here or call update_hardware if available
return await update_hardware(hardware_id, update_data)
@router.get("/hardware/eset/test", response_model=dict)
async def test_eset_device(device_uuid: str = Query(..., min_length=1)):
"""Test ESET device lookup by UUID."""
details = await eset_service.get_device_details(device_uuid)
if not details:
raise HTTPException(status_code=404, detail="Device not found in ESET")
return details
@router.get("/hardware/eset/devices", response_model=dict)
async def list_eset_devices():
"""List devices directly from ESET Device Management."""
payload = await eset_service.list_devices()
if not payload:
raise HTTPException(status_code=404, detail="No devices returned from ESET")
return payload
@router.post("/hardware/eset/import", response_model=dict)
async def import_eset_device(data: dict):
"""Import ESET device into hardware assets and optionally link to contact."""
device_uuid = (data.get("device_uuid") or "").strip()
contact_id = data.get("contact_id")
if not device_uuid:
raise HTTPException(status_code=400, detail="device_uuid is required")
details = await eset_service.get_device_details(device_uuid)
if not details:
raise HTTPException(status_code=404, detail="Device not found in ESET")
serial = _eset_extract_first_str(details, ["serialNumber", "serial", "serial_number"])
model = _eset_extract_first_str(details, ["model", "deviceModel", "deviceName", "name"])
brand = _eset_extract_first_str(details, ["manufacturer", "brand", "vendor"])
group_path = _eset_extract_group_path(details)
group_name = _eset_extract_group_name(details)
company = _eset_extract_company(details)
if contact_id:
contact_check = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,))
if not contact_check:
raise HTTPException(status_code=404, detail="Contact not found")
customer_id = _get_contact_customer(contact_id) if contact_id else None
if not customer_id:
customer_id = _match_customer_exact(group_name or company)
owner_type = "customer" if customer_id else "bmc"
conditions = ["eset_uuid = %s"]
params = [device_uuid]
if serial:
conditions.append("serial_number = %s")
params.append(serial)
lookup_query = f"SELECT * FROM hardware_assets WHERE deleted_at IS NULL AND ({' OR '.join(conditions)})"
existing = execute_query(lookup_query, tuple(params))
if existing:
hardware_id = existing[0]["id"]
update_fields = ["eset_uuid = %s", "hardware_specs = %s", "updated_at = NOW()"]
update_params = [device_uuid, Json(details)]
if group_path:
update_fields.append("eset_group = %s")
update_params.append(group_path)
if not existing[0].get("serial_number") and serial:
update_fields.append("serial_number = %s")
update_params.append(serial)
if not existing[0].get("model") and model:
update_fields.append("model = %s")
update_params.append(model)
if not existing[0].get("brand") and brand:
update_fields.append("brand = %s")
update_params.append(brand)
if customer_id:
update_fields.append("current_owner_type = %s")
update_params.append("customer")
update_fields.append("current_owner_customer_id = %s")
update_params.append(customer_id)
update_params.append(hardware_id)
update_query = f"""
UPDATE hardware_assets
SET {', '.join(update_fields)}
WHERE id = %s
RETURNING *
"""
hardware = execute_query(update_query, tuple(update_params))
hardware = hardware[0] if hardware else None
else:
insert_query = """
INSERT INTO hardware_assets (
asset_type, brand, model, serial_number,
current_owner_type, current_owner_customer_id,
notes, eset_uuid, hardware_specs, eset_group
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
"""
insert_params = (
_eset_detect_asset_type(details),
brand,
model,
serial,
owner_type,
customer_id,
"Imported from ESET",
device_uuid,
Json(details),
group_path
)
hardware = execute_query(insert_query, insert_params)
hardware = hardware[0] if hardware else None
if hardware and contact_id:
_upsert_hardware_contact(hardware["id"], contact_id)
return hardware or {}
@router.get("/hardware/eset/matches", response_model=List[dict])
async def list_eset_matches(limit: int = Query(500, ge=1, le=2000)):
"""List ESET-matched hardware with contact/customer info."""
query = """
SELECT
h.id,
h.asset_type,
h.brand,
h.model,
h.serial_number,
h.eset_uuid,
h.eset_group,
h.updated_at,
hc.contact_id,
c.first_name,
c.last_name,
c.user_company,
cc.customer_id,
cust.name AS customer_name
FROM hardware_assets h
LEFT JOIN hardware_contacts hc ON hc.hardware_id = h.id
LEFT JOIN contacts c ON c.id = hc.contact_id
LEFT JOIN contact_companies cc ON cc.contact_id = c.id
LEFT JOIN customers cust ON cust.id = cc.customer_id
WHERE h.deleted_at IS NULL
ORDER BY h.updated_at DESC NULLS LAST
LIMIT %s
"""
result = execute_query(query, (limit,))
return result or []
@router.get("/hardware/eset/incidents", response_model=List[dict])
async def list_eset_incidents(
severity: Optional[str] = Query("critical"),
limit: int = Query(200, ge=1, le=2000)
):
"""List cached ESET incidents by severity."""
severity_list = [s.strip().lower() for s in (severity or "").split(",") if s.strip()]
if not severity_list:
severity_list = ["critical"]
query = """
SELECT *
FROM eset_incidents
WHERE LOWER(COALESCE(severity, '')) = ANY(%s)
ORDER BY updated_at DESC NULLS LAST
LIMIT %s
"""
result = execute_query(query, (severity_list, limit))
return result or []

View File

@ -83,7 +83,7 @@ async def hardware_list(
if hardware:
customer_ids = [h['current_owner_customer_id'] for h in hardware if h.get('current_owner_customer_id')]
if customer_ids:
customer_query = "SELECT id, navn FROM customers WHERE id = ANY(%s)"
customer_query = "SELECT id, name AS navn FROM customers WHERE id = ANY(%s)"
customers = execute_query(customer_query, (customer_ids,))
customer_map = {c['id']: c['navn'] for c in customers} if customers else {}
@ -105,7 +105,7 @@ async def hardware_list(
async def create_hardware_form(request: Request):
"""Display create hardware form."""
# Get customers for dropdown
customers = execute_query("SELECT id, navn FROM customers WHERE deleted_at IS NULL ORDER BY navn")
customers = execute_query("SELECT id, name AS navn FROM customers WHERE deleted_at IS NULL ORDER BY name")
return templates.TemplateResponse("modules/hardware/templates/create.html", {
"request": request,
@ -113,6 +113,68 @@ async def create_hardware_form(request: Request):
})
@router.get("/hardware/eset", response_class=HTMLResponse)
async def hardware_eset_overview(request: Request):
"""Display ESET sync overview (matches + incidents)."""
matches_query = """
SELECT
h.id,
h.asset_type,
h.brand,
h.model,
h.serial_number,
h.eset_uuid,
h.eset_group,
h.updated_at,
hc.contact_id,
c.first_name,
c.last_name,
c.user_company,
cc.customer_id,
cust.name AS customer_name
FROM hardware_assets h
LEFT JOIN hardware_contacts hc ON hc.hardware_id = h.id
LEFT JOIN contacts c ON c.id = hc.contact_id
LEFT JOIN contact_companies cc ON cc.contact_id = c.id
LEFT JOIN customers cust ON cust.id = cc.customer_id
WHERE h.deleted_at IS NULL
ORDER BY h.updated_at DESC NULLS LAST
LIMIT 500
"""
matches = execute_query(matches_query)
incidents_query = """
SELECT *
FROM eset_incidents
WHERE LOWER(COALESCE(severity, '')) IN ('critical', 'high', 'severe')
ORDER BY updated_at DESC NULLS LAST
LIMIT 200
"""
incidents = execute_query(incidents_query)
return templates.TemplateResponse("modules/hardware/templates/eset_overview.html", {
"request": request,
"matches": matches or [],
"incidents": incidents or []
})
@router.get("/hardware/eset/test", response_class=HTMLResponse)
async def hardware_eset_test(request: Request):
"""Display ESET API test page."""
return templates.TemplateResponse("modules/hardware/templates/eset_test.html", {
"request": request
})
@router.get("/hardware/eset/import", response_class=HTMLResponse)
async def hardware_eset_import(request: Request):
"""Display ESET import page."""
return templates.TemplateResponse("modules/hardware/templates/eset_import.html", {
"request": request
})
@router.get("/hardware/{hardware_id}", response_class=HTMLResponse)
async def hardware_detail(request: Request, hardware_id: int):
"""Display hardware details."""
@ -126,7 +188,7 @@ async def hardware_detail(request: Request, hardware_id: int):
# Get customer name if applicable
if hardware.get('current_owner_customer_id'):
customer_query = "SELECT navn FROM customers WHERE id = %s"
customer_query = "SELECT name AS navn FROM customers WHERE id = %s"
customer_result = execute_query(customer_query, (hardware['current_owner_customer_id'],))
if customer_result:
hardware['customer_name'] = customer_result[0]['navn']
@ -143,7 +205,7 @@ async def hardware_detail(request: Request, hardware_id: int):
if ownership:
customer_ids = [o['owner_customer_id'] for o in ownership if o.get('owner_customer_id')]
if customer_ids:
customer_query = "SELECT id, navn FROM customers WHERE id = ANY(%s)"
customer_query = "SELECT id, name AS navn FROM customers WHERE id = ANY(%s)"
customers = execute_query(customer_query, (customer_ids,))
customer_map = {c['id']: c['navn'] for c in customers} if customers else {}
@ -219,7 +281,7 @@ async def edit_hardware_form(request: Request, hardware_id: int):
hardware = result[0]
# Get customers for dropdown
customers = execute_query("SELECT id, navn FROM customers WHERE deleted_at IS NULL ORDER BY navn")
customers = execute_query("SELECT id, name AS navn FROM customers WHERE deleted_at IS NULL ORDER BY name")
return templates.TemplateResponse("modules/hardware/templates/edit.html", {
"request": request,

View File

@ -1,4 +1,4 @@
{% extends "shared/frontend/base.html" %}
js{% extends "shared/frontend/base.html" %}
{% block title %}Opret Hardware - BMC Hub{% endblock %}
@ -216,6 +216,20 @@
<input type="text" id="anydesk_link" name="anydesk_link" placeholder="anydesk://...">
</div>
</div>
<div class="mt-2 small" id="anydesk_preview" style="display: none;">
<a href="#" id="anydesk_preview_link" target="_blank">Test forbindelse</a>
</div>
</div>
<!-- ESET -->
<div class="form-section">
<h3 class="form-section-title">🛡️ ESET</h3>
<div class="form-grid">
<div class="form-group">
<label for="eset_uuid">ESET UUID</label>
<input type="text" id="eset_uuid" name="eset_uuid" placeholder="ESET Device UUID">
</div>
</div>
</div>
<!-- Ownership -->
@ -304,6 +318,33 @@
}
}
function updateAnyDeskPreview() {
const idValue = document.getElementById('anydesk_id').value.trim();
const preview = document.getElementById('anydesk_preview');
const previewLink = document.getElementById('anydesk_preview_link');
const linkInput = document.getElementById('anydesk_link');
if (!preview || !previewLink) {
return;
}
if (!idValue) {
preview.style.display = 'none';
previewLink.href = '#';
if (linkInput) {
linkInput.value = '';
}
return;
}
const deepLink = `anydesk://${idValue}`;
previewLink.href = deepLink;
if (linkInput) {
linkInput.value = deepLink;
}
preview.style.display = 'block';
}
async function submitForm(event) {
event.preventDefault();
@ -345,5 +386,9 @@
// Initialize customer select visibility
toggleCustomerSelect();
// AnyDesk live preview
document.getElementById('anydesk_id').addEventListener('input', updateAnyDeskPreview);
updateAnyDeskPreview();
</script>
{% endblock %}

View File

@ -4,77 +4,11 @@
{% block extra_css %}
<style>
/* Nordic Top / Header Styling */
.page-header {
background: linear-gradient(135deg, var(--accent) 0%, #0b3a5b 100%);
color: white;
padding: 2rem 0 3rem;
margin-bottom: -2rem; /* Overlap with content */
border-radius: 0 0 12px 12px; /* Slight curve at bottom */
}
.page-header h1 {
font-weight: 700;
font-size: 2rem;
margin: 0;
}
.page-header .breadcrumb {
background: transparent;
padding: 0;
margin-bottom: 0.5rem;
}
.page-header .breadcrumb-item,
.page-header .breadcrumb-item a {
color: rgba(255,255,255,0.8);
text-decoration: none;
font-size: 0.9rem;
}
.page-header .breadcrumb-item.active {
color: white;
}
/* Content Styling */
.main-content {
position: relative;
z-index: 10;
padding-bottom: 3rem;
}
.card {
border: none;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
margin-bottom: 1.5rem;
transition: transform 0.2s;
}
.card-header {
background: white;
border-bottom: 1px solid rgba(0,0,0,0.05);
padding: 1.25rem 1.5rem;
font-weight: 600;
display: flex;
justify-content: space-between;
align-items: center;
border-radius: 12px 12px 0 0 !important;
}
.card-title-text {
color: var(--accent);
font-size: 1.1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 0.75rem 0;
border-bottom: 1px solid rgba(0,0,0,0.03);
padding: 0.8rem 0;
border-bottom: 1px solid rgba(0,0,0,0.05);
}
.info-row:last-child {
@ -82,22 +16,20 @@
}
.info-label {
font-weight: 600;
color: var(--text-secondary);
font-weight: 500;
font-size: 0.9rem;
min-width: 150px;
}
.info-value {
color: var(--text-primary);
font-weight: 600;
text-align: right;
word-break: break-all;
}
/* Quick Action Cards */
.action-card {
background: white;
padding: 1.5rem;
border-radius: 12px;
padding: 1rem;
border-radius: 8px;
text-align: center;
border: 1px dashed rgba(0,0,0,0.1);
cursor: pointer;
@ -107,6 +39,7 @@
flex-direction: column;
justify-content: center;
align-items: center;
gap: 0.5rem;
color: var(--text-secondary);
}
@ -114,146 +47,223 @@
border-color: var(--accent);
background: var(--accent-light);
color: var(--accent);
transform: translateY(-2px);
text-decoration: none;
}
.action-card i {
font-size: 2rem;
margin-bottom: 0.5rem;
font-size: 1.5rem;
}
/* Timeline */
/* Timeline Styling */
.timeline {
position: relative;
padding-left: 2rem;
padding-left: 1.5rem;
}
.timeline::before {
content: '';
position: absolute;
left: 0.5rem;
left: 0.4rem;
top: 0;
bottom: 0;
width: 2px;
background: #e9ecef;
}
.timeline-item {
position: relative;
padding-bottom: 1.5rem;
}
.timeline-marker {
position: absolute;
left: -2rem;
top: 0.2rem;
width: 1rem;
height: 1rem;
left: -1.09rem;
top: 0.3rem;
width: 0.8rem;
height: 0.8rem;
border-radius: 50%;
background: white;
border: 2px solid var(--accent);
}
.timeline-item.active .timeline-marker {
background: #28a745;
border-color: #28a745;
}
.icon-box {
width: 48px;
height: 48px;
/* Quick Info Bar */
.quick-info-label {
color: var(--accent);
font-weight: 700;
margin-right: 0.4rem;
}
.quick-info-item {
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
font-size: 1.5rem;
margin-right: 1rem;
border-right: 1px solid rgba(0,0,0,0.1);
padding-right: 0.75rem;
margin-right: 0.75rem;
}
.quick-info-item:last-child {
border-right: none;
margin-right: 0;
padding-right: 0;
}
.bg-soft-primary { background-color: rgba(13, 110, 253, 0.1); color: #0d6efd; }
.bg-soft-success { background-color: rgba(25, 135, 84, 0.1); color: #198754; }
.bg-soft-warning { background-color: rgba(255, 193, 7, 0.1); color: #ffc107; }
.bg-soft-info { background-color: rgba(13, 202, 240, 0.1); color: #0dcaf0; }
</style>
{% endblock %}
{% block content %}
<!-- Custom Nordic Blue Header -->
<div class="page-header">
<div class="container-fluid px-4">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">Forside</a></li>
<li class="breadcrumb-item"><a href="/hardware">Hardware</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ hardware.serial_number or 'Detail' }}</li>
</ol>
</nav>
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<div class="me-3" style="font-size: 2.5rem;">
{% if hardware.asset_type == 'pc' %}🖥️
{% elif hardware.asset_type == 'laptop' %}💻
{% elif hardware.asset_type == 'printer' %}🖨️
{% elif hardware.asset_type == 'skærm' %}🖥️
{% elif hardware.asset_type == 'telefon' %}📱
{% elif hardware.asset_type == 'server' %}🗄️
{% elif hardware.asset_type == 'netværk' %}🌐
{% else %}📦
{% endif %}
</div>
<div>
<h1>{{ hardware.brand or 'Unknown' }} {{ hardware.model or '' }}</h1>
<div class="d-flex align-items-center gap-2 mt-1">
<span class="badge bg-white text-dark border">{{ hardware.serial_number or 'Ingen serienummer' }}</span>
<div class="container-fluid" style="margin-top: 2rem; margin-bottom: 2rem; max-width: 1400px;">
<span class="badge {% if hardware.status == 'active' %}bg-success{% elif hardware.status == 'retired' %}bg-secondary{% elif hardware.status == 'in_repair' %}bg-primary{% else %}bg-warning{% endif %}">
<!-- Top Bar: Back Link + Global Tags -->
<div class="d-flex justify-content-between align-items-start mb-2">
<a href="/hardware" class="back-link text-decoration-none">
<i class="bi bi-chevron-left"></i> Tilbage til hardware
</a>
<!-- Global Tags Area -->
<div class="d-flex align-items-center p-2 rounded" style="background: rgba(0,0,0,0.02);">
<i class="bi bi-tags text-muted me-2 small"></i>
<div id="hardware-tags" class="d-flex flex-wrap justify-content-end gap-1 align-items-center">
<span class="spinner-border spinner-border-sm text-muted"></span>
</div>
<button class="btn btn-sm btn-link text-decoration-none ms-1 px-1 py-0 text-muted hover-primary"
onclick="window.showTagPicker('hardware', {{ hardware.id }}, () => window.renderEntityTags('hardware', {{ hardware.id }}, 'hardware-tags'))"
title="Tilføj tag">
<i class="bi bi-plus-lg"></i>
</button>
</div>
</div>
<!-- Quick Info Bar -->
<div class="card mb-3" style="background: var(--bg-card); border-left: 4px solid var(--accent); box-shadow: 0 1px 3px rgba(0,0,0,0.08);">
<div class="card-body py-2 px-3">
<div class="d-flex flex-wrap align-items-center" style="font-size: 0.85rem;">
<!-- ID -->
<div class="quick-info-item">
<span class="quick-info-label">ID:</span>
<span>{{ hardware.id }}</span>
</div>
<!-- Brand / Model -->
<div class="quick-info-item">
<span class="quick-info-label">Hardware:</span>
<span class="fw-bold">{{ hardware.brand or 'Unknown' }} {{ hardware.model or '' }}</span>
</div>
<!-- Serial -->
{% if hardware.serial_number %}
<div class="quick-info-item">
<span class="quick-info-label">S/N:</span>
<span class="font-monospace text-muted">{{ hardware.serial_number }}</span>
</div>
{% endif %}
<!-- Customer (Current Owner) -->
{% set current_owner = ownership[0] if ownership else None %}
{% if current_owner and not current_owner.end_date %}
<div class="quick-info-item">
<span class="quick-info-label">Ejer:</span>
<span>{{ current_owner.customer_name or current_owner.owner_type|title }}</span>
</div>
{% endif %}
<!-- Location (Current) -->
{% set current_loc = locations[0] if locations else None %}
{% if current_loc and not current_loc.end_date %}
<div class="quick-info-item">
<span class="quick-info-label">Lokation:</span>
<span>{{ current_loc.location_name }}</span>
</div>
{% endif %}
<!-- Status -->
<div class="quick-info-item">
<span class="quick-info-label">Status:</span>
<span class="badge {% if hardware.status == 'active' %}bg-success{% elif hardware.status == 'retired' %}bg-secondary{% elif hardware.status == 'in_repair' %}bg-primary{% else %}bg-warning{% endif %}" style="font-size: 0.7rem;">
{{ hardware.status|replace('_', ' ')|title }}
</span>
</div>
</div>
</div>
<div class="d-flex gap-2">
<a href="/hardware/{{ hardware.id }}/edit" class="btn btn-light text-primary fw-medium shadow-sm">
<i class="bi bi-pencil me-1"></i> Rediger
<!-- Link to Edit -->
<div class="ms-auto d-flex gap-2">
<a href="/hardware/{{ hardware.id }}/edit" class="btn btn-sm btn-outline-primary" title="Rediger">
<i class="bi bi-pencil"></i>
</a>
<button onclick="deleteHardware()" class="btn btn-danger text-white fw-medium shadow-sm" style="background-color: rgba(220, 53, 69, 0.9);">
<i class="bi bi-trash me-1"></i> Slet
<button onclick="deleteHardware()" class="btn btn-sm btn-outline-danger" title="Slet">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
</div>
</div>
<div class="container-fluid px-4 main-content">
<div class="row">
<!-- Left Column: Key Info & Relations -->
<div class="col-lg-4">
<!-- Tabs Navigation -->
<ul class="nav nav-tabs mb-4 px-2" id="hwTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="details-tab" data-bs-toggle="tab" data-bs-target="#details" type="button" role="tab">
<i class="bi bi-card-text me-2"></i>Hardware Detaljer
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="history-tab" data-bs-toggle="tab" data-bs-target="#history" type="button" role="tab">
<i class="bi bi-clock-history me-2"></i>Historik
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="files-tab" data-bs-toggle="tab" data-bs-target="#files" type="button" role="tab">
<i class="bi bi-paperclip me-2"></i>Filer ({{ attachments|length }})
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="notes-tab" data-bs-toggle="tab" data-bs-target="#notes" type="button" role="tab">
<i class="bi bi-sticky me-2"></i>Noter
</button>
</li>
</ul>
<!-- Key Details Card -->
<div class="card mb-4">
<div class="card-header">
<div class="card-title-text"><i class="bi bi-info-circle"></i> Stamdata</div>
<div class="tab-content" id="hwTabsContent">
<!-- Tab: Details -->
<div class="tab-pane fade show active" id="details" role="tabpanel">
<div class="row g-4">
<!-- Left Column -->
<div class="col-lg-8">
<!-- Basic Info Card -->
<div class="card mb-4 shadow-sm border-0">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3">
<h6 class="text-primary mb-0"><i class="bi bi-info-circle me-2"></i>Stamdata</h6>
</div>
<div class="card-body pt-0">
<div class="card-body">
<div class="info-row">
<span class="info-label">Type</span>
<span class="info-value">{{ hardware.asset_type|title }}</span>
<span class="info-value">
{% if hardware.asset_type == 'pc' %}🖥️ PC
{% elif hardware.asset_type == 'laptop' %}💻 Laptop
{% elif hardware.asset_type == 'printer' %}🖨️ Printer
{% elif hardware.asset_type == 'skærm' %}🖥️ Skærm
{% elif hardware.asset_type == 'telefon' %}📱 Telefon
{% elif hardware.asset_type == 'server' %}🗄️ Server
{% elif hardware.asset_type == 'netværk' %}🌐 Netværk
{% else %}📦 {{ hardware.asset_type|title }}
{% endif %}
</span>
</div>
<div class="info-row">
<span class="info-label">Mærke/Model</span>
<span class="info-value">{{ hardware.brand or '-' }} / {{ hardware.model or '-' }}</span>
</div>
{% if hardware.internal_asset_id %}
<div class="info-row">
<span class="info-label">Intern ID</span>
<span class="info-label">Internt Asset ID</span>
<span class="info-value">{{ hardware.internal_asset_id }}</span>
</div>
{% endif %}
{% if hardware.customer_asset_id %}
<div class="info-row">
<span class="info-label">Kunde ID</span>
<span class="info-label">Kunde Asset ID</span>
<span class="info-value">{{ hardware.customer_asset_id }}</span>
</div>
{% endif %}
{% if hardware.warranty_until %}
<div class="info-row">
<span class="info-label">Garanti Udløb</span>
<span class="info-label">Garanti til</span>
<span class="info-value">{{ hardware.warranty_until }}</span>
</div>
{% endif %}
@ -267,279 +277,256 @@
</div>
<!-- AnyDesk Card -->
<div class="card mb-4">
<div class="card-header">
<div class="card-title-text"><i class="bi bi-display"></i> AnyDesk</div>
{% set anydesk_url = hardware.anydesk_id and ('anydesk://' ~ hardware.anydesk_id) %}
<div class="card mb-4 shadow-sm border-0">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3 d-flex justify-content-between align-items-center">
<h6 class="text-danger mb-0"><i class="bi bi-display me-2"></i>Remote Access</h6>
<a class="btn btn-sm btn-link p-0" href="/hardware/{{ hardware.id }}/edit#anydesk" title="Rediger AnyDesk">
Ændre
</a>
</div>
<div class="card-body">
<div class="info-row">
<span class="info-label">AnyDesk ID</span>
<span class="info-value">{{ hardware.anydesk_id or '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">AnyDesk Link</span>
<span class="info-value">
{% if hardware.anydesk_link %}
<a href="{{ hardware.anydesk_link }}" target="_blank" class="btn btn-sm btn-outline-primary">Connect</a>
<span class="info-value fw-bold font-monospace">
{% if anydesk_url %}
<a href="{{ anydesk_url }}" target="_blank" rel="noreferrer noopener">{{ hardware.anydesk_id }}</a>
{% else %}
-
{% endif %}
</span>
</div>
<div class="info-row">
<span class="info-label">Handling</span>
<span class="info-value">
{% if anydesk_url %}
<a href="{{ anydesk_url }}" target="_blank" rel="noreferrer noopener" class="btn btn-sm btn-outline-danger">
<i class="bi bi-lightning-charge me-1"></i>Connect AnyDesk
</a>
{% else %}
<span class="text-muted small">Ingen link</span>
{% endif %}
</span>
</div>
</div>
</div>
<!-- Tags Card -->
<div class="card mb-4">
<div class="card-header">
<div class="card-title-text"><i class="bi bi-tags"></i> Tags</div>
<button class="btn btn-sm btn-outline-primary" onclick="window.showTagPicker('hardware', {{ hardware.id }}, () => window.renderEntityTags('hardware', {{ hardware.id }}, 'hardware-tags'))">
<i class="bi bi-plus-lg"></i> Tilføj
</button>
<!-- Location & Owner Grid -->
<div class="row">
<div class="col-md-6">
<div class="card h-100 shadow-sm border-0">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3 d-flex justify-content-between align-items-center">
<h6 class="text-primary mb-0"><i class="bi bi-geo-alt me-2"></i>Lokation</h6>
<button class="btn btn-sm btn-link p-0" data-bs-toggle="modal" data-bs-target="#locationModal">Ændre</button>
</div>
<div class="card-body">
<div id="hardware-tags" class="d-flex flex-wrap">
<!-- Tags loaded via JS -->
<div class="text-center w-100 py-2">
<span class="spinner-border spinner-border-sm text-muted"></span>
</div>
</div>
</div>
</div>
<!-- Current Location Card -->
<div class="card mb-4">
<div class="card-header">
<div class="card-title-text"><i class="bi bi-geo-alt"></i> Nuværende Lokation</div>
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#locationModal">
<i class="bi bi-arrow-left-right"></i> Skift
</button>
</div>
<div class="card-body">
{% if locations and locations|length > 0 %}
{% set current_loc = locations[0] %}
{% if not current_loc.end_date %}
<div class="d-flex align-items-center">
<div class="icon-box bg-soft-primary">
<i class="bi bi-building"></i>
</div>
<div>
<h5 class="mb-1">{{ current_loc.location_name or 'Ukendt' }}</h5>
<small class="text-muted">Siden {{ current_loc.start_date }}</small>
</div>
</div>
{% if current_loc and not current_loc.end_date %}
<div class="text-center py-3">
<div class="fs-4 mb-2"><i class="bi bi-building"></i></div>
<h5 class="fw-bold">{{ current_loc.location_name }}</h5>
<p class="text-muted small mb-0">Siden: {{ current_loc.start_date }}</p>
{% if current_loc.notes %}
<div class="mt-3 p-2 bg-light rounded small text-muted">
<i class="bi bi-card-text me-1"></i> {{ current_loc.notes }}
</div>
<div class="mt-2 text-muted fst-italic small">"{{ current_loc.notes }}"</div>
{% endif %}
</div>
{% else %}
<div class="text-center text-muted py-3">
<i class="bi bi-geo-alt" style="font-size: 2rem; opacity: 0.5;"></i>
<p class="mt-2 text-primary">Ingen aktiv lokation</p>
</div>
{% endif %}
{% else %}
<div class="text-center py-2">
<p class="text-muted mb-3">Hardwaret er ikke tildelt en lokation</p>
<button class="btn btn-primary w-100" data-bs-toggle="modal" data-bs-target="#locationModal">
<i class="bi bi-plus-circle me-1"></i> Tildel Lokation
</button>
<div class="text-center py-4 text-muted">
<p class="mb-2">Ingen aktiv lokation</p>
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#locationModal">Tildel nu</button>
</div>
{% endif %}
</div>
</div>
<!-- Current Owner Card -->
<div class="card mb-4">
<div class="card-header">
<div class="card-title-text"><i class="bi bi-person"></i> Nuværende Ejer</div>
</div>
<div class="col-md-6">
<div class="card h-100 shadow-sm border-0">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3">
<h6 class="text-success mb-0"><i class="bi bi-person me-2"></i>Ejer</h6>
</div>
<div class="card-body">
{% if ownership and ownership|length > 0 %}
{% set current_own = ownership[0] %}
{% if not current_own.end_date %}
<div class="d-flex align-items-center">
<div class="icon-box bg-soft-success">
<i class="bi bi-person-badge"></i>
</div>
<div>
<h5 class="mb-1">
{{ current_own.customer_name or current_own.owner_type|title }}
</h5>
<small class="text-muted">Siden {{ current_own.start_date }}</small>
</div>
{% if current_owner and not current_owner.end_date %}
<div class="text-center py-3">
<div class="fs-4 mb-2 text-success"><i class="bi bi-person-badge"></i></div>
<h5 class="fw-bold">{{ current_owner.customer_name or current_owner.owner_type|title }}</h5>
<p class="text-muted small mb-0">Siden: {{ current_owner.start_date }}</p>
</div>
{% else %}
<p class="text-muted text-center py-2">Ingen aktiv ejer registreret</p>
{% endif %}
{% else %}
<p class="text-muted text-center py-2">Ingen ejerhistorik</p>
<div class="text-center py-4 text-muted">
<p class="mb-0">Ingen aktiv ejer</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Right Column: Quick Add & History -->
<div class="col-lg-8">
<!-- Quick Actions Grid -->
<div class="row mb-4">
<div class="col-md-3">
<div class="action-card" onclick="alert('Funktion: Opret Sag til dette hardware')">
<i class="bi bi-ticket-perforated"></i>
<div>Opret Sag</div>
</div>
</div>
<div class="col-md-3">
<div class="action-card" data-bs-toggle="modal" data-bs-target="#locationModal">
<i class="bi bi-geo-alt"></i>
<div>Skift Lokation</div>
</div>
</div>
<div class="col-md-3">
<!-- Link to create new location, pre-filled? Or just general create -->
<a href="/app/locations" class="text-decoration-none">
<div class="action-card">
<i class="bi bi-building-add"></i>
<div>Ny Lokation</div>
</div>
<!-- Right Column: Quick Actions & Related -->
<div class="col-lg-4">
<!-- Quick Actions -->
<div class="row g-2 mb-4">
<div class="col-6">
<a href="#" class="action-card text-decoration-none" onclick="alert('Funktion: Opret Sag til dette hardware (kommer snart)')">
<i class="bi bi-ticket-perforated text-primary"></i>
<span class="small fw-bold">Opret Sag</span>
</a>
</div>
<div class="col-md-3">
<div class="action-card" onclick="alert('Funktion: Upload bilag')">
<i class="bi bi-paperclip"></i>
<div>Tilføj Bilag</div>
<div class="col-6">
<div class="action-card" data-bs-toggle="modal" data-bs-target="#locationModal">
<i class="bi bi-geo-alt text-primary"></i>
<span class="small fw-bold">Skift Lokation</span>
</div>
</div>
<div class="col-6">
<a href="/app/locations" class="action-card text-decoration-none">
<i class="bi bi-building-add text-secondary"></i>
<span class="small fw-bold">Ny Lokation</span>
</a>
</div>
<div class="col-6">
<div class="action-card" onclick="alert('Funktion: Upload bilag (kommer snart)')">
<i class="bi bi-paperclip text-secondary"></i>
<span class="small fw-bold">Tilføj Bilag</span>
</div>
</div>
</div>
<!-- Tabs Section -->
<div class="card">
<div class="card-header p-0 border-bottom-0">
<ul class="nav nav-tabs ps-3 pt-3 pe-3 w-100" id="hwTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="history-tab" data-bs-toggle="tab" data-bs-target="#history" type="button" role="tab">Historik</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="cases-tab" data-bs-toggle="tab" data-bs-target="#cases" type="button" role="tab">Sager ({{ cases|length }})</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="files-tab" data-bs-toggle="tab" data-bs-target="#files" type="button" role="tab">Filer ({{ attachments|length }})</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="notes-tab" data-bs-toggle="tab" data-bs-target="#notes" type="button" role="tab">Noter</button>
</li>
</ul>
<!-- Linked Cases -->
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3">
<h6 class="text-secondary mb-0"><i class="bi bi-briefcase me-2"></i>Seneste Sager</h6>
</div>
<div class="list-group list-group-flush">
{% if cases and cases|length > 0 %}
{% for case in cases[:5] %}
<a href="/sag/{{ case.case_id }}" class="list-group-item list-group-item-action border-0 px-3 py-2">
<div class="d-flex w-100 justify-content-between align-items-center">
<div class="text-truncate" style="max-width: 70%;">
<i class="bi bi-ticket me-1 text-muted small"></i>
<span class="small fw-bold text-dark">{{ case.titel }}</span>
</div>
<span class="badge bg-light text-dark border">{{ case.status }}</span>
</div>
<div class="small text-muted ms-3">{{ case.created_at.strftime('%Y-%m-%d') if case.created_at else '' }}</div>
</a>
{% endfor %}
{% if cases|length > 5 %}
<div class="card-footer bg-white text-center p-2">
<a href="#cases-tab" class="small text-decoration-none" onclick="document.getElementById('cases-tab').click()">Se alle {{ cases|length }} sager</a>
</div>
{% endif %}
{% else %}
<div class="text-center py-4 text-muted small">
Ingen sager tilknyttet
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Tab: History -->
<div class="tab-pane fade" id="history" role="tabpanel">
<div class="card shadow-sm border-0">
<div class="card-body">
<div class="tab-content" id="hwTabsContent">
<!-- History Tab -->
<div class="tab-pane fade show active" id="history" role="tabpanel">
<h6 class="text-secondary text-uppercase small fw-bold mb-4">Kombineret Historik</h6>
<div class="row">
<div class="col-md-6">
<h6 class="text-primary mb-3 ps-3 border-start border-3 border-primary">Lokations Historik</h6>
<div class="timeline">
<!-- Interleave items visually? For now just dump both lists or keep separate sections inside tab -->
<!-- Let's show Location History first -->
<div class="mb-4">
<strong class="d-block mb-3 text-primary"><i class="bi bi-geo-alt"></i> Placeringer</strong>
{% if locations %}
{% for loc in locations %}
<div class="timeline-item {% if not loc.end_date %}active{% endif %}">
<div class="timeline-marker"></div>
<div class="ms-2">
<div class="fw-bold">{{ loc.location_name or 'Ukendt' }} ({{ loc.start_date }} {% if loc.end_date %} - {{ loc.end_date }}{% else %}- nu{% endif %})</div>
{% if loc.notes %}<div class="text-muted small">{{ loc.notes }}</div>{% endif %}
<div class="ps-2">
<div class="fw-bold">{{ loc.location_name or 'Ukendt' }}</div>
<div class="text-muted small">
{{ loc.start_date }}
{% if loc.end_date %} - {{ loc.end_date }}{% else %} <span class="badge bg-success py-0">Nuværende</span>{% endif %}
</div>
{% if loc.notes %}<div class="text-muted small fst-italic mt-1">"{{ loc.notes }}"</div>{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<p class="text-muted fst-italic">Ingen lokations historik</p>
<p class="text-muted ps-4">Ingen historik.</p>
{% endif %}
</div>
<div class="mb-4">
<strong class="d-block mb-3 text-success"><i class="bi bi-person"></i> Ejerskab</strong>
</div>
<div class="col-md-6">
<h6 class="text-success mb-3 ps-3 border-start border-3 border-success">Ejerskabs Historik</h6>
<div class="timeline">
{% if ownership %}
{% for own in ownership %}
<div class="timeline-item {% if not own.end_date %}active{% endif %}">
<div class="timeline-marker"></div>
<div class="ms-2">
<div class="fw-bold">{{ own.customer_name or own.owner_type }} ({{ own.start_date }} {% if own.end_date %} - {{ own.end_date }}{% else %}- nu{% endif %})</div>
{% if own.notes %}<div class="text-muted small">{{ own.notes }}</div>{% endif %}
<div class="timeline-marker" style="border-color: var(--bs-success) !important; {% if not own.end_date %}background: var(--bs-success);{% endif %}"></div>
<div class="ps-2">
<div class="fw-bold">{{ own.customer_name or own.owner_type }}</div>
<div class="text-muted small">
{{ own.start_date }}
{% if own.end_date %} - {{ own.end_date }}{% else %} <span class="badge bg-success py-0">Nuværende</span>{% endif %}
</div>
{% if own.notes %}<div class="text-muted small fst-italic mt-1">"{{ own.notes }}"</div>{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<p class="text-muted fst-italic">Ingen ejerskabs historik</p>
<p class="text-muted ps-4">Ingen historik.</p>
{% endif %}
</div>
</div>
</div>
<!-- Cases Tab -->
<div class="tab-pane fade" id="cases" role="tabpanel">
{% if cases and cases|length > 0 %}
<div class="list-group list-group-flush">
{% for case in cases %}
<a href="/sag/{{ case.case_id }}" class="list-group-item list-group-item-action py-3 px-2">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1 text-primary">{{ case.titel }}</h6>
<small>{{ case.created_at }}</small>
</div>
<div class="d-flex justify-content-between align-items-center mt-1">
<small class="text-muted">Status: {{ case.status }}</small>
<span class="badge bg-light text-dark border">ID: {{ case.case_id }}</span>
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="text-center py-5">
<i class="bi bi-clipboard-check display-4 text-muted opacity-25"></i>
<p class="mt-3 text-muted">Ingen sager tilknyttet.</p>
<button class="btn btn-sm btn-outline-primary" onclick="alert('Opret Sag')">Opret ny sag</button>
</div>
{% endif %}
</div>
<!-- Attachments Tab -->
<!-- Tab: Files -->
<div class="tab-pane fade" id="files" role="tabpanel">
<div class="card shadow-sm border-0">
<div class="card-body">
<div class="row g-3">
{% if attachments %}
{% for att in attachments %}
<div class="col-md-4 col-sm-6">
<div class="p-3 border rounded text-center bg-light h-100">
<div class="col-md-3 col-sm-6">
<div class="p-3 border rounded text-center bg-light h-100 position-relative">
<div class="display-6 mb-2">📎</div>
<div class="text-truncate fw-bold">{{ att.file_name }}</div>
<div class="text-truncate fw-bold mb-1" title="{{ att.file_name }}">{{ att.file_name }}</div>
<div class="small text-muted">{{ att.uploaded_at }}</div>
<a href="#" class="stretched-link" onclick="alert('Download not implemented yet')"></a>
</div>
</div>
{% endfor %}
{% else %}
<div class="col-12 text-center py-4 text-muted">
Ingen filer vedhæftet
<div class="col-12 text-center py-5 text-muted">
<i class="bi bi-files display-4 opacity-25"></i>
<p class="mt-3">Ingen filer vedhæftet</p>
<button class="btn btn-sm btn-outline-primary" onclick="alert('Upload funktion kommer snart')">Upload fil</button>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Notes Tab -->
<!-- Tab: Notes -->
<div class="tab-pane fade" id="notes" role="tabpanel">
<div class="card shadow-sm border-0">
<div class="card-body">
<h6 class="mb-3">Noter</h6>
<div class="p-3 bg-light rounded border">
{% if hardware.notes %}
{{ hardware.notes }}
<div style="white-space: pre-wrap;">{{ hardware.notes }}</div>
{% else %}
<span class="text-muted fst-italic">Ingen noter...</span>
<span class="text-muted fst-italic">Ingen noter tilføjet...</span>
{% endif %}
</div>
<div class="mt-3 text-end">
<a href="/hardware/{{ hardware.id }}/edit" class="btn btn-sm btn-outline-primary">Rediger Noter</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -693,19 +680,6 @@
}
}
} else {
// Hide if not a match and doesn't contain matches
// BUT be careful not to hide if a parent matched?
// Actually, search usually filters down. If parent matches, should we show all children?
// Let's stick to showing matches and path to matches.
// Important: logic is tricky with flat recursion vs nested DOM
// My macro structure is nested: .location-item-container contains children-container which contains .location-item-container
// So `container.style.display = 'block'` on a parent effectively shows the wrapper.
// If I am not a match, and I have no children that are matches...
// But wait, if my parent is a match, do I show up?
// Usually "Search" filters items out.
if (isMatch || hasChildMatch) {
container.style.display = 'block';
} else {

View File

@ -210,6 +210,15 @@
<div class="form-group">
<label for="anydesk_id">AnyDesk ID</label>
<input type="text" id="anydesk_id" name="anydesk_id" value="{{ hardware.anydesk_id or '' }}" placeholder="123-456-789">
{% if hardware.anydesk_id %}
<div class="small mt-2">
{% if hardware.anydesk_link %}
<a href="{{ hardware.anydesk_link }}" target="_blank">Test forbindelse</a>
{% else %}
<a href="anydesk://{{ hardware.anydesk_id }}" target="_blank">Test forbindelse</a>
{% endif %}
</div>
{% endif %}
</div>
<div class="form-group">
<label for="anydesk_link">AnyDesk Link</label>

View File

@ -0,0 +1,293 @@
{% extends "shared/frontend/base.html" %}
{% block title %}ESET Import - Hardware - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.page-header {
margin-bottom: 2rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.section-card {
background: var(--bg-card);
border-radius: 12px;
border: 1px solid rgba(0,0,0,0.1);
padding: 1.5rem;
margin-bottom: 2rem;
}
.table thead th {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.02em;
color: var(--text-secondary);
}
.device-uuid {
max-width: 240px;
word-break: break-all;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.85rem;
padding: 0.35rem 0.6rem;
border-radius: 999px;
background: var(--accent-light);
color: var(--accent);
}
.contact-results {
max-height: 220px;
overflow: auto;
border: 1px solid rgba(0,0,0,0.1);
border-radius: 8px;
background: var(--bg-body);
}
.contact-result {
padding: 0.6rem 0.8rem;
cursor: pointer;
display: flex;
justify-content: space-between;
gap: 1rem;
}
.contact-result:hover {
background: var(--accent-light);
}
.contact-muted {
color: var(--text-secondary);
font-size: 0.85rem;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid" style="margin-top: 2rem; max-width: 1400px;">
<div class="page-header">
<h1>⬇️ ESET Import</h1>
<div class="d-flex gap-2">
<a href="/hardware/eset" class="btn btn-outline-secondary">ESET Oversigt</a>
<a href="/hardware" class="btn btn-outline-secondary">Tilbage til hardware</a>
</div>
</div>
<div class="section-card">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<div class="status-pill" id="deviceStatus">Ingen data indlaest</div>
<button class="btn btn-primary" onclick="loadDevices()">Hent devices</button>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Navn</th>
<th>Serial</th>
<th>Gruppe</th>
<th>Device UUID</th>
<th>Handling</th>
</tr>
</thead>
<tbody id="devicesTable">
<tr>
<td colspan="5" class="text-center text-muted">Klik "Hent devices" for at hente ESET-listen.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Import Modal -->
<div class="modal fade" id="esetImportModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Import fra ESET</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Device UUID</label>
<input type="text" class="form-control" id="importDeviceUuid" readonly>
</div>
<div class="mb-3">
<label class="form-label">Find kontakt</label>
<div class="input-group mb-2">
<input type="text" class="form-control" id="contactSearch" placeholder="Navn eller email">
<button class="btn btn-outline-secondary" type="button" onclick="searchContacts()">Sog</button>
</div>
<div id="contactResults" class="contact-results"></div>
</div>
<div class="mb-3">
<label class="form-label">Valgt kontakt</label>
<input type="text" class="form-control" id="selectedContact" placeholder="Ingen valgt" readonly>
</div>
<div id="importStatus" class="contact-muted"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="importDevice()">Importer</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
const devicesTable = document.getElementById('devicesTable');
const deviceStatus = document.getElementById('deviceStatus');
const importModal = new bootstrap.Modal(document.getElementById('esetImportModal'));
let selectedContactId = null;
function parseDevices(payload) {
if (Array.isArray(payload)) return payload;
if (!payload || typeof payload !== 'object') return [];
return payload.devices || payload.items || payload.results || payload.data || [];
}
function getField(device, keys) {
for (const key of keys) {
if (device[key]) return device[key];
}
return '';
}
function renderDevices(devices) {
if (!devices.length) {
devicesTable.innerHTML = '<tr><td colspan="5" class="text-center text-muted">Ingen devices fundet.</td></tr>';
return;
}
devicesTable.innerHTML = devices.map(device => {
const uuid = getField(device, ['deviceUuid', 'uuid', 'id']);
const name = getField(device, ['displayName', 'deviceName', 'name']);
const serial = getField(device, ['serialNumber', 'serial', 'serial_number']);
const group = getField(device, ['parentGroup', 'groupPath', 'group', 'path']);
return `
<tr>
<td>${name || '-'}</td>
<td>${serial || '-'}</td>
<td>${group || '-'}</td>
<td class="device-uuid">${uuid || '-'}</td>
<td>
<button class="btn btn-sm btn-outline-primary" onclick="openImportModal('${uuid || ''}')">Importer</button>
</td>
</tr>
`;
}).join('');
}
async function loadDevices() {
deviceStatus.textContent = 'Henter...';
try {
const response = await fetch('/api/v1/hardware/eset/devices');
if (!response.ok) {
const err = await response.text();
throw new Error(err || 'Request failed');
}
const data = await response.json();
const devices = parseDevices(data);
deviceStatus.textContent = `${devices.length} devices hentet`;
renderDevices(devices);
} catch (err) {
deviceStatus.textContent = 'Fejl ved hentning';
devicesTable.innerHTML = `<tr><td colspan="5" class="text-center text-danger">${err.message}</td></tr>`;
}
}
function openImportModal(uuid) {
document.getElementById('importDeviceUuid').value = uuid;
document.getElementById('contactSearch').value = '';
document.getElementById('contactResults').innerHTML = '';
document.getElementById('selectedContact').value = '';
document.getElementById('importStatus').textContent = '';
selectedContactId = null;
importModal.show();
}
async function searchContacts() {
const query = document.getElementById('contactSearch').value.trim();
const results = document.getElementById('contactResults');
if (!query) {
results.innerHTML = '<div class="p-2 text-muted">Indtast soegning.</div>';
return;
}
results.innerHTML = '<div class="p-2 text-muted">Soeger...</div>';
try {
const response = await fetch(`/api/v1/contacts?search=${encodeURIComponent(query)}&limit=20`);
if (!response.ok) {
const err = await response.text();
throw new Error(err || 'Request failed');
}
const data = await response.json();
const contacts = data.contacts || [];
if (!contacts.length) {
results.innerHTML = '<div class="p-2 text-muted">Ingen kontakter fundet.</div>';
return;
}
results.innerHTML = contacts.map(c => {
const name = `${c.first_name || ''} ${c.last_name || ''}`.trim();
const company = (c.company_names || []).join(', ');
return `
<div class="contact-result" onclick="selectContact(${c.id}, '${name.replace(/'/g, "\\'")}', '${company.replace(/'/g, "\\'")}')">
<div>
<div>${name || 'Ukendt'}</div>
<div class="contact-muted">${c.email || ''}</div>
</div>
<div class="contact-muted">${company || '-'}</div>
</div>
`;
}).join('');
} catch (err) {
results.innerHTML = `<div class="p-2 text-danger">${err.message}</div>`;
}
}
function selectContact(id, name, company) {
selectedContactId = id;
const label = company ? `${name} (${company})` : name;
document.getElementById('selectedContact').value = label;
document.getElementById('contactResults').innerHTML = '';
}
async function importDevice() {
const uuid = document.getElementById('importDeviceUuid').value.trim();
const statusEl = document.getElementById('importStatus');
statusEl.textContent = 'Importer...';
try {
const payload = { device_uuid: uuid };
if (selectedContactId) {
payload.contact_id = selectedContactId;
}
const response = await fetch('/api/v1/hardware/eset/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
const err = await response.text();
throw new Error(err || 'Request failed');
}
const data = await response.json();
statusEl.textContent = `Importeret hardware #${data.id}`;
} catch (err) {
statusEl.textContent = `Fejl: ${err.message}`;
}
}
</script>
{% endblock %}

View File

@ -0,0 +1,163 @@
{% extends "shared/frontend/base.html" %}
{% block title %}ESET Oversigt - Hardware - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.page-header {
margin-bottom: 2rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.section-card {
background: var(--bg-card);
border-radius: 12px;
border: 1px solid rgba(0,0,0,0.1);
padding: 1.5rem;
margin-bottom: 2rem;
}
.section-title {
font-size: 1.2rem;
font-weight: 700;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.table thead th {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.02em;
color: var(--text-secondary);
}
.badge-severity {
font-size: 0.75rem;
padding: 0.35rem 0.6rem;
border-radius: 999px;
color: white;
background: #dc3545;
}
.badge-high {
background: #fd7e14;
}
.badge-severe {
background: #6f42c1;
}
.empty-state {
padding: 1.5rem;
border: 1px dashed rgba(0,0,0,0.2);
border-radius: 10px;
text-align: center;
color: var(--text-secondary);
background: var(--bg-body);
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid" style="margin-top: 2rem; max-width: 1400px;">
<div class="page-header">
<h1>🛡️ ESET Oversigt</h1>
<div class="d-flex gap-2">
<a href="/hardware/eset/import" class="btn btn-outline-secondary">Import</a>
<a href="/hardware/eset/test" class="btn btn-outline-secondary">ESET Test</a>
<a href="/hardware" class="btn btn-outline-secondary">Tilbage til hardware</a>
</div>
</div>
<div class="section-card">
<div class="section-title">🔗 Matchede enheder</div>
{% if matches %}
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Hardware</th>
<th>Serial</th>
<th>ESET UUID</th>
<th>ESET Gruppe</th>
<th>Kontakt</th>
<th>Company</th>
<th>Kunde</th>
<th>Opdateret</th>
</tr>
</thead>
<tbody>
{% for row in matches %}
<tr>
<td>
<a href="/hardware/{{ row.id }}">
{{ row.brand or '' }} {{ row.model or '' }}
</a>
</td>
<td>{{ row.serial_number or '-' }}</td>
<td style="max-width: 220px; word-break: break-all;">{{ row.eset_uuid or '-' }}</td>
<td style="max-width: 220px; word-break: break-all;">{{ row.eset_group or '-' }}</td>
<td>
{% if row.contact_id %}
{{ row.first_name or '' }} {{ row.last_name or '' }}
{% else %}
-
{% endif %}
</td>
<td>{{ row.user_company or '-' }}</td>
<td>{{ row.customer_name or '-' }}</td>
<td>{{ row.updated_at or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">Ingen matchede enheder fundet endnu.</div>
{% endif %}
</div>
<div class="section-card">
<div class="section-title">🚨 Kritiske incidents</div>
{% if incidents %}
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Severity</th>
<th>Status</th>
<th>Device UUID</th>
<th>Detected</th>
<th>Last Seen</th>
</tr>
</thead>
<tbody>
{% for inc in incidents %}
<tr>
<td>
{% set sev = (inc.severity or '')|lower %}
<span class="badge-severity {% if sev == 'high' %}badge-high{% elif sev == 'severe' %}badge-severe{% endif %}">
{{ inc.severity or 'critical' }}
</span>
</td>
<td>{{ inc.status or '-' }}</td>
<td style="max-width: 220px; word-break: break-all;">{{ inc.device_uuid or '-' }}</td>
<td>{{ inc.detected_at or '-' }}</td>
<td>{{ inc.last_seen or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">Ingen kritiske incidents lige nu.</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,121 @@
{% extends "shared/frontend/base.html" %}
{% block title %}ESET Test - Hardware - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.page-header {
margin-bottom: 2rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.section-card {
background: var(--bg-card);
border-radius: 12px;
border: 1px solid rgba(0,0,0,0.1);
padding: 1.5rem;
margin-bottom: 2rem;
}
.result-box {
background: var(--bg-body);
border: 1px solid rgba(0,0,0,0.1);
border-radius: 10px;
padding: 1rem;
max-height: 420px;
overflow: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.85rem;
white-space: pre-wrap;
word-break: break-word;
}
.btn-wide {
min-width: 160px;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid" style="margin-top: 2rem; max-width: 1200px;">
<div class="page-header">
<h1>🧪 ESET Test</h1>
<div class="d-flex gap-2">
<a href="/hardware/eset" class="btn btn-outline-secondary">ESET Oversigt</a>
<a href="/hardware" class="btn btn-outline-secondary">Tilbage til hardware</a>
</div>
</div>
<div class="section-card">
<h5 class="mb-3">Test device list (raw)</h5>
<div class="d-flex gap-2 flex-wrap mb-3">
<button class="btn btn-primary btn-wide" onclick="loadDevices()">Hent devices</button>
<button class="btn btn-outline-secondary btn-wide" onclick="clearOutput()">Ryd</button>
</div>
<div id="devicesOutput" class="result-box">Tryk "Hent devices" for at teste forbindelsen.</div>
</div>
<div class="section-card">
<h5 class="mb-3">Test single device</h5>
<div class="input-group mb-3">
<input type="text" class="form-control" id="deviceUuid" placeholder="Device UUID">
<button class="btn btn-primary" onclick="loadDevice()">Hent device</button>
</div>
<div id="deviceOutput" class="result-box">Indtast en UUID og klik "Hent device".</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
function setOutput(el, content) {
el.textContent = content;
}
function clearOutput() {
setOutput(document.getElementById('devicesOutput'), 'Rykket.');
setOutput(document.getElementById('deviceOutput'), 'Rykket.');
}
async function loadDevices() {
const output = document.getElementById('devicesOutput');
setOutput(output, 'Henter devices...');
try {
const response = await fetch('/api/v1/hardware/eset/devices');
if (!response.ok) {
const err = await response.text();
throw new Error(err || 'Request failed');
}
const data = await response.json();
setOutput(output, JSON.stringify(data, null, 2));
} catch (err) {
setOutput(output, `Fejl: ${err.message}`);
}
}
async function loadDevice() {
const output = document.getElementById('deviceOutput');
const uuid = (document.getElementById('deviceUuid').value || '').trim();
if (!uuid) {
setOutput(output, 'Indtast en device UUID.');
return;
}
setOutput(output, 'Henter device...');
try {
const response = await fetch(`/api/v1/hardware/eset/test?device_uuid=${encodeURIComponent(uuid)}`);
if (!response.ok) {
const err = await response.text();
throw new Error(err || 'Request failed');
}
const data = await response.json();
setOutput(output, JSON.stringify(data, null, 2));
} catch (err) {
setOutput(output, `Fejl: ${err.message}`);
}
}
</script>
{% endblock %}

View File

@ -242,11 +242,17 @@
{% block content %}
<div class="page-header">
<h1>🖥️ Hardware Assets</h1>
<h1>🖥️ Hardware Oversigt</h1>
<div class="d-flex gap-2">
<a href="/hardware/eset" class="btn-new-hardware" style="background-color: #0f4c75;">
<i class="bi bi-shield-check"></i>
ESET Oversigt
</a>
<a href="/hardware/new" class="btn-new-hardware">
Nyt Hardware
</a>
</div>
</div>
<div class="filter-section">
<form method="get" action="/hardware">
@ -319,6 +325,18 @@
<span class="hardware-detail-label">Type:</span>
<span class="hardware-detail-value">{{ item.asset_type|title }}</span>
</div>
{% if item.anydesk_id or item.anydesk_link %}
<div class="hardware-detail-row">
<span class="hardware-detail-label">AnyDesk:</span>
<span class="hardware-detail-value">
{% if item.anydesk_link %}
<a href="{{ item.anydesk_link }}" target="_blank">{{ item.anydesk_id or 'Åbn' }}</a>
{% elif item.anydesk_id %}
<a href="anydesk://{{ item.anydesk_id }}" target="_blank">{{ item.anydesk_id }}</a>
{% endif %}
</span>
</div>
{% endif %}
{% if item.customer_name %}
<div class="hardware-detail-row">
<span class="hardware-detail-label">Ejer:</span>

View File

@ -220,10 +220,7 @@
<div class="col-md-4">
<label class="form-label">AnyDesk ID</label>
<input type="text" class="form-control" id="hardwareAnyDeskIdInput" placeholder="123-456-789">
</div>
<div class="col-md-4">
<label class="form-label">AnyDesk Link</label>
<input type="text" class="form-control" id="hardwareAnyDeskLinkInput" placeholder="anydesk://...">
<div class="form-text small text-muted">Link genereres automatisk.</div>
</div>
<div class="col-12 d-flex justify-content-end">
<button type="button" class="btn btn-outline-primary" onclick="quickCreateHardware()">
@ -565,10 +562,11 @@
}
}
async function quickCreateHardware() {
const name = document.getElementById('hardwareNameInput').value.trim();
const anydeskId = document.getElementById('hardwareAnyDeskIdInput').value.trim();
const anydeskLink = document.getElementById('hardwareAnyDeskLinkInput').value.trim();
const anydeskLink = anydeskId ? `anydesk://${anydeskId}` : null;
if (!name) {
alert('Navn er påkrævet');
@ -600,7 +598,6 @@
document.getElementById('hardwareNameInput').value = '';
document.getElementById('hardwareAnyDeskIdInput').value = '';
document.getElementById('hardwareAnyDeskLinkInput').value = '';
await loadHardwareForContacts();
} catch (err) {
alert('Fejl: ' + err.message);

View File

@ -0,0 +1,140 @@
"""
ESET PROTECT Integration Service
"""
import logging
import time
import httpx
from typing import Dict, Optional, Any
from app.core.config import settings
logger = logging.getLogger(__name__)
class EsetService:
def __init__(self):
self.base_url = settings.ESET_API_URL.rstrip('/')
self.iam_url = settings.ESET_IAM_URL.rstrip('/')
self.incidents_url = settings.ESET_INCIDENTS_URL.rstrip('/')
self.username = settings.ESET_USERNAME
self.password = settings.ESET_PASSWORD
self.client_id = settings.ESET_OAUTH_CLIENT_ID
self.client_secret = settings.ESET_OAUTH_CLIENT_SECRET
self.scope = settings.ESET_OAUTH_SCOPE
self.enabled = settings.ESET_ENABLED
# Disable SSL verification for ESET usually (self-signed certs common)
# In production this should be configurable
self.verify_ssl = False
self._access_token = None
self._access_token_expires_at = 0.0
async def _authenticate(self, client: httpx.AsyncClient) -> Optional[str]:
"""Authenticate and return access token."""
if not self.enabled:
return None
try:
# OAuth token endpoint via ESET Connect IAM
url = f"{self.iam_url}/oauth/token"
payload = {
"grant_type": "password",
"username": self.username,
"password": self.password
}
if self.scope:
payload["scope"] = self.scope
if self.client_id:
payload["client_id"] = self.client_id
auth = None
if self.client_id and self.client_secret:
auth = (self.client_id, self.client_secret)
logger.info(f"Authenticating with ESET IAM at {url}")
response = await client.post(
url,
data=payload,
auth=auth,
headers={"Content-Type": "application/x-www-form-urlencoded"}
)
if response.status_code == 200:
token_data = response.json()
access_token = token_data.get("access_token")
expires_in = token_data.get("expires_in", 3600)
if access_token:
self._access_token = access_token
self._access_token_expires_at = time.time() + int(expires_in) - 30
logger.info("✅ ESET Authentication successful")
return access_token
logger.error("❌ ESET Auth failed: missing access_token")
return None
else:
logger.error(f"❌ ESET Auth failed: {response.status_code} - {response.text[:200]}")
return None
except Exception as e:
logger.error(f"❌ ESET Auth error: {str(e)}")
return None
async def _get_access_token(self, client: httpx.AsyncClient) -> Optional[str]:
if self._access_token and time.time() < self._access_token_expires_at:
return self._access_token
return await self._authenticate(client)
async def _get_json(self, client: httpx.AsyncClient, url: str, params: Optional[dict] = None) -> Optional[Dict[str, Any]]:
token = await self._get_access_token(client)
if not token:
return None
response = await client.get(url, params=params, headers={"Authorization": f"Bearer {token}"})
if response.status_code == 200:
return response.json()
logger.error(
"ESET API error %s for %s: %s",
response.status_code,
url,
(response.text or "")[:500]
)
return None
async def list_devices(self) -> Optional[Dict[str, Any]]:
"""List devices from ESET Device Management."""
if not self.enabled:
logger.warning("ESET not enabled")
return None
url = f"{self.base_url}/v1/devices"
async with httpx.AsyncClient(verify=self.verify_ssl, timeout=settings.ESET_TIMEOUT_SECONDS) as client:
payload = await self._get_json(client, url)
if not payload:
logger.warning("ESET devices payload empty")
return payload
async def list_incidents(self) -> Optional[Dict[str, Any]]:
"""List incidents from ESET Incident Management."""
if not self.enabled:
logger.warning("ESET not enabled")
return None
url = f"{self.incidents_url}/v1/incidents"
async with httpx.AsyncClient(verify=self.verify_ssl, timeout=settings.ESET_TIMEOUT_SECONDS) as client:
return await self._get_json(client, url)
async def get_device_details(self, device_uuid: str) -> Optional[Dict[str, Any]]:
"""Fetch device details from ESET by UUID"""
if not self.enabled:
logger.warning("ESET not enabled")
return None
url = f"{self.base_url}/v1/devices/{device_uuid}"
try:
async with httpx.AsyncClient(verify=self.verify_ssl, timeout=settings.ESET_TIMEOUT_SECONDS) as client:
response = await self._get_json(client, url)
if response is None:
return None
return response
except Exception as e:
logger.error(f"ESET API error: {str(e)}")
return None
eset_service = EsetService()

View File

@ -239,6 +239,7 @@
<li><a class="dropdown-item py-2" href="/conversations/my"><i class="bi bi-mic me-2"></i>Mine Samtaler</a></li>
<li><a class="dropdown-item py-2" href="/ticket/archived"><i class="bi bi-archive me-2"></i>Arkiverede Tickets</a></li>
<li><a class="dropdown-item py-2" href="/hardware"><i class="bi bi-laptop me-2"></i>Hardware Assets</a></li>
<li><a class="dropdown-item py-2" href="/hardware/eset"><i class="bi bi-shield-check me-2"></i>ESET Oversigt</a></li>
<li><a class="dropdown-item py-2" href="/app/locations"><i class="bi bi-map-fill me-2"></i>Lokaliteter</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="/prepaid-cards"><i class="bi bi-credit-card-2-front me-2"></i>Prepaid Cards</a></li>
@ -313,7 +314,7 @@
<li><a class="dropdown-item py-2" href="/backups"><i class="bi bi-hdd-stack me-2"></i>Backup System</a></li>
<li><a class="dropdown-item py-2" href="/devportal"><i class="bi bi-code-square me-2"></i>DEV Portal</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2 text-danger" href="#">Log ud</a></li>
<li><a class="dropdown-item py-2 text-danger" href="#" onclick="logoutUser(event)"><i class="bi bi-box-arrow-right me-2"></i>Log ud</a></li>
</ul>
</div>
</div>
@ -1341,6 +1342,21 @@
checkMaintenanceMode();
}
}, 30000);
// Global Logout Function
function logoutUser(event) {
if (event) event.preventDefault();
// Clear local storage
localStorage.removeItem('access_token');
localStorage.removeItem('user');
// Clear cookies
document.cookie = "access_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
// Redirect to login
window.location.href = '/login';
}
</script>
{% block scripts %}{% endblock %}

View File

@ -848,7 +848,7 @@
}
// vTiger's 'duration' field is in seconds, convert to minutes
if (fieldName === 'duration' && typeof rawValue !== 'string') {
if (fieldName === 'duration') {
const seconds = typeof rawValue === 'number' ? rawValue : parseFloat(String(rawValue));
if (Number.isFinite(seconds)) {
return seconds / 60;
@ -1002,6 +1002,17 @@
const row = document.createElement('tr');
const hoursData = getTimelogHours(item);
const hours = normalizeTimelogHours(hoursData.value, hoursData.field);
// DEBUG: Log første entry for at verificere parsing
if (timelogs.indexOf(item) === 0) {
console.log('🔍 TIMELOG DEBUG:', {
id: item.id,
hoursData: hoursData,
parsedHours: hours,
rawItem: item
});
}
const relatedId = getTimelogRelatedId(item) || '-';
const relatedCase = caseMap.get(relatedId);
const caseNumber = relatedCase

13
main.py
View File

@ -125,6 +125,19 @@ async def lifespan(app: FastAPI):
)
logger.info("✅ Reminder job scheduled (every 5 minutes)")
if settings.ESET_ENABLED and settings.ESET_SYNC_ENABLED:
from app.jobs.eset_sync import run_eset_sync
backup_scheduler.scheduler.add_job(
func=run_eset_sync,
trigger=IntervalTrigger(minutes=settings.ESET_SYNC_INTERVAL_MINUTES),
id='eset_sync',
name='ESET Sync',
max_instances=1,
replace_existing=True
)
logger.info("✅ ESET sync job scheduled (every %d minutes)", settings.ESET_SYNC_INTERVAL_MINUTES)
logger.info("✅ System initialized successfully")
yield
# Shutdown

View File

@ -0,0 +1,8 @@
-- Add ESET integration fields to hardware_assets
-- Enables storing ESET UUID and raw hardware specs
ALTER TABLE hardware_assets
ADD COLUMN IF NOT EXISTS eset_uuid VARCHAR(100),
ADD COLUMN IF NOT EXISTS hardware_specs JSONB;
CREATE INDEX IF NOT EXISTS idx_hardware_eset_uuid ON hardware_assets(eset_uuid);

View File

@ -0,0 +1,30 @@
-- ESET sync support: hardware contacts + incidents cache
CREATE TABLE IF NOT EXISTS hardware_contacts (
id SERIAL PRIMARY KEY,
hardware_id INT NOT NULL REFERENCES hardware_assets(id) ON DELETE CASCADE,
contact_id INT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
role VARCHAR(50) DEFAULT 'primary',
source VARCHAR(50) DEFAULT 'eset',
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(hardware_id, contact_id)
);
CREATE INDEX IF NOT EXISTS idx_hardware_contacts_hardware ON hardware_contacts(hardware_id);
CREATE INDEX IF NOT EXISTS idx_hardware_contacts_contact ON hardware_contacts(contact_id);
CREATE TABLE IF NOT EXISTS eset_incidents (
id SERIAL PRIMARY KEY,
incident_uuid VARCHAR(100) UNIQUE,
severity VARCHAR(50),
status VARCHAR(50),
device_uuid VARCHAR(100),
detected_at TIMESTAMP,
last_seen TIMESTAMP,
payload JSONB,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_eset_incidents_severity ON eset_incidents(severity);
CREATE INDEX IF NOT EXISTS idx_eset_incidents_device ON eset_incidents(device_uuid);

View File

@ -0,0 +1,6 @@
-- Add ESET group field to hardware_assets
ALTER TABLE hardware_assets
ADD COLUMN IF NOT EXISTS eset_group TEXT;
CREATE INDEX IF NOT EXISTS idx_hardware_eset_group ON hardware_assets(eset_group);