Browse Source

feat(GeoNetAgent): CMDB model catalog, UI search/sort, and ingest owner fix

Add device_model_catalog with sync-from-assets for pending series, enrich
asset list with owner/agent fields and filters, preserve manual owners on
report ingest, and record session handover (0.1.13-collector-ui).

Co-authored-by: Cursor <cursoragent@cursor.com>
master
Budi Setiawan 1 week ago
parent
commit
6e611feb61
  1. 3
      GeoNetAgent/collector/README.md
  2. 27
      GeoNetAgent/collector/app/api/v1/assets.py
  3. 91
      GeoNetAgent/collector/app/api/v1/device_models.py
  4. 3
      GeoNetAgent/collector/app/api/v1/router.py
  5. 17
      GeoNetAgent/collector/app/db/models.py
  6. 8
      GeoNetAgent/collector/app/schemas/assets.py
  7. 52
      GeoNetAgent/collector/app/schemas/device_models.py
  8. 256
      GeoNetAgent/collector/app/services/asset_read_service.py
  9. 20
      GeoNetAgent/collector/app/services/asset_service.py
  10. 321
      GeoNetAgent/collector/app/services/device_model_catalog_service.py
  11. 37
      GeoNetAgent/collector/scripts/diag_assets_reporting.sh
  12. 17
      GeoNetAgent/collector/scripts/diag_owner.sql
  13. 9
      GeoNetAgent/collector/scripts/diag_rrj9g01.sql
  14. 31
      GeoNetAgent/collector/scripts/test_ingest_minimal.py
  15. 24
      GeoNetAgent/collector/scripts/test_ingest_report.py
  16. 32
      GeoNetAgent/collector/scripts/test_ingest_rrj9g01.py
  17. 19
      GeoNetAgent/collector/scripts/verify_catalog_api.py
  18. 10
      GeoNetAgent/collector/scripts/verify_owner_agent_ui.sh
  19. 9
      GeoNetAgent/collector/scripts/verify_search_sort.sh
  20. 13
      GeoNetAgent/collector/scripts/verify_sync_deploy.sh
  21. 398
      GeoNetAgent/collector/static/app.js
  22. 94
      GeoNetAgent/collector/static/index.html
  23. 121
      GeoNetAgent/collector/static/style.css
  24. 138
      GeoNetAgent/collector/tests/test_device_model_catalog.py
  25. 32
      GeoNetAgent/docs/ai-session-summary.md
  26. 15
      GeoNetAgent/docs/changelog.md
  27. 64
      GeoNetAgent/docs/guide/device-model-catalog.md
  28. 8
      GeoNetAgent/docs/issues.md
  29. 21
      GeoNetAgent/docs/todo.md
  30. 16
      GeoNetAgent/docs/troubleshooting.md
  31. 9
      GeoNetAgent/docs/version.md
  32. 31
      GeoNetAgent/infra/db/migrations/006_device_model_catalog.sql
  33. 3
      GeoNetAgent/infra/db/migrations/007_device_model_catalog_pending_series.sql
  34. 9
      GeoNetAgent/infra/db/seed/device_model_catalog.json
  35. 45
      Workspace-Session.md

3
GeoNetAgent/collector/README.md

@ -0,0 +1,3 @@
# GeoNetAgent Collector
FastAPI service for agent ingress and CMDB read API.

27
GeoNetAgent/collector/app/api/v1/assets.py

@ -36,8 +36,21 @@ def list_assets(
asset_type: str | None = Query(None, alias="type"), asset_type: str | None = Query(None, alias="type"),
status: str | None = None, status: str | None = None,
q: 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, stale: bool | None = None,
disk_warning: 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), page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=200), limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@ -48,8 +61,18 @@ def list_assets(
asset_type=asset_type, asset_type=asset_type,
status=status, status=status,
q=q, 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, stale=stale,
disk_warning=disk_warning, disk_warning=disk_warning,
sort=sort,
order=order,
page=page, page=page,
limit=limit, limit=limit,
) )
@ -155,7 +178,9 @@ def set_asset_owner(
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: str = Depends(verify_api_bearer), _: str = Depends(verify_api_bearer),
) -> AssetOwnerOut: ) -> 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: if result is None:
raise _not_found() raise _not_found()
return result return result

91
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")

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

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

17
GeoNetAgent/collector/app/db/models.py

@ -125,3 +125,20 @@ class AgentToken(Base):
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=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()) 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())

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

@ -16,6 +16,7 @@ class AssetListItem(BaseModel):
asset_on: str | None = None asset_on: str | None = None
manufacturer: str | None = None manufacturer: str | None = None
model: str | None = None model: str | None = None
series: str | None = None
serial_number: str | None = None serial_number: str | None = None
machine_id: str | None = None machine_id: str | None = None
smbios_uuid: str | None = None smbios_uuid: str | None = None
@ -23,6 +24,9 @@ class AssetListItem(BaseModel):
last_seen_at: datetime | None = None last_seen_at: datetime | None = None
latest_snapshot_at: datetime | None = None latest_snapshot_at: datetime | None = None
software_count: int | 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): class PaginatedAssets(BaseModel):
@ -214,9 +218,11 @@ class AssetDetail(BaseModel):
asset_on: str | None = None asset_on: str | None = None
notes: str | None = None notes: str | None = None
owner_ldap_username: str | None = None owner_ldap_username: str | None = None
owner_display_name: str | None = None
display_name: str | None = None display_name: str | None = None
manufacturer: str | None = None manufacturer: str | None = None
model: str | None = None model: str | None = None
series: str | None = None
serial_number: str | None = None serial_number: str | None = None
machine_id: str | None = None machine_id: str | None = None
smbios_uuid: str | None = None smbios_uuid: str | None = None
@ -252,6 +258,7 @@ class AssetMetaIn(BaseModel):
class AssetOwnerIn(BaseModel): class AssetOwnerIn(BaseModel):
ldap_username: str = Field(..., min_length=1, max_length=100) ldap_username: str = Field(..., min_length=1, max_length=100)
display_name: str | None = Field(None, max_length=200)
class AssetOwnerOut(BaseModel): class AssetOwnerOut(BaseModel):
@ -269,6 +276,7 @@ class AssetsByOwnerItem(BaseModel):
status: str status: str
manufacturer: str | None = None manufacturer: str | None = None
model: str | None = None model: str | None = None
series: str | None = None
serial_number: str | None = None serial_number: str | None = None
last_seen_at: datetime | None = None last_seen_at: datetime | None = None
latest_snapshot_at: datetime | None = None latest_snapshot_at: datetime | None = None

52
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

256
GeoNetAgent/collector/app/services/asset_read_service.py

