Показати сторінкуСтарі ревізіїПосилання сюдиПовернутися наверх Ця сторінка доступна тільки для перегляду. Ви можете продивитися вихідний текст, але не можете змінювати його. Якщо ви вважаєте, що це не вірно, зверніться до адміністратора. ====== Логування подій керування (MWJ Audit Service) ====== ===== Опис ===== Служба логування подій керування (вхід/вихід операторів, запис тегів) для проєкту **UA_Energy_Demo_23_04**. Складається з трьох частин: * **mwj_audit_service.py** — Python-скрипт, сканує журнал подій AlarmServer і віддає їх у JSON на ``127.0.0.1:5005/events``. Запускається автоматично через Task Scheduler. * **mwj_events.aspx** — сторінка на WebHMI-сервері, яка проксіює цей JSON у браузер клієнта. * **mwj_rawevents_patch.js** — JS-патч, який підтягує ці події в AlarmViewer (Events view) і показує їх поряд зі стандартними подіями. ===== 1. Розміщення mwj_audit_service.py ===== Скопіювати скрипт у папку проєкту: <code> C:\ProgramData\Schneider Electric\Power Operation\v2024\User\UA_Energy_Demo_23_04\mwj_audit_service.py </code> Перед запуском **замінити** ``JOURNAL_DIR`` під конкретний проєкт (та за потреби ``PORT``, якщо 5005 зайнятий): <code python> import re import os import struct import datetime import json import threading import time from http.server import HTTPServer, BaseHTTPRequestHandler JOURNAL_DIR = r"C:\ProgramData\Schneider Electric\Power Operation\v2024\Data\UA_Energy_Demo_23_04\c1.AlarmServer1\Database\Journal\JnI00000" #шлях до журналу подій хард клієнта PORT = 5005 DAYS_BACK = 7 POLL_SEC = 1 CORS_ORIGIN = "*" def filetime_to_dt(raw8: bytes): ft = struct.unpack('<Q', raw8)[0] if ft == 0: return None microseconds = ft // 10 return datetime.datetime(1601, 1, 1, tzinfo=datetime.timezone.utc) + \ datetime.timedelta(microseconds=microseconds) def parse_mwj(path: str, since: datetime.datetime) -> list: events = [] try: data = open(path, 'rb').read() except Exception: return events ext_pat = 'ExternalEvent'.encode('utf-16-le') kernel_pat = 'Kernel,367,S"'.encode('utf-16-le') pos = 0 while True: pos = data.find(ext_pat, pos) if pos == -1: break ts_bytes = data[pos - 8 : pos] dt = filetime_to_dt(ts_bytes) if len(ts_bytes) == 8 else None if dt and dt < since: pos += len(ext_pat) continue k_pos = data.find(kernel_pat, pos, pos + 512) msg = '' user = '' if k_pos != -1: str_start = k_pos + len(kernel_pat) str_end = data.find(b'\x22\x00', str_start) if str_end != -1: try: msg = data[str_start:str_end].decode('utf-16-le') except UnicodeDecodeError: pass after = str_end + 4 user_match = re.search(b'(?:[\x21-\x7e]\x00){2,}', data[after : after + 128]) if user_match: candidate = user_match.group().decode('utf-16-le') if not any(candidate.startswith(x) for x in ('Kernel', 'DBServer', 'System', 'Digital', 'External')): user = candidate if msg: event_type = classify(msg) if event_type: events.append({ 'id': f"{path}:{pos}", 'timestamp': dt.isoformat() if dt else '', 'type': event_type, 'message': msg, 'user': user, }) pos += len(ext_pat) return events def classify(msg: str) -> str | None: if 'Tag Write Operation' in msg: return 'TagWrite' if 'logged in' in msg or 'Logged on' in msg: return 'Login' if 'logged out' in msg or 'Logged off' in msg: return 'Logout' return None _cache_lock = threading.Lock() _events_cache: list = [] def scan_all(): since = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=DAYS_BACK) result = [] if not os.path.isdir(JOURNAL_DIR): print(f"[WARN] Journal dir not found: {JOURNAL_DIR}") return for fname in sorted(os.listdir(JOURNAL_DIR)): if not fname.upper().endswith('.MWJ'): continue full = os.path.join(JOURNAL_DIR, fname) try: mtime = datetime.datetime.fromtimestamp( os.path.getmtime(full), tz=datetime.timezone.utc) except OSError: continue if mtime < since: continue result.extend(parse_mwj(full, since)) seen = set() unique = [] for e in sorted(result, key=lambda x: x['timestamp'], reverse=True): if e['id'] not in seen: seen.add(e['id']) unique.append(e) with _cache_lock: global _events_cache _events_cache = unique print(f"[{datetime.datetime.now():%H:%M:%S}] Scanned {JOURNAL_DIR}: {len(unique)} events") def scanner_thread(): while True: try: scan_all() except Exception as e: print(f"[ERROR] Scanner: {e}") time.sleep(POLL_SEC) class Handler(BaseHTTPRequestHandler): def log_message(self, fmt, *args): pass # suppress default access log def send_cors(self): self.send_header('Access-Control-Allow-Origin', CORS_ORIGIN) self.send_header('Access-Control-Allow-Methods', 'GET, OPTIONS') self.send_header('Access-Control-Allow-Headers', 'Content-Type') def do_OPTIONS(self): self.send_response(204) self.send_cors() self.end_headers() def do_GET(self): path = self.path.split('?')[0] if path == '/events': with _cache_lock: data = json.dumps(_events_cache, ensure_ascii=False).encode('utf-8') self.send_response(200) self.send_header('Content-Type', 'application/json; charset=utf-8') self.send_header('Content-Length', str(len(data))) self.send_cors() self.end_headers() self.wfile.write(data) elif path == '/health': body = b'OK' self.send_response(200) self.send_cors() self.send_header('Content-Length', '2') self.end_headers() self.wfile.write(body) else: self.send_response(404) self.end_headers() if __name__ == '__main__': print(f"Starting MWJ Audit Service on port {PORT}") scan_all() t = threading.Thread(target=scanner_thread, daemon=True) t.start() server = HTTPServer(('127.0.0.1', PORT), Handler) print(f"Listening on http://127.0.0.1:{PORT}/events") try: server.serve_forever() except KeyboardInterrupt: print("Stopped.") </code> **Що замінити:** тільки рядок ``JOURNAL_DIR = r"...UA_Energy_Demo_23_04..."`` — підставити фактичну назву проєкту у шлях до Journal. ===== 2. Реєстрація завдання в Task Scheduler ===== Створити нове завдання (Create Task, не Basic Task) з іменем **logging**. == General == * Security options: **Run whether user is logged on or not** * Увімкнути **Run with highest privileges** * Configure for: відповідно до ОС сервера == Triggers == * New → Begin the task: **At startup** * Status: Enabled == Actions == * New → Action: **Start a program** * Program/script: <code> C:\python_portable\python.exe </code> * Add arguments: <code> mwj_audit_service.py </code> * Start in (обов'язково!): <code> C:\ProgramData\Schneider Electric\Power Operation\v2024\User\UA_Energy_Demo_23_04 </code> (поле "Start in" вказує на папку, куди поклали скрипт у п.1 — без цього скрипт може не знайти сам себе при запуску не з тієї директорії) == Conditions == * Power: увімкнути **Start the task only if the computer is on AC power** та **Stop if the computer switches to battery power** * Idle: не використовувати * Network: не обмежувати Зберегти завдання (OK), при потребі ввести пароль облікового запису Administrator. **Перевірка:** запустити завдання вручну (Run), відкрити на сервері ``http://127.0.0.1:5005/health`` — має повернути ``OK``, і ``http://127.0.0.1:5005/events`` — має повернути масив JSON. ===== 3. Розміщення mwj_events.aspx ===== Файл вставити за шляхом: <code> C:\Program Files (x86)\Schneider Electric\Power Operation\v2024\Applications\Web\WebHmi </code> Код без змін (порт 5005 має співпадати з портом у Python-скрипті): <code aspx> <%@ Page Language="C#" AutoEventWireup="true" %> <%@ Import Namespace="System.Net" %> <%@ Import Namespace="System.IO" %> <script runat="server"> protected void Page_Load(object sender, EventArgs e) { Response.Clear(); Response.ContentType = "application/json; charset=utf-8"; Response.AppendHeader("Access-Control-Allow-Origin", "*"); try { HttpWebRequest request = (HttpWebRequest)WebRequest.Create("http://127.0.0.1:5005/events"); request.Method = "GET"; request.Timeout = 4000; using (HttpWebResponse response = (HttpWebResponse)request.GetResponse()) { using (StreamReader reader = new StreamReader(response.GetResponseStream())) { string json = reader.ReadToEnd(); Response.Write(json); } } } catch (Exception ex) { Response.StatusCode = 500; Response.Write("{\"error\": \"" + ex.Message.Replace("\"", "\\\"") + "\"}"); } Response.End(); } </script> </code> **Перевірка:** ``https://<адреса_webhmi>/mwj_events.aspx`` має віддавати той самий JSON, що й ``http://127.0.0.1:5005/events`` на сервері. ===== 4. Розміщення mwj_rawevents_patch.js ===== Скопіювати скрипт у папку: <code> C:\Program Files (x86)\Schneider Electric\Power Operation\v2024\Applications\Web\SystemDataService\Content\Applications\Alarms\Scripts\Scripts\mwj_rawevents_patch.js </code> Далі відкрити файл (без змін розширення/типу, редагувати як текстовий): <code> C:\Program Files (x86)\Schneider Electric\Power Operation\v2024\Applications\Web\SystemDataService\Content\Applications\Alarms\Scripts\Alarms.Script.Shared </code> і додати в кінець списку рядок: <code> Scripts\mwj_rawevents_patch.js </code> Це підключає патч до AlarmViewer, щоб він підвантажив ``mwj_rawevents_patch.js`` разом з іншими скриптами модуля Alarms. Повний код патча: <code javascript> (function(){ 'use strict'; var AUDIT_API_URL = '/WebHmi/mwj_events.aspx'; var POLLING_INTERVAL_MS = 5000; var MWJ_SOURCE_ID = '<System>.Система'; var MWJ_PRIORITY = 0; var _mwjCache = []; var _mwjIds = {}; var _lastRawStr = ''; var _lastNativeData = []; function fetchMwj(){ fetch(AUDIT_API_URL) .then(function(r){return r.json();}) .then(function(events){ if(!Array.isArray(events)) return; var str = JSON.stringify(events); if(str === _lastRawStr) return; _lastRawStr = str; var prevIds = _mwjIds; _mwjIds = {}; _mwjCache = events.map(toRawEvent); _mwjCache.forEach(function(e){_mwjIds[e.Id] = true;}); var newEvents = _mwjCache.filter(function(e){return !prevIds[e.Id];}); if(newEvents.length > 0) pushNewMwjToView(newEvents); }) .catch(function(e){ console.warn('[MWJ] fetch error:', e); }); } function extractUser(msg){ var m = msg.match(/(?:^User|by User)\s+([^\s\.]+)/i); return m ? m[1] : ''; } function extractTag(msg){ var m = msg.match(/wrote to tags:\s*([^@\n]+)/i); if(!m) return msg; return m[1].replace(/\.\s*$/, '').trim(); } function toRawEvent(evt){ var ts = evt.timestamp ? new Date(evt.timestamp).getTime() : Date.now(); var msg = evt.message || ''; var user = extractUser(msg); var tagName = ''; var effect = ''; var cause = ''; if(evt.type === 'Login'){ tagName = user; effect = 'Вхід у систему'; cause = 'Вхід користувача ' + user; } else if(evt.type === 'Logout'){ tagName = user; effect = 'Вихід із системи'; cause = 'Вихід користувача ' + user; } else if(evt.type === 'TagWrite'){ var tag = extractTag(msg); tagName = 'Користувач ' + user + ' успішно записав тег'; effect = 'Запис тегу'; cause = tag; } else{ tagName = msg; effect = 'Системна подія'; cause = msg; } return{ Id: 'MWJ_' + evt.id, TimestampUtc: '/Date(' + ts + ')/', StartTimeUtc: '/Date(' + ts + ')/', Source:{ SourceId: MWJ_SOURCE_ID, DisplayName: MWJ_SOURCE_ID, IanaTimeZoneName: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', }, Cause: cause, CauseValue: user, Effect: effect, EffectValue: effect, Name: tagName, DisplayEffectValue: tagName, DisplayType: 'Подія', EventType: 4, Priority: MWJ_PRIORITY, TimeQuality: 0, TimeQualityMicroseconds: 0, Custom1:'', Custom3:'', Custom4:'', Custom5:'', Custom6:'', Custom7:'', Custom8:'', IsActive: false, IsAcknowledged: true, IsDisabled: false, IsShelved: false, IsDeleted: false, _isMwj: true }; } function pushNewMwjToView(newEvents){ var App = window.AlarmViewer && window.AlarmViewer.Models && window.AlarmViewer.Models.Application; if(!App) return; var AlarmViews = IonDataApi.AlarmViews; if(App.ViewModeCurrent !== AlarmViews.ViewModeEvents) return; var view = App.ActiveView; if(!view || typeof view.UpdateViewWithChanges !== 'function') return; view.UpdateViewWithChanges(newEvents, []); } function mergeWithMwj(nativeData){ var nativeIds = {}; nativeData.forEach(function(e){if(e && e.Id) nativeIds[e.Id] = true;}); var result = nativeData.slice(); _mwjCache.forEach(function(mwj){ if(!nativeIds[mwj.Id]) result.push(mwj); }); return result; } function waitForApp(cb){ var n = 0; var t = setInterval(function(){ n++; var App = window.AlarmViewer && window.AlarmViewer.Models && window.AlarmViewer.Models.Application; if(App && typeof App.RenderFreshData === 'function' && typeof App.RenderIncrementalUpdateData === 'function'){ clearInterval(t); cb(App); } if(n > 300){ clearInterval(t); console.warn('[MWJ] Application не знайдено за 30с'); } }, 100); } function applyPatch(App){ var AlarmViews = IonDataApi.AlarmViews; var _origFresh = App.RenderFreshData; App.RenderFreshData = function(isValid, viewMode, data){ if(viewMode === AlarmViews.ViewModeEvents && Array.isArray(data)){ _lastNativeData = data.filter(function(e){return !e._isMwj;}); data = mergeWithMwj(_lastNativeData); } return _origFresh.call(this, isValid, viewMode, data); }; var _origIncr = App.RenderIncrementalUpdateData; App.RenderIncrementalUpdateData = function(viewMode, added, removed){ if(viewMode === AlarmViews.ViewModeEvents){ if(Array.isArray(removed)){ removed = removed.filter(function(e){return !_mwjIds[e.Id];}); } if(Array.isArray(added)){ added.forEach(function(item){ if(!item._isMwj){ var found = false; for(var i = 0; i < _lastNativeData.length; i++){ if(_lastNativeData[i].Id === item.Id){ _lastNativeData[i] = item; found = true; break; } } if(!found) _lastNativeData.push(item); } }); } } return _origIncr.call(this, viewMode, added, removed); }; } fetchMwj(); setInterval(fetchMwj, POLLING_INTERVAL_MS); if(document.readyState === 'loading'){ document.addEventListener('DOMContentLoaded', function(){ waitForApp(applyPatch); }); } else{ waitForApp(applyPatch); } })(); </code> **Перевірка:** відкрити AlarmViewer → вкладку Events, після входу/виходу оператора або запису тегу — подія має з'явитись у списку протягом ~5 секунд (``POLLING_INTERVAL_MS``). ===== 5. Типові проблеми ===== ^ Симптом ^ Ймовірна причина ^ Рішення ^ | ``/health`` не відповідає | Завдання Task Scheduler не запустилось / скрипт впав | Перевірити журнал завдання (History), запустити скрипт вручну через ``python_portable\python.exe`` для перегляду traceback | | ``/events`` повертає порожній масив | Невірний ``JOURNAL_DIR`` для проєкту, або ``DAYS_BACK`` замалий | Перевірити фактичний шлях журналу проєкту, звірити назву проєкту в шляху | | ASPX повертає ``{"error": ...}`` | Python-сервіс не на тому ж сервері, або порт закритий фаєрволом | Перевірити, що IIS/WebHMI і скрипт на одній машині; дозволити 127.0.0.1:5005 у фаєрволі | | Події не з'являються в AlarmViewer | ``mwj_rawevents_patch.js`` не підключено в ``Alarms.Script.Shared`` | Перевірити, що рядок ``Scripts\mwj_rawevents_patch.js`` доданий у файл і немає помилок у шляху | | Завдання не стартує при перезавантаженні | Не увімкнено ``Run whether user is logged on or not`` або немає прав | Перевірити Security options і ``Run with highest privileges`` | scada/events.txt Востаннє змінено: 2026/08/28 10:58повз scada