Browse Source

feat(GeoNetAgent): IT Support API, attachments, and asset ticket counts

Add support case CRUD, photo attachments, change log, and global stats/list for PWA integration. Asset list exposes ticket count and active status. Includes migrations 008-010, collector UI, tests, ADR-004, and session handover.

Co-authored-by: Cursor <cursoragent@cursor.com>
master
Budi Setiawan 1 week ago
parent
commit
19672b8559
  1. 7
      GeoNetAgent/agent/Collect-GeoNetReport.ps1
  2. 1
      GeoNetAgent/agent/README.md
  3. 16
      GeoNetAgent/agent/Send-GeoNetReport.ps1
  4. 2
      GeoNetAgent/agent/VERSION
  5. 30
      GeoNetAgent/agent/install/index.html
  6. 2
      GeoNetAgent/agent/install/manifest.json
  7. 19
      GeoNetAgent/agent/lib/Agent-InstallCore.ps1
  8. 190
      GeoNetAgent/agent/lib/Get-GpuInfo.ps1
  9. 17
      GeoNetAgent/collector/app/api/v1/assets.py
  10. 117
      GeoNetAgent/collector/app/api/v1/attachments.py
  11. 4
      GeoNetAgent/collector/app/api/v1/router.py
  12. 154
      GeoNetAgent/collector/app/api/v1/support.py
  13. 16
      GeoNetAgent/collector/app/config.py
  14. 81
      GeoNetAgent/collector/app/db/models.py
  15. 26
      GeoNetAgent/collector/app/schemas/asset_changes.py
  16. 3
      GeoNetAgent/collector/app/schemas/assets.py
  17. 78
      GeoNetAgent/collector/app/schemas/attachments.py
  18. 247
      GeoNetAgent/collector/app/schemas/support.py
  19. 451
      GeoNetAgent/collector/app/services/asset_change_service.py
  20. 71
      GeoNetAgent/collector/app/services/asset_read_service.py
  21. 21
      GeoNetAgent/collector/app/services/asset_service.py
  22. 106
      GeoNetAgent/collector/app/services/document_storage_service.py
  23. 202
      GeoNetAgent/collector/app/services/support_attachment_service.py
  24. 586
      GeoNetAgent/collector/app/services/support_service.py
  25. 4
      GeoNetAgent/collector/pyproject.toml
  26. 404
      GeoNetAgent/collector/static/app.js
  27. 4
      GeoNetAgent/collector/static/index.html
  28. 157
      GeoNetAgent/collector/static/style.css
  29. 58
      GeoNetAgent/collector/tests/test_asset_change_service.py
  30. 10
      GeoNetAgent/collector/tests/test_support_attachments.py
  31. 17
      GeoNetAgent/collector/tests/test_support_service.py
  32. 44
      GeoNetAgent/docs/adr/ADR-004-asset-support-troubleshooting.md
  33. 1
      GeoNetAgent/docs/adr/README.md
  34. 40
      GeoNetAgent/docs/ai-session-summary.md
  35. 62
      GeoNetAgent/docs/api/service-collector.md
  36. 14
      GeoNetAgent/docs/changelog.md
  37. 71
      GeoNetAgent/docs/database.md
  38. 1
      GeoNetAgent/docs/guide/README.md
  39. 74
      GeoNetAgent/docs/guide/asset-support-troubleshooting.md
  40. 10
      GeoNetAgent/docs/issues.md
  41. 77
      GeoNetAgent/docs/todo.md
  42. 59
      GeoNetAgent/docs/version.md
  43. 2
      GeoNetAgent/infra/collector/docker-compose.yml
  44. 34
      GeoNetAgent/infra/collector/publish_agent_install.ps1
  45. 5
      GeoNetAgent/infra/db/README.md
  46. 29
      GeoNetAgent/infra/db/migrations/008_asset_change_events.sql
  47. 95
      GeoNetAgent/infra/db/migrations/009_asset_support.sql
  48. 49
      GeoNetAgent/infra/db/migrations/010_support_attachments.sql
  49. 43
      Workspace-Session.md

7
GeoNetAgent/agent/Collect-GeoNetReport.ps1

@ -10,12 +10,12 @@ param( @@ -10,12 +10,12 @@ param(
)
$ErrorActionPreference = 'Stop'
$AgentVersion = '0.1.10-phase0'
$SchemaVersion = '1.0'
$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
$libPath = Join-Path $scriptRoot 'lib'
. (Join-Path $libPath 'Agent-InstallCore.ps1')
. (Join-Path $libPath 'Format-IsoDateTime.ps1')
. (Join-Path $libPath 'Format-Text.ps1')
. (Join-Path $libPath 'Get-SystemInfo.ps1')
@ -31,6 +31,11 @@ $libPath = Join-Path $scriptRoot 'lib' @@ -31,6 +31,11 @@ $libPath = Join-Path $scriptRoot 'lib'
. (Join-Path $libPath 'Get-SoftwareInfo.ps1')
. (Join-Path $libPath 'Build-GeoNetReportPayload.ps1')
$AgentVersion = Get-GeoNetLocalAgentVersion -InstallRoot $scriptRoot
if ([string]::IsNullOrWhiteSpace($AgentVersion)) {
$AgentVersion = 'unknown'
}
if (-not $OutputDir) {
$repoRoot = Split-Path -Parent $scriptRoot
$OutputDir = Join-Path $repoRoot 'samples\agent-reports'

1
GeoNetAgent/agent/README.md

@ -63,6 +63,7 @@ bash infra/collector/publish_agent_install.sh @@ -63,6 +63,7 @@ bash infra/collector/publish_agent_install.sh
| Versi | Highlight |
|-------|-----------|
| v0.1.15 | GPU VRAM akurat >4GB (`nvidia-smi`, registry `qwMemorySize`) |
| v0.1.10 | `cpu{}` detail (cores, clock, cache, arch); `system.cpu_model` tetap |
| v0.1.9 | `physical_disks[]`, DDR label, GPU driver/resolusi, `form_factor` Laptop/PC |
| v0.1.8 | `gpus[]`, disk model, auto-update, interval task 3 jam |

16
GeoNetAgent/agent/Send-GeoNetReport.ps1

@ -28,6 +28,7 @@ $libPath = Join-Path $scriptRoot 'lib' @@ -28,6 +28,7 @@ $libPath = Join-Path $scriptRoot 'lib'
. (Join-Path $libPath 'Get-HealthInfo.ps1')
. (Join-Path $libPath 'Get-PeripheralsInfo.ps1')
. (Join-Path $libPath 'Get-SoftwareInfo.ps1')
. (Join-Path $libPath 'Agent-InstallCore.ps1')
. (Join-Path $libPath 'Build-GeoNetReportPayload.ps1')
. (Join-Path $libPath 'Send-CollectorReport.ps1')
@ -53,7 +54,10 @@ if (-not $CollectorUrl -or -not $AgentToken) { @@ -53,7 +54,10 @@ if (-not $CollectorUrl -or -not $AgentToken) {
throw "CollectorUrl dan AgentToken wajib (parameter atau config.json)."
}
$AgentVersion = '0.1.10-phase0'
$AgentVersion = Get-GeoNetLocalAgentVersion -InstallRoot $scriptRoot
if ([string]::IsNullOrWhiteSpace($AgentVersion)) {
$AgentVersion = 'unknown'
}
$SchemaVersion = '1.0'
$payload = Build-GeoNetReportPayload `
@ -65,12 +69,4 @@ $payload = Build-GeoNetReportPayload ` @@ -65,12 +69,4 @@ $payload = Build-GeoNetReportPayload `
Write-Host "Mengirim laporan ke $CollectorUrl ..." -ForegroundColor Cyan
$result = Send-GeoNetCollectorReport -Payload $payload -CollectorUrl $CollectorUrl -AgentToken $AgentToken
if ($result.Success) {
Write-Host "OK: $($result.Response | ConvertTo-Json -Compress)" -ForegroundColor Green
exit 0
}
Write-Host "GAGAL: $($result.Error)" -ForegroundColor Red
if ($result.Body) { Write-Host $result.Body -ForegroundColor Yellow }
exit 2
Write-GeoNetReportOutcome -Result $result

2
GeoNetAgent/agent/VERSION

@ -1 +1 @@ @@ -1 +1 @@
0.1.12-phase0
0.1.15-phase0

30
GeoNetAgent/agent/install/index.html

@ -37,6 +37,12 @@ @@ -37,6 +37,12 @@
Tanpa Admin, Scheduled Task tidak terdaftar.
</div>
<div class="note">
<strong>ExecutionPolicy:</strong> Windows default sering <code>Restricted</code> — perintah
<code>&amp; "...\GeoNetAgent-Install.ps1"</code> akan gagal. Selalu pakai baris 2a dengan
<code>-ExecutionPolicy Bypass</code> (sudah disertakan di bawah).
</div>
<h2>Install atau update (disarankan)</h2>
<p>Jalankan <strong>satu per satu</strong> — baris 1 dulu, tunggu selesai, lalu baris 2.</p>
@ -44,26 +50,28 @@ @@ -44,26 +50,28 @@
<pre>Invoke-WebRequest -Uri 'https://agent.gisportal.id/install/GeoNetAgent-Install.ps1' -OutFile "$env:TEMP\GeoNetAgent-Install.ps1" -UseBasicParsing</pre>
<p><span class="label">Baris 2a</span> Install + kirim laporan pertama:</p>
<pre>&amp; "$env:TEMP\GeoNetAgent-Install.ps1"</pre>
<p><span class="label">Baris 2a</span> Install + kirim laporan pertama (Optional jika error):</p>
<pre>powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$env:TEMP\GeoNetAgent-Install.ps1"</pre>
<p><span class="label">Baris 2a</span> Alternatif (jika ExecutionPolicy sudah <code>RemoteSigned</code> / <code>Bypass</code>):</p>
<pre>&amp; "$env:TEMP\GeoNetAgent-Install.ps1"</pre>
<p><span class="label">Baris 2b</span> Atau hanya pasang/update file + task (tanpa laporan sekarang):</p>
<pre>&amp; "$env:TEMP\GeoNetAgent-Install.ps1" -SkipInitialRun</pre>
<pre>powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$env:TEMP\GeoNetAgent-Install.ps1" -SkipInitialRun</pre>
<p>Perintah yang sama dipakai untuk <strong>update</strong> agent. <code>config.json</code> lokal dipertahankan.</p>
<div class="note">
<strong>Harapan sukses:</strong>
<code>Scheduled Task OK: GeoNetAgent-Report, GeoNetAgent-Report-Startup, GeoNetAgent-Report-Shutdown</code>
<br />Lalu laporan awal: <code>OK: {"status":"accepted",...}</code>
</div>
<h2>Cek setelah install</h2>
<pre>schtasks /Query /TN GeoNetAgent-Report
schtasks /Query /TN GeoNetAgent-Report-Startup
&amp; "$env:ProgramData\GeoNetAgent\UserAgent.ps1"</pre>
<p>Baris terakhir mengirim laporan manual ke collector (opsional).</p>
Get-Content "$env:ProgramData\GeoNetAgent\VERSION"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$env:ProgramData\GeoNetAgent\UserAgent.ps1"</pre>
<p>Baris <code>VERSION</code> harus sama dengan badge versi di atas. Baris terakhir mengirim laporan manual ke collector (opsional).</p>
<h2>Yang terpasang otomatis</h2>
<ul>
@ -78,11 +86,19 @@ schtasks /Query /TN GeoNetAgent-Report-Startup @@ -78,11 +86,19 @@ schtasks /Query /TN GeoNetAgent-Report-Startup
variabel seperti <code>$env:TEMP</code> bisa ter-expand salah. Copy perintah di atas langsung ke prompt Admin.
</div>
<h2>Solusi masalah umum</h2>
<ul>
<li><strong>running scripts is disabled</strong> — pakai baris 2a dengan <code>-ExecutionPolicy Bypass</code>, jangan <code>&amp; ...ps1</code> langsung.</li>
<li><strong>Missing closing '}'</strong> saat laporan awal — unduhan file agent terpotong; jalankan ulang baris 1 + 2a (update ke versi terbaru).</li>
<li><strong>Agent version di CMDB masih lama</strong> — pastikan <code>VERSION</code> lokal = versi di halaman ini, lalu kirim ulang dengan <code>UserAgent.ps1</code> (perintah di atas).</li>
<li><strong>Scheduled Task tidak ada</strong> — PowerShell harus dijalankan sebagai Administrator.</li>
</ul>
<h2>Alternatif: unduh lewat browser</h2>
<ol class="steps">
<li>Unduh <a href="GeoNetAgent-Install.ps1">GeoNetAgent-Install.ps1</a> ke folder <code>%TEMP%</code></li>
<li>PowerShell Admin: <code>cd $env:TEMP</code></li>
<li><code>.\GeoNetAgent-Install.ps1 -SkipInitialRun</code></li>
<li><code>powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\GeoNetAgent-Install.ps1 -SkipInitialRun</code></li>
</ol>
<script>
(function () {

2
GeoNetAgent/agent/install/manifest.json

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
{
"version": "0.1.12-phase0",
"version": "0.1.15-phase0",
"files": [
"Send-GeoNetReport.ps1",
"Collect-GeoNetReport.ps1",

19
GeoNetAgent/agent/lib/Agent-InstallCore.ps1

@ -259,3 +259,22 @@ function Get-GeoNetInstallBaseUrlFromConfig { @@ -259,3 +259,22 @@ function Get-GeoNetInstallBaseUrlFromConfig {
return $default
}
function Write-GeoNetReportOutcome {
param(
[Parameter(Mandatory = $true)]
[pscustomobject]$Result
)
if ($Result.Success) {
$okMsg = $Result.Response | ConvertTo-Json -Compress
Write-Host "OK: $okMsg" -ForegroundColor Green
exit 0
}
$errMsg = $Result.Error
Write-Host "GAGAL: $errMsg" -ForegroundColor Red
$bodyText = $Result.Body
if ($bodyText) { Write-Host $bodyText -ForegroundColor Yellow }
exit 2
}

190
GeoNetAgent/agent/lib/Get-GpuInfo.ps1

@ -4,6 +4,63 @@ if (Test-Path $formatText) { . $formatText } @@ -4,6 +4,63 @@ if (Test-Path $formatText) { . $formatText }
$isoDate = Join-Path $PSScriptRoot 'Format-IsoDateTime.ps1'
if (Test-Path $isoDate) { . $isoDate }
function Test-GeoNetGpuNameMatch {
param(
[string]$A,
[string]$B
)
if ([string]::IsNullOrWhiteSpace($A) -or [string]::IsNullOrWhiteSpace($B)) { return $false }
$norm = {
param([string]$Value)
($Value -replace '\s+', ' ').Trim().ToUpperInvariant()
}
$left = & $norm $A
$right = & $norm $B
return ($left -eq $right -or $left.Contains($right) -or $right.Contains($left))
}
function Convert-GeoNetRegistryQwordToBytes {
param([AllowNull()]$Value)
if ($null -eq $Value) { return $null }
if ($Value -is [byte[]]) {
if ($Value.Length -eq 0) { return $null }
$buffer = New-Object byte[] 8
[Array]::Copy($Value, $buffer, [Math]::Min($Value.Length, 8))
return [BitConverter]::ToUInt64($buffer, 0)
}
if ($Value -is [uint64]) { return $Value }
if ($Value -is [long] -or $Value -is [int64]) {
if ($Value -le 0) { return $null }
return [uint64]$Value
}
if ($Value -is [int] -or $Value -is [uint32]) {
if ($Value -le 0) { return $null }
return [uint64]$Value
}
return $null
}
function Convert-GeoNetBytesToMb {
param([AllowNull()]$Bytes)
if ($null -eq $Bytes -or $Bytes -le 0) { return $null }
try {
$mb = [math]::Round([decimal]$Bytes / 1MB, 0)
if ($mb -le 0 -or $mb -gt 131072) { return $null }
return [int]$mb
} catch {
return $null
}
}
function Convert-GeoNetAdapterRamMb {
param([AllowNull()]$AdapterRam)
@ -18,31 +75,121 @@ function Convert-GeoNetAdapterRamMb { @@ -18,31 +75,121 @@ function Convert-GeoNetAdapterRamMb {
}
}
function Get-GeoNetGpuMemoryMbFromRegistry {
param([AllowNull()][string]$PnpDeviceId)
if (-not $PnpDeviceId) { return $null }
function Get-GeoNetGpuRegistryEntries {
$entries = @()
try {
$baseKey = 'HKLM:\SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}'
$subKeys = Get-ChildItem $baseKey -ErrorAction Stop | Where-Object { $_.PSChildName -match '^\d{4}$' }
foreach ($key in $subKeys) {
$matchId = (Get-ItemProperty -Path $key.PSPath -Name 'MatchingDeviceId' -ErrorAction SilentlyContinue).'MatchingDeviceId'
$props = Get-ItemProperty -Path $key.PSPath -ErrorAction SilentlyContinue
if (-not $props) { continue }
$pnpNorm = $PnpDeviceId.ToUpper()
$matchNorm = if ($matchId) { $matchId.ToUpper() } else { '' }
$label = $props.'HardwareInformation.AdapterString'
if (-not $label) { $label = $props.DriverDesc }
if (-not $label) { continue }
if ($matchNorm -and $pnpNorm.StartsWith($matchNorm.Split('&')[0])) {
$memBytes = (Get-ItemProperty -Path $key.PSPath -Name 'HardwareInformation.MemorySize' -ErrorAction SilentlyContinue).'HardwareInformation.MemorySize'
if ($memBytes -and $memBytes -gt 0) {
$mb = [math]::Round([decimal]$memBytes / 1MB, 0)
if ($mb -gt 0 -and $mb -le 131072) { return [int]$mb }
}
$qwBytes = Convert-GeoNetRegistryQwordToBytes -Value $props.'HardwareInformation.qwMemorySize'
$memoryMb = Convert-GeoNetBytesToMb -Bytes $qwBytes
if ($null -eq $memoryMb) { continue }
$entries += [pscustomobject]@{
Name = [string]$label
MatchingDeviceId = Convert-GeoNetOptionalString -Value $props.MatchingDeviceId
MemoryMb = $memoryMb
}
}
} catch {
}
@($entries)
}
function Get-GeoNetNvidiaSmiGpus {
$nvsmi = $null
$cmd = Get-Command nvidia-smi.exe -ErrorAction SilentlyContinue
if ($cmd) {
$nvsmi = $cmd.Source
} else {
foreach ($candidate in @(
"$env:ProgramFiles\NVIDIA Corporation\NVSMI\nvidia-smi.exe",
"$env:SystemRoot\System32\nvidia-smi.exe"
)) {
if (Test-Path $candidate) {
$nvsmi = $candidate
break
}
}
}
if (-not $nvsmi) { return @() }
$lines = Invoke-GeoNetSafe {
@(& $nvsmi --query-gpu=gpu_name,driver_version,memory.total --format=csv,noheader,nounits 2>$null)
} -Default @()
$result = @()
foreach ($line in $lines) {
if ([string]::IsNullOrWhiteSpace($line)) { continue }
$parts = $line -split ',\s*', 3
if ($parts.Count -lt 3) { continue }
$memoryMb = $null
if ($parts[2] -match '^\d+(\.\d+)?$') {
$memoryMb = [int][math]::Round([decimal]$parts[2], 0)
}
if ($null -eq $memoryMb -or $memoryMb -le 0) { continue }
$result += [pscustomobject]@{
Name = $parts[0].Trim()
DriverVersion = $parts[1].Trim()
MemoryMb = $memoryMb
}
}
@($result)
}
function Get-GeoNetGpuDedicatedMemoryMb {
param(
$Controller,
$RegistryEntries,
$NvidiaSmiGpus
)
$name = Convert-GeoNetOptionalString -Value $Controller.Name
$pnp = Convert-GeoNetOptionalString -Value $Controller.PNPDeviceID
foreach ($gpu in $NvidiaSmiGpus) {
if (Test-GeoNetGpuNameMatch -A $name -B $gpu.Name) {
return $gpu.MemoryMb
}
}
if ($pnp) {
$pnpNorm = $pnp.ToUpperInvariant()
foreach ($entry in $RegistryEntries) {
if (-not $entry.MatchingDeviceId) { continue }
$matchNorm = $entry.MatchingDeviceId.ToUpperInvariant().Split('&')[0]
if ($matchNorm -and $pnpNorm.StartsWith($matchNorm)) {
return $entry.MemoryMb
}
}
}
foreach ($entry in $RegistryEntries) {
if (Test-GeoNetGpuNameMatch -A $name -B $entry.Name) {
return $entry.MemoryMb
}
}
# WMI AdapterRAM is 32-bit and often reports ~4GB for modern GPUs — use only when plausible.
$wmiMb = Convert-GeoNetAdapterRamMb -AdapterRam $Controller.AdapterRAM
if ($null -ne $wmiMb -and $wmiMb -lt 4096) {
return $wmiMb
}
return $null
}
@ -73,10 +220,13 @@ function Get-GeoNetGpuVendorFromPnp { @@ -73,10 +220,13 @@ function Get-GeoNetGpuVendorFromPnp {
}
function Get-GeoNetGpuInfo {
$registryEntries = @(Invoke-GeoNetSafe { @(Get-GeoNetGpuRegistryEntries) } -Default @())
$nvidiaSmiGpus = @(Invoke-GeoNetSafe { @(Get-GeoNetNvidiaSmiGpus) } -Default @())
$controllers = Invoke-GeoNetSafe {
Get-CimInstance Win32_VideoController -ErrorAction Stop |
Where-Object {
$_.Name -and $_.Name -notmatch 'Microsoft Basic|Remote|Sage Display|Display Manager'
$_.Name -and $_.Name -notmatch 'Microsoft Basic|Remote Display|Remote|Sage Display|Display Manager|Indirect Display'
}
} -Default @()
@ -88,6 +238,14 @@ function Get-GeoNetGpuInfo { @@ -88,6 +238,14 @@ function Get-GeoNetGpuInfo {
$vendor = Convert-GeoNetOptionalString -Value $_.AdapterCompatibility
if (-not $vendor) { $vendor = Get-GeoNetGpuVendorFromPnp -PnpDeviceId $pnp }
$driverVersion = Convert-GeoNetOptionalString -Value $_.DriverVersion
foreach ($nvGpu in $nvidiaSmiGpus) {
if (Test-GeoNetGpuNameMatch -A $name -B $nvGpu.Name) {
if ($nvGpu.DriverVersion) { $driverVersion = $nvGpu.DriverVersion }
break
}
}
$width = $null
$height = $null
if ($_.CurrentHorizontalResolution -gt 0 -and $_.CurrentVerticalResolution -gt 0) {
@ -100,9 +258,9 @@ function Get-GeoNetGpuInfo { @@ -100,9 +258,9 @@ function Get-GeoNetGpuInfo {
name = $name
adapter_compatibility = $vendor
video_processor = Convert-GeoNetOptionalString -Value $_.VideoProcessor
driver_version = Convert-GeoNetOptionalString -Value $_.DriverVersion
driver_version = $driverVersion
driver_date = Convert-GeoNetDriverDateIso -DriverDate $_.DriverDate
dedicated_memory_mb = $(if ($pnp) { $reg = Get-GeoNetGpuMemoryMbFromRegistry -PnpDeviceId $pnp; if ($null -ne $reg) { $reg } else { Convert-GeoNetAdapterRamMb -AdapterRam $_.AdapterRAM } } else { Convert-GeoNetAdapterRamMb -AdapterRam $_.AdapterRAM })
dedicated_memory_mb = Get-GeoNetGpuDedicatedMemoryMb -Controller $_ -RegistryEntries $registryEntries -NvidiaSmiGpus $nvidiaSmiGpus
pnp_device_id = $pnp
status = Convert-GeoNetOptionalString -Value $_.Status
current_resolution = $resolution

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

@ -11,7 +11,8 @@ from app.schemas.assets import ( @@ -11,7 +11,8 @@ from app.schemas.assets import (
AssetDetail, AssetMetaIn, AssetOwnerIn, AssetOwnerOut, AssetsByOwnerItem,
PaginatedAssets, PaginatedSnapshots, PaginatedSoftware, SnapshotSummary, StatsSummary,
)
from app.services import asset_read_service
from app.schemas.asset_changes import PaginatedAssetChanges
from app.services import asset_change_service, asset_read_service
router = APIRouter()
@ -186,3 +187,17 @@ def set_asset_owner( @@ -186,3 +187,17 @@ def set_asset_owner(
return result
@router.get("/assets/{asset_id}/changes", response_model=PaginatedAssetChanges)
def list_asset_changes(
asset_id: uuid.UUID,
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
_: str = Depends(verify_api_bearer),
) -> PaginatedAssetChanges:
result = asset_change_service.list_asset_changes(db, asset_id, page=page, limit=limit)
if result is None:
raise _not_found()
return result

117
GeoNetAgent/collector/app/api/v1/attachments.py

@ -0,0 +1,117 @@ @@ -0,0 +1,117 @@
import uuid
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status
from fastapi.responses import RedirectResponse, Response
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.attachments import AttachmentListOut, AttachmentOut
from app.services import support_attachment_service
router = APIRouter()
def _not_found(message: str = "Not found") -> HTTPException:
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ErrorResponse(error=ErrorBody(code="NOT_FOUND", message=message)).model_dump(),
)
def _bad_request(message: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=ErrorResponse(error=ErrorBody(code="VALIDATION_ERROR", message=message)).model_dump(),
)
@router.get("/support-cases/{case_id}/attachments", response_model=AttachmentListOut)
def list_support_attachments(
case_id: uuid.UUID,
db: Session = Depends(get_db),
_: str = Depends(verify_api_bearer),
) -> AttachmentListOut:
result = support_attachment_service.list_case_attachments(db, case_id)
if result is None:
raise _not_found("Support case not found")
return result
@router.post(
"/support-cases/{case_id}/attachments",
response_model=AttachmentOut,
status_code=status.HTTP_201_CREATED,
)
async def upload_support_attachment(
case_id: uuid.UUID,
file: UploadFile = File(...),
kind: str = Form("service_photo"),
actor: str = Form(...),
actor_display_name: str | None = Form(None),
caption: str | None = Form(None),
db: Session = Depends(get_db),
_: str = Depends(verify_api_bearer),
) -> AttachmentOut:
from app.schemas.attachments import AttachmentUploadForm
try:
body = AttachmentUploadForm(
kind=kind, # type: ignore[arg-type]
actor=actor.strip(),
actor_display_name=actor_display_name.strip() if actor_display_name else None,
caption=caption.strip() if caption else None,
)
except Exception as exc:
raise _bad_request(str(exc)) from exc
data = await file.read()
try:
result = support_attachment_service.upload_support_attachment(
db,
case_id,
filename=file.filename or "upload.bin",
content_type=file.content_type,
data=data,
form=body,
)
except ValueError as exc:
raise _bad_request(str(exc)) from exc
if result is None:
raise _not_found("Support case not found")
return result
@router.get("/attachments/{attachment_id}/download")
def download_attachment(
attachment_id: uuid.UUID,
db: Session = Depends(get_db),
_: str = Depends(verify_api_bearer),
) -> Response:
presigned = support_attachment_service.get_presigned_url(db, attachment_id)
if presigned:
return RedirectResponse(url=presigned, status_code=status.HTTP_302_FOUND)
result = support_attachment_service.get_attachment_for_download(db, attachment_id)
if result is None:
raise _not_found("Attachment not found")
row, data = result
media = row.content_type or "application/octet-stream"
headers = {
"Content-Disposition": f'inline; filename="{row.original_filename}"',
}
return Response(content=data, media_type=media, headers=headers)
@router.delete("/attachments/{attachment_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_attachment(
attachment_id: uuid.UUID,
actor: str = Query(..., min_length=2, max_length=100),
db: Session = Depends(get_db),
_: str = Depends(verify_api_bearer),
) -> None:
if not support_attachment_service.soft_delete_attachment(db, attachment_id, actor=actor.strip()):
raise _not_found("Attachment not found")

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

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api.v1 import agent, assets, device_models, health, ldap
from app.api.v1 import agent, assets, attachments, device_models, health, ldap, support
router = APIRouter(prefix="/api/v1")
router.include_router(health.router, tags=["health"])
@ -8,3 +8,5 @@ router.include_router(agent.router, tags=["agent"]) @@ -8,3 +8,5 @@ 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"])
router.include_router(support.router, tags=["support"])
router.include_router(attachments.router, tags=["attachments"])

154
GeoNetAgent/collector/app/api/v1/support.py

@ -0,0 +1,154 @@ @@ -0,0 +1,154 @@
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.support import (
PaginatedSupportCases,
SupportCaseCreateIn,
SupportCaseOut,
SupportCasePatchIn,
SupportEventCreateIn,
SupportEventOut,
SupportOptionsOut,
SupportStatsOut,
)
from app.services import support_service
router = APIRouter()
def _not_found(message: str = "Not found") -> HTTPException:
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ErrorResponse(error=ErrorBody(code="NOT_FOUND", message=message)).model_dump(),
)
def _bad_request(message: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=ErrorResponse(error=ErrorBody(code="VALIDATION_ERROR", message=message)).model_dump(),
)
@router.get("/support/options", response_model=SupportOptionsOut)
def get_support_options(_: str = Depends(verify_api_bearer)) -> SupportOptionsOut:
return support_service.get_support_options()
@router.get("/support/stats", response_model=SupportStatsOut)
def get_support_stats(
db: Session = Depends(get_db),
_: str = Depends(verify_api_bearer),
) -> SupportStatsOut:
return support_service.get_support_stats(db)
@router.get("/support-cases", response_model=PaginatedSupportCases)
def list_support_cases(
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100),
status: str | None = Query(None, max_length=30),
priority: str | None = Query(None, max_length=20),
category: str | None = Query(None, max_length=40),
active_only: bool = Query(False),
q: str | None = Query(None, max_length=200),
db: Session = Depends(get_db),
_: str = Depends(verify_api_bearer),
) -> PaginatedSupportCases:
return support_service.list_support_cases(
db,
page=page,
limit=limit,
status=status,
priority=priority,
category=category,
active_only=active_only,
q=q,
)
@router.get("/assets/{asset_id}/support-cases", response_model=PaginatedSupportCases)
def list_asset_support_cases(
asset_id: uuid.UUID,
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100),
status: str | None = Query(None, max_length=30),
db: Session = Depends(get_db),
_: str = Depends(verify_api_bearer),
) -> PaginatedSupportCases:
result = support_service.list_asset_support_cases(
db, asset_id, page=page, limit=limit, status=status
)
if result is None:
raise _not_found("Asset not found")
return result
@router.post("/assets/{asset_id}/support-cases", response_model=SupportCaseOut, status_code=status.HTTP_201_CREATED)
def create_support_case(
asset_id: uuid.UUID,
body: SupportCaseCreateIn,
db: Session = Depends(get_db),
_: str = Depends(verify_api_bearer),
) -> SupportCaseOut:
try:
result = support_service.create_support_case(db, asset_id, body)
except ValueError as exc:
raise _bad_request(str(exc)) from exc
if result is None:
raise _not_found("Asset not found")
return result
@router.get("/support-cases/{case_id}", response_model=SupportCaseOut)
def get_support_case(
case_id: uuid.UUID,
db: Session = Depends(get_db),
_: str = Depends(verify_api_bearer),
) -> SupportCaseOut:
result = support_service.get_support_case(db, case_id, include_events=True)
if result is None:
raise _not_found("Support case not found")
return result
@router.patch("/support-cases/{case_id}", response_model=SupportCaseOut)
def patch_support_case(
case_id: uuid.UUID,
body: SupportCasePatchIn,
actor: str = Query(..., min_length=2, max_length=100),
actor_display_name: str | None = Query(None, max_length=200),
db: Session = Depends(get_db),
_: str = Depends(verify_api_bearer),
) -> SupportCaseOut:
try:
result = support_service.patch_support_case(
db,
case_id,
body,
actor=actor.strip(),
actor_display_name=actor_display_name.strip() if actor_display_name else None,
)
except ValueError as exc:
raise _bad_request(str(exc)) from exc
if result is None:
raise _not_found("Support case not found")
return result
@router.post("/support-cases/{case_id}/events", response_model=SupportEventOut, status_code=status.HTTP_201_CREATED)
def add_support_event(
case_id: uuid.UUID,
body: SupportEventCreateIn,
db: Session = Depends(get_db),
_: str = Depends(verify_api_bearer),
) -> SupportEventOut:
result = support_service.add_support_event(db, case_id, body)
if result is None:
raise _not_found("Support case not found")
return result

16
GeoNetAgent/collector/app/config.py

@ -30,6 +30,22 @@ class Settings(BaseSettings): @@ -30,6 +30,22 @@ class Settings(BaseSettings):
ldap_base_dn: str = ""
ldap_user_search_base: str = ""
# Document attachments (support photos — ADR-003)
documents_storage_backend: str = "local" # local | s3
documents_local_path: str = "./data/documents"
documents_max_upload_bytes: int = 15 * 1024 * 1024
s3_endpoint: str = ""
s3_bucket: str = "geonetagent-documents"
s3_access_key_id: str = ""
s3_secret_access_key: str = ""
s3_region: str = "us-east-1"
s3_presign_expires_seconds: int = 3600
def documents_use_s3(self) -> bool:
if self.documents_storage_backend.strip().lower() == "s3":
return bool(self.s3_endpoint and self.s3_access_key_id and self.s3_secret_access_key)
return False
def ldap_enabled(self) -> bool:
return bool(self.ldap_url and self.ldap_bind_dn)

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

@ -142,3 +142,84 @@ class DeviceModelCatalog(Base): @@ -142,3 +142,84 @@ class DeviceModelCatalog(Base):
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())
class AssetChangeEvent(Base):
__tablename__ = "asset_change_events"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
asset_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
snapshot_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
event_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
source: Mapped[str] = mapped_column(String(30), nullable=False)
field_key: Mapped[str] = mapped_column(String(80), nullable=False)
field_label: Mapped[str] = mapped_column(String(120), nullable=False)
change_type: Mapped[str] = mapped_column(String(20), nullable=False, default="updated")
old_value: Mapped[str | None] = mapped_column(Text)
new_value: Mapped[str | None] = mapped_column(Text)
detail: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
class AssetSupportCase(Base):
__tablename__ = "asset_support_cases"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
asset_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
case_number: Mapped[str] = mapped_column(String(32), nullable=False, unique=True)
status: Mapped[str] = mapped_column(String(30), nullable=False, default="open")
priority: Mapped[str] = mapped_column(String(20), nullable=False, default="normal")
category: Mapped[str] = mapped_column(String(40), nullable=False)
title: Mapped[str] = mapped_column(String(200), nullable=False)
symptom: Mapped[str] = mapped_column(Text, nullable=False)
resolution: Mapped[str | None] = mapped_column(Text)
root_cause: Mapped[str | None] = mapped_column(Text)
reported_by: Mapped[str] = mapped_column(String(100), nullable=False)
reported_by_name: Mapped[str | None] = mapped_column(String(200))
assigned_to: Mapped[str | None] = mapped_column(String(100))
assigned_to_name: Mapped[str | None] = mapped_column(String(200))
device_status_at_open: Mapped[str | None] = mapped_column(String(30))
device_status_applied: Mapped[str | None] = mapped_column(String(30))
opened_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
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())
class AssetSupportEvent(Base):
__tablename__ = "asset_support_events"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
case_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
event_type: Mapped[str] = mapped_column(String(30), nullable=False)
summary: Mapped[str] = mapped_column(String(500), nullable=False)
detail: Mapped[str | None] = mapped_column(Text)
old_status: Mapped[str | None] = mapped_column(String(30))
new_status: Mapped[str | None] = mapped_column(String(30))
metadata_: Mapped[dict] = mapped_column("metadata", JSONB, nullable=False, default=dict)
actor: Mapped[str] = mapped_column(String(100), nullable=False)
actor_display_name: Mapped[str | None] = mapped_column(String(200))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class DocumentAttachment(Base):
__tablename__ = "document_attachments"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
domain: Mapped[str] = mapped_column(String(20), nullable=False, default="support")
kind: Mapped[str] = mapped_column(String(40), nullable=False, default="service_photo")
asset_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
support_case_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
storage_provider: Mapped[str] = mapped_column(String(20), nullable=False, default="local")
bucket: Mapped[str | None] = mapped_column(String(100))
object_key: Mapped[str] = mapped_column(String(500), nullable=False)
original_filename: Mapped[str] = mapped_column(String(255), nullable=False)
content_type: Mapped[str | None] = mapped_column(String(100))
file_size_bytes: Mapped[int | None] = mapped_column()
checksum_sha256: Mapped[str | None] = mapped_column(String(64))
uploaded_by: Mapped[str] = mapped_column(String(100), nullable=False)
uploaded_by_name: Mapped[str | None] = mapped_column(String(200))
caption: Mapped[str | None] = mapped_column(Text)
uploaded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
metadata_: Mapped[dict] = mapped_column("metadata", JSONB, nullable=False, default=dict)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))

26
GeoNetAgent/collector/app/schemas/asset_changes.py

@ -0,0 +1,26 @@ @@ -0,0 +1,26 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
class AssetChangeEventOut(BaseModel):
id: str
asset_id: str
snapshot_id: str | None = None
event_at: datetime
source: str
field_key: str
field_label: str
change_type: str
old_value: str | None = None
new_value: str | None = None
detail: dict[str, Any] = Field(default_factory=dict)
class PaginatedAssetChanges(BaseModel):
items: list[AssetChangeEventOut]
total: int
page: int
limit: int
pages: int

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

@ -27,6 +27,9 @@ class AssetListItem(BaseModel): @@ -27,6 +27,9 @@ class AssetListItem(BaseModel):
owner_ldap_username: str | None = None
owner_display_name: str | None = None
agent_version: str | None = None
support_case_count: int = 0
active_support_status: str | None = None
active_support_status_label: str | None = None
class PaginatedAssets(BaseModel):

78
GeoNetAgent/collector/app/schemas/attachments.py

@ -0,0 +1,78 @@ @@ -0,0 +1,78 @@
"""SSOT — document attachment kinds (ADR-003 / support troubleshooting)."""
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
SUPPORT_ATTACHMENT_KIND = [
"service_photo",
"before_photo",
"after_photo",
"parts_receipt",
"service_report",
"screenshot",
"other",
]
SUPPORT_ATTACHMENT_KIND_LABELS: dict[str, str] = {
"service_photo": "Foto kondisi / troubleshooting",
"before_photo": "Foto sebelum perbaikan",
"after_photo": "Foto setelah perbaikan",
"parts_receipt": "Nota / invoice sparepart",
"service_report": "Laporan service (PDF)",
"screenshot": "Screenshot error",
"other": "Lainnya",
}
ALLOWED_ATTACHMENT_CONTENT_TYPES = frozenset({
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
"application/pdf",
})
ALLOWED_ATTACHMENT_EXTENSIONS = frozenset({
".jpg", ".jpeg", ".png", ".webp", ".gif", ".pdf",
})
class AttachmentOut(BaseModel):
id: str
domain: str
kind: str
kind_label: str
asset_id: str
support_case_id: str
original_filename: str
content_type: str | None = None
file_size_bytes: int | None = None
caption: str | None = None
uploaded_by: str
uploaded_by_name: str | None = None
uploaded_at: datetime
download_url: str
is_image: bool = False
class AttachmentListOut(BaseModel):
items: list[AttachmentOut]
total: int
class AttachmentUploadForm(BaseModel):
"""Validated after multipart parse."""
kind: Literal[
"service_photo",
"before_photo",
"after_photo",
"parts_receipt",
"service_report",
"screenshot",
"other",
] = "service_photo"
actor: str = Field(..., min_length=2, max_length=100)
actor_display_name: str | None = Field(None, max_length=200)
caption: str | None = Field(None, max_length=2000)

247
GeoNetAgent/collector/app/schemas/support.py

@ -0,0 +1,247 @@ @@ -0,0 +1,247 @@
"""SSOT enums & labels — IT Support troubleshooting (ADR-004)."""
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, Field
from app.schemas.attachments import SUPPORT_ATTACHMENT_KIND, SUPPORT_ATTACHMENT_KIND_LABELS, AttachmentOut
SUPPORT_CASE_STATUS = [
"open",
"in_progress",
"waiting_parts",
"waiting_user",
"resolved",
"closed",
"cancelled",
]
SUPPORT_CASE_PRIORITY = ["low", "normal", "high", "critical"]
SUPPORT_CASE_CATEGORY = [
"power_no_boot",
"boot_os_error",
"hardware_failure",
"component_replacement",
"repair",
"preventive_maintenance",
"software_issue",
"network_connectivity",
"performance",
"data_backup_recovery",
"security_incident",
"user_request",
"decommission",
"other",
]
SUPPORT_EVENT_TYPE = [
"opened",
"note",
"status_change",
"action",
"parts",
"handover",
"resolution",
]
SUPPORT_STATUS_LABELS: dict[str, str] = {
"open": "Open — baru dilaporkan",
"in_progress": "In progress — sedang ditangani",
"waiting_parts": "Waiting parts — menunggu komponen",
"waiting_user": "Waiting user — menunggu user/konfirmasi",
"resolved": "Resolved — selesai diperbaiki",
"closed": "Closed — ditutup",
"cancelled": "Cancelled — dibatalkan",
}
SUPPORT_PRIORITY_LABELS: dict[str, str] = {
"low": "Low",
"normal": "Normal",
"high": "High",
"critical": "Critical",
}
SUPPORT_CATEGORY_LABELS: dict[str, str] = {
"power_no_boot": "Tidak nyala / mati total",
"boot_os_error": "Error boot / OS",
"hardware_failure": "Kerusakan hardware",
"component_replacement": "Penggantian komponen",
"repair": "Perbaikan",
"preventive_maintenance": "Service / maintenance",
"software_issue": "Masalah software / driver",
"network_connectivity": "Jaringan / konektivitas",
"performance": "Performa / lambat / overheat",
"data_backup_recovery": "Backup / recovery data",
"security_incident": "Keamanan / malware",
"user_request": "Permintaan user",
"decommission": "Penarikan / decommission",
"other": "Lainnya",
}
SUPPORT_EVENT_TYPE_LABELS: dict[str, str] = {
"opened": "Kasus dibuka",
"note": "Catatan",
"status_change": "Perubahan status",
"action": "Tindakan teknis",
"parts": "Komponen / parts",
"handover": "Serah terima",
"resolution": "Resolusi",
}
class SupportOptionsOut(BaseModel):
status: list[str]
status_labels: dict[str, str]
priority: list[str]
priority_labels: dict[str, str]
category: list[str]
category_labels: dict[str, str]
event_type: list[str]
event_type_labels: dict[str, str]
device_status: list[str]
attachment_kind: list[str]
attachment_kind_labels: dict[str, str]
class SupportEventOut(BaseModel):
id: str
case_id: str
event_type: str
event_type_label: str
summary: str
detail: str | None = None
old_status: str | None = None
new_status: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
actor: str
actor_display_name: str | None = None
created_at: datetime
class SupportCaseOut(BaseModel):
id: str
asset_id: str
asset_hostname: str | None = None
asset_display_name: str | None = None
case_number: str
status: str
status_label: str
priority: str
priority_label: str
category: str
category_label: str
title: str
symptom: str
resolution: str | None = None
root_cause: str | None = None
reported_by: str
reported_by_name: str | None = None
assigned_to: str | None = None
assigned_to_name: str | None = None
device_status_at_open: str | None = None
device_status_applied: str | None = None
opened_at: datetime
resolved_at: datetime | None = None
closed_at: datetime | None = None
created_at: datetime
updated_at: datetime
events: list[SupportEventOut] = Field(default_factory=list)
attachments: list[AttachmentOut] = Field(default_factory=list)
class SupportCaseListItem(BaseModel):
id: str
asset_id: str
asset_hostname: str | None = None
asset_display_name: str | None = None
case_number: str
status: str
status_label: str
priority: str
priority_label: str
category: str
category_label: str
title: str
reported_by: str
reported_by_name: str | None = None
opened_at: datetime
resolved_at: datetime | None = None
event_count: int = 0
class PaginatedSupportCases(BaseModel):
items: list[SupportCaseListItem]
total: int
page: int
limit: int
pages: int
class SupportStatsOut(BaseModel):
total: int
active: int
open: int
in_progress: int
waiting: int
resolved: int
closed: int
cancelled: int
critical: int
class SupportCaseCreateIn(BaseModel):
category: Literal[
"power_no_boot",
"boot_os_error",
"hardware_failure",
"component_replacement",
"repair",
"preventive_maintenance",
"software_issue",
"network_connectivity",
"performance",
"data_backup_recovery",
"security_incident",
"user_request",
"decommission",
"other",
]
priority: Literal["low", "normal", "high", "critical"] = "normal"
title: str = Field(..., min_length=3, max_length=200)
symptom: str = Field(..., min_length=5, max_length=8000)
reported_by: str = Field(..., min_length=2, max_length=100)
reported_by_name: str | None = Field(None, max_length=200)
assigned_to: str | None = Field(None, max_length=100)
assigned_to_name: str | None = Field(None, max_length=200)
apply_device_status: str | None = Field(None, max_length=30)
initial_note: str | None = Field(None, max_length=4000)
class SupportCasePatchIn(BaseModel):
status: Literal[
"open",
"in_progress",
"waiting_parts",
"waiting_user",
"resolved",
"closed",
"cancelled",
] | None = None
priority: Literal["low", "normal", "high", "critical"] | None = None
assigned_to: str | None = Field(None, max_length=100)
assigned_to_name: str | None = Field(None, max_length=200)
resolution: str | None = Field(None, max_length=8000)
root_cause: str | None = Field(None, max_length=4000)
apply_device_status: str | None = Field(None, max_length=30)
note: str | None = Field(None, max_length=4000)
class SupportEventCreateIn(BaseModel):
event_type: Literal["note", "action", "parts", "handover", "resolution"] = "note"
summary: str = Field(..., min_length=3, max_length=500)
detail: str | None = Field(None, max_length=8000)
actor: str = Field(..., min_length=2, max_length=100)
actor_display_name: str | None = Field(None, max_length=200)
parts: list[str] | None = None

451
GeoNetAgent/collector/app/services/asset_change_service.py

@ -0,0 +1,451 @@ @@ -0,0 +1,451 @@
"""Diff tracked asset fields and persist audit events (only when values change)."""
from __future__ import annotations
import math
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.db.models import Asset, AssetChangeEvent, AssetSnapshot, AssetUserAssignment, User
from app.schemas.agent_report import AgentReportRequest
from app.schemas.asset_changes import AssetChangeEventOut, PaginatedAssetChanges
TRACKED_FIELDS: dict[str, str] = {
"hostname": "Hostname",
"display_name": "Display name",
"owner": "Owner",
"agent_version": "Agent version",
"primary_mac": "MAC (primary)",
"network": "Network",
"software": "Software",
"device_status": "Device status",
"asset_for": "Asset for (kepemilikan)",
"asset_on": "Asset on (lokasi)",
"notes": "Catatan",
"os": "OS",
"cpu": "CPU",
"ram_gb": "RAM",
"bios": "BIOS",
"gpu": "Display adapter (GPU)",
"physical_disks": "Physical disks",
"volumes": "Volumes (partisi)",
"manufacturer": "Manufacturer",
"model": "Model",
"serial_number": "Serial number",
}
def _norm(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text if text else None
def _fmt_ram(value: Any) -> str | None:
if value is None:
return None
try:
return f"{float(value):g} GB"
except (TypeError, ValueError):
return _norm(value)
def _fmt_gpu(gpu: dict | None) -> str | None:
if not gpu:
return None
name = _norm(gpu.get("name"))
if not name:
return None
vram = gpu.get("dedicated_memory_mb")
if vram:
return f"{name} ({vram} MB)"
return name
def _serialize_network(items: list[dict] | None) -> str | None:
if not items:
return None
lines: list[str] = []
for item in items:
mac = _norm(item.get("mac_address")) or "-"
ip = _norm(item.get("ip_address")) or "-"
name = _norm(item.get("adapter_name")) or "-"
kind = _norm(item.get("adapter_kind")) or "-"
primary = " *" if item.get("is_primary") else ""
lines.append(f"{name} [{kind}] {mac} {ip}{primary}".strip())
return " | ".join(sorted(lines)) if lines else None
def _serialize_volumes(items: list[dict] | None) -> str | None:
if not items:
return None
lines: list[str] = []
for item in items:
letter = _norm(item.get("drive_letter")) or "?"
total = item.get("total_gb")
free = item.get("free_gb")
fs = _norm(item.get("file_system")) or "-"
model = _norm(item.get("model"))
chunk = f"{letter} {total}GB free {free}GB {fs}"
if model:
chunk = f"{chunk} ({model})"
lines.append(chunk)
return " | ".join(sorted(lines)) if lines else None
def _serialize_physical_disks(items: list[dict] | None) -> str | None:
if not items:
return None
lines: list[str] = []
for item in items:
label = _norm(item.get("friendly_name")) or _norm(item.get("model")) or _norm(item.get("device_id")) or "disk"
size = item.get("total_gb")
media = _norm(item.get("media_type")) or "-"
health = _norm(item.get("health_status")) or _norm(item.get("operational_status")) or "-"
serial = _norm(item.get("serial_number"))
chunk = f"{label} {size}GB {media} {health}"
if serial:
chunk = f"{chunk} SN:{serial}"
lines.append(chunk)
return " | ".join(sorted(lines)) if lines else None
def _software_keys(items: list[dict] | None) -> set[str]:
keys: set[str] = set()
if not items:
return keys
for item in items:
name = _norm(item.get("name"))
if not name:
continue
version = _norm(item.get("version")) or ""
keys.add(f"{name}|{version}")
return keys
def _format_software_change(old_keys: set[str], new_keys: set[str]) -> tuple[str | None, str | None, dict[str, Any]]:
if old_keys == new_keys:
return None, None, {}
added = sorted(new_keys - old_keys)
removed = sorted(old_keys - new_keys)
def _pretty(keys: list[str], limit: int = 8) -> list[str]:
out: list[str] = []
for key in keys[:limit]:
name, _, version = key.partition("|")
out.append(f"{name} {version}".strip() if version else name)
return out
old_summary = f"{len(old_keys)} packages" if old_keys else ""
new_summary = f"{len(new_keys)} packages"
notes: list[str] = []
if added:
notes.append(f"+{len(added)}: {', '.join(_pretty(added))}" + ("" if len(added) > 8 else ""))
if removed:
notes.append(f"{len(removed)}: {', '.join(_pretty(removed))}" + ("" if len(removed) > 8 else ""))
if notes:
new_summary = f"{new_summary} ({'; '.join(notes)})"
detail: dict[str, Any] = {}
if added:
detail["added"] = _pretty(added, 50)
if removed:
detail["removed"] = _pretty(removed, 50)
return old_summary, new_summary, detail
def get_primary_owner_label(db: Session, asset_id: uuid.UUID) -> str | None:
row = db.execute(
select(User.username, User.display_name)
.join(AssetUserAssignment, AssetUserAssignment.user_id == User.id)
.where(
AssetUserAssignment.asset_id == asset_id,
AssetUserAssignment.is_primary.is_(True),
AssetUserAssignment.ended_at.is_(None),
)
.limit(1)
).first()
if not row:
return None
username, display_name = row
if display_name:
return f"{display_name} ({username})"
return username
def build_tracked_state_from_report(
report: AgentReportRequest,
asset: Asset,
owner_label: str | None,
) -> dict[str, str | None]:
payload = report.model_dump(mode="json")
system = payload.get("system") or {}
cpu = payload.get("cpu") or {}
gpus = payload.get("gpus") or []
os_parts = [_norm(system.get("os_name")), _norm(system.get("os_version") or system.get("os_build"))]
os_text = " ".join(p for p in os_parts if p) or None
primary_mac = None
if report.asset_identity:
primary_mac = _norm(report.asset_identity.primary_physical_mac)
if not primary_mac and report.network:
for nic in report.network:
if nic.is_primary and nic.mac_address:
primary_mac = _norm(nic.mac_address)
break
gpu_primary = gpus[0] if gpus else None
if not gpu_primary and system.get("gpu_model"):
gpu_primary = {"name": system.get("gpu_model")}
return {
"hostname": _norm(asset.hostname or report.hostname),
"display_name": _norm(asset.display_name),
"owner": _norm(owner_label),
"agent_version": _norm(report.agent_version),
"primary_mac": primary_mac,
"network": _serialize_network([n.model_dump(mode="json") for n in report.network]),
"software": f"{len(report.software)} packages" if report.software else "0 packages",
"device_status": _norm(asset.device_status),
"asset_for": _norm(asset.asset_for),
"asset_on": _norm(asset.asset_on),
"notes": _norm(asset.notes),
"os": os_text,
"cpu": _norm(cpu.get("model") or system.get("cpu_model")),
"ram_gb": _fmt_ram(system.get("ram_gb")),
"bios": _norm(system.get("bios_version")),
"gpu": _fmt_gpu(gpu_primary),
"physical_disks": _serialize_physical_disks(payload.get("physical_disks")),
"volumes": _serialize_volumes([d.model_dump(mode="json") for d in report.disks]),
"manufacturer": _norm(system.get("manufacturer") or asset.manufacturer),
"model": _norm(system.get("model") or asset.model),
"serial_number": _norm(report.serial_number or asset.serial_number),
}
def build_tracked_state_from_snapshot(
snapshot: AssetSnapshot,
asset: Asset,
owner_label: str | None,
) -> dict[str, str | None]:
payload = snapshot.payload or {}
system = payload.get("system") or {}
cpu = payload.get("cpu") or {}
gpus = payload.get("gpus") or []
network = payload.get("network") or []
disks = payload.get("disks") or []
software = payload.get("software") or []
os_parts = [_norm(snapshot.os_name or system.get("os_name")), _norm(snapshot.os_version or system.get("os_version"))]
os_text = " ".join(p for p in os_parts if p) or None
primary_mac = _norm(asset.primary_physical_mac)
if not primary_mac:
identity = payload.get("asset_identity") or {}
primary_mac = _norm(identity.get("primary_physical_mac"))
gpu_primary = gpus[0] if gpus else None
if not gpu_primary and (snapshot.gpu_model or system.get("gpu_model")):
gpu_primary = {"name": snapshot.gpu_model or system.get("gpu_model")}
return {
"hostname": _norm(asset.hostname),
"display_name": _norm(asset.display_name),
"owner": _norm(owner_label),
"agent_version": _norm(snapshot.agent_version),
"primary_mac": primary_mac,
"network": _serialize_network(network if isinstance(network, list) else []),
"software": f"{len(software)} packages" if software else "0 packages",
"device_status": _norm(asset.device_status),
"asset_for": _norm(asset.asset_for),
"asset_on": _norm(asset.asset_on),
"notes": _norm(asset.notes),
"os": os_text,
"cpu": _norm(cpu.get("model") or snapshot.cpu_model or system.get("cpu_model")),
"ram_gb": _fmt_ram(snapshot.ram_gb if snapshot.ram_gb is not None else system.get("ram_gb")),
"bios": _norm(snapshot.bios_version or system.get("bios_version")),
"gpu": _fmt_gpu(gpu_primary if isinstance(gpu_primary, dict) else None),
"physical_disks": _serialize_physical_disks(payload.get("physical_disks")),
"volumes": _serialize_volumes(disks if isinstance(disks, list) else []),
"manufacturer": _norm(asset.manufacturer or system.get("manufacturer")),
"model": _norm(asset.model or system.get("model")),
"serial_number": _norm(asset.serial_number),
}
def build_tracked_state_from_asset(
asset: Asset,
owner_label: str | None,
) -> dict[str, str | None]:
return {
"hostname": _norm(asset.hostname),
"display_name": _norm(asset.display_name),
"owner": _norm(owner_label),
"agent_version": None,
"primary_mac": _norm(asset.primary_physical_mac),
"network": None,
"software": None,
"device_status": _norm(asset.device_status),
"asset_for": _norm(asset.asset_for),
"asset_on": _norm(asset.asset_on),
"notes": _norm(asset.notes),
"os": None,
"cpu": None,
"ram_gb": None,
"bios": None,
"gpu": None,
"physical_disks": None,
"volumes": None,
"manufacturer": _norm(asset.manufacturer),
"model": _norm(asset.model),
"serial_number": _norm(asset.serial_number),
}
def _change_type(old: str | None, new: str | None) -> str:
if not old and new:
return "added"
if old and not new:
return "removed"
return "updated"
def record_state_changes(
db: Session,
*,
asset_id: uuid.UUID,
old_state: dict[str, str | None],
new_state: dict[str, str | None],
source: str,
snapshot_id: uuid.UUID | None = None,
event_at: datetime | None = None,
old_software_keys: set[str] | None = None,
new_software_keys: set[str] | None = None,
) -> list[AssetChangeEvent]:
when = event_at or datetime.now().astimezone()
events: list[AssetChangeEvent] = []
for field_key, field_label in TRACKED_FIELDS.items():
old_val = old_state.get(field_key)
new_val = new_state.get(field_key)
detail: dict[str, Any] = {}
if field_key == "software" and old_software_keys is not None and new_software_keys is not None:
old_fmt, new_fmt, detail = _format_software_change(old_software_keys, new_software_keys)
if old_fmt is None and new_fmt is None:
continue
old_val, new_val = old_fmt, new_fmt
elif old_val == new_val:
continue
events.append(
AssetChangeEvent(
asset_id=asset_id,
snapshot_id=snapshot_id,
event_at=when,
source=source,
field_key=field_key,
field_label=field_label,
change_type=_change_type(old_val, new_val),
old_value=old_val,
new_value=new_val,
detail=detail,
)
)
if events:
db.add_all(events)
return events
def record_agent_report_changes(
db: Session,
*,
asset: Asset,
report: AgentReportRequest,
snapshot: AssetSnapshot,
previous_snapshot: AssetSnapshot | None,
owner_before: str | None = None,
) -> list[AssetChangeEvent]:
if previous_snapshot is None:
return []
owner_after = get_primary_owner_label(db, asset.id)
new_state = build_tracked_state_from_report(report, asset, owner_after)
new_sw = _software_keys([s.model_dump(mode="json") for s in report.software])
old_state = build_tracked_state_from_snapshot(previous_snapshot, asset, owner_before)
old_payload = previous_snapshot.payload or {}
software = old_payload.get("software") or []
old_sw = _software_keys(software if isinstance(software, list) else [])
return record_state_changes(
db,
asset_id=asset.id,
old_state=old_state,
new_state=new_state,
source="agent",
snapshot_id=snapshot.id,
event_at=snapshot.collected_at,
old_software_keys=old_sw,
new_software_keys=new_sw,
)
def record_manual_asset_changes(
db: Session,
*,
asset_id: uuid.UUID,
old_state: dict[str, str | None],
new_state: dict[str, str | None],
) -> list[AssetChangeEvent]:
tracked_keys = {"display_name", "device_status", "asset_for", "asset_on", "notes", "owner"}
return record_state_changes(
db,
asset_id=asset_id,
old_state={k: old_state.get(k) for k in tracked_keys},
new_state={k: new_state.get(k) for k in tracked_keys},
source="manual",
)
def list_asset_changes(
db: Session,
asset_id: uuid.UUID,
*,
page: int = 1,
limit: int = 50,
) -> PaginatedAssetChanges | None:
if not db.scalar(select(Asset.id).where(Asset.id == asset_id, Asset.deleted_at.is_(None))):
return None
base = select(AssetChangeEvent).where(AssetChangeEvent.asset_id == asset_id)
total = db.scalar(select(func.count()).select_from(base.subquery())) or 0
offset = (page - 1) * limit
rows = db.scalars(base.order_by(AssetChangeEvent.event_at.desc()).offset(offset).limit(limit)).all()
items = [
AssetChangeEventOut(
id=str(row.id),
asset_id=str(row.asset_id),
snapshot_id=str(row.snapshot_id) if row.snapshot_id else None,
event_at=row.event_at,
source=row.source,
field_key=row.field_key,
field_label=row.field_label,
change_type=row.change_type,
old_value=row.old_value,
new_value=row.new_value,
detail=row.detail or {},
)
for row in rows
]
pages = max(1, math.ceil(total / limit)) if total else 0
return PaginatedAssetChanges(items=items, total=total, page=page, limit=limit, pages=pages)

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

@ -5,7 +5,7 @@ from datetime import datetime, timedelta, timezone @@ -5,7 +5,7 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import Select, and_, exists, func, or_, select
from sqlalchemy.orm import Session
from app.db.models import Asset, AssetDisk, AssetNetworkAdapter, AssetSnapshot, AssetUserAssignment, DeviceModelCatalog, SoftwareInstallation, User
from app.db.models import Asset, AssetDisk, AssetNetworkAdapter, AssetSnapshot, AssetSupportCase, AssetUserAssignment, DeviceModelCatalog, SoftwareInstallation, User
from app.schemas.assets import (
AssetDetail,
AssetListItem,
@ -31,6 +31,10 @@ from app.schemas.assets import ( @@ -31,6 +31,10 @@ from app.schemas.assets import (
)
from app.services import device_model_catalog_service as catalog_service
from app.services.device_model_catalog_service import catalog_key
from app.services import asset_change_service
from app.schemas.support import SUPPORT_STATUS_LABELS
ACTIVE_SUPPORT_STATUSES = frozenset({"open", "in_progress", "waiting_parts", "waiting_user"})
def _utcnow() -> datetime:
@ -316,6 +320,7 @@ def list_assets( @@ -316,6 +320,7 @@ def list_assets(
[(a.manufacturer, a.model) for a in rows],
)
owners_map = _get_owners_map(db, [a.id for a in rows])
support_map = _get_support_summary_map(db, [a.id for a in rows])
latest = _latest_snapshot_subquery()
items: list[AssetListItem] = []
@ -332,6 +337,7 @@ def list_assets( @@ -332,6 +337,7 @@ def list_assets(
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))
case_count, active_status = support_map.get(asset.id, (0, None))
items.append(
AssetListItem(
id=str(asset.id),
@ -357,6 +363,11 @@ def list_assets( @@ -357,6 +363,11 @@ def list_assets(
owner_ldap_username=owner_username,
owner_display_name=owner_display_name,
agent_version=snap.agent_version if snap else None,
support_case_count=case_count,
active_support_status=active_status,
active_support_status_label=(
SUPPORT_STATUS_LABELS.get(active_status, active_status) if active_status else None
),
)
)
@ -661,6 +672,38 @@ def _get_owners_map( @@ -661,6 +672,38 @@ def _get_owners_map(
return {row.asset_id: (row.username, row.display_name) for row in rows}
def _get_support_summary_map(
db: Session,
asset_ids: list[uuid.UUID],
) -> dict[uuid.UUID, tuple[int, str | None]]:
if not asset_ids:
return {}
count_rows = db.execute(
select(AssetSupportCase.asset_id, func.count())
.where(AssetSupportCase.asset_id.in_(asset_ids))
.group_by(AssetSupportCase.asset_id)
).all()
counts = {row[0]: int(row[1]) for row in count_rows}
active_rows = db.scalars(
select(AssetSupportCase)
.where(
AssetSupportCase.asset_id.in_(asset_ids),
AssetSupportCase.status.in_(ACTIVE_SUPPORT_STATUSES),
)
.order_by(AssetSupportCase.opened_at.desc())
).all()
active_map: dict[uuid.UUID, str] = {}
for case in active_rows:
if case.asset_id not in active_map:
active_map[case.asset_id] = case.status
return {
aid: (counts.get(aid, 0), active_map.get(aid))
for aid in asset_ids
}
def get_asset(db: Session, asset_id: uuid.UUID) -> AssetDetail | None:
asset = db.scalar(select(Asset).where(Asset.id == asset_id, Asset.deleted_at.is_(None)))
if not asset:
@ -709,6 +752,9 @@ def patch_asset_meta(db: Session, asset_id: uuid.UUID, data: AssetMetaIn) -> Ass @@ -709,6 +752,9 @@ def patch_asset_meta(db: Session, asset_id: uuid.UUID, data: AssetMetaIn) -> Ass
if not asset:
return None
owner_before = asset_change_service.get_primary_owner_label(db, asset.id)
old_state = asset_change_service.build_tracked_state_from_asset(asset, owner_before)
if data.device_status is not None:
asset.device_status = data.device_status or None
if data.asset_for is not None:
@ -720,6 +766,15 @@ def patch_asset_meta(db: Session, asset_id: uuid.UUID, data: AssetMetaIn) -> Ass @@ -720,6 +766,15 @@ def patch_asset_meta(db: Session, asset_id: uuid.UUID, data: AssetMetaIn) -> Ass
if data.display_name is not None:
asset.display_name = data.display_name or None
owner_after = asset_change_service.get_primary_owner_label(db, asset.id)
new_state = asset_change_service.build_tracked_state_from_asset(asset, owner_after)
asset_change_service.record_manual_asset_changes(
db,
asset_id=asset.id,
old_state=old_state,
new_state=new_state,
)
db.commit()
return get_asset(db, asset_id)
@ -826,6 +881,9 @@ def set_asset_owner( @@ -826,6 +881,9 @@ def set_asset_owner(
if not asset:
return None
owner_before = asset_change_service.get_primary_owner_label(db, asset.id)
old_state = asset_change_service.build_tracked_state_from_asset(asset, owner_before)
user = db.scalar(select(User).where(User.username == ldap_username))
if not user:
user = User(username=ldap_username, display_name=display_name)
@ -852,6 +910,17 @@ def set_asset_owner( @@ -852,6 +910,17 @@ def set_asset_owner(
is_primary=True,
)
db.add(assignment)
db.flush()
owner_after = asset_change_service.get_primary_owner_label(db, asset.id)
new_state = asset_change_service.build_tracked_state_from_asset(asset, owner_after)
asset_change_service.record_manual_asset_changes(
db,
asset_id=asset.id,
old_state=old_state,
new_state=new_state,
)
db.commit()
db.refresh(assignment)

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

@ -15,6 +15,7 @@ from app.db.models import ( @@ -15,6 +15,7 @@ from app.db.models import (
)
from app.schemas.agent_report import AgentReportRequest, AgentReportResponse
from app.services.asset_identity import allocate_hostname, hardware_identity_compatible
from app.services import asset_change_service
def _parse_install_date(value: date | str | None) -> date | None:
@ -136,6 +137,15 @@ def ingest_agent_report(db: Session, report: AgentReportRequest) -> AgentReportR @@ -136,6 +137,15 @@ def ingest_agent_report(db: Session, report: AgentReportRequest) -> AgentReportR
primary_mac = report.asset_identity.primary_physical_mac if report.asset_identity else None
previous_snapshot = None
if not is_new:
previous_snapshot = db.scalar(
select(AssetSnapshot)
.where(AssetSnapshot.asset_id == asset.id)
.order_by(AssetSnapshot.collected_at.desc())
.limit(1)
)
if is_new:
hostname, display_name = allocate_hostname(db, report)
asset = Asset(
@ -242,9 +252,20 @@ def ingest_agent_report(db: Session, report: AgentReportRequest) -> AgentReportR @@ -242,9 +252,20 @@ def ingest_agent_report(db: Session, report: AgentReportRequest) -> AgentReportR
)
)
owner_before = asset_change_service.get_primary_owner_label(db, asset.id)
if report.logged_in_user:
_assign_user(db, asset, report.logged_in_user)
asset_change_service.record_agent_report_changes(
db,
asset=asset,
report=report,
snapshot=snapshot,
previous_snapshot=previous_snapshot,
owner_before=owner_before,
)
db.commit()
return AgentReportResponse(

106
GeoNetAgent/collector/app/services/document_storage_service.py

@ -0,0 +1,106 @@ @@ -0,0 +1,106 @@
"""Binary storage for document attachments — local dev or QNAP S3 (ADR-003)."""
from __future__ import annotations
import hashlib
import re
import uuid
from pathlib import Path
from app.config import Settings, get_settings
_FILENAME_SAFE = re.compile(r"[^a-zA-Z0-9._-]+")
def sanitize_filename(name: str) -> str:
base = Path(name).name.strip() or "file"
base = _FILENAME_SAFE.sub("_", base)
return base[:200] or "file"
def build_support_object_key(case_id: uuid.UUID, attachment_id: uuid.UUID, filename: str) -> str:
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
safe = sanitize_filename(filename)
return f"support/{now:%Y}/{now:%m}/{case_id}/{attachment_id}/{safe}"
def sha256_hex(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
class DocumentStorage:
def __init__(self, settings: Settings | None = None) -> None:
self.settings = settings or get_settings()
self._s3 = None
@property
def provider(self) -> str:
return "qnap_s3" if self.settings.documents_use_s3() else "local"
@property
def bucket(self) -> str | None:
if self.provider == "qnap_s3":
return self.settings.s3_bucket
return None
def _local_root(self) -> Path:
root = Path(self.settings.documents_local_path).resolve()
root.mkdir(parents=True, exist_ok=True)
return root
def _s3_client(self):
if self._s3 is not None:
return self._s3
try:
import boto3
from botocore.client import Config
except ImportError as exc:
raise RuntimeError("boto3 required for S3 storage — pip install geonetagent-collector[s3]") from exc
self._s3 = boto3.client(
"s3",
endpoint_url=self.settings.s3_endpoint,
aws_access_key_id=self.settings.s3_access_key_id,
aws_secret_access_key=self.settings.s3_secret_access_key,
region_name=self.settings.s3_region,
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
)
return self._s3
def put(self, object_key: str, data: bytes, content_type: str | None) -> None:
if self.provider == "local":
path = self._local_root() / object_key
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
return
client = self._s3_client()
extra = {"ContentType": content_type} if content_type else {}
client.put_object(Bucket=self.settings.s3_bucket, Key=object_key, Body=data, **extra)
def get_bytes(self, object_key: str) -> bytes:
if self.provider == "local":
path = self._local_root() / object_key
return path.read_bytes()
client = self._s3_client()
resp = client.get_object(Bucket=self.settings.s3_bucket, Key=object_key)
return resp["Body"].read()
def delete_object(self, object_key: str) -> None:
if self.provider == "local":
path = self._local_root() / object_key
if path.is_file():
path.unlink()
return
client = self._s3_client()
client.delete_object(Bucket=self.settings.s3_bucket, Key=object_key)
def presigned_get_url(self, object_key: str) -> str | None:
if self.provider == "local":
return None
client = self._s3_client()
return client.generate_presigned_url(
"get_object",
Params={"Bucket": self.settings.s3_bucket, "Key": object_key},
ExpiresIn=self.settings.s3_presign_expires_seconds,
)

202
GeoNetAgent/collector/app/services/support_attachment_service.py

@ -0,0 +1,202 @@ @@ -0,0 +1,202 @@
"""Support case photo/document attachments."""
from __future__ import annotations
import uuid
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.models import AssetSupportCase, DocumentAttachment
from app.schemas.attachments import (
ALLOWED_ATTACHMENT_CONTENT_TYPES,
ALLOWED_ATTACHMENT_EXTENSIONS,
SUPPORT_ATTACHMENT_KIND_LABELS,
AttachmentListOut,
AttachmentOut,
AttachmentUploadForm,
)
from app.services import support_service
from app.services.document_storage_service import (
DocumentStorage,
build_support_object_key,
sanitize_filename,
sha256_hex,
)
from app.config import get_settings
def _is_image(content_type: str | None) -> bool:
return bool(content_type and content_type.startswith("image/"))
def _attachment_download_path(attachment_id: uuid.UUID) -> str:
return f"/api/v1/attachments/{attachment_id}/download"
def _to_out(row: DocumentAttachment) -> AttachmentOut:
ct = row.content_type or ""
return AttachmentOut(
id=str(row.id),
domain=row.domain,
kind=row.kind,
kind_label=SUPPORT_ATTACHMENT_KIND_LABELS.get(row.kind, row.kind),
asset_id=str(row.asset_id),
support_case_id=str(row.support_case_id),
original_filename=row.original_filename,
content_type=row.content_type,
file_size_bytes=row.file_size_bytes,
caption=row.caption,
uploaded_by=row.uploaded_by,
uploaded_by_name=row.uploaded_by_name,
uploaded_at=row.uploaded_at,
download_url=_attachment_download_path(row.id),
is_image=_is_image(ct),
)
def _validate_upload(filename: str, content_type: str | None, size: int) -> None:
settings = get_settings()
if size <= 0:
raise ValueError("File kosong")
if size > settings.documents_max_upload_bytes:
max_mb = settings.documents_max_upload_bytes // (1024 * 1024)
raise ValueError(f"File terlalu besar (maks {max_mb} MB)")
ext = Path(filename).suffix.lower()
if ext not in ALLOWED_ATTACHMENT_EXTENSIONS:
raise ValueError(f"Ekstensi tidak diizinkan: {ext or '(tanpa ekstensi)'}")
ct = (content_type or "").split(";")[0].strip().lower()
if ct and ct not in ALLOWED_ATTACHMENT_CONTENT_TYPES:
raise ValueError(f"Tipe file tidak diizinkan: {ct}")
def list_case_attachments(db: Session, case_id: uuid.UUID) -> AttachmentListOut | None:
if not db.scalar(select(AssetSupportCase.id).where(AssetSupportCase.id == case_id)):
return None
rows = db.scalars(
select(DocumentAttachment)
.where(
DocumentAttachment.support_case_id == case_id,
DocumentAttachment.deleted_at.is_(None),
)
.order_by(DocumentAttachment.uploaded_at.desc())
).all()
items = [_to_out(r) for r in rows]
return AttachmentListOut(items=items, total=len(items))
def upload_support_attachment(
db: Session,
case_id: uuid.UUID,
*,
filename: str,
content_type: str | None,
data: bytes,
form: AttachmentUploadForm,
) -> AttachmentOut | None:
case = db.scalar(select(AssetSupportCase).where(AssetSupportCase.id == case_id))
if not case:
return None
_validate_upload(filename, content_type, len(data))
storage = DocumentStorage()
attachment_id = uuid.uuid4()
safe_name = sanitize_filename(filename)
object_key = build_support_object_key(case_id, attachment_id, safe_name)
checksum = sha256_hex(data)
storage.put(object_key, data, content_type)
row = DocumentAttachment(
id=attachment_id,
domain="support",
kind=form.kind,
asset_id=case.asset_id,
support_case_id=case_id,
storage_provider=storage.provider,
bucket=storage.bucket,
object_key=object_key,
original_filename=safe_name,
content_type=(content_type or "").split(";")[0].strip().lower() or None,
file_size_bytes=len(data),
checksum_sha256=checksum,
uploaded_by=form.actor.strip(),
uploaded_by_name=form.actor_display_name,
caption=form.caption.strip() if form.caption else None,
)
db.add(row)
db.flush()
kind_label = SUPPORT_ATTACHMENT_KIND_LABELS.get(form.kind, form.kind)
support_service.add_support_event_internal(
db,
case,
event_type="action",
summary=f"Lampiran: {kind_label}{safe_name}",
detail=form.caption,
actor=form.actor.strip(),
actor_display_name=form.actor_display_name,
metadata={"attachment_id": str(attachment_id), "kind": form.kind},
)
db.commit()
db.refresh(row)
return _to_out(row)
def get_attachment_for_download(db: Session, attachment_id: uuid.UUID) -> tuple[DocumentAttachment, bytes] | None:
row = db.scalar(
select(DocumentAttachment).where(
DocumentAttachment.id == attachment_id,
DocumentAttachment.deleted_at.is_(None),
)
)
if not row:
return None
storage = DocumentStorage()
if storage.provider == "qnap_s3":
url = storage.presigned_get_url(row.object_key)
if url:
return row, b"" # caller uses redirect
data = storage.get_bytes(row.object_key)
return row, data
def get_presigned_url(db: Session, attachment_id: uuid.UUID) -> str | None:
row = db.scalar(
select(DocumentAttachment).where(
DocumentAttachment.id == attachment_id,
DocumentAttachment.deleted_at.is_(None),
)
)
if not row:
return None
storage = DocumentStorage()
if storage.provider != "qnap_s3":
return None
return storage.presigned_get_url(row.object_key)
def soft_delete_attachment(
db: Session,
attachment_id: uuid.UUID,
*,
actor: str,
) -> bool:
row = db.scalar(
select(DocumentAttachment).where(
DocumentAttachment.id == attachment_id,
DocumentAttachment.deleted_at.is_(None),
)
)
if not row:
return False
from datetime import datetime, timezone
row.deleted_at = datetime.now(timezone.utc)
db.commit()
return True

586
GeoNetAgent/collector/app/services/support_service.py

@ -0,0 +1,586 @@ @@ -0,0 +1,586 @@
"""IT Support troubleshooting cases — SSOT service (ADR-004)."""
from __future__ import annotations
import math
import uuid
from datetime import datetime, timezone
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from app.db.models import Asset, AssetSupportCase, AssetSupportEvent
from app.schemas.assets import DEVICE_STATUS_OPTIONS
from app.schemas.attachments import SUPPORT_ATTACHMENT_KIND, SUPPORT_ATTACHMENT_KIND_LABELS
from app.schemas.support import (
SUPPORT_CATEGORY_LABELS,
SUPPORT_EVENT_TYPE_LABELS,
SUPPORT_PRIORITY_LABELS,
SUPPORT_STATUS_LABELS,
SupportCaseCreateIn,
SupportCaseListItem,
SupportCaseOut,
SupportCasePatchIn,
SupportEventCreateIn,
SupportEventOut,
SupportOptionsOut,
SupportStatsOut,
PaginatedSupportCases,
)
from app.services import asset_change_service
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def _paginate(total: int, page: int, limit: int) -> dict[str, int]:
pages = max(1, math.ceil(total / limit)) if total else 0
return {"total": total, "page": page, "limit": limit, "pages": pages}
def get_support_options() -> SupportOptionsOut:
return SupportOptionsOut(
status=list(SUPPORT_STATUS_LABELS.keys()),
status_labels=SUPPORT_STATUS_LABELS,
priority=list(SUPPORT_PRIORITY_LABELS.keys()),
priority_labels=SUPPORT_PRIORITY_LABELS,
category=list(SUPPORT_CATEGORY_LABELS.keys()),
category_labels=SUPPORT_CATEGORY_LABELS,
event_type=[k for k in SUPPORT_EVENT_TYPE_LABELS if k != "opened"],
event_type_labels=SUPPORT_EVENT_TYPE_LABELS,
device_status=DEVICE_STATUS_OPTIONS,
attachment_kind=list(SUPPORT_ATTACHMENT_KIND),
attachment_kind_labels=SUPPORT_ATTACHMENT_KIND_LABELS,
)
def _next_case_number(db: Session) -> str:
today = _utcnow().strftime("%Y%m%d")
prefix = f"GNA-SUP-{today}-"
count = db.scalar(
select(func.count())
.select_from(AssetSupportCase)
.where(AssetSupportCase.case_number.like(f"{prefix}%"))
) or 0
return f"{prefix}{count + 1:04d}"
def _event_to_out(row: AssetSupportEvent) -> SupportEventOut:
return SupportEventOut(
id=str(row.id),
case_id=str(row.case_id),
event_type=row.event_type,
event_type_label=SUPPORT_EVENT_TYPE_LABELS.get(row.event_type, row.event_type),
summary=row.summary,
detail=row.detail,
old_status=row.old_status,
new_status=row.new_status,
metadata=row.metadata_ or {},
actor=row.actor,
actor_display_name=row.actor_display_name,
created_at=row.created_at,
)
def _case_to_out(
case: AssetSupportCase,
events: list[AssetSupportEvent] | None = None,
attachments: list | None = None,
*,
asset_hostname: str | None = None,
asset_display_name: str | None = None,
) -> SupportCaseOut:
return SupportCaseOut(
id=str(case.id),
asset_id=str(case.asset_id),
asset_hostname=asset_hostname,
asset_display_name=asset_display_name,
case_number=case.case_number,
status=case.status,
status_label=SUPPORT_STATUS_LABELS.get(case.status, case.status),
priority=case.priority,
priority_label=SUPPORT_PRIORITY_LABELS.get(case.priority, case.priority),
category=case.category,
category_label=SUPPORT_CATEGORY_LABELS.get(case.category, case.category),
title=case.title,
symptom=case.symptom,
resolution=case.resolution,
root_cause=case.root_cause,
reported_by=case.reported_by,
reported_by_name=case.reported_by_name,
assigned_to=case.assigned_to,
assigned_to_name=case.assigned_to_name,
device_status_at_open=case.device_status_at_open,
device_status_applied=case.device_status_applied,
opened_at=case.opened_at,
resolved_at=case.resolved_at,
closed_at=case.closed_at,
created_at=case.created_at,
updated_at=case.updated_at,
events=[_event_to_out(e) for e in (events or [])],
attachments=attachments or [],
)
def _apply_device_status(
db: Session,
asset: Asset,
new_status: str | None,
*,
actor: str,
) -> None:
if not new_status or new_status == asset.device_status:
return
if new_status not in DEVICE_STATUS_OPTIONS:
raise ValueError(f"Invalid device_status: {new_status}")
owner = asset_change_service.get_primary_owner_label(db, asset.id)
old_state = asset_change_service.build_tracked_state_from_asset(asset, owner)
asset.device_status = new_status
owner_after = asset_change_service.get_primary_owner_label(db, asset.id)
new_state = asset_change_service.build_tracked_state_from_asset(asset, owner_after)
events = asset_change_service.record_state_changes(
db,
asset_id=asset.id,
old_state={"device_status": old_state.get("device_status")},
new_state={"device_status": new_state.get("device_status")},
source="support",
)
for ev in events:
ev.detail = {**(ev.detail or {}), "actor": actor}
def _append_event(
db: Session,
case: AssetSupportCase,
*,
event_type: str,
summary: str,
detail: str | None,
actor: str,
actor_display_name: str | None,
old_status: str | None = None,
new_status: str | None = None,
metadata: dict | None = None,
) -> AssetSupportEvent:
row = AssetSupportEvent(
case_id=case.id,
event_type=event_type,
summary=summary,
detail=detail,
old_status=old_status,
new_status=new_status,
metadata_=metadata or {},
actor=actor,
actor_display_name=actor_display_name,
)
db.add(row)
return row
def add_support_event_internal(
db: Session,
case: AssetSupportCase,
*,
event_type: str,
summary: str,
detail: str | None = None,
actor: str,
actor_display_name: str | None = None,
metadata: dict | None = None,
) -> AssetSupportEvent:
"""Append timeline row without commit (for use inside larger transactions)."""
return _append_event(
db,
case,
event_type=event_type,
summary=summary,
detail=detail,
actor=actor,
actor_display_name=actor_display_name,
metadata=metadata,
)
def _touch_status_timestamps(case: AssetSupportCase, new_status: str) -> None:
now = _utcnow()
if new_status == "resolved" and not case.resolved_at:
case.resolved_at = now
if new_status in {"closed", "cancelled"} and not case.closed_at:
case.closed_at = now
def create_support_case(
db: Session,
asset_id: uuid.UUID,
data: SupportCaseCreateIn,
) -> SupportCaseOut | None:
asset = db.scalar(select(Asset).where(Asset.id == asset_id, Asset.deleted_at.is_(None)))
if not asset:
return None
case = AssetSupportCase(
asset_id=asset.id,
case_number=_next_case_number(db),
status="open",
priority=data.priority,
category=data.category,
title=data.title.strip(),
symptom=data.symptom.strip(),
reported_by=data.reported_by.strip(),
reported_by_name=data.reported_by_name.strip() if data.reported_by_name else None,
assigned_to=data.assigned_to.strip() if data.assigned_to else None,
assigned_to_name=data.assigned_to_name.strip() if data.assigned_to_name else None,
device_status_at_open=asset.device_status,
)
db.add(case)
db.flush()
_append_event(
db,
case,
event_type="opened",
summary=f"Kasus dibuka — {SUPPORT_CATEGORY_LABELS.get(data.category, data.category)}",
detail=data.symptom.strip(),
actor=data.reported_by.strip(),
actor_display_name=data.reported_by_name,
new_status="open",
)
if data.initial_note:
_append_event(
db,
case,
event_type="note",
summary=data.initial_note.strip()[:500],
detail=data.initial_note.strip(),
actor=data.reported_by.strip(),
actor_display_name=data.reported_by_name,
)
if data.apply_device_status:
_apply_device_status(db, asset, data.apply_device_status, actor=data.reported_by.strip())
case.device_status_applied = data.apply_device_status
db.commit()
db.refresh(case)
return get_support_case(db, case.id, include_events=True)
def patch_support_case(
db: Session,
case_id: uuid.UUID,
data: SupportCasePatchIn,
*,
actor: str,
actor_display_name: str | None = None,
) -> SupportCaseOut | None:
case = db.scalar(select(AssetSupportCase).where(AssetSupportCase.id == case_id))
if not case:
return None
asset = db.scalar(select(Asset).where(Asset.id == case.asset_id, Asset.deleted_at.is_(None)))
if not asset:
return None
old_status = case.status
if data.priority is not None:
case.priority = data.priority
if data.assigned_to is not None:
case.assigned_to = data.assigned_to or None
if data.assigned_to_name is not None:
case.assigned_to_name = data.assigned_to_name or None
if data.resolution is not None:
case.resolution = data.resolution or None
if data.root_cause is not None:
case.root_cause = data.root_cause or None
if data.status is not None and data.status != case.status:
case.status = data.status
_touch_status_timestamps(case, data.status)
_append_event(
db,
case,
event_type="status_change",
summary=f"Status: {SUPPORT_STATUS_LABELS.get(old_status, old_status)}{SUPPORT_STATUS_LABELS.get(data.status, data.status)}",
detail=data.note,
actor=actor,
actor_display_name=actor_display_name,
old_status=old_status,
new_status=data.status,
)
elif data.note:
_append_event(
db,
case,
event_type="note",
summary=data.note.strip()[:500],
detail=data.note.strip(),
actor=actor,
actor_display_name=actor_display_name,
)
if data.resolution and case.status in {"open", "in_progress", "waiting_parts", "waiting_user"}:
case.status = "resolved"
_touch_status_timestamps(case, "resolved")
_append_event(
db,
case,
event_type="resolution",
summary="Resolusi dicatat",
detail=data.resolution,
actor=actor,
actor_display_name=actor_display_name,
new_status="resolved",
)
if data.apply_device_status:
_apply_device_status(db, asset, data.apply_device_status, actor=actor)
case.device_status_applied = data.apply_device_status
db.commit()
return get_support_case(db, case.id, include_events=True)
def add_support_event(
db: Session,
case_id: uuid.UUID,
data: SupportEventCreateIn,
) -> SupportEventOut | None:
case = db.scalar(select(AssetSupportCase).where(AssetSupportCase.id == case_id))
if not case:
return None
metadata = {}
if data.parts:
metadata["parts"] = data.parts
row = _append_event(
db,
case,
event_type=data.event_type,
summary=data.summary.strip(),
detail=data.detail.strip() if data.detail else None,
actor=data.actor.strip(),
actor_display_name=data.actor_display_name,
metadata=metadata,
)
db.commit()
db.refresh(row)
return _event_to_out(row)
def get_support_case(
db: Session,
case_id: uuid.UUID,
*,
include_events: bool = True,
) -> SupportCaseOut | None:
case = db.scalar(select(AssetSupportCase).where(AssetSupportCase.id == case_id))
if not case:
return None
events = []
if include_events:
events = list(
db.scalars(
select(AssetSupportEvent)
.where(AssetSupportEvent.case_id == case_id)
.order_by(AssetSupportEvent.created_at.asc())
).all()
)
from app.services import support_attachment_service
att = support_attachment_service.list_case_attachments(db, case_id)
asset = db.scalar(select(Asset).where(Asset.id == case.asset_id))
return _case_to_out(
case,
events,
att.items if att else [],
asset_hostname=asset.hostname if asset else None,
asset_display_name=asset.display_name if asset else None,
)
def _case_list_item(
case: AssetSupportCase,
*,
event_count: int,
asset_hostname: str | None = None,
asset_display_name: str | None = None,
) -> SupportCaseListItem:
return SupportCaseListItem(
id=str(case.id),
asset_id=str(case.asset_id),
asset_hostname=asset_hostname,
asset_display_name=asset_display_name,
case_number=case.case_number,
status=case.status,
status_label=SUPPORT_STATUS_LABELS.get(case.status, case.status),
priority=case.priority,
priority_label=SUPPORT_PRIORITY_LABELS.get(case.priority, case.priority),
category=case.category,
category_label=SUPPORT_CATEGORY_LABELS.get(case.category, case.category),
title=case.title,
reported_by=case.reported_by,
reported_by_name=case.reported_by_name,
opened_at=case.opened_at,
resolved_at=case.resolved_at,
event_count=event_count,
)
def get_support_stats(db: Session) -> SupportStatsOut:
rows = db.execute(
select(AssetSupportCase.status, AssetSupportCase.priority, func.count())
.group_by(AssetSupportCase.status, AssetSupportCase.priority)
).all()
total = 0
active = 0
open_c = 0
in_progress = 0
waiting = 0
resolved = 0
closed_c = 0
cancelled_c = 0
critical = 0
closed_statuses = {"closed", "cancelled"}
for status, priority, cnt in rows:
n = int(cnt)
total += n
if status not in closed_statuses:
active += n
if status == "open":
open_c += n
elif status == "in_progress":
in_progress += n
elif status in {"waiting_parts", "waiting_user"}:
waiting += n
elif status == "resolved":
resolved += n
elif status == "closed":
closed_c += n
elif status == "cancelled":
cancelled_c += n
if priority == "critical" and status not in closed_statuses:
critical += n
return SupportStatsOut(
total=total,
active=active,
open=open_c,
in_progress=in_progress,
waiting=waiting,
resolved=resolved,
closed=closed_c,
cancelled=cancelled_c,
critical=critical,
)
def list_support_cases(
db: Session,
*,
page: int = 1,
limit: int = 20,
status: str | None = None,
priority: str | None = None,
category: str | None = None,
active_only: bool = False,
q: str | None = None,
) -> PaginatedSupportCases:
base = (
select(AssetSupportCase, Asset.hostname, Asset.display_name)
.join(Asset, Asset.id == AssetSupportCase.asset_id)
.where(Asset.deleted_at.is_(None))
)
if status:
base = base.where(AssetSupportCase.status == status)
if priority:
base = base.where(AssetSupportCase.priority == priority)
if category:
base = base.where(AssetSupportCase.category == category)
if active_only:
base = base.where(AssetSupportCase.status.not_in(["closed", "cancelled"]))
if q and q.strip():
pattern = f"%{q.strip()}%"
base = base.where(
or_(
AssetSupportCase.case_number.ilike(pattern),
AssetSupportCase.title.ilike(pattern),
AssetSupportCase.symptom.ilike(pattern),
AssetSupportCase.reported_by.ilike(pattern),
AssetSupportCase.reported_by_name.ilike(pattern),
Asset.hostname.ilike(pattern),
Asset.display_name.ilike(pattern),
)
)
count_q = select(func.count()).select_from(base.subquery())
total = db.scalar(count_q) or 0
offset = (page - 1) * limit
rows = db.execute(
base.order_by(AssetSupportCase.opened_at.desc()).offset(offset).limit(limit)
).all()
items: list[SupportCaseListItem] = []
for case, hostname, display_name in rows:
event_count = db.scalar(
select(func.count())
.select_from(AssetSupportEvent)
.where(AssetSupportEvent.case_id == case.id)
) or 0
items.append(
_case_list_item(
case,
event_count=int(event_count),
asset_hostname=hostname,
asset_display_name=display_name,
)
)
return PaginatedSupportCases(items=items, **_paginate(total, page, limit))
def list_asset_support_cases(
db: Session,
asset_id: uuid.UUID,
*,
page: int = 1,
limit: int = 20,
status: str | None = None,
) -> PaginatedSupportCases | None:
if not db.scalar(select(Asset.id).where(Asset.id == asset_id, Asset.deleted_at.is_(None))):
return None
base = select(AssetSupportCase).where(AssetSupportCase.asset_id == asset_id)
if status:
base = base.where(AssetSupportCase.status == status)
total = db.scalar(select(func.count()).select_from(base.subquery())) or 0
offset = (page - 1) * limit
rows = db.scalars(base.order_by(AssetSupportCase.opened_at.desc()).offset(offset).limit(limit)).all()
asset = db.scalar(select(Asset).where(Asset.id == asset_id, Asset.deleted_at.is_(None)))
hostname = asset.hostname if asset else None
display_name = asset.display_name if asset else None
items: list[SupportCaseListItem] = []
for case in rows:
event_count = db.scalar(
select(func.count())
.select_from(AssetSupportEvent)
.where(AssetSupportEvent.case_id == case.id)
) or 0
items.append(
_case_list_item(
case,
event_count=int(event_count),
asset_hostname=hostname,
asset_display_name=display_name,
)
)
meta = _paginate(total, page, limit)
return PaginatedSupportCases(items=items, **meta)

4
GeoNetAgent/collector/pyproject.toml

@ -12,9 +12,13 @@ dependencies = [ @@ -12,9 +12,13 @@ dependencies = [
"pydantic>=2.10.0",
"pydantic-settings>=2.6.0",
"ldap3>=2.9.1",
"python-multipart>=0.0.12",
]
[project.optional-dependencies]
s3 = [
"boto3>=1.35.0",
]
dev = [
"httpx>=0.28.0",
"pytest>=8.3.0",

404
GeoNetAgent/collector/static/app.js

@ -12,6 +12,7 @@ const state = { @@ -12,6 +12,7 @@ const state = {
selectedId: null,
searchTimer: null,
options: { device_status: [], asset_for: [], asset_on: [] },
supportOptions: null,
ldapSearchTimer: null,
};
@ -123,6 +124,400 @@ async function apiMutate(method, path, body) { @@ -123,6 +124,400 @@ async function apiMutate(method, path, body) {
return data;
}
async function apiUpload(path, formData) {
const url = `${state.baseUrl.replace(/\/$/, "")}${path}`;
const res = await fetch(url, {
method: "POST",
headers: { Authorization: `Bearer ${state.token}` },
body: formData,
});
const text = await res.text();
let data;
try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; }
if (!res.ok) {
const msg = data?.detail?.error?.message || data?.detail || res.statusText;
throw new Error(typeof msg === "string" ? msg : JSON.stringify(msg));
}
return data;
}
async function authFetchBlob(path) {
const url = `${state.baseUrl.replace(/\/$/, "")}${path}`;
const res = await fetch(url, { headers: { Authorization: `Bearer ${state.token}` } });
if (!res.ok) throw new Error(`Gagal memuat file (${res.status})`);
return res.blob();
}
const attachmentBlobCache = new Map();
async function loadAttachmentPreview(attachment) {
const key = attachment.id;
if (attachmentBlobCache.has(key)) return attachmentBlobCache.get(key);
const blob = await authFetchBlob(attachment.download_url);
const url = URL.createObjectURL(blob);
attachmentBlobCache.set(key, url);
return url;
}
function renderAttachmentGallery(attachments) {
if (!attachments?.length) return `<p class="muted small">Belum ada foto/lampiran.</p>`;
return `<div class="support-attachments-gallery">${attachments
.map(
(a) => `<div class="support-attachment-card" data-attachment-id="${escapeHtml(a.id)}">
<div class="support-attachment-preview" data-preview-for="${escapeHtml(a.id)}">
${a.is_image ? `<span class="muted small">Memuat…</span>` : `<span class="badge info">PDF</span>`}
</div>
<div class="support-attachment-meta">
<span class="badge info">${escapeHtml(a.kind_label || a.kind)}</span>
<div class="small">${escapeHtml(a.original_filename)}</div>
${a.caption ? `<div class="muted small">${escapeHtml(a.caption)}</div>` : ""}
<div class="muted small">${escapeHtml(fmtDate(a.uploaded_at))} · ${escapeHtml(a.uploaded_by)}</div>
<a href="#" class="small att-open-link" data-att-path="${escapeHtml(a.download_url)}">Buka / unduh</a>
</div>
</div>`
)
.join("")}</div>`;
}
async function hydrateAttachmentPreviews(container) {
if (!container) return;
const cards = container.querySelectorAll(".support-attachment-card");
for (const card of cards) {
const id = card.dataset.attachmentId;
const att = state._caseAttachments?.find((x) => x.id === id);
if (!att) continue;
const preview = card.querySelector(`[data-preview-for="${id}"]`);
const link = card.querySelector(".att-open-link");
try {
const blobUrl = await loadAttachmentPreview(att);
if (att.is_image && preview) {
preview.innerHTML = `<img src="${blobUrl}" alt="${escapeHtml(att.original_filename)}" loading="lazy" />`;
}
if (link) {
link.addEventListener("click", (e) => {
e.preventDefault();
window.open(blobUrl, "_blank");
});
}
} catch (err) {
if (preview) preview.innerHTML = `<span class="error small">${escapeHtml(err.message)}</span>`;
}
}
}
async function loadSupportOptions() {
try {
state.supportOptions = await api("/api/v1/support/options");
} catch {
state.supportOptions = null;
}
}
const SUPPORT_STATUS_COLORS = {
open: "warn",
in_progress: "info",
waiting_parts: "warn",
waiting_user: "warn",
resolved: "ok",
closed: "muted",
cancelled: "muted",
};
const SUPPORT_PRIORITY_COLORS = {
low: "muted",
normal: "info",
high: "warn",
critical: "danger",
};
function buildSupportSelect(id, options, labels, current, placeholder = "— pilih —") {
let html = `<select id="${id}">`;
html += `<option value="">${placeholder}</option>`;
for (const opt of options || []) {
const sel = opt === current ? " selected" : "";
const label = labels?.[opt] || opt;
html += `<option value="${escapeHtml(opt)}"${sel}>${escapeHtml(label)}</option>`;
}
html += `</select>`;
return html;
}
function renderSupportTimeline(events) {
if (!events?.length) return `<p class="muted small">Belum ada aktivitas.</p>`;
return `<ul class="support-timeline">${events
.map((ev) => {
const meta = ev.metadata?.parts?.length
? `<div class="muted small">Parts: ${escapeHtml(ev.metadata.parts.join(", "))}</div>`
: "";
return `<li class="support-event">
<div class="support-event-head">
<span class="badge info">${escapeHtml(ev.event_type_label || ev.event_type)}</span>
<span class="muted small">${escapeHtml(fmtDate(ev.created_at))}</span>
</div>
<div><strong>${escapeHtml(ev.summary)}</strong></div>
${ev.detail ? `<div class="muted small">${escapeHtml(ev.detail)}</div>` : ""}
${ev.old_status && ev.new_status ? `<div class="muted small">${escapeHtml(ev.old_status)}${escapeHtml(ev.new_status)}</div>` : ""}
<div class="muted small">oleh ${escapeHtml(ev.actor_display_name || ev.actor)}</div>
${meta}
</li>`;
})
.join("")}</ul>`;
}
async function renderSupportSection(asset) {
const opts = state.supportOptions;
if (!opts) return `<div class="section-title">IT Support</div><p class="muted">Opsi support belum dimuat.</p>`;
let casesData;
try {
casesData = await api(`/api/v1/assets/${asset.id}/support-cases?limit=10`);
} catch (err) {
return `<div class="section-title">IT Support</div><p class="error">${escapeHtml(err.message)}</p>`;
}
const openCases = casesData.items?.filter((c) => !["closed", "cancelled"].includes(c.status)) || [];
let html = `<div class="support-panel" id="support-panel">
<div class="section-title">IT Support &amp; Troubleshooting</div>
<p class="muted small">SSOT riwayat kendala &amp; tindakan teknisi per device. Setiap kasus punya nomor tiket dan timeline.</p>
<details class="support-form-block" open>
<summary>Buka kasus baru</summary>
<form id="support-create-form" class="support-form">
<div class="support-form-grid">
<div class="field">
<label for="sup-category">Kategori kendala</label>
${buildSupportSelect("sup-category", opts.category, opts.category_labels)}
</div>
<div class="field">
<label for="sup-priority">Prioritas</label>
${buildSupportSelect("sup-priority", opts.priority, opts.priority_labels, "normal")}
</div>
<div class="field">
<label for="sup-reported-by">Teknisi (username)</label>
<input id="sup-reported-by" required placeholder="rbsetiawan" />
</div>
<div class="field">
<label for="sup-reported-name">Nama teknisi</label>
<input id="sup-reported-name" placeholder="Nama lengkap (opsional)" />
</div>
<div class="field" style="grid-column: 1 / -1">
<label for="sup-title">Judul singkat</label>
<input id="sup-title" required placeholder="PC mati total setelah update Windows" maxlength="200" />
</div>
<div class="field" style="grid-column: 1 / -1">
<label for="sup-symptom">Gejala / deskripsi kendala</label>
<textarea id="sup-symptom" rows="3" required placeholder="User melaporkan ..."></textarea>
</div>
<div class="field">
<label for="sup-device-status">Update device status (opsional)</label>
${buildSupportSelect("sup-device-status", opts.device_status, null, asset.device_status, "— tidak ubah —")}
</div>
<div class="field" style="grid-column: 1 / -1">
<label for="sup-initial-note">Catatan awal (opsional)</label>
<textarea id="sup-initial-note" rows="2" placeholder="Tindakan sementara, sparepart yang dibutuhkan..."></textarea>
</div>
</div>
<button type="submit">Buka kasus</button>
<span id="support-create-status" class="edit-status"></span>
</form>
</details>
<div class="support-cases-list">
<div class="section-subtitle">Riwayat kasus (${casesData.total})${openCases.length ? ` · ${openCases.length} aktif` : ""}</div>`;
if (!casesData.items?.length) {
html += `<p class="muted">Belum ada kasus support untuk device ini.</p>`;
} else {
html += casesData.items
.map((c) => {
const stClass = SUPPORT_STATUS_COLORS[c.status] || "muted";
const prClass = SUPPORT_PRIORITY_COLORS[c.priority] || "muted";
return `<details class="support-case-card" data-case-id="${escapeHtml(c.id)}">
<summary>
<code>${escapeHtml(c.case_number)}</code>
<span class="badge ${stClass}">${escapeHtml(c.status_label || c.status)}</span>
<span class="badge ${prClass}">${escapeHtml(c.priority_label || c.priority)}</span>
<strong>${escapeHtml(c.title)}</strong>
<span class="muted small">${escapeHtml(fmtDate(c.opened_at))}</span>
</summary>
<div class="support-case-body loading">Memuat timeline...</div>
</details>`;
})
.join("");
}
html += `</div></div>`;
return html;
}
function bindSupportPanel(asset) {
const form = document.getElementById("support-create-form");
const statusEl = document.getElementById("support-create-status");
if (form) {
form.addEventListener("submit", async (e) => {
e.preventDefault();
const btn = form.querySelector('button[type="submit"]');
btn.disabled = true;
statusEl.textContent = "Menyimpan...";
statusEl.className = "edit-status";
try {
const ds = document.getElementById("sup-device-status")?.value || null;
await apiMutate("POST", `/api/v1/assets/${asset.id}/support-cases`, {
category: document.getElementById("sup-category").value,
priority: document.getElementById("sup-priority").value || "normal",
title: document.getElementById("sup-title").value.trim(),
symptom: document.getElementById("sup-symptom").value.trim(),
reported_by: document.getElementById("sup-reported-by").value.trim(),
reported_by_name: document.getElementById("sup-reported-name").value.trim() || null,
apply_device_status: ds || null,
initial_note: document.getElementById("sup-initial-note").value.trim() || null,
});
statusEl.textContent = "Kasus dibuka.";
statusEl.className = "edit-status ok";
form.reset();
await openDetail(asset.id);
} catch (err) {
statusEl.textContent = err.message;
statusEl.className = "edit-status error";
} finally {
btn.disabled = false;
}
});
}
document.querySelectorAll(".support-case-card").forEach((card) => {
card.addEventListener("toggle", async () => {
if (!card.open) return;
const body = card.querySelector(".support-case-body");
if (!body || body.dataset.loaded === "1") return;
const caseId = card.dataset.caseId;
body.className = "support-case-body loading";
body.textContent = "Memuat timeline...";
try {
const detail = await api(`/api/v1/support-cases/${caseId}`);
const opts = state.supportOptions;
state._caseAttachments = detail.attachments || [];
body.className = "support-case-body";
body.innerHTML = `
<dl class="detail-grid compact">
<dt>Kategori</dt><dd>${escapeHtml(detail.category_label)}</dd>
<dt>Dilaporkan</dt><dd>${escapeHtml(detail.reported_by_name || detail.reported_by)}</dd>
<dt>Gejala</dt><dd>${escapeHtml(detail.symptom)}</dd>
${detail.resolution ? `<dt>Resolusi</dt><dd>${escapeHtml(detail.resolution)}</dd>` : ""}
${detail.root_cause ? `<dt>Root cause</dt><dd>${escapeHtml(detail.root_cause)}</dd>` : ""}
</dl>
<div class="section-subtitle">Foto &amp; lampiran</div>
${renderAttachmentGallery(detail.attachments)}
<form class="support-upload-form" data-case-id="${escapeHtml(caseId)}">
<div class="support-form-grid">
<div class="field">
<label>Jenis lampiran</label>
${buildSupportSelect(`sup-att-kind-${caseId}`, opts?.attachment_kind, opts?.attachment_kind_labels, "service_photo", "service_photo")}
</div>
<div class="field">
<label>Teknisi</label>
<input class="sup-att-actor" value="${escapeHtml(detail.reported_by)}" required />
</div>
<div class="field" style="grid-column: 1 / -1">
<label>File (foto/PDF, maks 15 MB)</label>
<input class="sup-att-file" type="file" accept="image/jpeg,image/png,image/webp,image/gif,application/pdf" capture="environment" required />
</div>
<div class="field" style="grid-column: 1 / -1">
<label>Keterangan (opsional)</label>
<input class="sup-att-caption" placeholder="Mis. kondisi PSU terbakar" />
</div>
</div>
<button type="submit" class="secondary small-btn">Unggah lampiran</button>
<span class="edit-status sup-upload-status"></span>
</form>
<div class="section-subtitle">Timeline</div>
${renderSupportTimeline(detail.events)}
<form class="support-update-form" data-case-id="${escapeHtml(caseId)}">
<div class="support-form-grid">
<div class="field">
<label>Status</label>
${buildSupportSelect(`sup-status-${caseId}`, opts?.status, opts?.status_labels, detail.status)}
</div>
<div class="field">
<label>Teknisi</label>
<input class="sup-actor" value="${escapeHtml(detail.reported_by)}" required />
</div>
<div class="field" style="grid-column: 1 / -1">
<label>Catatan / update</label>
<textarea class="sup-note" rows="2" placeholder="Tindakan hari ini..."></textarea>
</div>
</div>
<button type="submit" class="secondary small-btn">Update kasus</button>
<span class="edit-status sup-update-status"></span>
</form>`;
body.dataset.loaded = "1";
await hydrateAttachmentPreviews(body);
body.querySelector(".support-upload-form")?.addEventListener("submit", async (ev) => {
ev.preventDefault();
const f = ev.target;
const fileInput = f.querySelector(".sup-att-file");
const st = f.querySelector(".sup-upload-status");
const file = fileInput?.files?.[0];
if (!file) return;
const fd = new FormData();
fd.append("file", file);
fd.append("kind", f.querySelector(`#sup-att-kind-${caseId}`)?.value || "service_photo");
fd.append("actor", f.querySelector(".sup-att-actor").value.trim());
const cap = f.querySelector(".sup-att-caption")?.value?.trim();
if (cap) fd.append("caption", cap);
const btn = f.querySelector('button[type="submit"]');
btn.disabled = true;
st.textContent = "Mengunggah...";
st.className = "edit-status sup-upload-status";
try {
await apiUpload(`/api/v1/support-cases/${caseId}/attachments`, fd);
st.textContent = "Tersimpan.";
st.className = "edit-status ok sup-upload-status";
body.dataset.loaded = "0";
card.dispatchEvent(new Event("toggle"));
} catch (err) {
st.textContent = err.message;
st.className = "edit-status error sup-upload-status";
} finally {
btn.disabled = false;
}
});
body.querySelector(".support-update-form")?.addEventListener("submit", async (ev) => {
ev.preventDefault();
const f = ev.target;
const actor = f.querySelector(".sup-actor").value.trim();
const note = f.querySelector(".sup-note").value.trim();
const newStatus = f.querySelector(`#sup-status-${caseId}`)?.value;
const st = f.querySelector(".sup-update-status");
try {
const params = new URLSearchParams({ actor });
await apiMutate(
"PATCH",
`/api/v1/support-cases/${caseId}?${params.toString()}`,
{ status: newStatus || undefined, note: note || undefined }
);
st.textContent = "Tersimpan.";
st.className = "edit-status ok sup-update-status";
card.dataset.loaded = "";
body.dataset.loaded = "0";
body.className = "support-case-body loading";
body.textContent = "Memuat ulang...";
card.open = true;
card.dispatchEvent(new Event("toggle"));
} catch (err) {
st.textContent = err.message;
st.className = "edit-status error sup-update-status";
}
});
} catch (err) {
body.className = "support-case-body error";
body.textContent = err.message;
}
});
});
}
async function loadOptions() {
try {
state.options = await api("/api/v1/assets/options");
@ -768,7 +1163,7 @@ function renderPeripheralsSection(peripherals) { @@ -768,7 +1163,7 @@ function renderPeripheralsSection(peripherals) {
return html;
}
function renderDetail(asset, software) {
function renderDetail(asset, software, supportHtml = "") {
const snap = asset.latest_snapshot;
const formFactor =
snap?.system?.form_factor || asset.metadata?.form_factor || null;
@ -834,6 +1229,7 @@ function renderDetail(asset, software) { @@ -834,6 +1229,7 @@ function renderDetail(asset, software) {
}
html += renderEditPanel(asset);
html += supportHtml;
if (snap) {
html += `<div class="section-title">Snapshot terbaru — ${escapeHtml(fmtDate(snap.collected_at))}</div>`;
@ -884,6 +1280,7 @@ function renderDetail(asset, software) { @@ -884,6 +1280,7 @@ function renderDetail(asset, software) {
el.detailContent.innerHTML = html;
bindEditPanel(asset);
bindQuickSeriesAdd(asset);
bindSupportPanel(asset);
}
function bindQuickSeriesAdd(asset) {
@ -1026,9 +1423,10 @@ async function openDetail(assetId) { @@ -1026,9 +1423,10 @@ async function openDetail(assetId) {
api(`/api/v1/assets/${assetId}`),
api(`/api/v1/assets/${assetId}/software?limit=50`),
]);
const supportHtml = await renderSupportSection(asset);
el.detailContent.className = "detail-body";
try {
renderDetail(asset, software);
renderDetail(asset, software, supportHtml);
} catch (renderErr) {
el.detailContent.className = "detail-body error";
el.detailContent.textContent = `Gagal render detail: ${renderErr.message}`;
@ -1061,7 +1459,7 @@ async function connect() { @@ -1061,7 +1459,7 @@ async function connect() {
showConnectedUi(true);
try {
await Promise.all([loadStats(), loadAssets(), loadOptions(), loadCatalog()]);
await Promise.all([loadStats(), loadAssets(), loadOptions(), loadSupportOptions(), loadCatalog()]);
} catch (err) {
showConnectedUi(false);
showError(err);

4
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=20260711" />
<link rel="stylesheet" href="/ui/style.css?v=20260712b" />
</head>
<body>
<header>
@ -154,6 +154,6 @@ @@ -154,6 +154,6 @@
</section>
</main>
<script src="/ui/app.js?v=20260711"></script>
<script src="/ui/app.js?v=20260712b"></script>
</body>
</html>

157
GeoNetAgent/collector/static/style.css

@ -597,3 +597,160 @@ code.agent-ver { @@ -597,3 +597,160 @@ code.agent-ver {
#list-content table th.sort-active {
color: #93c5fd;
}
/* IT Support troubleshooting panel */
.support-panel {
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
.support-form-block {
margin: 0.75rem 0 1rem;
padding: 0.75rem 1rem;
border: 1px dashed var(--border);
border-radius: 8px;
background: rgba(59, 130, 246, 0.04);
}
.support-form-block summary {
cursor: pointer;
font-weight: 600;
margin-bottom: 0.5rem;
}
.support-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.support-form textarea {
min-height: 4rem;
resize: vertical;
}
.support-cases-list {
margin-top: 1rem;
}
.support-case-card {
margin-bottom: 0.5rem;
border: 1px solid var(--border);
border-radius: 8px;
background: rgba(15, 23, 42, 0.35);
}
.support-case-card summary {
cursor: pointer;
padding: 0.6rem 0.75rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem 0.6rem;
}
.support-case-body {
padding: 0 0.75rem 0.75rem;
border-top: 1px solid var(--border);
}
.support-timeline {
list-style: none;
margin: 0.5rem 0 1rem;
padding: 0;
border-left: 2px solid var(--border);
}
.support-event {
margin: 0 0 0.75rem 0.75rem;
padding-left: 0.75rem;
}
.support-event-head {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.25rem;
}
.section-subtitle {
font-size: 0.85rem;
font-weight: 600;
color: #93c5fd;
margin: 0.75rem 0 0.35rem;
}
.detail-grid.compact {
grid-template-columns: 120px 1fr;
font-size: 0.88rem;
}
.badge.danger {
background: rgba(239, 68, 68, 0.2);
color: #fca5a5;
}
.badge.info {
background: rgba(59, 130, 246, 0.18);
color: #93c5fd;
}
.badge.warn {
background: rgba(245, 158, 11, 0.2);
color: #fcd34d;
}
.badge.ok {
background: rgba(34, 197, 94, 0.18);
color: #86efac;
}
.badge.muted {
background: rgba(139, 156, 179, 0.15);
color: var(--muted);
}
.support-attachments-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.75rem;
margin: 0.5rem 0 1rem;
}
.support-attachment-card {
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
background: rgba(15, 23, 42, 0.4);
}
.support-attachment-preview {
aspect-ratio: 4/3;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.25);
min-height: 90px;
}
.support-attachment-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.support-attachment-meta {
padding: 0.45rem 0.5rem;
font-size: 0.78rem;
line-height: 1.35;
}
.support-upload-form {
margin: 0.5rem 0 1rem;
padding: 0.75rem;
border: 1px dashed var(--border);
border-radius: 8px;
}

58
GeoNetAgent/collector/tests/test_asset_change_service.py

@ -0,0 +1,58 @@ @@ -0,0 +1,58 @@
from datetime import datetime, timezone
from app.services import asset_change_service
def test_record_state_changes_only_when_different():
old = {"gpu": "NVIDIA GTX 850M (2048 MB)", "ram_gb": "8 GB"}
new = {"gpu": "NVIDIA GeForce RTX 5060 Ti (16384 MB)", "ram_gb": "8 GB"}
class FakeDb:
added = []
def add_all(self, rows):
self.added.extend(rows)
db = FakeDb()
events = asset_change_service.record_state_changes(
db,
asset_id=__import__("uuid").uuid4(),
old_state=old,
new_state=new,
source="agent",
)
assert len(events) == 1
assert events[0].field_key == "gpu"
assert "850M" in (events[0].old_value or "")
assert "5060 Ti" in (events[0].new_value or "")
def test_software_diff_summary():
old_keys = {"App A|1.0", "App B|2.0"}
new_keys = {"App A|1.0", "App B|2.1", "App C|1.0"}
old_fmt, new_fmt, detail = asset_change_service._format_software_change(old_keys, new_keys)
assert old_fmt == "2 packages"
assert "3 packages" in (new_fmt or "")
assert "+1" in (new_fmt or "")
assert detail.get("added")
def test_skip_identical_fields():
class FakeDb:
added = []
def add_all(self, rows):
self.added.extend(rows)
db = FakeDb()
state = {"hostname": "MULTIMEDIA", "cpu": "Intel Core i7"}
events = asset_change_service.record_state_changes(
db,
asset_id=__import__("uuid").uuid4(),
old_state=state,
new_state=state.copy(),
source="agent",
event_at=datetime.now(timezone.utc),
)
assert events == []
assert db.added == []

10
GeoNetAgent/collector/tests/test_support_attachments.py

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
from app.services.document_storage_service import sanitize_filename, sha256_hex
def test_sanitize_filename_strips_path():
assert sanitize_filename("foto unit.jpg") == "foto_unit.jpg"
assert sanitize_filename("report.pdf") == "report.pdf"
def test_sha256_hex():
assert sha256_hex(b"test") == "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"

17
GeoNetAgent/collector/tests/test_support_service.py

@ -0,0 +1,17 @@ @@ -0,0 +1,17 @@
from app.schemas.support import SUPPORT_CATEGORY_LABELS, SUPPORT_STATUS_LABELS
from app.services import support_service
def test_get_support_options_ssot():
opts = support_service.get_support_options()
assert opts.status == list(SUPPORT_STATUS_LABELS.keys())
assert opts.category_labels == SUPPORT_CATEGORY_LABELS
assert "component_replacement" in opts.category
assert opts.device_status # from DEVICE_STATUS_OPTIONS
assert "service_photo" in opts.attachment_kind
def test_support_category_labels_cover_all_categories():
opts = support_service.get_support_options()
for cat in opts.category:
assert cat in opts.category_labels

44
GeoNetAgent/docs/adr/ADR-004-asset-support-troubleshooting.md

@ -0,0 +1,44 @@ @@ -0,0 +1,44 @@
# ADR-004: IT Support Troubleshooting Cases (SSOT)
**Status:** Accepted
**Date:** 2026-07-09
## Context
CMDB GeoNetAgent sudah menyimpan inventaris otomatis (agent snapshots) dan audit perubahan field (`asset_change_events`). IT Support membutuhkan **catatan kendala & tindakan** yang:
- Dibuat manual oleh teknisi (mati, error, ganti komponen, service, dll.)
- Terikat ke satu asset (SSOT = `assets.id`)
- Dapat dilacak jangka panjang (timeline, bukan chat/email)
- Terpisah dari payload agent (agent tidak tahu konteks support)
## Decision
1. **Dua tabel SSOT:**
- `asset_support_cases` — tiket/kasus (status, kategori, gejala, resolusi)
- `asset_support_events` — timeline append-only per kasus
2. **Nomor kasus:** `GNA-SUP-YYYYMMDD-NNNN` (unik, human-readable)
3. **Enum** status / priority / category / event_type didefinisikan di **satu modul Python** (`schemas/support.py`) dan dicerminkan CHECK constraint DB.
4. **Auth:** sama dengan CMDB read/write API — `verify_api_bearer` (Bearer token portal).
5. **Integrasi asset:** opsional update `device_status` saat buka/tutup kasus; perubahan tercatat di `asset_change_events` dengan `source=support`.
6. **Bukan** duplikat Zabbix alerts (`alerts` tetap untuk monitoring otomatis).
## Alternatives considered
| Alternatif | Alasan ditolak |
|------------|----------------|
| Satu tabel flat tanpa timeline | Sulit audit aksi bertahap (ganti RAM → tes → tutup) |
| External ticket (Zammad only) | Tidak terikat snapshot hardware asset di CMDB |
| Log file di server | Bukan queryable, bukan SSOT relational |
## Consequences
- Migration `009_asset_support.sql`
- API `/api/v1/support/*` + nested under asset
- UI form di collector `/ui/` panel detail asset
- Laravel portal (fase berikutnya) bisa consume API yang sama

1
GeoNetAgent/docs/adr/README.md

@ -11,3 +11,4 @@ ADR mendokumentasikan keputusan arsitektur penting, alasan di baliknya, dan alte @@ -11,3 +11,4 @@ ADR mendokumentasikan keputusan arsitektur penting, alasan di baliknya, dan alte
| [ADR-001](ADR-001-agent-asset-identity.md) | Strategi Identitas Asset | Accepted |
| [ADR-002](ADR-002-asset-split-by-hardware.md) | Split Asset Hardware Campur | Accepted |
| [ADR-003](ADR-003-asset-documents-qnap.md) | Dokumen Asset di QNAP Object Storage | Accepted |
| [ADR-004](ADR-004-asset-support-troubleshooting.md) | IT Support Troubleshooting Cases | Accepted |

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

@ -1,39 +1,43 @@ @@ -1,39 +1,43 @@
# AI Session Summary
```yaml
session: 2026-07-08-cmdb-catalog-ui
session: 2026-07-09-it-support-pwa
project: GeoNetAgent/
version: 0.1.13-collector-ui
status: ready
next: Rollout agent ke endpoint stale; backfill owner display_name via LDAP re-select
version: 0.1.19-collector-support-pwa
status: deployed
next: procurement attachments (migration 005); QNAP S3 prod for documents
agent: Cursor
```
## TL;DR
- 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`)
- IT Support SSOT: migrations `009`/`010`, API kasus + lampiran foto/PDF, UI collector `/ui/`
- API global: `GET /support-cases`, `GET /support/stats` (termasuk `closed`/`cancelled`)
- Asset list: `support_case_count`, `active_support_status` per device
- PWA `my.gisportal.id` (repo terpisah): `/support`, profil Device+riwayat tiket, asset-mgmt owner fix
- Deploy: collector `.24`, DB `.25`, PWA `my-portal` SW **v8**
## Lanjut dari sini
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)
1. Collector UI: [collector.gisportal.id/ui/](https://collector.gisportal.id/ui/) — support panel per asset
2. PWA: [my.gisportal.id/support](https://my.gisportal.id/support), profil tab Device Saya
3. Owner backfill: Edit asset → pilih LDAP (kirim `display_name`) — sama pola collector UI
4. Opsional: `DOCUMENTS_STORAGE_BACKEND=s3` + bucket QNAP untuk lampiran prod
## Jangan
- 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
- Jangan commit `agent/config.json`, `.env`, token collector, file cert nginx
- Jangan overwrite owner manual via agent ingest (`assigned_by=manual` SSOT)
- Jangan hardcode URL — env `COLLECTOR_*` / `NEXT_PUBLIC_COLLECTOR_*`
## 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` |
| `10.100.1.25` | Migrasi `008`–`010` applied (`geonetagent_dev`) |
| `10.100.1.24` | `push_collector.ps1`; volume `./data/documents` |
| PWA | `deploy-my-portal.ps1`; repo `mysupperapps-fe-pwa` |
**Root cause stale massal:** banyak PC jarang lapor + bug owner (500) + GPO belum massal — bukan UI saja.
**API baru:** `support/options`, `support/stats`, `support-cases`, nested `assets/{id}/support-cases`, attachments upload/download.
**PWA repo:** `mysupperapps_FE_PWA/` — git terpisah, push ke `git.gisportal.id`.

62
GeoNetAgent/docs/api/service-collector.md

@ -182,3 +182,65 @@ Software terinstall (dari snapshot terbaru). @@ -182,3 +182,65 @@ Software terinstall (dari snapshot terbaru).
{"name": "Google Chrome", "version": "120.0.6099.130", "publisher": "Google LLC", "install_date": "2024-01-15"}
]
```
---
## IT Support Troubleshooting
SSOT manual untuk kendala device & tindakan teknisi (ADR-004). Auth: Bearer token CMDB.
### GET /api/v1/support/options
Enum kategori, status, prioritas, event type, device status.
### GET /api/v1/assets/{id}/support-cases
**Query:** `?page=1&limit=20&status=open`
### POST /api/v1/assets/{id}/support-cases
**Body (ringkas):**
```json
{
"category": "component_replacement",
"priority": "high",
"title": "Ganti SSD — boot failure",
"symptom": "Device tidak boot ke Windows setelah BSOD berulang.",
"reported_by": "rbsetiawan",
"reported_by_name": "Rudi Setiawan",
"apply_device_status": "maintenance",
"initial_note": "SSD ADATA 256GB suspected dead; spare Samsung 512GB ready."
}
```
**Response 201:** `SupportCaseOut` dengan `case_number`, `events[]`.
### PATCH /api/v1/support-cases/{id}?actor=rbsetiawan
**Body:** `status`, `note`, `resolution`, `root_cause`, `apply_device_status` (semua opsional).
### POST /api/v1/support-cases/{id}/events
Tambah baris timeline (action, parts, handover, resolution).
### GET /api/v1/support-cases/{id}/attachments
Daftar lampiran (foto/PDF) per kasus.
### POST /api/v1/support-cases/{id}/attachments
**Multipart form:** `file`, `kind` (default `service_photo`), `actor`, `caption` (opsional).
### GET /api/v1/attachments/{id}/download
Stream file (local) atau redirect presigned URL (QNAP S3).
---
## GET /api/v1/assets/{id}/changes
Audit log per field (old → new). Sumber: `agent`, `manual`, `support`.
**Query params:** `?limit=50`
---

14
GeoNetAgent/docs/changelog.md

@ -8,7 +8,19 @@ Ringkasan per versi: [version.md](./version.md). @@ -8,7 +8,19 @@ Ringkasan per versi: [version.md](./version.md).
---
## [0.1.13-collector-ui] - 2026-07-08
## [0.1.19-collector-support-pwa] - 2026-07-09
### Added
- Asset list: `support_case_count`, `active_support_status`, `active_support_status_label`
- `GET /api/v1/support-cases` (global paginated + filters)
- `GET /api/v1/support/stats` — counts termasuk `closed`, `cancelled`
### Deployed
- Collector `.24` + PWA `my.gisportal.id` (repo terpisah)
---
## [0.1.18-collector-support-attachments] - 2026-07-09
### Added
- `device_model_catalog` SSOT + API `/api/v1/device-models*` + UI Model catalog

71
GeoNetAgent/docs/database.md

@ -82,6 +82,77 @@ Point-in-time snapshot dari laporan agent. @@ -82,6 +82,77 @@ Point-in-time snapshot dari laporan agent.
---
### `asset_change_events`
Audit log **per field** — baris baru hanya jika nilai berubah (`old_value` → `new_value`).
| Kolom | Tipe | Keterangan |
|-------|------|------------|
| `id` | UUID PK | |
| `asset_id` | UUID FK → assets | |
| `snapshot_id` | UUID FK nullable | Snapshot pemicu (ingest agent) |
| `event_at` | timestamptz | Waktu kejadian |
| `source` | varchar | `agent`, `manual`, `system`, `support` |
| `field_key` | varchar | Kunci internal, mis. `gpu`, `owner` |
| `field_label` | varchar | Label UI |
| `change_type` | varchar | `added`, `removed`, `updated` |
| `old_value` | text | Nilai sebelum |
| `new_value` | text | Nilai sesudah |
| `detail` | jsonb | Detail tambahan (mis. software ±) |
Field dilacak: hostname, display name, owner, agent version, MAC, network, software, device status, asset for/on, notes, OS, CPU, RAM, BIOS, GPU, physical disks, volumes, manufacturer, model, serial.
API: `GET /api/v1/assets/{id}/changes` · Migration: `008_asset_change_events.sql`
---
### `asset_support_cases`
Tiket troubleshooting IT Support per asset (SSOT manual — bukan dari agent). Lihat ADR-004.
| Kolom | Tipe | Keterangan |
|-------|------|------------|
| `id` | UUID PK | |
| `asset_id` | UUID FK → assets | Device yang ditangani |
| `case_number` | varchar unique | `GNA-SUP-YYYYMMDD-NNNN` |
| `status` | varchar | `open`, `in_progress`, `waiting_parts`, `waiting_user`, `resolved`, `closed`, `cancelled` |
| `priority` | varchar | `low`, `normal`, `high`, `critical` |
| `category` | varchar | Kategori kendala (mati, ganti komponen, service, dll.) |
| `title` | varchar(200) | Judul singkat |
| `symptom` | text | Gejala / laporan awal |
| `resolution` | text nullable | Ringkasan perbaikan |
| `root_cause` | text nullable | Akar masalah |
| `reported_by` | varchar | Username teknisi pembuka |
| `device_status_at_open` | varchar nullable | Status device saat kasus dibuka |
| `device_status_applied` | varchar nullable | Status terakhir diterapkan via support |
| `opened_at` / `resolved_at` / `closed_at` | timestamptz | Milestone waktu |
Enum SSOT: `collector/app/schemas/support.py` · Migration: `009_asset_support.sql`
---
### `asset_support_events`
Timeline append-only per tiket support (catatan, aksi teknis, ganti komponen, perubahan status).
| Kolom | Tipe | Keterangan |
|-------|------|------------|
| `id` | UUID PK | |
| `case_id` | UUID FK → asset_support_cases | |
| `event_type` | varchar | `opened`, `note`, `status_change`, `action`, `parts`, `handover`, `resolution` |
| `summary` | varchar(500) | Ringkasan baris timeline |
| `detail` | text nullable | Detail panjang |
| `old_status` / `new_status` | varchar nullable | Untuk `status_change` |
| `metadata` | jsonb | Mis. daftar parts |
| `actor` | varchar | Username teknisi |
| `created_at` | timestamptz | |
API: `/api/v1/support/*` · Panduan: `docs/guide/asset-support-troubleshooting.md`
**Lampiran (migration `010`):** foto troubleshooting, nota parts, PDF service report. Binary di QNAP/local; metadata di tabel ini. Kind SSOT: `schemas/attachments.py`.
---
### `software_installations`
Daftar software terinstall dari scan agent.

1
GeoNetAgent/docs/guide/README.md

@ -3,4 +3,5 @@ @@ -3,4 +3,5 @@
| Guide | Path | Kapan |
|-------|------|-------|
| Migrasi server/domain | [migration.md](./migration.md) | Pindah collector/nginx |
| IT Support troubleshooting | [asset-support-troubleshooting.md](./asset-support-troubleshooting.md) | Kendala device & timeline teknisi |
| Deploy | [../deployment.md](../deployment.md) | Install stack |

74
GeoNetAgent/docs/guide/asset-support-troubleshooting.md

@ -0,0 +1,74 @@ @@ -0,0 +1,74 @@
# Panduan — IT Support Troubleshooting (CMDB)
Modul ini mencatat kendala device dan tindakan teknisi **per asset**, terpisah dari laporan agent otomatis. SSOT: PostgreSQL + API collector + UI `/ui/`.
## Kapan dipakai
- PC/laptop mati, error boot, BSOD
- Penggantian komponen (RAM, SSD, PSU, dll.)
- Perbaikan / service / preventive maintenance
- Masalah software, jaringan, performa
- Penarikan / decommission
**Bukan** untuk alert Zabbix (`alerts` tetap untuk monitoring).
## Alur kerja
1. Buka asset di **GeoNetAgent Asset Viewer** (`https://collector.gisportal.id/ui/`)
2. Scroll ke panel **IT Support & Troubleshooting**
3. **Buka kasus baru** — isi kategori, prioritas, gejala, teknisi
4. Opsional: set **device status** (mis. `maintenance`, `rusak`) — tercatat di change log (`source=support`)
5. Saat penanganan berlanjut: buka kasus → **Update kasus** (status + catatan)
6. Tutup dengan status **Resolved** atau **Closed**
Nomor tiket otomatis: `GNA-SUP-YYYYMMDD-NNNN`.
## API (Bearer token sama dengan CMDB UI)
| Method | Path | Fungsi |
|--------|------|--------|
| GET | `/api/v1/support/options` | Enum kategori, status, prioritas |
| GET | `/api/v1/assets/{id}/support-cases` | Daftar kasus per device |
| POST | `/api/v1/assets/{id}/support-cases` | Buka kasus baru |
| GET | `/api/v1/support-cases/{id}` | Detail + timeline |
| PATCH | `/api/v1/support-cases/{id}?actor=username` | Update status / catatan |
| POST | `/api/v1/support-cases/{id}/events` | Tambah event (aksi, parts, dll.) |
| GET | `/api/v1/support-cases/{id}/attachments` | Daftar foto/lampiran kasus |
| POST | `/api/v1/support-cases/{id}/attachments` | Upload foto/PDF (multipart) |
| GET | `/api/v1/attachments/{id}/download` | Unduh / preview file |
### Lampiran foto (troubleshooting)
Jenis lampiran (SSOT `schemas/attachments.py`):
| Kind | Kegunaan |
|------|----------|
| `service_photo` | Foto kondisi saat troubleshooting |
| `before_photo` / `after_photo` | Sebelum & sesudah perbaikan |
| `parts_receipt` | Nota sparepart |
| `service_report` | PDF laporan vendor |
| `screenshot` | Screenshot error |
Format: JPEG, PNG, WebP, GIF, PDF — maks 15 MB. Mobile PWA: `capture="environment"` pada input file.
Storage dev: folder lokal `./data/documents`. Produksi: set `DOCUMENTS_STORAGE_BACKEND=s3` + kredensial QNAP di `.env` collector.
Lihat ADR-004 dan `docs/database.md` untuk skema tabel.
## Deploy migration
```bash
sudo -u postgres psql -d geonetagent_dev -f 008_asset_change_events.sql # jika belum
sudo -u postgres psql -d geonetagent_dev -f 009_asset_support.sql
sudo -u postgres psql -d geonetagent_dev -f 010_support_attachments.sql
```
Rebuild & restart collector di `10.100.1.24` setelah migration.
## Praktik baik
- **Judul singkat** yang bisa dicari nanti (hostname + gejala)
- **Gejala** = apa yang user laporkan; **resolution** = apa yang teknisi lakukan
- **Root cause** untuk kasus berulang (mis. PSU lemah, thermal paste kering)
- Gunakan **waiting_parts** jika menunggu sparepart — jangan tutup prematur
- Username teknisi konsisten (LDAP) agar audit trail jelas

10
GeoNetAgent/docs/issues.md

@ -14,12 +14,12 @@ @@ -14,12 +14,12 @@
- **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:` / `$()` / batas ukuran
- **Dampak:** File PowerShell yang diunduh dari `agent.gisportal.id/install/` terpotong. Kode setelah `$env:` hilang → agent gagal install.
- **Penyebab:** Nginx SSI menginterpretasikan `$env:` sebagai variabel SSI → potong respons.
- **Fix:** Tambah `ssi off` di nginx config block `location /install/`.
- **Tanggal Fix:** 2026-06-22
- **Dampak:** File PowerShell yang diunduh dari `agent.gisportal.id/install/` terpotong → parse error / agent gagal kirim laporan.
- **Penyebab:** Nginx SSI/variabel pada konten statis (`$env:`, `$()`, `$_.`); file `Send-GeoNetReport.ps1` juga terpotong jika > ~2745 byte.
- **Fix:** `ssi off`; hindari `$()` / `$_.` di string file yang di-serve; logika outcome dipindah ke `lib/Agent-InstallCore.ps1`; publish script verifikasi parse HTTPS.
- **Tanggal Fix:** 2026-06-22 (SSI), 2026-07-09 (Send-GeoNetReport + outcome)
---

77
GeoNetAgent/docs/todo.md

@ -1,15 +1,15 @@ @@ -1,15 +1,15 @@
# TODO List
> **Terakhir Diperbarui:** 2026-07-08
> **Terakhir Diperbarui:** 2026-07-09
---
## 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`
- IT Support: migrations `009`/`010`, API + collector UI + lampiran foto
- API global support list/stats; asset list `support_case_count` + active ticket status
- PWA (repo terpisah): `/support`, profil Device+riwayat tiket, asset-mgmt owner `display_name`, dialog header fix
- Fix PWA `reported_by` via `normalizeAuthUser()`; deploy collector + PWA prod
---
@ -31,69 +31,34 @@ @@ -31,69 +31,34 @@
## 🟡 Medium Priority
### QNAP S3 untuk lampiran support (prod)
- **Deskripsi:** Set `DOCUMENTS_STORAGE_BACKEND=s3` di collector; bucket + credentials
- **File:** `infra/collector/docker-compose.yml`, `document_storage_service.py`
- **Estimasi:** 2–4 jam
- **Dependency:** Bucket QNAP
### Procurement / invoice attachments (migration 005)
- **Deskripsi:** Extend `document_attachments` untuk asset procurement — belum di PWA
- **Estimasi:** 1–2 hari
- **Dependency:** ADR + bucket
### 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
- **Deskripsi:** Re-select LDAP di UI/PWA 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`
- **Estimasi:** 1 jam
- **Dependency:** -
### Isi .cursor/rules/ files
- **Deskripsi:** Folder `.cursor/rules/` ada tapi kosong (hanya `project-context.mdc` yang disebut tapi tidak ada)
- **File:** `.cursor/rules/project-context.mdc`, `coding.mdc`, `agent-release-deploy.mdc`
- **Estimasi:** 1 jam
- **Dependency:** -
### Apply migration 005 di dev DB
- **Deskripsi:** `infra/db/005_asset_documents.sql` sudah dibuat, belum diapply. Butuh setup QNAP bucket dulu.
- **File:** `infra/db/005_asset_documents.sql`
- **Estimasi:** 2 jam
- **Dependency:** QNAP bucket `geonetagent-documents` dibuat
### Buat QNAP bucket geonetagent-documents
- **Deskripsi:** Bucket untuk dokumen procurement/service/complain (ADR-003)
- **File:** `infra/qnap/README.md` (referensi)
- **Estimasi:** 30 menit
- **Dependency:** ADR-003
### Uji agent di BKK-MINIPC
- **Deskripsi:** Verifikasi agent berjalan di PC kedua (bukan laptop Dell)
- **Estimasi:** 30 menit
- **Dependency:** Akses fisik ke BKK-MINIPC
---
## 🟢 Low Priority
### Agent v0.1.11 — Printer collection
- **Deskripsi:** Tambah `Get-PrinterInfo.ps1` menggunakan `Win32_Printer`
- **File:** `agent/lib/Get-PrinterInfo.ps1` (baru)
- **Estimasi:** 2-3 jam
- **Dependency:** -
### Agent v0.1.12 — Klasifikasi HID
- **Deskripsi:** Bedakan keyboard vs mouse vs dongle di `Get-PeripheralsInfo.ps1`
- **File:** `agent/lib/Get-PeripheralsInfo.ps1`
- **Estimasi:** 2-4 jam
- **Dependency:** -
### Tambah pytest tests untuk collector
- **Deskripsi:** Coverage untuk `/api/v1/agent/report` endpoint (test payload validation, upsert logic)
- **Deskripsi:** Coverage support + asset_change (partial done)
- **File:** `collector/tests/`
- **Estimasi:** 1-2 hari
- **Dependency:** -
### OpenAPI / Swagger doc finalize
- **Deskripsi:** Pastikan semua endpoint FastAPI punya docstring dan description yang lengkap untuk auto-generated docs di `/docs`
- **File:** `collector/app/api/*.py`
- **Estimasi:** 2 jam
- **Dependency:** -
### Agent v0.1.11 — Printer collection
- **Deskripsi:** `Get-PrinterInfo.ps1`
- **Estimasi:** 2-3 jam

59
GeoNetAgent/docs/version.md

@ -4,6 +4,65 @@ @@ -4,6 +4,65 @@
---
## [0.1.19-collector-support-pwa] - 2026-07-09
- Asset list API: jumlah tiket + status tiket aktif per device
- Support stats API: field `closed`, `cancelled`
- Global support cases list + filter untuk integrasi PWA IT Support
- Deploy prod: collector `.24`, migrasi `008`–`010` di `.25`
---
## [0.1.18-collector-support-attachments] - 2026-07-09
- Lampiran foto/PDF per kasus IT Support (`document_attachments` migration `010`)
- Upload multipart: `POST /api/v1/support-cases/{id}/attachments`
- Download: `GET /api/v1/attachments/{id}/download` (local stream atau S3 presigned)
- Storage: local `./data/documents` (dev) atau QNAP S3 (`DOCUMENTS_STORAGE_BACKEND=s3`)
- UI: galeri foto + upload di panel kasus support
- SSOT kind: `schemas/attachments.py`
---
## [0.1.17-collector-support] - 2026-07-09
- Modul IT Support troubleshooting: `asset_support_cases` + `asset_support_events` (ADR-004)
- API `/api/v1/support/options`, nested `/assets/{id}/support-cases`, CRUD kasus + timeline
- UI form & riwayat kasus di panel detail asset (`/ui/`)
- Enum SSOT di `schemas/support.py`; auth sama `verify_api_bearer`
- Integrasi opsional `device_status``asset_change_events` (`source=support`)
- Migration: `009_asset_support.sql`
---
## [0.1.16-collector-changelog] - 2026-07-09
- Tabel `asset_change_events` — audit log per field (old → new), hanya saat nilai berubah
- Hook ingest agent + edit manual UI (meta, owner)
- API `GET /api/v1/assets/{id}/changes`
---
## [0.1.15-phase0] - 2026-07-09
- Agent GPU VRAM: `nvidia-smi` + registry `HardwareInformation.qwMemorySize` (hindari cap WMI AdapterRAM 4GB)
- Filter adapter virtual tambahan (`Remote Display`, `Indirect Display`)
## [0.1.14-phase0] - 2026-07-09
- Fix `Send-GeoNetReport.ps1`: hindari `$()` di string — nginx memotong file `.ps1` di `/install/`
- Publish script: verifikasi ukuran HTTPS vs lokal untuk semua file manifest
---
## [0.1.13-phase0] - 2026-07-09
- Agent: `agent_version` di laporan dibaca dari file `VERSION` (bukan hardcode `0.1.10`)
- Halaman install: perintah utama memakai `-ExecutionPolicy Bypass` (Windows default `Restricted`)
- Collector tetap `0.1.13-collector-ui`
---
## [0.1.13-collector-ui] - 2026-07-08
- Collector CMDB UI: model catalog, sync-from-assets, owner+agent columns, search/filter/sort

2
GeoNetAgent/infra/collector/docker-compose.yml

@ -9,3 +9,5 @@ services: @@ -9,3 +9,5 @@ services:
- "127.0.0.1:18000:8000"
env_file:
- .env
volumes:
- ./data/documents:/app/data/documents

34
GeoNetAgent/infra/collector/publish_agent_install.ps1

@ -79,4 +79,38 @@ try { @@ -79,4 +79,38 @@ try {
Write-Warning "Tidak bisa verifikasi HTTPS: $_"
}
Write-Host "==> Verifikasi ukuran remote + parse HTTPS (nginx dapat memotong .ps1 besar)"
$verifyFiles = @($manifest.files + @(
'install/GeoNetAgent-Install.ps1',
'install/manifest.json'
)) | Select-Object -Unique
foreach ($rel in $verifyFiles) {
$local = if ($rel -like 'install/*') {
Join-Path $Agent ($rel -replace '^install/', 'install\')
} else {
Join-Path $Agent ($rel -replace '/', '\')
}
if (-not (Test-Path $local)) { continue }
$localSize = (Get-Item $local).Length
$remoteRel = $rel -replace '\\', '/'
$remoteSize = [int](ssh $Remote "wc -c < $InstallRoot/$remoteRel" 2>$null)
if ($remoteSize -ne $localSize) {
throw "Remote $remoteSize bytes != local $localSize bytes untuk $rel"
}
if ($rel -like '*.ps1') {
$tmp = Join-Path $env:TEMP "geonet-verify-$($rel -replace '[/\\]','-')"
Invoke-WebRequest -Uri "https://agent.gisportal.id/install/$remoteRel" -OutFile $tmp -UseBasicParsing -TimeoutSec 30 | Out-Null
$httpsSize = (Get-Item $tmp).Length
$parseErrors = $null
[void][System.Management.Automation.Language.Parser]::ParseFile($tmp, [ref]$null, [ref]$parseErrors)
if ($parseErrors) {
throw "HTTPS $rel tidak valid PowerShell ($httpsSize bytes): $($parseErrors[0].ToString())"
}
if ($httpsSize -lt $remoteSize) {
Write-Warning "HTTPS $rel lebih pendek ($httpsSize < $remoteSize) — cek nginx truncate"
}
}
Write-Host " OK $rel ($localSize bytes)"
}
Write-Host "OK: https://agent.gisportal.id/install/"

5
GeoNetAgent/infra/db/README.md

@ -51,7 +51,10 @@ Expected: 13 core tables (portal_users via Laravel later). @@ -51,7 +51,10 @@ Expected: 13 core tables (portal_users via Laravel later).
| `002_disk_model.sql` | Kolom `asset_disks.model` |
| `003_drop_machine_id_unique.sql` | `machine_id` bukan UNIQUE (disk clone) |
| `004_v_asset_hardware_latest.sql` | View hardware ringkas per asset (snapshot terbaru) |
| `005_asset_documents.sql` | `asset_invoices`, `tickets`, `document_attachments` (ADR 003 — Fase 3) |
| `005_asset_documents.sql` | `asset_invoices`, `tickets`, `document_attachments` (ADR 003 — procurement, belum apply penuh) |
| `008_asset_change_events.sql` | Audit log per field |
| `009_asset_support.sql` | Kasus IT Support + timeline |
| `010_support_attachments.sql` | `document_attachments` untuk foto/PDF kasus support |
Jalankan di dev:

29
GeoNetAgent/infra/db/migrations/008_asset_change_events.sql

@ -0,0 +1,29 @@ @@ -0,0 +1,29 @@
-- GeoNetAgent — audit log perubahan field asset (hanya jika nilai berubah)
-- Jalankan: sudo -u postgres psql -d geonetagent_dev -f 008_asset_change_events.sql
CREATE TABLE IF NOT EXISTS asset_change_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
snapshot_id uuid REFERENCES asset_snapshots(id) ON DELETE SET NULL,
event_at timestamptz NOT NULL DEFAULT now(),
source varchar(30) NOT NULL,
field_key varchar(80) NOT NULL,
field_label varchar(120) NOT NULL,
change_type varchar(20) NOT NULL DEFAULT 'updated',
old_value text,
new_value text,
detail jsonb NOT NULL DEFAULT '{}'::jsonb,
CONSTRAINT chk_asset_change_source CHECK (source IN ('agent', 'manual', 'system')),
CONSTRAINT chk_asset_change_type CHECK (change_type IN ('added', 'removed', 'updated'))
);
CREATE INDEX IF NOT EXISTS idx_asset_change_events_asset_time
ON asset_change_events (asset_id, event_at DESC);
CREATE INDEX IF NOT EXISTS idx_asset_change_events_field
ON asset_change_events (asset_id, field_key, event_at DESC);
COMMENT ON TABLE asset_change_events IS
'Audit trail per-field: old_value → new_value saat ingest agent atau edit manual UI.';
GRANT SELECT, INSERT ON asset_change_events TO geonet_collector;

95
GeoNetAgent/infra/db/migrations/009_asset_support.sql

@ -0,0 +1,95 @@ @@ -0,0 +1,95 @@
-- GeoNetAgent — IT Support / troubleshooting cases (SSOT)
-- Jalankan: sudo -u postgres psql -d geonetagent_dev -f 009_asset_support.sql
-- Extend audit source for support-driven asset updates
ALTER TABLE asset_change_events DROP CONSTRAINT IF EXISTS chk_asset_change_source;
ALTER TABLE asset_change_events ADD CONSTRAINT chk_asset_change_source
CHECK (source IN ('agent', 'manual', 'system', 'support'));
CREATE TABLE IF NOT EXISTS asset_support_cases (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
case_number varchar(32) NOT NULL UNIQUE,
status varchar(30) NOT NULL DEFAULT 'open',
priority varchar(20) NOT NULL DEFAULT 'normal',
category varchar(40) NOT NULL,
title varchar(200) NOT NULL,
symptom text NOT NULL,
resolution text,
root_cause text,
reported_by varchar(100) NOT NULL,
reported_by_name varchar(200),
assigned_to varchar(100),
assigned_to_name varchar(200),
device_status_at_open varchar(30),
device_status_applied varchar(30),
opened_at timestamptz NOT NULL DEFAULT now(),
resolved_at timestamptz,
closed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT chk_support_case_status CHECK (status IN (
'open', 'in_progress', 'waiting_parts', 'waiting_user', 'resolved', 'closed', 'cancelled'
)),
CONSTRAINT chk_support_case_priority CHECK (priority IN (
'low', 'normal', 'high', 'critical'
)),
CONSTRAINT chk_support_case_category CHECK (category IN (
'power_no_boot',
'boot_os_error',
'hardware_failure',
'component_replacement',
'repair',
'preventive_maintenance',
'software_issue',
'network_connectivity',
'performance',
'data_backup_recovery',
'security_incident',
'user_request',
'decommission',
'other'
))
);
CREATE INDEX IF NOT EXISTS idx_support_cases_asset_opened
ON asset_support_cases (asset_id, opened_at DESC);
CREATE INDEX IF NOT EXISTS idx_support_cases_status
ON asset_support_cases (status, opened_at DESC);
CREATE INDEX IF NOT EXISTS idx_support_cases_reported_by
ON asset_support_cases (reported_by, opened_at DESC);
CREATE TRIGGER trg_asset_support_cases_updated_at
BEFORE UPDATE ON asset_support_cases
FOR EACH ROW EXECUTE PROCEDURE set_updated_at();
CREATE TABLE IF NOT EXISTS asset_support_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
case_id uuid NOT NULL REFERENCES asset_support_cases(id) ON DELETE CASCADE,
event_type varchar(30) NOT NULL,
summary varchar(500) NOT NULL,
detail text,
old_status varchar(30),
new_status varchar(30),
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
actor varchar(100) NOT NULL,
actor_display_name varchar(200),
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT chk_support_event_type CHECK (event_type IN (
'opened', 'note', 'status_change', 'action', 'parts', 'handover', 'resolution'
))
);
CREATE INDEX IF NOT EXISTS idx_support_events_case_time
ON asset_support_events (case_id, created_at ASC);
COMMENT ON TABLE asset_support_cases IS
'SSOT tiket troubleshooting IT Support per asset — bukan file log, bukan snapshot agent.';
COMMENT ON TABLE asset_support_events IS
'Timeline append-only per tiket support (catatan, aksi, ganti komponen, perubahan status).';
GRANT SELECT, INSERT, UPDATE ON asset_support_cases TO geonet_collector;
GRANT SELECT, INSERT ON asset_support_events TO geonet_collector;

49
GeoNetAgent/infra/db/migrations/010_support_attachments.sql

@ -0,0 +1,49 @@ @@ -0,0 +1,49 @@
-- GeoNetAgent — support case photo/doc attachments (ADR-003 + ADR-004)
-- Jalankan setelah 009_asset_support.sql
-- SSOT metadata: document_attachments · binary: QNAP S3 atau local (collector)
\set ON_ERROR_STOP on
CREATE TABLE IF NOT EXISTS document_attachments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
domain varchar(20) NOT NULL DEFAULT 'support',
kind varchar(40) NOT NULL DEFAULT 'service_photo',
asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
support_case_id uuid NOT NULL REFERENCES asset_support_cases(id) ON DELETE CASCADE,
storage_provider varchar(20) NOT NULL DEFAULT 'local',
bucket varchar(100),
object_key varchar(500) NOT NULL,
original_filename varchar(255) NOT NULL,
content_type varchar(100),
file_size_bytes bigint,
checksum_sha256 varchar(64),
uploaded_by varchar(100) NOT NULL,
uploaded_by_name varchar(200),
caption text,
uploaded_at timestamptz NOT NULL DEFAULT now(),
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
deleted_at timestamptz,
CONSTRAINT chk_doc_attach_domain CHECK (domain IN (
'support', 'procurement', 'service', 'complaint'
)),
CONSTRAINT chk_doc_attach_kind_support CHECK (
domain <> 'support' OR kind IN (
'service_photo', 'before_photo', 'after_photo', 'parts_receipt',
'service_report', 'screenshot', 'other'
)
),
CONSTRAINT uq_doc_attach_storage UNIQUE (storage_provider, object_key)
);
CREATE INDEX IF NOT EXISTS idx_doc_attach_support_case
ON document_attachments (support_case_id, uploaded_at DESC)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_doc_attach_asset
ON document_attachments (asset_id, uploaded_at DESC)
WHERE deleted_at IS NULL;
COMMENT ON TABLE document_attachments IS
'Metadata lampiran file (foto troubleshooting, invoice, dll). Binary di QNAP/local — ADR-003.';
GRANT SELECT, INSERT, UPDATE ON document_attachments TO geonet_collector;

43
Workspace-Session.md

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
# Workspace Session
> Last updated: 2026-07-08 — overwrite per handover
> Last updated: 2026-07-09 — overwrite per handover
---
@ -8,19 +8,19 @@ @@ -8,19 +8,19 @@
### 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.
**IT Support + PWA ✅:** migrations 009/010, API kasus+lampiran, global stats/list, asset ticket counts. PWA `/support`, profil device riwayat tiket, asset-mgmt owner fix. Next: QNAP S3 lampiran prod; migration 005 procurement.
### nginx (`nginx/`)
### mysupperapps_FE_PWA (`mysupperapps_FE_PWA/` — repo terpisah)
**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.
**v1.6.0 deployed ✅:** IT Support tab, profil Device Saya + riwayat tiket, asset-mgmt tiket count + owner LDAP display_name. SW v8. Next: `/ticketing` Zammad; cleanup `scripts/patch_*`.
### Ollama / AI Onprem (`Ollama/`)
### nginx (`nginx/`)
**Fase 1+2+3 + UI Chat ✅:** Ollama GPU → pgvector → geonet-console → PWA. Next: user memory + permission guard.
**SSL geonet SSOT ✅:** cert di `.24`; renewal ~Agustus 2026. Next: jangan commit cert private ke git.
### Mailcow (`Mailcow/`)
### Ollama / AI Onprem (`Ollama/`)
Dual-domain geonet hybrid + gisportal Mailcow. SPF gisportal done. Next: **DKIM gisportal.id**.
NAS indexing WIP (uncommitted). Next: handover terpisah jika di-commit.
---
@ -28,13 +28,11 @@ Dual-domain geonet hybrid + gisportal Mailcow. SPF gisportal done. Next: **DKIM @@ -28,13 +28,11 @@ Dual-domain geonet hybrid + gisportal Mailcow. SPF gisportal done. Next: **DKIM
| Project | Version | Session summary | Status |
|---------|---------|-----------------|--------|
| **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 |
| **GeoNetAgent** | 0.1.19-collector-support-pwa | [GeoNetAgent/docs/ai-session-summary.md](GeoNetAgent/docs/ai-session-summary.md) | Support API+deploy |
| **mysupperapps_FE_PWA** | 1.6.0 | [mysupperapps_FE_PWA/docs/ai-session-summary.md](mysupperapps_FE_PWA/docs/ai-session-summary.md) | IT Support PWA live |
| **nginx** | 1.1.0 | [nginx/docs/ai-session-summary.md](nginx/docs/ai-session-summary.md) | SSL — uncommitted certs |
| **Ollama** | 0.4.0 | [Ollama/docs/ai-session-summary.md](Ollama/docs/ai-session-summary.md) | NAS WIP |
| **geonet-console** | ai-fase3.1 | [server-connection/geonet-console/docs/ai-session-summary.md](server-connection/geonet-console/docs/ai-session-summary.md) | — |
---
@ -42,19 +40,14 @@ Dual-domain geonet hybrid + gisportal Mailcow. SPF gisportal done. Next: **DKIM @@ -42,19 +40,14 @@ Dual-domain geonet hybrid + gisportal Mailcow. SPF gisportal done. Next: **DKIM
| Area | Impact |
|------|--------|
| `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 |
| `collector.gisportal.id` | Support API + asset ticket counts — PWA pakai `NEXT_PUBLIC_COLLECTOR_TOKEN` |
| `my.gisportal.id` | `/support`, profil, asset-management — repo PWA terpisah |
| PostgreSQL `.25` | `asset_support_*`, `document_attachments`, `asset_change_events` |
---
## Next agent
**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)
**AI / Mailcow:** lihat Active focus di atas.
2. [mysupperapps_FE_PWA/docs/ai-session-summary.md](mysupperapps_FE_PWA/docs/ai-session-summary.md) — repo sendiri
3. Guide: `GeoNetAgent/docs/guide/asset-support-troubleshooting.md`

Loading…
Cancel
Save