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 @@ @@ -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( @@ -36,8 +36,21 @@ def list_assets(
asset_type: str | None = Query(None, alias="type"),
status: str | None = None,
q: str | None = None,
search_in: str = Query("all", pattern="^(all|owner|model|series|manufacturer|hostname)$"),
owner: str | None = Query(None, max_length=100),
model: str | None = Query(None, max_length=100),
series: str | None = Query(None, max_length=255),
manufacturer: str | None = Query(None, max_length=100),
device_status: str | None = Query(None, max_length=30),
asset_for: str | None = Query(None, max_length=30),
asset_on: str | None = Query(None, max_length=30),
stale: bool | None = None,
disk_warning: bool | None = None,
sort: str = Query(
"last_seen_at",
pattern="^(last_seen_at|hostname|owner|model|series|manufacturer|agent_version|device_status)$",
),
order: str = Query("desc", pattern="^(asc|desc)$"),
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
@ -48,8 +61,18 @@ def list_assets( @@ -48,8 +61,18 @@ def list_assets(
asset_type=asset_type,
status=status,
q=q,
search_in=search_in,
owner=owner,
model=model,
series=series,
manufacturer=manufacturer,
device_status=device_status,
asset_for=asset_for,
asset_on=asset_on,
stale=stale,
disk_warning=disk_warning,
sort=sort,
order=order,
page=page,
limit=limit,
)
@ -155,7 +178,9 @@ def set_asset_owner( @@ -155,7 +178,9 @@ def set_asset_owner(
db: Session = Depends(get_db),
_: str = Depends(verify_api_bearer),
) -> AssetOwnerOut:
result = asset_read_service.set_asset_owner(db, asset_id, body.ldap_username)
result = asset_read_service.set_asset_owner(
db, asset_id, body.ldap_username, body.display_name
)
if result is None:
raise _not_found()
return result

91
GeoNetAgent/collector/app/api/v1/device_models.py

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

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

@ -125,3 +125,20 @@ class AgentToken(Base): @@ -125,3 +125,20 @@ class AgentToken(Base):
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class DeviceModelCatalog(Base):
"""SSOT: manufacturer + model (WMI) → series / marketing name."""
__tablename__ = "device_model_catalog"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
manufacturer: Mapped[str] = mapped_column(String(100), nullable=False)
model: Mapped[str] = mapped_column(String(100), nullable=False)
manufacturer_normalized: Mapped[str] = mapped_column(String(100), nullable=False)
model_normalized: Mapped[str] = mapped_column(String(100), nullable=False)
series: Mapped[str | None] = mapped_column(String(255))
source: Mapped[str] = mapped_column(String(50), nullable=False, default="manual")
notes: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

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

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

52
GeoNetAgent/collector/app/schemas/device_models.py

@ -0,0 +1,52 @@ @@ -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 @@ -2,10 +2,10 @@ import math
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import Select, exists, func, or_, select
from sqlalchemy import Select, and_, exists, func, or_, select
from sqlalchemy.orm import Session
from app.db.models import Asset, AssetDisk, AssetNetworkAdapter, AssetSnapshot, AssetUserAssignment, SoftwareInstallation, User
from app.db.models import Asset, AssetDisk, AssetNetworkAdapter, AssetSnapshot, AssetUserAssignment, DeviceModelCatalog, SoftwareInstallation, User
from app.schemas.assets import (
AssetDetail,
AssetListItem,
@ -29,6 +29,8 @@ from app.schemas.assets import ( @@ -29,6 +29,8 @@ from app.schemas.assets import (
StatsSummary,
SystemSnapshotOut,
)
from app.services import device_model_catalog_service as catalog_service
from app.services.device_model_catalog_service import catalog_key
def _utcnow() -> datetime:
@ -75,6 +77,156 @@ def _disk_warning_exists(): @@ -75,6 +77,156 @@ def _disk_warning_exists():
)
def _owner_exists_ilike(pattern: str):
return exists(
select(AssetUserAssignment.id)
.join(User, User.id == AssetUserAssignment.user_id)
.where(
AssetUserAssignment.asset_id == Asset.id,
AssetUserAssignment.is_primary == True, # noqa: E712
AssetUserAssignment.ended_at.is_(None),
or_(User.username.ilike(pattern), User.display_name.ilike(pattern)),
)
)
def _series_exists_ilike(pattern: str):
return exists(
select(DeviceModelCatalog.id).where(
DeviceModelCatalog.manufacturer_normalized == func.upper(func.trim(Asset.manufacturer)),
DeviceModelCatalog.model_normalized == func.upper(func.trim(Asset.model)),
DeviceModelCatalog.series.isnot(None),
DeviceModelCatalog.series != "",
DeviceModelCatalog.series.ilike(pattern),
)
)
def _owner_subquery():
return (
select(
AssetUserAssignment.asset_id.label("owner_asset_id"),
User.username.label("owner_username"),
User.display_name.label("owner_display_name"),
)
.join(User, User.id == AssetUserAssignment.user_id)
.where(
AssetUserAssignment.is_primary == True, # noqa: E712
AssetUserAssignment.ended_at.is_(None),
)
.subquery("asset_owner")
)
def _catalog_series_subquery():
return (
select(
DeviceModelCatalog.manufacturer_normalized.label("cat_mfr"),
DeviceModelCatalog.model_normalized.label("cat_model"),
DeviceModelCatalog.series.label("cat_series"),
)
.where(
DeviceModelCatalog.series.isnot(None),
DeviceModelCatalog.series != "",
)
.subquery("asset_catalog")
)
def _latest_agent_subquery():
latest = _latest_snapshot_subquery()
return (
select(
AssetSnapshot.asset_id.label("snap_asset_id"),
AssetSnapshot.agent_version.label("agent_version"),
)
.join(
latest,
(AssetSnapshot.asset_id == latest.c.asset_id)
& (AssetSnapshot.collected_at == latest.c.max_collected_at),
)
.subquery("latest_agent")
)
def _apply_asset_search(stmt: Select, q: str, search_in: str) -> Select:
pattern = f"%{q.strip()}%"
if search_in == "owner":
return stmt.where(_owner_exists_ilike(pattern))
if search_in == "model":
return stmt.where(Asset.model.ilike(pattern))
if search_in == "series":
return stmt.where(_series_exists_ilike(pattern))
if search_in == "manufacturer":
return stmt.where(Asset.manufacturer.ilike(pattern))
if search_in == "hostname":
return stmt.where(
or_(
Asset.hostname.ilike(pattern),
Asset.display_name.ilike(pattern),
Asset.serial_number.ilike(pattern),
Asset.primary_physical_mac.ilike(pattern),
Asset.machine_id.ilike(pattern),
)
)
return stmt.where(
or_(
Asset.hostname.ilike(pattern),
Asset.display_name.ilike(pattern),
Asset.serial_number.ilike(pattern),
Asset.primary_physical_mac.ilike(pattern),
Asset.machine_id.ilike(pattern),
Asset.model.ilike(pattern),
Asset.manufacturer.ilike(pattern),
_owner_exists_ilike(pattern),
_series_exists_ilike(pattern),
)
)
def _apply_asset_sort(stmt: Select, sort: str, order: str) -> Select:
descending = order.lower() != "asc"
def _order(col):
return col.desc().nullslast() if descending else col.asc().nullsfirst()
if sort == "owner":
owner_sq = _owner_subquery()
stmt = stmt.outerjoin(owner_sq, owner_sq.c.owner_asset_id == Asset.id)
return stmt.order_by(_order(owner_sq.c.owner_username), Asset.hostname.asc())
if sort == "series":
cat_sq = _catalog_series_subquery()
stmt = stmt.outerjoin(
cat_sq,
and_(
cat_sq.c.cat_mfr == func.upper(func.trim(Asset.manufacturer)),
cat_sq.c.cat_model == func.upper(func.trim(Asset.model)),
),
)
return stmt.order_by(_order(cat_sq.c.cat_series), Asset.hostname.asc())
if sort == "agent_version":
snap_sq = _latest_agent_subquery()
stmt = stmt.outerjoin(snap_sq, snap_sq.c.snap_asset_id == Asset.id)
return stmt.order_by(_order(snap_sq.c.agent_version), Asset.hostname.asc())
sort_map = {
"hostname": Asset.hostname,
"model": Asset.model,
"manufacturer": Asset.manufacturer,
"device_status": Asset.device_status,
"last_seen_at": Asset.last_seen_at,
}
col = sort_map.get(sort, Asset.last_seen_at)
if sort == "last_seen_at":
return stmt.order_by(
col.desc().nullslast() if descending else col.asc().nullsfirst(),
Asset.hostname.asc(),
)
return stmt.order_by(_order(col), Asset.hostname.asc())
def get_stats(db: Session) -> StatsSummary:
stale_cutoff = _utcnow() - timedelta(hours=24)
total_assets = db.scalar(select(func.count()).select_from(Asset).where(Asset.deleted_at.is_(None))) or 0
@ -113,8 +265,18 @@ def list_assets( @@ -113,8 +265,18 @@ def list_assets(
asset_type: str | None = None,
status: str | None = None,
q: str | None = None,
search_in: str = "all",
owner: str | None = None,
model: str | None = None,
series: str | None = None,
manufacturer: str | None = None,
device_status: str | None = None,
asset_for: str | None = None,
asset_on: str | None = None,
stale: bool | None = None,
disk_warning: bool | None = None,
sort: str = "last_seen_at",
order: str = "desc",
page: int = 1,
limit: int = 50,
) -> PaginatedAssets:
@ -124,15 +286,21 @@ def list_assets( @@ -124,15 +286,21 @@ def list_assets(
if status:
stmt = stmt.where(Asset.status == status)
if q:
pattern = f"%{q.strip()}%"
stmt = stmt.where(
or_(
Asset.hostname.ilike(pattern),
Asset.serial_number.ilike(pattern),
Asset.primary_physical_mac.ilike(pattern),
Asset.machine_id.ilike(pattern),
)
)
stmt = _apply_asset_search(stmt, q, search_in)
if owner:
stmt = stmt.where(_owner_exists_ilike(f"%{owner.strip()}%"))
if model:
stmt = stmt.where(Asset.model.ilike(f"%{model.strip()}%"))
if series:
stmt = stmt.where(_series_exists_ilike(f"%{series.strip()}%"))
if manufacturer:
stmt = stmt.where(Asset.manufacturer.ilike(f"%{manufacturer.strip()}%"))
if device_status:
stmt = stmt.where(Asset.device_status == device_status)
if asset_for:
stmt = stmt.where(Asset.asset_for == asset_for)
if asset_on:
stmt = stmt.where(Asset.asset_on == asset_on)
if stale:
cutoff = _utcnow() - timedelta(hours=24)
stmt = stmt.where(or_(Asset.last_seen_at.is_(None), Asset.last_seen_at < cutoff))
@ -141,7 +309,13 @@ def list_assets( @@ -141,7 +309,13 @@ def list_assets(
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
offset = (page - 1) * limit
rows = db.scalars(stmt.order_by(Asset.last_seen_at.desc().nullslast(), Asset.hostname).offset(offset).limit(limit)).all()
rows = db.scalars(_apply_asset_sort(stmt, sort, order).offset(offset).limit(limit)).all()
series_map = catalog_service.resolve_series_map(
db,
[(a.manufacturer, a.model) for a in rows],
)
owners_map = _get_owners_map(db, [a.id for a in rows])
latest = _latest_snapshot_subquery()
items: list[AssetListItem] = []
@ -155,6 +329,9 @@ def list_assets( @@ -155,6 +329,9 @@ def list_assets(
)
.where(AssetSnapshot.asset_id == asset.id)
)
key = catalog_key(asset.manufacturer, asset.model)
series = series_map.get(key) if key else None
owner_username, owner_display_name = owners_map.get(asset.id, (None, None))
items.append(
AssetListItem(
id=str(asset.id),
@ -169,6 +346,7 @@ def list_assets( @@ -169,6 +346,7 @@ def list_assets(
asset_on=asset.asset_on,
manufacturer=asset.manufacturer,
model=asset.model,
series=series,
serial_number=asset.serial_number,
machine_id=asset.machine_id,
smbios_uuid=asset.smbios_uuid,
@ -176,6 +354,9 @@ def list_assets( @@ -176,6 +354,9 @@ def list_assets(
last_seen_at=asset.last_seen_at,
latest_snapshot_at=snap.collected_at if snap else None,
software_count=snap.software_count if snap else None,
owner_ldap_username=owner_username,
owner_display_name=owner_display_name,
agent_version=snap.agent_version if snap else None,
)
)
@ -446,9 +627,9 @@ def _snapshot_to_summary(db: Session, snapshot: AssetSnapshot) -> SnapshotSummar @@ -446,9 +627,9 @@ def _snapshot_to_summary(db: Session, snapshot: AssetSnapshot) -> SnapshotSummar
)
def _get_asset_owner(db: Session, asset_id: uuid.UUID) -> str | None:
def _get_asset_owner_info(db: Session, asset_id: uuid.UUID) -> tuple[str | None, str | None]:
row = db.execute(
select(User.username)
select(User.username, User.display_name)
.join(AssetUserAssignment, AssetUserAssignment.user_id == User.id)
.where(
AssetUserAssignment.asset_id == asset_id,
@ -456,8 +637,28 @@ def _get_asset_owner(db: Session, asset_id: uuid.UUID) -> str | None: @@ -456,8 +637,28 @@ def _get_asset_owner(db: Session, asset_id: uuid.UUID) -> str | None:
AssetUserAssignment.ended_at.is_(None),
)
.limit(1)
).scalar_one_or_none()
return row
).first()
if not row:
return None, None
return row.username, row.display_name
def _get_owners_map(
db: Session,
asset_ids: list[uuid.UUID],
) -> dict[uuid.UUID, tuple[str | None, str | None]]:
if not asset_ids:
return {}
rows = db.execute(
select(AssetUserAssignment.asset_id, User.username, User.display_name)
.join(User, User.id == AssetUserAssignment.user_id)
.where(
AssetUserAssignment.asset_id.in_(asset_ids),
AssetUserAssignment.is_primary == True, # noqa: E712
AssetUserAssignment.ended_at.is_(None),
)
).all()
return {row.asset_id: (row.username, row.display_name) for row in rows}
def get_asset(db: Session, asset_id: uuid.UUID) -> AssetDetail | None:
@ -472,7 +673,8 @@ def get_asset(db: Session, asset_id: uuid.UUID) -> AssetDetail | None: @@ -472,7 +673,8 @@ def get_asset(db: Session, asset_id: uuid.UUID) -> AssetDetail | None:
.order_by(AssetSnapshot.collected_at.desc())
.limit(1)
)
owner_username = _get_asset_owner(db, asset_id)
owner_username, owner_display_name = _get_asset_owner_info(db, asset_id)
lookup = catalog_service.resolve_series(db, asset.manufacturer, asset.model)
return AssetDetail(
id=str(asset.id),
@ -484,9 +686,11 @@ def get_asset(db: Session, asset_id: uuid.UUID) -> AssetDetail | None: @@ -484,9 +686,11 @@ def get_asset(db: Session, asset_id: uuid.UUID) -> AssetDetail | None:
asset_on=asset.asset_on,
notes=asset.notes,
owner_ldap_username=owner_username,
owner_display_name=owner_display_name,
display_name=asset.display_name,
manufacturer=asset.manufacturer,
model=asset.model,
series=lookup.series if lookup.matched else None,
serial_number=asset.serial_number,
machine_id=asset.machine_id,
smbios_uuid=asset.smbios_uuid,
@ -612,16 +816,23 @@ def list_software( @@ -612,16 +816,23 @@ def list_software(
)
def set_asset_owner(db: Session, asset_id: uuid.UUID, ldap_username: str) -> AssetOwnerOut | None:
def set_asset_owner(
db: Session,
asset_id: uuid.UUID,
ldap_username: str,
display_name: str | None = None,
) -> AssetOwnerOut | None:
asset = db.scalar(select(Asset).where(Asset.id == asset_id, Asset.deleted_at.is_(None)))
if not asset:
return None
user = db.scalar(select(User).where(User.username == ldap_username))
if not user:
user = User(username=ldap_username)
user = User(username=ldap_username, display_name=display_name)
db.add(user)
db.flush()
elif display_name and display_name != user.display_name:
user.display_name = display_name
existing = db.scalar(
select(AssetUserAssignment).where(
@ -670,6 +881,10 @@ def get_assets_by_ldap_username(db: Session, ldap_username: str) -> list[AssetsB @@ -670,6 +881,10 @@ def get_assets_by_ldap_username(db: Session, ldap_username: str) -> list[AssetsB
).all()
latest = _latest_snapshot_subquery()
series_map = catalog_service.resolve_series_map(
db,
[(asset.manufacturer, asset.model) for asset, _ in rows],
)
items: list[AssetsByOwnerItem] = []
for asset, _ in rows:
snap = db.scalar(
@ -681,6 +896,8 @@ def get_assets_by_ldap_username(db: Session, ldap_username: str) -> list[AssetsB @@ -681,6 +896,8 @@ def get_assets_by_ldap_username(db: Session, ldap_username: str) -> list[AssetsB
)
.where(AssetSnapshot.asset_id == asset.id)
)
key = catalog_key(asset.manufacturer, asset.model)
series = series_map.get(key) if key else None
items.append(
AssetsByOwnerItem(
id=str(asset.id),
@ -690,6 +907,7 @@ def get_assets_by_ldap_username(db: Session, ldap_username: str) -> list[AssetsB @@ -690,6 +907,7 @@ def get_assets_by_ldap_username(db: Session, ldap_username: str) -> list[AssetsB
status=asset.status,
manufacturer=asset.manufacturer,
model=asset.model,
series=series,
serial_number=asset.serial_number,
last_seen_at=asset.last_seen_at,
latest_snapshot_at=snap.collected_at if snap else None,

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

@ -91,16 +91,28 @@ def _upsert_user(db: Session, username: str) -> User: @@ -91,16 +91,28 @@ def _upsert_user(db: Session, username: str) -> User:
def _assign_user(db: Session, asset: Asset, username: str) -> None:
user = _upsert_user(db, username)
existing = db.scalar(
username = _sanitize_text(username, 100)
if not username:
return
active_primary = db.scalar(
select(AssetUserAssignment).where(
AssetUserAssignment.asset_id == asset.id,
AssetUserAssignment.user_id == user.id,
AssetUserAssignment.is_primary == True, # noqa: E712
AssetUserAssignment.ended_at.is_(None),
)
)
if existing:
if active_primary and active_primary.assigned_by == "manual":
return
user = _upsert_user(db, username)
if active_primary and active_primary.user_id == user.id:
return
if active_primary:
active_primary.ended_at = datetime.now(timezone.utc)
db.flush()
db.add(
AssetUserAssignment(
asset_id=asset.id,

321
GeoNetAgent/collector/app/services/device_model_catalog_service.py

@ -0,0 +1,321 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 = { @@ -7,6 +7,8 @@ const state = {
limit: 25,
total: 0,
pages: 1,
sort: "last_seen_at",
order: "desc",
selectedId: null,
searchTimer: null,
options: { device_status: [], asset_for: [], asset_on: [] },
@ -20,6 +22,16 @@ const el = { @@ -20,6 +22,16 @@ const el = {
apiToken: document.getElementById("api-token"),
stats: document.getElementById("stats"),
searchQ: document.getElementById("search-q"),
searchIn: document.getElementById("search-in"),
filterForm: document.getElementById("filter-form"),
filterOwner: document.getElementById("filter-owner"),
filterModel: document.getElementById("filter-model"),
filterSeries: document.getElementById("filter-series"),
filterManufacturer: document.getElementById("filter-manufacturer"),
filterDeviceStatus: document.getElementById("filter-device-status"),
filterAssetFor: document.getElementById("filter-asset-for"),
filterAssetOn: document.getElementById("filter-asset-on"),
clearFilters: document.getElementById("clear-filters"),
filterType: document.getElementById("filter-type"),
filterStale: document.getElementById("filter-stale"),
listContent: document.getElementById("list-content"),
@ -33,6 +45,17 @@ const el = { @@ -33,6 +45,17 @@ const el = {
detailTitle: document.getElementById("detail-title"),
detailContent: document.getElementById("detail-content"),
closeDetail: document.getElementById("close-detail"),
toggleCatalog: document.getElementById("toggle-catalog"),
catalogPanel: document.getElementById("catalog-panel"),
catalogForm: document.getElementById("catalog-form"),
catalogContent: document.getElementById("catalog-content"),
catalogMeta: document.getElementById("catalog-meta"),
catalogSync: document.getElementById("catalog-sync"),
catalogPendingOnly: document.getElementById("catalog-pending-only"),
catManufacturer: document.getElementById("cat-manufacturer"),
catModel: document.getElementById("cat-model"),
catSeries: document.getElementById("cat-series"),
catNotes: document.getElementById("cat-notes"),
};
function loadSettings() {
@ -79,14 +102,17 @@ async function api(path) { @@ -79,14 +102,17 @@ async function api(path) {
async function apiMutate(method, path, body) {
const url = `${state.baseUrl.replace(/\/$/, "")}${path}`;
const res = await fetch(url, {
const init = {
method,
headers: {
Authorization: `Bearer ${state.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
};
if (body !== undefined) {
init.headers["Content-Type"] = "application/json";
init.body = JSON.stringify(body);
}
const res = await fetch(url, init);
const text = await res.text();
let data;
try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; }
@ -100,11 +126,41 @@ async function apiMutate(method, path, body) { @@ -100,11 +126,41 @@ async function apiMutate(method, path, body) {
async function loadOptions() {
try {
state.options = await api("/api/v1/assets/options");
populateFilterSelect(el.filterDeviceStatus, state.options.device_status);
populateFilterSelect(el.filterAssetFor, state.options.asset_for);
populateFilterSelect(el.filterAssetOn, state.options.asset_on);
} catch {
state.options = { device_status: [], asset_for: [], asset_on: [] };
}
}
function populateFilterSelect(selectEl, options) {
if (!selectEl) return;
const current = selectEl.value;
selectEl.innerHTML = '<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) {
if (!value) return "—";
try {
@ -125,6 +181,7 @@ function escapeHtml(str) { @@ -125,6 +181,7 @@ function escapeHtml(str) {
function showConnectedUi(show) {
el.stats.classList.toggle("hidden", !show);
el.searchForm.classList.toggle("hidden", !show);
if (el.filterForm) el.filterForm.classList.toggle("hidden", !show);
}
function renderStats(data) {
@ -150,18 +207,70 @@ function buildAssetsQuery() { @@ -150,18 +207,70 @@ function buildAssetsQuery() {
const params = new URLSearchParams();
params.set("page", String(state.page));
params.set("limit", String(state.limit));
const q = el.searchQ.value.trim();
params.set("sort", state.sort);
params.set("order", state.order);
const q = el.searchQ?.value.trim();
if (q) params.set("q", q);
const searchIn = el.searchIn?.value;
if (searchIn && searchIn !== "all") params.set("search_in", searchIn);
if (el.filterOwner?.value.trim()) params.set("owner", el.filterOwner.value.trim());
if (el.filterModel?.value.trim()) params.set("model", el.filterModel.value.trim());
if (el.filterSeries?.value.trim()) params.set("series", el.filterSeries.value.trim());
if (el.filterManufacturer?.value.trim()) params.set("manufacturer", el.filterManufacturer.value.trim());
if (el.filterDeviceStatus?.value) params.set("device_status", el.filterDeviceStatus.value);
if (el.filterAssetFor?.value) params.set("asset_for", el.filterAssetFor.value);
if (el.filterAssetOn?.value) params.set("asset_on", el.filterAssetOn.value);
const type = el.filterType.value;
if (type) params.set("type", type);
if (el.filterStale.checked) params.set("stale", "true");
return `?${params.toString()}`;
}
function sortTh(field, label) {
const active = state.sort === field;
const arrow = active ? (state.order === "asc" ? " ▲" : " ▼") : "";
return `<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) {
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) {
if (!value) return "";
const label = value === "laptop" ? "Laptop" : value === "desktop" ? "PC" : value;
@ -258,6 +367,10 @@ function bindEditPanel(asset) { @@ -258,6 +367,10 @@ function bindEditPanel(asset) {
const saveBtn = document.getElementById("save-meta-btn");
const editStatus = document.getElementById("edit-status");
if (ownerInput && asset.owner_display_name) {
ownerInput.dataset.ownerDisplayName = asset.owner_display_name;
}
if (ownerInput) {
ownerInput.addEventListener("input", () => {
clearTimeout(state.ldapSearchTimer);
@ -273,7 +386,7 @@ function bindEditPanel(asset) { @@ -273,7 +386,7 @@ function bindEditPanel(asset) {
suggestionsBox.innerHTML = `<div class="ldap-item muted">Tidak ada hasil.</div>`;
} else {
suggestionsBox.innerHTML = results.map(u => `
<div class="ldap-item" data-username="${escapeHtml(u.username)}">
<div class="ldap-item" data-username="${escapeHtml(u.username)}" data-display-name="${escapeHtml(u.display_name || "")}">
<strong>${escapeHtml(u.username)}</strong>
${u.display_name ? `<span class="muted"> — ${escapeHtml(u.display_name)}</span>` : ""}
${u.department ? `<span class="muted small"> · ${escapeHtml(u.department)}</span>` : ""}
@ -281,6 +394,7 @@ function bindEditPanel(asset) { @@ -281,6 +394,7 @@ function bindEditPanel(asset) {
suggestionsBox.querySelectorAll(".ldap-item").forEach(item => {
item.addEventListener("click", () => {
ownerInput.value = item.dataset.username;
ownerInput.dataset.ownerDisplayName = item.dataset.displayName || "";
suggestionsBox.classList.add("hidden");
});
});
@ -320,7 +434,18 @@ function bindEditPanel(asset) { @@ -320,7 +434,18 @@ function bindEditPanel(asset) {
const prevOwner = asset.owner_ldap_username || "";
if (newOwner !== prevOwner) {
if (newOwner) {
await apiMutate("PUT", `/api/v1/assets/${asset.id}/owner`, { ldap_username: newOwner });
const ownerBody = { ldap_username: newOwner };
const pickedName = ownerInput?.dataset?.ownerDisplayName?.trim();
if (pickedName) ownerBody.display_name = pickedName;
await apiMutate("PUT", `/api/v1/assets/${asset.id}/owner`, ownerBody);
}
} else if (newOwner && ownerInput?.dataset?.ownerDisplayName?.trim()) {
const pickedName = ownerInput.dataset.ownerDisplayName.trim();
if (pickedName !== (asset.owner_display_name || "")) {
await apiMutate("PUT", `/api/v1/assets/${asset.id}/owner`, {
ldap_username: newOwner,
display_name: pickedName,
});
}
}
@ -357,10 +482,12 @@ function renderAssets(data) { @@ -357,10 +482,12 @@ function renderAssets(data) {
const aoBadge = item.asset_on ? tagBadge(item.asset_on, ASSET_ON_COLORS) : "";
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><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>${afBadge} ${aoBadge}</td>
<td class="muted">${escapeHtml(item.serial_number || "—")}</td>
<td class="muted">${escapeHtml(fmtDate(item.last_seen_at))}</td>
</tr>`;
})
@ -371,12 +498,14 @@ function renderAssets(data) { @@ -371,12 +498,14 @@ function renderAssets(data) {
<table>
<thead>
<tr>
<th>Hostname</th>
<th>Tipe</th>
<th>Status</th>
${sortTh("hostname", "Hostname")}
${sortTh("owner", "Owner")}
${sortTh("model", "Model")}
${sortTh("series", "Series")}
${sortTh("agent_version", "Agent")}
${sortTh("device_status", "Status")}
<th>Tag</th>
<th>Serial</th>
<th>Last seen</th>
${sortTh("last_seen_at", "Last seen")}
</tr>
</thead>
<tbody>${rows}</tbody>
@ -385,6 +514,7 @@ function renderAssets(data) { @@ -385,6 +514,7 @@ function renderAssets(data) {
el.listContent.querySelectorAll("tbody tr").forEach((row) => {
row.addEventListener("click", () => openDetail(row.dataset.id));
});
bindSortHeaders();
el.pager.classList.remove("hidden");
el.pageInfo.textContent = `Halaman ${state.page} / ${state.pages}`;
@ -649,13 +779,13 @@ function renderDetail(asset, software) { @@ -649,13 +779,13 @@ function renderDetail(asset, software) {
asset.asset_on ? tagBadge(asset.asset_on, ASSET_ON_COLORS) : "",
].filter(Boolean).join(" ");
const ownerHtml = asset.owner_ldap_username
? `<span class="badge af-geonet">${escapeHtml(asset.owner_ldap_username)}</span>`
: `<span class="muted">—</span>`;
const ownerHtml = formatOwner(asset);
const identity = [
["Hostname", asset.hostname],
["Display name", asset.display_name],
["Owner", formatOwnerPlain(asset)],
["Agent version", snap?.agent_version || asset.metadata?.agent_version],
["Form factor", formFactor],
["Chassis", snap?.system?.chassis_label || asset.metadata?.chassis_label],
["Asset ID", asset.id],
@ -665,15 +795,16 @@ function renderDetail(asset, software) { @@ -665,15 +795,16 @@ function renderDetail(asset, software) {
["MAC", asset.primary_physical_mac],
["Manufacturer", asset.manufacturer],
["Model", asset.model],
["Series", asset.series],
["Snapshots", asset.snapshot_count],
["Last seen", fmtDate(asset.last_seen_at)],
];
let html = ``;
if (tagRow || asset.owner_ldap_username) {
if (tagRow || asset.owner_ldap_username || asset.owner_display_name) {
html += `<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}` : ""}
${asset.notes ? `<div class="muted small notes-preview">${escapeHtml(asset.notes)}</div>` : ""}
</div>`;
@ -685,6 +816,23 @@ function renderDetail(asset, software) { @@ -685,6 +816,23 @@ function renderDetail(asset, software) {
)
.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);
if (snap) {
@ -735,6 +883,121 @@ function renderDetail(asset, software) { @@ -735,6 +883,121 @@ function renderDetail(asset, software) {
el.detailTitle.innerHTML = `${escapeHtml(asset.display_name || asset.hostname)}${titleBadges ? " " + titleBadges : ""}`;
el.detailContent.innerHTML = html;
bindEditPanel(asset);
bindQuickSeriesAdd(asset);
}
function bindQuickSeriesAdd(asset) {
const btn = document.getElementById("quick-series-btn");
const input = document.getElementById("quick-series");
const status = document.getElementById("quick-series-status");
if (!btn || !input) return;
btn.addEventListener("click", async () => {
const series = input.value.trim();
if (!series) {
status.textContent = "Isi series dulu.";
status.className = "edit-status error";
return;
}
btn.disabled = true;
status.textContent = "Menyimpan...";
status.className = "edit-status";
try {
await apiMutate("POST", "/api/v1/device-models", {
manufacturer: asset.manufacturer,
model: asset.model,
series,
source: "manual",
notes: document.getElementById("cat-notes")?.value || null,
});
status.textContent = "Tersimpan.";
status.className = "edit-status ok";
await loadCatalog();
openDetail(asset.id);
} catch (err) {
status.textContent = err.message;
status.className = "edit-status error";
} finally {
btn.disabled = false;
}
});
}
async function loadCatalog() {
if (!el.catalogContent) return;
el.catalogContent.className = "loading";
el.catalogContent.textContent = "Memuat catalog...";
try {
const pendingOnly = el.catalogPendingOnly?.checked ? "&pending_only=true" : "";
const data = await api(`/api/v1/device-models?limit=200${pendingOnly}`);
const pendingCount = data.items?.filter((row) => row.series_pending).length ?? 0;
el.catalogMeta.textContent = `${data.total} mapping${pendingCount ? ` · ${pendingCount} perlu series` : ""}`;
if (!data.items?.length) {
el.catalogContent.className = "empty";
el.catalogContent.textContent = pendingOnly
? "Semua model sudah punya series."
: "Belum ada mapping. Klik Sync dari assets atau tambahkan manual di form atas.";
return;
}
el.catalogContent.className = "";
el.catalogContent.innerHTML = `
<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() {
@ -798,7 +1061,7 @@ async function connect() { @@ -798,7 +1061,7 @@ async function connect() {
showConnectedUi(true);
try {
await Promise.all([loadStats(), loadAssets(), loadOptions()]);
await Promise.all([loadStats(), loadAssets(), loadOptions(), loadCatalog()]);
} catch (err) {
showConnectedUi(false);
showError(err);
@ -834,6 +1097,35 @@ el.filterStale.addEventListener("change", () => { @@ -834,6 +1097,35 @@ el.filterStale.addEventListener("change", () => {
loadAssets().catch(showError);
});
function bindFilterInputs() {
const filterEls = [
el.filterOwner,
el.filterModel,
el.filterSeries,
el.filterManufacturer,
el.filterDeviceStatus,
el.filterAssetFor,
el.filterAssetOn,
el.searchIn,
].filter(Boolean);
filterEls.forEach((input) => {
const evt = input.tagName === "SELECT" ? "change" : "input";
input.addEventListener(evt, () => {
clearTimeout(state.searchTimer);
state.searchTimer = setTimeout(() => {
state.page = 1;
loadAssets().catch(showError);
}, input.tagName === "SELECT" ? 0 : 350);
});
});
}
if (el.clearFilters) {
el.clearFilters.addEventListener("click", resetFilters);
}
bindFilterInputs();
el.prevPage.addEventListener("click", () => {
if (state.page > 1) {
state.page -= 1;
@ -850,6 +1142,72 @@ el.nextPage.addEventListener("click", () => { @@ -850,6 +1142,72 @@ el.nextPage.addEventListener("click", () => {
el.closeDetail.addEventListener("click", closeDetail);
if (el.toggleCatalog && el.catalogPanel) {
el.toggleCatalog.addEventListener("click", () => {
el.catalogPanel.classList.toggle("hidden");
if (!el.catalogPanel.classList.contains("hidden")) {
loadCatalog().catch((err) => {
el.catalogContent.className = "error";
el.catalogContent.textContent = err.message;
});
}
});
}
if (el.catalogSync) {
el.catalogSync.addEventListener("click", async () => {
el.catalogSync.disabled = true;
try {
const result = await apiMutate("POST", "/api/v1/device-models/sync-from-assets", {});
alert(
`Sync selesai.\n` +
`${result.inserted} model baru ditambahkan\n` +
`${result.already_exists} sudah ada di catalog\n` +
`${result.pending_series} masih perlu diisi series\n` +
`${result.distinct_asset_models} kombinasi unik di assets`
);
await loadCatalog();
} catch (err) {
alert(err.message);
} finally {
el.catalogSync.disabled = false;
}
});
}
if (el.catalogPendingOnly) {
el.catalogPendingOnly.addEventListener("change", () => {
loadCatalog().catch(showError);
});
}
if (el.catalogForm) {
el.catalogForm.addEventListener("submit", async (e) => {
e.preventDefault();
const submitBtn = el.catalogForm.querySelector('button[type="submit"]');
submitBtn.disabled = true;
try {
await apiMutate("POST", "/api/v1/device-models", {
manufacturer: el.catManufacturer.value.trim(),
model: el.catModel.value.trim(),
series: el.catSeries.value.trim(),
source: "manual",
notes: el.catNotes.value.trim() || null,
});
el.catSeries.value = "";
el.catNotes.value = "";
await Promise.all([loadCatalog(), loadAssets()]);
if (state.selectedId) {
openDetail(state.selectedId);
}
} catch (err) {
alert(err.message);
} finally {
submitBtn.disabled = false;
}
});
}
loadSettings();
if (!el.apiBase.value) el.apiBase.value = defaultBaseUrl();
if (el.apiToken.value) connect();

94
GeoNetAgent/collector/static/index.html

@ -4,7 +4,7 @@ @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>GeoNetAgent — CMDB UI</title>
<link rel="stylesheet" href="/ui/style.css?v=20260625" />
<link rel="stylesheet" href="/ui/style.css?v=20260711" />
</head>
<body>
<header>
@ -27,9 +27,20 @@ @@ -27,9 +27,20 @@
<div id="stats" class="stats hidden"></div>
<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>
<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 class="field">
<label for="filter-type">Tipe</label>
@ -40,13 +51,86 @@ @@ -40,13 +51,86 @@
</div>
<div class="field">
<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)
</label>
</div>
<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>
<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">
<section class="panel" id="list-panel">
<div class="panel-header">
@ -70,6 +154,6 @@ @@ -70,6 +154,6 @@
</section>
</main>
<script src="/ui/app.js?v=20260625"></script>
<script src="/ui/app.js?v=20260711"></script>
</body>
</html>

121
GeoNetAgent/collector/static/style.css

@ -476,3 +476,124 @@ tbody tr.selected { @@ -476,3 +476,124 @@ tbody tr.selected {
}
.ldap-item:last-child { border-bottom: none; }
.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 @@ @@ -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 @@ @@ -1,25 +1,39 @@
# AI Session Summary
```yaml
session: 2026-07-06-docs-bootstrap
session: 2026-07-08-cmdb-catalog-ui
project: GeoNetAgent/
version: 0.1.12-phase0
version: 0.1.13-collector-ui
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
```
## TL;DR
- Bootstrap docs AI-Agent-Standards: version.md, system-requirements.md, guide/, ai-session-summary
- Agent live `v0.1.12-phase0`; collector di `10.100.1.24`
- Device model catalog SSOT (`device_model_catalog`) + sync dari assets (series pending) — live UI
- 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
1. Baca `.cursor/progress.md` untuk fase aktif
2. Bump versi agent → append `docs/version.md` + publish
1. UI: [collector.gisportal.id/ui/](https://collector.gisportal.id/ui/) — isi Series pending; filter stale + rollout install agent
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 hardcode credential di skrip PowerShell
- Jangan commit `.env`
- Jangan commit `agent/config.json` / `.env` (token)
- 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). @@ -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
### Changed

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

@ -0,0 +1,64 @@ @@ -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 @@ @@ -6,6 +6,14 @@
## ✅ 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
- **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 @@ @@ -1,6 +1,15 @@
# 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 @@ @@ -22,6 +31,16 @@
## 🟡 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/
- **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`

16
GeoNetAgent/docs/troubleshooting.md

@ -4,6 +4,22 @@ @@ -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"
**Kemungkinan:**

9
GeoNetAgent/docs/version.md

@ -4,6 +4,15 @@ @@ -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
- Agent version bump (lihat `agent/VERSION`)

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

@ -0,0 +1,31 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -1,18 +1,26 @@
# Workspace Session
> Last updated: 2026-07-06 — overwrite per handover
> Last updated: 2026-07-08 — overwrite per handover
---
## 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/`)
**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/`)
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 @@ -20,10 +28,13 @@ Dual-domain: **geonet.co.id** hybrid + **gisportal.id** Mailcow murni. SPF gispo
| 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 |
| **PC-Multimedia** | 0.3.0 | [PC-Multimedia/docs/ai-session-summary.md](PC-Multimedia/docs/ai-session-summary.md) | Stabil — GPU node aktif |
| **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 |
| **Mailcow** | 1.2.5 | [Mailcow/docs/ai-session-summary.md](Mailcow/docs/ai-session-summary.md) | Active — DKIM gisportal pending |
| **GeoNetAgent** | 0.1.13-collector-ui | [GeoNetAgent/docs/ai-session-summary.md](GeoNetAgent/docs/ai-session-summary.md) | Catalog+UI+ingest fix deployed |
| **nginx** | 1.1.0 | [nginx/docs/ai-session-summary.md](nginx/docs/ai-session-summary.md) | SSL geonet — renewal Sep 2026 |
| **Ollama** | 0.4.0 | [Ollama/docs/ai-session-summary.md](Ollama/docs/ai-session-summary.md) | AI pipeline live |
| **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 @@ -31,19 +42,19 @@ Dual-domain: **geonet.co.id** hybrid + **gisportal.id** Mailcow murni. SPF gispo
| Area | Impact |
|------|--------|
| AI pipeline | PC-Multimedia `.14` → pgvector `.24:5433` → geonet-console → Super Apps PWA |
| Zammad | Planned IMAP/SMTP → Mailcow `10.100.1.28` |
| nginx `.24` | TLS `sync.geonet.co.id` — unchanged |
| AD `10.100.1.40` | Mail-Users 26 mailbox geonet; gisportal auth mailcow |
| `collector.gisportal.id` / `agent.gisportal.id` | CMDB UI + agent report di nginx `.24` → collector Docker |
| `iguf.geonet.co.id` | TLS renewed — tidak terkait sesi GeoNetAgent |
| AI pipeline | PC-Multimedia `.14` → pgvector → console → PWA |
---
## Next agent
**AI pipeline:**
1. [Ollama/docs/ai-session-summary.md](Ollama/docs/ai-session-summary.md) — UI chat PWA + user memory
2. [server-connection/geonet-console/docs/ai-session-summary.md](server-connection/geonet-console/docs/ai-session-summary.md)
**GeoNetAgent:**
1. [GeoNetAgent/AGENTS.md](GeoNetAgent/AGENTS.md) + [ai-session-summary](GeoNetAgent/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:**
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
**AI / Mailcow:** lihat Active focus di atas.

Loading…
Cancel
Save