Зміст

Логування подій керування (MWJ Audit Service)

Опис

Служба логування подій керування (вхід/вихід операторів, запис тегів) для проєкту UA_Energy_Demo_23_04.

Складається з трьох частин:

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
Triggers
Actions
C:\python_portable\python.exe
mwj_audit_service.py
C:\ProgramData\Schneider Electric\Power Operation\v2024\User\UA_Energy_Demo_23_04

(поле “Start in” вказує на папку, куди поклали скрипт у п.1 — без цього скрипт може не знайти сам себе при запуску не з тієї директорії)

Conditions

Зберегти завдання (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``