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 = ``; + return html; +} + +function renderEditPanel(asset) { + const opts = state.options; + return ` +
+
⚙ Edit Asset
+ +
+
Display Name
+ +
+ +
+
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)} +
+ +
+
Catatan
+ +
+ +
+ + +
+
`; +} + +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 += ``; + 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 ` + + + + + + + + + `; + }) + .join(""); + html += `
SlotKapasitasTipeSpeedFormMerekPart no.Serial
${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 || "—")}
`; + } + 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 += ``; + html += ` + + + + + + + + + `; + html += `
ModelVendorCoresThreadsClockCacheSocketArch
${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 || "—")}
`; + return html; +} + +function renderGpuSection(snap) { + if (!snap.gpus?.length) { + if (!snap.gpu_model) return ""; + return `
Display adapter (GPU)
+ + +
Card nameDedicated memory
${escapeHtml(snap.gpu_model)}
`; + } + let html = `
Display adapter (GPU)
`; + html += ``; + 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 ` + + + + + + + `; + }) + .join(""); + html += `
CardVendorDedicatedDriverResolusiStatus
${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 || "—")}
`; + return html; +} + +function renderPhysicalDiskSection(snap) { + if (!snap.physical_disks?.length) return ""; + let html = `
Physical disks (${snap.physical_disks.length})
`; + html += ``; + html += snap.physical_disks + .map((d) => { + const healthWarn = d.health_status && d.health_status !== "Healthy" ? "style='color:var(--warn)'" : ""; + return ` + + + + + + + + + `; + }) + .join(""); + html += `
#ModelMediaBusKapasitasPartisiStatusHealth
${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 || "—")}
`; + return html; +} + +function renderDiskSection(snap) { + let html = renderPhysicalDiskSection(snap); + if (!snap.disks?.length) return html; + html += `
Volumes (partisi)
`; + html += ``; + html += snap.disks + .map((d) => { + const warn = d.pct_used > 85 ? "style='color:var(--warn)'" : ""; + return ` + + + + + + + `; + }) + .join(""); + html += `
DriveModelFile systemTotalUsedFree
${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
`; + return html; +} + +function renderDisplaySection(peripherals) { + const displays = (peripherals || []).filter((p) => p.connection === "display"); + if (!displays.length) return ""; + + let html = `
Display devices (monitor)
`; + html += ``; + 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 ` + + + + + + + `; + }) + .join(""); + html += `
MonitorManufacturerResolusiRefreshTipeSerial
${escapeHtml(p.name)}${escapeHtml(p.manufacturer || "—")}${escapeHtml(res)}${escapeHtml(hz)}${escapeHtml(kind)}${escapeHtml(p.device_serial || p.edid_product || "—")}
`; + return html; +} + +function renderPeripheralsSection(peripherals) { + const items = (peripherals || []).filter((p) => p.connection !== "display"); + if (!items.length) return ""; + + let html = `
Peripherals (${escapeHtml(items.length)})
`; + html += ``; + 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 ` + + + + + + `; + }) + .join(""); + html += `
NamaKelasKoneksiRoleDetail
${escapeHtml(p.name)}
${escapeHtml(p.manufacturer || "")}
${escapeHtml(p.device_class || "—")}${escapeHtml(connectionLabel(p.connection))} ${ext}${escapeHtml(p.device_role || "—")}${escapeHtml(detail)}
`; + 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 += `
+ ${ownerHtml ? `Owner: ${ownerHtml}` : ""} + ${tagRow ? `  ${tagRow}` : ""} + ${asset.notes ? `
${escapeHtml(asset.notes)}
` : ""} +
`; + } + + 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
`; - html += snap.gpus - .map((g) => { - const mem = - g.dedicated_memory_mb != null ? `${g.dedicated_memory_mb} MB` : "—"; - return ``; - }) - .join(""); - html += `
CardDedicated memory
${escapeHtml(g.name)}${escapeHtml(mem)}
`; - } - - if (snap.disks?.length) { - html += `
Disk
`; - html += snap.disks - .map((d) => { - const warn = d.pct_used > 85 ? "style='color:var(--warn)'" : ""; - return ``; - }) - .join(""); - html += `
DriveModelUsedFree
${escapeHtml(d.drive_letter)}${escapeHtml(d.model || "—")}${escapeHtml(d.pct_used)}%${escapeHtml(d.free_gb)} GB
`; - } + html += renderCpuSection(snap); + html += renderMemorySection(snap); + html += renderGpuSection(snap); + html += renderDiskSection(snap); + html += renderDisplaySection(snap.peripherals); if (snap.network?.length) { html += `
Network
`; @@ -258,40 +714,7 @@ function renderDetail(asset, software) { html += `
AdapterMACIP
`; } - if (snap.peripherals?.length) { - html += `
Peripherals (${escapeHtml(snap.peripherals.length)})
`; - html += ``; - 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 ` - - - - - - `; - }) - .join(""); - html += `
NamaKelasKoneksiRoleDetail
${escapeHtml(p.name)}
${escapeHtml(p.manufacturer || "")}
${escapeHtml(p.device_class || "—")}${escapeHtml(connectionLabel(p.connection))} ${ext}${escapeHtml(p.device_role || "—")}${escapeHtml(detail)}
`; - } + 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 +
@@ -70,6 +70,6 @@ - + diff --git a/GeoNetAgent/collector/static/style.css b/GeoNetAgent/collector/static/style.css index 677f013..e7bb897 100644 --- a/GeoNetAgent/collector/static/style.css +++ b/GeoNetAgent/collector/static/style.css @@ -202,6 +202,31 @@ tbody tr.selected { 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 { background: rgba(34, 197, 94, 0.15); color: var(--ok); @@ -324,3 +349,130 @@ tbody tr.selected { .hidden { 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); }