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:
parent
3d7fb1aa48
commit
297a8ef2d6
@ -1,253 +1,5 @@
|
||||
# Implementeringsplan: Sag-modulet (Case Module)
|
||||
|
||||
## Oversigt – Hvad er “Sag”?
|
||||
|
||||
**Sag-modulet** er hjertet i BMC Hub’s 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: 1–2 timer
|
||||
- Frontend (liste + detalje): 1–2 timer
|
||||
- Test + dokumentation: 1 time
|
||||
|
||||
**Total: 4–6 timer**
|
||||
|
||||
---
|
||||
|
||||
## TL;DR – for udvikler
|
||||
|
||||
- Alt er en sag
|
||||
- Forskelle = tags + relationer
|
||||
- Ingen tickets, ingen tasks, ingen orders
|
||||
- Relationer danner kæder
|
||||
- Tags styrer arbejdet
|
||||
- Status er kun åben/lukket
|
||||
|
||||
---
|
||||
|
||||
Hvis du vil næste skridt, kan vi:
|
||||
- lave SQL CTE-eksempler til traversal
|
||||
- definere første reference-workflow
|
||||
- skrive README “Architectural Laws”
|
||||
- eller lave et diagram, der matcher præcis dette
|
||||
|
||||
Men modellen?
|
||||
Den er nu færdig og sund.# Implementeringsplan: Sag-modulet (Case Module)
|
||||
|
||||
## Oversigt - Hvad er "Sag"?
|
||||
|
||||
**Sag-modulet** er hjertet i BMC Hub's nye relation- og proces-styringssystem. I stedet for at have separate systemer for "tickets", "opgaver" og "ordrer", har vi én universel entitet: **en Sag**.
|
||||
|
||||
@ -120,6 +120,11 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
localStorage.setItem('access_token', data.access_token);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
|
||||
// Set cookie for HTML navigation access (expires in 24 hours)
|
||||
const d = new Date();
|
||||
d.setTime(d.getTime() + (24*60*60*1000));
|
||||
document.cookie = `access_token=${data.access_token};expires=${d.toUTCString()};path=/;SameSite=Lax`;
|
||||
|
||||
// Redirect to dashboard
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
@ -153,6 +158,11 @@ if (token) {
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
// Ensure cookie is set (sync with localStorage)
|
||||
const d = new Date();
|
||||
d.setTime(d.getTime() + (24*60*60*1000));
|
||||
document.cookie = `access_token=${token};expires=${d.toUTCString()};path=/;SameSite=Lax`;
|
||||
|
||||
// Redirect to dashboard
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
|
||||
@ -213,6 +213,22 @@ class Settings(BaseSettings):
|
||||
ANYDESK_TIMEOUT_SECONDS: int = 30
|
||||
ANYDESK_AUTO_START_SESSION: bool = True # Auto-start session when requested
|
||||
|
||||
# ESET Integration
|
||||
ESET_ENABLED: bool = False
|
||||
ESET_API_URL: str = "https://eu.device-management.eset.systems"
|
||||
ESET_IAM_URL: str = "https://eu.business-account.iam.eset.systems"
|
||||
ESET_INCIDENTS_URL: str = "https://eu.incident-management.eset.systems"
|
||||
ESET_USERNAME: str = ""
|
||||
ESET_PASSWORD: str = ""
|
||||
ESET_OAUTH_CLIENT_ID: str = ""
|
||||
ESET_OAUTH_CLIENT_SECRET: str = ""
|
||||
ESET_OAUTH_SCOPE: str = ""
|
||||
ESET_READ_ONLY: bool = True
|
||||
ESET_TIMEOUT_SECONDS: int = 30
|
||||
ESET_SYNC_ENABLED: bool = True
|
||||
ESET_SYNC_INTERVAL_MINUTES: int = 120
|
||||
ESET_INCIDENTS_ENABLED: bool = True
|
||||
|
||||
# SMS Integration (CPSMS)
|
||||
SMS_API_KEY: str = ""
|
||||
SMS_USERNAME: str = ""
|
||||
|
||||
308
app/jobs/eset_sync.py
Normal file
308
app/jobs/eset_sync.py
Normal 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()
|
||||
@ -2,6 +2,8 @@ import logging
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, HTTPException, Query, UploadFile, File
|
||||
from app.core.database import execute_query
|
||||
from app.services.eset_service import eset_service
|
||||
from psycopg2.extras import Json
|
||||
from datetime import datetime, date
|
||||
import os
|
||||
import uuid
|
||||
@ -9,6 +11,92 @@ import uuid
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _eset_extract_first_str(payload: dict, keys: List[str]) -> Optional[str]:
|
||||
if payload is None:
|
||||
return None
|
||||
|
||||
key_set = {k.lower() for k in keys}
|
||||
stack = [payload]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
if isinstance(current, dict):
|
||||
for k, v in current.items():
|
||||
if k.lower() in key_set and isinstance(v, str) and v.strip():
|
||||
return v.strip()
|
||||
if isinstance(v, (dict, list)):
|
||||
stack.append(v)
|
||||
elif isinstance(current, list):
|
||||
for item in current:
|
||||
if isinstance(item, (dict, list)):
|
||||
stack.append(item)
|
||||
return None
|
||||
|
||||
|
||||
def _eset_extract_group_path(payload: dict) -> Optional[str]:
|
||||
return _eset_extract_first_str(payload, ["parentGroup", "groupPath", "group", "path"])
|
||||
|
||||
|
||||
def _eset_extract_group_name(payload: dict) -> Optional[str]:
|
||||
group_path = _eset_extract_group_path(payload)
|
||||
if group_path and "/" in group_path:
|
||||
name = group_path.split("/")[-1].strip()
|
||||
return name or None
|
||||
return group_path
|
||||
|
||||
|
||||
def _eset_extract_company(payload: dict) -> Optional[str]:
|
||||
company = _eset_extract_first_str(payload, ["company", "organization", "tenant", "customer", "userCompany"])
|
||||
if company:
|
||||
return company
|
||||
group_path = _eset_extract_group_path(payload)
|
||||
if group_path and "/" in group_path:
|
||||
return group_path.split("/")[-1].strip() or None
|
||||
return None
|
||||
|
||||
|
||||
def _eset_detect_asset_type(payload: dict) -> str:
|
||||
device_type = _eset_extract_first_str(payload, ["deviceType", "type"])
|
||||
if device_type:
|
||||
val = device_type.lower()
|
||||
if "server" in val:
|
||||
return "server"
|
||||
if "laptop" in val or "notebook" in val:
|
||||
return "laptop"
|
||||
return "pc"
|
||||
|
||||
|
||||
def _match_customer_exact(name: str) -> Optional[int]:
|
||||
if not name:
|
||||
return None
|
||||
result = execute_query("SELECT id FROM customers WHERE LOWER(name) = LOWER(%s)", (name,))
|
||||
if len(result or []) == 1:
|
||||
return result[0]["id"]
|
||||
return None
|
||||
|
||||
|
||||
def _get_contact_customer(contact_id: int) -> Optional[int]:
|
||||
query = """
|
||||
SELECT customer_id
|
||||
FROM contact_companies
|
||||
WHERE contact_id = %s
|
||||
ORDER BY is_primary DESC, id ASC
|
||||
LIMIT 1
|
||||
"""
|
||||
result = execute_query(query, (contact_id,))
|
||||
if result:
|
||||
return result[0]["customer_id"]
|
||||
return None
|
||||
|
||||
|
||||
def _upsert_hardware_contact(hardware_id: int, contact_id: int) -> None:
|
||||
query = """
|
||||
INSERT INTO hardware_contacts (hardware_id, contact_id, role, source)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON CONFLICT (hardware_id, contact_id) DO NOTHING
|
||||
"""
|
||||
execute_query(query, (hardware_id, contact_id, "primary", "eset"))
|
||||
|
||||
# ============================================================================
|
||||
# CRUD Endpoints for Hardware Assets
|
||||
# ============================================================================
|
||||
@ -80,11 +168,17 @@ async def create_hardware(data: dict):
|
||||
asset_type, brand, model, serial_number, customer_asset_id,
|
||||
internal_asset_id, notes, current_owner_type, current_owner_customer_id,
|
||||
status, status_reason, warranty_until, end_of_life,
|
||||
anydesk_id, anydesk_link
|
||||
anydesk_id, anydesk_link,
|
||||
eset_uuid, hardware_specs, eset_group
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING *
|
||||
"""
|
||||
|
||||
specs = data.get("hardware_specs")
|
||||
if specs:
|
||||
specs = Json(specs)
|
||||
|
||||
params = (
|
||||
data.get("asset_type"),
|
||||
data.get("brand"),
|
||||
@ -101,6 +195,9 @@ async def create_hardware(data: dict):
|
||||
data.get("end_of_life"),
|
||||
data.get("anydesk_id"),
|
||||
data.get("anydesk_link"),
|
||||
data.get("eset_uuid"),
|
||||
specs,
|
||||
data.get("eset_group")
|
||||
)
|
||||
result = execute_query(query, params)
|
||||
if not result:
|
||||
@ -200,13 +297,17 @@ async def update_hardware(hardware_id: int, data: dict):
|
||||
"asset_type", "brand", "model", "serial_number", "customer_asset_id",
|
||||
"internal_asset_id", "notes", "current_owner_type", "current_owner_customer_id",
|
||||
"status", "status_reason", "warranty_until", "end_of_life",
|
||||
"follow_up_date", "follow_up_owner_user_id", "anydesk_id", "anydesk_link"
|
||||
"follow_up_date", "follow_up_owner_user_id", "anydesk_id", "anydesk_link",
|
||||
"eset_uuid", "hardware_specs", "eset_group"
|
||||
]
|
||||
|
||||
for field in allowed_fields:
|
||||
if field in data:
|
||||
update_fields.append(f"{field} = %s")
|
||||
params.append(data[field])
|
||||
val = data[field]
|
||||
if field == "hardware_specs" and val:
|
||||
val = Json(val)
|
||||
params.append(val)
|
||||
|
||||
if not update_fields:
|
||||
raise HTTPException(status_code=400, detail="No valid fields to update")
|
||||
@ -586,3 +687,209 @@ async def search_hardware(q: str = Query(..., min_length=1)):
|
||||
|
||||
logger.info(f"✅ Search for '{q}' returned {len(result) if result else 0} results")
|
||||
return result or []
|
||||
|
||||
|
||||
@router.post("/hardware/{hardware_id}/sync-eset", response_model=dict)
|
||||
async def sync_eset_data(hardware_id: int, eset_uuid: Optional[str] = Query(None)):
|
||||
"""Sync hardware data from ESET."""
|
||||
# Get current hardware
|
||||
check_query = "SELECT * FROM hardware_assets WHERE id = %s AND deleted_at IS NULL"
|
||||
result = execute_query(check_query, (hardware_id,))
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Hardware not found")
|
||||
current = result[0]
|
||||
|
||||
# Determine UUID
|
||||
uuid_to_use = eset_uuid or current.get("eset_uuid")
|
||||
if not uuid_to_use:
|
||||
raise HTTPException(status_code=400, detail="No ESET UUID provided or found on asset. Please provide 'eset_uuid' query parameter.")
|
||||
|
||||
# Fetch from ESET
|
||||
details = await eset_service.get_device_details(uuid_to_use)
|
||||
if not details:
|
||||
raise HTTPException(status_code=404, detail="Device not found in ESET")
|
||||
|
||||
# Update hardware asset
|
||||
update_data = {
|
||||
"eset_uuid": uuid_to_use,
|
||||
"hardware_specs": details
|
||||
}
|
||||
|
||||
# We can perform the update directly here or call update_hardware if available
|
||||
return await update_hardware(hardware_id, update_data)
|
||||
|
||||
|
||||
@router.get("/hardware/eset/test", response_model=dict)
|
||||
async def test_eset_device(device_uuid: str = Query(..., min_length=1)):
|
||||
"""Test ESET device lookup by UUID."""
|
||||
details = await eset_service.get_device_details(device_uuid)
|
||||
if not details:
|
||||
raise HTTPException(status_code=404, detail="Device not found in ESET")
|
||||
|
||||
return details
|
||||
|
||||
|
||||
@router.get("/hardware/eset/devices", response_model=dict)
|
||||
async def list_eset_devices():
|
||||
"""List devices directly from ESET Device Management."""
|
||||
payload = await eset_service.list_devices()
|
||||
if not payload:
|
||||
raise HTTPException(status_code=404, detail="No devices returned from ESET")
|
||||
return payload
|
||||
|
||||
|
||||
@router.post("/hardware/eset/import", response_model=dict)
|
||||
async def import_eset_device(data: dict):
|
||||
"""Import ESET device into hardware assets and optionally link to contact."""
|
||||
device_uuid = (data.get("device_uuid") or "").strip()
|
||||
contact_id = data.get("contact_id")
|
||||
|
||||
if not device_uuid:
|
||||
raise HTTPException(status_code=400, detail="device_uuid is required")
|
||||
|
||||
details = await eset_service.get_device_details(device_uuid)
|
||||
if not details:
|
||||
raise HTTPException(status_code=404, detail="Device not found in ESET")
|
||||
|
||||
serial = _eset_extract_first_str(details, ["serialNumber", "serial", "serial_number"])
|
||||
model = _eset_extract_first_str(details, ["model", "deviceModel", "deviceName", "name"])
|
||||
brand = _eset_extract_first_str(details, ["manufacturer", "brand", "vendor"])
|
||||
group_path = _eset_extract_group_path(details)
|
||||
group_name = _eset_extract_group_name(details)
|
||||
company = _eset_extract_company(details)
|
||||
|
||||
if contact_id:
|
||||
contact_check = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,))
|
||||
if not contact_check:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
|
||||
customer_id = _get_contact_customer(contact_id) if contact_id else None
|
||||
if not customer_id:
|
||||
customer_id = _match_customer_exact(group_name or company)
|
||||
|
||||
owner_type = "customer" if customer_id else "bmc"
|
||||
|
||||
conditions = ["eset_uuid = %s"]
|
||||
params = [device_uuid]
|
||||
if serial:
|
||||
conditions.append("serial_number = %s")
|
||||
params.append(serial)
|
||||
|
||||
lookup_query = f"SELECT * FROM hardware_assets WHERE deleted_at IS NULL AND ({' OR '.join(conditions)})"
|
||||
existing = execute_query(lookup_query, tuple(params))
|
||||
|
||||
if existing:
|
||||
hardware_id = existing[0]["id"]
|
||||
update_fields = ["eset_uuid = %s", "hardware_specs = %s", "updated_at = NOW()"]
|
||||
update_params = [device_uuid, Json(details)]
|
||||
|
||||
if group_path:
|
||||
update_fields.append("eset_group = %s")
|
||||
update_params.append(group_path)
|
||||
if not existing[0].get("serial_number") and serial:
|
||||
update_fields.append("serial_number = %s")
|
||||
update_params.append(serial)
|
||||
if not existing[0].get("model") and model:
|
||||
update_fields.append("model = %s")
|
||||
update_params.append(model)
|
||||
if not existing[0].get("brand") and brand:
|
||||
update_fields.append("brand = %s")
|
||||
update_params.append(brand)
|
||||
if customer_id:
|
||||
update_fields.append("current_owner_type = %s")
|
||||
update_params.append("customer")
|
||||
update_fields.append("current_owner_customer_id = %s")
|
||||
update_params.append(customer_id)
|
||||
|
||||
update_params.append(hardware_id)
|
||||
update_query = f"""
|
||||
UPDATE hardware_assets
|
||||
SET {', '.join(update_fields)}
|
||||
WHERE id = %s
|
||||
RETURNING *
|
||||
"""
|
||||
hardware = execute_query(update_query, tuple(update_params))
|
||||
hardware = hardware[0] if hardware else None
|
||||
else:
|
||||
insert_query = """
|
||||
INSERT INTO hardware_assets (
|
||||
asset_type, brand, model, serial_number,
|
||||
current_owner_type, current_owner_customer_id,
|
||||
notes, eset_uuid, hardware_specs, eset_group
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING *
|
||||
"""
|
||||
insert_params = (
|
||||
_eset_detect_asset_type(details),
|
||||
brand,
|
||||
model,
|
||||
serial,
|
||||
owner_type,
|
||||
customer_id,
|
||||
"Imported from ESET",
|
||||
device_uuid,
|
||||
Json(details),
|
||||
group_path
|
||||
)
|
||||
hardware = execute_query(insert_query, insert_params)
|
||||
hardware = hardware[0] if hardware else None
|
||||
|
||||
if hardware and contact_id:
|
||||
_upsert_hardware_contact(hardware["id"], contact_id)
|
||||
|
||||
return hardware or {}
|
||||
|
||||
|
||||
@router.get("/hardware/eset/matches", response_model=List[dict])
|
||||
async def list_eset_matches(limit: int = Query(500, ge=1, le=2000)):
|
||||
"""List ESET-matched hardware with contact/customer info."""
|
||||
query = """
|
||||
SELECT
|
||||
h.id,
|
||||
h.asset_type,
|
||||
h.brand,
|
||||
h.model,
|
||||
h.serial_number,
|
||||
h.eset_uuid,
|
||||
h.eset_group,
|
||||
h.updated_at,
|
||||
hc.contact_id,
|
||||
c.first_name,
|
||||
c.last_name,
|
||||
c.user_company,
|
||||
cc.customer_id,
|
||||
cust.name AS customer_name
|
||||
FROM hardware_assets h
|
||||
LEFT JOIN hardware_contacts hc ON hc.hardware_id = h.id
|
||||
LEFT JOIN contacts c ON c.id = hc.contact_id
|
||||
LEFT JOIN contact_companies cc ON cc.contact_id = c.id
|
||||
LEFT JOIN customers cust ON cust.id = cc.customer_id
|
||||
WHERE h.deleted_at IS NULL
|
||||
ORDER BY h.updated_at DESC NULLS LAST
|
||||
LIMIT %s
|
||||
"""
|
||||
result = execute_query(query, (limit,))
|
||||
return result or []
|
||||
|
||||
|
||||
@router.get("/hardware/eset/incidents", response_model=List[dict])
|
||||
async def list_eset_incidents(
|
||||
severity: Optional[str] = Query("critical"),
|
||||
limit: int = Query(200, ge=1, le=2000)
|
||||
):
|
||||
"""List cached ESET incidents by severity."""
|
||||
severity_list = [s.strip().lower() for s in (severity or "").split(",") if s.strip()]
|
||||
if not severity_list:
|
||||
severity_list = ["critical"]
|
||||
|
||||
query = """
|
||||
SELECT *
|
||||
FROM eset_incidents
|
||||
WHERE LOWER(COALESCE(severity, '')) = ANY(%s)
|
||||
ORDER BY updated_at DESC NULLS LAST
|
||||
LIMIT %s
|
||||
"""
|
||||
result = execute_query(query, (severity_list, limit))
|
||||
return result or []
|
||||
|
||||
|
||||
@ -83,7 +83,7 @@ async def hardware_list(
|
||||
if hardware:
|
||||
customer_ids = [h['current_owner_customer_id'] for h in hardware if h.get('current_owner_customer_id')]
|
||||
if customer_ids:
|
||||
customer_query = "SELECT id, navn FROM customers WHERE id = ANY(%s)"
|
||||
customer_query = "SELECT id, name AS navn FROM customers WHERE id = ANY(%s)"
|
||||
customers = execute_query(customer_query, (customer_ids,))
|
||||
customer_map = {c['id']: c['navn'] for c in customers} if customers else {}
|
||||
|
||||
@ -105,7 +105,7 @@ async def hardware_list(
|
||||
async def create_hardware_form(request: Request):
|
||||
"""Display create hardware form."""
|
||||
# Get customers for dropdown
|
||||
customers = execute_query("SELECT id, navn FROM customers WHERE deleted_at IS NULL ORDER BY navn")
|
||||
customers = execute_query("SELECT id, name AS navn FROM customers WHERE deleted_at IS NULL ORDER BY name")
|
||||
|
||||
return templates.TemplateResponse("modules/hardware/templates/create.html", {
|
||||
"request": request,
|
||||
@ -113,6 +113,68 @@ async def create_hardware_form(request: Request):
|
||||
})
|
||||
|
||||
|
||||
@router.get("/hardware/eset", response_class=HTMLResponse)
|
||||
async def hardware_eset_overview(request: Request):
|
||||
"""Display ESET sync overview (matches + incidents)."""
|
||||
matches_query = """
|
||||
SELECT
|
||||
h.id,
|
||||
h.asset_type,
|
||||
h.brand,
|
||||
h.model,
|
||||
h.serial_number,
|
||||
h.eset_uuid,
|
||||
h.eset_group,
|
||||
h.updated_at,
|
||||
hc.contact_id,
|
||||
c.first_name,
|
||||
c.last_name,
|
||||
c.user_company,
|
||||
cc.customer_id,
|
||||
cust.name AS customer_name
|
||||
FROM hardware_assets h
|
||||
LEFT JOIN hardware_contacts hc ON hc.hardware_id = h.id
|
||||
LEFT JOIN contacts c ON c.id = hc.contact_id
|
||||
LEFT JOIN contact_companies cc ON cc.contact_id = c.id
|
||||
LEFT JOIN customers cust ON cust.id = cc.customer_id
|
||||
WHERE h.deleted_at IS NULL
|
||||
ORDER BY h.updated_at DESC NULLS LAST
|
||||
LIMIT 500
|
||||
"""
|
||||
matches = execute_query(matches_query)
|
||||
|
||||
incidents_query = """
|
||||
SELECT *
|
||||
FROM eset_incidents
|
||||
WHERE LOWER(COALESCE(severity, '')) IN ('critical', 'high', 'severe')
|
||||
ORDER BY updated_at DESC NULLS LAST
|
||||
LIMIT 200
|
||||
"""
|
||||
incidents = execute_query(incidents_query)
|
||||
|
||||
return templates.TemplateResponse("modules/hardware/templates/eset_overview.html", {
|
||||
"request": request,
|
||||
"matches": matches or [],
|
||||
"incidents": incidents or []
|
||||
})
|
||||
|
||||
|
||||
@router.get("/hardware/eset/test", response_class=HTMLResponse)
|
||||
async def hardware_eset_test(request: Request):
|
||||
"""Display ESET API test page."""
|
||||
return templates.TemplateResponse("modules/hardware/templates/eset_test.html", {
|
||||
"request": request
|
||||
})
|
||||
|
||||
|
||||
@router.get("/hardware/eset/import", response_class=HTMLResponse)
|
||||
async def hardware_eset_import(request: Request):
|
||||
"""Display ESET import page."""
|
||||
return templates.TemplateResponse("modules/hardware/templates/eset_import.html", {
|
||||
"request": request
|
||||
})
|
||||
|
||||
|
||||
@router.get("/hardware/{hardware_id}", response_class=HTMLResponse)
|
||||
async def hardware_detail(request: Request, hardware_id: int):
|
||||
"""Display hardware details."""
|
||||
@ -126,7 +188,7 @@ async def hardware_detail(request: Request, hardware_id: int):
|
||||
|
||||
# Get customer name if applicable
|
||||
if hardware.get('current_owner_customer_id'):
|
||||
customer_query = "SELECT navn FROM customers WHERE id = %s"
|
||||
customer_query = "SELECT name AS navn FROM customers WHERE id = %s"
|
||||
customer_result = execute_query(customer_query, (hardware['current_owner_customer_id'],))
|
||||
if customer_result:
|
||||
hardware['customer_name'] = customer_result[0]['navn']
|
||||
@ -143,7 +205,7 @@ async def hardware_detail(request: Request, hardware_id: int):
|
||||
if ownership:
|
||||
customer_ids = [o['owner_customer_id'] for o in ownership if o.get('owner_customer_id')]
|
||||
if customer_ids:
|
||||
customer_query = "SELECT id, navn FROM customers WHERE id = ANY(%s)"
|
||||
customer_query = "SELECT id, name AS navn FROM customers WHERE id = ANY(%s)"
|
||||
customers = execute_query(customer_query, (customer_ids,))
|
||||
customer_map = {c['id']: c['navn'] for c in customers} if customers else {}
|
||||
|
||||
@ -219,7 +281,7 @@ async def edit_hardware_form(request: Request, hardware_id: int):
|
||||
hardware = result[0]
|
||||
|
||||
# Get customers for dropdown
|
||||
customers = execute_query("SELECT id, navn FROM customers WHERE deleted_at IS NULL ORDER BY navn")
|
||||
customers = execute_query("SELECT id, name AS navn FROM customers WHERE deleted_at IS NULL ORDER BY name")
|
||||
|
||||
return templates.TemplateResponse("modules/hardware/templates/edit.html", {
|
||||
"request": request,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
{% extends "shared/frontend/base.html" %}
|
||||
js{% extends "shared/frontend/base.html" %}
|
||||
|
||||
{% block title %}Opret Hardware - BMC Hub{% endblock %}
|
||||
|
||||
@ -216,6 +216,20 @@
|
||||
<input type="text" id="anydesk_link" name="anydesk_link" placeholder="anydesk://...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 small" id="anydesk_preview" style="display: none;">
|
||||
<a href="#" id="anydesk_preview_link" target="_blank">Test forbindelse</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ESET -->
|
||||
<div class="form-section">
|
||||
<h3 class="form-section-title">🛡️ ESET</h3>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="eset_uuid">ESET UUID</label>
|
||||
<input type="text" id="eset_uuid" name="eset_uuid" placeholder="ESET Device UUID">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ownership -->
|
||||
@ -304,6 +318,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
function updateAnyDeskPreview() {
|
||||
const idValue = document.getElementById('anydesk_id').value.trim();
|
||||
const preview = document.getElementById('anydesk_preview');
|
||||
const previewLink = document.getElementById('anydesk_preview_link');
|
||||
const linkInput = document.getElementById('anydesk_link');
|
||||
|
||||
if (!preview || !previewLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!idValue) {
|
||||
preview.style.display = 'none';
|
||||
previewLink.href = '#';
|
||||
if (linkInput) {
|
||||
linkInput.value = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const deepLink = `anydesk://${idValue}`;
|
||||
previewLink.href = deepLink;
|
||||
if (linkInput) {
|
||||
linkInput.value = deepLink;
|
||||
}
|
||||
preview.style.display = 'block';
|
||||
}
|
||||
|
||||
async function submitForm(event) {
|
||||
event.preventDefault();
|
||||
|
||||
@ -345,5 +386,9 @@
|
||||
|
||||
// Initialize customer select visibility
|
||||
toggleCustomerSelect();
|
||||
|
||||
// AnyDesk live preview
|
||||
document.getElementById('anydesk_id').addEventListener('input', updateAnyDeskPreview);
|
||||
updateAnyDeskPreview();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -4,77 +4,11 @@
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
/* Nordic Top / Header Styling */
|
||||
.page-header {
|
||||
background: linear-gradient(135deg, var(--accent) 0%, #0b3a5b 100%);
|
||||
color: white;
|
||||
padding: 2rem 0 3rem;
|
||||
margin-bottom: -2rem; /* Overlap with content */
|
||||
border-radius: 0 0 12px 12px; /* Slight curve at bottom */
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-weight: 700;
|
||||
font-size: 2rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-header .breadcrumb {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.page-header .breadcrumb-item,
|
||||
.page-header .breadcrumb-item a {
|
||||
color: rgba(255,255,255,0.8);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.page-header .breadcrumb-item.active {
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Content Styling */
|
||||
.main-content {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
|
||||
margin-bottom: 1.5rem;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: white;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.05);
|
||||
padding: 1.25rem 1.5rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-radius: 12px 12px 0 0 !important;
|
||||
}
|
||||
|
||||
.card-title-text {
|
||||
color: var(--accent);
|
||||
font-size: 1.1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.03);
|
||||
padding: 0.8rem 0;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.info-row:last-child {
|
||||
@ -82,22 +16,20 @@
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Quick Action Cards */
|
||||
.action-card {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
border: 1px dashed rgba(0,0,0,0.1);
|
||||
cursor: pointer;
|
||||
@ -107,6 +39,7 @@
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@ -114,146 +47,223 @@
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-light);
|
||||
color: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.action-card i {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
/* Timeline */
|
||||
/* Timeline Styling */
|
||||
.timeline {
|
||||
position: relative;
|
||||
padding-left: 2rem;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
left: 0.4rem;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.timeline-marker {
|
||||
position: absolute;
|
||||
left: -2rem;
|
||||
top: 0.2rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
left: -1.09rem;
|
||||
top: 0.3rem;
|
||||
width: 0.8rem;
|
||||
height: 0.8rem;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
border: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
.timeline-item.active .timeline-marker {
|
||||
background: #28a745;
|
||||
border-color: #28a745;
|
||||
}
|
||||
|
||||
.icon-box {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
/* Quick Info Bar */
|
||||
.quick-info-label {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
.quick-info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
font-size: 1.5rem;
|
||||
margin-right: 1rem;
|
||||
border-right: 1px solid rgba(0,0,0,0.1);
|
||||
padding-right: 0.75rem;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
.quick-info-item:last-child {
|
||||
border-right: none;
|
||||
margin-right: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.bg-soft-primary { background-color: rgba(13, 110, 253, 0.1); color: #0d6efd; }
|
||||
.bg-soft-success { background-color: rgba(25, 135, 84, 0.1); color: #198754; }
|
||||
.bg-soft-warning { background-color: rgba(255, 193, 7, 0.1); color: #ffc107; }
|
||||
.bg-soft-info { background-color: rgba(13, 202, 240, 0.1); color: #0dcaf0; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Custom Nordic Blue Header -->
|
||||
<div class="page-header">
|
||||
<div class="container-fluid px-4">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Forside</a></li>
|
||||
<li class="breadcrumb-item"><a href="/hardware">Hardware</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{{ hardware.serial_number or 'Detail' }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="me-3" style="font-size: 2.5rem;">
|
||||
{% if hardware.asset_type == 'pc' %}🖥️
|
||||
{% elif hardware.asset_type == 'laptop' %}💻
|
||||
{% elif hardware.asset_type == 'printer' %}🖨️
|
||||
{% elif hardware.asset_type == 'skærm' %}🖥️
|
||||
{% elif hardware.asset_type == 'telefon' %}📱
|
||||
{% elif hardware.asset_type == 'server' %}🗄️
|
||||
{% elif hardware.asset_type == 'netværk' %}🌐
|
||||
{% else %}📦
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<h1>{{ hardware.brand or 'Unknown' }} {{ hardware.model or '' }}</h1>
|
||||
<div class="d-flex align-items-center gap-2 mt-1">
|
||||
<span class="badge bg-white text-dark border">{{ hardware.serial_number or 'Ingen serienummer' }}</span>
|
||||
<div class="container-fluid" style="margin-top: 2rem; margin-bottom: 2rem; max-width: 1400px;">
|
||||
|
||||
<span class="badge {% if hardware.status == 'active' %}bg-success{% elif hardware.status == 'retired' %}bg-secondary{% elif hardware.status == 'in_repair' %}bg-primary{% else %}bg-warning{% endif %}">
|
||||
<!-- Top Bar: Back Link + Global Tags -->
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<a href="/hardware" class="back-link text-decoration-none">
|
||||
<i class="bi bi-chevron-left"></i> Tilbage til hardware
|
||||
</a>
|
||||
|
||||
<!-- Global Tags Area -->
|
||||
<div class="d-flex align-items-center p-2 rounded" style="background: rgba(0,0,0,0.02);">
|
||||
<i class="bi bi-tags text-muted me-2 small"></i>
|
||||
<div id="hardware-tags" class="d-flex flex-wrap justify-content-end gap-1 align-items-center">
|
||||
<span class="spinner-border spinner-border-sm text-muted"></span>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-link text-decoration-none ms-1 px-1 py-0 text-muted hover-primary"
|
||||
onclick="window.showTagPicker('hardware', {{ hardware.id }}, () => window.renderEntityTags('hardware', {{ hardware.id }}, 'hardware-tags'))"
|
||||
title="Tilføj tag">
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Info Bar -->
|
||||
<div class="card mb-3" style="background: var(--bg-card); border-left: 4px solid var(--accent); box-shadow: 0 1px 3px rgba(0,0,0,0.08);">
|
||||
<div class="card-body py-2 px-3">
|
||||
<div class="d-flex flex-wrap align-items-center" style="font-size: 0.85rem;">
|
||||
<!-- ID -->
|
||||
<div class="quick-info-item">
|
||||
<span class="quick-info-label">ID:</span>
|
||||
<span>{{ hardware.id }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Brand / Model -->
|
||||
<div class="quick-info-item">
|
||||
<span class="quick-info-label">Hardware:</span>
|
||||
<span class="fw-bold">{{ hardware.brand or 'Unknown' }} {{ hardware.model or '' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Serial -->
|
||||
{% if hardware.serial_number %}
|
||||
<div class="quick-info-item">
|
||||
<span class="quick-info-label">S/N:</span>
|
||||
<span class="font-monospace text-muted">{{ hardware.serial_number }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Customer (Current Owner) -->
|
||||
{% set current_owner = ownership[0] if ownership else None %}
|
||||
{% if current_owner and not current_owner.end_date %}
|
||||
<div class="quick-info-item">
|
||||
<span class="quick-info-label">Ejer:</span>
|
||||
<span>{{ current_owner.customer_name or current_owner.owner_type|title }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Location (Current) -->
|
||||
{% set current_loc = locations[0] if locations else None %}
|
||||
{% if current_loc and not current_loc.end_date %}
|
||||
<div class="quick-info-item">
|
||||
<span class="quick-info-label">Lokation:</span>
|
||||
<span>{{ current_loc.location_name }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Status -->
|
||||
<div class="quick-info-item">
|
||||
<span class="quick-info-label">Status:</span>
|
||||
<span class="badge {% if hardware.status == 'active' %}bg-success{% elif hardware.status == 'retired' %}bg-secondary{% elif hardware.status == 'in_repair' %}bg-primary{% else %}bg-warning{% endif %}" style="font-size: 0.7rem;">
|
||||
{{ hardware.status|replace('_', ' ')|title }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="/hardware/{{ hardware.id }}/edit" class="btn btn-light text-primary fw-medium shadow-sm">
|
||||
<i class="bi bi-pencil me-1"></i> Rediger
|
||||
|
||||
<!-- Link to Edit -->
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
<a href="/hardware/{{ hardware.id }}/edit" class="btn btn-sm btn-outline-primary" title="Rediger">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<button onclick="deleteHardware()" class="btn btn-danger text-white fw-medium shadow-sm" style="background-color: rgba(220, 53, 69, 0.9);">
|
||||
<i class="bi bi-trash me-1"></i> Slet
|
||||
<button onclick="deleteHardware()" class="btn btn-sm btn-outline-danger" title="Slet">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container-fluid px-4 main-content">
|
||||
<div class="row">
|
||||
<!-- Left Column: Key Info & Relations -->
|
||||
<div class="col-lg-4">
|
||||
<!-- Tabs Navigation -->
|
||||
<ul class="nav nav-tabs mb-4 px-2" id="hwTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="details-tab" data-bs-toggle="tab" data-bs-target="#details" type="button" role="tab">
|
||||
<i class="bi bi-card-text me-2"></i>Hardware Detaljer
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="history-tab" data-bs-toggle="tab" data-bs-target="#history" type="button" role="tab">
|
||||
<i class="bi bi-clock-history me-2"></i>Historik
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="files-tab" data-bs-toggle="tab" data-bs-target="#files" type="button" role="tab">
|
||||
<i class="bi bi-paperclip me-2"></i>Filer ({{ attachments|length }})
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="notes-tab" data-bs-toggle="tab" data-bs-target="#notes" type="button" role="tab">
|
||||
<i class="bi bi-sticky me-2"></i>Noter
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Key Details Card -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<div class="card-title-text"><i class="bi bi-info-circle"></i> Stamdata</div>
|
||||
<div class="tab-content" id="hwTabsContent">
|
||||
|
||||
<!-- Tab: Details -->
|
||||
<div class="tab-pane fade show active" id="details" role="tabpanel">
|
||||
<div class="row g-4">
|
||||
<!-- Left Column -->
|
||||
<div class="col-lg-8">
|
||||
<!-- Basic Info Card -->
|
||||
<div class="card mb-4 shadow-sm border-0">
|
||||
<div class="card-header bg-white border-bottom-0 pt-3 ps-3">
|
||||
<h6 class="text-primary mb-0"><i class="bi bi-info-circle me-2"></i>Stamdata</h6>
|
||||
</div>
|
||||
<div class="card-body pt-0">
|
||||
<div class="card-body">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Type</span>
|
||||
<span class="info-value">{{ hardware.asset_type|title }}</span>
|
||||
<span class="info-value">
|
||||
{% if hardware.asset_type == 'pc' %}🖥️ PC
|
||||
{% elif hardware.asset_type == 'laptop' %}💻 Laptop
|
||||
{% elif hardware.asset_type == 'printer' %}🖨️ Printer
|
||||
{% elif hardware.asset_type == 'skærm' %}🖥️ Skærm
|
||||
{% elif hardware.asset_type == 'telefon' %}📱 Telefon
|
||||
{% elif hardware.asset_type == 'server' %}🗄️ Server
|
||||
{% elif hardware.asset_type == 'netværk' %}🌐 Netværk
|
||||
{% else %}📦 {{ hardware.asset_type|title }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Mærke/Model</span>
|
||||
<span class="info-value">{{ hardware.brand or '-' }} / {{ hardware.model or '-' }}</span>
|
||||
</div>
|
||||
{% if hardware.internal_asset_id %}
|
||||
<div class="info-row">
|
||||
<span class="info-label">Intern ID</span>
|
||||
<span class="info-label">Internt Asset ID</span>
|
||||
<span class="info-value">{{ hardware.internal_asset_id }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if hardware.customer_asset_id %}
|
||||
<div class="info-row">
|
||||
<span class="info-label">Kunde ID</span>
|
||||
<span class="info-label">Kunde Asset ID</span>
|
||||
<span class="info-value">{{ hardware.customer_asset_id }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if hardware.warranty_until %}
|
||||
<div class="info-row">
|
||||
<span class="info-label">Garanti Udløb</span>
|
||||
<span class="info-label">Garanti til</span>
|
||||
<span class="info-value">{{ hardware.warranty_until }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
@ -267,279 +277,256 @@
|
||||
</div>
|
||||
|
||||
<!-- AnyDesk Card -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<div class="card-title-text"><i class="bi bi-display"></i> AnyDesk</div>
|
||||
{% set anydesk_url = hardware.anydesk_id and ('anydesk://' ~ hardware.anydesk_id) %}
|
||||
<div class="card mb-4 shadow-sm border-0">
|
||||
<div class="card-header bg-white border-bottom-0 pt-3 ps-3 d-flex justify-content-between align-items-center">
|
||||
<h6 class="text-danger mb-0"><i class="bi bi-display me-2"></i>Remote Access</h6>
|
||||
<a class="btn btn-sm btn-link p-0" href="/hardware/{{ hardware.id }}/edit#anydesk" title="Rediger AnyDesk">
|
||||
Ændre
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="info-row">
|
||||
<span class="info-label">AnyDesk ID</span>
|
||||
<span class="info-value">{{ hardware.anydesk_id or '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">AnyDesk Link</span>
|
||||
<span class="info-value">
|
||||
{% if hardware.anydesk_link %}
|
||||
<a href="{{ hardware.anydesk_link }}" target="_blank" class="btn btn-sm btn-outline-primary">Connect</a>
|
||||
<span class="info-value fw-bold font-monospace">
|
||||
{% if anydesk_url %}
|
||||
<a href="{{ anydesk_url }}" target="_blank" rel="noreferrer noopener">{{ hardware.anydesk_id }}</a>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Handling</span>
|
||||
<span class="info-value">
|
||||
{% if anydesk_url %}
|
||||
<a href="{{ anydesk_url }}" target="_blank" rel="noreferrer noopener" class="btn btn-sm btn-outline-danger">
|
||||
<i class="bi bi-lightning-charge me-1"></i>Connect AnyDesk
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-muted small">Ingen link</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags Card -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<div class="card-title-text"><i class="bi bi-tags"></i> Tags</div>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="window.showTagPicker('hardware', {{ hardware.id }}, () => window.renderEntityTags('hardware', {{ hardware.id }}, 'hardware-tags'))">
|
||||
<i class="bi bi-plus-lg"></i> Tilføj
|
||||
</button>
|
||||
<!-- Location & Owner Grid -->
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100 shadow-sm border-0">
|
||||
<div class="card-header bg-white border-bottom-0 pt-3 ps-3 d-flex justify-content-between align-items-center">
|
||||
<h6 class="text-primary mb-0"><i class="bi bi-geo-alt me-2"></i>Lokation</h6>
|
||||
<button class="btn btn-sm btn-link p-0" data-bs-toggle="modal" data-bs-target="#locationModal">Ændre</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="hardware-tags" class="d-flex flex-wrap">
|
||||
<!-- Tags loaded via JS -->
|
||||
<div class="text-center w-100 py-2">
|
||||
<span class="spinner-border spinner-border-sm text-muted"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Location Card -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<div class="card-title-text"><i class="bi bi-geo-alt"></i> Nuværende Lokation</div>
|
||||
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#locationModal">
|
||||
<i class="bi bi-arrow-left-right"></i> Skift
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if locations and locations|length > 0 %}
|
||||
{% set current_loc = locations[0] %}
|
||||
{% if not current_loc.end_date %}
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="icon-box bg-soft-primary">
|
||||
<i class="bi bi-building"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="mb-1">{{ current_loc.location_name or 'Ukendt' }}</h5>
|
||||
<small class="text-muted">Siden {{ current_loc.start_date }}</small>
|
||||
</div>
|
||||
</div>
|
||||
{% if current_loc and not current_loc.end_date %}
|
||||
<div class="text-center py-3">
|
||||
<div class="fs-4 mb-2"><i class="bi bi-building"></i></div>
|
||||
<h5 class="fw-bold">{{ current_loc.location_name }}</h5>
|
||||
<p class="text-muted small mb-0">Siden: {{ current_loc.start_date }}</p>
|
||||
{% if current_loc.notes %}
|
||||
<div class="mt-3 p-2 bg-light rounded small text-muted">
|
||||
<i class="bi bi-card-text me-1"></i> {{ current_loc.notes }}
|
||||
</div>
|
||||
<div class="mt-2 text-muted fst-italic small">"{{ current_loc.notes }}"</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-3">
|
||||
<i class="bi bi-geo-alt" style="font-size: 2rem; opacity: 0.5;"></i>
|
||||
<p class="mt-2 text-primary">Ingen aktiv lokation</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="text-center py-2">
|
||||
<p class="text-muted mb-3">Hardwaret er ikke tildelt en lokation</p>
|
||||
<button class="btn btn-primary w-100" data-bs-toggle="modal" data-bs-target="#locationModal">
|
||||
<i class="bi bi-plus-circle me-1"></i> Tildel Lokation
|
||||
</button>
|
||||
<div class="text-center py-4 text-muted">
|
||||
<p class="mb-2">Ingen aktiv lokation</p>
|
||||
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#locationModal">Tildel nu</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Owner Card -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<div class="card-title-text"><i class="bi bi-person"></i> Nuværende Ejer</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100 shadow-sm border-0">
|
||||
<div class="card-header bg-white border-bottom-0 pt-3 ps-3">
|
||||
<h6 class="text-success mb-0"><i class="bi bi-person me-2"></i>Ejer</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if ownership and ownership|length > 0 %}
|
||||
{% set current_own = ownership[0] %}
|
||||
{% if not current_own.end_date %}
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="icon-box bg-soft-success">
|
||||
<i class="bi bi-person-badge"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="mb-1">
|
||||
{{ current_own.customer_name or current_own.owner_type|title }}
|
||||
</h5>
|
||||
<small class="text-muted">Siden {{ current_own.start_date }}</small>
|
||||
</div>
|
||||
{% if current_owner and not current_owner.end_date %}
|
||||
<div class="text-center py-3">
|
||||
<div class="fs-4 mb-2 text-success"><i class="bi bi-person-badge"></i></div>
|
||||
<h5 class="fw-bold">{{ current_owner.customer_name or current_owner.owner_type|title }}</h5>
|
||||
<p class="text-muted small mb-0">Siden: {{ current_owner.start_date }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted text-center py-2">Ingen aktiv ejer registreret</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-muted text-center py-2">Ingen ejerhistorik</p>
|
||||
<div class="text-center py-4 text-muted">
|
||||
<p class="mb-0">Ingen aktiv ejer</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Quick Add & History -->
|
||||
<div class="col-lg-8">
|
||||
|
||||
<!-- Quick Actions Grid -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="action-card" onclick="alert('Funktion: Opret Sag til dette hardware')">
|
||||
<i class="bi bi-ticket-perforated"></i>
|
||||
<div>Opret Sag</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="action-card" data-bs-toggle="modal" data-bs-target="#locationModal">
|
||||
<i class="bi bi-geo-alt"></i>
|
||||
<div>Skift Lokation</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<!-- Link to create new location, pre-filled? Or just general create -->
|
||||
<a href="/app/locations" class="text-decoration-none">
|
||||
<div class="action-card">
|
||||
<i class="bi bi-building-add"></i>
|
||||
<div>Ny Lokation</div>
|
||||
</div>
|
||||
<!-- Right Column: Quick Actions & Related -->
|
||||
<div class="col-lg-4">
|
||||
<!-- Quick Actions -->
|
||||
<div class="row g-2 mb-4">
|
||||
<div class="col-6">
|
||||
<a href="#" class="action-card text-decoration-none" onclick="alert('Funktion: Opret Sag til dette hardware (kommer snart)')">
|
||||
<i class="bi bi-ticket-perforated text-primary"></i>
|
||||
<span class="small fw-bold">Opret Sag</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="action-card" onclick="alert('Funktion: Upload bilag')">
|
||||
<i class="bi bi-paperclip"></i>
|
||||
<div>Tilføj Bilag</div>
|
||||
<div class="col-6">
|
||||
<div class="action-card" data-bs-toggle="modal" data-bs-target="#locationModal">
|
||||
<i class="bi bi-geo-alt text-primary"></i>
|
||||
<span class="small fw-bold">Skift Lokation</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<a href="/app/locations" class="action-card text-decoration-none">
|
||||
<i class="bi bi-building-add text-secondary"></i>
|
||||
<span class="small fw-bold">Ny Lokation</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="action-card" onclick="alert('Funktion: Upload bilag (kommer snart)')">
|
||||
<i class="bi bi-paperclip text-secondary"></i>
|
||||
<span class="small fw-bold">Tilføj Bilag</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs Section -->
|
||||
<div class="card">
|
||||
<div class="card-header p-0 border-bottom-0">
|
||||
<ul class="nav nav-tabs ps-3 pt-3 pe-3 w-100" id="hwTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="history-tab" data-bs-toggle="tab" data-bs-target="#history" type="button" role="tab">Historik</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="cases-tab" data-bs-toggle="tab" data-bs-target="#cases" type="button" role="tab">Sager ({{ cases|length }})</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="files-tab" data-bs-toggle="tab" data-bs-target="#files" type="button" role="tab">Filer ({{ attachments|length }})</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="notes-tab" data-bs-toggle="tab" data-bs-target="#notes" type="button" role="tab">Noter</button>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- Linked Cases -->
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-white border-bottom-0 pt-3 ps-3">
|
||||
<h6 class="text-secondary mb-0"><i class="bi bi-briefcase me-2"></i>Seneste Sager</h6>
|
||||
</div>
|
||||
<div class="list-group list-group-flush">
|
||||
{% if cases and cases|length > 0 %}
|
||||
{% for case in cases[:5] %}
|
||||
<a href="/sag/{{ case.case_id }}" class="list-group-item list-group-item-action border-0 px-3 py-2">
|
||||
<div class="d-flex w-100 justify-content-between align-items-center">
|
||||
<div class="text-truncate" style="max-width: 70%;">
|
||||
<i class="bi bi-ticket me-1 text-muted small"></i>
|
||||
<span class="small fw-bold text-dark">{{ case.titel }}</span>
|
||||
</div>
|
||||
<span class="badge bg-light text-dark border">{{ case.status }}</span>
|
||||
</div>
|
||||
<div class="small text-muted ms-3">{{ case.created_at.strftime('%Y-%m-%d') if case.created_at else '' }}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% if cases|length > 5 %}
|
||||
<div class="card-footer bg-white text-center p-2">
|
||||
<a href="#cases-tab" class="small text-decoration-none" onclick="document.getElementById('cases-tab').click()">Se alle {{ cases|length }} sager</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="text-center py-4 text-muted small">
|
||||
Ingen sager tilknyttet
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: History -->
|
||||
<div class="tab-pane fade" id="history" role="tabpanel">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body">
|
||||
<div class="tab-content" id="hwTabsContent">
|
||||
|
||||
<!-- History Tab -->
|
||||
<div class="tab-pane fade show active" id="history" role="tabpanel">
|
||||
<h6 class="text-secondary text-uppercase small fw-bold mb-4">Kombineret Historik</h6>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-primary mb-3 ps-3 border-start border-3 border-primary">Lokations Historik</h6>
|
||||
<div class="timeline">
|
||||
<!-- Interleave items visually? For now just dump both lists or keep separate sections inside tab -->
|
||||
<!-- Let's show Location History first -->
|
||||
<div class="mb-4">
|
||||
<strong class="d-block mb-3 text-primary"><i class="bi bi-geo-alt"></i> Placeringer</strong>
|
||||
{% if locations %}
|
||||
{% for loc in locations %}
|
||||
<div class="timeline-item {% if not loc.end_date %}active{% endif %}">
|
||||
<div class="timeline-marker"></div>
|
||||
<div class="ms-2">
|
||||
<div class="fw-bold">{{ loc.location_name or 'Ukendt' }} ({{ loc.start_date }} {% if loc.end_date %} - {{ loc.end_date }}{% else %}- nu{% endif %})</div>
|
||||
{% if loc.notes %}<div class="text-muted small">{{ loc.notes }}</div>{% endif %}
|
||||
<div class="ps-2">
|
||||
<div class="fw-bold">{{ loc.location_name or 'Ukendt' }}</div>
|
||||
<div class="text-muted small">
|
||||
{{ loc.start_date }}
|
||||
{% if loc.end_date %} - {{ loc.end_date }}{% else %} <span class="badge bg-success py-0">Nuværende</span>{% endif %}
|
||||
</div>
|
||||
{% if loc.notes %}<div class="text-muted small fst-italic mt-1">"{{ loc.notes }}"</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-muted fst-italic">Ingen lokations historik</p>
|
||||
<p class="text-muted ps-4">Ingen historik.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<strong class="d-block mb-3 text-success"><i class="bi bi-person"></i> Ejerskab</strong>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-success mb-3 ps-3 border-start border-3 border-success">Ejerskabs Historik</h6>
|
||||
<div class="timeline">
|
||||
{% if ownership %}
|
||||
{% for own in ownership %}
|
||||
<div class="timeline-item {% if not own.end_date %}active{% endif %}">
|
||||
<div class="timeline-marker"></div>
|
||||
<div class="ms-2">
|
||||
<div class="fw-bold">{{ own.customer_name or own.owner_type }} ({{ own.start_date }} {% if own.end_date %} - {{ own.end_date }}{% else %}- nu{% endif %})</div>
|
||||
{% if own.notes %}<div class="text-muted small">{{ own.notes }}</div>{% endif %}
|
||||
<div class="timeline-marker" style="border-color: var(--bs-success) !important; {% if not own.end_date %}background: var(--bs-success);{% endif %}"></div>
|
||||
<div class="ps-2">
|
||||
<div class="fw-bold">{{ own.customer_name or own.owner_type }}</div>
|
||||
<div class="text-muted small">
|
||||
{{ own.start_date }}
|
||||
{% if own.end_date %} - {{ own.end_date }}{% else %} <span class="badge bg-success py-0">Nuværende</span>{% endif %}
|
||||
</div>
|
||||
{% if own.notes %}<div class="text-muted small fst-italic mt-1">"{{ own.notes }}"</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-muted fst-italic">Ingen ejerskabs historik</p>
|
||||
<p class="text-muted ps-4">Ingen historik.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cases Tab -->
|
||||
<div class="tab-pane fade" id="cases" role="tabpanel">
|
||||
{% if cases and cases|length > 0 %}
|
||||
<div class="list-group list-group-flush">
|
||||
{% for case in cases %}
|
||||
<a href="/sag/{{ case.case_id }}" class="list-group-item list-group-item-action py-3 px-2">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h6 class="mb-1 text-primary">{{ case.titel }}</h6>
|
||||
<small>{{ case.created_at }}</small>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mt-1">
|
||||
<small class="text-muted">Status: {{ case.status }}</small>
|
||||
<span class="badge bg-light text-dark border">ID: {{ case.case_id }}</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-clipboard-check display-4 text-muted opacity-25"></i>
|
||||
<p class="mt-3 text-muted">Ingen sager tilknyttet.</p>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="alert('Opret Sag')">Opret ny sag</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Attachments Tab -->
|
||||
<!-- Tab: Files -->
|
||||
<div class="tab-pane fade" id="files" role="tabpanel">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
{% if attachments %}
|
||||
{% for att in attachments %}
|
||||
<div class="col-md-4 col-sm-6">
|
||||
<div class="p-3 border rounded text-center bg-light h-100">
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<div class="p-3 border rounded text-center bg-light h-100 position-relative">
|
||||
<div class="display-6 mb-2">📎</div>
|
||||
<div class="text-truncate fw-bold">{{ att.file_name }}</div>
|
||||
<div class="text-truncate fw-bold mb-1" title="{{ att.file_name }}">{{ att.file_name }}</div>
|
||||
<div class="small text-muted">{{ att.uploaded_at }}</div>
|
||||
<a href="#" class="stretched-link" onclick="alert('Download not implemented yet')"></a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="col-12 text-center py-4 text-muted">
|
||||
Ingen filer vedhæftet
|
||||
<div class="col-12 text-center py-5 text-muted">
|
||||
<i class="bi bi-files display-4 opacity-25"></i>
|
||||
<p class="mt-3">Ingen filer vedhæftet</p>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="alert('Upload funktion kommer snart')">Upload fil</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes Tab -->
|
||||
<!-- Tab: Notes -->
|
||||
<div class="tab-pane fade" id="notes" role="tabpanel">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body">
|
||||
<h6 class="mb-3">Noter</h6>
|
||||
<div class="p-3 bg-light rounded border">
|
||||
{% if hardware.notes %}
|
||||
{{ hardware.notes }}
|
||||
<div style="white-space: pre-wrap;">{{ hardware.notes }}</div>
|
||||
{% else %}
|
||||
<span class="text-muted fst-italic">Ingen noter...</span>
|
||||
<span class="text-muted fst-italic">Ingen noter tilføjet...</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mt-3 text-end">
|
||||
<a href="/hardware/{{ hardware.id }}/edit" class="btn btn-sm btn-outline-primary">Rediger Noter</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -693,19 +680,6 @@
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Hide if not a match and doesn't contain matches
|
||||
// BUT be careful not to hide if a parent matched?
|
||||
// Actually, search usually filters down. If parent matches, should we show all children?
|
||||
// Let's stick to showing matches and path to matches.
|
||||
|
||||
// Important: logic is tricky with flat recursion vs nested DOM
|
||||
// My macro structure is nested: .location-item-container contains children-container which contains .location-item-container
|
||||
// So `container.style.display = 'block'` on a parent effectively shows the wrapper.
|
||||
|
||||
// If I am not a match, and I have no children that are matches...
|
||||
// But wait, if my parent is a match, do I show up?
|
||||
// Usually "Search" filters items out.
|
||||
|
||||
if (isMatch || hasChildMatch) {
|
||||
container.style.display = 'block';
|
||||
} else {
|
||||
|
||||
@ -210,6 +210,15 @@
|
||||
<div class="form-group">
|
||||
<label for="anydesk_id">AnyDesk ID</label>
|
||||
<input type="text" id="anydesk_id" name="anydesk_id" value="{{ hardware.anydesk_id or '' }}" placeholder="123-456-789">
|
||||
{% if hardware.anydesk_id %}
|
||||
<div class="small mt-2">
|
||||
{% if hardware.anydesk_link %}
|
||||
<a href="{{ hardware.anydesk_link }}" target="_blank">Test forbindelse</a>
|
||||
{% else %}
|
||||
<a href="anydesk://{{ hardware.anydesk_id }}" target="_blank">Test forbindelse</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="anydesk_link">AnyDesk Link</label>
|
||||
|
||||
293
app/modules/hardware/templates/eset_import.html
Normal file
293
app/modules/hardware/templates/eset_import.html
Normal 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 %}
|
||||
163
app/modules/hardware/templates/eset_overview.html
Normal file
163
app/modules/hardware/templates/eset_overview.html
Normal 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 %}
|
||||
121
app/modules/hardware/templates/eset_test.html
Normal file
121
app/modules/hardware/templates/eset_test.html
Normal 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 %}
|
||||
@ -242,11 +242,17 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>🖥️ Hardware Assets</h1>
|
||||
<h1>🖥️ Hardware Oversigt</h1>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="/hardware/eset" class="btn-new-hardware" style="background-color: #0f4c75;">
|
||||
<i class="bi bi-shield-check"></i>
|
||||
ESET Oversigt
|
||||
</a>
|
||||
<a href="/hardware/new" class="btn-new-hardware">
|
||||
➕ Nyt Hardware
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<form method="get" action="/hardware">
|
||||
@ -319,6 +325,18 @@
|
||||
<span class="hardware-detail-label">Type:</span>
|
||||
<span class="hardware-detail-value">{{ item.asset_type|title }}</span>
|
||||
</div>
|
||||
{% if item.anydesk_id or item.anydesk_link %}
|
||||
<div class="hardware-detail-row">
|
||||
<span class="hardware-detail-label">AnyDesk:</span>
|
||||
<span class="hardware-detail-value">
|
||||
{% if item.anydesk_link %}
|
||||
<a href="{{ item.anydesk_link }}" target="_blank">{{ item.anydesk_id or 'Åbn' }}</a>
|
||||
{% elif item.anydesk_id %}
|
||||
<a href="anydesk://{{ item.anydesk_id }}" target="_blank">{{ item.anydesk_id }}</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if item.customer_name %}
|
||||
<div class="hardware-detail-row">
|
||||
<span class="hardware-detail-label">Ejer:</span>
|
||||
|
||||
@ -220,10 +220,7 @@
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">AnyDesk ID</label>
|
||||
<input type="text" class="form-control" id="hardwareAnyDeskIdInput" placeholder="123-456-789">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">AnyDesk Link</label>
|
||||
<input type="text" class="form-control" id="hardwareAnyDeskLinkInput" placeholder="anydesk://...">
|
||||
<div class="form-text small text-muted">Link genereres automatisk.</div>
|
||||
</div>
|
||||
<div class="col-12 d-flex justify-content-end">
|
||||
<button type="button" class="btn btn-outline-primary" onclick="quickCreateHardware()">
|
||||
@ -565,10 +562,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function quickCreateHardware() {
|
||||
const name = document.getElementById('hardwareNameInput').value.trim();
|
||||
const anydeskId = document.getElementById('hardwareAnyDeskIdInput').value.trim();
|
||||
const anydeskLink = document.getElementById('hardwareAnyDeskLinkInput').value.trim();
|
||||
const anydeskLink = anydeskId ? `anydesk://${anydeskId}` : null;
|
||||
|
||||
if (!name) {
|
||||
alert('Navn er påkrævet');
|
||||
@ -600,7 +598,6 @@
|
||||
|
||||
document.getElementById('hardwareNameInput').value = '';
|
||||
document.getElementById('hardwareAnyDeskIdInput').value = '';
|
||||
document.getElementById('hardwareAnyDeskLinkInput').value = '';
|
||||
await loadHardwareForContacts();
|
||||
} catch (err) {
|
||||
alert('Fejl: ' + err.message);
|
||||
|
||||
140
app/services/eset_service.py
Normal file
140
app/services/eset_service.py
Normal 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()
|
||||
@ -239,6 +239,7 @@
|
||||
<li><a class="dropdown-item py-2" href="/conversations/my"><i class="bi bi-mic me-2"></i>Mine Samtaler</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/ticket/archived"><i class="bi bi-archive me-2"></i>Arkiverede Tickets</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/hardware"><i class="bi bi-laptop me-2"></i>Hardware Assets</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/hardware/eset"><i class="bi bi-shield-check me-2"></i>ESET Oversigt</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/app/locations"><i class="bi bi-map-fill me-2"></i>Lokaliteter</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item py-2" href="/prepaid-cards"><i class="bi bi-credit-card-2-front me-2"></i>Prepaid Cards</a></li>
|
||||
@ -313,7 +314,7 @@
|
||||
<li><a class="dropdown-item py-2" href="/backups"><i class="bi bi-hdd-stack me-2"></i>Backup System</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/devportal"><i class="bi bi-code-square me-2"></i>DEV Portal</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item py-2 text-danger" href="#">Log ud</a></li>
|
||||
<li><a class="dropdown-item py-2 text-danger" href="#" onclick="logoutUser(event)"><i class="bi bi-box-arrow-right me-2"></i>Log ud</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@ -1341,6 +1342,21 @@
|
||||
checkMaintenanceMode();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// Global Logout Function
|
||||
function logoutUser(event) {
|
||||
if (event) event.preventDefault();
|
||||
|
||||
// Clear local storage
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('user');
|
||||
|
||||
// Clear cookies
|
||||
document.cookie = "access_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
||||
|
||||
// Redirect to login
|
||||
window.location.href = '/login';
|
||||
}
|
||||
</script>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
@ -848,7 +848,7 @@
|
||||
}
|
||||
|
||||
// vTiger's 'duration' field is in seconds, convert to minutes
|
||||
if (fieldName === 'duration' && typeof rawValue !== 'string') {
|
||||
if (fieldName === 'duration') {
|
||||
const seconds = typeof rawValue === 'number' ? rawValue : parseFloat(String(rawValue));
|
||||
if (Number.isFinite(seconds)) {
|
||||
return seconds / 60;
|
||||
@ -1002,6 +1002,17 @@
|
||||
const row = document.createElement('tr');
|
||||
const hoursData = getTimelogHours(item);
|
||||
const hours = normalizeTimelogHours(hoursData.value, hoursData.field);
|
||||
|
||||
// DEBUG: Log første entry for at verificere parsing
|
||||
if (timelogs.indexOf(item) === 0) {
|
||||
console.log('🔍 TIMELOG DEBUG:', {
|
||||
id: item.id,
|
||||
hoursData: hoursData,
|
||||
parsedHours: hours,
|
||||
rawItem: item
|
||||
});
|
||||
}
|
||||
|
||||
const relatedId = getTimelogRelatedId(item) || '-';
|
||||
const relatedCase = caseMap.get(relatedId);
|
||||
const caseNumber = relatedCase
|
||||
|
||||
13
main.py
13
main.py
@ -125,6 +125,19 @@ async def lifespan(app: FastAPI):
|
||||
)
|
||||
logger.info("✅ Reminder job scheduled (every 5 minutes)")
|
||||
|
||||
if settings.ESET_ENABLED and settings.ESET_SYNC_ENABLED:
|
||||
from app.jobs.eset_sync import run_eset_sync
|
||||
|
||||
backup_scheduler.scheduler.add_job(
|
||||
func=run_eset_sync,
|
||||
trigger=IntervalTrigger(minutes=settings.ESET_SYNC_INTERVAL_MINUTES),
|
||||
id='eset_sync',
|
||||
name='ESET Sync',
|
||||
max_instances=1,
|
||||
replace_existing=True
|
||||
)
|
||||
logger.info("✅ ESET sync job scheduled (every %d minutes)", settings.ESET_SYNC_INTERVAL_MINUTES)
|
||||
|
||||
logger.info("✅ System initialized successfully")
|
||||
yield
|
||||
# Shutdown
|
||||
|
||||
8
migrations/117_add_eset_fields.sql
Normal file
8
migrations/117_add_eset_fields.sql
Normal 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);
|
||||
30
migrations/118_eset_sync.sql
Normal file
30
migrations/118_eset_sync.sql
Normal 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);
|
||||
6
migrations/119_add_eset_group.sql
Normal file
6
migrations/119_add_eset_group.sql
Normal 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);
|
||||
Loading…
Reference in New Issue
Block a user