====== Логування подій керування (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(' 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" %>
**Перевірка:** ``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 = '.Система';
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`` |