{
  "started_utc": "2026-11-01T03:21:49.186067+00:00",
  "finished_utc": "2026-11-01T03:21:50.197953+00:00",
  "query_sha256": "f1ea3eb1ba097653eabd1026634b7b21a06fa9cc5f992eec135377327ef65567",
  "data": {
    "mapper_text": "package com.icerti.platform.poc.repository.server;\r\n\r\nimport java.util.List;\r\nimport java.util.Optional;\r\n\r\nimport org.apache.ibatis.annotations.Mapper;\r\nimport org.apache.ibatis.annotations.Param;\r\nimport org.apache.ibatis.annotations.Delete;\r\nimport org.apache.ibatis.annotations.Select;\r\nimport org.apache.ibatis.annotations.Update;\r\n\r\n@Mapper\r\npublic interface RecRegistrationMapper {\r\n\r\n    @Select(\"\"\"\r\n            SELECT s.server_id AS serverId,\r\n                   s.control_ip AS controlIp,\r\n                   s.control_port AS controlPort\r\n            FROM SERVER s\r\n            INNER JOIN ACCOUNT a ON a.server_id = s.server_id\r\n            WHERE a.actor_type IN ('REC', 'MEDIA')\r\n              AND a.enabled = TRUE\r\n              AND a.deleted_at_wall_ms IS NULL\r\n              AND s.control_ip IS NOT NULL\r\n              AND s.control_port IS NOT NULL\r\n            ORDER BY CAST(SUBSTRING_INDEX(s.server_id, '-', -1) AS UNSIGNED), s.server_id\r\n            \"\"\")\r\n    List<ConnectionEndpoint> findEnabledRecConnectionEndpoints();\r\n\r\n    /**\r\n     * 기동 때 다시 붙어야 할 AI 서버의 접속 주소.\r\n     *\r\n     * <p>{@code SERVER.control_ip} 를 읽지 않는다. 그 열은 AI 서버가 login 한 출발지 주소이고,\r\n     * Manager 가 실제로 걸어야 하는 곳은 AI 서버가 알려 온 {@code advertised_ip} 다. 두 값은\r\n     * 다른 질문의 답이라 한쪽으로 대신할 수 없다.</p>\r\n     *\r\n     * <p>Redis 관련 열은 하나도 읽지 않는다. WSS 주소를 만드는 데 쓰이지 않는 값이며, 읽어\r\n     * 오지 않은 값은 로그나 예외로 새어 나갈 수 없다.</p>\r\n     */\r\n    @Select(\"\"\"\r\n            SELECT s.server_id AS serverId,\r\n                   conn.advertised_ip AS advertisedIp,\r\n                   conn.control_port AS controlPort,\r\n                   conn.connection_config_version AS connectionConfigVersion\r\n            FROM AI_SERVER_CONNECTION conn\r\n            INNER JOIN SERVER s ON s.server_pk = conn.server_pk\r\n            INNER JOIN ACCOUNT a ON a.server_id = s.server_id\r\n            WHERE a.actor_type = 'AI'\r\n              AND a.enabled = TRUE\r\n              AND a.deleted_at_wall_ms IS NULL\r\n            ORDER BY CAST(SUBSTRING_INDEX(s.server_id, '-', -1) AS UNSIGNED), s.server_id\r\n            \"\"\")\r\n    List<AiConnectionEndpoint> findEnabledAiConnectionEndpoints();\r\n\r\n    boolean accountIdExists(@Param(\"loginId\") String loginId);\r\n\r\n    Optional<Integer> findMaxServerNumber(@Param(\"actorType\") String actorType);\r\n\r\n    int insertAccount(@Param(\"loginId\") String loginId, @Param(\"passwordHash\") String passwordHash,\r\n            @Param(\"actorType\") String actorType, @Param(\"permission\") String permission,\r\n            @Param(\"serverId\") String serverId, @Param(\"nowWallMs\") long nowWallMs);\r\n\r\n    Optional<Long> findAccountPkByLoginId(@Param(\"loginId\") String loginId);\r\n\r\n    int insertAppUser(@Param(\"accountPk\") long accountPk, @Param(\"nowWallMs\") long nowWallMs);\r\n\r\n    int insertServer(@Param(\"serverId\") String serverId, @Param(\"nowWallMs\") long nowWallMs);\r\n\r\n    Optional<Long> findServerPkById(@Param(\"serverId\") String serverId);\r\n\r\n    int insertStreamingConfig(@Param(\"serverPk\") long serverPk, @Param(\"enabled\") boolean enabled,\r\n            @Param(\"streamingBindIp\") String streamingBindIp, @Param(\"rtspOutputMode\") String rtspOutputMode,\r\n            @Param(\"rtspPort\") int rtspPort, @Param(\"httpPort\") int httpPort, @Param(\"rtcPort\") int rtcPort,\r\n            @Param(\"nowWallMs\") long nowWallMs);\r\n\r\n    /** Locks the REC server and projects the endpoint/config data needed by lifecycle services. */\r\n    @Select(\"\"\"\r\n            SELECT s.server_pk AS serverPk,\r\n                   s.server_id AS serverId,\r\n                   a.account_pk AS accountPk,\r\n                   a.login_id AS accountId,\r\n                   a.actor_type AS actorType,\r\n                   a.enabled AS enabled,\r\n                   a.deleted_at_wall_ms AS deletedAtWallMs,\r\n                   s.control_ip AS controlIp,\r\n                   s.control_port AS controlPort,\r\n                   sc.enabled AS streamingEnabled,\r\n                   sc.streaming_bind_ip AS streamingBindIp,\r\n                   sc.rtsp_output_mode AS rtspOutputMode,\r\n                   sc.rtsp_port AS rtspPort,\r\n                   sc.http_port AS httpPort,\r\n                   sc.rtc_port AS rtcPort\r\n            FROM SERVER s\r\n            INNER JOIN ACCOUNT a ON a.server_id = s.server_id\r\n            LEFT JOIN STREAMING_CONFIG sc ON sc.server_pk = s.server_pk\r\n            WHERE s.server_pk = #{serverPk}\r\n              AND a.actor_type IN ('REC', 'MEDIA')\r\n            FOR UPDATE\r\n            \"\"\")\r\n    Optional<ServerRecord> findRecServerForUpdate(@Param(\"serverPk\") long serverPk);\r\n\r\n    @Select(\"\"\"\r\n            SELECT s.server_pk AS serverPk,\r\n                   s.server_id AS serverId,\r\n                   a.account_pk AS accountPk,\r\n                   a.login_id AS accountId,\r\n                   a.actor_type AS actorType,\r\n                   a.enabled AS enabled,\r\n                   a.deleted_at_wall_ms AS deletedAtWallMs,\r\n                   s.control_ip AS controlIp,\r\n                   s.control_port AS controlPort,\r\n                   sc.enabled AS streamingEnabled,\r\n                   sc.streaming_bind_ip AS streamingBindIp,\r\n                   sc.rtsp_output_mode AS rtspOutputMode,\r\n                   sc.rtsp_port AS rtspPort,\r\n                   sc.http_port AS httpPort,\r\n                   sc.rtc_port AS rtcPort\r\n            FROM SERVER s\r\n            INNER JOIN ACCOUNT a ON a.server_id = s.server_id\r\n            LEFT JOIN STREAMING_CONFIG sc ON sc.server_pk = s.server_pk\r\n            WHERE s.server_id = #{serverId}\r\n              AND a.actor_type IN ('REC', 'MEDIA')\r\n            FOR UPDATE\r\n            \"\"\")\r\n    Optional<ServerRecord> findRecServerForUpdateById(@Param(\"serverId\") String serverId);\r\n\r\n    /**\r\n     * 등록된 미디어(REC)·AI 서버 전부를 읽는다. 서버 관리 화면의 목록용이라 잠금을 걸지 않는다.\r\n     *\r\n     * <p>삭제된 계정은 제외한다. 계정을 지워도 SERVER 행은 남는데, 그 서버는 더 이상 로그인할\r\n     * 주체가 없어 화면에 띄울 대상이 아니다.</p>\r\n     *\r\n     * <p>{@code ServerRecord} 대신 전용 행 타입을 쓰는 이유는 {@code last_login_at_wall_ms}\r\n     * 때문이다. 이 값은 AI 서버가 로그인한 적이 있는지를 화면에 알려 주는 유일한 근거인데,\r\n     * {@code ServerRecord} 는 lifecycle service 들이 함께 쓰는 타입이라 목록 전용 열을\r\n     * 거기에 얹으면 그 서비스들의 잠금 조회까지 따라 바뀐다.</p>\r\n     *\r\n     * <p>{@code actor_type} 을 먼저 정렬해 같은 종류끼리 붙여 둔다. 화면이 종류 열을 보여\r\n     * 주므로 섞여 나오면 읽기 어렵다. 그 안에서는 발급 번호 순이다 - 미디어 서버 ID 는\r\n     * {@code REC-NN} 과 {@code MEDIA-NN} 이 섞이므로(V17) 문자열 순이면 등록 순서와 어긋난다.</p>\r\n     */\r\n    @Select(\"\"\"\r\n            SELECT s.server_id AS serverId,\r\n                   a.login_id AS accountId,\r\n                   a.actor_type AS actorType,\r\n                   a.enabled AS enabled,\r\n                   a.last_login_at_wall_ms AS lastLoginAtWallMs,\r\n                   s.control_ip AS controlIp,\r\n                   s.control_port AS controlPort,\r\n                   sc.enabled AS streamingEnabled,\r\n                   sc.streaming_bind_ip AS streamingBindIp,\r\n                   sc.rtsp_output_mode AS rtspOutputMode,\r\n                   sc.rtsp_port AS rtspPort,\r\n                   sc.http_port AS httpPort,\r\n                   sc.rtc_port AS rtcPort\r\n            FROM SERVER s\r\n            INNER JOIN ACCOUNT a ON a.server_id = s.server_id\r\n            LEFT JOIN STREAMING_CONFIG sc ON sc.server_pk = s.server_pk\r\n            WHERE a.actor_type IN ('REC', 'MEDIA', 'AI') AND a.deleted_at_wall_ms IS NULL\r\n            ORDER BY CASE WHEN a.actor_type IN ('REC', 'MEDIA') THEN 'REC' ELSE a.actor_type END,\r\n                     CAST(SUBSTRING_INDEX(s.server_id, '-', -1) AS UNSIGNED), s.server_id\r\n            \"\"\")\r\n    List<ManagedServerRow> findManagedServers();\r\n\r\n    /**\r\n     * 종류를 가리지 않고 서버를 잠그고 읽는다. 서버 관리 화면의 삭제가 쓴다.\r\n     *\r\n     * <p>{@link #findRecServerForUpdate} 을 쓰지 않는다. 그쪽 SQL 은 {@code actor_type IN ('REC', 'MEDIA')}\r\n     * 로 걸러 있어 AI 서버를 언제나 못 찾고, 그 조건은 REC 전용 lifecycle service 들이\r\n     * 의존하는 것이라 느슨하게 풀 수 없다. 두 종류를 함께 다루는 곳은 이 조회를 쓴다.</p>\r\n     *\r\n     * <p>{@code MANAGER} 와 {@code Client} 는 {@code SERVER} 행을 갖지 않지만, 조건을 적어 둔다.\r\n     * 이 조회가 지우는 데 쓰이므로 대상을 넣어 잠금으로 좋히는 편이 낫다.</p>\r\n     */\r\n    @Select(\"\"\"\r\n            SELECT s.server_pk AS serverPk,\r\n                   s.server_id AS serverId,\r\n                   a.account_pk AS accountPk,\r\n                   a.login_id AS accountId,\r\n                   a.actor_type AS actorType,\r\n                   a.enabled AS enabled,\r\n                   a.deleted_at_wall_ms AS deletedAtWallMs,\r\n                   s.control_ip AS controlIp,\r\n                   s.control_port AS controlPort,\r\n                   sc.enabled AS streamingEnabled,\r\n                   sc.streaming_bind_ip AS streamingBindIp,\r\n                   sc.rtsp_output_mode AS rtspOutputMode,\r\n                   sc.rtsp_port AS rtspPort,\r\n                   sc.http_port AS httpPort,\r\n                   sc.rtc_port AS rtcPort\r\n            FROM SERVER s\r\n            INNER JOIN ACCOUNT a ON a.server_id = s.server_id\r\n            LEFT JOIN STREAMING_CONFIG sc ON sc.server_pk = s.server_pk\r\n            WHERE s.server_pk = #{serverPk}\r\n              AND a.actor_type IN ('REC', 'MEDIA', 'AI')\r\n            FOR UPDATE\r\n            \"\"\")\r\n    Optional<ServerRecord> findManagedServerForUpdate(@Param(\"serverPk\") long serverPk);\r\n\r\n    @Select(\"SELECT COUNT(*) FROM CAMERA WHERE rec_server_pk = #{serverPk}\")\r\n    long countCameras(@Param(\"serverPk\") long serverPk);\r\n\r\n    @Select(\"SELECT COUNT(*) FROM CAMERA_GROUP WHERE rec_server_pk = #{serverPk}\")\r\n    long countCameraGroups(@Param(\"serverPk\") long serverPk);\r\n\r\n    /**\r\n     * 이 서버를 AI 로 배정받은 카메라 수. {@code CAMERA.ai_server_pk} 는 nullable 이지만\r\n     * FK 가 {@code ON DELETE RESTRICT} 라 한 행이라도 남아 있으면 서버를 지울 수 없다.\r\n     *\r\n     * <p>soft delete 된 카메라도 센다. 지운 것으로 표시됐을 뿐 행은 그대로 있고, FK 를\r\n     * 잡는 것은 표시가 아니라 행이다. {@link #countCameras} 와 같은 기준이다.</p>\r\n     */\r\n    @Select(\"SELECT COUNT(*) FROM CAMERA WHERE ai_server_pk = #{serverPk}\")\r\n    long countAiAssignedCameras(@Param(\"serverPk\") long serverPk);\r\n\r\n    /**\r\n     * 이 AI 서버가 기록한 탐지 이벤트 수.\r\n     *\r\n     * <p>{@code AI_EVENT.ai_server_pk} 는 {@code NOT NULL} 이며 FK 가 {@code RESTRICT} 다.\r\n     * 즉 이벤트를 남긴 채 서버만 지우는 것은 스키마가 허용하지 않는다. 이력을 지우지\r\n     * 않기로 했으므로(2026-09-16 결정) 이 수가 0 이 아니면 삭제를 거절한다.</p>\r\n     */\r\n    @Select(\"SELECT COUNT(*) FROM AI_EVENT WHERE ai_server_pk = #{serverPk}\")\r\n    long countAiEvents(@Param(\"serverPk\") long serverPk);\r\n\r\n    @Select(\"\"\"\r\n            SELECT EXISTS (\r\n                SELECT 1\r\n                FROM SERVER other_server\r\n                INNER JOIN ACCOUNT other_account ON other_account.server_id = other_server.server_id\r\n                WHERE other_server.server_pk <> #{serverPk}\r\n                  AND other_account.enabled = TRUE\r\n                  AND other_account.deleted_at_wall_ms IS NULL\r\n                  AND other_server.control_ip = #{controlIp}\r\n                  AND other_server.control_port = #{controlPort}\r\n            )\r\n            \"\"\")\r\n    boolean activeEndpointExists(@Param(\"serverPk\") long serverPk, @Param(\"controlIp\") String controlIp,\r\n            @Param(\"controlPort\") int controlPort);\r\n\r\n    @Update(\"\"\"\r\n            UPDATE SERVER\r\n            SET control_ip = #{controlIp},\r\n                control_port = #{controlPort},\r\n                updated_at_wall_ms = #{nowWallMs}\r\n            WHERE server_pk = #{serverPk}\r\n            \"\"\")\r\n    int updateServerEndpointByPk(@Param(\"serverPk\") long serverPk, @Param(\"controlIp\") String controlIp,\r\n            @Param(\"controlPort\") int controlPort, @Param(\"nowWallMs\") long nowWallMs);\r\n\r\n    @Update(\"\"\"\r\n            UPDATE SERVER\r\n            SET control_ip = NULL,\r\n                control_port = NULL,\r\n                updated_at_wall_ms = #{nowWallMs}\r\n            WHERE server_pk = #{serverPk}\r\n            \"\"\")\r\n    int clearServerEndpoint(@Param(\"serverPk\") long serverPk, @Param(\"nowWallMs\") long nowWallMs);\r\n\r\n    @Update(\"\"\"\r\n            UPDATE ACCOUNT\r\n            SET enabled = #{enabled},\r\n                updated_at_wall_ms = #{nowWallMs}\r\n            WHERE account_pk = #{accountPk}\r\n            \"\"\")\r\n    int updateAccountEnabled(@Param(\"accountPk\") long accountPk, @Param(\"enabled\") boolean enabled,\r\n            @Param(\"nowWallMs\") long nowWallMs);\r\n\r\n    @Delete(\"DELETE FROM ACCESS_TOKEN WHERE account_pk = #{accountPk}\")\r\n    int deleteAccessTokens(@Param(\"accountPk\") long accountPk);\r\n\r\n    @Delete(\"DELETE FROM ACTIVE_ALERT WHERE server_pk = #{serverPk}\")\r\n    int deleteActiveAlerts(@Param(\"serverPk\") long serverPk);\r\n\r\n    @Delete(\"DELETE FROM SERVER_LOG WHERE server_pk = #{serverPk}\")\r\n    int deleteServerLogs(@Param(\"serverPk\") long serverPk);\r\n\r\n    @Delete(\"DELETE FROM SERVER_RESOURCE_ID_ALLOCATION WHERE server_pk = #{serverPk}\")\r\n    int deleteResourceAllocations(@Param(\"serverPk\") long serverPk);\r\n\r\n    @Delete(\"DELETE FROM SERVER_RESOURCE_ID_COUNTER WHERE server_pk = #{serverPk}\")\r\n    int deleteResourceCounters(@Param(\"serverPk\") long serverPk);\r\n\r\n    @Delete(\"DELETE FROM STREAMING_CONFIG WHERE server_pk = #{serverPk}\")\r\n    int deleteStreamingConfig(@Param(\"serverPk\") long serverPk);\r\n\r\n    @Delete(\"DELETE FROM AUDIT_LOG WHERE account_pk = #{accountPk}\")\r\n    int deleteAccountAuditLogs(@Param(\"accountPk\") long accountPk);\r\n\r\n    @Delete(\"DELETE FROM SERVER WHERE server_pk = #{serverPk}\")\r\n    int deleteServer(@Param(\"serverPk\") long serverPk);\r\n\r\n    @Delete(\"DELETE FROM ACCOUNT WHERE account_pk = #{accountPk}\")\r\n    int deleteAccount(@Param(\"accountPk\") long accountPk);\r\n\r\n    record ServerRecord(long serverPk, String serverId, long accountPk, String accountId, String actorType,\r\n            boolean enabled, Long deletedAtWallMs, String controlIp, Integer controlPort,\r\n            Boolean streamingEnabled, String streamingBindIp, String rtspOutputMode, Integer rtspPort,\r\n            Integer httpPort, Integer rtcPort) {\r\n    }\r\n\r\n    /**\r\n     * 서버 관리 화면 목록 한 줄.\r\n     *\r\n     * <p>{@code streamingEnabled} 가 {@code null} 이면 {@code STREAMING_CONFIG} 행이 없다는\r\n     * 뜻이다. AI 서버는 송출 설정을 갖지 않으므로 항상 그렇다.</p>\r\n     */\r\n    record ManagedServerRow(String serverId, String accountId, String actorType, boolean enabled,\r\n            Long lastLoginAtWallMs, String controlIp, Integer controlPort, Boolean streamingEnabled,\r\n            String streamingBindIp, String rtspOutputMode, Integer rtspPort, Integer httpPort,\r\n            Integer rtcPort) {\r\n    }\r\n\r\n    record ConnectionEndpoint(String serverId, String controlIp, Integer controlPort) {\r\n    }\r\n\r\n    /**\r\n     * 저장된 AI 접속정보 한 줄. 비밀은 담지 않는다.\r\n     *\r\n     * <p>{@code connectionConfigVersion} 은 이 접속정보의 판 번호이며 {@code desiredConfigVersion}\r\n     * 이나 카메라 {@code configVersion} 과 무관하다.</p>\r\n     */\r\n    record AiConnectionEndpoint(String serverId, String advertisedIp, Integer controlPort,\r\n            Long connectionConfigVersion) {\r\n    }\r\n}\r\n",
    "shortcuts": [
      {
        "name": "i-VPM Manager.lnk",
        "target": "C:\\icerti\\i-VPM Manager\\i-VPM Manager.exe",
        "working_directory": "C:\\icerti\\i-VPM Manager"
      },
      {
        "name": "i-VPM Plus.lnk",
        "target": "C:\\Program Files\\iCerti\\iVPM\\i-VPM Plus___\\iVPMPlusLauncher.exe",
        "working_directory": "C:\\Program Files\\iCerti\\iVPM\\i-VPM Plus___"
      },
      {
        "name": "i-VPM SecuEditor.lnk",
        "target": "C:\\Program Files\\iCerti\\iVPM\\i-VPM SecuEditor\\i-VPM SecuEditor.exe",
        "working_directory": "C:\\Program Files\\iCerti\\iVPM\\i-VPM SecuEditor"
      },
      {
        "name": "IVPMPlus.exe - 바로 가기.lnk",
        "target": "C:\\Program Files\\iCerti\\iVPM\\i-VPM Plus\\IVPMPlus.exe",
        "working_directory": "C:\\Program Files\\iCerti\\iVPM\\i-VPM Plus"
      },
      {
        "name": "iVPMViewer.exe - 바로 가기.lnk",
        "target": "C:\\Program Files\\iCerti\\iVPM\\i-VPM Viewer\\iVPMViewer.exe",
        "working_directory": "C:\\Program Files\\iCerti\\iVPM\\i-VPM Viewer"
      }
    ],
    "chrome_files": [
      {
        "Name": "process_details.csv",
        "Length": 6693832,
        "modified": "2026-10-28T14:41:53.4325932+09:00"
      },
      {
        "Name": "system_summary.csv",
        "Length": 100543,
        "modified": "2026-10-28T14:41:53.3169667+09:00"
      }
    ],
    "chrome_csv_header": "time,pidPrivateMB,ramMB,chromeTotalGB,commitGB,commitLimitGB",
    "chrome_monitor_script": "param(\n    [int]$IntervalSeconds = 5,\n    [string]$OutputDirectory = \"$env:USERPROFILE\\Desktop\\ChromeMonitor\"\n)\n\nNew-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null\n$summaryPath = Join-Path $OutputDirectory 'system_summary.csv'\n$processPath = Join-Path $OutputDirectory 'process_details.csv'\n\nWrite-Host \"Monitoring every $IntervalSeconds seconds. Press Ctrl+C to stop.\"\nWrite-Host \"Results: $OutputDirectory\"\n\nwhile ($true) {\n    $now = (Get-Date).ToString('o')\n    try {\n        $os = Get-CimInstance Win32_OperatingSystem\n        $procs = @(Get-CimInstance Win32_Process -Filter \"Name='chrome.exe' OR Name='vmmemWSL.exe' OR Name='IVPMPlus.exe'\")\n        $perfByPid = @{}\n        foreach ($proc in (Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.ProcessName -in @(\"chrome\", \"vmmemWSL\", \"IVPMPlus\") })) {\n            $perfByPid[$proc.Id] = $proc\n        }\n        $totalRamMB = [math]::Round($os.TotalVisibleMemorySize / 1024, 1)\n        $freeRamMB = [math]::Round($os.FreePhysicalMemory / 1024, 1)\n        $chrome = @($procs | Where-Object Name -eq 'chrome.exe')\n        $chromeCommitMB = [math]::Round(((@($chrome | ForEach-Object { if ($perfByPid.ContainsKey([int]$_.ProcessId)) { $perfByPid[[int]$_.ProcessId].PrivateMemorySize64 } }) | Measure-Object -Sum).Sum) / 1MB, 1)\n        $chromeWorkingSetMB = [math]::Round((($chrome | Measure-Object -Property WorkingSetSize -Sum).Sum) / 1MB, 1)\n\n        $disk = Get-CimInstance Win32_PerfFormattedData_PerfDisk_PhysicalDisk -ErrorAction Stop |\n            Where-Object { $_.Name -eq '_Total' } | Select-Object -First 1\n        $diskBusy = if ($null -ne $disk) { $disk.PercentDiskTime } else { $null }\n        $diskReadBps = if ($null -ne $disk) { $disk.DiskReadBytesPerSec } else { $null }\n        $diskWriteBps = if ($null -ne $disk) { $disk.DiskWriteBytesPerSec } else { $null }\n\n        [pscustomobject]@{\n            Timestamp = $now\n            TotalRAM_MB = $totalRamMB\n            FreeRAM_MB = $freeRamMB\n            ChromeProcessCount = $chrome.Count\n            ChromePrivateCommit_MB = $chromeCommitMB\n            ChromeWorkingSetSum_MB = $chromeWorkingSetMB\n            DiskBusyPercent = $diskBusy\n            DiskReadBytesPerSec = $diskReadBps\n            DiskWriteBytesPerSec = $diskWriteBps\n        } | Export-Csv -Path $summaryPath -NoTypeInformation -Encoding UTF8 -Append\n\n        foreach ($p in $procs) {\n            [pscustomobject]@{\n                Timestamp = $now\n                Name = $p.Name\n                PID = $p.ProcessId\n                ParentPID = $p.ParentProcessId\n                PrivateCommit_MB = if ($perfByPid.ContainsKey([int]$p.ProcessId)) { [math]::Round($perfByPid[[int]$p.ProcessId].PrivateMemorySize64 / 1MB, 1) } else { $null }\n                WorkingSet_MB = [math]::Round($p.WorkingSetSize / 1MB, 1)\n                CommandLine = $p.CommandLine\n            } | Export-Csv -Path $processPath -NoTypeInformation -Encoding UTF8 -Append\n        }\n        Write-Host \"[$now] Chrome count=$($chrome.Count), private commit=${chromeCommitMB}MB; free RAM=${freeRamMB}MB; disk busy=$diskBusy%\"\n    } catch {\n        Write-Warning \"[$now] Sample failed: $($_.Exception.Message)\"\n    }\n    Start-Sleep -Seconds ([math]::Max(1, $IntervalSeconds))\n}\n"
  }
}
