diff --git a/GeoNetAgent/collector/README.md b/GeoNetAgent/collector/README.md new file mode 100644 index 0000000..331909e --- /dev/null +++ b/GeoNetAgent/collector/README.md @@ -0,0 +1,3 @@ +# GeoNetAgent Collector + +FastAPI service for agent ingress and CMDB read API. diff --git a/GeoNetAgent/collector/app/api/v1/assets.py b/GeoNetAgent/collector/app/api/v1/assets.py index 90738f6..91b0cc5 100644 --- a/GeoNetAgent/collector/app/api/v1/assets.py +++ b/GeoNetAgent/collector/app/api/v1/assets.py @@ -36,8 +36,21 @@ def list_assets( asset_type: str | None = Query(None, alias="type"), status: str | None = None, q: str | None = None, + search_in: str = Query("all", pattern="^(all|owner|model|series|manufacturer|hostname)$"), + owner: str | None = Query(None, max_length=100), + model: str | None = Query(None, max_length=100), + series: str | None = Query(None, max_length=255), + manufacturer: str | None = Query(None, max_length=100), + device_status: str | None = Query(None, max_length=30), + asset_for: str | None = Query(None, max_length=30), + asset_on: str | None = Query(None, max_length=30), stale: bool | None = None, disk_warning: bool | None = None, + sort: str = Query( + "last_seen_at", + pattern="^(last_seen_at|hostname|owner|model|series|manufacturer|agent_version|device_status)$", + ), + order: str = Query("desc", pattern="^(asc|desc)$"), page: int = Query(1, ge=1), limit: int = Query(50, ge=1, le=200), db: Session = Depends(get_db), @@ -48,8 +61,18 @@ def list_assets( asset_type=asset_type, status=status, q=q, + search_in=search_in, + owner=owner, + model=model, + series=series, + manufacturer=manufacturer, + device_status=device_status, + asset_for=asset_for, + asset_on=asset_on, stale=stale, disk_warning=disk_warning, + sort=sort, + order=order, page=page, limit=limit, ) @@ -155,7 +178,9 @@ def set_asset_owner( db: Session = Depends(get_db), _: str = Depends(verify_api_bearer), ) -> AssetOwnerOut: - result = asset_read_service.set_asset_owner(db, asset_id, body.ldap_username) + result = asset_read_service.set_asset_owner( + db, asset_id, body.ldap_username, body.display_name + ) if result is None: raise _not_found() return result diff --git a/GeoNetAgent/collector/app/api/v1/device_models.py b/GeoNetAgent/collector/app/api/v1/device_models.py new file mode 100644 index 0000000..faf234d --- /dev/null +++ b/GeoNetAgent/collector/app/api/v1/device_models.py @@ -0,0 +1,91 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Query, status +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.device_models import ( + DeviceModelCatalogIn, + DeviceModelCatalogOut, + DeviceModelCatalogPatch, + DeviceModelLookupOut, + DeviceModelSyncResult, + PaginatedDeviceModelCatalog, +) +from app.services import device_model_catalog_service as catalog_service + +router = APIRouter(prefix="/device-models") + + +def _bad_request(message: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ErrorResponse(error=ErrorBody(code="BAD_REQUEST", message=message)).model_dump(), + ) + + +@router.get("", response_model=PaginatedDeviceModelCatalog) +def list_device_models( + q: str | None = Query(None, max_length=200), + pending_only: bool = Query(False), + page: int = Query(1, ge=1), + limit: int = Query(50, ge=1, le=200), + db: Session = Depends(get_db), + _: str = Depends(verify_api_bearer), +) -> PaginatedDeviceModelCatalog: + return catalog_service.list_catalog(db, q=q, pending_only=pending_only, page=page, limit=limit) + + +@router.post("/sync-from-assets", response_model=DeviceModelSyncResult) +def sync_device_models_from_assets( + db: Session = Depends(get_db), + _: str = Depends(verify_api_bearer), +) -> DeviceModelSyncResult: + return catalog_service.sync_missing_from_assets(db) + + +@router.get("/lookup", response_model=DeviceModelLookupOut) +def lookup_device_model( + manufacturer: str = Query(..., min_length=1, max_length=100), + model: str = Query(..., min_length=1, max_length=100), + db: Session = Depends(get_db), + _: str = Depends(verify_api_bearer), +) -> DeviceModelLookupOut: + return catalog_service.resolve_series(db, manufacturer, model) + + +@router.post("", response_model=DeviceModelCatalogOut, status_code=status.HTTP_201_CREATED) +def create_device_model( + body: DeviceModelCatalogIn, + db: Session = Depends(get_db), + _: str = Depends(verify_api_bearer), +) -> DeviceModelCatalogOut: + try: + return catalog_service.create_catalog_entry(db, body) + except ValueError as exc: + raise _bad_request(str(exc)) from exc + + +@router.patch("/{entry_id}", response_model=DeviceModelCatalogOut) +def patch_device_model( + entry_id: uuid.UUID, + body: DeviceModelCatalogPatch, + db: Session = Depends(get_db), + _: str = Depends(verify_api_bearer), +) -> DeviceModelCatalogOut: + result = catalog_service.patch_catalog_entry(db, entry_id, body) + if result is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Catalog entry not found") + return result + + +@router.delete("/{entry_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_device_model( + entry_id: uuid.UUID, + db: Session = Depends(get_db), + _: str = Depends(verify_api_bearer), +) -> None: + if not catalog_service.delete_catalog_entry(db, entry_id): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Catalog entry not found") diff --git a/GeoNetAgent/collector/app/api/v1/router.py b/GeoNetAgent/collector/app/api/v1/router.py index 0e412fa..931f3d9 100644 --- a/GeoNetAgent/collector/app/api/v1/router.py +++ b/GeoNetAgent/collector/app/api/v1/router.py @@ -1,9 +1,10 @@ from fastapi import APIRouter -from app.api.v1 import agent, assets, health, ldap +from app.api.v1 import agent, assets, device_models, 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(device_models.router, tags=["device-models"]) router.include_router(ldap.router, tags=["ldap"]) diff --git a/GeoNetAgent/collector/app/db/models.py b/GeoNetAgent/collector/app/db/models.py index 114612d..db700a8 100644 --- a/GeoNetAgent/collector/app/db/models.py +++ b/GeoNetAgent/collector/app/db/models.py @@ -125,3 +125,20 @@ class AgentToken(Base): is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class DeviceModelCatalog(Base): + """SSOT: manufacturer + model (WMI) → series / marketing name.""" + + __tablename__ = "device_model_catalog" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + manufacturer: Mapped[str] = mapped_column(String(100), nullable=False) + model: Mapped[str] = mapped_column(String(100), nullable=False) + manufacturer_normalized: Mapped[str] = mapped_column(String(100), nullable=False) + model_normalized: Mapped[str] = mapped_column(String(100), nullable=False) + series: Mapped[str | None] = mapped_column(String(255)) + source: Mapped[str] = mapped_column(String(50), nullable=False, default="manual") + notes: Mapped[str | None] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/GeoNetAgent/collector/app/schemas/assets.py b/GeoNetAgent/collector/app/schemas/assets.py index d0753cf..c9abbf9 100644 --- a/GeoNetAgent/collector/app/schemas/assets.py +++ b/GeoNetAgent/collector/app/schemas/assets.py @@ -16,6 +16,7 @@ class AssetListItem(BaseModel): asset_on: str | None = None manufacturer: str | None = None model: str | None = None + series: str | None = None serial_number: str | None = None machine_id: str | None = None smbios_uuid: str | None = None @@ -23,6 +24,9 @@ class AssetListItem(BaseModel): last_seen_at: datetime | None = None latest_snapshot_at: datetime | None = None software_count: int | None = None + owner_ldap_username: str | None = None + owner_display_name: str | None = None + agent_version: str | None = None class PaginatedAssets(BaseModel): @@ -214,9 +218,11 @@ class AssetDetail(BaseModel): asset_on: str | None = None notes: str | None = None owner_ldap_username: str | None = None + owner_display_name: str | None = None display_name: str | None = None manufacturer: str | None = None model: str | None = None + series: str | None = None serial_number: str | None = None machine_id: str | None = None smbios_uuid: str | None = None @@ -252,6 +258,7 @@ class AssetMetaIn(BaseModel): class AssetOwnerIn(BaseModel): ldap_username: str = Field(..., min_length=1, max_length=100) + display_name: str | None = Field(None, max_length=200) class AssetOwnerOut(BaseModel): @@ -269,6 +276,7 @@ class AssetsByOwnerItem(BaseModel): status: str manufacturer: str | None = None model: str | None = None + series: str | None = None serial_number: str | None = None last_seen_at: datetime | None = None latest_snapshot_at: datetime | None = None diff --git a/GeoNetAgent/collector/app/schemas/device_models.py b/GeoNetAgent/collector/app/schemas/device_models.py new file mode 100644 index 0000000..efc7706 --- /dev/null +++ b/GeoNetAgent/collector/app/schemas/device_models.py @@ -0,0 +1,52 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + + +class DeviceModelCatalogIn(BaseModel): + manufacturer: str = Field(..., min_length=1, max_length=100) + model: str = Field(..., min_length=1, max_length=100) + series: str = Field(..., min_length=1, max_length=255) + source: str = Field(default="manual", max_length=50) + notes: str | None = Field(default=None, max_length=2000) + + +class DeviceModelCatalogPatch(BaseModel): + series: str | None = Field(default=None, min_length=1, max_length=255) + source: str | None = Field(default=None, max_length=50) + notes: str | None = Field(default=None, max_length=2000) + + +class DeviceModelCatalogOut(BaseModel): + id: str + manufacturer: str + model: str + series: str | None = None + series_pending: bool = False + source: str + notes: str | None = None + created_at: datetime + updated_at: datetime + + +class DeviceModelSyncResult(BaseModel): + inserted: int + already_exists: int + pending_series: int + distinct_asset_models: int + + +class PaginatedDeviceModelCatalog(BaseModel): + items: list[DeviceModelCatalogOut] + total: int + page: int + limit: int + pages: int + + +class DeviceModelLookupOut(BaseModel): + manufacturer: str + model: str + series: str | None = None + catalog_id: str | None = None + matched: bool = False diff --git a/GeoNetAgent/collector/app/services/asset_read_service.py b/GeoNetAgent/collector/app/services/asset_read_service.py index e9ef14a..7f3e8c0 100644 --- a/GeoNetAgent/collector/app/services/asset_read_service.py +++ b/GeoNetAgent/collector/app/services/asset_read_service.py @@ -2,10 +2,10 @@ import math import uuid from datetime import datetime, timedelta, timezone -from sqlalchemy import Select, exists, func, or_, select +from sqlalchemy import Select, and_, exists, func, or_, select from sqlalchemy.orm import Session -from app.db.models import Asset, AssetDisk, AssetNetworkAdapter, AssetSnapshot, AssetUserAssignment, SoftwareInstallation, User +from app.db.models import Asset, AssetDisk, AssetNetworkAdapter, AssetSnapshot, AssetUserAssignment, DeviceModelCatalog, SoftwareInstallation, User from app.schemas.assets import ( AssetDetail, AssetListItem, @@ -29,6 +29,8 @@ from app.schemas.assets import ( StatsSummary, SystemSnapshotOut, ) +from app.services import device_model_catalog_service as catalog_service +from app.services.device_model_catalog_service import catalog_key def _utcnow() -> datetime: @@ -75,6 +77,156 @@ def _disk_warning_exists(): ) +def _owner_exists_ilike(pattern: str): + return exists( + select(AssetUserAssignment.id) + .join(User, User.id == AssetUserAssignment.user_id) + .where( + AssetUserAssignment.asset_id == Asset.id, + AssetUserAssignment.is_primary == True, # noqa: E712 + AssetUserAssignment.ended_at.is_(None), + or_(User.username.ilike(pattern), User.display_name.ilike(pattern)), + ) + ) + + +def _series_exists_ilike(pattern: str): + return exists( + select(DeviceModelCatalog.id).where( + DeviceModelCatalog.manufacturer_normalized == func.upper(func.trim(Asset.manufacturer)), + DeviceModelCatalog.model_normalized == func.upper(func.trim(Asset.model)), + DeviceModelCatalog.series.isnot(None), + DeviceModelCatalog.series != "", + DeviceModelCatalog.series.ilike(pattern), + ) + ) + + +def _owner_subquery(): + return ( + select( + AssetUserAssignment.asset_id.label("owner_asset_id"), + User.username.label("owner_username"), + User.display_name.label("owner_display_name"), + ) + .join(User, User.id == AssetUserAssignment.user_id) + .where( + AssetUserAssignment.is_primary == True, # noqa: E712 + AssetUserAssignment.ended_at.is_(None), + ) + .subquery("asset_owner") + ) + + +def _catalog_series_subquery(): + return ( + select( + DeviceModelCatalog.manufacturer_normalized.label("cat_mfr"), + DeviceModelCatalog.model_normalized.label("cat_model"), + DeviceModelCatalog.series.label("cat_series"), + ) + .where( + DeviceModelCatalog.series.isnot(None), + DeviceModelCatalog.series != "", + ) + .subquery("asset_catalog") + ) + + +def _latest_agent_subquery(): + latest = _latest_snapshot_subquery() + return ( + select( + AssetSnapshot.asset_id.label("snap_asset_id"), + AssetSnapshot.agent_version.label("agent_version"), + ) + .join( + latest, + (AssetSnapshot.asset_id == latest.c.asset_id) + & (AssetSnapshot.collected_at == latest.c.max_collected_at), + ) + .subquery("latest_agent") + ) + + +def _apply_asset_search(stmt: Select, q: str, search_in: str) -> Select: + pattern = f"%{q.strip()}%" + if search_in == "owner": + return stmt.where(_owner_exists_ilike(pattern)) + if search_in == "model": + return stmt.where(Asset.model.ilike(pattern)) + if search_in == "series": + return stmt.where(_series_exists_ilike(pattern)) + if search_in == "manufacturer": + return stmt.where(Asset.manufacturer.ilike(pattern)) + if search_in == "hostname": + return stmt.where( + or_( + Asset.hostname.ilike(pattern), + Asset.display_name.ilike(pattern), + Asset.serial_number.ilike(pattern), + Asset.primary_physical_mac.ilike(pattern), + Asset.machine_id.ilike(pattern), + ) + ) + return stmt.where( + or_( + Asset.hostname.ilike(pattern), + Asset.display_name.ilike(pattern), + Asset.serial_number.ilike(pattern), + Asset.primary_physical_mac.ilike(pattern), + Asset.machine_id.ilike(pattern), + Asset.model.ilike(pattern), + Asset.manufacturer.ilike(pattern), + _owner_exists_ilike(pattern), + _series_exists_ilike(pattern), + ) + ) + + +def _apply_asset_sort(stmt: Select, sort: str, order: str) -> Select: + descending = order.lower() != "asc" + + def _order(col): + return col.desc().nullslast() if descending else col.asc().nullsfirst() + + if sort == "owner": + owner_sq = _owner_subquery() + stmt = stmt.outerjoin(owner_sq, owner_sq.c.owner_asset_id == Asset.id) + return stmt.order_by(_order(owner_sq.c.owner_username), Asset.hostname.asc()) + + if sort == "series": + cat_sq = _catalog_series_subquery() + stmt = stmt.outerjoin( + cat_sq, + and_( + cat_sq.c.cat_mfr == func.upper(func.trim(Asset.manufacturer)), + cat_sq.c.cat_model == func.upper(func.trim(Asset.model)), + ), + ) + return stmt.order_by(_order(cat_sq.c.cat_series), Asset.hostname.asc()) + + if sort == "agent_version": + snap_sq = _latest_agent_subquery() + stmt = stmt.outerjoin(snap_sq, snap_sq.c.snap_asset_id == Asset.id) + return stmt.order_by(_order(snap_sq.c.agent_version), Asset.hostname.asc()) + + sort_map = { + "hostname": Asset.hostname, + "model": Asset.model, + "manufacturer": Asset.manufacturer, + "device_status": Asset.device_status, + "last_seen_at": Asset.last_seen_at, + } + col = sort_map.get(sort, Asset.last_seen_at) + if sort == "last_seen_at": + return stmt.order_by( + col.desc().nullslast() if descending else col.asc().nullsfirst(), + Asset.hostname.asc(), + ) + return stmt.order_by(_order(col), Asset.hostname.asc()) + + def get_stats(db: Session) -> StatsSummary: stale_cutoff = _utcnow() - timedelta(hours=24) total_assets = db.scalar(select(func.count()).select_from(Asset).where(Asset.deleted_at.is_(None))) or 0 @@ -113,8 +265,18 @@ def list_assets( asset_type: str | None = None, status: str | None = None, q: str | None = None, + search_in: str = "all", + owner: str | None = None, + model: str | None = None, + series: str | None = None, + manufacturer: str | None = None, + device_status: str | None = None, + asset_for: str | None = None, + asset_on: str | None = None, stale: bool | None = None, disk_warning: bool | None = None, + sort: str = "last_seen_at", + order: str = "desc", page: int = 1, limit: int = 50, ) -> PaginatedAssets: @@ -124,15 +286,21 @@ def list_assets( if status: stmt = stmt.where(Asset.status == status) if q: - pattern = f"%{q.strip()}%" - stmt = stmt.where( - or_( - Asset.hostname.ilike(pattern), - Asset.serial_number.ilike(pattern), - Asset.primary_physical_mac.ilike(pattern), - Asset.machine_id.ilike(pattern), - ) - ) + stmt = _apply_asset_search(stmt, q, search_in) + if owner: + stmt = stmt.where(_owner_exists_ilike(f"%{owner.strip()}%")) + if model: + stmt = stmt.where(Asset.model.ilike(f"%{model.strip()}%")) + if series: + stmt = stmt.where(_series_exists_ilike(f"%{series.strip()}%")) + if manufacturer: + stmt = stmt.where(Asset.manufacturer.ilike(f"%{manufacturer.strip()}%")) + if device_status: + stmt = stmt.where(Asset.device_status == device_status) + if asset_for: + stmt = stmt.where(Asset.asset_for == asset_for) + if asset_on: + stmt = stmt.where(Asset.asset_on == asset_on) if stale: cutoff = _utcnow() - timedelta(hours=24) stmt = stmt.where(or_(Asset.last_seen_at.is_(None), Asset.last_seen_at < cutoff)) @@ -141,7 +309,13 @@ def list_assets( total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0 offset = (page - 1) * limit - rows = db.scalars(stmt.order_by(Asset.last_seen_at.desc().nullslast(), Asset.hostname).offset(offset).limit(limit)).all() + rows = db.scalars(_apply_asset_sort(stmt, sort, order).offset(offset).limit(limit)).all() + + series_map = catalog_service.resolve_series_map( + db, + [(a.manufacturer, a.model) for a in rows], + ) + owners_map = _get_owners_map(db, [a.id for a in rows]) latest = _latest_snapshot_subquery() items: list[AssetListItem] = [] @@ -155,6 +329,9 @@ def list_assets( ) .where(AssetSnapshot.asset_id == asset.id) ) + key = catalog_key(asset.manufacturer, asset.model) + series = series_map.get(key) if key else None + owner_username, owner_display_name = owners_map.get(asset.id, (None, None)) items.append( AssetListItem( id=str(asset.id), @@ -169,6 +346,7 @@ def list_assets( asset_on=asset.asset_on, manufacturer=asset.manufacturer, model=asset.model, + series=series, serial_number=asset.serial_number, machine_id=asset.machine_id, smbios_uuid=asset.smbios_uuid, @@ -176,6 +354,9 @@ def list_assets( last_seen_at=asset.last_seen_at, latest_snapshot_at=snap.collected_at if snap else None, software_count=snap.software_count if snap else None, + owner_ldap_username=owner_username, + owner_display_name=owner_display_name, + agent_version=snap.agent_version if snap else None, ) ) @@ -446,9 +627,9 @@ def _snapshot_to_summary(db: Session, snapshot: AssetSnapshot) -> SnapshotSummar ) -def _get_asset_owner(db: Session, asset_id: uuid.UUID) -> str | None: +def _get_asset_owner_info(db: Session, asset_id: uuid.UUID) -> tuple[str | None, str | None]: row = db.execute( - select(User.username) + select(User.username, User.display_name) .join(AssetUserAssignment, AssetUserAssignment.user_id == User.id) .where( AssetUserAssignment.asset_id == asset_id, @@ -456,8 +637,28 @@ def _get_asset_owner(db: Session, asset_id: uuid.UUID) -> str | None: AssetUserAssignment.ended_at.is_(None), ) .limit(1) - ).scalar_one_or_none() - return row + ).first() + if not row: + return None, None + return row.username, row.display_name + + +def _get_owners_map( + db: Session, + asset_ids: list[uuid.UUID], +) -> dict[uuid.UUID, tuple[str | None, str | None]]: + if not asset_ids: + return {} + rows = db.execute( + select(AssetUserAssignment.asset_id, User.username, User.display_name) + .join(User, User.id == AssetUserAssignment.user_id) + .where( + AssetUserAssignment.asset_id.in_(asset_ids), + AssetUserAssignment.is_primary == True, # noqa: E712 + AssetUserAssignment.ended_at.is_(None), + ) + ).all() + return {row.asset_id: (row.username, row.display_name) for row in rows} def get_asset(db: Session, asset_id: uuid.UUID) -> AssetDetail | None: @@ -472,7 +673,8 @@ 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) + owner_username, owner_display_name = _get_asset_owner_info(db, asset_id) + lookup = catalog_service.resolve_series(db, asset.manufacturer, asset.model) return AssetDetail( id=str(asset.id), @@ -484,9 +686,11 @@ def get_asset(db: Session, asset_id: uuid.UUID) -> AssetDetail | None: asset_on=asset.asset_on, notes=asset.notes, owner_ldap_username=owner_username, + owner_display_name=owner_display_name, display_name=asset.display_name, manufacturer=asset.manufacturer, model=asset.model, + series=lookup.series if lookup.matched else None, serial_number=asset.serial_number, machine_id=asset.machine_id, smbios_uuid=asset.smbios_uuid, @@ -612,16 +816,23 @@ def list_software( ) -def set_asset_owner(db: Session, asset_id: uuid.UUID, ldap_username: str) -> AssetOwnerOut | None: +def set_asset_owner( + db: Session, + asset_id: uuid.UUID, + ldap_username: str, + display_name: str | None = None, +) -> AssetOwnerOut | None: asset = db.scalar(select(Asset).where(Asset.id == asset_id, Asset.deleted_at.is_(None))) if not asset: return None user = db.scalar(select(User).where(User.username == ldap_username)) if not user: - user = User(username=ldap_username) + user = User(username=ldap_username, display_name=display_name) db.add(user) db.flush() + elif display_name and display_name != user.display_name: + user.display_name = display_name existing = db.scalar( select(AssetUserAssignment).where( @@ -670,6 +881,10 @@ def get_assets_by_ldap_username(db: Session, ldap_username: str) -> list[AssetsB ).all() latest = _latest_snapshot_subquery() + series_map = catalog_service.resolve_series_map( + db, + [(asset.manufacturer, asset.model) for asset, _ in rows], + ) items: list[AssetsByOwnerItem] = [] for asset, _ in rows: snap = db.scalar( @@ -681,6 +896,8 @@ def get_assets_by_ldap_username(db: Session, ldap_username: str) -> list[AssetsB ) .where(AssetSnapshot.asset_id == asset.id) ) + key = catalog_key(asset.manufacturer, asset.model) + series = series_map.get(key) if key else None items.append( AssetsByOwnerItem( id=str(asset.id), @@ -690,6 +907,7 @@ def get_assets_by_ldap_username(db: Session, ldap_username: str) -> list[AssetsB status=asset.status, manufacturer=asset.manufacturer, model=asset.model, + series=series, serial_number=asset.serial_number, last_seen_at=asset.last_seen_at, latest_snapshot_at=snap.collected_at if snap else None, diff --git a/GeoNetAgent/collector/app/services/asset_service.py b/GeoNetAgent/collector/app/services/asset_service.py index 67c0e36..d67cafa 100644 --- a/GeoNetAgent/collector/app/services/asset_service.py +++ b/GeoNetAgent/collector/app/services/asset_service.py @@ -91,16 +91,28 @@ def _upsert_user(db: Session, username: str) -> User: def _assign_user(db: Session, asset: Asset, username: str) -> None: - user = _upsert_user(db, username) - existing = db.scalar( + username = _sanitize_text(username, 100) + if not username: + return + + active_primary = db.scalar( select(AssetUserAssignment).where( AssetUserAssignment.asset_id == asset.id, - AssetUserAssignment.user_id == user.id, + AssetUserAssignment.is_primary == True, # noqa: E712 AssetUserAssignment.ended_at.is_(None), ) ) - if existing: + if active_primary and active_primary.assigned_by == "manual": return + + user = _upsert_user(db, username) + if active_primary and active_primary.user_id == user.id: + return + + if active_primary: + active_primary.ended_at = datetime.now(timezone.utc) + db.flush() + db.add( AssetUserAssignment( asset_id=asset.id, diff --git a/GeoNetAgent/collector/app/services/device_model_catalog_service.py b/GeoNetAgent/collector/app/services/device_model_catalog_service.py new file mode 100644 index 0000000..108a281 --- /dev/null +++ b/GeoNetAgent/collector/app/services/device_model_catalog_service.py @@ -0,0 +1,321 @@ +import math +import re +import uuid + +from sqlalchemy import case, func, or_, select +from sqlalchemy.orm import Session + +from app.db.models import Asset, DeviceModelCatalog +from app.schemas.device_models import ( + DeviceModelCatalogIn, + DeviceModelCatalogOut, + DeviceModelCatalogPatch, + DeviceModelLookupOut, + DeviceModelSyncResult, + PaginatedDeviceModelCatalog, +) + +_MANUFACTURER_SUFFIXES = re.compile(r"\b(INC\.?|LTD\.?|CO\.?|CORP\.?|GMBH|S\.A\.?)\b", re.I) + + +def normalize_manufacturer(value: str | None) -> str | None: + if not value: + return None + text = re.sub(r"\s+", " ", value.strip().upper()) + text = _MANUFACTURER_SUFFIXES.sub("", text) + text = re.sub(r"[^\w\s&.-]", "", text) + text = re.sub(r"\s+", " ", text).strip() + return text or None + + +def normalize_model(value: str | None) -> str | None: + if not value: + return None + text = re.sub(r"\s+", " ", value.strip().upper()) + return text or None + + +def catalog_key(manufacturer: str | None, model: str | None) -> tuple[str, str] | None: + mfr = normalize_manufacturer(manufacturer) + mdl = normalize_model(model) + if not mfr or not mdl: + return None + return mfr, mdl + + +def _series_pending(series: str | None) -> bool: + return not (series or "").strip() + + +def _to_out(row: DeviceModelCatalog) -> DeviceModelCatalogOut: + pending = _series_pending(row.series) + return DeviceModelCatalogOut( + id=str(row.id), + manufacturer=row.manufacturer, + model=row.model, + series=None if pending else (row.series or "").strip(), + series_pending=pending, + source=row.source, + notes=row.notes, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +def resolve_series( + db: Session, + manufacturer: str | None, + model: str | None, +) -> DeviceModelLookupOut: + key = catalog_key(manufacturer, model) + if not key: + return DeviceModelLookupOut( + manufacturer=manufacturer or "", + model=model or "", + series=None, + matched=False, + ) + + row = db.scalar( + select(DeviceModelCatalog).where( + DeviceModelCatalog.manufacturer_normalized == key[0], + DeviceModelCatalog.model_normalized == key[1], + ) + ) + if not row or _series_pending(row.series): + return DeviceModelLookupOut( + manufacturer=manufacturer or "", + model=model or "", + series=None, + catalog_id=str(row.id) if row else None, + matched=False, + ) + + return DeviceModelLookupOut( + manufacturer=row.manufacturer, + model=row.model, + series=(row.series or "").strip(), + catalog_id=str(row.id), + matched=True, + ) + + +def resolve_series_map( + db: Session, + pairs: list[tuple[str | None, str | None]], +) -> dict[tuple[str, str], str]: + keys: set[tuple[str, str]] = set() + for manufacturer, model in pairs: + key = catalog_key(manufacturer, model) + if key: + keys.add(key) + if not keys: + return {} + + mfrs = {k[0] for k in keys} + mdls = {k[1] for k in keys} + rows = db.scalars( + select(DeviceModelCatalog).where( + DeviceModelCatalog.manufacturer_normalized.in_(mfrs), + DeviceModelCatalog.model_normalized.in_(mdls), + ) + ).all() + + result: dict[tuple[str, str], str] = {} + for row in rows: + key = (row.manufacturer_normalized, row.model_normalized) + if key in keys and not _series_pending(row.series): + result[key] = (row.series or "").strip() + return result + + +def list_catalog( + db: Session, + *, + q: str | None = None, + pending_only: bool = False, + page: int = 1, + limit: int = 50, +) -> PaginatedDeviceModelCatalog: + stmt = select(DeviceModelCatalog) + if pending_only: + stmt = stmt.where( + or_( + DeviceModelCatalog.series.is_(None), + DeviceModelCatalog.series == "", + ) + ) + if q: + pattern = f"%{q.strip()}%" + stmt = stmt.where( + or_( + DeviceModelCatalog.manufacturer.ilike(pattern), + DeviceModelCatalog.model.ilike(pattern), + DeviceModelCatalog.series.ilike(pattern), + DeviceModelCatalog.manufacturer_normalized.ilike(pattern), + DeviceModelCatalog.model_normalized.ilike(pattern), + ) + ) + + total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0 + pages = max(1, math.ceil(total / limit)) if total else 0 + offset = (page - 1) * limit + rows = db.scalars( + stmt.order_by( + case( + (or_(DeviceModelCatalog.series.is_(None), DeviceModelCatalog.series == ""), 0), + else_=1, + ), + DeviceModelCatalog.manufacturer_normalized, + DeviceModelCatalog.model_normalized, + ) + .offset(offset) + .limit(limit) + ).all() + + return PaginatedDeviceModelCatalog( + items=[_to_out(row) for row in rows], + total=total, + page=page, + limit=limit, + pages=pages, + ) + + +def create_catalog_entry(db: Session, data: DeviceModelCatalogIn) -> DeviceModelCatalogOut: + key = catalog_key(data.manufacturer, data.model) + if not key: + raise ValueError("manufacturer and model are required") + + existing = db.scalar( + select(DeviceModelCatalog).where( + DeviceModelCatalog.manufacturer_normalized == key[0], + DeviceModelCatalog.model_normalized == key[1], + ) + ) + if existing: + existing.series = data.series.strip() + existing.source = (data.source or "manual").strip() or "manual" + existing.notes = data.notes + existing.manufacturer = data.manufacturer.strip() + existing.model = data.model.strip() + db.commit() + db.refresh(existing) + return _to_out(existing) + + row = DeviceModelCatalog( + manufacturer=data.manufacturer.strip(), + model=data.model.strip(), + manufacturer_normalized=key[0], + model_normalized=key[1], + series=data.series.strip(), + source=(data.source or "manual").strip() or "manual", + notes=data.notes, + ) + db.add(row) + db.commit() + db.refresh(row) + return _to_out(row) + + +def patch_catalog_entry( + db: Session, + entry_id: uuid.UUID, + data: DeviceModelCatalogPatch, +) -> DeviceModelCatalogOut | None: + row = db.scalar(select(DeviceModelCatalog).where(DeviceModelCatalog.id == entry_id)) + if not row: + return None + + if data.series is not None: + row.series = data.series.strip() + if data.source is not None: + row.source = data.source.strip() or "manual" + if data.notes is not None: + row.notes = data.notes or None + + db.commit() + db.refresh(row) + return _to_out(row) + + +def delete_catalog_entry(db: Session, entry_id: uuid.UUID) -> bool: + row = db.scalar(select(DeviceModelCatalog).where(DeviceModelCatalog.id == entry_id)) + if not row: + return False + db.delete(row) + db.commit() + return True + + +def sync_missing_from_assets(db: Session) -> DeviceModelSyncResult: + """Insert distinct asset manufacturer+model pairs missing from catalog (series kosong).""" + pairs = db.execute( + select(Asset.manufacturer, Asset.model) + .where( + Asset.deleted_at.is_(None), + Asset.manufacturer.isnot(None), + Asset.model.isnot(None), + Asset.manufacturer != "", + Asset.model != "", + ) + .distinct() + ).all() + + inserted = 0 + already_exists = 0 + pending_series = 0 + + for manufacturer, model in pairs: + key = catalog_key(manufacturer, model) + if not key: + continue + + existing = db.scalar( + select(DeviceModelCatalog).where( + DeviceModelCatalog.manufacturer_normalized == key[0], + DeviceModelCatalog.model_normalized == key[1], + ) + ) + if existing: + already_exists += 1 + if _series_pending(existing.series): + pending_series += 1 + continue + + db.add( + DeviceModelCatalog( + manufacturer=manufacturer.strip(), + model=model.strip(), + manufacturer_normalized=key[0], + model_normalized=key[1], + series=None, + source="asset-discovered", + notes="Auto dari assets — isi series", + ) + ) + inserted += 1 + pending_series += 1 + + if inserted: + db.commit() + else: + db.rollback() + + pending_total = db.scalar( + select(func.count()) + .select_from(DeviceModelCatalog) + .where( + or_( + DeviceModelCatalog.series.is_(None), + DeviceModelCatalog.series == "", + ) + ) + ) or 0 + + return DeviceModelSyncResult( + inserted=inserted, + already_exists=already_exists, + pending_series=pending_total, + distinct_asset_models=len(pairs), + ) diff --git a/GeoNetAgent/collector/scripts/diag_assets_reporting.sh b/GeoNetAgent/collector/scripts/diag_assets_reporting.sh new file mode 100644 index 0000000..e89efd2 --- /dev/null +++ b/GeoNetAgent/collector/scripts/diag_assets_reporting.sh @@ -0,0 +1,37 @@ +#!/bin/bash +set -euo pipefail +TOKEN=$(grep -E '^AGENT_BEARER_TOKEN=' /opt/stacks/geonetagent-collector/.env | cut -d= -f2- | tr -d '\r"') +echo "=== stats ===" +curl -sf -H "Authorization: Bearer $TOKEN" http://127.0.0.1:18000/api/v1/health +echo +curl -sf -H "Authorization: Bearer $TOKEN" http://127.0.0.1:18000/api/v1/stats/summary +echo +echo "=== assets summary ===" +curl -sf -H "Authorization: Bearer $TOKEN" 'http://127.0.0.1:18000/api/v1/assets?limit=50' | python3 <<'PY' +import json,sys +from datetime import datetime, timezone, timedelta +d=json.load(sys.stdin) +now=datetime.now(timezone.utc) +cut=now-timedelta(hours=24) +fresh=stale=none=0 +for i in d['items']: + ls=i.get('last_seen_at') + if not ls: + none+=1 + continue + t=datetime.fromisoformat(ls.replace('Z','+00:00')) + if t>=cut: fresh+=1 + else: stale+=1 +print(f"page_items={len(d['items'])} total_in_db={d['total']}") +print(f"fresh_24h={fresh} stale_24h={stale} no_last_seen={none} (on this page)") +print("--- newest ---") +for i in sorted(d['items'], key=lambda x: x.get('last_seen_at') or '', reverse=True)[:10]: + print(i['hostname'], i.get('last_seen_at'), i.get('agent_version')) +print("--- oldest on page ---") +for i in sorted(d['items'], key=lambda x: x.get('last_seen_at') or '')[:5]: + print(i['hostname'], i.get('last_seen_at'), i.get('agent_version')) +PY +echo "=== search DESKTOP-RRJ9G01 ===" +curl -sf -H "Authorization: Bearer $TOKEN" 'http://127.0.0.1:18000/api/v1/assets?q=DESKTOP-RRJ9G01&limit=5' | python3 -m json.tool +echo "=== collector logs (agent) ===" +docker logs geonet-collector --tail 40 2>&1 | grep -iE 'report|401|403|error|DESKTOP' || docker logs geonet-collector --tail 15 diff --git a/GeoNetAgent/collector/scripts/diag_owner.sql b/GeoNetAgent/collector/scripts/diag_owner.sql new file mode 100644 index 0000000..5cd53a6 --- /dev/null +++ b/GeoNetAgent/collector/scripts/diag_owner.sql @@ -0,0 +1,17 @@ +SELECT a.hostname, a.last_seen_at, u.username, aua.is_primary, aua.assigned_by +FROM assets a +LEFT JOIN asset_user_assignments aua ON aua.asset_id = a.id AND aua.ended_at IS NULL +LEFT JOIN users u ON u.id = aua.user_id +WHERE a.deleted_at IS NULL +ORDER BY a.last_seen_at DESC NULLS LAST +LIMIT 20; + +SELECT a.hostname, u.username, count(*) +FROM asset_user_assignments aua +JOIN assets a ON a.id = aua.asset_id +JOIN users u ON u.id = aua.user_id +WHERE aua.is_primary = true AND aua.ended_at IS NULL +GROUP BY a.hostname, u.username +HAVING count(*) > 1; + +SELECT hostname, last_seen_at FROM assets WHERE hostname ILIKE '%RRJ9G01%'; diff --git a/GeoNetAgent/collector/scripts/diag_rrj9g01.sql b/GeoNetAgent/collector/scripts/diag_rrj9g01.sql new file mode 100644 index 0000000..697ce13 --- /dev/null +++ b/GeoNetAgent/collector/scripts/diag_rrj9g01.sql @@ -0,0 +1,9 @@ +SELECT id, hostname, machine_id, smbios_uuid, serial_number, primary_physical_mac +FROM assets +WHERE hostname ILIKE '%RRJ9G01%' AND deleted_at IS NULL; + +SELECT u.username, aua.* +FROM asset_user_assignments aua +JOIN users u ON u.id = aua.user_id +JOIN assets a ON a.id = aua.asset_id +WHERE a.hostname = 'DESKTOP-RRJ9G01'; diff --git a/GeoNetAgent/collector/scripts/test_ingest_minimal.py b/GeoNetAgent/collector/scripts/test_ingest_minimal.py new file mode 100644 index 0000000..bdd3dd1 --- /dev/null +++ b/GeoNetAgent/collector/scripts/test_ingest_minimal.py @@ -0,0 +1,31 @@ +import json +import traceback +import uuid +from datetime import datetime, timezone + +from app.db.session import SessionLocal +from app.schemas.agent_report import AgentReportRequest, SystemInfo, HealthInfo +from app.services.asset_service import ingest_agent_report + +report = AgentReportRequest( + schema_version="1.0", + agent_version="0.1.12-phase0", + collected_at=datetime.now(timezone.utc), + hostname="DESKTOP-RRJ9G01", + machine_id="test-machine-id", + smbios_uuid="00000000-0000-0000-0000-000000000001", + serial_number="TESTSERIAL", + asset_type="endpoint", + logged_in_user="DESKTOP-RRJ9G01\\admin", + system=SystemInfo(manufacturer="LENOVO", model="82AU", os_name="Windows 11"), + health=HealthInfo(), + software=[{"name": "Test App", "version": "1.0"}], +) + +db = SessionLocal() +try: + result = ingest_agent_report(db, report) + print("OK", result.model_dump()) +except Exception: + db.rollback() + traceback.print_exc() diff --git a/GeoNetAgent/collector/scripts/test_ingest_report.py b/GeoNetAgent/collector/scripts/test_ingest_report.py new file mode 100644 index 0000000..c6db49b --- /dev/null +++ b/GeoNetAgent/collector/scripts/test_ingest_report.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +import json +import sys +import traceback + +from app.db.session import SessionLocal +from app.schemas.agent_report import AgentReportRequest +from app.services.asset_service import ingest_agent_report + +path = sys.argv[1] if len(sys.argv) > 1 else "/tmp/geonet-report-test.json" +with open(path, encoding="utf-8") as f: + data = json.load(f) + +report = AgentReportRequest.model_validate(data) +db = SessionLocal() +try: + result = ingest_agent_report(db, report) + print("OK", result.model_dump()) +except Exception: + db.rollback() + traceback.print_exc() + sys.exit(1) +finally: + db.close() diff --git a/GeoNetAgent/collector/scripts/test_ingest_rrj9g01.py b/GeoNetAgent/collector/scripts/test_ingest_rrj9g01.py new file mode 100644 index 0000000..9a71da4 --- /dev/null +++ b/GeoNetAgent/collector/scripts/test_ingest_rrj9g01.py @@ -0,0 +1,32 @@ +import traceback +from datetime import datetime, timezone + +from app.db.session import SessionLocal +from app.schemas.agent_report import AgentReportRequest, SystemInfo, HealthInfo, AssetIdentity +from app.services.asset_service import ingest_agent_report + +report = AgentReportRequest( + schema_version="1.0", + agent_version="0.1.12-phase0", + collected_at=datetime.now(timezone.utc), + hostname="DESKTOP-RRJ9G01", + machine_id="3709baa5-5265-4e4d-9a81-fb8c8927bb3b", + smbios_uuid="03D502E0-045E-0550-8E06-BA0700080009", + asset_type="endpoint", + logged_in_user="DESKTOP-RRJ9G01\\admin", + asset_identity=AssetIdentity(primary_physical_mac="E0:D5:5E:50:8E:BA"), + system=SystemInfo(manufacturer="LENOVO", model="82AU", os_name="Windows 11"), + health=HealthInfo(), + software=[{"name": "Test App", "version": "1.0"}], +) + +db = SessionLocal() +try: + result = ingest_agent_report(db, report) + print("OK", result.model_dump()) +except Exception: + db.rollback() + traceback.print_exc() + raise +finally: + db.close() diff --git a/GeoNetAgent/collector/scripts/verify_catalog_api.py b/GeoNetAgent/collector/scripts/verify_catalog_api.py new file mode 100644 index 0000000..24fee8b --- /dev/null +++ b/GeoNetAgent/collector/scripts/verify_catalog_api.py @@ -0,0 +1,19 @@ +import urllib.parse +import urllib.request + +from app.config import get_settings + +token = get_settings().resolved_api_bearer_token() +headers = {"Authorization": f"Bearer {token}"} + + +def get(url: str) -> str: + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req) as resp: + return resp.read().decode() + + +base = "http://127.0.0.1:8000" +print(get(f"{base}/api/v1/device-models?limit=3")) +q = urllib.parse.urlencode({"manufacturer": "LENOVO", "model": "82AU"}) +print(get(f"{base}/api/v1/device-models/lookup?{q}")) diff --git a/GeoNetAgent/collector/scripts/verify_owner_agent_ui.sh b/GeoNetAgent/collector/scripts/verify_owner_agent_ui.sh new file mode 100644 index 0000000..b44126a --- /dev/null +++ b/GeoNetAgent/collector/scripts/verify_owner_agent_ui.sh @@ -0,0 +1,10 @@ +#!/bin/bash +set -euo pipefail +ENV_FILE="${1:-/opt/stacks/geonetagent-collector/.env}" +TOKEN=$(grep -E '^AGENT_BEARER_TOKEN=' "$ENV_FILE" | cut -d= -f2- | tr -d '\r"') +curl -sf -H "Authorization: Bearer $TOKEN" 'http://127.0.0.1:18000/api/v1/assets?limit=2' | python3 -c " +import json,sys +d=json.load(sys.stdin) +for i in d.get('items',[])[:2]: + print(f\"{i.get('hostname')}: owner={i.get('owner_display_name')} / {i.get('owner_ldap_username')} agent={i.get('agent_version')}\") +" diff --git a/GeoNetAgent/collector/scripts/verify_search_sort.sh b/GeoNetAgent/collector/scripts/verify_search_sort.sh new file mode 100644 index 0000000..8d78d3e --- /dev/null +++ b/GeoNetAgent/collector/scripts/verify_search_sort.sh @@ -0,0 +1,9 @@ +#!/bin/bash +set -euo pipefail +TOKEN=$(grep -E '^AGENT_BEARER_TOKEN=' /opt/stacks/geonetagent-collector/.env | cut -d= -f2- | tr -d '\r"') +echo "=== owner search ===" +curl -sf -H "Authorization: Bearer $TOKEN" 'http://127.0.0.1:18000/api/v1/assets?owner=rbsetiawan&limit=3' +echo +echo "=== series sort ===" +curl -sf -H "Authorization: Bearer $TOKEN" 'http://127.0.0.1:18000/api/v1/assets?sort=series&order=asc&limit=2' +echo diff --git a/GeoNetAgent/collector/scripts/verify_sync_deploy.sh b/GeoNetAgent/collector/scripts/verify_sync_deploy.sh new file mode 100644 index 0000000..e01d9d6 --- /dev/null +++ b/GeoNetAgent/collector/scripts/verify_sync_deploy.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -euo pipefail +ENV_FILE="${1:-/opt/stacks/geonetagent-collector/.env}" +TOKEN=$(grep -E '^AGENT_BEARER_TOKEN=' "$ENV_FILE" | cut -d= -f2- | tr -d '\r"') +echo "token_len=${#TOKEN}" +echo "=== health ===" +curl -s -w "\nHTTP:%{http_code}\n" -H "Authorization: Bearer $TOKEN" http://127.0.0.1:18000/api/v1/health +echo "=== sync-from-assets ===" +curl -s -w "\nHTTP:%{http_code}\n" -X POST -H "Authorization: Bearer $TOKEN" \ + http://127.0.0.1:18000/api/v1/device-models/sync-from-assets +echo "=== pending (limit 3) ===" +curl -s -w "\nHTTP:%{http_code}\n" -H "Authorization: Bearer $TOKEN" \ + 'http://127.0.0.1:18000/api/v1/device-models?pending_only=true&limit=3' diff --git a/GeoNetAgent/collector/static/app.js b/GeoNetAgent/collector/static/app.js index 7197646..6e9336d 100644 --- a/GeoNetAgent/collector/static/app.js +++ b/GeoNetAgent/collector/static/app.js @@ -7,6 +7,8 @@ const state = { limit: 25, total: 0, pages: 1, + sort: "last_seen_at", + order: "desc", selectedId: null, searchTimer: null, options: { device_status: [], asset_for: [], asset_on: [] }, @@ -20,6 +22,16 @@ const el = { apiToken: document.getElementById("api-token"), stats: document.getElementById("stats"), searchQ: document.getElementById("search-q"), + searchIn: document.getElementById("search-in"), + filterForm: document.getElementById("filter-form"), + filterOwner: document.getElementById("filter-owner"), + filterModel: document.getElementById("filter-model"), + filterSeries: document.getElementById("filter-series"), + filterManufacturer: document.getElementById("filter-manufacturer"), + filterDeviceStatus: document.getElementById("filter-device-status"), + filterAssetFor: document.getElementById("filter-asset-for"), + filterAssetOn: document.getElementById("filter-asset-on"), + clearFilters: document.getElementById("clear-filters"), filterType: document.getElementById("filter-type"), filterStale: document.getElementById("filter-stale"), listContent: document.getElementById("list-content"), @@ -33,6 +45,17 @@ const el = { detailTitle: document.getElementById("detail-title"), detailContent: document.getElementById("detail-content"), closeDetail: document.getElementById("close-detail"), + toggleCatalog: document.getElementById("toggle-catalog"), + catalogPanel: document.getElementById("catalog-panel"), + catalogForm: document.getElementById("catalog-form"), + catalogContent: document.getElementById("catalog-content"), + catalogMeta: document.getElementById("catalog-meta"), + catalogSync: document.getElementById("catalog-sync"), + catalogPendingOnly: document.getElementById("catalog-pending-only"), + catManufacturer: document.getElementById("cat-manufacturer"), + catModel: document.getElementById("cat-model"), + catSeries: document.getElementById("cat-series"), + catNotes: document.getElementById("cat-notes"), }; function loadSettings() { @@ -79,14 +102,17 @@ async function api(path) { async function apiMutate(method, path, body) { const url = `${state.baseUrl.replace(/\/$/, "")}${path}`; - const res = await fetch(url, { + const init = { method, headers: { Authorization: `Bearer ${state.token}`, - "Content-Type": "application/json", }, - body: JSON.stringify(body), - }); + }; + if (body !== undefined) { + init.headers["Content-Type"] = "application/json"; + init.body = JSON.stringify(body); + } + const res = await fetch(url, init); const text = await res.text(); let data; try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; } @@ -100,11 +126,41 @@ async function apiMutate(method, path, body) { async function loadOptions() { try { state.options = await api("/api/v1/assets/options"); + populateFilterSelect(el.filterDeviceStatus, state.options.device_status); + populateFilterSelect(el.filterAssetFor, state.options.asset_for); + populateFilterSelect(el.filterAssetOn, state.options.asset_on); } catch { state.options = { device_status: [], asset_for: [], asset_on: [] }; } } +function populateFilterSelect(selectEl, options) { + if (!selectEl) return; + const current = selectEl.value; + selectEl.innerHTML = ''; + for (const opt of options || []) { + const o = document.createElement("option"); + o.value = opt; + o.textContent = opt; + selectEl.appendChild(o); + } + if (current) selectEl.value = current; +} + +function resetFilters() { + if (el.filterOwner) el.filterOwner.value = ""; + if (el.filterModel) el.filterModel.value = ""; + if (el.filterSeries) el.filterSeries.value = ""; + if (el.filterManufacturer) el.filterManufacturer.value = ""; + if (el.filterDeviceStatus) el.filterDeviceStatus.value = ""; + if (el.filterAssetFor) el.filterAssetFor.value = ""; + if (el.filterAssetOn) el.filterAssetOn.value = ""; + if (el.searchQ) el.searchQ.value = ""; + if (el.searchIn) el.searchIn.value = "all"; + state.page = 1; + loadAssets().catch(showError); +} + function fmtDate(value) { if (!value) return "—"; try { @@ -125,6 +181,7 @@ function escapeHtml(str) { function showConnectedUi(show) { el.stats.classList.toggle("hidden", !show); el.searchForm.classList.toggle("hidden", !show); + if (el.filterForm) el.filterForm.classList.toggle("hidden", !show); } function renderStats(data) { @@ -150,18 +207,70 @@ function buildAssetsQuery() { const params = new URLSearchParams(); params.set("page", String(state.page)); params.set("limit", String(state.limit)); - const q = el.searchQ.value.trim(); + params.set("sort", state.sort); + params.set("order", state.order); + const q = el.searchQ?.value.trim(); if (q) params.set("q", q); + const searchIn = el.searchIn?.value; + if (searchIn && searchIn !== "all") params.set("search_in", searchIn); + if (el.filterOwner?.value.trim()) params.set("owner", el.filterOwner.value.trim()); + if (el.filterModel?.value.trim()) params.set("model", el.filterModel.value.trim()); + if (el.filterSeries?.value.trim()) params.set("series", el.filterSeries.value.trim()); + if (el.filterManufacturer?.value.trim()) params.set("manufacturer", el.filterManufacturer.value.trim()); + if (el.filterDeviceStatus?.value) params.set("device_status", el.filterDeviceStatus.value); + if (el.filterAssetFor?.value) params.set("asset_for", el.filterAssetFor.value); + if (el.filterAssetOn?.value) params.set("asset_on", el.filterAssetOn.value); const type = el.filterType.value; if (type) params.set("type", type); if (el.filterStale.checked) params.set("stale", "true"); return `?${params.toString()}`; } +function sortTh(field, label) { + const active = state.sort === field; + const arrow = active ? (state.order === "asc" ? " ▲" : " ▼") : ""; + return `${escapeHtml(label)}${arrow}`; +} + +function bindSortHeaders() { + el.listContent.querySelectorAll("th.sortable").forEach((th) => { + th.addEventListener("click", (e) => { + e.stopPropagation(); + const field = th.dataset.sort; + if (state.sort === field) { + state.order = state.order === "asc" ? "desc" : "asc"; + } else { + state.sort = field; + state.order = field === "last_seen_at" ? "desc" : "asc"; + } + state.page = 1; + loadAssets().catch(showError); + }); + }); +} + function assetLabel(item) { return item.display_name || item.hostname; } +function formatOwner(item) { + const username = item.owner_ldap_username; + const fullname = item.owner_display_name; + if (fullname && username) { + return `${escapeHtml(fullname)}
${escapeHtml(username)}
`; + } + if (fullname) return `${escapeHtml(fullname)}`; + if (username) return `${escapeHtml(username)}`; + return ``; +} + +function formatOwnerPlain(item) { + const username = item.owner_ldap_username; + const fullname = item.owner_display_name; + if (fullname && username) return `${fullname} (${username})`; + return fullname || username || "—"; +} + function formFactorBadge(value) { if (!value) return ""; const label = value === "laptop" ? "Laptop" : value === "desktop" ? "PC" : value; @@ -258,6 +367,10 @@ function bindEditPanel(asset) { const saveBtn = document.getElementById("save-meta-btn"); const editStatus = document.getElementById("edit-status"); + if (ownerInput && asset.owner_display_name) { + ownerInput.dataset.ownerDisplayName = asset.owner_display_name; + } + if (ownerInput) { ownerInput.addEventListener("input", () => { clearTimeout(state.ldapSearchTimer); @@ -273,7 +386,7 @@ function bindEditPanel(asset) { 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)}` : ""} @@ -281,6 +394,7 @@ function bindEditPanel(asset) { suggestionsBox.querySelectorAll(".ldap-item").forEach(item => { item.addEventListener("click", () => { ownerInput.value = item.dataset.username; + ownerInput.dataset.ownerDisplayName = item.dataset.displayName || ""; suggestionsBox.classList.add("hidden"); }); }); @@ -320,7 +434,18 @@ function bindEditPanel(asset) { const prevOwner = asset.owner_ldap_username || ""; if (newOwner !== prevOwner) { if (newOwner) { - await apiMutate("PUT", `/api/v1/assets/${asset.id}/owner`, { ldap_username: newOwner }); + const ownerBody = { ldap_username: newOwner }; + const pickedName = ownerInput?.dataset?.ownerDisplayName?.trim(); + if (pickedName) ownerBody.display_name = pickedName; + await apiMutate("PUT", `/api/v1/assets/${asset.id}/owner`, ownerBody); + } + } else if (newOwner && ownerInput?.dataset?.ownerDisplayName?.trim()) { + const pickedName = ownerInput.dataset.ownerDisplayName.trim(); + if (pickedName !== (asset.owner_display_name || "")) { + await apiMutate("PUT", `/api/v1/assets/${asset.id}/owner`, { + ldap_username: newOwner, + display_name: pickedName, + }); } } @@ -357,10 +482,12 @@ function renderAssets(data) { const aoBadge = item.asset_on ? tagBadge(item.asset_on, ASSET_ON_COLORS) : ""; return ` ${escapeHtml(assetLabel(item))}${item.display_name ? `
${escapeHtml(item.hostname)}
` : ""}${formFactorBadge(item.form_factor)} - ${escapeHtml(item.asset_type)} + ${formatOwner(item)} + ${escapeHtml(item.model || "—")} + ${item.series ? `${escapeHtml(item.series)}` : ``} + ${item.agent_version ? `${escapeHtml(item.agent_version)}` : "—"} ${dsBadge || `${escapeHtml(item.status)}`} ${afBadge} ${aoBadge} - ${escapeHtml(item.serial_number || "—")} ${escapeHtml(fmtDate(item.last_seen_at))} `; }) @@ -371,12 +498,14 @@ function renderAssets(data) { - - - + ${sortTh("hostname", "Hostname")} + ${sortTh("owner", "Owner")} + ${sortTh("model", "Model")} + ${sortTh("series", "Series")} + ${sortTh("agent_version", "Agent")} + ${sortTh("device_status", "Status")} - - + ${sortTh("last_seen_at", "Last seen")} ${rows} @@ -385,6 +514,7 @@ function renderAssets(data) { el.listContent.querySelectorAll("tbody tr").forEach((row) => { row.addEventListener("click", () => openDetail(row.dataset.id)); }); + bindSortHeaders(); el.pager.classList.remove("hidden"); el.pageInfo.textContent = `Halaman ${state.page} / ${state.pages}`; @@ -649,13 +779,13 @@ function renderDetail(asset, software) { 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 ownerHtml = formatOwner(asset); const identity = [ ["Hostname", asset.hostname], ["Display name", asset.display_name], + ["Owner", formatOwnerPlain(asset)], + ["Agent version", snap?.agent_version || asset.metadata?.agent_version], ["Form factor", formFactor], ["Chassis", snap?.system?.chassis_label || asset.metadata?.chassis_label], ["Asset ID", asset.id], @@ -665,15 +795,16 @@ function renderDetail(asset, software) { ["MAC", asset.primary_physical_mac], ["Manufacturer", asset.manufacturer], ["Model", asset.model], + ["Series", asset.series], ["Snapshots", asset.snapshot_count], ["Last seen", fmtDate(asset.last_seen_at)], ]; let html = ``; - if (tagRow || asset.owner_ldap_username) { + if (tagRow || asset.owner_ldap_username || asset.owner_display_name) { html += `
- ${ownerHtml ? `Owner: ${ownerHtml}` : ""} + ${(asset.owner_ldap_username || asset.owner_display_name) ? `Owner: ${ownerHtml}` : ""} ${tagRow ? `  ${tagRow}` : ""} ${asset.notes ? `
${escapeHtml(asset.notes)}
` : ""}
`; @@ -685,6 +816,23 @@ function renderDetail(asset, software) { ) .join("")}`; + if (asset.manufacturer && asset.model && !asset.series) { + html += `
+
Tambah Series ke SSOT
+

Series belum diisi untuk model ${escapeHtml(asset.model)} — lengkapi di catalog.

+
+ + +
+ + +
+ +
+ +
`; + } + html += renderEditPanel(asset); if (snap) { @@ -735,6 +883,121 @@ function renderDetail(asset, software) { el.detailTitle.innerHTML = `${escapeHtml(asset.display_name || asset.hostname)}${titleBadges ? " " + titleBadges : ""}`; el.detailContent.innerHTML = html; bindEditPanel(asset); + bindQuickSeriesAdd(asset); +} + +function bindQuickSeriesAdd(asset) { + const btn = document.getElementById("quick-series-btn"); + const input = document.getElementById("quick-series"); + const status = document.getElementById("quick-series-status"); + if (!btn || !input) return; + + btn.addEventListener("click", async () => { + const series = input.value.trim(); + if (!series) { + status.textContent = "Isi series dulu."; + status.className = "edit-status error"; + return; + } + btn.disabled = true; + status.textContent = "Menyimpan..."; + status.className = "edit-status"; + try { + await apiMutate("POST", "/api/v1/device-models", { + manufacturer: asset.manufacturer, + model: asset.model, + series, + source: "manual", + notes: document.getElementById("cat-notes")?.value || null, + }); + status.textContent = "Tersimpan."; + status.className = "edit-status ok"; + await loadCatalog(); + openDetail(asset.id); + } catch (err) { + status.textContent = err.message; + status.className = "edit-status error"; + } finally { + btn.disabled = false; + } + }); +} + +async function loadCatalog() { + if (!el.catalogContent) return; + el.catalogContent.className = "loading"; + el.catalogContent.textContent = "Memuat catalog..."; + try { + const pendingOnly = el.catalogPendingOnly?.checked ? "&pending_only=true" : ""; + const data = await api(`/api/v1/device-models?limit=200${pendingOnly}`); + const pendingCount = data.items?.filter((row) => row.series_pending).length ?? 0; + el.catalogMeta.textContent = `${data.total} mapping${pendingCount ? ` · ${pendingCount} perlu series` : ""}`; + if (!data.items?.length) { + el.catalogContent.className = "empty"; + el.catalogContent.textContent = pendingOnly + ? "Semua model sudah punya series." + : "Belum ada mapping. Klik Sync dari assets atau tambahkan manual di form atas."; + return; + } + el.catalogContent.className = ""; + el.catalogContent.innerHTML = ` +
HostnameTipeStatusTagSerialLast seen
+ + + + + ${data.items.map((row) => { + const seriesCell = row.series_pending + ? `
+ + +
` + : `${escapeHtml(row.series)}`; + const rowClass = row.series_pending ? ' class="row-pending"' : ""; + return ` + + + + + + + `; + }).join("")} + +
ManufacturerModelSeriesCatatan
${escapeHtml(row.manufacturer)}${escapeHtml(row.model)}${seriesCell}${escapeHtml(row.notes || "—")}
`; + el.catalogContent.querySelectorAll("[data-del-catalog]").forEach((btn) => { + btn.addEventListener("click", async () => { + if (!confirm("Hapus mapping ini?")) return; + await apiMutate("DELETE", `/api/v1/device-models/${btn.dataset.delCatalog}`); + await loadCatalog(); + loadAssets().catch(showError); + }); + }); + el.catalogContent.querySelectorAll(".save-pending-series").forEach((btn) => { + btn.addEventListener("click", async () => { + const id = btn.dataset.id; + const input = el.catalogContent.querySelector(`.pending-series-input[data-id="${id}"]`); + const series = input?.value.trim(); + if (!series) { + alert("Isi series dulu."); + return; + } + btn.disabled = true; + try { + await apiMutate("PATCH", `/api/v1/device-models/${id}`, { series, source: "manual" }); + await Promise.all([loadCatalog(), loadAssets()]); + if (state.selectedId) openDetail(state.selectedId); + } catch (err) { + alert(err.message); + } finally { + btn.disabled = false; + } + }); + }); + } catch (err) { + el.catalogContent.className = "error"; + el.catalogContent.textContent = err.message; + } } async function loadStats() { @@ -798,7 +1061,7 @@ async function connect() { showConnectedUi(true); try { - await Promise.all([loadStats(), loadAssets(), loadOptions()]); + await Promise.all([loadStats(), loadAssets(), loadOptions(), loadCatalog()]); } catch (err) { showConnectedUi(false); showError(err); @@ -834,6 +1097,35 @@ el.filterStale.addEventListener("change", () => { loadAssets().catch(showError); }); +function bindFilterInputs() { + const filterEls = [ + el.filterOwner, + el.filterModel, + el.filterSeries, + el.filterManufacturer, + el.filterDeviceStatus, + el.filterAssetFor, + el.filterAssetOn, + el.searchIn, + ].filter(Boolean); + filterEls.forEach((input) => { + const evt = input.tagName === "SELECT" ? "change" : "input"; + input.addEventListener(evt, () => { + clearTimeout(state.searchTimer); + state.searchTimer = setTimeout(() => { + state.page = 1; + loadAssets().catch(showError); + }, input.tagName === "SELECT" ? 0 : 350); + }); + }); +} + +if (el.clearFilters) { + el.clearFilters.addEventListener("click", resetFilters); +} + +bindFilterInputs(); + el.prevPage.addEventListener("click", () => { if (state.page > 1) { state.page -= 1; @@ -850,6 +1142,72 @@ el.nextPage.addEventListener("click", () => { el.closeDetail.addEventListener("click", closeDetail); +if (el.toggleCatalog && el.catalogPanel) { + el.toggleCatalog.addEventListener("click", () => { + el.catalogPanel.classList.toggle("hidden"); + if (!el.catalogPanel.classList.contains("hidden")) { + loadCatalog().catch((err) => { + el.catalogContent.className = "error"; + el.catalogContent.textContent = err.message; + }); + } + }); +} + +if (el.catalogSync) { + el.catalogSync.addEventListener("click", async () => { + el.catalogSync.disabled = true; + try { + const result = await apiMutate("POST", "/api/v1/device-models/sync-from-assets", {}); + alert( + `Sync selesai.\n` + + `• ${result.inserted} model baru ditambahkan\n` + + `• ${result.already_exists} sudah ada di catalog\n` + + `• ${result.pending_series} masih perlu diisi series\n` + + `• ${result.distinct_asset_models} kombinasi unik di assets` + ); + await loadCatalog(); + } catch (err) { + alert(err.message); + } finally { + el.catalogSync.disabled = false; + } + }); +} + +if (el.catalogPendingOnly) { + el.catalogPendingOnly.addEventListener("change", () => { + loadCatalog().catch(showError); + }); +} + +if (el.catalogForm) { + el.catalogForm.addEventListener("submit", async (e) => { + e.preventDefault(); + const submitBtn = el.catalogForm.querySelector('button[type="submit"]'); + submitBtn.disabled = true; + try { + await apiMutate("POST", "/api/v1/device-models", { + manufacturer: el.catManufacturer.value.trim(), + model: el.catModel.value.trim(), + series: el.catSeries.value.trim(), + source: "manual", + notes: el.catNotes.value.trim() || null, + }); + el.catSeries.value = ""; + el.catNotes.value = ""; + await Promise.all([loadCatalog(), loadAssets()]); + if (state.selectedId) { + openDetail(state.selectedId); + } + } catch (err) { + alert(err.message); + } finally { + submitBtn.disabled = false; + } + }); +} + loadSettings(); if (!el.apiBase.value) el.apiBase.value = defaultBaseUrl(); if (el.apiToken.value) connect(); diff --git a/GeoNetAgent/collector/static/index.html b/GeoNetAgent/collector/static/index.html index c8983a3..420fff6 100644 --- a/GeoNetAgent/collector/static/index.html +++ b/GeoNetAgent/collector/static/index.html @@ -4,7 +4,7 @@ GeoNetAgent — CMDB UI - +
@@ -27,9 +27,20 @@