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) {
- | Hostname |
- Tipe |
- Status |
+ ${sortTh("hostname", "Hostname")}
+ ${sortTh("owner", "Owner")}
+ ${sortTh("model", "Model")}
+ ${sortTh("series", "Series")}
+ ${sortTh("agent_version", "Agent")}
+ ${sortTh("device_status", "Status")}
Tag |
- Serial |
- Last seen |
+ ${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 += ``;
@@ -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 = `
+ `;
+ 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
-
+