Browse Source

feat(collector): CMDB UI edit - owner LDAP autocomplete, device_status/asset_for/asset_on tags, PATCH /meta endpoint

master
Budi Setiawan 3 weeks ago
parent
commit
fc96ba6c41
  1. 30
      GeoNetAgent/collector/app/api/v1/assets.py
  2. 15
      GeoNetAgent/collector/app/api/v1/ldap.py
  3. 3
      GeoNetAgent/collector/app/api/v1/router.py
  4. 9
      GeoNetAgent/collector/app/config.py
  5. 6
      GeoNetAgent/collector/app/db/models.py
  6. 2
      GeoNetAgent/collector/app/main.py
  7. 61
      GeoNetAgent/collector/app/schemas/agent_report.py
  8. 21
      GeoNetAgent/collector/app/schemas/assets.py
  9. 84
      GeoNetAgent/collector/app/services/asset_identity.py
  10. 44
      GeoNetAgent/collector/app/services/asset_read_service.py
  11. 30
      GeoNetAgent/collector/app/services/asset_service.py
  12. 244
      GeoNetAgent/collector/app/services/asset_split_service.py
  13. 61
      GeoNetAgent/collector/app/services/ldap_service.py
  14. 1
      GeoNetAgent/collector/pyproject.toml
  15. 547
      GeoNetAgent/collector/static/app.js
  16. 6
      GeoNetAgent/collector/static/index.html
  17. 152
      GeoNetAgent/collector/static/style.css

30
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.core.auth import verify_api_bearer
from app.db.session import get_db from app.db.session import get_db
from app.schemas.agent_report import ErrorBody, ErrorResponse 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 from app.services import asset_read_service
router = APIRouter() 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]) @router.get("/assets/by-owner/{ldap_username}", response_model=list[AssetsByOwnerItem])
def get_assets_by_owner( def get_assets_by_owner(
ldap_username: str, ldap_username: str,
@ -120,6 +135,19 @@ def list_asset_software(
return result 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) @router.put("/assets/{asset_id}/owner", response_model=AssetOwnerOut)
def set_asset_owner( def set_asset_owner(
asset_id: uuid.UUID, asset_id: uuid.UUID,

15
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)

3
GeoNetAgent/collector/app/api/v1/router.py

@ -1,8 +1,9 @@
from fastapi import APIRouter 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 = APIRouter(prefix="/api/v1")
router.include_router(health.router, tags=["health"]) router.include_router(health.router, tags=["health"])
router.include_router(agent.router, tags=["agent"]) router.include_router(agent.router, tags=["agent"])
router.include_router(assets.router, tags=["assets"]) router.include_router(assets.router, tags=["assets"])
router.include_router(ldap.router, tags=["ldap"])

9
GeoNetAgent/collector/app/config.py

@ -24,6 +24,15 @@ class Settings(BaseSettings):
timezone: str = "Asia/Jakarta" 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: def resolved_api_bearer_token(self) -> str:
return self.api_bearer_token.strip() or self.agent_bearer_token return self.api_bearer_token.strip() or self.agent_bearer_token

6
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) 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) asset_type: Mapped[str] = mapped_column(String(30), nullable=False)
hostname: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) 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) smbios_uuid: Mapped[str | None] = mapped_column(String(64), unique=True)
primary_physical_mac: Mapped[str | None] = mapped_column(String(17)) primary_physical_mac: Mapped[str | None] = mapped_column(String(17))
display_name: Mapped[str | None] = mapped_column(String(255)) display_name: Mapped[str | None] = mapped_column(String(255))
@ -22,6 +22,10 @@ class Asset(Base):
model: Mapped[str | None] = mapped_column(String(100)) model: Mapped[str | None] = mapped_column(String(100))
serial_number: 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") 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) metadata_: Mapped[dict] = mapped_column("metadata", JSONB, nullable=False, default=dict)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

2
GeoNetAgent/collector/app/main.py

@ -25,7 +25,7 @@ app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=settings.cors_origin_list(), allow_origins=settings.cors_origin_list(),
allow_credentials=True, allow_credentials=True,
allow_methods=["GET", "POST", "OPTIONS"], allow_methods=["GET", "POST", "PUT", "PATCH", "OPTIONS"],
allow_headers=["Authorization", "Content-Type"], allow_headers=["Authorization", "Content-Type"],
) )

61
GeoNetAgent/collector/app/schemas/agent_report.py

