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) # 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"? ## 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**. **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('access_token', data.access_token);
localStorage.setItem('user', JSON.stringify(data.user)); 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 // Redirect to dashboard
window.location.href = '/'; window.location.href = '/';
} else { } else {
@ -153,6 +158,11 @@ if (token) {
}) })
.then(response => { .then(response => {
if (response.ok) { 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 // Redirect to dashboard
window.location.href = '/'; window.location.href = '/';
} else { } else {

View File

@ -213,6 +213,22 @@ class Settings(BaseSettings):
ANYDESK_TIMEOUT_SECONDS: int = 30 ANYDESK_TIMEOUT_SECONDS: int = 30
ANYDESK_AUTO_START_SESSION: bool = True # Auto-start session when requested 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 Integration (CPSMS)
SMS_API_KEY: str = "" SMS_API_KEY: str = ""
SMS_USERNAME: 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 typing import List, Optional
from fastapi import APIRouter, HTTPException, Query, UploadFile, File from fastapi import APIRouter, HTTPException, Query, UploadFile, File
from app.core.database import execute_query 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 from datetime import datetime, date
import os import os
import uuid import uuid
@ -9,6 +11,92 @@ import uuid
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() 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 # CRUD Endpoints for Hardware Assets
# ============================================================================ # ============================================================================
@ -80,11 +168,17 @@ async def create_hardware(data: dict):
asset_type, brand, model, serial_number, customer_asset_id, asset_type, brand, model, serial_number, customer_asset_id,
internal_asset_id, notes, current_owner_type, current_owner_customer_id, internal_asset_id, notes, current_owner_type, current_owner_customer_id,
status, status_reason, warranty_until, end_of_life, 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 * RETURNING *
""" """
specs = data.get("hardware_specs")
if specs:
specs = Json(specs)
params = ( params = (
data.get("asset_type"), data.get("asset_type"),
data.get("brand"), data.get("brand"),
@ -101,6 +195,9 @@ async def create_hardware(data: dict):
data.get("end_of_life"), data.get("end_of_life"),
data.get("anydesk_id"), data.get("anydesk_id"),
data.get("anydesk_link"), data.get("anydesk_link"),
data.get("eset_uuid"),
specs,
data.get("eset_group")
) )
result = execute_query(query, params) result = execute_query(query, params)
if not result: 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", "asset_type", "brand", "model", "serial_number", "customer_asset_id",
"internal_asset_id", "notes", "current_owner_type", "current_owner_customer_id", "internal_asset_id", "notes", "current_owner_type", "current_owner_customer_id",
"status", "status_reason", "warranty_until", "end_of_life", "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: for field in allowed_fields:
if field in data: if field in data:
update_fields.append(f"{field} = %s") 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: if not update_fields:
raise HTTPException(status_code=400, detail="No valid fields to update") 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") logger.info(f"✅ Search for '{q}' returned {len(result) if result else 0} results")
return result or [] 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: if hardware:
customer_ids = [h['current_owner_customer_id'] for h in hardware if h.get('current_owner_customer_id')] customer_ids = [h['current_owner_customer_id'] for h in hardware if h.get('current_owner_customer_id')]
if customer_ids: 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,)) customers = execute_query(customer_query, (customer_ids,))
customer_map = {c['id']: c['navn'] for c in customers} if customers else {} 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): async def create_hardware_form(request: Request):
"""Display create hardware form.""" """Display create hardware form."""
# Get customers for dropdown # 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", { return templates.TemplateResponse("modules/hardware/templates/create.html", {
"request": request, "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) @router.get("/hardware/{hardware_id}", response_class=HTMLResponse)
async def hardware_detail(request: Request, hardware_id: int): async def hardware_detail(request: Request, hardware_id: int):
"""Display hardware details.""" """Display hardware details."""
@ -126,7 +188,7 @@ async def hardware_detail(request: Request, hardware_id: int):
# Get customer name if applicable # Get customer name if applicable
if hardware.get('current_owner_customer_id'): 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'],)) customer_result = execute_query(customer_query, (hardware['current_owner_customer_id'],))
if customer_result: if customer_result:
hardware['customer_name'] = customer_result[0]['navn'] hardware['customer_name'] = customer_result[0]['navn']
@ -143,7 +205,7 @@ async def hardware_detail(request: Request, hardware_id: int):
if ownership: if ownership:
customer_ids = [o['owner_customer_id'] for o in ownership if o.get('owner_customer_id')] customer_ids = [o['owner_customer_id'] for o in ownership if o.get('owner_customer_id')]
if customer_ids: 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,)) customers = execute_query(customer_query, (customer_ids,))
customer_map = {c['id']: c['navn'] for c in customers} if customers else {} 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] hardware = result[0]
# Get customers for dropdown # 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", { return templates.TemplateResponse("modules/hardware/templates/edit.html", {
"request": request, "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 %} {% block title %}Opret Hardware - BMC Hub{% endblock %}
@ -216,6 +216,20 @@
<input type="text" id="anydesk_link" name="anydesk_link" placeholder="anydesk://..."> <input type="text" id="anydesk_link" name="anydesk_link" placeholder="anydesk://...">
</div> </div>
</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> </div>
<!-- Ownership --> <!-- 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) { async function submitForm(event) {
event.preventDefault(); event.preventDefault();
@ -345,5 +386,9 @@
// Initialize customer select visibility // Initialize customer select visibility
toggleCustomerSelect(); toggleCustomerSelect();
// AnyDesk live preview
document.getElementById('anydesk_id').addEventListener('input', updateAnyDeskPreview);
updateAnyDeskPreview();
</script> </script>
{% endblock %} {% endblock %}

View File

@ -4,77 +4,11 @@
{% block extra_css %} {% block extra_css %}
<style> <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 { .info-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
padding: 0.75rem 0; padding: 0.8rem 0;
border-bottom: 1px solid rgba(0,0,0,0.03); border-bottom: 1px solid rgba(0,0,0,0.05);
} }
.info-row:last-child { .info-row:last-child {
@ -82,22 +16,20 @@
} }
.info-label { .info-label {
font-weight: 600;
color: var(--text-secondary); color: var(--text-secondary);
font-weight: 500; min-width: 150px;
font-size: 0.9rem;
} }
.info-value { .info-value {
color: var(--text-primary); color: var(--text-primary);
font-weight: 600; word-break: break-all;
text-align: right;
} }
/* Quick Action Cards */
.action-card { .action-card {
background: white; background: white;
padding: 1.5rem; padding: 1rem;
border-radius: 12px; border-radius: 8px;
text-align: center; text-align: center;
border: 1px dashed rgba(0,0,0,0.1); border: 1px dashed rgba(0,0,0,0.1);
cursor: pointer; cursor: pointer;
@ -107,6 +39,7 @@
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
gap: 0.5rem;
color: var(--text-secondary); color: var(--text-secondary);
} }
@ -114,146 +47,223 @@
border-color: var(--accent); border-color: var(--accent);
background: var(--accent-light); background: var(--accent-light);
color: var(--accent); color: var(--accent);
transform: translateY(-2px); text-decoration: none;
} }
.action-card i { .action-card i {
font-size: 2rem; font-size: 1.5rem;
margin-bottom: 0.5rem;
} }
/* Timeline */ /* Timeline Styling */
.timeline { .timeline {
position: relative; position: relative;
padding-left: 2rem; padding-left: 1.5rem;
} }
.timeline::before { .timeline::before {
content: ''; content: '';
position: absolute; position: absolute;
left: 0.5rem; left: 0.4rem;
top: 0; top: 0;
bottom: 0; bottom: 0;
width: 2px; width: 2px;
background: #e9ecef; background: #e9ecef;
} }
.timeline-item { .timeline-item {
position: relative; position: relative;
padding-bottom: 1.5rem; padding-bottom: 1.5rem;
} }
.timeline-marker { .timeline-marker {
position: absolute; position: absolute;
left: -2rem; left: -1.09rem;
top: 0.2rem; top: 0.3rem;
width: 1rem; width: 0.8rem;
height: 1rem; height: 0.8rem;
border-radius: 50%; border-radius: 50%;
background: white; background: white;
border: 2px solid var(--accent); border: 2px solid var(--accent);
} }
.timeline-item.active .timeline-marker { .timeline-item.active .timeline-marker {
background: #28a745; background: #28a745;
border-color: #28a745; border-color: #28a745;
} }
.icon-box { /* Quick Info Bar */
width: 48px; .quick-info-label {
height: 48px; color: var(--accent);
font-weight: 700;
margin-right: 0.4rem;
}
.quick-info-item {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; border-right: 1px solid rgba(0,0,0,0.1);
border-radius: 12px; padding-right: 0.75rem;
font-size: 1.5rem; margin-right: 0.75rem;
margin-right: 1rem; }
.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> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<!-- Custom Nordic Blue Header --> <div class="container-fluid" style="margin-top: 2rem; margin-bottom: 2rem; max-width: 1400px;">
<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>
<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 }} {{ hardware.status|replace('_', ' ')|title }}
</span> </span>
</div> </div>
</div>
</div> <!-- Link to Edit -->
<div class="d-flex gap-2"> <div class="ms-auto d-flex gap-2">
<a href="/hardware/{{ hardware.id }}/edit" class="btn btn-light text-primary fw-medium shadow-sm"> <a href="/hardware/{{ hardware.id }}/edit" class="btn btn-sm btn-outline-primary" title="Rediger">
<i class="bi bi-pencil me-1"></i> Rediger <i class="bi bi-pencil"></i>
</a> </a>
<button onclick="deleteHardware()" class="btn btn-danger text-white fw-medium shadow-sm" style="background-color: rgba(220, 53, 69, 0.9);"> <button onclick="deleteHardware()" class="btn btn-sm btn-outline-danger" title="Slet">
<i class="bi bi-trash me-1"></i> Slet <i class="bi bi-trash"></i>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="container-fluid px-4 main-content"> <!-- Tabs Navigation -->
<div class="row"> <ul class="nav nav-tabs mb-4 px-2" id="hwTabs" role="tablist">
<!-- Left Column: Key Info & Relations --> <li class="nav-item" role="presentation">
<div class="col-lg-4"> <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="tab-content" id="hwTabsContent">
<div class="card mb-4">
<div class="card-header"> <!-- Tab: Details -->
<div class="card-title-text"><i class="bi bi-info-circle"></i> Stamdata</div> <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>
<div class="card-body pt-0"> <div class="card-body">
<div class="info-row"> <div class="info-row">
<span class="info-label">Type</span> <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> </div>
{% if hardware.internal_asset_id %} {% if hardware.internal_asset_id %}
<div class="info-row"> <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> <span class="info-value">{{ hardware.internal_asset_id }}</span>
</div> </div>
{% endif %} {% endif %}
{% if hardware.customer_asset_id %} {% if hardware.customer_asset_id %}
<div class="info-row"> <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> <span class="info-value">{{ hardware.customer_asset_id }}</span>
</div> </div>
{% endif %} {% endif %}
{% if hardware.warranty_until %} {% if hardware.warranty_until %}
<div class="info-row"> <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> <span class="info-value">{{ hardware.warranty_until }}</span>
</div> </div>
{% endif %} {% endif %}
@ -267,279 +277,256 @@
</div> </div>
<!-- AnyDesk Card --> <!-- AnyDesk Card -->
<div class="card mb-4"> {% set anydesk_url = hardware.anydesk_id and ('anydesk://' ~ hardware.anydesk_id) %}
<div class="card-header"> <div class="card mb-4 shadow-sm border-0">
<div class="card-title-text"><i class="bi bi-display"></i> AnyDesk</div> <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>
<div class="card-body"> <div class="card-body">
<div class="info-row"> <div class="info-row">
<span class="info-label">AnyDesk ID</span> <span class="info-label">AnyDesk ID</span>
<span class="info-value">{{ hardware.anydesk_id or '-' }}</span> <span class="info-value fw-bold font-monospace">
</div> {% if anydesk_url %}
<div class="info-row"> <a href="{{ anydesk_url }}" target="_blank" rel="noreferrer noopener">{{ hardware.anydesk_id }}</a>
<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>
{% else %} {% else %}
- -
{% endif %} {% endif %}
</span> </span>
</div> </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>
</div> </div>
<!-- Tags Card --> <!-- Location & Owner Grid -->
<div class="card mb-4"> <div class="row">
<div class="card-header"> <div class="col-md-6">
<div class="card-title-text"><i class="bi bi-tags"></i> Tags</div> <div class="card h-100 shadow-sm border-0">
<button class="btn btn-sm btn-outline-primary" onclick="window.showTagPicker('hardware', {{ hardware.id }}, () => window.renderEntityTags('hardware', {{ hardware.id }}, 'hardware-tags'))"> <div class="card-header bg-white border-bottom-0 pt-3 ps-3 d-flex justify-content-between align-items-center">
<i class="bi bi-plus-lg"></i> Tilføj <h6 class="text-primary mb-0"><i class="bi bi-geo-alt me-2"></i>Lokation</h6>
</button> <button class="btn btn-sm btn-link p-0" data-bs-toggle="modal" data-bs-target="#locationModal">Ændre</button>
</div> </div>
<div class="card-body"> <div class="card-body">
<div id="hardware-tags" class="d-flex flex-wrap"> {% if current_loc and not current_loc.end_date %}
<!-- Tags loaded via JS --> <div class="text-center py-3">
<div class="text-center w-100 py-2"> <div class="fs-4 mb-2"><i class="bi bi-building"></i></div>
<span class="spinner-border spinner-border-sm text-muted"></span> <h5 class="fw-bold">{{ current_loc.location_name }}</h5>
</div> <p class="text-muted small mb-0">Siden: {{ current_loc.start_date }}</p>
</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.notes %} {% if current_loc.notes %}
<div class="mt-3 p-2 bg-light rounded small text-muted"> <div class="mt-2 text-muted fst-italic small">"{{ current_loc.notes }}"</div>
<i class="bi bi-card-text me-1"></i> {{ current_loc.notes }}
</div>
{% endif %} {% endif %}
</div>
{% else %} {% else %}
<div class="text-center text-muted py-3"> <div class="text-center py-4 text-muted">
<i class="bi bi-geo-alt" style="font-size: 2rem; opacity: 0.5;"></i> <p class="mb-2">Ingen aktiv lokation</p>
<p class="mt-2 text-primary">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 %}
{% 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> </div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div>
<!-- Current Owner Card --> <div class="col-md-6">
<div class="card mb-4"> <div class="card h-100 shadow-sm border-0">
<div class="card-header"> <div class="card-header bg-white border-bottom-0 pt-3 ps-3">
<div class="card-title-text"><i class="bi bi-person"></i> Nuværende Ejer</div> <h6 class="text-success mb-0"><i class="bi bi-person me-2"></i>Ejer</h6>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if ownership and ownership|length > 0 %} {% if current_owner and not current_owner.end_date %}
{% set current_own = ownership[0] %} <div class="text-center py-3">
{% if not current_own.end_date %} <div class="fs-4 mb-2 text-success"><i class="bi bi-person-badge"></i></div>
<div class="d-flex align-items-center"> <h5 class="fw-bold">{{ current_owner.customer_name or current_owner.owner_type|title }}</h5>
<div class="icon-box bg-soft-success"> <p class="text-muted small mb-0">Siden: {{ current_owner.start_date }}</p>
<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>
</div> </div>
{% else %} {% else %}
<p class="text-muted text-center py-2">Ingen aktiv ejer registreret</p> <div class="text-center py-4 text-muted">
{% endif %} <p class="mb-0">Ingen aktiv ejer</p>
{% else %} </div>
<p class="text-muted text-center py-2">Ingen ejerhistorik</p>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div>
</div>
</div> </div>
<!-- Right Column: Quick Add & History --> <!-- Right Column: Quick Actions & Related -->
<div class="col-lg-8"> <div class="col-lg-4">
<!-- Quick Actions -->
<!-- Quick Actions Grid --> <div class="row g-2 mb-4">
<div class="row mb-4"> <div class="col-6">
<div class="col-md-3"> <a href="#" class="action-card text-decoration-none" onclick="alert('Funktion: Opret Sag til dette hardware (kommer snart)')">
<div class="action-card" onclick="alert('Funktion: Opret Sag til dette hardware')"> <i class="bi bi-ticket-perforated text-primary"></i>
<i class="bi bi-ticket-perforated"></i> <span class="small fw-bold">Opret Sag</span>
<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>
</a> </a>
</div> </div>
<div class="col-md-3"> <div class="col-6">
<div class="action-card" onclick="alert('Funktion: Upload bilag')"> <div class="action-card" data-bs-toggle="modal" data-bs-target="#locationModal">
<i class="bi bi-paperclip"></i> <i class="bi bi-geo-alt text-primary"></i>
<div>Tilføj Bilag</div> <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> </div>
</div> </div>
<!-- Tabs Section --> <!-- Linked Cases -->
<div class="card"> <div class="card shadow-sm border-0 mb-4">
<div class="card-header p-0 border-bottom-0"> <div class="card-header bg-white border-bottom-0 pt-3 ps-3">
<ul class="nav nav-tabs ps-3 pt-3 pe-3 w-100" id="hwTabs" role="tablist"> <h6 class="text-secondary mb-0"><i class="bi bi-briefcase me-2"></i>Seneste Sager</h6>
<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>
</div> </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="card-body">
<div class="tab-content" id="hwTabsContent"> <div class="row">
<div class="col-md-6">
<!-- History Tab --> <h6 class="text-primary mb-3 ps-3 border-start border-3 border-primary">Lokations Historik</h6>
<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="timeline"> <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 %} {% if locations %}
{% for loc in locations %} {% for loc in locations %}
<div class="timeline-item {% if not loc.end_date %}active{% endif %}"> <div class="timeline-item {% if not loc.end_date %}active{% endif %}">
<div class="timeline-marker"></div> <div class="timeline-marker"></div>
<div class="ms-2"> <div class="ps-2">
<div class="fw-bold">{{ loc.location_name or 'Ukendt' }} ({{ loc.start_date }} {% if loc.end_date %} - {{ loc.end_date }}{% else %}- nu{% endif %})</div> <div class="fw-bold">{{ loc.location_name or 'Ukendt' }}</div>
{% if loc.notes %}<div class="text-muted small">{{ loc.notes }}</div>{% endif %} <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>
</div> </div>
{% endfor %} {% endfor %}
{% else %} {% else %}
<p class="text-muted fst-italic">Ingen lokations historik</p> <p class="text-muted ps-4">Ingen historik.</p>
{% endif %} {% endif %}
</div> </div>
</div>
<div class="mb-4"> <div class="col-md-6">
<strong class="d-block mb-3 text-success"><i class="bi bi-person"></i> Ejerskab</strong> <h6 class="text-success mb-3 ps-3 border-start border-3 border-success">Ejerskabs Historik</h6>
<div class="timeline">
{% if ownership %} {% if ownership %}
{% for own in ownership %} {% for own in ownership %}
<div class="timeline-item {% if not own.end_date %}active{% endif %}"> <div class="timeline-item {% if not own.end_date %}active{% endif %}">
<div class="timeline-marker"></div> <div class="timeline-marker" style="border-color: var(--bs-success) !important; {% if not own.end_date %}background: var(--bs-success);{% endif %}"></div>
<div class="ms-2"> <div class="ps-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> <div class="fw-bold">{{ own.customer_name or own.owner_type }}</div>
{% if own.notes %}<div class="text-muted small">{{ own.notes }}</div>{% endif %} <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>
</div> </div>
{% endfor %} {% endfor %}
{% else %} {% else %}
<p class="text-muted fst-italic">Ingen ejerskabs historik</p> <p class="text-muted ps-4">Ingen historik.</p>
{% endif %} {% endif %}
</div> </div>
</div> </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>
<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> </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> </div>
<!-- Attachments Tab --> <!-- Tab: Files -->
<div class="tab-pane fade" id="files" role="tabpanel"> <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"> <div class="row g-3">
{% if attachments %} {% if attachments %}
{% for att in attachments %} {% for att in attachments %}
<div class="col-md-4 col-sm-6"> <div class="col-md-3 col-sm-6">
<div class="p-3 border rounded text-center bg-light h-100"> <div class="p-3 border rounded text-center bg-light h-100 position-relative">
<div class="display-6 mb-2">📎</div> <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> <div class="small text-muted">{{ att.uploaded_at }}</div>
<a href="#" class="stretched-link" onclick="alert('Download not implemented yet')"></a>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
{% else %} {% else %}
<div class="col-12 text-center py-4 text-muted"> <div class="col-12 text-center py-5 text-muted">
Ingen filer vedhæftet <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> </div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div>
</div>
<!-- Notes Tab --> <!-- Tab: Notes -->
<div class="tab-pane fade" id="notes" role="tabpanel"> <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"> <div class="p-3 bg-light rounded border">
{% if hardware.notes %} {% if hardware.notes %}
{{ hardware.notes }} <div style="white-space: pre-wrap;">{{ hardware.notes }}</div>
{% else %} {% else %}
<span class="text-muted fst-italic">Ingen noter...</span> <span class="text-muted fst-italic">Ingen noter tilføjet...</span>
{% endif %} {% endif %}
</div> </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> </div>
</div>
</div> </div>
</div> </div>
@ -693,19 +680,6 @@
} }
} }
} else { } 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) { if (isMatch || hasChildMatch) {
container.style.display = 'block'; container.style.display = 'block';
} else { } else {

View File

@ -210,6 +210,15 @@
<div class="form-group"> <div class="form-group">
<label for="anydesk_id">AnyDesk ID</label> <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"> <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>
<div class="form-group"> <div class="form-group">
<label for="anydesk_link">AnyDesk Link</label> <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 %} {% block content %}
<div class="page-header"> <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"> <a href="/hardware/new" class="btn-new-hardware">
Nyt Hardware Nyt Hardware
</a> </a>
</div> </div>
</div>
<div class="filter-section"> <div class="filter-section">
<form method="get" action="/hardware"> <form method="get" action="/hardware">
@ -319,6 +325,18 @@
<span class="hardware-detail-label">Type:</span> <span class="hardware-detail-label">Type:</span>
<span class="hardware-detail-value">{{ item.asset_type|title }}</span> <span class="hardware-detail-value">{{ item.asset_type|title }}</span>
</div> </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 %} {% if item.customer_name %}
<div class="hardware-detail-row"> <div class="hardware-detail-row">
<span class="hardware-detail-label">Ejer:</span> <span class="hardware-detail-label">Ejer:</span>

View File

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

View File

@ -848,7 +848,7 @@
} }
// vTiger's 'duration' field is in seconds, convert to minutes // 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)); const seconds = typeof rawValue === 'number' ? rawValue : parseFloat(String(rawValue));
if (Number.isFinite(seconds)) { if (Number.isFinite(seconds)) {
return seconds / 60; return seconds / 60;
@ -1002,6 +1002,17 @@
const row = document.createElement('tr'); const row = document.createElement('tr');
const hoursData = getTimelogHours(item); const hoursData = getTimelogHours(item);
const hours = normalizeTimelogHours(hoursData.value, hoursData.field); 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 relatedId = getTimelogRelatedId(item) || '-';
const relatedCase = caseMap.get(relatedId); const relatedCase = caseMap.get(relatedId);
const caseNumber = relatedCase 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)") 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") logger.info("✅ System initialized successfully")
yield yield
# Shutdown # 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);