@ -2,10 +2,10 @@ import math
import uuid import uuid
from datetime import datetime, timedelta, timezone 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 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 ( from app.schemas.assets import (
AssetDetail, AssetDetail,
AssetListItem, AssetListItem,
@ -29,6 +29,8 @@ from app.schemas.assets import (
StatsSummary, StatsSummary,
SystemSnapshotOut, 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: 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: def get_stats(db: Session) -> StatsSummary:
stale_cutoff = _utcnow() - timedelta(hours=24) stale_cutoff = _utcnow() - timedelta(hours=24)
total_assets = db.scalar(select(func.count()).select_from(Asset).where(Asset.deleted_at.is_(None))) or 0 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, asset_type: str | None = None,
status: str | None = None, status: str | None = None,
q: 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, stale: bool | None = None,
disk_warning: bool | None = None, disk_warning: bool | None = None,
sort: str = "last_seen_at",
order: str = "desc",
page: int = 1, page: int = 1,
limit: int = 50, limit: int = 50,
) -> PaginatedAssets: ) -> PaginatedAssets:
@ -124,15 +286,21 @@ def list_assets(
if status: if status:
stmt = stmt.where(Asset.status == status) stmt = stmt.where(Asset.status == status)
if q: if q:
pattern = f"%{q.strip()}%" stmt = _apply_asset_search(stmt, q, search_in)
stmt = stmt.where( if owner:
or_( stmt = stmt.where(_owner_exists_ilike(f"%{owner.strip()}%"))
Asset.hostname.ilike(pattern), if model:
Asset.serial_number.ilike(pattern), stmt = stmt.where(Asset.model.ilike(f"%{model.strip()}%"))
Asset.primary_physical_mac.ilike(pattern), if series:
Asset.machine_id.ilike(pattern), 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: if stale:
cutoff = _utcnow() - timedelta(hours=24) cutoff = _utcnow() - timedelta(hours=24)
stmt = stmt.where(or_(Asset.last_seen_at.is_(None), Asset.last_seen_at < cutoff)) 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 total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
offset = (page - 1) * limit 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() latest = _latest_snapshot_subquery()
items: list[AssetListItem] = [] items: list[AssetListItem] = []
@ -155,6 +329,9 @@ def list_assets(
) )
.where(AssetSnapshot.asset_id == asset.id) .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( items.append(
AssetListItem( AssetListItem(
id=str(asset.id), id=str(asset.id),
@ -169,6 +346,7 @@ def list_assets(
asset_on=asset.asset_on, asset_on=asset.asset_on,
manufacturer=asset.manufacturer, manufacturer=asset.manufacturer,
model=asset.model, model=asset.model,
series=series,
serial_number=asset.serial_number, serial_number=asset.serial_number,
machine_id=asset.machine_id, machine_id=asset.machine_id,
smbios_uuid=asset.smbios_uuid, smbios_uuid=asset.smbios_uuid,
@ -176,6 +354,9 @@ def list_assets(
last_seen_at=asset.last_seen_at, last_seen_at=asset.last_seen_at,
latest_snapshot_at=snap.collected_at if snap else None, latest_snapshot_at=snap.collected_at if snap else None,
software_count=snap.software_count 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( row = db.execute(
select(User.username) select(User.username, User.display_name)
.join(AssetUserAssignment, AssetUserAssignment.user_id == User.id) .join(AssetUserAssignment, AssetUserAssignment.user_id == User.id)
.where( .where(
AssetUserAssignment.asset_id == asset_id, 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), AssetUserAssignment.ended_at.is_(None),
) )
.limit(1) .limit(1)
).scalar_one_or_none() ).first()
return row 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: 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()) .order_by(AssetSnapshot.collected_at.desc())
.limit(1) .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( return AssetDetail(
id=str(asset.id), id=str(asset.id),
@ -484,9 +686,11 @@ def get_asset(db: Session, asset_id: uuid.UUID) -> AssetDetail | None:
asset_on=asset.asset_on, asset_on=asset.asset_on,
notes=asset.notes, notes=asset.notes,
owner_ldap_username=owner_username, owner_ldap_username=owner_username,
owner_display_name=owner_display_name,
display_name=asset.display_name, display_name=asset.display_name,
manufacturer=asset.manufacturer, manufacturer=asset.manufacturer,
model=asset.model, model=asset.model,
series=lookup.series if lookup.matched else None,
serial_number=asset.serial_number, serial_number=asset.serial_number,
machine_id=asset.machine_id, machine_id=asset.machine_id,
smbios_uuid=asset.smbios_uuid, 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))) asset = db.scalar(select(Asset).where(Asset.id == asset_id, Asset.deleted_at.is_(None)))
if not asset: if not asset:
return None return None
user = db.scalar(select(User).where(User.username == ldap_username)) user = db.scalar(select(User).where(User.username == ldap_username))
if not user: if not user:
user = User(username=ldap_username) user = User(username=ldap_username, display_name=display_name)
db.add(user) db.add(user)
db.flush() db.flush()
elif display_name and display_name != user.display_name:
user.display_name = display_name
existing = db.scalar( existing = db.scalar(
select(AssetUserAssignment).where( select(AssetUserAssignment).where(
@ -670,6 +881,10 @@ def get_assets_by_ldap_username(db: Session, ldap_username: str) -> list[AssetsB
).all() ).all()
latest = _latest_snapshot_subquery() latest = _latest_snapshot_subquery()
series_map = catalog_service.resolve_series_map(
db,
[(asset.manufacturer, asset.model) for asset, _ in rows],
)
items: list[AssetsByOwnerItem] = [] items: list[AssetsByOwnerItem] = []
for asset, _ in rows: for asset, _ in rows:
snap = db.scalar( 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) .where(AssetSnapshot.asset_id == asset.id)
) )
key = catalog_key(asset.manufacturer, asset.model)
series = series_map.get(key) if key else None
items.append( items.append(
AssetsByOwnerItem( AssetsByOwnerItem(
id=str(asset.id), id=str(asset.id),
@ -690,6 +907,7 @@ def get_assets_by_ldap_username(db: Session, ldap_username: str) -> list[AssetsB
status=asset.status, status=asset.status,
manufacturer=asset.manufacturer, manufacturer=asset.manufacturer,
model=asset.model, model=asset.model,
series=series,
serial_number=asset.serial_number, serial_number=asset.serial_number,
last_seen_at=asset.last_seen_at, last_seen_at=asset.last_seen_at,
latest_snapshot_at=snap.collected_at if snap else None, latest_snapshot_at=snap.collected_at if snap else None,

20
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: def _assign_user(db: Session, asset: Asset, username: str) -> None:
user = _upsert_user(db, username) username = _sanitize_text(username, 100)
existing = db.scalar( if not username:
return
active_primary = db.scalar(
select(AssetUserAssignment).where( select(AssetUserAssignment).where(
AssetUserAssignment.asset_id == asset.id, AssetUserAssignment.asset_id == asset.id,
AssetUserAssignment.user_id == user.id, AssetUserAssignment.is_primary == True, # noqa: E712
AssetUserAssignment.ended_at.is_(None), AssetUserAssignment.ended_at.is_(None),
) )
) )
if existing: if active_primary and active_primary.assigned_by == "manual":
return 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( db.add(
AssetUserAssignment( AssetUserAssignment(
asset_id=asset.id, asset_id=asset.id,

321
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),
)

37
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

17
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%';

9
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';

31
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()

24
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()

32
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()

19
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}"))

10
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')}\")
"

9
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

13
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'

398
GeoNetAgent/collector/static/app.js

@ -7,6 +7,8 @@ const state = {
limit: 25, limit: 25,
total: 0, total: 0,
pages: 1, pages: 1,
sort: "last_seen_at",
order: "desc",
selectedId: null, selectedId: null,
searchTimer: null, searchTimer: null,
options: { device_status: [], asset_for: [], asset_on: [] }, options: { device_status: [], asset_for: [], asset_on: [] },
@ -20,6 +22,16 @@ const el = {
apiToken: document.getElementById("api-token"), apiToken: document.getElementById("api-token"),
stats: document.getElementById("stats"), stats: document.getElementById("stats"),
searchQ: document.getElementById("search-q"), 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"), filterType: document.getElementById("filter-type"),
filterStale: document.getElementById("filter-stale"), filterStale: document.getElementById("filter-stale"),
listContent: document.getElementById("list-content"), listContent: document.getElementById("list-content"),
@ -33,6 +45,17 @@ const el = {
detailTitle: document.getElementById("detail-title"), detailTitle: document.getElementById("detail-title"),
detailContent: document.getElementById("detail-content"), detailContent: document.getElementById("detail-content"),
closeDetail: document.getElementById("close-detail"), 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() { function loadSettings() {
@ -79,14 +102,17 @@ async function api(path) {
async function apiMutate(method, path, body) { async function apiMutate(method, path, body) {
const url = `${state.baseUrl.replace(/\/$/, "")}${path}`; const url = `${state.baseUrl.replace(/\/$/, "")}${path}`;
const res = await fetch(url, { const init = {
method, method,
headers: { headers: {
Authorization: `Bearer ${state.token}`, 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(); const text = await res.text();
let data; let data;
try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; } try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; }
@ -100,11 +126,41 @@ async function apiMutate(method, path, body) {
async function loadOptions() { async function loadOptions() {
try { try {
state.options = await api("/api/v1/assets/options"); 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 { } catch {
state.options = { device_status: [], asset_for: [], asset_on: [] }; state.options = { device_status: [], asset_for: [], asset_on: [] };
} }
} }
function populateFilterSelect(selectEl, options) {
if (!selectEl) return;
const current = selectEl.value;
selectEl.innerHTML = '<option value="">Semua</option>';
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) { function fmtDate(value) {
if (!value) return "—"; if (!value) return "—";
try { try {
@ -125,6 +181,7 @@ function escapeHtml(str) {
function showConnectedUi(show) { function showConnectedUi(show) {
el.stats.classList.toggle("hidden", !show); el.stats.classList.toggle("hidden", !show);
el.searchForm.classList.toggle("hidden", !show); el.searchForm.classList.toggle("hidden", !show);
if (el.filterForm) el.filterForm.classList.toggle("hidden", !show);
} }
function renderStats(data) { function renderStats(data) {
@ -150,18 +207,70 @@ function buildAssetsQuery() {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set("page", String(state.page)); params.set("page", String(state.page));
params.set("limit", String(state.limit)); 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); 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; const type = el.filterType.value;
if (type) params.set("type", type); if (type) params.set("type", type);
if (el.filterStale.checked) params.set("stale", "true"); if (el.filterStale.checked) params.set("stale", "true");
return `?${params.toString()}`; return `?${params.toString()}`;
} }
function sortTh(field, label) {
const active = state.sort === field;
const arrow = active ? (state.order === "asc" ? " ▲" : " ▼") : "";
return `<th class="sortable${active ? " sort-active" : ""}" data-sort="${field}">${escapeHtml(label)}${arrow}</th>`;
}
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) { function assetLabel(item) {
return item.display_name || item.hostname; 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 `<strong>${escapeHtml(fullname)}</strong><div class="muted small">${escapeHtml(username)}</div>`;
}
if (fullname) return `<strong>${escapeHtml(fullname)}</strong>`;
if (username) return `<span class="badge af-geonet">${escapeHtml(username)}</span>`;
return `<span class="muted">—</span>`;
}
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) { function formFactorBadge(value) {
if (!value) return ""; if (!value) return "";
const label = value === "laptop" ? "Laptop" : value === "desktop" ? "PC" : value; const label = value === "laptop" ? "Laptop" : value === "desktop" ? "PC" : value;
@ -258,6 +367,10 @@ function bindEditPanel(asset) {
const saveBtn = document.getElementById("save-meta-btn"); const saveBtn = document.getElementById("save-meta-btn");
const editStatus = document.getElementById("edit-status"); const editStatus = document.getElementById("edit-status");
if (ownerInput && asset.owner_display_name) {
ownerInput.dataset.ownerDisplayName = asset.owner_display_name;
}
if (ownerInput) { if (ownerInput) {
ownerInput.addEventListener("input", () => { ownerInput.addEventListener("input", () => {
clearTimeout(state.ldapSearchTimer); clearTimeout(state.ldapSearchTimer);
@ -273,7 +386,7 @@ function bindEditPanel(asset) {
suggestionsBox.innerHTML = `<div class="ldap-item muted">Tidak ada hasil.</div>`; suggestionsBox.innerHTML = `<div class="ldap-item muted">Tidak ada hasil.</div>`;
} else { } else {
suggestionsBox.innerHTML = results.map(u => ` suggestionsBox.innerHTML = results.map(u => `
<div class="ldap-item" data-username="${escapeHtml(u.username)}"> <div class="ldap-item" data-username="${escapeHtml(u.username)}" data-display-name="${escapeHtml(u.display_name || "")}">
<strong>${escapeHtml(u.username)}</strong> <strong>${escapeHtml(u.username)}</strong>
${u.display_name ? `<span class="muted"> — ${escapeHtml(u.display_name)}</span>` : ""} ${u.display_name ? `<span class="muted"> — ${escapeHtml(u.display_name)}</span>` : ""}
${u.department ? `<span class="muted small"> · ${escapeHtml(u.department)}</span>` : ""} ${u.department ? `<span class="muted small"> · ${escapeHtml(u.department)}</span>` : ""}
@ -281,6 +394,7 @@ function bindEditPanel(asset) {
suggestionsBox.querySelectorAll(".ldap-item").forEach(item => { suggestionsBox.querySelectorAll(".ldap-item").forEach(item => {
item.addEventListener("click", () => { item.addEventListener("click", () => {
ownerInput.value = item.dataset.username; ownerInput.value = item.dataset.username;
ownerInput.dataset.ownerDisplayName = item.dataset.displayName || "";
suggestionsBox.classList.add("hidden"); suggestionsBox.classList.add("hidden");
}); });
}); });
@ -320,7 +434,18 @@ function bindEditPanel(asset) {
const prevOwner = asset.owner_ldap_username || ""; const prevOwner = asset.owner_ldap_username || "";
if (newOwner !== prevOwner) { if (newOwner !== prevOwner) {
if (newOwner) { 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) : ""; const aoBadge = item.asset_on ? tagBadge(item.asset_on, ASSET_ON_COLORS) : "";
return `<tr data-id="${escapeHtml(item.id)}" class="${selected}"> return `<tr data-id="${escapeHtml(item.id)}" class="${selected}">
<td><strong>${escapeHtml(assetLabel(item))}</strong>${item.display_name ? `<div class="muted small">${escapeHtml(item.hostname)}</div>` : ""}${formFactorBadge(item.form_factor)}</td> <td><strong>${escapeHtml(assetLabel(item))}</strong>${item.display_name ? `<div class="muted small">${escapeHtml(item.hostname)}</div>` : ""}${formFactorBadge(item.form_factor)}</td>
<td><span class="badge endpoint">${escapeHtml(item.asset_type)}</span></td> <td class="owner-cell">${formatOwner(item)}</td>
<td class="muted">${escapeHtml(item.model || "—")}</td>
<td>${item.series ? `<span class="series-name">${escapeHtml(item.series)}</span>` : `<span class="muted">—</span>`}</td>
<td class="muted">${item.agent_version ? `<code class="agent-ver">${escapeHtml(item.agent_version)}</code>` : "—"}</td>
<td>${dsBadge || `<span class="badge active">${escapeHtml(item.status)}</span>`}</td> <td>${dsBadge || `<span class="badge active">${escapeHtml(item.status)}</span>`}</td>
<td>${afBadge} ${aoBadge}</td> <td>${afBadge} ${aoBadge}</td>
<td class="muted">${escapeHtml(item.serial_number || "—")}</td>
<td class="muted">${escapeHtml(fmtDate(item.last_seen_at))}</td> <td class="muted">${escapeHtml(fmtDate(item.last_seen_at))}</td>
</tr>`; </tr>`;
}) })
@ -371,12 +498,14 @@ function renderAssets(data) {
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Hostname</th> ${sortTh("hostname", "Hostname")}
<th>Tipe</th> ${sortTh("owner", "Owner")}
<th>Status</th> ${sortTh("model", "Model")}
${sortTh("series", "Series")}
${sortTh("agent_version", "Agent")}
${sortTh("device_status", "Status")}
<th>Tag</th> <th>Tag</th>
<th>Serial</th> ${sortTh("last_seen_at", "Last seen")}
<th>Last seen</th>
</tr> </tr>
</thead> </thead>
<tbody>${rows}</tbody> <tbody>${rows}</tbody>
@ -385,6 +514,7 @@ function renderAssets(data) {
el.listContent.querySelectorAll("tbody tr").forEach((row) => { el.listContent.querySelectorAll("tbody tr").forEach((row) => {
row.addEventListener("click", () => openDetail(row.dataset.id)); row.addEventListener("click", () => openDetail(row.dataset.id));
}); });
bindSortHeaders();
el.pager.classList.remove("hidden"); el.pager.classList.remove("hidden");
el.pageInfo.textContent = `Halaman ${state.page} / ${state.pages}`; 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) : "", asset.asset_on ? tagBadge(asset.asset_on, ASSET_ON_COLORS) : "",
].filter(Boolean).join(" "); ].filter(Boolean).join(" ");
const ownerHtml = asset.owner_ldap_username const ownerHtml = formatOwner(asset);
? `<span class="badge af-geonet">${escapeHtml(asset.owner_ldap_username)}</span>`
: `<span class="muted">—</span>`;
const identity = [ const identity = [
["Hostname", asset.hostname], ["Hostname", asset.hostname],
["Display name", asset.display_name], ["Display name", asset.display_name],
["Owner", formatOwnerPlain(asset)],
["Agent version", snap?.agent_version || asset.metadata?.agent_version],
["Form factor", formFactor], ["Form factor", formFactor],
["Chassis", snap?.system?.chassis_label || asset.metadata?.chassis_label], ["Chassis", snap?.system?.chassis_label || asset.metadata?.chassis_label],
["Asset ID", asset.id], ["Asset ID", asset.id],
@ -665,15 +795,16 @@ function renderDetail(asset, software) {
["MAC", asset.primary_physical_mac], ["MAC", asset.primary_physical_mac],
["Manufacturer", asset.manufacturer], ["Manufacturer", asset.manufacturer],
["Model", asset.model], ["Model", asset.model],
["Series", asset.series],
["Snapshots", asset.snapshot_count], ["Snapshots", asset.snapshot_count],
["Last seen", fmtDate(asset.last_seen_at)], ["Last seen", fmtDate(asset.last_seen_at)],
]; ];
let html = ``; let html = ``;
if (tagRow || asset.owner_ldap_username) { if (tagRow || asset.owner_ldap_username || asset.owner_display_name) {
html += `<div class="asset-tags-row"> html += `<div class="asset-tags-row">
${ownerHtml ? `<span class="muted small">Owner:</span> ${ownerHtml}` : ""} ${(asset.owner_ldap_username || asset.owner_display_name) ? `<span class="muted small">Owner:</span> ${ownerHtml}` : ""}
${tagRow ? `&nbsp;&nbsp;${tagRow}` : ""} ${tagRow ? `&nbsp;&nbsp;${tagRow}` : ""}
${asset.notes ? `<div class="muted small notes-preview">${escapeHtml(asset.notes)}</div>` : ""} ${asset.notes ? `<div class="muted small notes-preview">${escapeHtml(asset.notes)}</div>` : ""}
</div>`; </div>`;
@ -685,6 +816,23 @@ function renderDetail(asset, software) {
) )
.join("")}</dl>`; .join("")}</dl>`;
if (asset.manufacturer && asset.model && !asset.series) {
html += `<div class="catalog-quick-add" id="catalog-quick-add">
<div class="section-title">Tambah Series ke SSOT</div>
<p class="muted small">Series belum diisi untuk model <strong>${escapeHtml(asset.model)}</strong> lengkapi di catalog.</p>
<div class="catalog-form inline">
<input type="hidden" id="quick-mfr" value="${escapeHtml(asset.manufacturer)}" />
<input type="hidden" id="quick-mdl" value="${escapeHtml(asset.model)}" />
<div class="field" style="flex:1">
<label for="quick-series">Series</label>
<input id="quick-series" placeholder="Lenovo Legion 5-15IMH05" />
</div>
<button type="button" id="quick-series-btn">Simpan</button>
</div>
<span id="quick-series-status" class="edit-status"></span>
</div>`;
}
html += renderEditPanel(asset); html += renderEditPanel(asset);
if (snap) { if (snap) {
@ -735,6 +883,121 @@ function renderDetail(asset, software) {
el.detailTitle.innerHTML = `${escapeHtml(asset.display_name || asset.hostname)}${titleBadges ? " " + titleBadges : ""}`; el.detailTitle.innerHTML = `${escapeHtml(asset.display_name || asset.hostname)}${titleBadges ? " " + titleBadges : ""}`;
el.detailContent.innerHTML = html; el.detailContent.innerHTML = html;
bindEditPanel(asset); 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 = `
<table>
<thead>
<tr><th>Manufacturer</th><th>Model</th><th>Series</th><th>Catatan</th><th></th></tr>
</thead>
<tbody>
${data.items.map((row) => {
const seriesCell = row.series_pending
? `<div class="catalog-inline-series">
<input type="text" class="pending-series-input" data-id="${escapeHtml(row.id)}" placeholder="Isi series..." />
<button type="button" class="secondary small-btn save-pending-series" data-id="${escapeHtml(row.id)}">Simpan</button>
</div>`
: `<strong>${escapeHtml(row.series)}</strong>`;
const rowClass = row.series_pending ? ' class="row-pending"' : "";
return `
<tr${rowClass}>
<td>${escapeHtml(row.manufacturer)}</td>
<td><code>${escapeHtml(row.model)}</code></td>
<td>${seriesCell}</td>
<td class="muted">${escapeHtml(row.notes || "—")}</td>
<td><button type="button" class="secondary small-btn" data-del-catalog="${escapeHtml(row.id)}">Hapus</button></td>
</tr>`;
}).join("")}
</tbody>
</table>`;
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() { async function loadStats() {
@ -798,7 +1061,7 @@ async function connect() {
showConnectedUi(true); showConnectedUi(true);
try { try {
await Promise.all([loadStats(), loadAssets(), loadOptions()]); await Promise.all([loadStats(), loadAssets(), loadOptions(), loadCatalog()]);
} catch (err) { } catch (err) {
showConnectedUi(false); showConnectedUi(false);
showError(err); showError(err);
@ -834,6 +1097,35 @@ el.filterStale.addEventListener("change", () => {
loadAssets().catch(showError); 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", () => { el.prevPage.addEventListener("click", () => {
if (state.page > 1) { if (state.page > 1) {
state.page -= 1; state.page -= 1;
@ -850,6 +1142,72 @@ el.nextPage.addEventListener("click", () => {
el.closeDetail.addEventListener("click", closeDetail); 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(); loadSettings();
if (!el.apiBase.value) el.apiBase.value = defaultBaseUrl(); if (!el.apiBase.value) el.apiBase.value = defaultBaseUrl();
if (el.apiToken.value) connect(); if (el.apiToken.value) connect();

94
GeoNetAgent/collector/static/index.html

@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>GeoNetAgent — CMDB UI</title> <title>GeoNetAgent — CMDB UI</title>
<link rel="stylesheet" href="/ui/style.css?v=20260625" /> <link rel="stylesheet" href="/ui/style.css?v=20260711" />
</head> </head>
<body> <body>
<header> <header>
@ -27,9 +27,20 @@
<div id="stats" class="stats hidden"></div> <div id="stats" class="stats hidden"></div>
<form class="toolbar hidden" id="search-form"> <form class="toolbar hidden" id="search-form">
<div class="field" style="flex:1; min-width:240px"> <div class="field" style="flex:1; min-width:200px">
<label for="search-q">Cari</label> <label for="search-q">Cari</label>
<input id="search-q" type="search" placeholder="hostname, serial, MAC, machine_id..." /> <input id="search-q" type="search" placeholder="Ketik kata kunci..." />
</div>
<div class="field">
<label for="search-in">Berdasarkan</label>
<select id="search-in">
<option value="all">Semua field</option>
<option value="owner">Owner</option>
<option value="model">Model</option>
<option value="series">Series</option>
<option value="manufacturer">Manufacturer</option>
<option value="hostname">Hostname / serial</option>
</select>
</div> </div>
<div class="field"> <div class="field">
<label for="filter-type">Tipe</label> <label for="filter-type">Tipe</label>
@ -40,13 +51,86 @@
</div> </div>
<div class="field"> <div class="field">
<label for="filter-stale">&nbsp;</label> <label for="filter-stale">&nbsp;</label>
<label style="text-transform:none;letter-spacing:0;display:flex;align-items:center;gap:.4rem;min-height:38px"> <label class="checkbox-label">
<input id="filter-stale" type="checkbox" /> Stale (&gt;24j) <input id="filter-stale" type="checkbox" /> Stale (&gt;24j)
</label> </label>
</div> </div>
<button type="submit" class="secondary">Refresh</button> <button type="submit" class="secondary">Refresh</button>
<button type="button" class="secondary" id="toggle-catalog">Model catalog</button>
</form>
<form class="toolbar toolbar-filters hidden" id="filter-form">
<div class="field">
<label for="filter-owner">Owner</label>
<input id="filter-owner" type="search" placeholder="username / nama" />
</div>
<div class="field">
<label for="filter-model">Model</label>
<input id="filter-model" type="search" placeholder="82AU, Inspiron..." />
</div>
<div class="field">
<label for="filter-series">Series</label>
<input id="filter-series" type="search" placeholder="Legion, ThinkPad..." />
</div>
<div class="field">
<label for="filter-manufacturer">Manufacturer</label>
<input id="filter-manufacturer" type="search" placeholder="LENOVO, Dell..." />
</div>
<div class="field">
<label for="filter-device-status">Device status</label>
<select id="filter-device-status">
<option value="">Semua</option>
</select>
</div>
<div class="field">
<label for="filter-asset-for">Asset for</label>
<select id="filter-asset-for">
<option value="">Semua</option>
</select>
</div>
<div class="field">
<label for="filter-asset-on">Asset on</label>
<select id="filter-asset-on">
<option value="">Semua</option>
</select>
</div>
<button type="button" class="secondary" id="clear-filters">Reset filter</button>
</form> </form>
<section class="panel catalog-panel hidden" id="catalog-panel">
<div class="panel-header catalog-header">
<span>SSOT Model → Series</span>
<div class="catalog-actions">
<button type="button" class="secondary small-btn" id="catalog-sync">Sync dari assets</button>
<label class="catalog-pending-filter">
<input type="checkbox" id="catalog-pending-only" />
Hanya belum diisi
</label>
<span class="muted" id="catalog-meta"></span>
</div>
</div>
<form class="catalog-form" id="catalog-form">
<div class="field">
<label for="cat-manufacturer">Manufacturer</label>
<input id="cat-manufacturer" required placeholder="LENOVO" />
</div>
<div class="field">
<label for="cat-model">Model (WMI)</label>
<input id="cat-model" required placeholder="82AU" />
</div>
<div class="field" style="flex:1; min-width:220px">
<label for="cat-series">Series</label>
<input id="cat-series" required placeholder="Lenovo Legion 5-15IMH05" />
</div>
<div class="field" style="flex:1; min-width:180px">
<label for="cat-notes">Catatan</label>
<input id="cat-notes" placeholder="PSREF, sumber..." />
</div>
<button type="submit">Simpan mapping</button>
</form>
<div id="catalog-content" class="loading">Hubungkan API dulu.</div>
</section>
<main class="layout" id="layout"> <main class="layout" id="layout">
<section class="panel" id="list-panel"> <section class="panel" id="list-panel">
<div class="panel-header"> <div class="panel-header">
@ -70,6 +154,6 @@
</section> </section>
</main> </main>
<script src="/ui/app.js?v=20260625"></script> <script src="/ui/app.js?v=20260711"></script>
</body> </body>
</html> </html>

121
GeoNetAgent/collector/static/style.css

@ -476,3 +476,124 @@ tbody tr.selected {
} }
.ldap-item:last-child { border-bottom: none; } .ldap-item:last-child { border-bottom: none; }
.ldap-item:hover { background: rgba(59,130,246,.12); } .ldap-item:hover { background: rgba(59,130,246,.12); }
.catalog-panel {
margin: 0 1.5rem 1rem;
}
.catalog-panel .panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
}
.catalog-header .catalog-actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.catalog-pending-filter {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.85rem;
text-transform: none;
letter-spacing: 0;
}
.catalog-inline-series {
display: flex;
gap: 0.35rem;
align-items: center;
}
.catalog-inline-series input {
min-width: 180px;
}
tr.row-pending td {
background: rgba(234, 179, 8, 0.08);
}
.catalog-form {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: end;
padding: 1rem;
border-bottom: 1px solid var(--border);
}
.catalog-form.inline {
padding: 0;
border-bottom: none;
}
.catalog-quick-add {
margin: 1rem 0;
padding: 0.75rem 1rem;
border: 1px dashed var(--border);
border-radius: 8px;
background: rgba(59, 130, 246, 0.05);
}
.small-btn {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
.edit-status.ok {
color: var(--ok);
}
.owner-cell {
min-width: 120px;
max-width: 180px;
line-height: 1.35;
}
code.agent-ver {
font-size: 0.78rem;
color: #93c5fd;
background: rgba(59, 130, 246, 0.12);
padding: 0.1rem 0.35rem;
border-radius: 4px;
}
.toolbar-filters {
margin-top: -0.5rem;
padding-top: 0;
border-top: none;
}
.checkbox-label {
text-transform: none;
letter-spacing: 0;
display: flex;
align-items: center;
gap: 0.4rem;
min-height: 38px;
}
.series-name {
font-size: 0.85rem;
color: #c4d4e8;
}
#list-content table th.sortable {
cursor: pointer;
user-select: none;
white-space: nowrap;
}
#list-content table th.sortable:hover {
color: var(--accent);
}
#list-content table th.sort-active {
color: #93c5fd;
}

138
GeoNetAgent/collector/tests/test_device_model_catalog.py

@ -0,0 +1,138 @@
from app.services.device_model_catalog_service import (
catalog_key,
normalize_manufacturer,
normalize_model,
)
def test_normalize_manufacturer_strips_suffix():
assert normalize_manufacturer("Dell Inc.") == "DELL"
assert normalize_manufacturer("LENOVO") == "LENOVO"
def test_normalize_model_uppercase():
assert normalize_model("82au") == "82AU"
assert normalize_model(" Inspiron 7447 ") == "INSPIRON 7447"
def test_catalog_key_requires_both():
assert catalog_key("LENOVO", "82AU") == ("LENOVO", "82AU")
assert catalog_key(None, "82AU") is None
assert catalog_key("LENOVO", "") is None
def test_create_catalog_upsert(monkeypatch):
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.db.models import DeviceModelCatalog
from app.db.session import Base
from app.schemas.device_models import DeviceModelCatalogIn
from app.services import device_model_catalog_service as svc
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine, tables=[DeviceModelCatalog.__table__])
Session = sessionmaker(bind=engine)
db = Session()
created = svc.create_catalog_entry(
db,
DeviceModelCatalogIn(
manufacturer="LENOVO",
model="82AU",
series="Lenovo Legion 5-15IMH05",
notes="test",
),
)
assert created.series == "Lenovo Legion 5-15IMH05"
updated = svc.create_catalog_entry(
db,
DeviceModelCatalogIn(
manufacturer="Lenovo",
model="82au",
series="Lenovo Legion 5-15IMH05H",
),
)
assert updated.id == created.id
assert updated.series == "Lenovo Legion 5-15IMH05H"
lookup = svc.resolve_series(db, "LENOVO", "82AU")
assert lookup.matched is True
assert lookup.series == "Lenovo Legion 5-15IMH05H"
series_map = svc.resolve_series_map(db, [("LENOVO", "82AU"), ("Dell Inc.", "XPS")])
assert series_map[("LENOVO", "82AU")] == "Lenovo Legion 5-15IMH05H"
assert ("DELL", "XPS") not in series_map
db.close()
def test_sync_missing_from_assets_inserts_pending():
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.db.models import Asset, DeviceModelCatalog
from app.db.session import Base
from app.schemas.device_models import DeviceModelCatalogPatch
from app.services import device_model_catalog_service as svc
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine, tables=[DeviceModelCatalog.__table__, Asset.__table__])
Session = sessionmaker(bind=engine)
db = Session()
db.add(
Asset(
asset_type="endpoint",
hostname="pc1",
manufacturer="Dell Inc.",
model="0X8DX",
)
)
db.add(
Asset(
asset_type="endpoint",
hostname="pc2",
manufacturer="Dell Inc.",
model="0X8DX",
)
)
db.commit()
result = svc.sync_missing_from_assets(db)
assert result.inserted == 1
assert result.pending_series == 1
assert result.distinct_asset_models == 1
row = db.scalar(
select(DeviceModelCatalog).where(DeviceModelCatalog.model_normalized == "0X8DX")
)
assert row is not None
assert row.series is None
assert row.source == "asset-discovered"
lookup = svc.resolve_series(db, "Dell Inc.", "0X8DX")
assert lookup.matched is False
assert lookup.catalog_id == str(row.id)
svc.patch_catalog_entry(db, row.id, DeviceModelCatalogPatch(series="OptiPlex 7090"))
lookup2 = svc.resolve_series(db, "DELL", "0X8DX")
assert lookup2.matched is True
assert lookup2.series == "OptiPlex 7090"
result2 = svc.sync_missing_from_assets(db)
assert result2.inserted == 0
assert result2.already_exists == 1
db.close()

32
GeoNetAgent/docs/ai-session-summary.md

@ -1,25 +1,39 @@
# AI Session Summary # AI Session Summary
```yaml ```yaml
session: 2026-07-06-docs-bootstrap session: 2026-07-08-cmdb-catalog-ui
project: GeoNetAgent/ project: GeoNetAgent/
version: 0.1.12-phase0 version: 0.1.13-collector-ui
status: ready status: ready
next: Lanjutkan fase aktif di .cursor/progress.md next: Rollout agent ke endpoint stale; backfill owner display_name via LDAP re-select
agent: Cursor agent: Cursor
``` ```
## TL;DR ## TL;DR
- Bootstrap docs AI-Agent-Standards: version.md, system-requirements.md, guide/, ai-session-summary - Device model catalog SSOT (`device_model_catalog`) + sync dari assets (series pending) — live UI
- Agent live `v0.1.12-phase0`; collector di `10.100.1.24` - CMDB UI: Owner (nama+username), Agent version, search/filter/sort (owner/model/series)
- Bug ingest 500: agent vs owner manual → unique primary — **fixed** (manual owner tidak ditimpa)
- Deploy: migrasi `006`/`007` di `.25`; collector rebuild di `.24` (`geonet-collector`)
## Lanjut dari sini ## Lanjut dari sini
1. Baca `.cursor/progress.md` untuk fase aktif 1. UI: [collector.gisportal.id/ui/](https://collector.gisportal.id/ui/) — isi Series pending; filter stale + rollout install agent
2. Bump versi agent → append `docs/version.md` + publish 2. Backfill `users.display_name`: di Edit Asset, pilih ulang owner dari LDAP autocomplete → Simpan
3. Opsional: hapus asset uji `DESKTOP-RRJ9G01-00000000` (TESTSERIAL dari ingest minimal)
## Jangan ## Jangan
- Jangan hardcode credential di skrip PowerShell - Jangan commit `agent/config.json` / `.env` (token)
- Jangan commit `.env` - Jangan biarkan agent overwrite owner `assigned_by=manual`
- Jangan hardcode URL collector di kode — pakai config/`AGENT_*` env
## Detail (deploy)
| Host | Aksi |
|------|------|
| `10.100.1.25` | `006_device_model_catalog.sql`, `007_..._pending_series.sql` applied |
| `10.100.1.24` | `/opt/stacks/geonetagent-collector/` rebuild; health OK |
| Guide | `docs/guide/device-model-catalog.md` |
**Root cause stale massal:** banyak PC jarang lapor + bug owner (500) + GPO belum massal — bukan UI saja.

15
GeoNetAgent/docs/changelog.md

@ -8,6 +8,21 @@ Ringkasan per versi: [version.md](./version.md).
--- ---
## [0.1.13-collector-ui] - 2026-07-08
### Added
- `device_model_catalog` SSOT + API `/api/v1/device-models*` + UI Model catalog
- `POST /device-models/sync-from-assets` (pending series)
- Asset list: Owner display name, agent_version; search/filter/sort (owner, model, series, …)
### Fixed
- Agent report 500 saat `logged_in_user` bentrok unique primary owner manual
### Deployed
- Migrasi DB 006/007 di `10.100.1.25`; collector di `10.100.1.24`
---
## [0.1.12-phase0] - 2026-06-29 ## [0.1.12-phase0] - 2026-06-29
### Changed ### Changed

64
GeoNetAgent/docs/guide/device-model-catalog.md

@ -0,0 +1,64 @@
# Device Model Catalog (SSOT)
Manual mapping **Manufacturer + Model (WMI)****Series** (nama produk marketing).
## Database
| Tabel | Kunci unik |
|-------|------------|
| `device_model_catalog` | `manufacturer_normalized` + `model_normalized` |
Migration: `infra/db/migrations/006_device_model_catalog.sql`
Pending series (nullable): `infra/db/migrations/007_device_model_catalog_pending_series.sql`
Seed contoh (repo): `infra/db/seed/device_model_catalog.json`
Normalisasi lookup:
- Manufacturer → uppercase, hapus suffix `Inc.`, `Ltd.`, dll.
- Model → uppercase, trim spasi
Contoh: `LENOVO` + `82AU``Lenovo Legion 5-15IMH05`
## API (Bearer token)
| Method | Path | Fungsi |
|--------|------|--------|
| GET | `/api/v1/device-models` | Daftar mapping (`?q=` search, `?pending_only=true`) |
| GET | `/api/v1/device-models/lookup?manufacturer=&model=` | Resolve satu pasangan |
| POST | `/api/v1/device-models/sync-from-assets` | Auto-insert model dari tabel `assets` yang belum ada di catalog |
| POST | `/api/v1/device-models` | Tambah / upsert mapping |
| PATCH | `/api/v1/device-models/{id}` | Update series/notes |
| DELETE | `/api/v1/device-models/{id}` | Hapus mapping |
POST body:
```json
{
"manufacturer": "LENOVO",
"model": "82AU",
"series": "Lenovo Legion 5-15IMH05",
"source": "manual",
"notes": "PSREF"
}
```
Asset API menambahkan field `series` (read-only, dari catalog) pada `GET /assets` dan `GET /assets/{id}`.
## UI
[https://collector.gisportal.id/ui/](https://collector.gisportal.id/ui/)
1. Hubungkan dengan Bearer token
2. Klik **Model catalog****Sync dari assets** (isi otomatis manufacturer + model dari DB)
3. Isi kolom **Series** pada baris kuning (pending), atau gunakan form manual
4. Di detail asset tanpa series → **Tambah Series ke SSOT**
## Deploy
```bash
# Di PostgreSQL collector
psql -U geonet_collector -d geonetagent_dev -f infra/db/migrations/006_device_model_catalog.sql
psql -U geonet_collector -d geonetagent_dev -f infra/db/migrations/007_device_model_catalog_pending_series.sql
# Rebuild + restart container geonet-collector di 10.100.1.24
```

8
GeoNetAgent/docs/issues.md

@ -6,6 +6,14 @@
## ✅ Fixed ## ✅ Fixed
### [FIXED] Agent report 500 — unique primary owner vs `logged_in_user`
- **Dampak:** PC dengan owner manual (UI) gagal ingest saat user login beda → `last_seen` tidak update (stale massal ikut terbantu)
- **Penyebab:** `_assign_user` insert primary baru tanpa end assignment lama → violating `idx_asset_user_primary_active`
- **Fix:** Skip assign agent jika primary aktif `assigned_by=manual`; end+replace hanya untuk assignment agent
- **File:** `collector/app/services/asset_service.py`
- **Tanggal Fix:** 2026-07-08
### [FIXED] Nginx memotong file `.ps1` di `/install/``$env:` hilang ### [FIXED] Nginx memotong file `.ps1` di `/install/``$env:` hilang
- **Dampak:** File PowerShell yang diunduh dari `agent.gisportal.id/install/` terpotong. Kode setelah `$env:` hilang → agent gagal install. - **Dampak:** File PowerShell yang diunduh dari `agent.gisportal.id/install/` terpotong. Kode setelah `$env:` hilang → agent gagal install.

21
GeoNetAgent/docs/todo.md

@ -1,6 +1,15 @@
# TODO List # TODO List
> **Terakhir Diperbarui:** 2026-06-29 > **Terakhir Diperbarui:** 2026-07-08
---
## Selesai (Sesi Ini)
- Device model catalog (migrasi 006/007, API, UI, sync-from-assets) — deployed `.24`/`.25`
- UI: kolom Owner fullname+username, Agent version; search/filter/sort owner·model·series
- Fix ingest 500: `_assign_user` skip jika primary owner manual aktif
- Guide: `docs/guide/device-model-catalog.md`
--- ---
@ -22,6 +31,16 @@
## 🟡 Medium Priority ## 🟡 Medium Priority
### Rollout agent ke endpoint stale
- **Deskripsi:** ~33/34 asset stale >24j — install/verify task `GeoNetAgent-Report*` + GPO
- **Estimasi:** multi-hari
- **Dependency:** fix ingest owner (done)
### Backfill owner display_name
- **Deskripsi:** Banyak owner hanya username; re-select LDAP di UI agar `users.display_name` terisi
- **Estimasi:** 1–2 jam ops
- **Dependency:** LDAP search API
### Buat ADR files di docs/adr/ ### Buat ADR files di docs/adr/
- **Deskripsi:** ADR-001, ADR-002, ADR-003 belum ada sebagai file — hanya disebut di progress.md dan context-index. Perlu dibuat sebagai file `.md` di `docs/adr/` - **Deskripsi:** ADR-001, ADR-002, ADR-003 belum ada sebagai file — hanya disebut di progress.md dan context-index. Perlu dibuat sebagai file `.md` di `docs/adr/`
- **File:** `docs/adr/ADR-001-*.md`, `docs/adr/ADR-002-*.md`, `docs/adr/ADR-003-*.md` - **File:** `docs/adr/ADR-001-*.md`, `docs/adr/ADR-002-*.md`, `docs/adr/ADR-003-*.md`

16
GeoNetAgent/docs/troubleshooting.md

@ -4,6 +4,22 @@
--- ---
## Agent gagal kirim laporan — HTTP 500 Internal Server Error
**Gejala:** `UserAgent.ps1``Failed to ingest agent report`; asset jadi stale.
**Penyebab umum (2026-07-08):** Unique index primary owner — owner manual di UI + `logged_in_user` agent berbeda.
**Fix server (deployed):** `asset_service._assign_user` tidak menimpa owner `assigned_by=manual`. Update kode + restart `geonet-collector`, lalu:
```powershell
& "$env:ProgramData\GeoNetAgent\UserAgent.ps1"
```
**Verifikasi:** response `status: accepted`; UI last_seen terbarui; owner manual tetap.
---
## Agent gagal kirim laporan — "Cannot connect" ## Agent gagal kirim laporan — "Cannot connect"
**Kemungkinan:** **Kemungkinan:**

9
GeoNetAgent/docs/version.md

@ -4,6 +4,15 @@
--- ---
## [0.1.13-collector-ui] - 2026-07-08
- Collector CMDB UI: model catalog, sync-from-assets, owner+agent columns, search/filter/sort
- Migrasi `006_device_model_catalog`, `007_device_model_catalog_pending_series` (PostgreSQL `.25`)
- Fix ingest owner conflict (manual SSOT preserved)
- Agent binary tetap `0.1.12-phase0` (ProgramData); UI cache `?v=20260711`
---
## [0.1.12-phase0] - 2026-06-29 ## [0.1.12-phase0] - 2026-06-29
- Agent version bump (lihat `agent/VERSION`) - Agent version bump (lihat `agent/VERSION`)

31
GeoNetAgent/infra/db/migrations/006_device_model_catalog.sql

@ -0,0 +1,31 @@
-- Migration 006: SSOT lookup manufacturer + model → series (manual catalog)
-- Run on geonetagent_dev / geonetagent
CREATE TABLE IF NOT EXISTS device_model_catalog (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
manufacturer varchar(100) NOT NULL,
model varchar(100) NOT NULL,
manufacturer_normalized varchar(100) NOT NULL,
model_normalized varchar(100) NOT NULL,
series varchar(255) NOT NULL,
source varchar(50) NOT NULL DEFAULT 'manual',
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_device_model_catalog_key UNIQUE (manufacturer_normalized, model_normalized)
);
CREATE INDEX IF NOT EXISTS idx_device_model_catalog_mfr_model
ON device_model_catalog (manufacturer_normalized, model_normalized);
CREATE TRIGGER trg_device_model_catalog_updated_at
BEFORE UPDATE ON device_model_catalog
FOR EACH ROW EXECUTE PROCEDURE set_updated_at();
-- Contoh seed (opsional — hapus jika tidak perlu)
INSERT INTO device_model_catalog (
manufacturer, model, manufacturer_normalized, model_normalized, series, source, notes
) VALUES (
'LENOVO', '82AU', 'LENOVO', '82AU',
'Lenovo Legion 5-15IMH05', 'manual', 'PSREF / contoh SSOT'
) ON CONFLICT (manufacturer_normalized, model_normalized) DO NOTHING;

3
GeoNetAgent/infra/db/migrations/007_device_model_catalog_pending_series.sql

@ -0,0 +1,3 @@
-- Migration 007: allow pending catalog rows (series belum diisi)
ALTER TABLE device_model_catalog
ALTER COLUMN series DROP NOT NULL;

9
GeoNetAgent/infra/db/seed/device_model_catalog.json

@ -0,0 +1,9 @@
[
{
"manufacturer": "LENOVO",
"model": "82AU",
"series": "Lenovo Legion 5-15IMH05",
"source": "manual",
"notes": "PSREF — contoh entri SSOT"
}
]

45
Workspace-Session.md

@ -1,18 +1,26 @@
# Workspace Session # Workspace Session
> Last updated: 2026-07-06 — overwrite per handover > Last updated: 2026-07-08 — overwrite per handover
--- ---
## Active focus ## Active focus
### GeoNetAgent (`GeoNetAgent/`)
**CMDB catalog + UI ✅:** model→series SSOT, sync-from-assets, owner/agent columns, search/sort; ingest owner-manual fix. Next: rollout agent ke stale endpoints; backfill LDAP display_name.
### nginx (`nginx/`)
**SSL geonet SSOT ✅:** cert `cd4e6`+YR1 di `10.100.1.24`; `iguf.geonet.co.id` valid s/d **2026-09-04**. Next: seragamkan `iguf.co.id.conf`; renewal ~Agustus 2026.
### Ollama / AI Onprem (`Ollama/`) ### Ollama / AI Onprem (`Ollama/`)
**Fase 1+2+3 ✅ selesai:** Ollama GPU aktif → pgvector 1.784 chunks → `POST /api/v1/ai/chat` live. Next: **UI chat di Super Apps PWA** + user memory. **Fase 1+2+3 + UI Chat ✅:** Ollama GPU → pgvector → geonet-console → PWA. Next: user memory + permission guard.
### Mailcow (`Mailcow/`) ### Mailcow (`Mailcow/`)
Dual-domain: **geonet.co.id** hybrid + **gisportal.id** Mailcow murni. SPF gisportal **done** (`ip4:117.102.73.102 -all`). Next: **DKIM gisportal.id** di Master Web. Dual-domain geonet hybrid + gisportal Mailcow. SPF gisportal done. Next: **DKIM gisportal.id**.
--- ---
@ -20,10 +28,13 @@ Dual-domain: **geonet.co.id** hybrid + **gisportal.id** Mailcow murni. SPF gispo
| Project | Version | Session summary | Status | | Project | Version | Session summary | Status |
|---------|---------|-----------------|--------| |---------|---------|-----------------|--------|
| **Ollama** | 0.4.0 | [Ollama/docs/ai-session-summary.md](Ollama/docs/ai-session-summary.md) | Fase 1+2+3 selesai | | **GeoNetAgent** | 0.1.13-collector-ui | [GeoNetAgent/docs/ai-session-summary.md](GeoNetAgent/docs/ai-session-summary.md) | Catalog+UI+ingest fix deployed |
| **PC-Multimedia** | 0.3.0 | [PC-Multimedia/docs/ai-session-summary.md](PC-Multimedia/docs/ai-session-summary.md) | Stabil — GPU node aktif | | **nginx** | 1.1.0 | [nginx/docs/ai-session-summary.md](nginx/docs/ai-session-summary.md) | SSL geonet — renewal Sep 2026 |
| **geonet-console** | ai-fase3 | [server-connection/geonet-console/docs/ai-session-summary.md](server-connection/geonet-console/docs/ai-session-summary.md) | `/api/v1/ai/chat` live | | **Ollama** | 0.4.0 | [Ollama/docs/ai-session-summary.md](Ollama/docs/ai-session-summary.md) | AI pipeline live |
| **Mailcow** | 1.2.5 | [Mailcow/docs/ai-session-summary.md](Mailcow/docs/ai-session-summary.md) | Active — DKIM gisportal pending | | **PC-Multimedia** | 0.3.0 | [PC-Multimedia/docs/ai-session-summary.md](PC-Multimedia/docs/ai-session-summary.md) | GPU node |
| **mysupperapps_FE_PWA** | 1.5.0 | [mysupperapps_FE_PWA/docs/ai-session-summary.md](mysupperapps_FE_PWA/docs/ai-session-summary.md) | AI Chat live |
| **geonet-console** | ai-fase3.1 | [server-connection/geonet-console/docs/ai-session-summary.md](server-connection/geonet-console/docs/ai-session-summary.md) | Error sanitasi |
| **Mailcow** | 1.2.5 | [Mailcow/docs/ai-session-summary.md](Mailcow/docs/ai-session-summary.md) | DKIM gisportal pending |
--- ---
@ -31,19 +42,19 @@ Dual-domain: **geonet.co.id** hybrid + **gisportal.id** Mailcow murni. SPF gispo
| Area | Impact | | Area | Impact |
|------|--------| |------|--------|
| AI pipeline | PC-Multimedia `.14` → pgvector `.24:5433` → geonet-console → Super Apps PWA | | `collector.gisportal.id` / `agent.gisportal.id` | CMDB UI + agent report di nginx `.24` → collector Docker |
| Zammad | Planned IMAP/SMTP → Mailcow `10.100.1.28` | | `iguf.geonet.co.id` | TLS renewed — tidak terkait sesi GeoNetAgent |
| nginx `.24` | TLS `sync.geonet.co.id` — unchanged | | AI pipeline | PC-Multimedia `.14` → pgvector → console → PWA |
| AD `10.100.1.40` | Mail-Users 26 mailbox geonet; gisportal auth mailcow |
--- ---
## Next agent ## Next agent
**AI pipeline:** **GeoNetAgent:**
1. [Ollama/docs/ai-session-summary.md](Ollama/docs/ai-session-summary.md) — UI chat PWA + user memory 1. [GeoNetAgent/AGENTS.md](GeoNetAgent/AGENTS.md) + [ai-session-summary](GeoNetAgent/docs/ai-session-summary.md)
2. [server-connection/geonet-console/docs/ai-session-summary.md](server-connection/geonet-console/docs/ai-session-summary.md) 2. Stale rollout / `docs/guide/device-model-catalog.md`
**nginx (SSL):**
3. [nginx/AGENTS.md](nginx/AGENTS.md) + [renew-geonet-ssl](nginx/docs/guide/renew-geonet-ssl.md)
**Mailcow:** **AI / Mailcow:** lihat Active focus di atas.
3. [Mailcow/AGENTS.md](Mailcow/AGENTS.md) + [ai-session-summary](Mailcow/docs/ai-session-summary.md)
4. [Mailcow/docs/todo.md](Mailcow/docs/todo.md) — DKIM gisportal.id

Loading…
Cancel
Save