@ -4,11 +4,6 @@ from typing import Any, Literal
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
class GpuInfo(BaseModel):
name: str
dedicated_memory_mb: int | None = None
class SystemInfo(BaseModel): class SystemInfo(BaseModel):
manufacturer: str | None = None manufacturer: str | None = None
model: str | None = None model: str | None = None
@ -20,6 +15,25 @@ class SystemInfo(BaseModel):
ram_gb: float | None = None ram_gb: float | None = None
gpu_model: str | None = None gpu_model: str | None = None
last_boot_at: datetime | 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): class DiskInfo(BaseModel):
@ -31,15 +45,49 @@ class DiskInfo(BaseModel):
model: str | None = None 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): class MemoryModule(BaseModel):
slot: str | None = None slot: str | None = None
bank_label: str | None = None bank_label: str | None = None
capacity_gb: float | None = None capacity_gb: float | None = None
speed_mhz: int | None = None speed_mhz: int | None = None
configured_speed_mhz: int | None = None
manufacturer: str | None = None manufacturer: str | None = None
part_number: str | None = None part_number: str | None = None
serial_number: str | None = None
form_factor: str | None = None form_factor: str | None = None
memory_type: int | 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): class MemorySummary(BaseModel):
@ -50,6 +98,7 @@ class MemorySummary(BaseModel):
layout: str | None = None layout: str | None = None
configuration: str | None = None configuration: str | None = None
upgradeable: bool | None = None upgradeable: bool | None = None
ddr_generation: str | None = None
class NetworkAdapter(BaseModel): class NetworkAdapter(BaseModel):
@ -122,8 +171,10 @@ class AgentReportRequest(BaseModel):
asset_type: str = "endpoint" asset_type: str = "endpoint"
logged_in_user: str | None = None logged_in_user: str | None = None
system: SystemInfo system: SystemInfo
cpu: CpuInfo | None = None
gpus: list[GpuInfo] = Field(default_factory=list) gpus: list[GpuInfo] = Field(default_factory=list)
disks: list[DiskInfo] = 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_modules: list[MemoryModule] = Field(default_factory=list)
memory_summary: MemorySummary | None = None memory_summary: MemorySummary | None = None
network: list[NetworkAdapter] = Field(default_factory=list) network: list[NetworkAdapter] = Field(default_factory=list)

21
GeoNetAgent/collector/app/schemas/assets.py

@ -11,6 +11,9 @@ class AssetListItem(BaseModel):
form_factor: str | None = None form_factor: str | None = None
asset_type: str asset_type: str
status: str status: str
device_status: str | None = None
asset_for: str | None = None
asset_on: str | None = None
manufacturer: str | None = None manufacturer: str | None = None
model: str | None = None model: str | None = None
serial_number: str | None = None serial_number: str | None = None
@ -206,6 +209,11 @@ class AssetDetail(BaseModel):
hostname: str hostname: str
asset_type: str asset_type: str
status: 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 display_name: str | None = None
manufacturer: str | None = None manufacturer: str | None = None
model: str | None = None model: str | None = None
@ -229,6 +237,19 @@ class StatsSummary(BaseModel):
total_software_rows: int 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): class AssetOwnerIn(BaseModel):
ldap_username: str = Field(..., min_length=1, max_length=100) ldap_username: str = Field(..., min_length=1, max_length=100)

84
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

