Логування подій керування (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
Скопіювати скрипт у папку проєкту:
C:\ProgramData\Schneider Electric\Power Operation\v2024\User\UA_Energy_Demo_23_04\mwj_audit_service.py
Перед запуском замінити ``JOURNAL_DIR`` під конкретний проєкт (та за потреби ``PORT``, якщо 5005 зайнятий):
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.")
Що замінити: тільки рядок ``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:
C:\python_portable\python.exe
- Add arguments:
mwj_audit_service.py
- Start in (обов'язково!):
C:\ProgramData\Schneider Electric\Power Operation\v2024\User\UA_Energy_Demo_23_04
(поле “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
Файл вставити за шляхом:
C:\Program Files (x86)\Schneider Electric\Power Operation\v2024\Applications\Web\WebHmi
Код без змін (порт 5005 має співпадати з портом у Python-скрипті):
<%@ 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>
Перевірка: ``https://<адреса_webhmi>/mwj_events.aspx`` має віддавати той самий JSON, що й ``http://127.0.0.1:5005/events`` на сервері.
4. Розміщення mwj_rawevents_patch.js
Скопіювати скрипт у папку:
C:\Program Files (x86)\Schneider Electric\Power Operation\v2024\Applications\Web\SystemDataService\Content\Applications\Alarms\Scripts\Scripts\mwj_rawevents_patch.js
Далі відкрити файл (без змін розширення/типу, редагувати як текстовий):
C:\Program Files (x86)\Schneider Electric\Power Operation\v2024\Applications\Web\SystemDataService\Content\Applications\Alarms\Scripts\Alarms.Script.Shared
і додати в кінець списку рядок:
Scripts\mwj_rawevents_patch.js
Це підключає патч до AlarmViewer, щоб він підвантажив ``mwj_rawevents_patch.js`` разом з іншими скриптами модуля Alarms.
Повний код патча:
(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); } })();
Перевірка: відкрити 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`` |