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