From bb200cab963ba488bdfdf5305b9e633b950fb013 Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Sun, 26 Apr 2026 15:39:40 +0300 Subject: [PATCH] feat(dlp): expose menu and document web review flow --- ansible/README.md | 1 + ansible/deploy_aw_server.yml | 6 +- aw-server/aw-ru-patch.js | 681 +++++++++++++++++++++++++++++- docs/FULL_DEPLOYMENT_MANUAL_RU.md | 41 ++ docs/deployment.md | 5 +- docs/operations.md | 13 + 6 files changed, 741 insertions(+), 6 deletions(-) diff --git a/ansible/README.md b/ansible/README.md index 4932fd1..dd998d1 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -88,5 +88,6 @@ Playbook: - Установлен ActivityWatch Server. - Создан systemd-unit `activitywatch-server.service`. - Установлен RU Web UI patch. +- Для Web UI используется checksum-based cache-bust для `ru-patch-v5.js` и `sw-cleanup.js`, чтобы браузер не держал старую DLP/русскую статику после деплоя. - Выполнена валидация API `http://127.0.0.1:5600/api/0/info`. - Для full-stack сценария CT создаётся автоматически через `pct create`. diff --git a/ansible/deploy_aw_server.yml b/ansible/deploy_aw_server.yml index e924f6d..b6038b1 100644 --- a/ansible/deploy_aw_server.yml +++ b/ansible/deploy_aw_server.yml @@ -9,6 +9,8 @@ aw_release_dir: "{{ aw_release_root }}/{{ aw_server_version }}" aw_archive_path: "/tmp/activitywatch-{{ aw_server_version }}.zip" aw_bootstrap_dir: "/tmp/aw-rus-bootstrap" + aw_ru_patch_cache_bust: "{{ lookup('file', aw_repo_root + '/aw-server/aw-ru-patch.js') | hash('sha1') | truncate(12, true, '') }}" + aw_sw_cleanup_cache_bust: "{{ lookup('file', aw_repo_root + '/aw-server/aw-sw-cleanup.js') | hash('sha1') | truncate(12, true, '') }}" tasks: - name: Install base packages @@ -103,13 +105,13 @@ ansible.builtin.replace: path: "{{ aw_server_webui_dir }}/index.html" regexp: '' - replace: '' + replace: '' - name: Insert RU patch loader before body end ansible.builtin.replace: path: "{{ aw_server_webui_dir }}/index.html" regexp: '' - replace: '' + replace: '' - name: Write /etc/activitywatch/aw-server.env ansible.builtin.copy: diff --git a/aw-server/aw-ru-patch.js b/aw-server/aw-ru-patch.js index 0aa05ab..bc21566 100755 --- a/aw-server/aw-ru-patch.js +++ b/aw-server/aw-ru-patch.js @@ -1,6 +1,6 @@ (function () { - window.__awRuPatchVersion = "template-v2"; - document.documentElement.setAttribute("data-aw-ru-patch", "template-v2"); + window.__awRuPatchVersion = "template-v4-dlp-review"; + document.documentElement.setAttribute("data-aw-ru-patch", "template-v4-dlp-review"); const exact = new Map([ ["ActivityWatch", "АктивВотч"], @@ -259,6 +259,13 @@ /\/raw\b/i ]; + const dlpVerdictOptions = [ + { value: "false_positive", label: "Ложное срабатывание" }, + { value: "allowed", label: "Разрешено" }, + { value: "review_needed", label: "На проверку" }, + { value: "incident", label: "Инцидент" } + ]; + function replaceText(text) { if (!text) return text; if (exact.has(text.trim())) { @@ -301,7 +308,21 @@ style.id = "aw-ru-hide-noise-style"; style.textContent = [ '[href*="/raw-data"], [href*="/raw"], a[data-testid*="raw"], button[data-testid*="raw"] { display: none !important; }', - '[aria-label="Raw Data"], [aria-label="Сырые данные"] { display: none !important; }' + '[aria-label="Raw Data"], [aria-label="Сырые данные"] { display: none !important; }', + '.aw-ru-dlp-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-dlp-toolbar { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-bottom: 12px; }', + '.aw-ru-dlp-toolbar input, .aw-ru-dlp-toolbar select, .aw-ru-dlp-toolbar textarea { min-height: 32px; }', + '.aw-ru-dlp-toolbar button, .aw-ru-dlp-row button { min-height: 32px; padding: 4px 10px; }', + '.aw-ru-dlp-table { width: 100%; border-collapse: collapse; font-size: 13px; }', + '.aw-ru-dlp-table th, .aw-ru-dlp-table td { border: 1px solid rgba(120,120,120,.25); padding: 6px; vertical-align: top; }', + '.aw-ru-dlp-table td input, .aw-ru-dlp-table td select { width: 100%; box-sizing: border-box; }', + '.aw-ru-dlp-muted { opacity: .55; }', + '.aw-ru-dlp-pill { display: inline-block; padding: 2px 8px; border-radius: 999px; background: rgba(90,140,255,.15); font-size: 12px; }', + '.aw-ru-dlp-status { margin-left: auto; font-size: 12px; opacity: .8; }', + '.aw-ru-dlp-message { margin-top: 8px; font-size: 12px; }', + '.aw-ru-dlp-actions { display: flex; gap: 6px; flex-wrap: wrap; }', + '.aw-ru-dlp-section { margin-top: 18px; }', + '.aw-ru-dlp-section h5 { margin: 0 0 8px; }' ].join("\n"); document.head.appendChild(style); } @@ -318,7 +339,639 @@ } } + function getCurrentHostFromHash() { + const hash = window.location.hash || ""; + const activityMatch = hash.match(/#\/activity\/([^/?#]+)/); + if (activityMatch && activityMatch[1]) return decodeURIComponent(activityMatch[1]); + const trendsMatch = hash.match(/#\/trends\/([^/?#]+)/); + if (trendsMatch && trendsMatch[1]) return decodeURIComponent(trendsMatch[1]); + return ""; + } + + function getDlpHostFromSettings(settings) { + const routeHost = getCurrentHostFromHash(); + if (routeHost) return routeHost; + const bucketHost = getDlpHostFromBucketId(getDlpBucketIdFromHash()); + if (bucketHost) return bucketHost; + return getTrendsHostFromSettings(settings); + } + + function getDlpHref(host) { + if (!host) return "#/buckets"; + return "#/buckets/" + encodeURIComponent("aw-dlp-endpoint-signals_" + host); + } + + function isDlpSignalBucketRoute() { + return /^#\/buckets\/aw-dlp-endpoint-signals_/i.test(window.location.hash || ""); + } + + function getDlpBucketIdFromHash() { + const hash = window.location.hash || ""; + const match = hash.match(/^#\/buckets\/([^/?#]+)/i); + return match && match[1] ? decodeURIComponent(match[1]) : ""; + } + + function getDlpHostFromBucketId(bucketId) { + const prefix = "aw-dlp-endpoint-signals_"; + return bucketId.startsWith(prefix) ? bucketId.slice(prefix.length) : ""; + } + + function escapeHtml(value) { + return String(value == null ? "" : value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + function normalizeText(value) { + return String(value == null ? "" : value).trim(); + } + + function buildDlpKey(event) { + const data = event && event.data ? event.data : {}; + return [ + event && event.timestamp || "", + data.signalType || "", + data.username || "", + data.owner || "", + data.documentName || "", + data.printerName || "" + ].join("|"); + } + + function generateDlpId(prefix) { + return prefix + "-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 10); + } + + async function awApiJson(url, options) { + const response = await fetch(url, Object.assign({ + credentials: "same-origin", + headers: { + "Content-Type": "application/json; charset=utf-8" + } + }, options || {})); + if (response.status === 304 || response.status === 409) { + return null; + } + if (!response.ok) { + throw new Error("aw-api-" + response.status); + } + if (response.status === 204) return null; + const text = await response.text(); + return text ? JSON.parse(text) : null; + } + + async function ensureAwBucket(bucketId, clientName, bucketType, hostname) { + await awApiJson("/api/0/buckets/" + encodeURIComponent(bucketId), { + method: "POST", + body: JSON.stringify({ + client: clientName, + type: bucketType, + hostname: hostname + }) + }); + } + + async function saveAwHeartbeat(bucketId, payload, pulsetimeSeconds) { + const pulsetime = pulsetimeSeconds || 1; + await awApiJson("/api/0/buckets/" + encodeURIComponent(bucketId) + "/heartbeat?pulsetime=" + pulsetime, { + method: "POST", + body: JSON.stringify(payload) + }); + } + + async function loadBucketEvents(bucketId, limit) { + const data = await awApiJson("/api/0/buckets/" + encodeURIComponent(bucketId) + "/events?limit=" + (limit || 100), { + method: "GET", + headers: {} + }); + return Array.isArray(data) ? data : []; + } + + function getRuleMatchFields(event) { + const data = event && event.data ? event.data : {}; + return { + signalType: normalizeText(data.signalType), + username: normalizeText(data.username), + owner: normalizeText(data.owner), + documentName: normalizeText(data.documentName), + printerName: normalizeText(data.printerName), + hostname: normalizeText(data.hostname) + }; + } + + function serializeRuleMatch(match) { + const normalized = match || {}; + return [ + normalized.signalType || "", + normalized.username || "", + normalized.owner || "", + normalized.documentName || "", + normalized.printerName || "", + normalized.hostname || "" + ].join("|"); + } + + function getRuleId(ruleEvent) { + const data = ruleEvent && ruleEvent.data ? ruleEvent.data : {}; + return normalizeText(data.ruleId) || [ + serializeRuleMatch(data.match || {}), + normalizeText(data.category), + normalizeText(data.action) + ].join("|"); + } + + function getReviewId(reviewEvent) { + const data = reviewEvent && reviewEvent.data ? reviewEvent.data : {}; + const review = data.review || {}; + return normalizeText(review.reviewId) || [ + data.sourceEvent && data.sourceEvent.timestamp || "", + data.sourceEvent && data.sourceEvent.data ? buildDlpKey({ timestamp: data.sourceEvent.timestamp, data: data.sourceEvent.data }) : "", + normalizeText(review.verdict), + normalizeText(review.category) + ].join("|"); + } + + function collapseRuleEvents(events) { + const ordered = (events || []).slice().sort(function (a, b) { + return String(a.timestamp).localeCompare(String(b.timestamp)); + }); + const map = new Map(); + ordered.forEach(function (event) { + map.set(getRuleId(event), event); + }); + return Array.from(map.values()).sort(function (a, b) { + return String(b.timestamp).localeCompare(String(a.timestamp)); + }); + } + + function collapseReviewEvents(events) { + const ordered = (events || []).slice().sort(function (a, b) { + return String(a.timestamp).localeCompare(String(b.timestamp)); + }); + const map = new Map(); + ordered.forEach(function (event) { + map.set(getReviewId(event), event); + }); + return Array.from(map.values()).sort(function (a, b) { + return String(b.timestamp).localeCompare(String(a.timestamp)); + }); + } + + function ruleMatchesEvent(rule, event) { + const eventFields = getRuleMatchFields(event); + const match = rule && rule.data && rule.data.match ? rule.data.match : {}; + return Object.keys(eventFields).every(function (key) { + const ruleValue = normalizeText(match[key]); + return !ruleValue || ruleValue === eventFields[key]; + }); + } + + function getSuppressionState() { + if (!window.__awRuDlpState) { + window.__awRuDlpState = { + rules: [], + activeRules: [], + reviews: [], + events: [], + loading: false + }; + } + return window.__awRuDlpState; + } + + function removeBadDlpLinks(root) { + const badLinks = root.querySelectorAll("a[href*='/view/DLP']"); + badLinks.forEach(function (link) { + const item = link.closest("li") || link; + item.remove(); + }); + } + + function updateDlpLinks(root, href) { + const links = root.querySelectorAll("a[data-aw-ru-dlp-link='1']"); + for (const link of links) { + if (link.getAttribute("href") !== href) { + link.setAttribute("href", href); + } + } + } + + function buildDlpNavItem(templateItem, href) { + const templateLink = templateItem.querySelector("a[href], [role='link']"); + if (!templateLink) return null; + + const item = document.createElement("li"); + item.setAttribute("data-aw-ru-dlp-item", "1"); + item.className = templateItem.className || ""; + + const link = document.createElement("a"); + link.setAttribute("href", href); + link.setAttribute("data-aw-ru-dlp-link", "1"); + link.textContent = "DLP"; + link.className = templateLink.className || ""; + + item.appendChild(link); + return item; + } + + function findPrimaryNavList(root) { + const navLists = Array.from(root.querySelectorAll("nav ul")); + return navLists.find(function (list) { + const labels = Array.from(list.querySelectorAll("a")) + .map(function (link) { return normalizeText(link.textContent); }) + .filter(Boolean); + return labels.includes("Главная") || + labels.includes("Home") || + labels.includes("Активность") || + labels.includes("Activity"); + }) || null; + } + + function injectDlpNavigation(root) { + const href = getDlpHref(window.__awRuPatchSettingsHost || getCurrentHostFromHash()); + removeBadDlpLinks(root); + updateDlpLinks(root, href); + if (root.querySelector("[data-aw-ru-dlp-item='1']")) return; + + const primaryNav = findPrimaryNavList(root); + if (primaryNav) { + const templateItem = primaryNav.querySelector("li") || primaryNav.parentElement; + const dlpItem = templateItem ? buildDlpNavItem(templateItem, href) : null; + if (dlpItem) { + primaryNav.appendChild(dlpItem); + return; + } + const item = document.createElement("li"); + item.setAttribute("data-aw-ru-dlp-item", "1"); + const link = document.createElement("a"); + link.setAttribute("href", href); + link.setAttribute("data-aw-ru-dlp-link", "1"); + link.textContent = "DLP"; + item.appendChild(link); + primaryNav.appendChild(item); + } + } + + function renderDlpTableRows(center, host) { + const state = getSuppressionState(); + const tbody = center.querySelector("[data-aw-ru-dlp-events]"); + if (!tbody) return; + const hideSuppressed = center.querySelector("[data-aw-ru-hide-suppressed]") && center.querySelector("[data-aw-ru-hide-suppressed]").checked; + const rows = []; + for (const event of state.events) { + const matchedRule = state.activeRules.find(function (rule) { return ruleMatchesEvent(rule, event); }) || null; + if (hideSuppressed && matchedRule) continue; + const data = event.data || {}; + const eventKey = buildDlpKey(event); + rows.push( + '' + + "" + escapeHtml(new Date(event.timestamp).toLocaleString()) + "" + + "" + escapeHtml(data.signalType || "") + "" + + "" + escapeHtml(data.username || data.owner || "") + "" + + "" + escapeHtml(data.documentName || "") + "" + + "" + escapeHtml(data.printerName || "") + "" + + "" + (matchedRule ? 'Подавлено правилом' : "") + "" + + '" + + '' + + '' + + '' + + '' + + '' + + "" + + "" + ); + } + tbody.innerHTML = rows.length ? rows.join("") : 'Нет DLP-событий в выборке.'; + center.querySelector("[data-aw-ru-dlp-status]").textContent = + "Событий: " + state.events.length + " · правил: " + state.activeRules.length + "/" + state.rules.length + " · review: " + state.reviews.filter(function (review) { return !(review.data && review.data.review && review.data.review.archived); }).length + "/" + state.reviews.length; + bindDlpRowActions(center, host); + renderDlpRuleManager(center, host); + renderDlpReviewManager(center, host); + } + + async function saveDlpReview(host, event, row) { + const bucketId = "aw-dlp-review_" + host; + await ensureAwBucket(bucketId, "aw-dlp-review", "aw.dlp.review", host); + const verdict = row.querySelector("[data-aw-ru-dlp-verdict]").value; + const category = row.querySelector("[data-aw-ru-dlp-category]").value.trim(); + const comment = row.querySelector("[data-aw-ru-dlp-comment]").value.trim(); + await saveAwHeartbeat(bucketId, { + timestamp: new Date().toISOString(), + duration: 0, + data: { + host: host, + sourceBucket: getDlpBucketIdFromHash(), + sourceEvent: { + timestamp: event.timestamp, + data: event.data || {} + }, + review: { + reviewId: generateDlpId("review"), + verdict: verdict, + category: category, + comment: comment, + archived: false + } + } + }, 1); + } + + async function saveDlpRule(host, event, row) { + const bucketId = "aw-dlp-rules_" + host; + await ensureAwBucket(bucketId, "aw-dlp-rules", "aw.dlp.rule", host); + const verdict = row.querySelector("[data-aw-ru-dlp-verdict]").value; + const category = row.querySelector("[data-aw-ru-dlp-category]").value.trim(); + const comment = row.querySelector("[data-aw-ru-dlp-comment]").value.trim(); + await saveAwHeartbeat(bucketId, { + timestamp: new Date().toISOString(), + duration: 0, + data: { + host: host, + ruleId: generateDlpId("rule"), + enabled: true, + action: verdict, + category: category, + comment: comment, + match: getRuleMatchFields(event) + } + }, 1); + } + + function bindDlpRowActions(center, host) { + const state = getSuppressionState(); + const rows = center.querySelectorAll("[data-aw-ru-dlp-key]"); + rows.forEach(function (row) { + if (row.getAttribute("data-aw-ru-bound") === "1") return; + row.setAttribute("data-aw-ru-bound", "1"); + const eventKey = row.getAttribute("data-aw-ru-dlp-key"); + const event = state.events.find(function (item) { return buildDlpKey(item) === eventKey; }); + if (!event) return; + row.querySelector("[data-aw-ru-save-review]").addEventListener("click", async function () { + const message = center.querySelector("[data-aw-ru-dlp-message]"); + try { + await saveDlpReview(host, event, row); + state.reviews = collapseReviewEvents(await loadBucketEvents("aw-dlp-review_" + host, 200)); + renderDlpTableRows(center, host); + message.textContent = "Review сохранен."; + } catch (error) { + message.textContent = "Ошибка сохранения review: " + error.message; + } + }); + row.querySelector("[data-aw-ru-save-rule]").addEventListener("click", async function () { + const message = center.querySelector("[data-aw-ru-dlp-message]"); + try { + await saveDlpRule(host, event, row); + state.rules = collapseRuleEvents(await loadBucketEvents("aw-dlp-rules_" + host, 200)); + state.activeRules = state.rules.filter(function (rule) { return !(rule.data && rule.data.enabled === false); }); + message.textContent = "Правило сохранено."; + renderDlpTableRows(center, host); + } catch (error) { + message.textContent = "Ошибка сохранения правила: " + error.message; + } + }); + }); + } + + async function setDlpRuleEnabled(host, ruleEvent, enabled) { + const bucketId = "aw-dlp-rules_" + host; + await ensureAwBucket(bucketId, "aw-dlp-rules", "aw.dlp.rule", host); + const data = ruleEvent.data || {}; + await saveAwHeartbeat(bucketId, { + timestamp: new Date().toISOString(), + duration: 0, + data: { + host: host, + ruleId: getRuleId(ruleEvent), + enabled: enabled, + action: data.action || "", + category: data.category || "", + comment: data.comment || "", + match: data.match || {} + } + }, 1); + } + + async function setDlpReviewArchived(host, reviewEvent, archived) { + const bucketId = "aw-dlp-review_" + host; + await ensureAwBucket(bucketId, "aw-dlp-review", "aw.dlp.review", host); + const data = reviewEvent.data || {}; + const review = data.review || {}; + await saveAwHeartbeat(bucketId, { + timestamp: new Date().toISOString(), + duration: 0, + data: { + host: host, + sourceBucket: data.sourceBucket || getDlpBucketIdFromHash(), + sourceEvent: data.sourceEvent || {}, + review: { + reviewId: getReviewId(reviewEvent), + verdict: review.verdict || "", + category: review.category || "", + comment: review.comment || "", + archived: archived + } + } + }, 1); + } + + function renderDlpRuleManager(center, host) { + const state = getSuppressionState(); + const tbody = center.querySelector("[data-aw-ru-dlp-rules]"); + if (!tbody) return; + const showDisabled = center.querySelector("[data-aw-ru-show-disabled-rules]") && center.querySelector("[data-aw-ru-show-disabled-rules]").checked; + const rows = []; + state.rules.forEach(function (ruleEvent) { + const data = ruleEvent.data || {}; + const isEnabled = data.enabled !== false; + if (!isEnabled && !showDisabled) return; + rows.push( + '' + + '' + escapeHtml(new Date(ruleEvent.timestamp).toLocaleString()) + '' + + '' + (isEnabled ? 'Активно' : 'Отключено') + '' + + '' + escapeHtml(data.action || "") + '' + + '' + escapeHtml(data.category || "") + '' + + '' + escapeHtml(serializeRuleMatch(data.match || {})) + '' + + '' + escapeHtml(data.comment || "") + '' + + '' + + '' + + '' + + '' + ); + }); + tbody.innerHTML = rows.length ? rows.join("") : 'Сохраненных правил нет.'; + tbody.querySelectorAll("[data-aw-ru-toggle-rule]").forEach(function (button) { + button.addEventListener("click", async function () { + const row = button.closest("[data-aw-ru-rule-id]"); + const ruleId = row && row.getAttribute("data-aw-ru-rule-id"); + const ruleEvent = state.rules.find(function (item) { return getRuleId(item) === ruleId; }); + if (!ruleEvent) return; + const message = center.querySelector("[data-aw-ru-dlp-message]"); + try { + await setDlpRuleEnabled(host, ruleEvent, ruleEvent.data && ruleEvent.data.enabled === false); + state.rules = collapseRuleEvents(await loadBucketEvents("aw-dlp-rules_" + host, 200)); + state.activeRules = state.rules.filter(function (rule) { return !(rule.data && rule.data.enabled === false); }); + renderDlpTableRows(center, host); + message.textContent = "Статус правила обновлен."; + } catch (error) { + message.textContent = "Ошибка обновления правила: " + error.message; + } + }); + }); + } + + function renderDlpReviewManager(center, host) { + const state = getSuppressionState(); + const tbody = center.querySelector("[data-aw-ru-dlp-reviews]"); + if (!tbody) return; + const showArchived = center.querySelector("[data-aw-ru-show-archived-reviews]") && center.querySelector("[data-aw-ru-show-archived-reviews]").checked; + const rows = []; + state.reviews.forEach(function (reviewEvent) { + const data = reviewEvent.data || {}; + const review = data.review || {}; + const archived = review.archived === true; + if (archived && !showArchived) return; + const sourceData = data.sourceEvent && data.sourceEvent.data ? data.sourceEvent.data : {}; + rows.push( + '' + + '' + escapeHtml(new Date(reviewEvent.timestamp).toLocaleString()) + '' + + '' + (archived ? 'Архив' : 'Активно') + '' + + '' + escapeHtml(review.verdict || "") + '' + + '' + escapeHtml(review.category || "") + '' + + '' + escapeHtml(review.comment || "") + '' + + '' + escapeHtml(sourceData.signalType || "") + ' · ' + escapeHtml(sourceData.documentName || sourceData.printerName || sourceData.username || "") + '' + + '' + + '' + + '' + + '' + ); + }); + tbody.innerHTML = rows.length ? rows.join("") : 'Сохраненных review нет.'; + tbody.querySelectorAll("[data-aw-ru-toggle-review]").forEach(function (button) { + button.addEventListener("click", async function () { + const row = button.closest("[data-aw-ru-review-id]"); + const reviewId = row && row.getAttribute("data-aw-ru-review-id"); + const reviewEvent = state.reviews.find(function (item) { return getReviewId(item) === reviewId; }); + if (!reviewEvent) return; + const message = center.querySelector("[data-aw-ru-dlp-message]"); + try { + await setDlpReviewArchived(host, reviewEvent, !(reviewEvent.data && reviewEvent.data.review && reviewEvent.data.review.archived === true)); + state.reviews = collapseReviewEvents(await loadBucketEvents("aw-dlp-review_" + host, 200)); + renderDlpTableRows(center, host); + message.textContent = "Статус review обновлен."; + } catch (error) { + message.textContent = "Ошибка обновления review: " + error.message; + } + }); + }); + } + + async function refreshDlpCenter(center, host) { + const state = getSuppressionState(); + if (state.loading) return; + state.loading = true; + center.querySelector("[data-aw-ru-dlp-message]").textContent = "Загрузка DLP-событий..."; + try { + state.events = (await loadBucketEvents(getDlpBucketIdFromHash(), 200)) + .sort(function (a, b) { return String(b.timestamp).localeCompare(String(a.timestamp)); }); + try { + state.rules = collapseRuleEvents(await loadBucketEvents("aw-dlp-rules_" + host, 200)); + state.activeRules = state.rules.filter(function (rule) { return !(rule.data && rule.data.enabled === false); }); + } catch (error) { + state.rules = []; + state.activeRules = []; + } + try { + state.reviews = collapseReviewEvents(await loadBucketEvents("aw-dlp-review_" + host, 200)); + } catch (error) { + state.reviews = []; + } + renderDlpTableRows(center, host); + center.querySelector("[data-aw-ru-dlp-message]").textContent = "DLP review центр обновлен."; + } catch (error) { + center.querySelector("[data-aw-ru-dlp-message]").textContent = "Ошибка загрузки DLP-событий: " + error.message; + } finally { + state.loading = false; + } + } + + function injectDlpReviewCenter(root) { + if (!isDlpSignalBucketRoute()) return; + const bucketId = getDlpBucketIdFromHash(); + const host = getDlpHostFromBucketId(bucketId); + if (!host) return; + + const heading = root.querySelector("h3"); + if (!heading) return; + + let center = root.querySelector("[data-aw-ru-dlp-center='1']"); + if (!center) { + center = document.createElement("section"); + center.className = "aw-ru-dlp-center"; + center.setAttribute("data-aw-ru-dlp-center", "1"); + center.innerHTML = + '

DLP review и правила

' + + '
' + + 'bucket: ' + escapeHtml(bucketId) + '' + + '' + + '' + + '
Событий: 0 · правил: 0 · review: 0
' + + '
' + + '

Здесь можно категорировать DLP-события и сохранять suppress/rule записи прямо в AW. Это снижает ложные сработки на уровне review-потока.

' + + '' + + '' + + '' + + '
ВремяТипПользовательДокументПринтер/каналСтатусВердиктКатегорияКомментарийДействия
Загрузка...
' + + '
' + + '
' + + '
DLP Rules
' + + '' + + '
' + + '' + + '' + + '' + + '
ВремяСтатусДействиеКатегорияMatchКомментарийУправление
Загрузка...
' + + '
' + + '
' + + '
' + + '
DLP Review
' + + '' + + '
' + + '' + + '' + + '' + + '
ВремяСтатусВердиктКатегорияКомментарийИсточникУправление
Загрузка...
' + + '
' + + '
'; + heading.parentElement.insertBefore(center, heading.nextSibling); + center.querySelector("[data-aw-ru-refresh-dlp]").addEventListener("click", function () { + refreshDlpCenter(center, host); + }); + center.querySelector("[data-aw-ru-hide-suppressed]").addEventListener("change", function () { + renderDlpTableRows(center, host); + }); + center.querySelector("[data-aw-ru-show-disabled-rules]").addEventListener("change", function () { + renderDlpRuleManager(center, host); + }); + center.querySelector("[data-aw-ru-show-archived-reviews]").addEventListener("change", function () { + renderDlpReviewManager(center, host); + }); + } + + if (center.getAttribute("data-aw-ru-loaded") !== "1") { + center.setAttribute("data-aw-ru-loaded", "1"); + refreshDlpCenter(center, host); + } + } + let trendsRedirectInFlight = false; + let settingsHostFetchInFlight = false; function getTrendsHostFromSettings(settings) { if (!settings || typeof settings !== "object") return ""; @@ -347,6 +1000,7 @@ return response.json(); }) .then(function (settings) { + window.__awRuPatchSettingsHost = getDlpHostFromSettings(settings); const host = getTrendsHostFromSettings(settings); if (!host || !shouldRedirectTrends(window.location.hash)) return; const target = "#/trends/" + encodeURIComponent(host); @@ -360,11 +1014,32 @@ }); } + function ensureSettingsHost() { + if (window.__awRuPatchSettingsHost || settingsHostFetchInFlight) return; + settingsHostFetchInFlight = true; + fetch("/api/0/settings/", { credentials: "same-origin" }) + .then(function (response) { + if (!response.ok) throw new Error("settings-fetch-failed"); + return response.json(); + }) + .then(function (settings) { + window.__awRuPatchSettingsHost = getDlpHostFromSettings(settings); + }) + .catch(function () {}) + .finally(function () { + settingsHostFetchInFlight = false; + injectDlpNavigation(document.body); + }); + } + function applyPatch() { + ensureSettingsHost(); injectStyles(); walk(document.body); translateAttributes(document.body); hideNoiseNavigation(document.body); + injectDlpNavigation(document.body); + injectDlpReviewCenter(document.body); redirectBareTrendsRoute(); } diff --git a/docs/FULL_DEPLOYMENT_MANUAL_RU.md b/docs/FULL_DEPLOYMENT_MANUAL_RU.md index 9c75936..62224ec 100755 --- a/docs/FULL_DEPLOYMENT_MANUAL_RU.md +++ b/docs/FULL_DEPLOYMENT_MANUAL_RU.md @@ -134,6 +134,13 @@ bash /root/bootstrap/apply_webui_ru_patch.sh systemctl restart activitywatch-server.service ``` +После применения патча доступны: + +- верхнее меню `DLP` в Web UI; +- DLP-страница bucket `aw-dlp-endpoint-signals_`; +- встроенный центр `DLP review и правила`; +- служебные buckets `aw-dlp-review_` и `aw-dlp-rules_`. + ### 2.5 Проверка сервера В CT: @@ -152,6 +159,12 @@ grep -n 'aw-ru-patch\|aw-sw-cleanup' /opt/activitywatch/webui-ru/index.html - порт 5600 слушается; - в `index.html` присутствуют оба скрипта. +Дополнительно после первого входа в Web UI: + +- `#/home` должен показывать один корректный пункт `DLP`; +- `#/buckets/aw-dlp-endpoint-signals_` должен открываться без ошибок; +- сохранение review/rule должно создавать buckets `aw-dlp-review_` и `aw-dlp-rules_`. + --- ## 3) Развёртывание Windows-клиентов (другой AD-домен) @@ -279,6 +292,9 @@ curl -sS http://127.0.0.1:5600/api/0/buckets | jq 'keys' - `aw-watcher-window_` - `aw-watcher-web-_` - `aw-detmir-web-category_` (категоризованный поток) +- `aw-dlp-endpoint-signals_` (endpoint сигналы) +- `aw-dlp-review_` (ручная классификация через UI) +- `aw-dlp-rules_` (suppress/rule записи через UI) Проверка событий браузера: @@ -292,6 +308,31 @@ curl -sS "http://127.0.0.1:5600/api/0/buckets/aw-watcher-web-edge_/events? curl -sS "http://127.0.0.1:5600/api/0/buckets/aw-detmir-web-category_/events?limit=5" | jq ``` +Проверка DLP review/rules: + +```bash +curl -sS "http://127.0.0.1:5600/api/0/buckets/aw-dlp-review_/events?limit=20" | jq +curl -sS "http://127.0.0.1:5600/api/0/buckets/aw-dlp-rules_/events?limit=20" | jq +``` + +Ожидаемые поля review: + +- `reviewId` +- `signalType` +- `verdict` +- `category` +- `comment` +- `archived` + +Ожидаемые поля rules: + +- `ruleId` +- `signalType` +- `match` +- `category` +- `comment` +- `enabled` + --- ## 6) Сопровождение (обязательно) diff --git a/docs/deployment.md b/docs/deployment.md index 0a291d8..bb98e5d 100755 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -94,7 +94,8 @@ systemctl restart activitywatch-server.service - копирует `aw-ru-patch.js` и `aw-sw-cleanup.js`; - делает backup `index.html`; - добавляет include в `index.html`; -- заменяет `service-worker.js` cleanup-версией для сброса старого cache. +- заменяет `service-worker.js` cleanup-версией для сброса старого cache; +- включает DLP Web UI override: верхнее меню `DLP`, центр `DLP review и правила`, buckets `aw-dlp-review_` и `aw-dlp-rules_`. ## 6. Проверить сервис @@ -119,6 +120,8 @@ grep -n 'aw-ru-patch\|aw-sw-cleanup' /opt/activitywatch/webui-ru/index.html - открывается `/`; - API доступен по ожидаемому URL; - после hard refresh видна русификация; +- в верхнем меню доступен `DLP` и он открывает `#/buckets/aw-dlp-endpoint-signals_`; +- на DLP bucket-странице доступны секции `DLP review и правила`, `DLP Rules`, `DLP Review`; - reverse proxy не кэширует старую статику. ## 8. Что зафиксировать после ввода diff --git a/docs/operations.md b/docs/operations.md index 38ec951..1530496 100755 --- a/docs/operations.md +++ b/docs/operations.md @@ -8,6 +8,7 @@ - LXC-контейнера с Debian 12; - `ActivityWatch Server` на Rust; - Web UI override с RU patch; +- DLP Web UI overlay для review/rules поверх bucket `aw-dlp-endpoint-signals_`; - systemd unit `activitywatch-server.service`. Базовые правила: @@ -49,6 +50,7 @@ - `/etc/systemd/system/activitywatch-server.service`; - `/opt/activitywatch/webui-ru/`; - `/opt/activitywatch/releases/`; +- buckets `aw-dlp-review_*` и `aw-dlp-rules_*` через API export, если review/rule-классификация уже ведётся в UI; - конфиг reverse proxy, если он есть. Пример: @@ -57,6 +59,9 @@ vzdump --mode snapshot --compress zstd --storage pct exec -- tar -C / -czf /root/activitywatch-config-backup.tgz \ etc/activitywatch etc/systemd/system/activitywatch-server.service opt/activitywatch/webui-ru + +curl -sS http://127.0.0.1:5600/api/0/buckets/aw-dlp-review_/events?limit=500 > /root/aw-dlp-review-.json +curl -sS http://127.0.0.1:5600/api/0/buckets/aw-dlp-rules_/events?limit=500 > /root/aw-dlp-rules-.json ``` ## Rollback @@ -68,6 +73,8 @@ cp /opt/activitywatch/webui-ru/index.html.bak. /opt/activitywatch/web systemctl restart activitywatch-server.service ``` +Примечание: после rollback или повторного деплоя открыть UI с hard refresh, так как браузер может держать старую версию `ru-patch-v5.js`. + ### Rollback server release 1. Остановить сервис. @@ -95,6 +102,12 @@ systemctl restart activitywatch-server.service 6. Проверить API/UI. 7. Зафиксировать результат. +После обновления UI отдельно проверить: + +- `#/home` — есть один корректный пункт `DLP`; +- `#/buckets/aw-dlp-endpoint-signals_` — работают сохранение review/rule и списки `DLP Rules` / `DLP Review`; +- API создаёт/читает buckets `aw-dlp-review_` и `aw-dlp-rules_` без ошибок `304/409`. + ## Эскалация Эскалировать сразу, если: