diff --git a/aw-server/aw-ru-patch.js b/aw-server/aw-ru-patch.js index 2491d05..92a5669 100755 --- a/aw-server/aw-ru-patch.js +++ b/aw-server/aw-ru-patch.js @@ -1,6 +1,6 @@ (function () { - window.__awRuPatchVersion = "template-v12-activity-heading-ru"; - document.documentElement.setAttribute("data-aw-ru-patch", "template-v12-activity-heading-ru"); + window.__awRuPatchVersion = "template-v13-pve-audit-day-filter"; + document.documentElement.setAttribute("data-aw-ru-patch", "template-v13-pve-audit-day-filter"); const exact = new Map([ ["ActivityWatch", "АктивВотч"], @@ -333,6 +333,11 @@ '.aw-ru-host-item-title { font-weight: 600; margin-bottom: 6px; }', '.aw-ru-host-links { display: flex; flex-wrap: wrap; gap: 6px; }', '.aw-ru-host-links a { display: inline-block; padding: 4px 8px; border-radius: 999px; background: rgba(90,140,255,.15); text-decoration: none; }', + '.aw-ru-worktime-center { margin: 16px 0; padding: 16px; border: 1px solid rgba(120,120,120,.35); border-radius: 8px; background: rgba(20,20,20,.03); }', + '.aw-ru-worktime-controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: end; margin-bottom: 10px; }', + '.aw-ru-worktime-controls label { font-size: 12px; display:flex; flex-direction:column; gap:4px; }', + '.aw-ru-worktime-table { width: 100%; border-collapse: collapse; font-size: 13px; }', + '.aw-ru-worktime-table th, .aw-ru-worktime-table td { border: 1px solid rgba(120,120,120,.25); padding: 6px 8px; text-align: left; }', '.aw-ru-pve-audit { margin: 16px 0; padding: 16px; border: 1px solid rgba(120,120,120,.35); border-radius: 8px; background: rgba(10,20,40,.04); }', '.aw-ru-pve-audit-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; margin: 12px 0 16px; }', '.aw-ru-pve-audit-card { border: 1px solid rgba(120,120,120,.22); border-radius: 8px; padding: 12px; background: rgba(255,255,255,.02); }', @@ -735,6 +740,23 @@ return !!(match && isPveLikeHost(decodeURIComponent(match[1] || ""))); } + function getSelectedDayFromHash() { + const hash = window.location.hash || ""; + const match = hash.match(/^#\/activity\/[^/]+\/day\/([^/?#]+)/i); + return match && match[1] ? decodeURIComponent(match[1]) : ""; + } + + function filterEventsBySelectedDay(events) { + const day = getSelectedDayFromHash(); + if (!day) return Array.isArray(events) ? events : []; + const start = new Date(day + "T00:00:00"); + const end = new Date(day + "T23:59:59.999"); + return (Array.isArray(events) ? events : []).filter(function (event) { + const timestamp = event && event.timestamp ? new Date(event.timestamp) : null; + return timestamp && !isNaN(timestamp.getTime()) && timestamp >= start && timestamp <= end; + }); + } + function extractHostFromBucket(bucketId, bucketMeta) { if (bucketMeta && bucketMeta.hostname) return String(bucketMeta.hostname); const prefixes = [ @@ -1306,10 +1328,10 @@ loadBucketEvents("aw-console-commands_" + host, 50).catch(function () { return []; }) ]); const data = { - web: webEvents || [], - tasks: taskEvents || [], - ssh: sshEvents || [], - cmd: cmdEvents || [] + web: filterEventsBySelectedDay(webEvents || []), + tasks: filterEventsBySelectedDay(taskEvents || []), + ssh: filterEventsBySelectedDay(sshEvents || []), + cmd: filterEventsBySelectedDay(cmdEvents || []) }; center.querySelector("[data-aw-ru-pve-web-count]").textContent = String(data.web.length); center.querySelector("[data-aw-ru-pve-task-count]").textContent = String(data.tasks.length); @@ -1584,6 +1606,124 @@ }); } + function ms(v) { + return new Date(v).getTime(); + } + + function fmtHours(sec) { + return (Math.round((sec / 3600) * 100) / 100).toFixed(2); + } + + function rangesIntersect(a0, a1, b0, b1) { + return Math.max(a0, b0) < Math.min(a1, b1); + } + + function intersectDurationSeconds(a0, a1, b0, b1) { + return Math.max(0, (Math.min(a1, b1) - Math.max(a0, b0)) / 1000); + } + + async function renderPersonalWorktime(host, fromIso, toIso, target) { + const sessionBucket = "aw-worktime-sessions_" + host; + const windowBucket = "aw-watcher-window_" + host; + const afkBucket = "aw-watcher-afk_" + host; + const [sessionEvents, windowEvents, afkEvents] = await Promise.all([ + loadBucketEvents(sessionBucket, 5000).catch(function () { return []; }), + loadBucketEvents(windowBucket, 10000).catch(function () { return []; }), + loadBucketEvents(afkBucket, 10000).catch(function () { return []; }) + ]); + + const fromMs = ms(fromIso); + const toMs = ms(toIso); + const userRanges = {}; + (sessionEvents || []).forEach(function (e) { + const d = e && e.data ? e.data : {}; + const u = String(d.username || "").trim(); + if (!u) return; + const t0 = ms(e.timestamp); + const t1 = t0 + Math.max(1, Number(e.duration || 0)) * 1000; + if (!rangesIntersect(t0, t1, fromMs, toMs)) return; + userRanges[u] = userRanges[u] || []; + userRanges[u].push([t0, t1]); + }); + + const activeRanges = []; + const afkByTs = new Map(); + (afkEvents || []).forEach(function (e) { afkByTs.set(String(e.timestamp || ""), e); }); + (windowEvents || []).forEach(function (e) { + const t0 = ms(e.timestamp); + const t1 = t0 + Math.max(1, Number(e.duration || 0)) * 1000; + if (!rangesIntersect(t0, t1, fromMs, toMs)) return; + const afk = afkByTs.get(String(e.timestamp || "")); + const status = String(afk && afk.data && afk.data.status || ""); + if (status && status.toLowerCase() === "afk") return; + activeRanges.push([t0, t1]); + }); + + const rows = Object.keys(userRanges).sort().map(function (u) { + let presentSec = 0; + let activeSec = 0; + userRanges[u].forEach(function (r) { + presentSec += intersectDurationSeconds(r[0], r[1], fromMs, toMs); + activeRanges.forEach(function (a) { + activeSec += intersectDurationSeconds(r[0], r[1], a[0], a[1]); + }); + }); + const idleSec = Math.max(0, presentSec - activeSec); + return { user: u, presentSec: presentSec, activeSec: activeSec, idleSec: idleSec }; + }); + + if (!rows.length) { + target.innerHTML = '
Нет данных в aw-worktime-sessions_' + host + ' за выбранный интервал.
'; + return; + } + + target.innerHTML = '' + + rows.map(function (r) { + return ''; + }).join("") + + '
ПользовательАктивно, чВ сессии, чПростой, ч
' + r.user + '' + fmtHours(r.activeSec) + '' + fmtHours(r.presentSec) + '' + fmtHours(r.idleSec) + '
'; + } + + async function injectPersonalWorktimeCenter(root) { + const isActivity = /^#\/activity\//i.test(window.location.hash || ""); + if (!isActivity) return; + if (root.querySelector(".aw-ru-worktime-center")) return; + const host = getCurrentHostFromHash(); + if (!host) return; + + const container = document.createElement("div"); + container.className = "aw-ru-worktime-center"; + const now = new Date(); + const day = now.toISOString().slice(0, 10); + container.innerHTML = + '

Персональный учёт рабочего времени

' + + '
' + + '' + + '' + + '' + + '' + + '
' + + '
Нажмите "Рассчитать".
'; + + root.insertBefore(container, root.firstChild); + + const run = async function () { + const date = container.querySelector("[data-aw-wt-date]").value; + const tFrom = container.querySelector("[data-aw-wt-from]").value || "09:00"; + const tTo = container.querySelector("[data-aw-wt-to]").value || "18:00"; + const result = container.querySelector("[data-aw-wt-result]"); + result.textContent = "Расчёт..."; + const fromIso = new Date(date + "T" + tFrom + ":00").toISOString(); + const toIso = new Date(date + "T" + tTo + ":00").toISOString(); + try { + await renderPersonalWorktime(host, fromIso, toIso, result); + } catch (e) { + result.textContent = "Ошибка расчёта: " + String(e.message || e); + } + }; + container.querySelector("[data-aw-wt-run]").addEventListener("click", function () { run(); }); + } + function applyPatch() { enforceSafeActivityViewForPveHost(); ensureSettingsHost(); @@ -1599,6 +1739,7 @@ injectDlpNavigation(document.body); injectDlpReviewCenter(document.body); injectDlpAlertsCenter(document.body); + injectPersonalWorktimeCenter(document.body).catch(function () {}); injectHostGroupsCenter(document.body).catch(function () {}); redirectBareTrendsRoute(); } diff --git a/windows/ActivityWatch.Windows.Common.psm1 b/windows/ActivityWatch.Windows.Common.psm1 index 4bca474..fb36dca 100755 --- a/windows/ActivityWatch.Windows.Common.psm1 +++ b/windows/ActivityWatch.Windows.Common.psm1 @@ -243,6 +243,8 @@ function Copy-ActivityWatchCollectorAssets { [Parameter(Mandatory = $true)] [string]$EndpointCollectorScriptSource, [Parameter(Mandatory = $true)] + [string]$SessionCollectorScriptSource, + [Parameter(Mandatory = $true)] [string]$ExampleRulesSource, [Parameter(Mandatory = $true)] [string]$ExamplePolicySource, @@ -256,6 +258,7 @@ function Copy-ActivityWatchCollectorAssets { $collectorTarget = Join-Path $StateRoot 'browser-domains-native-collector.ps1' $endpointCollectorTarget = Join-Path $StateRoot 'dlp-endpoint-signals-collector.ps1' + $sessionCollectorTarget = Join-Path $StateRoot 'worktime-session-collector.ps1' $exampleRulesTarget = Join-Path $StateRoot 'web-category-rules.example.json' $rulesTarget = Join-Path $StateRoot 'web-category-rules.json' $examplePolicyTarget = Join-Path $StateRoot 'dlp-policy.example.json' @@ -263,6 +266,7 @@ function Copy-ActivityWatchCollectorAssets { Copy-Item -LiteralPath $CollectorScriptSource -Destination $collectorTarget -Force Copy-Item -LiteralPath $EndpointCollectorScriptSource -Destination $endpointCollectorTarget -Force + Copy-Item -LiteralPath $SessionCollectorScriptSource -Destination $sessionCollectorTarget -Force Copy-Item -LiteralPath $ExampleRulesSource -Destination $exampleRulesTarget -Force Copy-Item -LiteralPath $ExamplePolicySource -Destination $examplePolicyTarget -Force @@ -282,6 +286,7 @@ function Copy-ActivityWatchCollectorAssets { return [pscustomobject]@{ CollectorScript = $collectorTarget EndpointCollectorScript = $endpointCollectorTarget + SessionCollectorScript = $sessionCollectorTarget ExampleRules = $exampleRulesTarget ActiveRules = $rulesTarget ExamplePolicy = $examplePolicyTarget @@ -308,6 +313,8 @@ function New-ActivityWatchDeploymentConfig { [Parameter(Mandatory = $true)] [string]$EndpointCollectorScript, [Parameter(Mandatory = $true)] + [string]$SessionCollectorScript, + [Parameter(Mandatory = $true)] [string]$RulesPath, [Parameter(Mandatory = $true)] [string]$PolicyPath, @@ -349,6 +356,7 @@ function New-ActivityWatchDeploymentConfig { logsRoot = $LogsRoot collectorScript = $CollectorScript endpointCollectorScript = $EndpointCollectorScript + sessionCollectorScript = $SessionCollectorScript rulesPath = $RulesPath policyPath = $PolicyPath launchScript = $LaunchScriptPath @@ -631,6 +639,7 @@ function Start-CollectorScriptIfNeeded { `$script:KnownBuckets = @{} `$collectorScript = [string]`$config.paths.collectorScript `$endpointCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]`$config.paths.endpointCollectorScript } else { '' } +`$sessionCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]`$config.paths.sessionCollectorScript } else { '' } `$afkExe = Join-Path `$installRoot 'aw-watcher-afk\aw-watcher-afk.exe' `$windowExe = Join-Path `$installRoot 'aw-watcher-window\aw-watcher-window.exe' `$serverArgs = @('--host', [string]`$config.server.host, '--port', [string]`$config.server.port) @@ -661,6 +670,7 @@ catch { } Start-CollectorScriptIfNeeded -ScriptPath `$collectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId Start-CollectorScriptIfNeeded -ScriptPath `$endpointCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId +Start-CollectorScriptIfNeeded -ScriptPath `$sessionCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId "@ Set-Content -LiteralPath $Path -Value $content -Encoding UTF8 diff --git a/windows/deploy-domain-users.ps1 b/windows/deploy-domain-users.ps1 index 936ce3c..c6ca412 100755 --- a/windows/deploy-domain-users.ps1 +++ b/windows/deploy-domain-users.ps1 @@ -44,6 +44,7 @@ $launchScriptPath = Join-Path $StateRoot 'launch-watchers.ps1' $recoveryScriptPath = Join-Path $StateRoot 'recovery-loop.ps1' $collectorSource = Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1' $endpointCollectorSource = Join-Path $PSScriptRoot 'dlp-endpoint-signals-collector.ps1' +$sessionCollectorSource = Join-Path $PSScriptRoot 'worktime-session-collector.ps1' $exampleRulesSource = Join-Path $PSScriptRoot 'web-category-rules.example.json' $examplePolicySource = Join-Path $PSScriptRoot 'dlp-policy.example.json' @@ -57,6 +58,7 @@ Get-ActivityWatchExecutableMap -InstallRoot $InstallRoot | Out-Null $assetResult = Copy-ActivityWatchCollectorAssets ` -CollectorScriptSource $collectorSource ` -EndpointCollectorScriptSource $endpointCollectorSource ` + -SessionCollectorScriptSource $sessionCollectorSource ` -ExampleRulesSource $exampleRulesSource ` -ExamplePolicySource $examplePolicySource ` -StateRoot $StateRoot ` @@ -76,6 +78,7 @@ $config = New-ActivityWatchDeploymentConfig ` -LogsRoot $logsRoot ` -CollectorScript $assetResult.CollectorScript ` -EndpointCollectorScript $assetResult.EndpointCollectorScript ` + -SessionCollectorScript $assetResult.SessionCollectorScript ` -RulesPath $assetResult.ActiveRules ` -PolicyPath $assetResult.ActivePolicy ` -PollSeconds $PollSeconds ` diff --git a/windows/deploy-single-user.ps1 b/windows/deploy-single-user.ps1 index d3eafef..dfd24b4 100755 --- a/windows/deploy-single-user.ps1 +++ b/windows/deploy-single-user.ps1 @@ -42,6 +42,7 @@ $launchScriptPath = Join-Path $StateRoot 'launch-watchers.ps1' $recoveryScriptPath = Join-Path $StateRoot 'recovery-loop.ps1' $collectorSource = Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1' $endpointCollectorSource = Join-Path $PSScriptRoot 'dlp-endpoint-signals-collector.ps1' +$sessionCollectorSource = Join-Path $PSScriptRoot 'worktime-session-collector.ps1' $exampleRulesSource = Join-Path $PSScriptRoot 'web-category-rules.example.json' $examplePolicySource = Join-Path $PSScriptRoot 'dlp-policy.example.json' @@ -55,6 +56,7 @@ Get-ActivityWatchExecutableMap -InstallRoot $InstallRoot | Out-Null $assetResult = Copy-ActivityWatchCollectorAssets ` -CollectorScriptSource $collectorSource ` -EndpointCollectorScriptSource $endpointCollectorSource ` + -SessionCollectorScriptSource $sessionCollectorSource ` -ExampleRulesSource $exampleRulesSource ` -ExamplePolicySource $examplePolicySource ` -StateRoot $StateRoot ` @@ -74,6 +76,7 @@ $config = New-ActivityWatchDeploymentConfig ` -LogsRoot $logsRoot ` -CollectorScript $assetResult.CollectorScript ` -EndpointCollectorScript $assetResult.EndpointCollectorScript ` + -SessionCollectorScript $assetResult.SessionCollectorScript ` -RulesPath $assetResult.ActiveRules ` -PolicyPath $assetResult.ActivePolicy ` -PollSeconds $PollSeconds ` diff --git a/windows/hardening-recovery.ps1 b/windows/hardening-recovery.ps1 index c908b2f..1ab1f74 100755 --- a/windows/hardening-recovery.ps1 +++ b/windows/hardening-recovery.ps1 @@ -53,6 +53,7 @@ $effectiveLaunchScript = Join-Path $effectiveStateRoot 'launch-watchers.ps1' $effectiveRecoveryScript = Join-Path $effectiveStateRoot 'recovery-loop.ps1' $effectiveCollector = Join-Path $effectiveStateRoot 'browser-domains-native-collector.ps1' $effectiveEndpointCollector = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]$existingConfig.paths.endpointCollectorScript } else { Join-Path $effectiveStateRoot 'dlp-endpoint-signals-collector.ps1' } +$effectiveSessionCollector = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]$existingConfig.paths.sessionCollectorScript } else { Join-Path $effectiveStateRoot 'worktime-session-collector.ps1' } $effectiveRules = Join-Path $effectiveStateRoot 'web-category-rules.json' $effectivePolicy = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$existingConfig.paths.policyPath } else { Join-Path $effectiveStateRoot 'dlp-policy.json' } @@ -96,6 +97,7 @@ Get-ActivityWatchExecutableMap -InstallRoot $effectiveInstallRoot | Out-Null $assetResult = Copy-ActivityWatchCollectorAssets ` -CollectorScriptSource (Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1') ` -EndpointCollectorScriptSource (Join-Path $PSScriptRoot 'dlp-endpoint-signals-collector.ps1') ` + -SessionCollectorScriptSource (Join-Path $PSScriptRoot 'worktime-session-collector.ps1') ` -ExampleRulesSource (Join-Path $PSScriptRoot 'web-category-rules.example.json') ` -ExamplePolicySource (Join-Path $PSScriptRoot 'dlp-policy.example.json') ` -StateRoot $effectiveStateRoot ` @@ -115,6 +117,7 @@ $config = New-ActivityWatchDeploymentConfig ` -LogsRoot $effectiveLogsRoot ` -CollectorScript $effectiveCollector ` -EndpointCollectorScript $effectiveEndpointCollector ` + -SessionCollectorScript $effectiveSessionCollector ` -RulesPath $effectiveRules ` -PolicyPath $effectivePolicy ` -PollSeconds $effectivePollSeconds ` diff --git a/windows/worktime-session-collector.ps1 b/windows/worktime-session-collector.ps1 new file mode 100644 index 0000000..8575a9f --- /dev/null +++ b/windows/worktime-session-collector.ps1 @@ -0,0 +1,120 @@ +param( + [string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json', + [string]$Hostname, + [int]$PollSeconds = 30 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-Config { + param([string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { throw "Config not found: $Path" } + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Json + ) + $bytes = [Text.Encoding]::UTF8.GetBytes($Json) + Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null +} + +function Ensure-Bucket { + param( + [Parameter(Mandatory = $true)][string]$ApiBase, + [Parameter(Mandatory = $true)][string]$BucketId, + [Parameter(Mandatory = $true)][string]$HostnameValue + ) + try { + Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null + return + } + catch { + } + + $body = @{ + client = 'aw-worktime-session-collector' + type = 'aw.worktime.session' + hostname = $HostnameValue + } | ConvertTo-Json -Compress + Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId" -Json $body +} + +function Get-SessionRecords { + $records = @() + try { + $lines = (quser 2>$null) + if (-not $lines) { return @() } + foreach ($line in $lines | Select-Object -Skip 1) { + $clean = ($line -replace '^\s*>?', '').Trim() + if (-not $clean) { continue } + $parts = $clean -split '\s+' + if ($parts.Count -lt 4) { continue } + $user = $parts[0] + $sessionName = $parts[1] + $sessionId = 0 + if ($parts[2] -match '^\d+$') { + $sessionId = [int]$parts[2] + } + $state = $parts[3] + $records += [pscustomobject]@{ + username = $user + sessionName = $sessionName + sessionId = $sessionId + state = $state + } + } + } + catch { + } + return $records +} + +$cfg = Get-Config -Path $ConfigPath +$hostValue = if ($Hostname) { $Hostname } else { [string]$env:COMPUTERNAME } +$apiBase = '{0}://{1}:{2}/api/0' -f [string]$cfg.server.scheme, [string]$cfg.server.host, [string]$cfg.server.port +$bucketId = 'aw-worktime-sessions_' + $hostValue +$pulse = 120 +$sleepSec = if ($PollSeconds -gt 0) { $PollSeconds } elseif ($cfg.collector -and $cfg.collector.pollSeconds) { [int]$cfg.collector.pollSeconds } else { 30 } + +Ensure-Bucket -ApiBase $apiBase -BucketId $bucketId -HostnameValue $hostValue + +while ($true) { + $now = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + $records = Get-SessionRecords + if (-not $records -or $records.Count -eq 0) { + $records = @([pscustomobject]@{ + username = $env:USERNAME + sessionName = '' + sessionId = (Get-Process -Id $PID).SessionId + state = 'Unknown' + }) + } + + foreach ($rec in $records) { + $payload = @{ + timestamp = $now + duration = 0 + data = @{ + username = [string]$rec.username + userId = "$($env:USERDOMAIN)\$($rec.username)" + sessionId = [int]$rec.sessionId + sessionName = [string]$rec.sessionName + state = [string]$rec.state + active = ($rec.state -match 'Active') + hostname = $hostValue + source = 'worktime-session-collector' + } + } | ConvertTo-Json -Depth 6 -Compress + try { + Invoke-AwJsonPost -Uri "$apiBase/buckets/$bucketId/heartbeat?pulsetime=$pulse" -Json $payload + } + catch { + } + } + + Start-Sleep -Seconds $sleepSec +}