Розбіжності
Тут показані розбіжності між вибраною ревізією та поточною версією сторінки.
| Порівняння попередніх версій Попередня ревізія Наступна ревізія | Попередня ревізія | ||
| scada:events [2026/08/28 10:39] – scada | scada:events [2026/08/28 10:58] (поточний) – scada | ||
|---|---|---|---|
| Рядок 1: | Рядок 1: | ||
| ====== Логування подій керування (MWJ Audit Service) ====== | ====== Логування подій керування (MWJ Audit Service) ====== | ||
| - | Опис | + | ===== Опис |
| - | ===== Загальна схема ===== | + | Служба логування подій керування (вхід/ |
| - | | + | Складається з трьох частин: |
| - | * **Task Scheduler** — запускає | + | |
| - | * **mwj_events.aspx** — ASP.NET-сторінка на WebHMI-сервері, | + | * **mwj_events.aspx** — сторінка на WebHMI-сервері, |
| + | * **mwj_rawevents_patch.js** — JS-патч, який підтягує ці події в AlarmViewer | ||
| - | Разом ці три компоненти дозволяють підтягувати події керування | + | ===== 1. Розміщення |
| - | ===== 1. Встановлення Python-скрипта | + | Скопіювати |
| - | - Скопіювати ``mwj_audit_service.py`` на сервер, | + | < |
| - | | + | C:\ProgramData\Schneider Electric\Power Operation\v2024\User\UA_Energy_Demo_23_04\mwj_audit_service.py |
| - | - У скрипті перевірити/ | + | </ |
| + | |||
| + | Перед запуском **замінити** ``JOURNAL_DIR`` під конкретний проєкт | ||
| <code python> | <code python> | ||
| - | JOURNAL_DIR = r" | + | import re |
| + | import os | ||
| + | import struct | ||
| + | import datetime | ||
| + | import json | ||
| + | import threading | ||
| + | import time | ||
| + | from http.server import HTTPServer, BaseHTTPRequestHandler | ||
| + | |||
| + | JOURNAL_DIR = r" | ||
| PORT = 5005 | PORT = 5005 | ||
| DAYS_BACK = 7 | DAYS_BACK = 7 | ||
| POLL_SEC = 1 | POLL_SEC = 1 | ||
| - | </ | + | CORS_ORIGIN = " |
| - | * ``UA_Energy_Demo_23_04`` — назва проєкту, змінюється під кожне нове розгортання. | + | def filetime_to_dt(raw8: |
| - | * ``PORT`` — локальний порт, на якому скрипт віддає ``/events`` та ``/health``. | + | ft = struct.unpack('< |
| - | * ``DAYS_BACK`` — глибина історії, яку скрипт підвантажує при старті. | + | if ft == 0: |
| - | * ``POLL_SEC`` — інтервал повторного сканування журналу. | + | return None |
| + | microseconds = ft // 10 | ||
| + | return datetime.datetime(1601, | ||
| + | | ||
| + | def parse_mwj(path: | ||
| + | | ||
| + | try: | ||
| + | data = open(path, ' | ||
| + | | ||
| + | return events | ||
| + | ext_pat = ' | ||
| + | kernel_pat = ' | ||
| + | pos = 0 | ||
| + | while True: | ||
| + | pos = data.find(ext_pat, | ||
| + | if pos == -1: | ||
| + | break | ||
| + | ts_bytes = data[pos - 8 : pos] | ||
| + | dt = filetime_to_dt(ts_bytes) if len(ts_bytes) == 8 else None | ||
| - | Скрипт піднімає локальний HTTP-сервер | + | if dt and dt < since: |
| + | pos += len(ext_pat) | ||
| + | continue | ||
| + | k_pos = data.find(kernel_pat, | ||
| + | msg = '' | ||
| + | user = '' | ||
| + | if k_pos != -1: | ||
| + | str_start = k_pos + len(kernel_pat) | ||
| + | str_end = data.find(b' | ||
| + | if str_end != -1: | ||
| + | try: | ||
| + | msg = data[str_start: | ||
| + | except UnicodeDecodeError: | ||
| + | pass | ||
| + | after = str_end + 4 | ||
| + | user_match = re.search(b' | ||
| + | if user_match: | ||
| + | candidate = user_match.group().decode(' | ||
| + | if not any(candidate.startswith(x) for x in (' | ||
| + | user = candidate | ||
| + | if msg: | ||
| + | event_type = classify(msg) | ||
| + | if event_type: | ||
| + | events.append({ | ||
| + | ' | ||
| + | ' | ||
| + | ' | ||
| + | ' | ||
| + | ' | ||
| + | }) | ||
| - | | + | pos += len(ext_pat) |
| - | * ``GET /health`` — перевірка живості (``OK``). | + | return events |
| + | def classify(msg: | ||
| + | if 'Tag Write Operation' | ||
| + | return ' | ||
| + | if ' | ||
| + | return ' | ||
| + | if ' | ||
| + | return ' | ||
| + | return None | ||
| + | _cache_lock | ||
| + | _events_cache: | ||
| + | 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" | ||
| + | return | ||
| + | for fname in sorted(os.listdir(JOURNAL_DIR)): | ||
| + | if not fname.upper().endswith(' | ||
| + | continue | ||
| + | full = os.path.join(JOURNAL_DIR, | ||
| + | try: | ||
| + | mtime = datetime.datetime.fromtimestamp( | ||
| + | os.path.getmtime(full), | ||
| + | except OSError: | ||
| + | continue | ||
| + | if mtime < since: | ||
| + | continue | ||
| + | result.extend(parse_mwj(full, | ||
| + | seen = set() | ||
| + | unique = [] | ||
| + | for e in sorted(result, | ||
| + | if e[' | ||
| + | seen.add(e[' | ||
| + | unique.append(e) | ||
| + | with _cache_lock: | ||
| + | global _events_cache | ||
| + | _events_cache = unique | ||
| + | print(f" | ||
| + | def scanner_thread(): | ||
| + | while True: | ||
| + | try: | ||
| + | scan_all() | ||
| + | except Exception as e: | ||
| + | print(f" | ||
| + | time.sleep(POLL_SEC) | ||
| + | class Handler(BaseHTTPRequestHandler): | ||
| + | def log_message(self, | ||
| + | pass # suppress default access log | ||
| + | def send_cors(self): | ||
| + | self.send_header(' | ||
| + | self.send_header(' | ||
| + | self.send_header(' | ||
| + | def do_OPTIONS(self): | ||
| + | self.send_response(204) | ||
| + | self.send_cors() | ||
| + | self.end_headers() | ||
| + | def do_GET(self): | ||
| + | path = self.path.split('?' | ||
| + | if path == '/events': | ||
| + | with _cache_lock: | ||
| + | data = json.dumps(_events_cache, | ||
| + | self.send_response(200) | ||
| + | self.send_header(' | ||
| + | self.send_header(' | ||
| + | self.send_cors() | ||
| + | self.end_headers() | ||
| + | self.wfile.write(data) | ||
| + | elif path == '/ | ||
| + | body = b' | ||
| + | self.send_response(200) | ||
| + | self.send_cors() | ||
| + | self.send_header(' | ||
| + | self.end_headers() | ||
| + | self.wfile.write(body) | ||
| + | else: | ||
| + | self.send_response(404) | ||
| + | self.end_headers() | ||
| + | if __name__ == ' | ||
| + | print(f" | ||
| + | scan_all() | ||
| + | t = threading.Thread(target=scanner_thread, | ||
| + | t.start() | ||
| + | server = HTTPServer((' | ||
| + | print(f" | ||
| + | try: | ||
| + | server.serve_forever() | ||
| + | except KeyboardInterrupt: | ||
| + | print(" | ||
| + | </ | ||
| + | |||
| + | **Що замінити:** тільки рядок ``JOURNAL_DIR = r" | ||
| ===== 2. Реєстрація завдання в Task Scheduler ===== | ===== 2. Реєстрація завдання в Task Scheduler ===== | ||
| - | Створюється | + | Створити нове |
| - | ==== Вкладка General ==== | + | == General |
| + | * Security options: **Run whether user is logged on or not** | ||
| + | * Увімкнути **Run with highest privileges** | ||
| + | * Configure for: відповідно до ОС сервера | ||
| - | * **Name:** ``logging`` | + | == Triggers == |
| - | * **Security options:** обрати ``Run whether user is logged on or not`` | + | * New → Begin the task: **At startup** |
| - | * Обов' | + | * Status: Enabled |
| - | * **Configure for:** відповідно до ОС сервера (у демо — Windows Server 2019) | + | |
| - | {{ :mwj_task_general.png?400 |Вкладка | + | == Actions == |
| + | * New → Action: **Start a program** | ||
| + | * Program/ | ||
| + | < | ||
| + | C: | ||
| + | </ | ||
| + | * Add arguments: | ||
| + | < | ||
| + | mwj_audit_service.py | ||
| + | </ | ||
| + | * Start in (обов' | ||
| + | < | ||
| + | C: | ||
| + | </ | ||
| + | (поле "Start in" вказує на папку, куди поклали скрипт у п.1 — без цього скрипт може не знайти сам себе при запуску не з тієї директорії) | ||
| - | ==== Вкладка Triggers ==== | + | == Conditions |
| + | * Power: увімкнути **Start the task only if the computer is on AC power** та **Stop if the computer switches to battery power** | ||
| + | * Idle: не використовувати | ||
| + | * Network: не обмежувати | ||
| - | * Додати тригер **At startup** (запуск | + | Зберегти |
| - | {{ : | + | **Перевірка:** запустити завдання вручну (Run), відкрити на сервері ``http:// |
| - | ==== Вкладка Actions | + | ===== 3. Розміщення mwj_events.aspx ===== |
| - | * Тип дії: **Start a program** | + | Файл вставити за шляхом: |
| - | * Program/ | + | |
| - | * Add arguments: ``mwj_audit_service.py`` | + | |
| - | * Start in (обов' | + | |
| - | + | ||
| - | {{ : | + | |
| - | + | ||
| - | ==== Вкладка Conditions ==== | + | |
| - | + | ||
| - | * **Power:** увімкнути ``Start the task only if the computer is on AC power`` та ``Stop if the computer switches to battery power`` | + | |
| - | * **Idle:** не використовувати (сервер не має idle-стану) | + | |
| - | * **Network: | + | |
| - | + | ||
| - | {{ : | + | |
| - | + | ||
| - | ==== Перевірка завдання ==== | + | |
| - | + | ||
| - | - Запустити завдання вручну (``Run``) або перезавантажити сервер. | + | |
| - | - Переконатися, | + | |
| - | - Відкрити ``http:// | + | |
| - | - Відкрити ``http:// | + | |
| - | + | ||
| - | ===== 3. Розгортання ASPX-моста для WebHMI ===== | + | |
| - | + | ||
| - | Файл ``mwj_events.aspx`` розміщується на WebHMI-сервері | + | |
| < | < | ||
| Рядок 85: | Рядок 230: | ||
| </ | </ | ||
| - | Він проксіює | + | Код без змін (порт |
| <code aspx> | <code aspx> | ||
| Рядок 123: | Рядок 268: | ||
| </ | </ | ||
| - | **Важливо:** | + | **Перевірка: |
| + | |||
| + | ===== 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, | ||
| + | |||
| + | Повний код патча: | ||
| + | |||
| + | <code javascript> | ||
| + | (function(){ | ||
| + | 'use strict'; | ||
| + | var AUDIT_API_URL = '/ | ||
| + | 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(' | ||
| + | } | ||
| + | function extractUser(msg){ | ||
| + | var m = msg.match(/ | ||
| + | return m ? m[1] : ''; | ||
| + | } | ||
| + | function extractTag(msg){ | ||
| + | var m = msg.match(/ | ||
| + | if(!m) return msg; | ||
| + | return m[1].replace(/ | ||
| + | } | ||
| + | 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 === ' | ||
| + | tagName = user; | ||
| + | effect = ' | ||
| + | cause = ' | ||
| + | } | ||
| + | else if(evt.type === ' | ||
| + | tagName = user; | ||
| + | effect = ' | ||
| + | cause = ' | ||
| + | |||
| + | } | ||
| + | else if(evt.type === ' | ||
| + | var tag = extractTag(msg); | ||
| + | tagName = ' | ||
| + | effect = ' | ||
| + | cause = tag; | ||
| + | |||
| + | } | ||
| + | else{ | ||
| + | tagName | ||
| + | effect | ||
| + | cause = msg; | ||
| + | } | ||
| + | return{ | ||
| + | Id: ' | ||
| + | TimestampUtc: | ||
| + | StartTimeUtc: | ||
| + | Source:{ | ||
| + | SourceId: MWJ_SOURCE_ID, | ||
| + | DisplayName: | ||
| + | IanaTimeZoneName: | ||
| + | }, | ||
| + | Cause: cause, | ||
| + | CauseValue: user, | ||
| + | Effect: effect, | ||
| + | EffectValue: | ||
| + | Name: tagName, | ||
| + | DisplayEffectValue: | ||
| + | DisplayType: | ||
| + | EventType: 4, | ||
| + | Priority: MWJ_PRIORITY, | ||
| + | TimeQuality: | ||
| + | Custom1:'', | ||
| + | IsActive: false, IsAcknowledged: | ||
| + | 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 !== ' | ||
| + | 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 && | ||
| + | | ||
| + | | ||
| + | clearInterval(t); | ||
| + | cb(App); | ||
| + | } | ||
| + | if(n > 300){ clearInterval(t); | ||
| + | }, 100); | ||
| + | } | ||
| + | function applyPatch(App){ | ||
| + | var AlarmViews = IonDataApi.AlarmViews; | ||
| + | |||
| + | var _origFresh = App.RenderFreshData; | ||
| + | App.RenderFreshData = function(isValid, | ||
| + | if(viewMode === AlarmViews.ViewModeEvents && Array.isArray(data)){ | ||
| + | _lastNativeData = data.filter(function(e){return !e._isMwj; | ||
| + | data = mergeWithMwj(_lastNativeData); | ||
| + | } | ||
| + | return _origFresh.call(this, | ||
| + | }; | ||
| + | var _origIncr = App.RenderIncrementalUpdateData; | ||
| + | App.RenderIncrementalUpdateData = function(viewMode, | ||
| + | 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; | ||
| + | if(_lastNativeData[i].Id === item.Id){ | ||
| + | _lastNativeData[i] = item; found = true; break; | ||
| + | } | ||
| + | } | ||
| + | if(!found) _lastNativeData.push(item); | ||
| + | } | ||
| + | }); | ||
| + | } | ||
| + | } | ||
| + | return _origIncr.call(this, | ||
| + | }; | ||
| + | } | ||
| + | fetchMwj(); | ||
| + | setInterval(fetchMwj, | ||
| + | if(document.readyState === ' | ||
| + | document.addEventListener(' | ||
| + | } | ||
| + | else{ | ||
| + | waitForApp(applyPatch); | ||
| + | } | ||
| + | })(); | ||
| + | </ | ||
| - | | + | **Перевірка:** відкрити AlarmViewer → вкладку Events, після входу/виходу оператора |
| - | * Після копіювання файлу перевірити доступність зовні: ``https://< | + | |
| - | * Якщо повертається ``{" | + | |
| - | ===== 4. Типові проблеми ===== | + | ===== 5. Типові проблеми ===== |
| ^ Симптом ^ Ймовірна причина ^ Рішення ^ | ^ Симптом ^ Ймовірна причина ^ Рішення ^ | ||
| Рядок 135: | Рядок 477: | ||
| | ``/events`` повертає порожній масив | Невірний ``JOURNAL_DIR`` для проєкту, | | ``/events`` повертає порожній масив | Невірний ``JOURNAL_DIR`` для проєкту, | ||
| | ASPX повертає ``{" | | ASPX повертає ``{" | ||
| + | | Події не з' | ||
| | Завдання не стартує при перезавантаженні | Не увімкнено ``Run whether user is logged on or not`` або немає прав | Перевірити Security options і ``Run with highest privileges`` | | | Завдання не стартує при перезавантаженні | Не увімкнено ``Run whether user is logged on or not`` або немає прав | Перевірити Security options і ``Run with highest privileges`` | | ||