44
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 ( from app.schemas.assets import (
AssetDetail, AssetDetail,
AssetListItem, AssetListItem,
AssetMetaIn,
AssetOwnerOut, AssetOwnerOut,
AssetsByOwnerItem, AssetsByOwnerItem,
CpuOut, CpuOut,
@ -163,6 +164,9 @@ def list_assets(
or (asset.metadata_ or {}).get("form_factor"), or (asset.metadata_ or {}).get("form_factor"),
asset_type=asset.asset_type, asset_type=asset.asset_type,
status=asset.status, status=asset.status,
device_status=asset.device_status,
asset_for=asset.asset_for,
asset_on=asset.asset_on,
manufacturer=asset.manufacturer, manufacturer=asset.manufacturer,
model=asset.model, model=asset.model,
serial_number=asset.serial_number, 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: 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))) asset = db.scalar(select(Asset).where(Asset.id == asset_id, Asset.deleted_at.is_(None)))
if not asset: if not asset:
@ -454,12 +472,18 @@ def get_asset(db: Session, asset_id: uuid.UUID) -> AssetDetail | None:
.order_by(AssetSnapshot.collected_at.desc()) .order_by(AssetSnapshot.collected_at.desc())
.limit(1) .limit(1)
) )
owner_username = _get_asset_owner(db, asset_id)
return AssetDetail( return AssetDetail(
id=str(asset.id), id=str(asset.id),
hostname=asset.hostname, hostname=asset.hostname,
asset_type=asset.asset_type, asset_type=asset.asset_type,
status=asset.status, 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, display_name=asset.display_name,
manufacturer=asset.manufacturer, manufacturer=asset.manufacturer,
model=asset.model, 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( def list_snapshots(
db: Session, db: Session,
asset_id: uuid.UUID, asset_id: uuid.UUID,

30
GeoNetAgent/collector/app/services/asset_service.py

@ -14,6 +14,7 @@ from app.db.models import (
User, User,
) )
from app.schemas.agent_report import AgentReportRequest, AgentReportResponse 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: def _parse_install_date(value: date | str | None) -> date | None:
@ -45,31 +46,38 @@ def _sanitize_json(value):
return 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: def _find_asset(db: Session, report: AgentReportRequest) -> Asset | None:
identity = report.asset_identity identity = report.asset_identity
primary_mac = identity.primary_physical_mac if identity else None primary_mac = identity.primary_physical_mac if identity else None
if report.serial_number: 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: if asset:
return asset return asset
if report.smbios_uuid: 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: if asset:
return asset return asset
if primary_mac: 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: if asset:
return asset return asset
if report.machine_id: 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: if asset:
return 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: 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 primary_mac = report.asset_identity.primary_physical_mac if report.asset_identity else None
if is_new: if is_new:
hostname, display_name = allocate_hostname(db, report)
asset = Asset( asset = Asset(
asset_type=report.asset_type, asset_type=report.asset_type,
hostname=report.hostname, hostname=hostname,
display_name=display_name,
status="active", status="active",
) )
db.add(asset) db.add(asset)
db.flush() db.flush()
else: else:
if asset.hostname != report.hostname: if not asset.display_name and asset.hostname != report.hostname:
asset.hostname = report.hostname asset.hostname = report.hostname
asset.machine_id = report.machine_id 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_summary": report.memory_summary.model_dump() if report.memory_summary else None,
"memory_modules": [m.model_dump() for m in report.memory_modules], "memory_modules": [m.model_dump() for m in report.memory_modules],
"agent_metadata": report.metadata, "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( existing_snapshot = db.scalar(
select(AssetSnapshot).where( select(AssetSnapshot).where(
@ -162,7 +176,7 @@ def ingest_agent_report(db: Session, report: AgentReportRequest) -> AgentReportR
agent_version=report.agent_version, agent_version=report.agent_version,
os_name=report.system.os_name, os_name=report.system.os_name,
os_version=report.system.os_version or report.system.os_build, 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, ram_gb=report.system.ram_gb,
gpu_model=report.system.gpu_model or (report.gpus[0].name if report.gpus else None), gpu_model=report.system.gpu_model or (report.gpus[0].name if report.gpus else None),
bios_version=report.system.bios_version, bios_version=report.system.bios_version,

244
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

61
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 []

1
GeoNetAgent/collector/pyproject.toml

@ -11,6 +11,7 @@ dependencies = [
"psycopg2-binary>=2.9.10", "psycopg2-binary>=2.9.10",
"pydantic>=2.10.0", "pydantic>=2.10.0",
"pydantic-settings>=2.6.0", "pydantic-settings>=2.6.0",
"ldap3>=2.9.1",
] ]
[project.optional-dependencies] [project.optional-dependencies]

547
GeoNetAgent/collector/static/app.js

@ -9,6 +9,8 @@ const state = {
pages: 1, pages: 1,
selectedId: null, selectedId: null,
searchTimer: null, searchTimer: null,
options: { device_status: [], asset_for: [], asset_on: [] },
ldapSearchTimer: null,
}; };
const el = { const el = {
@ -75,6 +77,34 @@ async function api(path) {
return data; 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) { function fmtDate(value) {
if (!value) return "—"; if (!value) return "—";
try { try {
@ -128,6 +158,185 @@ function buildAssetsQuery() {
return `?${params.toString()}`; 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 `<span class="badge ${cls}">${escapeHtml(label)}</span>`;
}
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 `<span class="badge ${cls}">${escapeHtml(value)}</span>`;
}
function buildSelect(name, options, current, placeholder = "— pilih —") {
let html = `<select id="edit-${name}" data-field="${name}">`;
html += `<option value="">${placeholder}</option>`;
for (const opt of options) {
const sel = opt === current ? " selected" : "";
html += `<option value="${escapeHtml(opt)}"${sel}>${escapeHtml(opt)}</option>`;
}
html += `</select>`;
return html;
}
function renderEditPanel(asset) {
const opts = state.options;
return `
<div class="edit-panel" id="edit-panel">
<div class="edit-panel-title"> Edit Asset</div>
<div class="edit-section">
<div class="edit-label">Display Name</div>
<input type="text" id="edit-display_name" data-field="display_name"
value="${escapeHtml(asset.display_name || "")}"
placeholder="Nama tampilan (opsional)" />
</div>
<div class="edit-section">
<div class="edit-label">Owner (LDAP Username)</div>
<div class="ldap-autocomplete">
<input type="text" id="edit-owner-input"
value="${escapeHtml(asset.owner_ldap_username || "")}"
placeholder="Ketik username / nama... (min 2 karakter)"
autocomplete="off" />
<div id="ldap-suggestions" class="ldap-dropdown hidden"></div>
</div>
</div>
<div class="edit-section">
<div class="edit-label">Device Status</div>
${buildSelect("device_status", opts.device_status || [], asset.device_status)}
</div>
<div class="edit-section">
<div class="edit-label">Asset For (Kepemilikan)</div>
${buildSelect("asset_for", opts.asset_for || [], asset.asset_for)}
</div>
<div class="edit-section">
<div class="edit-label">Asset On (Lokasi)</div>
${buildSelect("asset_on", opts.asset_on || [], asset.asset_on)}
</div>
<div class="edit-section">
<div class="edit-label">Catatan</div>
<textarea id="edit-notes" data-field="notes" rows="3"
placeholder="Catatan tambahan...">${escapeHtml(asset.notes || "")}</textarea>
</div>
<div class="edit-actions">
<button type="button" id="save-meta-btn">Simpan</button>
<span id="edit-status" class="edit-status"></span>
</div>
</div>`;
}
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 = `<div class="ldap-item muted">Tidak ada hasil.</div>`;
} else {
suggestionsBox.innerHTML = results.map(u => `
<div class="ldap-item" data-username="${escapeHtml(u.username)}">
<strong>${escapeHtml(u.username)}</strong>
${u.display_name ? `<span class="muted"> — ${escapeHtml(u.display_name)}</span>` : ""}
${u.department ? `<span class="muted small"> · ${escapeHtml(u.department)}</span>` : ""}
</div>`).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) { function renderAssets(data) {
state.total = data.total; state.total = data.total;
state.pages = data.pages || 1; state.pages = data.pages || 1;
@ -143,12 +352,15 @@ function renderAssets(data) {
const rows = data.items const rows = data.items
.map((item) => { .map((item) => {
const selected = item.id === state.selectedId ? "selected" : ""; 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 `<tr data-id="${escapeHtml(item.id)}" class="${selected}"> return `<tr data-id="${escapeHtml(item.id)}" class="${selected}">
<td><strong>${escapeHtml(item.hostname)}</strong></td> <td><strong>${escapeHtml(assetLabel(item))}</strong>${item.display_name ? `<div class="muted small">${escapeHtml(item.hostname)}</div>` : ""}${formFactorBadge(item.form_factor)}</td>
<td><span class="badge endpoint">${escapeHtml(item.asset_type)}</span></td> <td><span class="badge endpoint">${escapeHtml(item.asset_type)}</span></td>
<td><span class="badge active">${escapeHtml(item.status)}</span></td> <td>${dsBadge || `<span class="badge active">${escapeHtml(item.status)}</span>`}</td>
<td>${afBadge} ${aoBadge}</td>
<td class="muted">${escapeHtml(item.serial_number || "—")}</td> <td class="muted">${escapeHtml(item.serial_number || "—")}</td>
<td>${escapeHtml(item.software_count ?? "—")}</td>
<td class="muted">${escapeHtml(fmtDate(item.last_seen_at))}</td> <td class="muted">${escapeHtml(fmtDate(item.last_seen_at))}</td>
</tr>`; </tr>`;
}) })
@ -162,8 +374,8 @@ function renderAssets(data) {
<th>Hostname</th> <th>Hostname</th>
<th>Tipe</th> <th>Tipe</th>
<th>Status</th> <th>Status</th>
<th>Tag</th>
<th>Serial</th> <th>Serial</th>
<th>Software</th>
<th>Last seen</th> <th>Last seen</th>
</tr> </tr>
</thead> </thead>
@ -193,83 +405,215 @@ function connectionLabel(value) {
return labels[value] || value || "—"; return labels[value] || value || "—";
} }
function renderDetail(asset, software) { function renderMemorySection(snap) {
const snap = asset.latest_snapshot; const summary = snap.memory_summary;
const identity = [ const modules = snap.memory_modules || [];
["Hostname", asset.hostname], if (!summary && !modules.length) return "";
["Asset ID", asset.id],
["Serial", asset.serial_number], let html = `<div class="section-title">Memory (RAM)</div>`;
["SMBIOS UUID", asset.smbios_uuid], if (summary) {
["Machine ID", asset.machine_id], const layout = summary.layout || summary.configuration || "—";
["MAC", asset.primary_physical_mac], const moduleLabel =
["Manufacturer", asset.manufacturer], summary.module_count != null
["Model", asset.model], ? `${summary.module_count} modul`
["Snapshots", asset.snapshot_count], : modules.length
["Last seen", fmtDate(asset.last_seen_at)], ? `${modules.length} modul`
]; : "—";
html += `<dl class="detail-grid memory-summary">
let html = `<dl class="detail-grid">${identity <dt>Total</dt><dd>${escapeHtml(summary.total_gb != null ? `${summary.total_gb} GB` : snap.ram_gb != null ? `${snap.ram_gb} GB` : "")}</dd>
.map( <dt>Layout</dt><dd>${escapeHtml(layout)}</dd>
([k, v]) => `<dt>${escapeHtml(k)}</dt><dd>${escapeHtml(v || "—")}</dd>` <dt>Generasi</dt><dd>${escapeHtml(summary.ddr_generation || "")}</dd>
) <dt>Modul</dt><dd>${escapeHtml(moduleLabel)}</dd>
.join("")}</dl>`; <dt>Slot kosong</dt><dd>${escapeHtml(summary.empty_slots != null ? summary.empty_slots : "")}</dd>
if (snap) {
html += `<div class="section-title">Snapshot terbaru — ${escapeHtml(fmtDate(snap.collected_at))}</div>`;
html += `<dl class="detail-grid">
<dt>OS</dt><dd>${escapeHtml(snap.os_name || "")} ${escapeHtml(snap.os_version || "")}</dd>
<dt>CPU</dt><dd>${escapeHtml(snap.cpu_model || "")}</dd>
<dt>RAM</dt><dd>${escapeHtml(snap.ram_gb != null ? `${snap.ram_gb} GB` : "")}</dd>
<dt>GPU</dt><dd>${escapeHtml(snap.gpu_model || "")}</dd>
<dt>Agent</dt><dd>${escapeHtml(snap.agent_version)}</dd>
</dl>`; </dl>`;
}
if (modules.length) {
html += `<table><thead><tr><th>Slot</th><th>Kapasitas</th><th>Tipe</th><th>Speed</th><th>Form</th><th>Merek</th><th>Part no.</th><th>Serial</th></tr></thead><tbody>`;
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 `<tr>
<td>${escapeHtml(m.slot || m.bank_label || "—")}</td>
<td>${escapeHtml(cap)}</td>
<td>${escapeHtml(m.memory_type_label || "—")}</td>
<td class="muted">${escapeHtml(spd)}</td>
<td class="muted">${escapeHtml(m.form_factor || "—")}</td>
<td class="muted">${escapeHtml(m.manufacturer || "—")}</td>
<td class="muted">${escapeHtml(m.part_number || "—")}</td>
<td class="muted">${escapeHtml(m.serial_number || "—")}</td>
</tr>`;
})
.join("");
html += `</tbody></table>`;
}
return html;
}
function renderCpuSection(snap) {
const cpu = snap.cpu;
if (!cpu) {
if (!snap.cpu_model) return "";
return `<div class="section-title">Processor (CPU)</div>
<table><thead><tr><th>Model</th></tr></thead><tbody>
<tr><td>${escapeHtml(snap.cpu_model)}</td></tr>
</tbody></table>`;
}
const fmtKb = (v) => (v != null ? `${v} KB` : "—");
let html = `<div class="section-title">Processor (CPU)</div>`;
html += `<table><thead><tr><th>Model</th><th>Vendor</th><th>Cores</th><th>Threads</th><th>Clock</th><th>Cache</th><th>Socket</th><th>Arch</th></tr></thead><tbody>`;
html += `<tr>
<td><strong>${escapeHtml(cpu.model || snap.cpu_model || "—")}</strong></td>
<td class="muted">${escapeHtml(cpu.manufacturer || "—")}</td>
<td>${escapeHtml(cpu.cores ?? "—")}</td>
<td>${escapeHtml(cpu.logical_processors ?? "—")}</td>
<td class="muted">${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(" · ")
: "—"
)}</td>
<td class="muted">${escapeHtml(
cpu.l2_cache_kb != null || cpu.l3_cache_kb != null
? [`L2 ${fmtKb(cpu.l2_cache_kb)}`, `L3 ${fmtKb(cpu.l3_cache_kb)}`].join(" · ")
: "—"
)}</td>
<td class="muted">${escapeHtml(
cpu.socket_count != null && cpu.socket_count > 1
? `${cpu.socket || "—"} (${cpu.socket_count}×)`
: cpu.socket || "—"
)}</td>
<td class="muted">${escapeHtml(cpu.architecture || "—")}</td>
</tr>`;
html += `</tbody></table>`;
return html;
}
if (snap.gpus?.length) { function renderGpuSection(snap) {
html += `<div class="section-title">GPU</div><table><thead><tr><th>Card</th><th>Dedicated memory</th></tr></thead><tbody>`; if (!snap.gpus?.length) {
if (!snap.gpu_model) return "";
return `<div class="section-title">Display adapter (GPU)</div>
<table><thead><tr><th>Card name</th><th>Dedicated memory</th></tr></thead><tbody>
<tr><td>${escapeHtml(snap.gpu_model)}</td><td></td></tr>
</tbody></table>`;
}
let html = `<div class="section-title">Display adapter (GPU)</div>`;
html += `<table><thead><tr><th>Card</th><th>Vendor</th><th>Dedicated</th><th>Driver</th><th>Resolusi</th><th>Status</th></tr></thead><tbody>`;
html += snap.gpus html += snap.gpus
.map((g) => { .map((g) => {
const mem = const mem =
g.dedicated_memory_mb != null ? `${g.dedicated_memory_mb} MB` : "—"; g.dedicated_memory_mb != null ? `${g.dedicated_memory_mb} MB` : "—";
return `<tr><td>${escapeHtml(g.name)}</td><td>${escapeHtml(mem)}</td></tr>`; const driver = [g.driver_version, g.driver_date ? fmtDate(g.driver_date) : null]
.filter(Boolean)
.join(" · ") || "—";
return `<tr>
<td><strong>${escapeHtml(g.name)}</strong>${g.video_processor ? `<div class="muted small">${escapeHtml(g.video_processor)}</div>` : ""}</td>
<td class="muted">${escapeHtml(g.adapter_compatibility || "—")}</td>
<td>${escapeHtml(mem)}</td>
<td class="muted">${escapeHtml(driver)}</td>
<td class="muted">${escapeHtml(g.current_resolution || "—")}</td>
<td class="muted">${escapeHtml(g.status || "—")}</td>
</tr>`;
}) })
.join(""); .join("");
html += `</tbody></table>`; html += `</tbody></table>`;
return html;
} }
if (snap.disks?.length) { function renderPhysicalDiskSection(snap) {
html += `<div class="section-title">Disk</div><table><thead><tr><th>Drive</th><th>Model</th><th>Used</th><th>Free</th></tr></thead><tbody>`; if (!snap.physical_disks?.length) return "";
let html = `<div class="section-title">Physical disks (${snap.physical_disks.length})</div>`;
html += `<table><thead><tr><th>#</th><th>Model</th><th>Media</th><th>Bus</th><th>Kapasitas</th><th>Partisi</th><th>Status</th><th>Health</th></tr></thead><tbody>`;
html += snap.physical_disks
.map((d) => {
const healthWarn = d.health_status && d.health_status !== "Healthy" ? "style='color:var(--warn)'" : "";
return `<tr>
<td>${escapeHtml(d.disk_number ?? d.device_id ?? "—")}</td>
<td><strong>${escapeHtml(d.friendly_name || d.model || "—")}</strong></td>
<td><span class="badge media-${escapeHtml((d.media_type || "unknown").toLowerCase())}">${escapeHtml(d.media_type || "—")}</span></td>
<td class="muted">${escapeHtml(d.bus_type || "—")}</td>
<td>${escapeHtml(d.total_gb != null ? `${d.total_gb} GB` : "—")}</td>
<td class="muted">${escapeHtml(d.partition_style || "—")}</td>
<td class="muted">${escapeHtml(d.operational_status || "—")}</td>
<td ${healthWarn}>${escapeHtml(d.health_status || "—")}</td>
</tr>`;
})
.join("");
html += `</tbody></table>`;
return html;
}
function renderDiskSection(snap) {
let html = renderPhysicalDiskSection(snap);
if (!snap.disks?.length) return html;
html += `<div class="section-title">Volumes (partisi)</div>`;
html += `<table><thead><tr><th>Drive</th><th>Model</th><th>File system</th><th>Total</th><th>Used</th><th>Free</th></tr></thead><tbody>`;
html += snap.disks html += snap.disks
.map((d) => { .map((d) => {
const warn = d.pct_used > 85 ? "style='color:var(--warn)'" : ""; const warn = d.pct_used > 85 ? "style='color:var(--warn)'" : "";
return `<tr><td>${escapeHtml(d.drive_letter)}</td><td class="muted">${escapeHtml(d.model || "—")}</td><td ${warn}>${escapeHtml(d.pct_used)}%</td><td>${escapeHtml(d.free_gb)} GB</td></tr>`; return `<tr>
<td>${escapeHtml(d.drive_letter)}</td>
<td>${escapeHtml(d.model || "—")}</td>
<td class="muted">${escapeHtml(d.file_system || "—")}</td>
<td class="muted">${escapeHtml(d.total_gb)} GB</td>
<td ${warn}>${escapeHtml(d.pct_used)}%</td>
<td>${escapeHtml(d.free_gb)} GB</td>
</tr>`;
}) })
.join(""); .join("");
html += `</tbody></table>`; html += `</tbody></table>`;
return html;
} }
if (snap.network?.length) { function renderDisplaySection(peripherals) {
html += `<div class="section-title">Network</div><table><thead><tr><th>Adapter</th><th>MAC</th><th>IP</th></tr></thead><tbody>`; const displays = (peripherals || []).filter((p) => p.connection === "display");
html += snap.network if (!displays.length) return "";
.map(
(n) => let html = `<div class="section-title">Display devices (monitor)</div>`;
`<tr><td>${escapeHtml(n.adapter_name)}</td><td class="muted">${escapeHtml(n.mac_address || "—")}</td><td>${escapeHtml(n.ip_address || "—")}</td></tr>` html += `<table><thead><tr><th>Monitor</th><th>Manufacturer</th><th>Resolusi</th><th>Refresh</th><th>Tipe</th><th>Serial</th></tr></thead><tbody>`;
) 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 `<tr>
<td><strong>${escapeHtml(p.name)}</strong></td>
<td class="muted">${escapeHtml(p.manufacturer || "—")}</td>
<td>${escapeHtml(res)}</td>
<td class="muted">${escapeHtml(hz)}</td>
<td><span class="badge conn-display">${escapeHtml(kind)}</span></td>
<td class="muted">${escapeHtml(p.device_serial || p.edid_product || "—")}</td>
</tr>`;
})
.join(""); .join("");
html += `</tbody></table>`; html += `</tbody></table>`;
return html;
} }
if (snap.peripherals?.length) { function renderPeripheralsSection(peripherals) {
html += `<div class="section-title">Peripherals (${escapeHtml(snap.peripherals.length)})</div>`; const items = (peripherals || []).filter((p) => p.connection !== "display");
if (!items.length) return "";
let html = `<div class="section-title">Peripherals (${escapeHtml(items.length)})</div>`;
html += `<table class="peripherals-table"><thead><tr><th>Nama</th><th>Kelas</th><th>Koneksi</th><th>Role</th><th>Detail</th></tr></thead><tbody>`; html += `<table class="peripherals-table"><thead><tr><th>Nama</th><th>Kelas</th><th>Koneksi</th><th>Role</th><th>Detail</th></tr></thead><tbody>`;
html += snap.peripherals html += items
.map((p) => { .map((p) => {
let detail = "—"; let detail = "—";
if (p.connection === "display") { if (p.vid && p.pid) {
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}`; detail = `${p.vid}:${p.pid}`;
} else if (p.vid || p.pid) { } else if (p.vid || p.pid) {
detail = p.vid || p.pid; detail = p.vid || p.pid;
@ -291,7 +635,86 @@ function renderDetail(asset, software) {
}) })
.join(""); .join("");
html += `</tbody></table>`; html += `</tbody></table>`;
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
? `<span class="badge af-geonet">${escapeHtml(asset.owner_ldap_username)}</span>`
: `<span class="muted">—</span>`;
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],
["Machine ID", asset.machine_id],
["MAC", asset.primary_physical_mac],
["Manufacturer", asset.manufacturer],
["Model", asset.model],
["Snapshots", asset.snapshot_count],
["Last seen", fmtDate(asset.last_seen_at)],
];
let html = ``;
if (tagRow || asset.owner_ldap_username) {
html += `<div class="asset-tags-row">
${ownerHtml ? `<span class="muted small">Owner:</span> ${ownerHtml}` : ""}
${tagRow ? `&nbsp;&nbsp;${tagRow}` : ""}
${asset.notes ? `<div class="muted small notes-preview">${escapeHtml(asset.notes)}</div>` : ""}
</div>`;
}
html += `<dl class="detail-grid">${identity
.map(
([k, v]) => `<dt>${escapeHtml(k)}</dt><dd>${escapeHtml(v || "—")}</dd>`
)
.join("")}</dl>`;
html += renderEditPanel(asset);
if (snap) {
html += `<div class="section-title">Snapshot terbaru — ${escapeHtml(fmtDate(snap.collected_at))}</div>`;
html += `<dl class="detail-grid">
<dt>OS</dt><dd>${escapeHtml(snap.os_name || "")} ${escapeHtml(snap.os_version || "")}</dd>
<dt>CPU</dt><dd>${escapeHtml(snap.cpu?.model || snap.cpu_model || "")}</dd>
<dt>RAM</dt><dd>${escapeHtml(snap.ram_gb != null ? `${snap.ram_gb} GB` : "")}</dd>
<dt>BIOS</dt><dd>${escapeHtml(snap.bios_version || "")}</dd>
<dt>Agent</dt><dd>${escapeHtml(snap.agent_version)}</dd>
</dl>`;
html += renderCpuSection(snap);
html += renderMemorySection(snap);
html += renderGpuSection(snap);
html += renderDiskSection(snap);
html += renderDisplaySection(snap.peripherals);
if (snap.network?.length) {
html += `<div class="section-title">Network</div><table><thead><tr><th>Adapter</th><th>MAC</th><th>IP</th></tr></thead><tbody>`;
html += snap.network
.map(
(n) =>
`<tr><td>${escapeHtml(n.adapter_name)}</td><td class="muted">${escapeHtml(n.mac_address || "—")}</td><td>${escapeHtml(n.ip_address || "—")}</td></tr>`
)
.join("");
html += `</tbody></table>`;
} }
html += renderPeripheralsSection(snap.peripherals);
} }
if (software?.items?.length) { 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; el.detailContent.innerHTML = html;
bindEditPanel(asset);
} }
async function loadStats() { async function loadStats() {
@ -339,10 +764,16 @@ async function openDetail(assetId) {
api(`/api/v1/assets/${assetId}/software?limit=50`), api(`/api/v1/assets/${assetId}/software?limit=50`),
]); ]);
el.detailContent.className = "detail-body"; el.detailContent.className = "detail-body";
try {
renderDetail(asset, software); renderDetail(asset, software);
} catch (renderErr) {
el.detailContent.className = "detail-body error";
el.detailContent.textContent = `Gagal render detail: ${renderErr.message}`;
console.error(renderErr);
}
} catch (err) { } catch (err) {
el.detailContent.className = "detail-body error"; el.detailContent.className = "detail-body error";
el.detailContent.textContent = err.message; el.detailContent.innerHTML = `<p><strong>Gagal memuat detail asset</strong></p><p>${escapeHtml(err.message)}</p>`;
} }
} }
@ -367,7 +798,7 @@ async function connect() {
showConnectedUi(true); showConnectedUi(true);
try { try {
await Promise.all([loadStats(), loadAssets()]); await Promise.all([loadStats(), loadAssets(), loadOptions()]);
} catch (err) { } catch (err) {
showConnectedUi(false); showConnectedUi(false);
showError(err); showError(err);

6
GeoNetAgent/collector/static/index.html

@ -3,8 +3,8 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>GeoNetAgent — Asset Viewer</title> <title>GeoNetAgent — CMDB UI</title>
<link rel="stylesheet" href="/ui/style.css" /> <link rel="stylesheet" href="/ui/style.css?v=20260625" />
</head> </head>
<body> <body>
<header> <header>
@ -70,6 +70,6 @@
</section> </section>
</main> </main>
<script src="/ui/app.js"></script> <script src="/ui/app.js?v=20260625"></script>
</body> </body>
</html> </html>

152
GeoNetAgent/collector/static/style.css

@ -202,6 +202,31 @@ tbody tr.selected {
text-transform: uppercase; text-transform: uppercase;
} }
.badge.form-laptop {
background: rgba(59, 130, 246, 0.2);
color: #93c5fd;
}
.badge.form-desktop {
background: rgba(34, 197, 94, 0.15);
color: #86efac;
}
.badge.form-other {
background: rgba(139, 156, 179, 0.2);
color: var(--muted);
}
.badge.media-ssd {
background: rgba(59, 130, 246, 0.15);
color: #93c5fd;
}
.badge.media-hdd {
background: rgba(245, 158, 11, 0.15);
color: #fcd34d;
}
.badge.active { .badge.active {
background: rgba(34, 197, 94, 0.15); background: rgba(34, 197, 94, 0.15);
color: var(--ok); color: var(--ok);
@ -324,3 +349,130 @@ tbody tr.selected {
.hidden { .hidden {
display: none !important; display: none !important;
} }
/* ── Device Status badges ── */
.badge.ds-active { background: rgba(34,197,94,.15); color: #86efac; }
.badge.ds-used { background: rgba(59,130,246,.15); color: #93c5fd; }
.badge.ds-stok { background: rgba(168,85,247,.15); color: #c084fc; }
.badge.ds-rusak { background: rgba(239,68,68,.2); color: #fca5a5; }
.badge.ds-maintenance { background: rgba(245,158,11,.2); color: #fcd34d; }
.badge.ds-hilang { background: rgba(239,68,68,.3); color: #f87171; }
.badge.ds-retired { background: rgba(139,156,179,.2); color: var(--muted); }
.badge.ds-pinjam { background: rgba(20,184,166,.15); color: #5eead4; }
/* ── Asset For badges ── */
.badge.af-geonet { background: rgba(59,130,246,.18); color: #60a5fa; }
.badge.af-sewa { background: rgba(245,158,11,.18); color: #fbbf24; }
.badge.af-proyek { background: rgba(168,85,247,.18); color: #a78bfa; }
.badge.af-personal { background: rgba(34,197,94,.18); color: #4ade80; }
.badge.af-client { background: rgba(20,184,166,.18); color: #2dd4bf; }
.badge.af-inventaris { background: rgba(139,156,179,.18);color: var(--muted); }
/* ── Asset On badges ── */
.badge.ao-geonet { background: rgba(59,130,246,.12); color: #93c5fd; }
.badge.ao-site { background: rgba(239,68,68,.12); color: #fca5a5; }
.badge.ao-remote { background: rgba(168,85,247,.12); color: #c084fc; }
.badge.ao-gudang { background: rgba(245,158,11,.12); color: #fde68a; }
.badge.ao-client { background: rgba(20,184,166,.12); color: #99f6e4; }
.badge.ao-lab { background: rgba(34,197,94,.12); color: #86efac; }
.badge.tag-default { background: rgba(139,156,179,.15); color: var(--muted); }
/* ── Asset tags row di detail ── */
.asset-tags-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .4rem;
padding: .5rem .75rem;
margin-bottom: .75rem;
background: rgba(59,130,246,.04);
border: 1px solid var(--border);
border-radius: 6px;
}
.notes-preview {
width: 100%;
margin-top: .25rem;
font-style: italic;
opacity: .8;
}
/* ── Edit Panel ── */
.edit-panel {
border: 1px solid var(--border);
border-radius: 8px;
padding: .75rem 1rem;
margin: 1rem 0;
background: rgba(255,255,255,.02);
}
.edit-panel-title {
font-size: .8rem;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--muted);
margin-bottom: .75rem;
font-weight: 600;
}
.edit-section {
margin-bottom: .65rem;
}
.edit-label {
font-size: .7rem;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--muted);
margin-bottom: .2rem;
}
.edit-panel input[type="text"],
.edit-panel select,
.edit-panel textarea {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: .4rem .65rem;
font: inherit;
font-size: .85rem;
min-width: unset;
}
.edit-panel textarea {
resize: vertical;
}
.edit-actions {
display: flex;
align-items: center;
gap: .75rem;
margin-top: .5rem;
}
.edit-status {
font-size: .8rem;
}
.edit-status.ok { color: var(--ok); }
.edit-status.error { color: var(--danger); }
/* ── LDAP Autocomplete ── */
.ldap-autocomplete {
position: relative;
}
.ldap-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 0 0 6px 6px;
z-index: 100;
max-height: 220px;
overflow-y: auto;
box-shadow: 0 8px 24px rgba(0,0,0,.4);
}
.ldap-item {
padding: .45rem .75rem;
cursor: pointer;
font-size: .85rem;
border-bottom: 1px solid var(--border);
}
.ldap-item:last-child { border-bottom: none; }
.ldap-item:hover { background: rgba(59,130,246,.12); }

Loading…
Cancel
Save