diff --git a/GeoNetAgent/collector/app/api/v1/assets.py b/GeoNetAgent/collector/app/api/v1/assets.py
index d8f18d8..90738f6 100644
--- a/GeoNetAgent/collector/app/api/v1/assets.py
+++ b/GeoNetAgent/collector/app/api/v1/assets.py
@@ -6,7 +6,11 @@ from sqlalchemy.orm import Session
from app.core.auth import verify_api_bearer
from app.db.session import get_db
from app.schemas.agent_report import ErrorBody, ErrorResponse
-from app.schemas.assets import AssetDetail, AssetOwnerIn, AssetOwnerOut, AssetsByOwnerItem, PaginatedAssets, PaginatedSnapshots, PaginatedSoftware, SnapshotSummary, StatsSummary
+from app.schemas.assets import (
+ ASSET_FOR_OPTIONS, ASSET_ON_OPTIONS, DEVICE_STATUS_OPTIONS,
+ AssetDetail, AssetMetaIn, AssetOwnerIn, AssetOwnerOut, AssetsByOwnerItem,
+ PaginatedAssets, PaginatedSnapshots, PaginatedSoftware, SnapshotSummary, StatsSummary,
+)
from app.services import asset_read_service
router = APIRouter()
@@ -51,6 +55,17 @@ def list_assets(
)
+@router.get("/assets/options")
+def get_asset_options_early(
+ _: str = Depends(verify_api_bearer),
+) -> dict:
+ return {
+ "device_status": DEVICE_STATUS_OPTIONS,
+ "asset_for": ASSET_FOR_OPTIONS,
+ "asset_on": ASSET_ON_OPTIONS,
+ }
+
+
@router.get("/assets/by-owner/{ldap_username}", response_model=list[AssetsByOwnerItem])
def get_assets_by_owner(
ldap_username: str,
@@ -120,6 +135,19 @@ def list_asset_software(
return result
+@router.patch("/assets/{asset_id}/meta", response_model=AssetDetail)
+def patch_asset_meta(
+ asset_id: uuid.UUID,
+ body: AssetMetaIn,
+ db: Session = Depends(get_db),
+ _: str = Depends(verify_api_bearer),
+) -> AssetDetail:
+ result = asset_read_service.patch_asset_meta(db, asset_id, body)
+ if result is None:
+ raise _not_found()
+ return result
+
+
@router.put("/assets/{asset_id}/owner", response_model=AssetOwnerOut)
def set_asset_owner(
asset_id: uuid.UUID,
diff --git a/GeoNetAgent/collector/app/api/v1/ldap.py b/GeoNetAgent/collector/app/api/v1/ldap.py
new file mode 100644
index 0000000..258f078
--- /dev/null
+++ b/GeoNetAgent/collector/app/api/v1/ldap.py
@@ -0,0 +1,15 @@
+from fastapi import APIRouter, Depends, Query
+
+from app.core.auth import verify_api_bearer
+from app.services.ldap_service import search_ldap_users
+
+router = APIRouter()
+
+
+@router.get("/ldap/users/search")
+def ldap_user_search(
+ q: str = Query(..., min_length=2),
+ limit: int = Query(10, ge=1, le=30),
+ _: str = Depends(verify_api_bearer),
+) -> list[dict]:
+ return search_ldap_users(q, limit=limit)
diff --git a/GeoNetAgent/collector/app/api/v1/router.py b/GeoNetAgent/collector/app/api/v1/router.py
index e66d418..0e412fa 100644
--- a/GeoNetAgent/collector/app/api/v1/router.py
+++ b/GeoNetAgent/collector/app/api/v1/router.py
@@ -1,8 +1,9 @@
from fastapi import APIRouter
-from app.api.v1 import agent, assets, health
+from app.api.v1 import agent, assets, health, ldap
router = APIRouter(prefix="/api/v1")
router.include_router(health.router, tags=["health"])
router.include_router(agent.router, tags=["agent"])
router.include_router(assets.router, tags=["assets"])
+router.include_router(ldap.router, tags=["ldap"])
diff --git a/GeoNetAgent/collector/app/config.py b/GeoNetAgent/collector/app/config.py
index b490d72..ce283f4 100644
--- a/GeoNetAgent/collector/app/config.py
+++ b/GeoNetAgent/collector/app/config.py
@@ -24,6 +24,15 @@ class Settings(BaseSettings):
timezone: str = "Asia/Jakarta"
+ ldap_url: str = ""
+ ldap_bind_dn: str = ""
+ ldap_bind_password: str = ""
+ ldap_base_dn: str = ""
+ ldap_user_search_base: str = ""
+
+ def ldap_enabled(self) -> bool:
+ return bool(self.ldap_url and self.ldap_bind_dn)
+
def resolved_api_bearer_token(self) -> str:
return self.api_bearer_token.strip() or self.agent_bearer_token
diff --git a/GeoNetAgent/collector/app/db/models.py b/GeoNetAgent/collector/app/db/models.py
index 395ccc6..114612d 100644
--- a/GeoNetAgent/collector/app/db/models.py
+++ b/GeoNetAgent/collector/app/db/models.py
@@ -14,7 +14,7 @@ class Asset(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
asset_type: Mapped[str] = mapped_column(String(30), nullable=False)
hostname: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
- machine_id: Mapped[str | None] = mapped_column(String(64), unique=True)
+ machine_id: Mapped[str | None] = mapped_column(String(64))
smbios_uuid: Mapped[str | None] = mapped_column(String(64), unique=True)
primary_physical_mac: Mapped[str | None] = mapped_column(String(17))
display_name: Mapped[str | None] = mapped_column(String(255))
@@ -22,6 +22,10 @@ class Asset(Base):
model: Mapped[str | None] = mapped_column(String(100))
serial_number: Mapped[str | None] = mapped_column(String(100))
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
+ device_status: Mapped[str | None] = mapped_column(String(30))
+ asset_for: Mapped[str | None] = mapped_column(String(30))
+ asset_on: Mapped[str | None] = mapped_column(String(30))
+ notes: Mapped[str | None] = mapped_column(Text)
metadata_: Mapped[dict] = mapped_column("metadata", JSONB, nullable=False, default=dict)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
diff --git a/GeoNetAgent/collector/app/main.py b/GeoNetAgent/collector/app/main.py
index 0dc5c6e..6235670 100644
--- a/GeoNetAgent/collector/app/main.py
+++ b/GeoNetAgent/collector/app/main.py
@@ -25,7 +25,7 @@ app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list(),
allow_credentials=True,
- allow_methods=["GET", "POST", "OPTIONS"],
+ allow_methods=["GET", "POST", "PUT", "PATCH", "OPTIONS"],
allow_headers=["Authorization", "Content-Type"],
)
diff --git a/GeoNetAgent/collector/app/schemas/agent_report.py b/GeoNetAgent/collector/app/schemas/agent_report.py
index 8e7d0b2..4bb549c 100644
--- a/GeoNetAgent/collector/app/schemas/agent_report.py
+++ b/GeoNetAgent/collector/app/schemas/agent_report.py
@@ -4,11 +4,6 @@ from typing import Any, Literal
from pydantic import BaseModel, Field, field_validator
-class GpuInfo(BaseModel):
- name: str
- dedicated_memory_mb: int | None = None
-
-
class SystemInfo(BaseModel):
manufacturer: str | None = None
model: str | None = None
@@ -20,6 +15,25 @@ class SystemInfo(BaseModel):
ram_gb: float | None = None
gpu_model: str | None = None
last_boot_at: datetime | None = None
+ form_factor: str | None = None
+ chassis_label: str | None = None
+ chassis_types: list[int] = Field(default_factory=list)
+ pc_system_type: int | None = None
+ pc_system_label: str | None = None
+
+
+class CpuInfo(BaseModel):
+ model: str | None = None
+ manufacturer: str | None = None
+ cores: int | None = None
+ logical_processors: int | None = None
+ max_clock_mhz: int | None = None
+ current_clock_mhz: int | None = None
+ socket: str | None = None
+ socket_count: int | None = None
+ l2_cache_kb: int | None = None
+ l3_cache_kb: int | None = None
+ architecture: str | None = None
class DiskInfo(BaseModel):
@@ -31,15 +45,49 @@ class DiskInfo(BaseModel):
model: str | None = None
+class GpuInfo(BaseModel):
+ name: str
+ dedicated_memory_mb: int | None = None
+ adapter_compatibility: str | None = None
+ video_processor: str | None = None
+ driver_version: str | None = None
+ driver_date: datetime | str | None = None
+ pnp_device_id: str | None = None
+ status: str | None = None
+ current_resolution: str | None = None
+ video_mode_description: str | None = None
+
+
+class PhysicalDiskInfo(BaseModel):
+ disk_number: int | None = None
+ device_id: str | None = None
+ model: str | None = None
+ friendly_name: str | None = None
+ media_type: str | None = None
+ operational_status: str | None = None
+ health_status: str | None = None
+ total_gb: float | None = None
+ partition_style: str | None = None
+ bus_type: str | None = None
+ serial_number: str | None = None
+
+
class MemoryModule(BaseModel):
slot: str | None = None
bank_label: str | None = None
capacity_gb: float | None = None
speed_mhz: int | None = None
+ configured_speed_mhz: int | None = None
manufacturer: str | None = None
part_number: str | None = None
+ serial_number: str | None = None
form_factor: str | None = None
memory_type: int | None = None
+ memory_type_label: str | None = None
+ data_width: int | None = None
+ total_width: int | None = None
+ rank: int | None = None
+ configured_voltage_mv: int | None = None
class MemorySummary(BaseModel):
@@ -50,6 +98,7 @@ class MemorySummary(BaseModel):
layout: str | None = None
configuration: str | None = None
upgradeable: bool | None = None
+ ddr_generation: str | None = None
class NetworkAdapter(BaseModel):
@@ -122,8 +171,10 @@ class AgentReportRequest(BaseModel):
asset_type: str = "endpoint"
logged_in_user: str | None = None
system: SystemInfo
+ cpu: CpuInfo | None = None
gpus: list[GpuInfo] = Field(default_factory=list)
disks: list[DiskInfo] = Field(default_factory=list)
+ physical_disks: list[PhysicalDiskInfo] = Field(default_factory=list)
memory_modules: list[MemoryModule] = Field(default_factory=list)
memory_summary: MemorySummary | None = None
network: list[NetworkAdapter] = Field(default_factory=list)
diff --git a/GeoNetAgent/collector/app/schemas/assets.py b/GeoNetAgent/collector/app/schemas/assets.py
index da6ab48..d0753cf 100644
--- a/GeoNetAgent/collector/app/schemas/assets.py
+++ b/GeoNetAgent/collector/app/schemas/assets.py
@@ -11,6 +11,9 @@ class AssetListItem(BaseModel):
form_factor: str | None = None
asset_type: str
status: str
+ device_status: str | None = None
+ asset_for: str | None = None
+ asset_on: str | None = None
manufacturer: str | None = None
model: str | None = None
serial_number: str | None = None
@@ -206,6 +209,11 @@ class AssetDetail(BaseModel):
hostname: str
asset_type: str
status: str
+ device_status: str | None = None
+ asset_for: str | None = None
+ asset_on: str | None = None
+ notes: str | None = None
+ owner_ldap_username: str | None = None
display_name: str | None = None
manufacturer: str | None = None
model: str | None = None
@@ -229,6 +237,19 @@ class StatsSummary(BaseModel):
total_software_rows: int
+DEVICE_STATUS_OPTIONS = ['active', 'used', 'stok', 'rusak', 'maintenance', 'hilang', 'retired', 'pinjam']
+ASSET_FOR_OPTIONS = ['geonet', 'sewa', 'proyek', 'personal', 'client', 'inventaris']
+ASSET_ON_OPTIONS = ['geonet', 'site', 'remote', 'gudang', 'client', 'lab']
+
+
+class AssetMetaIn(BaseModel):
+ device_status: str | None = Field(None)
+ asset_for: str | None = Field(None)
+ asset_on: str | None = Field(None)
+ notes: str | None = Field(None, max_length=1000)
+ display_name: str | None = Field(None, max_length=255)
+
+
class AssetOwnerIn(BaseModel):
ldap_username: str = Field(..., min_length=1, max_length=100)
diff --git a/GeoNetAgent/collector/app/services/asset_identity.py b/GeoNetAgent/collector/app/services/asset_identity.py
new file mode 100644
index 0000000..25f9f5a
--- /dev/null
+++ b/GeoNetAgent/collector/app/services/asset_identity.py
@@ -0,0 +1,84 @@
+"""Hardware identity helpers for agent asset merge."""
+
+from __future__ import annotations
+
+import uuid
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.db.models import Asset
+from app.schemas.agent_report import AgentReportRequest
+
+_GENERIC_SERIALS = frozenset(
+ {
+ "TO BE FILLED BY O.E.M.",
+ "DEFAULT STRING",
+ "SYSTEM SERIAL NUMBER",
+ "NOT APPLICABLE",
+ "NONE",
+ }
+)
+
+
+def normalize_smbios_uuid(value: str | None) -> str | None:
+ if not value:
+ return None
+ cleaned = value.strip().strip("{}").upper()
+ return cleaned or None
+
+
+def normalize_serial_number(value: str | None) -> str | None:
+ if not value:
+ return None
+ cleaned = value.strip()
+ if not cleaned or cleaned.upper() in _GENERIC_SERIALS:
+ return None
+ return cleaned
+
+
+def hardware_identity_compatible(asset: Asset, report: AgentReportRequest) -> bool:
+ """Reject merge when both sides have hardware IDs that clearly disagree."""
+ report_smbios = normalize_smbios_uuid(report.smbios_uuid)
+ asset_smbios = normalize_smbios_uuid(asset.smbios_uuid)
+ if report_smbios and asset_smbios and report_smbios != asset_smbios:
+ return False
+
+ report_serial = normalize_serial_number(report.serial_number)
+ asset_serial = normalize_serial_number(asset.serial_number)
+ if report_serial and asset_serial and report_serial != asset_serial:
+ return False
+
+ return True
+
+
+def disambiguated_hostname(base: str, smbios_uuid: str | None, *, machine_id: str | None = None) -> str:
+ suffix_source = normalize_smbios_uuid(smbios_uuid)
+ if not suffix_source and machine_id:
+ suffix_source = machine_id.replace("-", "").upper()
+ suffix = (suffix_source or uuid.uuid4().hex)[:8]
+ return f"{base.strip()}-{suffix}"
+
+
+def _hostname_taken(db: Session, hostname: str) -> bool:
+ return db.scalar(
+ select(Asset.id).where(Asset.hostname == hostname, Asset.deleted_at.is_(None))
+ ) is not None
+
+
+def allocate_hostname(db: Session, report: AgentReportRequest) -> tuple[str, str | None]:
+ """
+ Return (unique hostname, optional display_name).
+ display_name holds the agent-reported hostname when disambiguation is required.
+ """
+ base = report.hostname.strip()
+ if not _hostname_taken(db, base):
+ return base, None
+
+ candidate = disambiguated_hostname(base, report.smbios_uuid, machine_id=report.machine_id)
+ counter = 2
+ while _hostname_taken(db, candidate):
+ candidate = f"{candidate}-{counter}"
+ counter += 1
+
+ return candidate, base
diff --git a/GeoNetAgent/collector/app/services/asset_read_service.py b/GeoNetAgent/collector/app/services/asset_read_service.py
index 56c62ce..e9ef14a 100644
--- a/GeoNetAgent/collector/app/services/asset_read_service.py
+++ b/GeoNetAgent/collector/app/services/asset_read_service.py
@@ -9,6 +9,7 @@ from app.db.models import Asset, AssetDisk, AssetNetworkAdapter, AssetSnapshot,
from app.schemas.assets import (
AssetDetail,
AssetListItem,
+ AssetMetaIn,
AssetOwnerOut,
AssetsByOwnerItem,
CpuOut,
@@ -163,6 +164,9 @@ def list_assets(
or (asset.metadata_ or {}).get("form_factor"),
asset_type=asset.asset_type,
status=asset.status,
+ device_status=asset.device_status,
+ asset_for=asset.asset_for,
+ asset_on=asset.asset_on,
manufacturer=asset.manufacturer,
model=asset.model,
serial_number=asset.serial_number,
@@ -442,6 +446,20 @@ def _snapshot_to_summary(db: Session, snapshot: AssetSnapshot) -> SnapshotSummar
)
+def _get_asset_owner(db: Session, asset_id: uuid.UUID) -> str | None:
+ row = db.execute(
+ select(User.username)
+ .join(AssetUserAssignment, AssetUserAssignment.user_id == User.id)
+ .where(
+ AssetUserAssignment.asset_id == asset_id,
+ AssetUserAssignment.is_primary == True, # noqa: E712
+ AssetUserAssignment.ended_at.is_(None),
+ )
+ .limit(1)
+ ).scalar_one_or_none()
+ return row
+
+
def get_asset(db: Session, asset_id: uuid.UUID) -> AssetDetail | None:
asset = db.scalar(select(Asset).where(Asset.id == asset_id, Asset.deleted_at.is_(None)))
if not asset:
@@ -454,12 +472,18 @@ def get_asset(db: Session, asset_id: uuid.UUID) -> AssetDetail | None:
.order_by(AssetSnapshot.collected_at.desc())
.limit(1)
)
+ owner_username = _get_asset_owner(db, asset_id)
return AssetDetail(
id=str(asset.id),
hostname=asset.hostname,
asset_type=asset.asset_type,
status=asset.status,
+ device_status=asset.device_status,
+ asset_for=asset.asset_for,
+ asset_on=asset.asset_on,
+ notes=asset.notes,
+ owner_ldap_username=owner_username,
display_name=asset.display_name,
manufacturer=asset.manufacturer,
model=asset.model,
@@ -476,6 +500,26 @@ def get_asset(db: Session, asset_id: uuid.UUID) -> AssetDetail | None:
)
+def patch_asset_meta(db: Session, asset_id: uuid.UUID, data: AssetMetaIn) -> AssetDetail | None:
+ asset = db.scalar(select(Asset).where(Asset.id == asset_id, Asset.deleted_at.is_(None)))
+ if not asset:
+ return None
+
+ if data.device_status is not None:
+ asset.device_status = data.device_status or None
+ if data.asset_for is not None:
+ asset.asset_for = data.asset_for or None
+ if data.asset_on is not None:
+ asset.asset_on = data.asset_on or None
+ if data.notes is not None:
+ asset.notes = data.notes or None
+ if data.display_name is not None:
+ asset.display_name = data.display_name or None
+
+ db.commit()
+ return get_asset(db, asset_id)
+
+
def list_snapshots(
db: Session,
asset_id: uuid.UUID,
diff --git a/GeoNetAgent/collector/app/services/asset_service.py b/GeoNetAgent/collector/app/services/asset_service.py
index 7d84262..67c0e36 100644
--- a/GeoNetAgent/collector/app/services/asset_service.py
+++ b/GeoNetAgent/collector/app/services/asset_service.py
@@ -14,6 +14,7 @@ from app.db.models import (
User,
)
from app.schemas.agent_report import AgentReportRequest, AgentReportResponse
+from app.services.asset_identity import allocate_hostname, hardware_identity_compatible
def _parse_install_date(value: date | str | None) -> date | None:
@@ -45,31 +46,38 @@ def _sanitize_json(value):
return value
+def _match_asset(db: Session, report: AgentReportRequest, where) -> Asset | None:
+ asset = db.scalar(select(Asset).where(where, Asset.deleted_at.is_(None)))
+ if asset and hardware_identity_compatible(asset, report):
+ return asset
+ return None
+
+
def _find_asset(db: Session, report: AgentReportRequest) -> Asset | None:
identity = report.asset_identity
primary_mac = identity.primary_physical_mac if identity else None
if report.serial_number:
- asset = db.scalar(select(Asset).where(Asset.serial_number == report.serial_number))
+ asset = _match_asset(db, report, Asset.serial_number == report.serial_number)
if asset:
return asset
if report.smbios_uuid:
- asset = db.scalar(select(Asset).where(Asset.smbios_uuid == report.smbios_uuid))
+ asset = _match_asset(db, report, Asset.smbios_uuid == report.smbios_uuid)
if asset:
return asset
if primary_mac:
- asset = db.scalar(select(Asset).where(Asset.primary_physical_mac == primary_mac))
+ asset = _match_asset(db, report, Asset.primary_physical_mac == primary_mac)
if asset:
return asset
if report.machine_id:
- asset = db.scalar(select(Asset).where(Asset.machine_id == report.machine_id))
+ asset = _match_asset(db, report, Asset.machine_id == report.machine_id)
if asset:
return asset
- return db.scalar(select(Asset).where(Asset.hostname == report.hostname))
+ return _match_asset(db, report, Asset.hostname == report.hostname)
def _upsert_user(db: Session, username: str) -> User:
@@ -117,15 +125,17 @@ def ingest_agent_report(db: Session, report: AgentReportRequest) -> AgentReportR
primary_mac = report.asset_identity.primary_physical_mac if report.asset_identity else None
if is_new:
+ hostname, display_name = allocate_hostname(db, report)
asset = Asset(
asset_type=report.asset_type,
- hostname=report.hostname,
+ hostname=hostname,
+ display_name=display_name,
status="active",
)
db.add(asset)
db.flush()
else:
- if asset.hostname != report.hostname:
+ if not asset.display_name and asset.hostname != report.hostname:
asset.hostname = report.hostname
asset.machine_id = report.machine_id
@@ -140,7 +150,11 @@ def ingest_agent_report(db: Session, report: AgentReportRequest) -> AgentReportR
"memory_summary": report.memory_summary.model_dump() if report.memory_summary else None,
"memory_modules": [m.model_dump() for m in report.memory_modules],
"agent_metadata": report.metadata,
+ "form_factor": report.system.form_factor,
+ "chassis_label": report.system.chassis_label,
}
+ if is_new and asset.display_name:
+ asset.metadata_["reported_hostname"] = asset.display_name
existing_snapshot = db.scalar(
select(AssetSnapshot).where(
@@ -162,7 +176,7 @@ def ingest_agent_report(db: Session, report: AgentReportRequest) -> AgentReportR
agent_version=report.agent_version,
os_name=report.system.os_name,
os_version=report.system.os_version or report.system.os_build,
- cpu_model=report.system.cpu_model,
+ cpu_model=report.system.cpu_model or (report.cpu.model if report.cpu else None),
ram_gb=report.system.ram_gb,
gpu_model=report.system.gpu_model or (report.gpus[0].name if report.gpus else None),
bios_version=report.system.bios_version,
diff --git a/GeoNetAgent/collector/app/services/asset_split_service.py b/GeoNetAgent/collector/app/services/asset_split_service.py
new file mode 100644
index 0000000..fa783f8
--- /dev/null
+++ b/GeoNetAgent/collector/app/services/asset_split_service.py
@@ -0,0 +1,244 @@
+"""Split merged asset snapshots by distinct hardware profiles."""
+
+from __future__ import annotations
+
+import uuid
+from dataclasses import dataclass, field
+from datetime import datetime
+
+from sqlalchemy import select, update
+from sqlalchemy.orm import Session
+
+from app.db.models import Asset, AssetSnapshot, SoftwareInstallation
+from app.services.asset_identity import disambiguated_hostname, normalize_smbios_uuid
+
+
+@dataclass
+class HardwareGroup:
+ fingerprint: str
+ label: str
+ smbios_uuid: str | None
+ snapshot_ids: list[uuid.UUID] = field(default_factory=list)
+ latest_collected_at: datetime | None = None
+
+
+@dataclass
+class SplitAssetResult:
+ source_asset_id: uuid.UUID
+ dry_run: bool
+ groups: list[HardwareGroup]
+ primary_fingerprint: str
+ kept_asset_id: uuid.UUID
+ created_assets: list[dict] = field(default_factory=list)
+ moved_snapshots: int = 0
+
+
+def hardware_fingerprint(payload: dict) -> str:
+ smbios = normalize_smbios_uuid(payload.get("smbios_uuid"))
+ if smbios:
+ return f"smbios:{smbios}"
+
+ system = payload.get("system") or {}
+ manufacturer = (system.get("manufacturer") or "").strip()
+ model = (system.get("model") or "").strip()
+ if manufacturer and model:
+ return f"product:{manufacturer}|{model}"
+
+ return "unknown"
+
+
+def hardware_label(payload: dict) -> str:
+ system = payload.get("system") or {}
+ model = (system.get("model") or "").strip()
+ manufacturer = (system.get("manufacturer") or "").strip()
+ if model and manufacturer:
+ return f"{manufacturer} {model}".strip()
+ if model:
+ return model
+ if manufacturer:
+ return manufacturer
+ return "Unknown hardware"
+
+
+def _chassis_kind(payload: dict) -> str:
+ system = payload.get("system") or {}
+ form_factor = (system.get("form_factor") or "").strip().lower()
+ if form_factor == "laptop":
+ return "Laptop"
+ if form_factor == "desktop":
+ return "PC"
+ model = (system.get("model") or "").lower()
+ if any(token in model for token in ("inspiron", "latitude", "thinkpad", "pavilion", "vivobook", "zenbook")):
+ return "Laptop"
+ return "PC"
+
+
+def _group_snapshots(snapshots: list[AssetSnapshot]) -> dict[str, HardwareGroup]:
+ groups: dict[str, HardwareGroup] = {}
+ for snapshot in snapshots:
+ payload = snapshot.payload or {}
+ fp = hardware_fingerprint(payload)
+ group = groups.get(fp)
+ if not group:
+ group = HardwareGroup(
+ fingerprint=fp,
+ label=hardware_label(payload),
+ smbios_uuid=normalize_smbios_uuid(payload.get("smbios_uuid")),
+ )
+ groups[fp] = group
+ group.snapshot_ids.append(snapshot.id)
+ if group.latest_collected_at is None or snapshot.collected_at > group.latest_collected_at:
+ group.latest_collected_at = snapshot.collected_at
+ return groups
+
+
+def _latest_snapshot(db: Session, snapshot_ids: list[uuid.UUID]) -> AssetSnapshot | None:
+ if not snapshot_ids:
+ return None
+ return db.scalar(
+ select(AssetSnapshot)
+ .where(AssetSnapshot.id.in_(snapshot_ids))
+ .order_by(AssetSnapshot.collected_at.desc())
+ .limit(1)
+ )
+
+
+def _apply_payload_to_asset(asset: Asset, snapshot: AssetSnapshot, payload: dict) -> None:
+ system = payload.get("system") or {}
+ identity = payload.get("asset_identity") or {}
+ asset.manufacturer = system.get("manufacturer")
+ asset.model = system.get("model")
+ asset.smbios_uuid = normalize_smbios_uuid(payload.get("smbios_uuid"))
+ asset.machine_id = payload.get("machine_id")
+ asset.serial_number = payload.get("serial_number")
+ asset.primary_physical_mac = identity.get("primary_physical_mac")
+ asset.last_seen_at = snapshot.collected_at
+
+
+def _ensure_unique_hostname(db: Session, candidate: str, *, exclude_asset_id: uuid.UUID | None = None) -> str:
+ hostname = candidate
+ counter = 2
+ while True:
+ stmt = select(Asset.id).where(Asset.hostname == hostname, Asset.deleted_at.is_(None))
+ if exclude_asset_id:
+ stmt = stmt.where(Asset.id != exclude_asset_id)
+ if db.scalar(stmt) is None:
+ return hostname
+ hostname = f"{candidate}-{counter}"
+ counter += 1
+
+
+def split_asset_by_hardware(
+ db: Session,
+ asset_id: uuid.UUID,
+ *,
+ dry_run: bool = False,
+) -> SplitAssetResult:
+ asset = db.scalar(select(Asset).where(Asset.id == asset_id, Asset.deleted_at.is_(None)))
+ if not asset:
+ raise ValueError(f"Asset not found: {asset_id}")
+
+ snapshots = db.scalars(
+ select(AssetSnapshot).where(AssetSnapshot.asset_id == asset.id).order_by(AssetSnapshot.collected_at)
+ ).all()
+ if not snapshots:
+ raise ValueError(f"Asset has no snapshots: {asset_id}")
+
+ groups = _group_snapshots(snapshots)
+ if len(groups) <= 1:
+ raise ValueError(f"Asset has only one hardware profile; nothing to split: {asset_id}")
+
+ primary_fp = max(groups.values(), key=lambda g: g.latest_collected_at or datetime.min).fingerprint
+ result = SplitAssetResult(
+ source_asset_id=asset.id,
+ dry_run=dry_run,
+ groups=list(groups.values()),
+ primary_fingerprint=primary_fp,
+ kept_asset_id=asset.id,
+ )
+
+ reported_hostname = (asset.metadata_ or {}).get("reported_hostname") or asset.display_name or asset.hostname
+
+ for fp, group in groups.items():
+ if fp == primary_fp:
+ continue
+
+ latest = _latest_snapshot(db, group.snapshot_ids)
+ if not latest:
+ continue
+
+ payload = latest.payload or {}
+ kind = _chassis_kind(payload)
+ display_name = f"{reported_hostname} · {kind}"
+ new_hostname = _ensure_unique_hostname(
+ db,
+ disambiguated_hostname(reported_hostname, group.smbios_uuid, machine_id=payload.get("machine_id")),
+ exclude_asset_id=asset.id,
+ )
+
+ result.created_assets.append(
+ {
+ "hostname": new_hostname,
+ "display_name": display_name,
+ "fingerprint": fp,
+ "label": group.label,
+ "snapshot_count": len(group.snapshot_ids),
+ "status": "inactive",
+ }
+ )
+
+ if dry_run:
+ result.moved_snapshots += len(group.snapshot_ids)
+ continue
+
+ new_asset = Asset(
+ asset_type=asset.asset_type,
+ hostname=new_hostname,
+ display_name=display_name,
+ status="inactive",
+ metadata_={
+ "reported_hostname": reported_hostname,
+ "hardware_profile": kind,
+ "split_from_asset_id": str(asset.id),
+ "hardware_fingerprint": fp,
+ },
+ )
+ db.add(new_asset)
+ db.flush()
+
+ db.execute(
+ update(AssetSnapshot)
+ .where(AssetSnapshot.id.in_(group.snapshot_ids))
+ .values(asset_id=new_asset.id)
+ )
+ db.execute(
+ update(SoftwareInstallation)
+ .where(SoftwareInstallation.snapshot_id.in_(group.snapshot_ids))
+ .values(asset_id=new_asset.id)
+ )
+
+ _apply_payload_to_asset(new_asset, latest, payload)
+ result.moved_snapshots += len(group.snapshot_ids)
+
+ asset.metadata_ = {
+ **(asset.metadata_ or {}),
+ f"split_child_{kind.lower()}": str(new_asset.id),
+ }
+
+ if not dry_run:
+ primary_group = groups[primary_fp]
+ primary_latest = _latest_snapshot(db, primary_group.snapshot_ids)
+ if primary_latest:
+ _apply_payload_to_asset(asset, primary_latest, primary_latest.payload or {})
+ kind = _chassis_kind(primary_latest.payload or {})
+ asset.display_name = f"{reported_hostname} · {kind}"
+ asset.status = "active"
+ asset.metadata_ = {
+ **(asset.metadata_ or {}),
+ "reported_hostname": reported_hostname,
+ "hardware_profile": kind,
+ "split_at": datetime.now().astimezone().isoformat(),
+ }
+ db.commit()
+
+ return result
diff --git a/GeoNetAgent/collector/app/services/ldap_service.py b/GeoNetAgent/collector/app/services/ldap_service.py
new file mode 100644
index 0000000..ee6fdc5
--- /dev/null
+++ b/GeoNetAgent/collector/app/services/ldap_service.py
@@ -0,0 +1,61 @@
+from __future__ import annotations
+
+from app.config import get_settings
+
+settings = get_settings()
+
+
+def search_ldap_users(q: str, limit: int = 10) -> list[dict]:
+ if not settings.ldap_enabled():
+ return []
+ if not q or len(q.strip()) < 2:
+ return []
+
+ try:
+ import ldap3
+ except ImportError:
+ return []
+
+ q = q.strip()
+ search_filter = (
+ f"(&(objectClass=user)(objectCategory=person)"
+ f"(|(sAMAccountName=*{q}*)(displayName=*{q}*)(mail=*{q}*)))"
+ )
+ attrs = ["sAMAccountName", "displayName", "mail", "department", "distinguishedName"]
+
+ try:
+ server = ldap3.Server(settings.ldap_url, get_info=ldap3.NONE, connect_timeout=3)
+ conn = ldap3.Connection(
+ server,
+ user=settings.ldap_bind_dn,
+ password=settings.ldap_bind_password,
+ auto_bind=ldap3.AUTO_BIND_TLS_BEFORE_BIND
+ if settings.ldap_url.startswith("ldaps")
+ else ldap3.AUTO_BIND_NO_TLS,
+ receive_timeout=5,
+ )
+ base = settings.ldap_user_search_base or settings.ldap_base_dn
+ conn.search(
+ search_base=base,
+ search_filter=search_filter,
+ search_scope=ldap3.SUBTREE,
+ attributes=attrs,
+ size_limit=limit,
+ )
+ results = []
+ for entry in conn.entries:
+ sam = str(entry.sAMAccountName) if entry.sAMAccountName else None
+ if not sam:
+ continue
+ results.append(
+ {
+ "username": sam,
+ "display_name": str(entry.displayName) if entry.displayName else None,
+ "email": str(entry.mail) if entry.mail else None,
+ "department": str(entry.department) if entry.department else None,
+ }
+ )
+ conn.unbind()
+ return results
+ except Exception:
+ return []
diff --git a/GeoNetAgent/collector/pyproject.toml b/GeoNetAgent/collector/pyproject.toml
index e8b23f1..ef2828d 100644
--- a/GeoNetAgent/collector/pyproject.toml
+++ b/GeoNetAgent/collector/pyproject.toml
@@ -11,6 +11,7 @@ dependencies = [
"psycopg2-binary>=2.9.10",
"pydantic>=2.10.0",
"pydantic-settings>=2.6.0",
+ "ldap3>=2.9.1",
]
[project.optional-dependencies]
diff --git a/GeoNetAgent/collector/static/app.js b/GeoNetAgent/collector/static/app.js
index 2cf78fa..7197646 100644
--- a/GeoNetAgent/collector/static/app.js
+++ b/GeoNetAgent/collector/static/app.js
@@ -9,6 +9,8 @@ const state = {
pages: 1,
selectedId: null,
searchTimer: null,
+ options: { device_status: [], asset_for: [], asset_on: [] },
+ ldapSearchTimer: null,
};
const el = {
@@ -75,6 +77,34 @@ async function api(path) {
return data;
}
+async function apiMutate(method, path, body) {
+ const url = `${state.baseUrl.replace(/\/$/, "")}${path}`;
+ const res = await fetch(url, {
+ method,
+ headers: {
+ Authorization: `Bearer ${state.token}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(body),
+ });
+ const text = await res.text();
+ let data;
+ try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; }
+ if (!res.ok) {
+ const msg = data?.detail?.error?.message || data?.detail || res.statusText;
+ throw new Error(typeof msg === "string" ? msg : JSON.stringify(msg));
+ }
+ return data;
+}
+
+async function loadOptions() {
+ try {
+ state.options = await api("/api/v1/assets/options");
+ } catch {
+ state.options = { device_status: [], asset_for: [], asset_on: [] };
+ }
+}
+
function fmtDate(value) {
if (!value) return "—";
try {
@@ -128,6 +158,185 @@ function buildAssetsQuery() {
return `?${params.toString()}`;
}
+function assetLabel(item) {
+ return item.display_name || item.hostname;
+}
+
+function formFactorBadge(value) {
+ if (!value) return "";
+ const label = value === "laptop" ? "Laptop" : value === "desktop" ? "PC" : value;
+ const cls = value === "laptop" ? "form-laptop" : value === "desktop" ? "form-desktop" : "form-other";
+ return `${escapeHtml(label)} `;
+}
+
+const DEVICE_STATUS_COLORS = {
+ active: "ds-active", used: "ds-used", stok: "ds-stok",
+ rusak: "ds-rusak", maintenance: "ds-maintenance",
+ hilang: "ds-hilang", retired: "ds-retired", pinjam: "ds-pinjam",
+};
+const ASSET_FOR_COLORS = {
+ geonet: "af-geonet", sewa: "af-sewa", proyek: "af-proyek",
+ personal: "af-personal", client: "af-client", inventaris: "af-inventaris",
+};
+const ASSET_ON_COLORS = {
+ geonet: "ao-geonet", site: "ao-site", remote: "ao-remote",
+ gudang: "ao-gudang", client: "ao-client", lab: "ao-lab",
+};
+
+function tagBadge(value, colorMap) {
+ if (!value) return "";
+ const cls = colorMap[value] || "tag-default";
+ return `${escapeHtml(value)} `;
+}
+
+function buildSelect(name, options, current, placeholder = "— pilih —") {
+ let html = ``;
+ html += `${placeholder} `;
+ for (const opt of options) {
+ const sel = opt === current ? " selected" : "";
+ html += `${escapeHtml(opt)} `;
+ }
+ html += ` `;
+ return html;
+}
+
+function renderEditPanel(asset) {
+ const opts = state.options;
+ return `
+
+
⚙ Edit Asset
+
+
+
+
+
Owner (LDAP Username)
+
+
+
+
+
Device Status
+ ${buildSelect("device_status", opts.device_status || [], asset.device_status)}
+
+
+
+
Asset For (Kepemilikan)
+ ${buildSelect("asset_for", opts.asset_for || [], asset.asset_for)}
+
+
+
+
Asset On (Lokasi)
+ ${buildSelect("asset_on", opts.asset_on || [], asset.asset_on)}
+
+
+
+
+
+ Simpan
+
+
+
`;
+}
+
+function bindEditPanel(asset) {
+ const ownerInput = document.getElementById("edit-owner-input");
+ const suggestionsBox = document.getElementById("ldap-suggestions");
+ const saveBtn = document.getElementById("save-meta-btn");
+ const editStatus = document.getElementById("edit-status");
+
+ if (ownerInput) {
+ ownerInput.addEventListener("input", () => {
+ clearTimeout(state.ldapSearchTimer);
+ const q = ownerInput.value.trim();
+ if (q.length < 2) {
+ suggestionsBox.classList.add("hidden");
+ return;
+ }
+ state.ldapSearchTimer = setTimeout(async () => {
+ try {
+ const results = await api(`/api/v1/ldap/users/search?q=${encodeURIComponent(q)}&limit=8`);
+ if (!results.length) {
+ suggestionsBox.innerHTML = `Tidak ada hasil.
`;
+ } else {
+ suggestionsBox.innerHTML = results.map(u => `
+
+ ${escapeHtml(u.username)}
+ ${u.display_name ? ` — ${escapeHtml(u.display_name)} ` : ""}
+ ${u.department ? ` · ${escapeHtml(u.department)} ` : ""}
+
`).join("");
+ suggestionsBox.querySelectorAll(".ldap-item").forEach(item => {
+ item.addEventListener("click", () => {
+ ownerInput.value = item.dataset.username;
+ suggestionsBox.classList.add("hidden");
+ });
+ });
+ }
+ suggestionsBox.classList.remove("hidden");
+ } catch {
+ suggestionsBox.classList.add("hidden");
+ }
+ }, 300);
+ });
+
+ document.addEventListener("click", (e) => {
+ if (!e.target.closest(".ldap-autocomplete")) {
+ suggestionsBox.classList.add("hidden");
+ }
+ }, { once: false });
+ }
+
+ if (saveBtn) {
+ saveBtn.addEventListener("click", async () => {
+ saveBtn.disabled = true;
+ editStatus.textContent = "Menyimpan...";
+ editStatus.className = "edit-status";
+
+ try {
+ const metaBody = {};
+ ["display_name", "device_status", "asset_for", "asset_on"].forEach(field => {
+ const el = document.getElementById(`edit-${field}`);
+ if (el) metaBody[field] = el.value || null;
+ });
+ const notesEl = document.getElementById("edit-notes");
+ if (notesEl) metaBody.notes = notesEl.value || null;
+
+ await apiMutate("PATCH", `/api/v1/assets/${asset.id}/meta`, metaBody);
+
+ const newOwner = ownerInput?.value?.trim() || "";
+ const prevOwner = asset.owner_ldap_username || "";
+ if (newOwner !== prevOwner) {
+ if (newOwner) {
+ await apiMutate("PUT", `/api/v1/assets/${asset.id}/owner`, { ldap_username: newOwner });
+ }
+ }
+
+ editStatus.textContent = "✓ Tersimpan";
+ editStatus.className = "edit-status ok";
+ setTimeout(() => openDetail(asset.id), 800);
+ } catch (err) {
+ editStatus.textContent = `✗ ${err.message}`;
+ editStatus.className = "edit-status error";
+ } finally {
+ saveBtn.disabled = false;
+ }
+ });
+ }
+}
+
function renderAssets(data) {
state.total = data.total;
state.pages = data.pages || 1;
@@ -143,12 +352,15 @@ function renderAssets(data) {
const rows = data.items
.map((item) => {
const selected = item.id === state.selectedId ? "selected" : "";
+ const dsBadge = item.device_status ? tagBadge(item.device_status, DEVICE_STATUS_COLORS) : "";
+ const afBadge = item.asset_for ? tagBadge(item.asset_for, ASSET_FOR_COLORS) : "";
+ const aoBadge = item.asset_on ? tagBadge(item.asset_on, ASSET_ON_COLORS) : "";
return `
- ${escapeHtml(item.hostname)}
+ ${escapeHtml(assetLabel(item))} ${item.display_name ? `${escapeHtml(item.hostname)}
` : ""}${formFactorBadge(item.form_factor)}
${escapeHtml(item.asset_type)}
- ${escapeHtml(item.status)}
+ ${dsBadge || `${escapeHtml(item.status)} `}
+ ${afBadge} ${aoBadge}
${escapeHtml(item.serial_number || "—")}
- ${escapeHtml(item.software_count ?? "—")}
${escapeHtml(fmtDate(item.last_seen_at))}
`;
})
@@ -162,8 +374,8 @@ function renderAssets(data) {
Hostname
Tipe
Status
+ Tag
Serial
- Software
Last seen
@@ -193,10 +405,259 @@ function connectionLabel(value) {
return labels[value] || value || "—";
}
+function renderMemorySection(snap) {
+ const summary = snap.memory_summary;
+ const modules = snap.memory_modules || [];
+ if (!summary && !modules.length) return "";
+
+ let html = `Memory (RAM)
`;
+ if (summary) {
+ const layout = summary.layout || summary.configuration || "—";
+ const moduleLabel =
+ summary.module_count != null
+ ? `${summary.module_count} modul`
+ : modules.length
+ ? `${modules.length} modul`
+ : "—";
+ html += `
+ Total ${escapeHtml(summary.total_gb != null ? `${summary.total_gb} GB` : snap.ram_gb != null ? `${snap.ram_gb} GB` : "—")}
+ Layout ${escapeHtml(layout)}
+ Generasi ${escapeHtml(summary.ddr_generation || "—")}
+ Modul ${escapeHtml(moduleLabel)}
+ Slot kosong ${escapeHtml(summary.empty_slots != null ? summary.empty_slots : "—")}
+ `;
+ }
+
+ if (modules.length) {
+ html += `Slot Kapasitas Tipe Speed Form Merek Part no. Serial `;
+ html += modules
+ .map((m) => {
+ const cap = m.capacity_gb != null ? `${m.capacity_gb} GB` : "—";
+ const spd = m.configured_speed_mhz != null ? `${m.configured_speed_mhz} MHz` : m.speed_mhz != null ? `${m.speed_mhz} MHz` : "—";
+ return `
+ ${escapeHtml(m.slot || m.bank_label || "—")}
+ ${escapeHtml(cap)}
+ ${escapeHtml(m.memory_type_label || "—")}
+ ${escapeHtml(spd)}
+ ${escapeHtml(m.form_factor || "—")}
+ ${escapeHtml(m.manufacturer || "—")}
+ ${escapeHtml(m.part_number || "—")}
+ ${escapeHtml(m.serial_number || "—")}
+ `;
+ })
+ .join("");
+ html += `
`;
+ }
+ return html;
+}
+
+function renderCpuSection(snap) {
+ const cpu = snap.cpu;
+ if (!cpu) {
+ if (!snap.cpu_model) return "";
+ return `Processor (CPU)
+ Model
+ ${escapeHtml(snap.cpu_model)}
+
`;
+ }
+
+ const fmtKb = (v) => (v != null ? `${v} KB` : "—");
+
+ let html = `Processor (CPU)
`;
+ html += `Model Vendor Cores Threads Clock Cache Socket Arch `;
+ html += `
+ ${escapeHtml(cpu.model || snap.cpu_model || "—")}
+ ${escapeHtml(cpu.manufacturer || "—")}
+ ${escapeHtml(cpu.cores ?? "—")}
+ ${escapeHtml(cpu.logical_processors ?? "—")}
+ ${escapeHtml(
+ cpu.max_clock_mhz != null || cpu.current_clock_mhz != null
+ ? [cpu.current_clock_mhz != null ? `now ${cpu.current_clock_mhz}` : null, cpu.max_clock_mhz != null ? `max ${cpu.max_clock_mhz}` : null]
+ .filter(Boolean)
+ .map((s) => `${s} MHz`)
+ .join(" · ")
+ : "—"
+ )}
+ ${escapeHtml(
+ cpu.l2_cache_kb != null || cpu.l3_cache_kb != null
+ ? [`L2 ${fmtKb(cpu.l2_cache_kb)}`, `L3 ${fmtKb(cpu.l3_cache_kb)}`].join(" · ")
+ : "—"
+ )}
+ ${escapeHtml(
+ cpu.socket_count != null && cpu.socket_count > 1
+ ? `${cpu.socket || "—"} (${cpu.socket_count}×)`
+ : cpu.socket || "—"
+ )}
+ ${escapeHtml(cpu.architecture || "—")}
+ `;
+ html += `
`;
+ return html;
+}
+
+function renderGpuSection(snap) {
+ if (!snap.gpus?.length) {
+ if (!snap.gpu_model) return "";
+ return `Display adapter (GPU)
+ Card name Dedicated memory
+ ${escapeHtml(snap.gpu_model)} —
+
`;
+ }
+ let html = `Display adapter (GPU)
`;
+ html += `Card Vendor Dedicated Driver Resolusi Status `;
+ html += snap.gpus
+ .map((g) => {
+ const mem =
+ g.dedicated_memory_mb != null ? `${g.dedicated_memory_mb} MB` : "—";
+ const driver = [g.driver_version, g.driver_date ? fmtDate(g.driver_date) : null]
+ .filter(Boolean)
+ .join(" · ") || "—";
+ return `
+ ${escapeHtml(g.name)} ${g.video_processor ? `${escapeHtml(g.video_processor)}
` : ""}
+ ${escapeHtml(g.adapter_compatibility || "—")}
+ ${escapeHtml(mem)}
+ ${escapeHtml(driver)}
+ ${escapeHtml(g.current_resolution || "—")}
+ ${escapeHtml(g.status || "—")}
+ `;
+ })
+ .join("");
+ html += `
`;
+ return html;
+}
+
+function renderPhysicalDiskSection(snap) {
+ if (!snap.physical_disks?.length) return "";
+ let html = `Physical disks (${snap.physical_disks.length})
`;
+ html += `# Model Media Bus Kapasitas Partisi Status Health `;
+ html += snap.physical_disks
+ .map((d) => {
+ const healthWarn = d.health_status && d.health_status !== "Healthy" ? "style='color:var(--warn)'" : "";
+ return `
+ ${escapeHtml(d.disk_number ?? d.device_id ?? "—")}
+ ${escapeHtml(d.friendly_name || d.model || "—")}
+ ${escapeHtml(d.media_type || "—")}
+ ${escapeHtml(d.bus_type || "—")}
+ ${escapeHtml(d.total_gb != null ? `${d.total_gb} GB` : "—")}
+ ${escapeHtml(d.partition_style || "—")}
+ ${escapeHtml(d.operational_status || "—")}
+ ${escapeHtml(d.health_status || "—")}
+ `;
+ })
+ .join("");
+ html += `
`;
+ return html;
+}
+
+function renderDiskSection(snap) {
+ let html = renderPhysicalDiskSection(snap);
+ if (!snap.disks?.length) return html;
+ html += `Volumes (partisi)
`;
+ html += `Drive Model File system Total Used Free `;
+ html += snap.disks
+ .map((d) => {
+ const warn = d.pct_used > 85 ? "style='color:var(--warn)'" : "";
+ return `
+ ${escapeHtml(d.drive_letter)}
+ ${escapeHtml(d.model || "—")}
+ ${escapeHtml(d.file_system || "—")}
+ ${escapeHtml(d.total_gb)} GB
+ ${escapeHtml(d.pct_used)}%
+ ${escapeHtml(d.free_gb)} GB
+ `;
+ })
+ .join("");
+ html += `
`;
+ return html;
+}
+
+function renderDisplaySection(peripherals) {
+ const displays = (peripherals || []).filter((p) => p.connection === "display");
+ if (!displays.length) return "";
+
+ let html = `Display devices (monitor)
`;
+ html += `Monitor Manufacturer Resolusi Refresh Tipe Serial `;
+ html += displays
+ .map((p) => {
+ const res =
+ p.resolution ||
+ (p.resolution_width && p.resolution_height
+ ? `${p.resolution_width}x${p.resolution_height}`
+ : "—");
+ const hz = p.refresh_hz != null ? `${p.refresh_hz} Hz` : "—";
+ const kind =
+ p.is_external === true
+ ? "Eksternal"
+ : p.is_external === false
+ ? "Internal"
+ : "—";
+ return `
+ ${escapeHtml(p.name)}
+ ${escapeHtml(p.manufacturer || "—")}
+ ${escapeHtml(res)}
+ ${escapeHtml(hz)}
+ ${escapeHtml(kind)}
+ ${escapeHtml(p.device_serial || p.edid_product || "—")}
+ `;
+ })
+ .join("");
+ html += `
`;
+ return html;
+}
+
+function renderPeripheralsSection(peripherals) {
+ const items = (peripherals || []).filter((p) => p.connection !== "display");
+ if (!items.length) return "";
+
+ let html = `Peripherals (${escapeHtml(items.length)})
`;
+ html += `Nama Kelas Koneksi Role Detail `;
+ html += items
+ .map((p) => {
+ let detail = "—";
+ if (p.vid && p.pid) {
+ detail = `${p.vid}:${p.pid}`;
+ } else if (p.vid || p.pid) {
+ detail = p.vid || p.pid;
+ }
+ const ext =
+ p.is_external === true
+ ? 'Eksternal '
+ : p.is_external === false
+ ? 'Internal '
+ : "";
+ const connClass = p.connection ? `conn-${escapeHtml(p.connection)}` : "";
+ return `
+ ${escapeHtml(p.name)} ${escapeHtml(p.manufacturer || "")}
+ ${escapeHtml(p.device_class || "—")}
+ ${escapeHtml(connectionLabel(p.connection))} ${ext}
+ ${escapeHtml(p.device_role || "—")}
+ ${escapeHtml(detail)}
+ `;
+ })
+ .join("");
+ html += `
`;
+ return html;
+}
+
function renderDetail(asset, software) {
const snap = asset.latest_snapshot;
+ const formFactor =
+ snap?.system?.form_factor || asset.metadata?.form_factor || null;
+
+ const tagRow = [
+ asset.device_status ? tagBadge(asset.device_status, DEVICE_STATUS_COLORS) : "",
+ asset.asset_for ? tagBadge(asset.asset_for, ASSET_FOR_COLORS) : "",
+ asset.asset_on ? tagBadge(asset.asset_on, ASSET_ON_COLORS) : "",
+ ].filter(Boolean).join(" ");
+
+ const ownerHtml = asset.owner_ldap_username
+ ? `${escapeHtml(asset.owner_ldap_username)} `
+ : `— `;
+
const identity = [
["Hostname", asset.hostname],
+ ["Display name", asset.display_name],
+ ["Form factor", formFactor],
+ ["Chassis", snap?.system?.chassis_label || asset.metadata?.chassis_label],
["Asset ID", asset.id],
["Serial", asset.serial_number],
["SMBIOS UUID", asset.smbios_uuid],
@@ -208,44 +669,39 @@ function renderDetail(asset, software) {
["Last seen", fmtDate(asset.last_seen_at)],
];
- let html = `${identity
+ let html = ``;
+
+ if (tagRow || asset.owner_ldap_username) {
+ html += ``;
+ }
+
+ html += `${identity
.map(
([k, v]) => `${escapeHtml(k)} ${escapeHtml(v || "—")} `
)
.join("")} `;
+ html += renderEditPanel(asset);
+
if (snap) {
html += `Snapshot terbaru — ${escapeHtml(fmtDate(snap.collected_at))}
`;
html += `
OS ${escapeHtml(snap.os_name || "—")} ${escapeHtml(snap.os_version || "")}
- CPU ${escapeHtml(snap.cpu_model || "—")}
+ CPU ${escapeHtml(snap.cpu?.model || snap.cpu_model || "—")}
RAM ${escapeHtml(snap.ram_gb != null ? `${snap.ram_gb} GB` : "—")}
- GPU ${escapeHtml(snap.gpu_model || "—")}
+ BIOS ${escapeHtml(snap.bios_version || "—")}
Agent ${escapeHtml(snap.agent_version)}
`;
- if (snap.gpus?.length) {
- html += `GPU
Card Dedicated memory `;
- html += snap.gpus
- .map((g) => {
- const mem =
- g.dedicated_memory_mb != null ? `${g.dedicated_memory_mb} MB` : "—";
- return `${escapeHtml(g.name)} ${escapeHtml(mem)} `;
- })
- .join("");
- html += `
`;
- }
-
- if (snap.disks?.length) {
- html += `Disk
Drive Model Used Free `;
- html += snap.disks
- .map((d) => {
- const warn = d.pct_used > 85 ? "style='color:var(--warn)'" : "";
- return `${escapeHtml(d.drive_letter)} ${escapeHtml(d.model || "—")} ${escapeHtml(d.pct_used)}% ${escapeHtml(d.free_gb)} GB `;
- })
- .join("");
- html += `
`;
- }
+ html += renderCpuSection(snap);
+ html += renderMemorySection(snap);
+ html += renderGpuSection(snap);
+ html += renderDiskSection(snap);
+ html += renderDisplaySection(snap.peripherals);
if (snap.network?.length) {
html += `Network
Adapter MAC IP `;
@@ -258,40 +714,7 @@ function renderDetail(asset, software) {
html += `
`;
}
- if (snap.peripherals?.length) {
- html += `Peripherals (${escapeHtml(snap.peripherals.length)})
`;
- html += `Nama Kelas Koneksi Role Detail `;
- html += snap.peripherals
- .map((p) => {
- let detail = "—";
- if (p.connection === "display") {
- const res = p.resolution || (p.resolution_width && p.resolution_height
- ? `${p.resolution_width}x${p.resolution_height}` : "");
- const hz = p.refresh_hz != null ? `${p.refresh_hz} Hz` : "";
- detail = [res, hz, p.device_serial, p.edid_product].filter(Boolean).join(" · ") || "—";
- } else if (p.vid && p.pid) {
- detail = `${p.vid}:${p.pid}`;
- } else if (p.vid || p.pid) {
- detail = p.vid || p.pid;
- }
- const ext =
- p.is_external === true
- ? 'Eksternal '
- : p.is_external === false
- ? 'Internal '
- : "";
- const connClass = p.connection ? `conn-${escapeHtml(p.connection)}` : "";
- return `
- ${escapeHtml(p.name)} ${escapeHtml(p.manufacturer || "")}
- ${escapeHtml(p.device_class || "—")}
- ${escapeHtml(connectionLabel(p.connection))} ${ext}
- ${escapeHtml(p.device_role || "—")}
- ${escapeHtml(detail)}
- `;
- })
- .join("");
- html += `
`;
- }
+ html += renderPeripheralsSection(snap.peripherals);
}
if (software?.items?.length) {
@@ -308,8 +731,10 @@ function renderDetail(asset, software) {
}
}
- el.detailTitle.textContent = asset.hostname;
+ const titleBadges = [formFactor ? formFactorBadge(formFactor) : ""].filter(Boolean).join(" ");
+ el.detailTitle.innerHTML = `${escapeHtml(asset.display_name || asset.hostname)}${titleBadges ? " " + titleBadges : ""}`;
el.detailContent.innerHTML = html;
+ bindEditPanel(asset);
}
async function loadStats() {
@@ -339,10 +764,16 @@ async function openDetail(assetId) {
api(`/api/v1/assets/${assetId}/software?limit=50`),
]);
el.detailContent.className = "detail-body";
- renderDetail(asset, software);
+ try {
+ renderDetail(asset, software);
+ } catch (renderErr) {
+ el.detailContent.className = "detail-body error";
+ el.detailContent.textContent = `Gagal render detail: ${renderErr.message}`;
+ console.error(renderErr);
+ }
} catch (err) {
el.detailContent.className = "detail-body error";
- el.detailContent.textContent = err.message;
+ el.detailContent.innerHTML = `Gagal memuat detail asset
${escapeHtml(err.message)}
`;
}
}
@@ -367,7 +798,7 @@ async function connect() {
showConnectedUi(true);
try {
- await Promise.all([loadStats(), loadAssets()]);
+ await Promise.all([loadStats(), loadAssets(), loadOptions()]);
} catch (err) {
showConnectedUi(false);
showError(err);
diff --git a/GeoNetAgent/collector/static/index.html b/GeoNetAgent/collector/static/index.html
index 4de9a68..c8983a3 100644
--- a/GeoNetAgent/collector/static/index.html
+++ b/GeoNetAgent/collector/static/index.html
@@ -3,8 +3,8 @@
- GeoNetAgent — Asset Viewer
-
+ GeoNetAgent — CMDB UI
+