Compare commits
2
Commits
191e69eb1d
...
cded4a0200
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cded4a0200 | ||
|
|
400837b41a |
No files matched your search
+47
-10
@@ -1,44 +1,81 @@
|
|||||||
import traceback
|
import traceback
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from contextvars import ContextVar
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
_ACTIVE_LOG_GROUP = ContextVar('appdaemon_log_group', default=None)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def log_group(kind, label=None, *, inherit=True):
|
||||||
|
"""Tag LoggerInterface messages in this synchronous operation with one group ID."""
|
||||||
|
parent = _ACTIVE_LOG_GROUP.get() if inherit else None
|
||||||
|
group = parent or {
|
||||||
|
'id': uuid4().hex[:12],
|
||||||
|
'kind': kind,
|
||||||
|
'label': str(label) if label is not None else '',
|
||||||
|
}
|
||||||
|
token = _ACTIVE_LOG_GROUP.set(group)
|
||||||
|
try:
|
||||||
|
yield group
|
||||||
|
finally:
|
||||||
|
_ACTIVE_LOG_GROUP.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class LoggerInterface:
|
class LoggerInterface:
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.initialized = False
|
self.initialized = False
|
||||||
|
|
||||||
def __init__(self,ad_api, default_log = "main_log",mute = False):
|
def __init__(self, ad_api, default_log='main_log', mute=False):
|
||||||
self.initialize_logger_interface(ad_api, default_log, mute)
|
self.initialize_logger_interface(ad_api, default_log, mute)
|
||||||
|
|
||||||
def initialize_logger_interface(self,ad_api, default_log = "main_log",mute = False):
|
def initialize_logger_interface(self, ad_api, default_log='main_log', mute=False):
|
||||||
self.ad_api = ad_api
|
self.ad_api = ad_api
|
||||||
self.default_log = default_log
|
self.default_log = default_log
|
||||||
self.mute = False
|
self.mute = False
|
||||||
self.initialized = True
|
self.initialized = True
|
||||||
|
|
||||||
|
def log_group(self, kind, label=None):
|
||||||
|
return log_group(kind, label)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _tag_log_message(message):
|
||||||
|
group = _ACTIVE_LOG_GROUP.get()
|
||||||
|
if group is None:
|
||||||
|
return message
|
||||||
|
marker = f"[group={group['kind']}:{group['id']}]"
|
||||||
|
if isinstance(message, str) and marker in message:
|
||||||
|
return message
|
||||||
|
return f"{message} {marker}"
|
||||||
|
|
||||||
def mute_logger(self, mute: bool):
|
def mute_logger(self, mute: bool):
|
||||||
if mute:
|
if mute:
|
||||||
self.ad_api.log("Muting logger",level = "INFO", log = self.default_log)
|
self.ad_api.log(self._tag_log_message('Muting logger'), level='INFO', log=self.default_log)
|
||||||
self.mute = bool(mute)
|
self.mute = bool(mute)
|
||||||
|
|
||||||
def log_info(self, message):
|
def log_info(self, message):
|
||||||
assert self.initialized
|
assert self.initialized
|
||||||
if not self.mute:
|
if not self.mute:
|
||||||
self.ad_api.log(message,level = "INFO", log = self.default_log)
|
self.ad_api.log(self._tag_log_message(message), level='INFO', log=self.default_log)
|
||||||
|
|
||||||
def log_warning(self, message):
|
def log_warning(self, message):
|
||||||
assert self.initialized
|
assert self.initialized
|
||||||
self.ad_api.log(message,level = "WARNING", log = self.default_log)
|
self.ad_api.log(self._tag_log_message(message), level='WARNING', log=self.default_log)
|
||||||
|
|
||||||
def log_error(self, message, stop_app=False, dump_stack=False):
|
def log_error(self, message, stop_app=False, dump_stack=False):
|
||||||
assert self.initialized
|
assert self.initialized
|
||||||
|
|
||||||
if dump_stack:
|
if dump_stack:
|
||||||
stack = "\n"
|
stack = '\n'
|
||||||
for line in traceback.format_stack()[:-1]:
|
for line in traceback.format_stack()[:-1]:
|
||||||
stack += line.strip() + '\n'
|
stack += line.strip() + '\n'
|
||||||
else: stack = ""
|
else:
|
||||||
|
stack = ''
|
||||||
|
|
||||||
self.ad_api.log(message,level = "ERROR", log = self.default_log)
|
message = self._tag_log_message(message)
|
||||||
self.ad_api.log(message + stack,level = "ERROR", log = "error_log")
|
self.ad_api.log(message, level='ERROR', log=self.default_log)
|
||||||
|
self.ad_api.log(message + stack, level='ERROR', log='error_log')
|
||||||
|
|
||||||
if stop_app:
|
if stop_app:
|
||||||
self.stop_app(self.name)
|
self.stop_app(self.name)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
#import appdaemon.plugins.hass.hassapi as hass
|
#import appdaemon.plugins.hass.hassapi as hass
|
||||||
import enum
|
import enum
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from contextlib import nullcontext
|
||||||
#from pickle import FALSE
|
#from pickle import FALSE
|
||||||
|
|
||||||
from adexpressioncontext import ADExpressionContext
|
from adexpressioncontext import ADExpressionContext
|
||||||
@@ -56,6 +57,7 @@ class Evaluator():
|
|||||||
|
|
||||||
self.__no_log_prefix = parser.parse_args('no_log_prefix',False) #as no_log_prefix is used to feedback parser error, it need to be defined first
|
self.__no_log_prefix = parser.parse_args('no_log_prefix',False) #as no_log_prefix is used to feedback parser error, it need to be defined first
|
||||||
condition_name = parser.parse_args('condition_name',None,['log_prefix'])
|
condition_name = parser.parse_args('condition_name',None,['log_prefix'])
|
||||||
|
self.__trace_name = ', '.join(map(str, condition_name)) if isinstance(condition_name, list) else str(condition_name or 'condition')
|
||||||
if condition_name:
|
if condition_name:
|
||||||
if isinstance(condition_name,list):
|
if isinstance(condition_name,list):
|
||||||
for name in condition_name:
|
for name in condition_name:
|
||||||
@@ -161,7 +163,17 @@ class Evaluator():
|
|||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
|
||||||
|
def __log_group(self):
|
||||||
|
# SmartObject itself implements LoggerInterface, but many evaluators are
|
||||||
|
# constructed with only the app as their API and no logger_interface kwarg.
|
||||||
|
logger = self.__logger_interface or self.__appdaemon_api
|
||||||
|
group_scope = getattr(logger, 'log_group', None)
|
||||||
|
if callable(group_scope):
|
||||||
|
return group_scope('smartcondition', self.__trace_name)
|
||||||
|
return nullcontext()
|
||||||
|
|
||||||
def evaluate(self,log = True):
|
def evaluate(self,log = True):
|
||||||
|
with self.__log_group():
|
||||||
try: result = self.__evaluate()
|
try: result = self.__evaluate()
|
||||||
except EvaluationException as e:
|
except EvaluationException as e:
|
||||||
self.__log(f"{e}",level = "ERROR")
|
self.__log(f"{e}",level = "ERROR")
|
||||||
@@ -491,6 +503,10 @@ class Evaluator():
|
|||||||
self.__last_evaluation_result = new_result
|
self.__last_evaluation_result = new_result
|
||||||
|
|
||||||
def __trigger_callbacks(self,trigger_reason,triggered_from_activation = False):
|
def __trigger_callbacks(self,trigger_reason,triggered_from_activation = False):
|
||||||
|
with self.__log_group():
|
||||||
|
return self.__trigger_callbacks_in_group(trigger_reason, triggered_from_activation)
|
||||||
|
|
||||||
|
def __trigger_callbacks_in_group(self,trigger_reason,triggered_from_activation = False):
|
||||||
has_log_trigger_reason = False
|
has_log_trigger_reason = False
|
||||||
has_evaluation_result = False
|
has_evaluation_result = False
|
||||||
|
|
||||||
|
|||||||
+17
-3
@@ -183,10 +183,22 @@ class SmartObject(hass.Hass,LoggerInterface):
|
|||||||
# mute → entity → constants → templates_library → virtual_sensors
|
# mute → entity → constants → templates_library → virtual_sensors
|
||||||
# → attributes_override → dataset restore → on_initialize_smart_object()
|
# → attributes_override → dataset restore → on_initialize_smart_object()
|
||||||
def initialize(self):
|
def initialize(self):
|
||||||
|
self.initialize_logger_interface(self.get_ad_api())
|
||||||
|
with self.log_group('initialization', self.name) as group:
|
||||||
|
self.log(f"Initialization started [group=initialization:{group['id']}]")
|
||||||
|
try:
|
||||||
|
succeeded = self._initialize_smart_object()
|
||||||
|
except BaseException:
|
||||||
|
self.log(f"Initialization failed [group=initialization:{group['id']}]", level='ERROR')
|
||||||
|
raise
|
||||||
|
status = 'completed' if succeeded else 'failed'
|
||||||
|
level = 'INFO' if succeeded else 'ERROR'
|
||||||
|
self.log(f"Initialization {status} [group=initialization:{group['id']}]", level=level)
|
||||||
|
|
||||||
|
def _initialize_smart_object(self):
|
||||||
try:
|
try:
|
||||||
#self.depends_on_module(["smartobject","virtualsensors","logger_interface"])
|
#self.depends_on_module(["smartobject","virtualsensors","logger_interface"])
|
||||||
|
|
||||||
self.initialize_logger_interface(self.get_ad_api())
|
|
||||||
self.log_info(f"Name = {self.name}")
|
self.log_info(f"Name = {self.name}")
|
||||||
# mute: true → suppress all log output for this app
|
# mute: true → suppress all log output for this app
|
||||||
try: self.mute_logger(self.args['mute'])
|
try: self.mute_logger(self.args['mute'])
|
||||||
@@ -272,8 +284,10 @@ class SmartObject(hass.Hass,LoggerInterface):
|
|||||||
self.log_info(self.get_dataset_name() + " loaded")
|
self.log_info(self.get_dataset_name() + " loaded")
|
||||||
except (FileNotFoundError,EOFError): pass #self.log_info("File " + self.get_dataset_name() + " not found (and it's ok)")
|
except (FileNotFoundError,EOFError): pass #self.log_info("File " + self.get_dataset_name() + " not found (and it's ok)")
|
||||||
self.on_initialize_smart_object()
|
self.on_initialize_smart_object()
|
||||||
except ParsingException as e: self.log_error(str(e),stop_app = True)
|
except ParsingException as e:
|
||||||
|
self.log_error(str(e), stop_app=True)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
# AppDaemon shutdown hook. Persists self.dataset to disk (if set).
|
# AppDaemon shutdown hook. Persists self.dataset to disk (if set).
|
||||||
def terminate(self):
|
def terminate(self):
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ classes = load_classes(TOOLBOX / 'eventsforwarder.py', {
|
|||||||
|
|
||||||
class ForwarderTests(unittest.TestCase):
|
class ForwarderTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.owner = Mock()
|
# Do not fabricate optional API methods such as log_group on lookup.
|
||||||
|
self.owner = Mock(spec=[
|
||||||
|
'entity_exists', 'get_state', 'log', 'log_info', 'log_warning',
|
||||||
|
'log_error', 'fire_event', 'call_service',
|
||||||
|
])
|
||||||
self.states = {'media_player.bravia_theatre': 'off',
|
self.states = {'media_player.bravia_theatre': 'off',
|
||||||
'binary_sensor.is_tv_on': 'off'}
|
'binary_sensor.is_tv_on': 'off'}
|
||||||
self.owner.entity_exists.side_effect = lambda entity: entity in self.states
|
self.owner.entity_exists.side_effect = lambda entity: entity in self.states
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Verify scoped log grouping without replacing AppDaemon API methods."""
|
||||||
|
import os
|
||||||
|
import pickle
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
TOOLBOX = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(TOOLBOX))
|
||||||
|
from logger_interface import LoggerInterface
|
||||||
|
import smartcondition
|
||||||
|
|
||||||
|
|
||||||
|
def load_smart_object():
|
||||||
|
source = (TOOLBOX / 'smartobject.py').read_text()
|
||||||
|
import ast
|
||||||
|
tree = ast.parse(source)
|
||||||
|
smart_object = next(node for node in tree.body
|
||||||
|
if isinstance(node, ast.ClassDef) and node.name == 'SmartObject')
|
||||||
|
class FakeHass:
|
||||||
|
def log(self, message, level='INFO', log=None):
|
||||||
|
self.rows.append((level, message, log))
|
||||||
|
namespace = {'hass': SimpleNamespace(Hass=FakeHass),
|
||||||
|
'LoggerInterface': LoggerInterface, 'VirtualSensors': object,
|
||||||
|
'ParsingException': type('ParsingException', (Exception,), {}),
|
||||||
|
'pickle': pickle, 'os': os}
|
||||||
|
exec(compile(ast.Module(body=[smart_object], type_ignores=[]), str(TOOLBOX / 'smartobject.py'), 'exec'), namespace)
|
||||||
|
return namespace['SmartObject']
|
||||||
|
|
||||||
|
|
||||||
|
class FakeApp(LoggerInterface):
|
||||||
|
name = 'fake_app'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.rows = []
|
||||||
|
super().__init__(self)
|
||||||
|
|
||||||
|
def log(self, message, level='INFO', log=None):
|
||||||
|
self.rows.append((level, message, log))
|
||||||
|
|
||||||
|
def initialize(self):
|
||||||
|
self.log_info('LoggerInterface initialization message')
|
||||||
|
self.log('Direct app log stays readable')
|
||||||
|
|
||||||
|
|
||||||
|
class LoggerGroupingTests(unittest.TestCase):
|
||||||
|
def test_initialize_is_not_implicitly_wrapped_or_tagged(self):
|
||||||
|
app = FakeApp()
|
||||||
|
original_initialize = app.initialize.__func__
|
||||||
|
original_log = app.log.__func__
|
||||||
|
app.initialize()
|
||||||
|
self.assertIs(app.initialize.__func__, original_initialize)
|
||||||
|
self.assertIs(app.log.__func__, original_log)
|
||||||
|
self.assertEqual(app.rows[0][1], 'LoggerInterface initialization message')
|
||||||
|
self.assertEqual(app.rows[1][1], 'Direct app log stays readable')
|
||||||
|
|
||||||
|
def test_smart_object_groups_its_own_lifecycle(self):
|
||||||
|
SmartObject = load_smart_object()
|
||||||
|
app = SmartObject.__new__(SmartObject)
|
||||||
|
app.rows = []
|
||||||
|
app.name = 'test_smart_object'
|
||||||
|
app.args = {}
|
||||||
|
with tempfile.TemporaryDirectory() as folder:
|
||||||
|
app.AD = SimpleNamespace(app_dir=folder)
|
||||||
|
app.get_ad_api = lambda: app
|
||||||
|
app.on_initialize_smart_object = lambda: app.log_info('Subclass initialization hook')
|
||||||
|
app.initialize()
|
||||||
|
self.assertIn('Initialization started [group=initialization:', app.rows[0][1])
|
||||||
|
group_id = app.rows[0][1].split(':', 1)[1].rstrip(']')
|
||||||
|
self.assertIn(f'[group=initialization:{group_id}]', app.rows[1][1])
|
||||||
|
self.assertIn(f'[group=initialization:{group_id}]', app.rows[2][1])
|
||||||
|
self.assertEqual(app.rows[-1][1], f'Initialization completed [group=initialization:{group_id}]')
|
||||||
|
|
||||||
|
def test_smartcondition_uses_smartobject_api_scope_without_logger_interface_argument(self):
|
||||||
|
app = FakeApp()
|
||||||
|
evaluator = smartcondition.Evaluator.__new__(smartcondition.Evaluator)
|
||||||
|
evaluator._Evaluator__logger_interface = None
|
||||||
|
evaluator._Evaluator__appdaemon_api = app
|
||||||
|
evaluator._Evaluator__trace_name = 'smart_conditions'
|
||||||
|
with evaluator._Evaluator__log_group():
|
||||||
|
app.log_info('Callback trigger by sensor.example(0 => 1)')
|
||||||
|
app.log_info('[smart_conditions][Evaluating] Triggered by condition')
|
||||||
|
marker_a = app.rows[0][1].split('[group=')[1].rstrip(']')
|
||||||
|
marker_b = app.rows[1][1].split('[group=')[1].rstrip(']')
|
||||||
|
self.assertEqual(marker_a, marker_b)
|
||||||
|
self.assertTrue(marker_a.startswith('smartcondition:'))
|
||||||
|
|
||||||
|
def test_nested_scopes_inherit_outer_group_and_reset_after_exception(self):
|
||||||
|
app = FakeApp()
|
||||||
|
with app.log_group('value_selector', 'sensor.a') as outer:
|
||||||
|
app.log_info('candidate rejected')
|
||||||
|
with app.log_group('smartcondition', 'condition-a') as inner:
|
||||||
|
self.assertIs(inner, outer)
|
||||||
|
app.log_info('condition result')
|
||||||
|
app.log_info('outside group')
|
||||||
|
self.assertIn(f'[group=value_selector:{outer["id"]}]', app.rows[0][1])
|
||||||
|
self.assertIn(f'[group=value_selector:{outer["id"]}]', app.rows[1][1])
|
||||||
|
self.assertEqual(app.rows[2][1], 'outside group')
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
with app.log_group('smartcondition'):
|
||||||
|
raise RuntimeError('expected')
|
||||||
|
app.log_info('after failure')
|
||||||
|
self.assertEqual(app.rows[3][1], 'after failure')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -2,3 +2,8 @@ node_modules/
|
|||||||
src/
|
src/
|
||||||
tsconfig.json
|
tsconfig.json
|
||||||
.gitignore
|
.gitignore
|
||||||
|
|
||||||
|
tests/**
|
||||||
|
|
||||||
|
*.vsix
|
||||||
|
@eaDir/**
|
||||||
+58
-72
@@ -1,96 +1,82 @@
|
|||||||
# Extension VS Code AppDaemon
|
# AppDaemon for Home Assistant — VS Code extension
|
||||||
|
|
||||||
Extension VS Code pour développer et administrer les applications AppDaemon
|
Version: **0.2.0**
|
||||||
utilisées avec Home Assistant.
|
|
||||||
|
|
||||||
Version documentée : `0.1.0`
|
## Install or update
|
||||||
|
|
||||||
## Installation
|
Open **Extensions**, select **… → Install from VSIX…**, and choose
|
||||||
|
`vscode-appdaemon-0.2.0.vsix`. Reload VS Code when prompted.
|
||||||
|
|
||||||
1. Ouvrir **Extensions** dans VS Code (`Ctrl+Shift+X` / `Cmd+Shift+X`).
|
## Log explorer
|
||||||
2. Cliquer sur `...`, puis **Install from VSIX...**.
|
|
||||||
3. Sélectionner `vscode-appdaemon-0.1.0.vsix` dans ce dossier.
|
|
||||||
4. Recharger VS Code si nécessaire.
|
|
||||||
|
|
||||||
## Configuration
|
Select **$(list-tree) AD Logs** in the VS Code status bar, or run **AppDaemon: Explore Logs** from the Command Palette. The explorer opens in a central editor tab, including when no document is open.
|
||||||
|
|
||||||
Les réglages sont disponibles dans **Paramètres → Extensions → AppDaemon**, ou
|
By default, it reads the existing AppDaemon text logs from the workspace:
|
||||||
dans `.vscode/settings.json` :
|
|
||||||
|
- `logs/appdaemon.log`
|
||||||
|
- `logs/error.log`
|
||||||
|
- `logs/virtualsensors.log`
|
||||||
|
|
||||||
|
The explorer groups lines by the app name in the standard AppDaemon log format. It provides INFO/WARNING/ERROR counts, expandable app histories, relative timestamps, sorting by latest activity or app name, text/time/level filters, live updates, copy/paste, raw-log navigation, and archived-log import. Duplicate error lines found in multiple files are merged. The log reader handles file rotation, truncation, multiline exceptions, UTF-8, and legacy Windows-1252 bytes.
|
||||||
|
|
||||||
|
The standard AppDaemon text logs do not contain causal links or reliable initialization boundaries. The details panel will say so for ordinary entries. Existing messages that already contain trace or initialization metadata can still be grouped or linked; the explorer does not create that metadata. **Open history** can also load archived files. No AppDaemon Python changes are needed for this raw-log view.
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"appdaemon.haUrl": "http://homeassistant:8123",
|
"appdaemon.haUrl": "http://homeassistant:8123",
|
||||||
"appdaemon.haToken": "<TOKEN_HOME_ASSISTANT>",
|
"appdaemon.haToken": "<HOME_ASSISTANT_TOKEN>",
|
||||||
"appdaemon.adUrl": "http://localhost:5050",
|
"appdaemon.adUrl": "http://localhost:5050",
|
||||||
"appdaemon.errorLogPath": "logs/error.log",
|
"appdaemon.errorLogPath": "logs/error.log",
|
||||||
"appdaemon.mainLogPath": "logs/appdaemon.log",
|
"appdaemon.mainLogPath": "logs/appdaemon.log",
|
||||||
"appdaemon.entityRefreshInterval": 300
|
"appdaemon.entityRefreshInterval": 300,
|
||||||
|
"appdaemon.logExplorerPaths": [
|
||||||
|
"logs/appdaemon.log",
|
||||||
|
"logs/error.log",
|
||||||
|
"logs/virtualsensors.log"
|
||||||
|
],
|
||||||
|
"appdaemon.logTimezone": "Europe/Berlin",
|
||||||
|
"appdaemon.logEncoding": "auto"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Réglage | Défaut | Description |
|
| Setting | Purpose |
|
||||||
| --- | --- | --- |
|
| --- | --- |
|
||||||
| `appdaemon.haUrl` | `http://localhost:8123` | URL de Home Assistant |
|
| `haUrl`, `haToken` | Home Assistant connection for entity information |
|
||||||
| `appdaemon.haToken` | vide | Jeton longue durée Home Assistant |
|
| `adUrl` | AppDaemon HTTP API URL |
|
||||||
| `appdaemon.adUrl` | `http://localhost:5050` | URL de l’API HTTP AppDaemon |
|
| `errorLogPath`, `mainLogPath` | Log locations relative to the workspace |
|
||||||
| `appdaemon.errorLogPath` | `logs/error.log` | Journal d’erreurs, relatif au workspace |
|
| `entityRefreshInterval` | Entity refresh interval in seconds |
|
||||||
| `appdaemon.mainLogPath` | `logs/appdaemon.log` | Journal principal, relatif au workspace |
|
| `logExplorerPaths` | Text log files followed by the explorer |
|
||||||
| `appdaemon.entityRefreshInterval` | `300` | Rafraîchissement des entités, en secondes |
|
| `logTimezone` | Timezone used to interpret AppDaemon timestamps |
|
||||||
|
| `logEncoding` | `auto`, `utf8`, or `windows-1252` for text logs |
|
||||||
|
|
||||||
Ne pas versionner le jeton Home Assistant. Utiliser de préférence les réglages
|
Do not commit your Home Assistant token. Use user settings or an ignored local
|
||||||
utilisateur ou un fichier local ignoré par Git.
|
settings file.
|
||||||
|
|
||||||
## Raccourcis clavier
|
## Other extension features
|
||||||
|
|
||||||
| Action | Windows / Linux | macOS |
|
- Home Assistant entity completion and hover information in Python/YAML.
|
||||||
| --- | --- | --- |
|
- Navigation to AppDaemon app and virtual-sensor definitions.
|
||||||
| Aller à une application AppDaemon | `Ctrl+Shift+A` | `Cmd+Shift+A` |
|
- AppDaemon errors as VS Code diagnostics and an output channel.
|
||||||
| Aller à un capteur virtuel | `Ctrl+Shift+V` | `Cmd+Shift+V` |
|
- Status bar actions for app reload, Home Assistant restart, production mode,
|
||||||
|
error handling and apps defined in the active YAML file.
|
||||||
|
|
||||||
Chaque raccourci ouvre une palette permettant de rechercher puis sélectionner
|
| Shortcut | Action |
|
||||||
l’application ou le capteur souhaité.
|
| --- | --- |
|
||||||
|
| Ctrl+Shift+A / Cmd+Shift+A | Go to App |
|
||||||
|
| Ctrl+Shift+V / Cmd+Shift+V | Go to Virtual Sensor |
|
||||||
|
|
||||||
Pour modifier un raccourci, ouvrir les raccourcis clavier (`Ctrl+K Ctrl+S`),
|
Commands are available under **AppDaemon:** in the Command Palette: **Go to
|
||||||
rechercher `AppDaemon`, puis modifier la commande voulue.
|
App**, **Restart Apps in Current File**, **Reload Apps**, **Restart Home
|
||||||
|
Assistant**, **Toggle Production Mode**, **Show Error Log**, **Refresh Entity
|
||||||
|
List**, **Handle Errors**, **Clear Error Diagnostics**, and **Explore Logs**.
|
||||||
|
|
||||||
## Commandes disponibles
|
## Verification
|
||||||
|
|
||||||
Toutes les commandes sont accessibles avec la palette (`Ctrl+Shift+P` / `Cmd+Shift+P`)
|
```sh
|
||||||
en recherchant `AppDaemon:`.
|
npm test
|
||||||
|
```
|
||||||
- **Go to App** : ouvrir la définition d’une application AppDaemon.
|
|
||||||
- **Restart Apps in Current File** : redémarrer les apps du fichier YAML actif.
|
|
||||||
- **Reload Apps** : recharger toutes les applications AppDaemon.
|
|
||||||
- **Restart Home Assistant** : redémarrer Home Assistant ou l’hôte, après confirmation.
|
|
||||||
- **Toggle Production Mode** : activer ou désactiver le mode production.
|
|
||||||
- **Show Error Log** : afficher le journal d’erreurs.
|
|
||||||
- **Refresh Entity List** : recharger les entités Home Assistant.
|
|
||||||
- **Handle Errors** : afficher les erreurs et choisir de les consulter ou de les effacer.
|
|
||||||
- **Clear Error Diagnostics** : effacer les diagnostics d’erreur de VS Code.
|
|
||||||
|
|
||||||
## Barre d’état
|
|
||||||
|
|
||||||
La barre d’état propose des boutons pour recharger AppDaemon, redémarrer Home
|
|
||||||
Assistant, basculer le mode production, consulter les erreurs et redémarrer les
|
|
||||||
apps trouvées dans le fichier YAML actif.
|
|
||||||
|
|
||||||
Quand des erreurs sont détectées, `AD Errors` affiche leur nombre et permet de
|
|
||||||
consulter le journal ou d’effacer les diagnostics.
|
|
||||||
|
|
||||||
## Fonctionnalités d’aide au développement
|
|
||||||
|
|
||||||
Dans les fichiers YAML et Python, l’extension fournit notamment :
|
|
||||||
|
|
||||||
- la complétion des entités Home Assistant ;
|
|
||||||
- les informations au survol des entités ;
|
|
||||||
- la navigation vers les définitions de capteurs virtuels ;
|
|
||||||
- les symboles et la navigation vers les applications AppDaemon ;
|
|
||||||
- la remontée des erreurs présentes dans le journal AppDaemon.
|
|
||||||
|
|
||||||
## Dépannage rapide
|
|
||||||
|
|
||||||
- **Aucune entité proposée** : vérifier `haUrl`, le jeton et l’accès réseau, puis lancer `AppDaemon: Refresh Entity List`.
|
|
||||||
- **Les commandes AppDaemon échouent** : vérifier que l’API HTTP est active et que `appdaemon.adUrl` correspond à la section `http` de `appdaemon.yaml`.
|
|
||||||
- **Les erreurs ne remontent pas** : vérifier `appdaemon.errorLogPath`, relatif à la racine ouverte dans VS Code.
|
|
||||||
- **Un raccourci ne fonctionne pas** : rechercher la commande dans les raccourcis VS Code et vérifier les conflits.
|
|
||||||
|
|
||||||
|
The log explorer preview is available at `docs/log-explorer-preview.html` in the
|
||||||
|
workspace; it uses sample data and is not a live log server.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M5 19 11 5l6 14M7.2 13.8h7.6"/>
|
||||||
|
<circle cx="18.5" cy="7" r="2"/>
|
||||||
|
<path d="M15.8 8.7 14 10M4 19h15"/>
|
||||||
|
<circle cx="4" cy="19" r="1.3"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 342 B |
@@ -0,0 +1,8 @@
|
|||||||
|
:root{color-scheme:dark light;--bg:var(--vscode-editor-background,#111820);--fg:var(--vscode-editor-foreground,#dae3ed);--panel:var(--vscode-sideBar-background,#18212c);--border:var(--vscode-panel-border,#334252);--muted:var(--vscode-descriptionForeground,#a0afbf);--accent:var(--vscode-focusBorder,#6caef0)}
|
||||||
|
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:13px var(--vscode-font-family,system-ui,sans-serif)}header{padding:22px 24px 16px;display:flex;justify-content:space-between;gap:20px;align-items:center}h1{font-size:21px;margin:0}h1 span{font-weight:400;color:var(--muted);font-size:16px;margin-left:10px}p{color:var(--muted);line-height:1.6}header p{margin:7px 0 0}.actions{display:flex;gap:8px}button,input,select{font:inherit;color:var(--fg);background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:8px 10px}button{cursor:pointer}button:hover{border-color:var(--accent)}button:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:2px}button[aria-pressed=true]{border-color:var(--accent);color:var(--accent)}nav{display:flex;align-items:center;gap:10px;padding:0 24px 18px;flex-wrap:wrap}nav input[type=search]{flex:1;min-width:220px}nav label{display:flex;align-items:center;gap:6px;color:var(--muted)}main{display:grid;grid-template-columns:minmax(350px,1fr) minmax(380px,1fr);border-top:1px solid var(--border);height:calc(100vh - 150px)}section{overflow:auto;padding:18px 20px}.apps{border-right:1px solid var(--border)}.section-title{display:flex;justify-content:space-between;align-items:center;margin-bottom:15px;gap:10px}h2{font-size:14px;margin:0}.section-title>span{color:var(--muted)}details{border:1px solid var(--border);border-radius:8px;margin-bottom:10px;background:var(--panel)}summary{cursor:pointer;padding:12px;display:flex;align-items:center;gap:10px}summary:before{content:'▸';color:var(--muted)}details[open]>summary:before{content:'▾'}summary .name{flex:1;overflow-wrap:anywhere}.badge{border-radius:5px;padding:3px 6px;font-size:11px;font-weight:600;white-space:nowrap}.INFO,.DEBUG{color:#91c5fa;background:#284561}.WARNING{color:#f3cd80;background:#594925}.ERROR,.CRITICAL{color:#ffacac;background:#5b3039}.rows{border-top:1px solid var(--border)}.row{display:block;width:100%;text-align:left;border:0;border-radius:0;background:transparent;padding:11px 13px;border-bottom:1px solid var(--border)}.row:last-child{border-bottom:0}.row.selected{background:var(--vscode-list-activeSelectionBackground,#263f57);box-shadow:inset 3px 0 var(--accent)}.row-meta{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:11px;margin-bottom:5px}.row-message{white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.5}.link-hint{margin-left:auto;color:var(--accent)}.empty{padding:40px 15px;color:var(--muted);line-height:1.6;text-align:center}.selected-message{font-size:15px;line-height:1.5;white-space:pre-wrap;overflow-wrap:anywhere}.metadata{color:var(--muted);font-size:12px;margin:8px 0 15px}.legend{color:var(--muted);font-size:12px;margin:15px 0}.graph{overflow:auto;position:relative;margin:20px 0;min-height:120px}.graph svg{position:absolute;left:0;top:0;pointer-events:none}.graph-stage{position:relative}.node{position:absolute;width:320px;height:115px;overflow:auto;padding:12px;text-align:left;background:var(--panel);font-size:12px;line-height:1.5}.node strong{display:block;color:var(--accent);margin-bottom:5px}.node .text{white-space:pre-wrap;overflow-wrap:anywhere}.node.target{border-color:var(--accent)}.node.missing{border-style:dashed;color:var(--muted)}.tree ul{padding-left:23px;list-style:none;border-left:1px solid var(--border)}.tree li{margin:12px 0}.tree button{text-align:left;line-height:1.5}.tree .probable{color:#f3cd80;font-size:11px}.values{width:100%;border-collapse:collapse;font-size:12px}.values td{padding:8px;border-bottom:1px solid var(--border);overflow-wrap:anywhere}.values td:first-child{color:var(--muted)}#warnings{color:#f3cd80;padding:0 24px}#warnings:empty{display:none}.more{padding:12px;color:var(--muted)}@media(max-width:850px){main{grid-template-columns:1fr;height:auto}.apps{border-right:0;border-bottom:1px solid var(--border)}section{max-height:65vh}header{align-items:flex-start;padding:18px}nav{padding:0 18px 15px}summary{gap:6px}.badge{font-size:10px}}
|
||||||
|
.app-label{flex:1;min-width:0;display:flex;flex-direction:column;gap:5px}.last-log{color:var(--muted);font-size:11px;font-weight:400;overflow-wrap:anywhere}.row{cursor:pointer;user-select:text;-webkit-user-select:text}.row:hover{background:var(--vscode-list-hoverBackground,#203040)}.selected-message,.row-message,.node,.tree,.values{user-select:text;-webkit-user-select:text}.section-title{flex-wrap:wrap}.section-title select{font-size:12px}.actions{flex-wrap:wrap}
|
||||||
|
@keyframes new-log-pulse{0%{box-shadow:inset 0 0 0 0 color-mix(in srgb,var(--accent) 0%,transparent)}25%{box-shadow:inset 0 0 0 2px color-mix(in srgb,var(--accent) 85%,transparent);background-color:color-mix(in srgb,var(--accent) 12%,var(--panel))}100%{box-shadow:inset 0 0 0 0 color-mix(in srgb,var(--accent) 0%,transparent)}}.new-log-app>summary{animation:new-log-pulse 1.8s ease-out}.recent-log{animation:new-log-pulse 1.8s ease-out}@media(prefers-reduced-motion:reduce){.new-log-app>summary,.recent-log{animation:none;outline:2px solid var(--accent);outline-offset:-2px}}
|
||||||
|
|
||||||
|
.log-message.level-WARNING{color:var(--vscode-editorWarning-foreground,#f3cd80)}.log-message.level-ERROR{color:var(--vscode-editorError-foreground,#ffacac)}
|
||||||
|
|
||||||
|
.block-title{font-weight:600;margin-bottom:6px;color:var(--fg);overflow-wrap:anywhere}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src {{CSP}}; script-src 'nonce-{{NONCE}}';">
|
||||||
|
<link rel="stylesheet" href="{{CSS}}"><title>AppDaemon · Logs</title></head>
|
||||||
|
<body>
|
||||||
|
<header><div><h1>AppDaemon <span>Logs</span></h1><p id="status" role="status">Reading logs…</p></div><div class="actions"><button id="import">Open history</button><button id="copy">Copy selected log</button><button id="pause">Pause</button></div></header>
|
||||||
|
<nav aria-label="Filters"><input id="search" type="search" placeholder="Search apps, entities and messages…" aria-label="Search"><select id="period" aria-label="Time range"><option value="15">15 minutes</option><option value="60">1 hour</option><option value="1440">24 hours</option><option value="0" selected>All loaded</option></select><select id="level" aria-label="Level"><option value="all">All levels</option><option value="INFO">INFO</option><option value="WARNING">WARNING</option><option value="ERROR">ERROR</option><option value="DEBUG">DEBUG</option></select></nav>
|
||||||
|
<div id="warnings" role="status"></div>
|
||||||
|
<main><section class="apps" aria-label="Applications"><div class="section-title"><h2>Applications</h2><span id="count"></span><select id="sort" aria-label="Sort apps"><option value="latest" selected>Latest log first</option><option value="oldest">Oldest log first</option><option value="name">App name</option></select></div><div id="apps"></div></section><section class="causes" aria-label="Log details"><div class="section-title"><h2>Log details</h2><div><button id="graph-mode" aria-pressed="true">Graph</button><button id="tree-mode" aria-pressed="false">Tree</button></div></div><div id="detail"><div class="empty">Select a log to view its details.<p>The base AppDaemon log format does not include causal links.</p></div></div></section></main>
|
||||||
|
<script nonce="{{NONCE}}" src="{{JS}}"></script></body></html>
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
/* Shared UI: VS Code transport or a standalone review fixture. No log HTML is executed. */
|
||||||
|
(() => {
|
||||||
|
const inVSCode = typeof acquireVsCodeApi === 'function';
|
||||||
|
const vscode = inVSCode ? acquireVsCodeApi() : {
|
||||||
|
async postMessage(message) {
|
||||||
|
if (message.type !== 'copy') return;
|
||||||
|
if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(message.text);
|
||||||
|
else {
|
||||||
|
const area = document.createElement('textarea'); area.value = message.text;
|
||||||
|
document.body.append(area); area.select(); document.execCommand?.('copy'); area.remove();
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new MessageEvent('message', { data: { type: 'copied' } }));
|
||||||
|
}, getState() {}, setState() {}
|
||||||
|
};
|
||||||
|
const saved = vscode.getState() || {};
|
||||||
|
let entries = [], latest, appliedMessage, displayZone = 'Europe/Berlin', paused = false, selected = saved.selected, mode = saved.mode || 'graph', origin = saved.origin || saved.selected;
|
||||||
|
const expanded = new Set(saved.expanded || []);
|
||||||
|
const visibleEventGroups = new Map();
|
||||||
|
const recentEvents = new Set();
|
||||||
|
const recentApps = new Set();
|
||||||
|
const appFlashTokens = new Map();
|
||||||
|
let receivedInitialData = false;
|
||||||
|
let pointerSelecting = false;
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
const node = (tag, text, cls) => { const e = document.createElement(tag); if (text !== undefined) e.textContent = text; if (cls) e.className = cls; return e; };
|
||||||
|
const normalLevel = e => e.level === 'CRITICAL' ? 'ERROR' : e.level;
|
||||||
|
function save() { vscode.setState({ selected, mode, origin, expanded: [...expanded], sort: $('sort').value }); }
|
||||||
|
function choose(id, fromList = true) { if (fromList) origin = id; selected = id; save(); renderApps(); renderDetail(); }
|
||||||
|
function filtered() {
|
||||||
|
const q = $('search').value.toLowerCase(), level = $('level').value, minutes = Number($('period').value);
|
||||||
|
const after = minutes ? Date.now() - minutes * 60000 : -Infinity;
|
||||||
|
return entries.filter(e => (e.kind === 'log' || e.kind.startsWith('initialization_')) && (!minutes || e.time >= after)
|
||||||
|
&& (level === 'all' || normalLevel(e) === level)
|
||||||
|
&& `${e.app} ${e.entity || ''} ${e.message}`.toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
const cleanMessage = e => e.message.replace(/ \[event=[\w-]+ trace=[\w-]+\]/g, '').replace(/ \[init=[\w-]+\]/g, '').replace(/ \[group=(?:initialization|smartcondition|value_selector):[a-f0-9]{12}\]/gi, '');
|
||||||
|
const formatTime = (entry, includeYear = false) => {
|
||||||
|
if (!Number.isFinite(entry.time)) return entry.timestamp;
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat(undefined, { timeZone: displayZone, day: '2-digit', month: 'short', ...(includeYear ? { year: 'numeric' } : {}), hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23', timeZoneName: 'shortOffset' }).format(new Date(entry.time));
|
||||||
|
} catch { return entry.timestamp; }
|
||||||
|
};
|
||||||
|
const copyText = e => `${formatTime(e, true)} ${e.level} ${e.app}: ${cleanMessage(e)}`;
|
||||||
|
function timeSince(time) {
|
||||||
|
const seconds = Math.max(0, Math.floor((Date.now() - Number(time)) / 1000));
|
||||||
|
if (seconds < 60) return `${seconds} second${seconds === 1 ? '' : 's'} ago`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
if (days < 30) return `${days} day${days === 1 ? '' : 's'} ago`;
|
||||||
|
const months = Math.floor(days / 30);
|
||||||
|
if (months < 12) return `${months} month${months === 1 ? '' : 's'} ago`;
|
||||||
|
const years = Math.floor(days / 365);
|
||||||
|
return `${years} year${years === 1 ? '' : 's'} ago`;
|
||||||
|
}
|
||||||
|
function copy(text) { if (text) vscode.postMessage({ type: 'copy', text }); }
|
||||||
|
function logMessages(items, cls) {
|
||||||
|
const container = node('div', undefined, cls);
|
||||||
|
items.forEach((entry, index) => {
|
||||||
|
if (index) container.append(document.createTextNode('\n'));
|
||||||
|
container.append(node('span', cleanMessage(entry), `log-message level-${normalLevel(entry)}`));
|
||||||
|
});
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
function blockTitle(items, groupKey) {
|
||||||
|
const first = items[0];
|
||||||
|
const kind = groupKey?.startsWith('initialization:') || first.initialization_id ? 'initialization' : items.find(entry => entry.group_kind)?.group_kind;
|
||||||
|
const names = { initialization: 'Initialization', smartcondition: 'Smart condition', value_selector: 'Value selector' };
|
||||||
|
const name = names[kind] || (items.length > 1 || first.trace_id ? 'Event' : 'Log');
|
||||||
|
const label = kind === 'initialization' ? first.app : items.map(entry => entry.group_label || cleanMessage(entry).match(/^\[([^\]]+)\]/)?.[1]).find(Boolean) || first.entity || first.app;
|
||||||
|
return node('div', `${name} · ${label}`, 'block-title');
|
||||||
|
}
|
||||||
|
function row(e) {
|
||||||
|
const item = node('div', undefined, `row${selected === e.event_id ? ' selected' : ''}${recentEvents.has(e.event_id) ? ' recent-log' : ''}`);
|
||||||
|
item.dataset.eventId = e.event_id; item.dataset.eventIds = e.event_id;
|
||||||
|
item.tabIndex = 0; item.setAttribute('role', 'button');
|
||||||
|
const meta = node('div', undefined, 'row-meta');
|
||||||
|
const relative = node('span', timeSince(e.time), 'relative-time');
|
||||||
|
relative.dataset.time = String(e.time);
|
||||||
|
relative.title = `${formatTime(e, true)} · Exact: ${e.timestamp}`;
|
||||||
|
meta.append(relative, node('span', e.level, `badge ${normalLevel(e)}`));
|
||||||
|
if (e.cause_ids.length) meta.append(node('span', 'View causes ↗', 'link-hint'));
|
||||||
|
item.append(blockTitle([e]), meta, logMessages([e], 'row-message'));
|
||||||
|
item.onclick = () => { if (!window.getSelection()?.toString()) choose(e.event_id); };
|
||||||
|
item.onkeydown = event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); choose(e.event_id); } };
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
function groupedByEvent(items, groupKey) {
|
||||||
|
const groups = [];
|
||||||
|
items.forEach(e => {
|
||||||
|
const key = groupKey || (e.group_id ? `group:${e.group_id}` : e.kind === 'log' && e.trace_id ? `trace:${e.trace_id}` : undefined);
|
||||||
|
const previous = groups[groups.length - 1];
|
||||||
|
if (key && previous?.key === key) previous.entries.push(e);
|
||||||
|
else groups.push({ key, entries: [e] });
|
||||||
|
});
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
function appendEventRows(parent, items, groupKey) {
|
||||||
|
groupedByEvent(items, groupKey).forEach(unit => {
|
||||||
|
if (unit.entries.length === 1) { parent.append(row(unit.entries[0])); return; }
|
||||||
|
const representative = [...unit.entries].reverse().find(e => e.cause_ids?.length) || unit.entries[unit.entries.length - 1];
|
||||||
|
const ids = unit.entries.map(e => e.event_id);
|
||||||
|
visibleEventGroups.set(representative.event_id, unit.entries);
|
||||||
|
const recent = ids.some(id => recentEvents.has(id));
|
||||||
|
const item = node('div', undefined, `row event-log-group${ids.includes(selected) ? ' selected' : ''}${recent ? ' recent-log' : ''}`);
|
||||||
|
item.dataset.eventId = representative.event_id; item.dataset.eventIds = ids.join(',');
|
||||||
|
item.tabIndex = 0; item.setAttribute('role', 'button');
|
||||||
|
const meta = node('div', undefined, 'row-meta');
|
||||||
|
const relative = node('span', timeSince(unit.entries[0].time), 'relative-time');
|
||||||
|
relative.dataset.time = String(unit.entries[0].time);
|
||||||
|
relative.title = `${formatTime(unit.entries[0], true)} · Exact: ${unit.entries[0].timestamp}`;
|
||||||
|
meta.append(relative);
|
||||||
|
for (const level of ['INFO', 'WARNING', 'ERROR']) {
|
||||||
|
const count = unit.entries.filter(e => normalLevel(e) === level).length;
|
||||||
|
if (count) meta.append(node('span', count === 1 ? level : `${level} ${count}`, `badge ${level}`));
|
||||||
|
}
|
||||||
|
if (unit.entries.some(e => e.cause_ids.length)) meta.append(node('span', 'View causes ↗', 'link-hint'));
|
||||||
|
item.append(blockTitle(unit.entries, groupKey), meta, logMessages(unit.entries, 'row-message'));
|
||||||
|
item.onclick = () => { if (!window.getSelection()?.toString()) choose(representative.event_id); };
|
||||||
|
item.onkeydown = event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); choose(representative.event_id); } };
|
||||||
|
parent.append(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function renderApps() {
|
||||||
|
visibleEventGroups.clear();
|
||||||
|
const container = $('apps'); container.replaceChildren();
|
||||||
|
const groups = new Map();
|
||||||
|
filtered().forEach(e => { if (!groups.has(e.app)) groups.set(e.app, []); groups.get(e.app).push(e); });
|
||||||
|
$('count').textContent = `${groups.size} apps`;
|
||||||
|
if (!groups.size) container.append(node('div', 'No logs match these filters. Try a wider time range or clear the search.', 'empty'));
|
||||||
|
const lastLogs = new Map();
|
||||||
|
entries.filter(e => e.kind === 'log' || e.kind.startsWith('initialization_')).forEach(e => {
|
||||||
|
if (!lastLogs.has(e.app) || e.time > lastLogs.get(e.app).time) lastLogs.set(e.app, e);
|
||||||
|
});
|
||||||
|
const timeOf = (app, logs) => (lastLogs.get(app) || logs[logs.length - 1]).time;
|
||||||
|
[...groups].sort(([a, al], [b, bl]) => {
|
||||||
|
const order = $('sort').value;
|
||||||
|
if (order === 'name') return a.localeCompare(b);
|
||||||
|
return (order === 'oldest' ? 1 : -1) * (timeOf(a, al) - timeOf(b, bl)) || a.localeCompare(b);
|
||||||
|
}).forEach(([app, logs]) => {
|
||||||
|
const detail = node('details'); detail.open = expanded.has(app); detail.dataset.app = app;
|
||||||
|
if (recentApps.has(app)) detail.classList.add('new-log-app');
|
||||||
|
const summary = node('summary'), label = node('div', undefined, 'app-label');
|
||||||
|
const last = lastLogs.get(app);
|
||||||
|
label.append(node('span', app, 'name'));
|
||||||
|
if (last) {
|
||||||
|
const age = node('span', `Last log: ${timeSince(last.time)}`, 'last-log last-age');
|
||||||
|
age.dataset.time = String(last.time);
|
||||||
|
age.title = `${formatTime(last, true)} · Exact: ${last.timestamp}`;
|
||||||
|
label.append(age);
|
||||||
|
} else label.append(node('span', 'No log in loaded history', 'last-log'));
|
||||||
|
summary.append(label);
|
||||||
|
['INFO', 'WARNING', 'ERROR'].forEach(level => summary.append(node('span', `${level} ${logs.filter(e => normalLevel(e) === level).length}`, `badge ${level}`)));
|
||||||
|
detail.append(summary);
|
||||||
|
function fill() {
|
||||||
|
detail.querySelector('.rows')?.remove();
|
||||||
|
if (!detail.open) return;
|
||||||
|
const rows = node('div', undefined, 'rows');
|
||||||
|
const units = [], initGroups = new Map();
|
||||||
|
logs.forEach(e => {
|
||||||
|
if (e.initialization_id) {
|
||||||
|
if (!initGroups.has(e.initialization_id)) { const unit = { id: e.initialization_id, entries: [] }; initGroups.set(e.initialization_id, unit); units.push(unit); }
|
||||||
|
initGroups.get(e.initialization_id).entries.push(e);
|
||||||
|
} else {
|
||||||
|
const key = e.group_id ? `group:${e.group_id}` : e.kind === 'log' && e.trace_id ? `trace:${e.trace_id}` : undefined;
|
||||||
|
const previous = units[units.length - 1];
|
||||||
|
if (key && previous && !previous.id && previous.key === key) previous.entries.push(e);
|
||||||
|
else units.push({ key, entries: [e] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const startTime = unit => unit.id
|
||||||
|
? (entries.find(e => e.app === app && e.initialization_id === unit.id && e.kind === 'initialization_started') || unit.entries[0]).time
|
||||||
|
: unit.entries[0].time;
|
||||||
|
units.sort((a, b) => startTime(a) - startTime(b));
|
||||||
|
units.slice(-150).forEach(unit => {
|
||||||
|
appendEventRows(rows, unit.entries, unit.id ? `initialization:${unit.id}` : undefined);
|
||||||
|
});
|
||||||
|
if (units.length > 150) rows.append(node('div', `${units.length - 150} older rows/groups: narrow the time range or search.`, 'more'));
|
||||||
|
detail.append(rows);
|
||||||
|
}
|
||||||
|
detail.addEventListener('toggle', event => { if (event.target !== detail) return; detail.open ? expanded.add(app) : expanded.delete(app); save(); fill(); });
|
||||||
|
fill(); container.append(detail);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function renderDetail() {
|
||||||
|
$('graph-mode').setAttribute('aria-pressed', String(mode === 'graph'));
|
||||||
|
$('tree-mode').setAttribute('aria-pressed', String(mode === 'tree'));
|
||||||
|
const e = entries.find(e => e.event_id === selected), container = $('detail');
|
||||||
|
$('graph-mode').parentElement.hidden = !e || !e.cause_ids.length;
|
||||||
|
if (!e) { container.replaceChildren(node('div', selected ? 'This entry is no longer in the loaded history.' : 'Select a log to view its details.', 'empty')); return; }
|
||||||
|
const selectedGroup = visibleEventGroups.get(e.event_id) || [e];
|
||||||
|
container.replaceChildren(logMessages(selectedGroup, 'selected-message'), node('div', `${e.app} · ${e.timestamp} · ${selectedGroup.length} log lines`, 'metadata'));
|
||||||
|
const raw = node('button', 'Open raw log'); raw.onclick = () => vscode.postMessage({ type: 'open', id: e.event_id }); container.append(raw);
|
||||||
|
if (e.source) { const source = node('button', 'Open source'); source.onclick = () => vscode.postMessage({ type: 'source', id: e.event_id }); container.append(source); }
|
||||||
|
if (origin && origin !== selected) { const back = node('button', 'Back to selected log'); back.onclick = () => choose(origin); container.append(back); }
|
||||||
|
const byId = new Map(entries.map(e => [e.event_id, e]));
|
||||||
|
const mirroredEvaluations = new Map();
|
||||||
|
entries.filter(candidate => candidate.kind === 'log').forEach(log => {
|
||||||
|
log.cause_ids.forEach(id => {
|
||||||
|
const candidate = byId.get(id);
|
||||||
|
if (candidate?.kind === 'evaluation' && candidate.app === log.app && candidate.trace_id && candidate.trace_id === log.trace_id && cleanMessage(candidate) === cleanMessage(log)) mirroredEvaluations.set(id, log);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const causesFor = entry => entry.cause_ids.flatMap(id => {
|
||||||
|
const mirror = mirroredEvaluations.get(id);
|
||||||
|
return mirror?.event_id === entry.event_id ? (byId.get(id)?.cause_ids || []) : [id];
|
||||||
|
});
|
||||||
|
const probableCausesFor = entry => {
|
||||||
|
const causes = new Set(entry.probable_cause_ids || []);
|
||||||
|
entry.cause_ids.forEach(id => {
|
||||||
|
if (mirroredEvaluations.get(id)?.event_id === entry.event_id) (byId.get(id)?.probable_cause_ids || []).forEach(parent => causes.add(parent));
|
||||||
|
});
|
||||||
|
return causes;
|
||||||
|
};
|
||||||
|
const mirroredEvaluation = [...mirroredEvaluations].find(([, log]) => log.event_id === e.event_id)?.[0];
|
||||||
|
if (!causesFor(e).length) container.append(node('p', e.kind === 'log' ? 'The base AppDaemon log format does not record what caused this entry.' : 'Observed origin: no upstream cause was recorded.'));
|
||||||
|
const nodes = new Map(), edges = [];
|
||||||
|
function visit(id, depth, path) {
|
||||||
|
if (depth > 16 || nodes.size >= 80 || path.has(id)) return;
|
||||||
|
const entry = byId.get(id);
|
||||||
|
const existing = nodes.get(id);
|
||||||
|
if (existing && existing.depth >= depth) return;
|
||||||
|
nodes.set(id, { e: entry, depth, id });
|
||||||
|
if (!entry) return;
|
||||||
|
const next = new Set(path); next.add(id);
|
||||||
|
const probable = probableCausesFor(entry);
|
||||||
|
causesFor(entry).forEach(parent => {
|
||||||
|
if (!edges.some(edge => edge.from === parent && edge.to === id)) edges.push({ from: parent, to: id, probable: probable.has(parent) });
|
||||||
|
visit(parent, depth + 1, next);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
visit(e.event_id, 0, new Set());
|
||||||
|
if (e.cause_ids.length) {
|
||||||
|
container.append(node('p', 'Solid: explicit link · Dashed: probable link · Missing causes may be outside the loaded history. Limited to 80 events and 16 levels.', 'legend'));
|
||||||
|
if (mode === 'graph') {
|
||||||
|
const graph = node('div', undefined, 'graph'), stage = node('div', undefined, 'graph-stage');
|
||||||
|
const nodeWidth = 320, nodeHeight = 115, laneGap = 350, levelGap = 155;
|
||||||
|
const maxDepth = Math.max(...[...nodes.values()].map(n => n.depth)), positions = new Map(), lanes = new Map();
|
||||||
|
nodes.forEach(n => { const level = maxDepth - n.depth; const lane = lanes.get(level) || 0; lanes.set(level, lane + 1); positions.set(n.id, { x: lane * laneGap + 10, y: level * levelGap + 10 }); });
|
||||||
|
const width = Math.max(nodeWidth + 20, ...[...positions.values()].map(p => p.x + nodeWidth + 10)), height = maxDepth * levelGap + nodeHeight + 20;
|
||||||
|
stage.style.width = `${width}px`; stage.style.height = `${height}px`;
|
||||||
|
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('width', width); svg.setAttribute('height', height);
|
||||||
|
edges.forEach(edge => {
|
||||||
|
const a = positions.get(edge.from), b = positions.get(edge.to); if (!a || !b) return;
|
||||||
|
const path = document.createElementNS(svg.namespaceURI, 'path');
|
||||||
|
path.setAttribute('d', `M ${a.x + nodeWidth / 2} ${a.y + nodeHeight} C ${a.x + nodeWidth / 2} ${a.y + nodeHeight + 20},${b.x + nodeWidth / 2} ${b.y - 20},${b.x + nodeWidth / 2} ${b.y}`);
|
||||||
|
path.setAttribute('fill', 'none'); path.setAttribute('stroke', edge.probable ? '#d5ad68' : '#7199bc'); path.setAttribute('stroke-width', '2');
|
||||||
|
if (edge.probable) path.setAttribute('stroke-dasharray', '5 5'); svg.append(path);
|
||||||
|
const arrow = document.createElementNS(svg.namespaceURI, 'path'); arrow.setAttribute('d', `M ${b.x + nodeWidth / 2 - 5} ${b.y - 6} L ${b.x + nodeWidth / 2} ${b.y} L ${b.x + nodeWidth / 2 + 5} ${b.y - 6}`); arrow.setAttribute('fill', 'none'); arrow.setAttribute('stroke', '#7199bc'); svg.append(arrow);
|
||||||
|
});
|
||||||
|
stage.append(svg);
|
||||||
|
nodes.forEach(n => { const p = positions.get(n.id), b = node('button', undefined, `node${!n.e ? ' missing' : ''}${n.id === selected ? ' target' : ''}`); const message = n.e ? (visibleEventGroups.get(n.e.event_id) || [n.e]).map(cleanMessage).join('\n') : n.id; b.style.left = `${p.x}px`; b.style.top = `${p.y}px`; b.append(node('strong', n.e?.app || 'Missing cause'), node('div', message, 'text')); b.title = message; b.disabled = !n.e; b.onclick = () => choose(n.id, false); stage.append(b); });
|
||||||
|
graph.append(stage); container.append(graph);
|
||||||
|
} else {
|
||||||
|
const tree = node('div', undefined, 'tree');
|
||||||
|
let count = 0;
|
||||||
|
function branch(id, depth, path) {
|
||||||
|
const list = node('ul');
|
||||||
|
if (depth > 16 || count++ > 80 || path.has(id)) { list.append(node('li', 'Depth limit or cycle.')); return list; }
|
||||||
|
const n = byId.get(id), item = node('li'), button = node('button', n ? `${n.app} · ${(visibleEventGroups.get(n.event_id) || [n]).map(cleanMessage).join('\n')}` : 'Cause missing from loaded history'); button.disabled = !n; button.onclick = () => choose(id, false); item.append(button);
|
||||||
|
const next = new Set(path); next.add(id);
|
||||||
|
if (n) { const probable = probableCausesFor(n); causesFor(n).forEach(parent => { if (probable.has(parent)) item.append(node('div', 'Probable link ↓', 'probable')); item.append(branch(parent, depth + 1, next)); }); } list.append(item); return list;
|
||||||
|
}
|
||||||
|
tree.append(branch(e.event_id, 0, new Set())); container.append(tree);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const detailValues = e.values || (mirroredEvaluation ? byId.get(mirroredEvaluation)?.values : undefined);
|
||||||
|
if (detailValues && Object.keys(detailValues).length) {
|
||||||
|
container.append(node('h2', 'Values used during evaluation'));
|
||||||
|
const table = node('table', undefined, 'values'); Object.entries(detailValues).forEach(([key, value]) => { const row = node('tr'); row.append(node('td', key), node('td', JSON.stringify(value))); table.append(row); }); container.append(table);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function highlightNewLogs(incoming) {
|
||||||
|
const byApp = new Map();
|
||||||
|
incoming.forEach(e => {
|
||||||
|
recentEvents.add(e.event_id);
|
||||||
|
if (!byApp.has(e.app)) byApp.set(e.app, []);
|
||||||
|
byApp.get(e.app).push(e.event_id);
|
||||||
|
});
|
||||||
|
byApp.forEach((ids, app) => {
|
||||||
|
recentApps.add(app);
|
||||||
|
const token = {};
|
||||||
|
appFlashTokens.set(app, token);
|
||||||
|
setTimeout(() => {
|
||||||
|
ids.forEach(id => {
|
||||||
|
recentEvents.delete(id);
|
||||||
|
document.querySelectorAll('.row.recent-log').forEach(item => { if (item.dataset.eventId === id) item.classList.remove('recent-log'); });
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.event-log-group.recent-log').forEach(item => {
|
||||||
|
if (!item.dataset.eventIds.split(',').some(id => recentEvents.has(id))) item.classList.remove('recent-log');
|
||||||
|
});
|
||||||
|
if (appFlashTokens.get(app) === token) {
|
||||||
|
recentApps.delete(app); appFlashTokens.delete(app);
|
||||||
|
document.querySelectorAll('.new-log-app').forEach(item => { if (item.dataset.app === app) item.classList.remove('new-log-app'); });
|
||||||
|
}
|
||||||
|
}, 1800);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function apply(message) {
|
||||||
|
const nextEntries = message.entries;
|
||||||
|
if (receivedInitialData) {
|
||||||
|
const known = new Set(entries.map(e => e.event_id));
|
||||||
|
highlightNewLogs(nextEntries.filter(e => e.kind === 'log' && !known.has(e.event_id)));
|
||||||
|
}
|
||||||
|
receivedInitialData = true;
|
||||||
|
appliedMessage = message; displayZone = message.timezone || displayZone; entries = nextEntries;
|
||||||
|
const logCount = entries.filter(e => e.kind === 'log' || e.kind.startsWith('initialization_')).length;
|
||||||
|
const traceCount = entries.length - logCount;
|
||||||
|
const traceLabel = traceCount === 1 ? 'trace event' : 'trace events';
|
||||||
|
$('status').textContent = traceCount
|
||||||
|
? `${logCount} log lines + ${traceCount} ${traceLabel} · Live updates · Up to ${message.limit || 10000} records`
|
||||||
|
: `${logCount} log lines · Live updates · Up to ${message.limit || 10000} records`;
|
||||||
|
$('status').title = traceCount
|
||||||
|
? 'Showing AppDaemon logs and additional records from configured history files.'
|
||||||
|
: 'Reading the configured AppDaemon text logs. Causal links require additional metadata that the base log format does not contain.';
|
||||||
|
$('warnings').textContent = (message.warnings || []).join(' · '); renderApps(); renderDetail();
|
||||||
|
}
|
||||||
|
function refreshLastLogAges() {
|
||||||
|
document.querySelectorAll('.last-age[data-time]').forEach(label => { label.textContent = `Last log: ${timeSince(label.dataset.time)}`; });
|
||||||
|
document.querySelectorAll('.relative-time[data-time]').forEach(label => { label.textContent = timeSince(label.dataset.time); });
|
||||||
|
}
|
||||||
|
setInterval(refreshLastLogAges, 1000);
|
||||||
|
function selectionActive() { return pointerSelecting || Boolean(window.getSelection()?.toString()); }
|
||||||
|
window.addEventListener('message', event => {
|
||||||
|
if (event.data?.type === 'copied') { $('copy').textContent = 'Copied'; setTimeout(() => $('copy').textContent = 'Copy selected log', 1200); return; }
|
||||||
|
if (event.data?.type !== 'entries') return;
|
||||||
|
latest = event.data;
|
||||||
|
if (!paused && !selectionActive()) apply(latest);
|
||||||
|
else if (!paused) $('status').textContent = 'Updates buffered while text is selected';
|
||||||
|
});
|
||||||
|
document.addEventListener('pointerdown', event => { pointerSelecting = Boolean(event.target.closest?.('.row, .selected-message, .node, .values')); });
|
||||||
|
document.addEventListener('pointerup', () => { pointerSelecting = false; });
|
||||||
|
document.addEventListener('selectionchange', () => { if (!paused && !selectionActive() && latest && latest !== appliedMessage) setTimeout(() => { if (!paused && !selectionActive() && latest !== appliedMessage) apply(latest); }, 0); });
|
||||||
|
document.addEventListener('keydown', event => {
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c' && !event.target.closest?.('input, textarea, [contenteditable=true]')) {
|
||||||
|
const entry = entries.find(e => e.event_id === selected);
|
||||||
|
const text = window.getSelection()?.toString() || (entry ? (visibleEventGroups.get(entry.event_id) || [entry]).map(copyText).join('\n') : '');
|
||||||
|
if (text && (inVSCode || !window.getSelection()?.toString())) { event.preventDefault(); copy(text); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$('copy').onclick = () => { const e = entries.find(e => e.event_id === selected); copy(window.getSelection()?.toString() || (e ? (visibleEventGroups.get(e.event_id) || [e]).map(copyText).join('\n') : '')); };
|
||||||
|
['search', 'period', 'level', 'sort'].forEach(id => $(id).addEventListener('input', () => { save(); renderApps(); }));
|
||||||
|
$('pause').onclick = () => { paused = !paused; $('pause').textContent = paused ? 'Resume' : 'Pause'; if (!paused && latest) apply(latest); else if (paused) $('status').textContent = 'Display paused · New events are still being collected'; };
|
||||||
|
$('import').onclick = () => vscode.postMessage({ type: 'import' });
|
||||||
|
$('graph-mode').onclick = () => { mode = 'graph'; save(); renderDetail(); };
|
||||||
|
$('tree-mode').onclick = () => { mode = 'tree'; save(); renderDetail(); };
|
||||||
|
$('sort').value = saved.sort || 'latest';
|
||||||
|
vscode.postMessage({ type: 'ready' });
|
||||||
|
if (window.LOG_REVIEW_DATA) { apply({ timezone: 'Europe/Berlin', entries: window.LOG_REVIEW_DATA, warnings: ['Interface preview: sample data, no AppDaemon connection.'], limit: 10000 }); expanded.add('automation'); selected = 'review-5'; renderApps(); renderDetail(); }
|
||||||
|
})();
|
||||||
Generated
+533
-2
@@ -1,21 +1,151 @@
|
|||||||
{
|
{
|
||||||
"name": "vscode-appdaemon",
|
"name": "vscode-appdaemon",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "vscode-appdaemon",
|
"name": "vscode-appdaemon",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
"@types/vscode": "^1.85.0",
|
"@types/vscode": "^1.85.0",
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
"typescript": "^5.3.0"
|
"typescript": "^5.3.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"vscode": "^1.85.0"
|
"vscode": "^1.85.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@asamuzakjp/css-color": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@csstools/css-calc": "^2.1.3",
|
||||||
|
"@csstools/css-color-parser": "^3.0.9",
|
||||||
|
"@csstools/css-parser-algorithms": "^3.0.4",
|
||||||
|
"@csstools/css-tokenizer": "^3.0.3",
|
||||||
|
"lru-cache": "^10.4.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/color-helpers": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-calc": {
|
||||||
|
"version": "2.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
|
||||||
|
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@csstools/css-parser-algorithms": "^3.0.5",
|
||||||
|
"@csstools/css-tokenizer": "^3.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-color-parser": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@csstools/color-helpers": "^5.1.0",
|
||||||
|
"@csstools/css-calc": "^2.1.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@csstools/css-parser-algorithms": "^3.0.5",
|
||||||
|
"@csstools/css-tokenizer": "^3.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-parser-algorithms": {
|
||||||
|
"version": "3.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
|
||||||
|
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@csstools/css-tokenizer": "^3.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-tokenizer": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.39",
|
"version": "20.19.39",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
||||||
@@ -33,6 +163,307 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/agent-base": {
|
||||||
|
"version": "7.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
|
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cssstyle": {
|
||||||
|
"version": "4.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
|
||||||
|
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@asamuzakjp/css-color": "^3.2.0",
|
||||||
|
"rrweb-cssom": "^0.8.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/data-urls": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-mimetype": "^4.0.0",
|
||||||
|
"whatwg-url": "^14.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/decimal.js": {
|
||||||
|
"version": "10.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||||
|
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/html-encoding-sniffer": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-encoding": "^3.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/http-proxy-agent": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "^7.1.0",
|
||||||
|
"debug": "^4.3.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/https-proxy-agent": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "^7.1.2",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/iconv-lite": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-potential-custom-element-name": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/jsdom": {
|
||||||
|
"version": "26.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
|
||||||
|
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cssstyle": "^4.2.1",
|
||||||
|
"data-urls": "^5.0.0",
|
||||||
|
"decimal.js": "^10.5.0",
|
||||||
|
"html-encoding-sniffer": "^4.0.0",
|
||||||
|
"http-proxy-agent": "^7.0.2",
|
||||||
|
"https-proxy-agent": "^7.0.6",
|
||||||
|
"is-potential-custom-element-name": "^1.0.1",
|
||||||
|
"nwsapi": "^2.2.16",
|
||||||
|
"parse5": "^7.2.1",
|
||||||
|
"rrweb-cssom": "^0.8.0",
|
||||||
|
"saxes": "^6.0.0",
|
||||||
|
"symbol-tree": "^3.2.4",
|
||||||
|
"tough-cookie": "^5.1.1",
|
||||||
|
"w3c-xmlserializer": "^5.0.0",
|
||||||
|
"webidl-conversions": "^7.0.0",
|
||||||
|
"whatwg-encoding": "^3.1.1",
|
||||||
|
"whatwg-mimetype": "^4.0.0",
|
||||||
|
"whatwg-url": "^14.1.1",
|
||||||
|
"ws": "^8.18.0",
|
||||||
|
"xml-name-validator": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"canvas": "^3.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"canvas": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lru-cache": {
|
||||||
|
"version": "10.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||||
|
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/nwsapi": {
|
||||||
|
"version": "2.2.28",
|
||||||
|
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.28.tgz",
|
||||||
|
"integrity": "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/parse5": {
|
||||||
|
"version": "7.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||||
|
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"entities": "^6.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/punycode": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/rrweb-cssom": {
|
||||||
|
"version": "0.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
|
||||||
|
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/safer-buffer": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/saxes": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"xmlchars": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=v12.22.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/symbol-tree": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tldts": {
|
||||||
|
"version": "6.1.86",
|
||||||
|
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
|
||||||
|
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tldts-core": "^6.1.86"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tldts": "bin/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tldts-core": {
|
||||||
|
"version": "6.1.86",
|
||||||
|
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
|
||||||
|
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tough-cookie": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"tldts": "^6.1.32"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"punycode": "^2.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
@@ -53,6 +484,106 @@
|
|||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/w3c-xmlserializer": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xml-name-validator": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/webidl-conversions": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-encoding": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
|
||||||
|
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"iconv-lite": "0.6.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-mimetype": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "14.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
|
||||||
|
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "^5.1.0",
|
||||||
|
"webidl-conversions": "^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.22.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
|
||||||
|
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xml-name-validator": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlchars": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "vscode-appdaemon",
|
"name": "vscode-appdaemon",
|
||||||
"displayName": "AppDaemon for Home Assistant",
|
"displayName": "AppDaemon for Home Assistant",
|
||||||
"description": "AppDaemon development tools: status bar controls, entity autocompletion, hover tooltips, error viewer",
|
"description": "AppDaemon development tools: log explorer, status bar controls, entity autocompletion, and error viewer",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"publisher": "ad-toolbox",
|
"publisher": "ad-toolbox",
|
||||||
"engines": {
|
"engines": {
|
||||||
"vscode": "^1.85.0"
|
"vscode": "^1.85.0"
|
||||||
@@ -47,6 +47,33 @@
|
|||||||
"type": "number",
|
"type": "number",
|
||||||
"default": 300,
|
"default": 300,
|
||||||
"description": "Entity list refresh interval in seconds"
|
"description": "Entity list refresh interval in seconds"
|
||||||
|
},
|
||||||
|
"appdaemon.logExplorerPaths": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"default": [
|
||||||
|
"logs/appdaemon.log",
|
||||||
|
"logs/error.log",
|
||||||
|
"logs/virtualsensors.log"
|
||||||
|
],
|
||||||
|
"description": "Log files followed by the explorer, relative to the workspace."
|
||||||
|
},
|
||||||
|
"appdaemon.logTimezone": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "Europe/Berlin",
|
||||||
|
"description": "Timezone used by AppDaemon text logs."
|
||||||
|
},
|
||||||
|
"appdaemon.logEncoding": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"auto",
|
||||||
|
"utf8",
|
||||||
|
"windows-1252"
|
||||||
|
],
|
||||||
|
"default": "auto",
|
||||||
|
"description": "Text log encoding. Auto preserves UTF-8 and decodes legacy Windows-1252 bytes."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -93,6 +120,11 @@
|
|||||||
{
|
{
|
||||||
"command": "appdaemon.clearErrors",
|
"command": "appdaemon.clearErrors",
|
||||||
"title": "AppDaemon: Clear Error Diagnostics"
|
"title": "AppDaemon: Clear Error Diagnostics"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "appdaemon.exploreLogs",
|
||||||
|
"title": "AppDaemon: Explore Logs",
|
||||||
|
"icon": "$(list-tree)"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"keybindings": [
|
"keybindings": [
|
||||||
@@ -111,16 +143,19 @@
|
|||||||
"key": "ctrl+shift+v",
|
"key": "ctrl+shift+v",
|
||||||
"mac": "cmd+shift+v"
|
"mac": "cmd+shift+v"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"menus": {}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"vscode:prepublish": "npm run compile",
|
"vscode:prepublish": "npm run compile",
|
||||||
"compile": "tsc -p ./",
|
"compile": "tsc -p ./",
|
||||||
"watch": "tsc -watch -p ./"
|
"watch": "tsc -watch -p ./",
|
||||||
|
"test": "npm run compile && node --test tests/*.test.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/vscode": "^1.85.0",
|
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
|
"@types/vscode": "^1.85.0",
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
"typescript": "^5.3.0"
|
"typescript": "^5.3.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import { ADClient } from './adClient';
|
|||||||
import { StatusBarManager } from './statusBar';
|
import { StatusBarManager } from './statusBar';
|
||||||
import { EntityCompletionProvider, EntityHoverProvider } from './entityProvider';
|
import { EntityCompletionProvider, EntityHoverProvider } from './entityProvider';
|
||||||
import { ErrorViewer } from './errorViewer';
|
import { ErrorViewer } from './errorViewer';
|
||||||
|
import { LogExplorer } from './logExplorer';
|
||||||
|
|
||||||
let haClient: HAClient;
|
let haClient: HAClient;
|
||||||
let adClient: ADClient;
|
let adClient: ADClient;
|
||||||
@@ -344,6 +345,8 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
haClient = new HAClient(outputChannel);
|
haClient = new HAClient(outputChannel);
|
||||||
adClient = new ADClient(outputChannel);
|
adClient = new ADClient(outputChannel);
|
||||||
errorViewer = new ErrorViewer();
|
errorViewer = new ErrorViewer();
|
||||||
|
const logExplorer = new LogExplorer(context);
|
||||||
|
context.subscriptions.push(logExplorer, vscode.commands.registerCommand('appdaemon.exploreLogs', () => logExplorer.show()));
|
||||||
statusBar = new StatusBarManager(adClient);
|
statusBar = new StatusBarManager(adClient);
|
||||||
|
|
||||||
// Wire error count → status bar badge
|
// Wire error count → status bar badge
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
import { LogEntry, parseLine, decodeLogLine } from './logModel';
|
||||||
|
|
||||||
|
type Cursor = { offset: number; identity: string; pending: Buffer; last?: LogEntry; discard: boolean; generation: string };
|
||||||
|
export class LogExplorer implements vscode.Disposable {
|
||||||
|
private panel?: vscode.WebviewPanel;
|
||||||
|
private view?: vscode.WebviewView;
|
||||||
|
private activeWebview?: vscode.Webview;
|
||||||
|
private messageListener?: vscode.Disposable;
|
||||||
|
private timer?: ReturnType<typeof setInterval>;
|
||||||
|
private entries = new Map<string, LogEntry>();
|
||||||
|
private cursors = new Map<string, Cursor>();
|
||||||
|
private imported = new Set<string>();
|
||||||
|
private warnings = new Set<string>();
|
||||||
|
private busy = false;
|
||||||
|
constructor(private context: vscode.ExtensionContext) {}
|
||||||
|
show() {
|
||||||
|
if (this.view) { this.view.show(); return; }
|
||||||
|
if (this.panel) { this.panel.reveal(); return; }
|
||||||
|
this.panel = vscode.window.createWebviewPanel('appdaemon.logs', 'AppDaemon · Logs', vscode.ViewColumn.One, {
|
||||||
|
enableScripts: true, retainContextWhenHidden: true,
|
||||||
|
localResourceRoots: [vscode.Uri.joinPath(this.context.extensionUri, 'media')]
|
||||||
|
});
|
||||||
|
this.configureWebview(this.panel.webview);
|
||||||
|
this.panel.onDidDispose(() => {
|
||||||
|
this.panel = undefined;
|
||||||
|
if (!this.view) { this.stopPolling(); this.activeWebview = undefined; }
|
||||||
|
});
|
||||||
|
this.startPolling();
|
||||||
|
}
|
||||||
|
resolveWebviewView(view: vscode.WebviewView) {
|
||||||
|
this.view = view;
|
||||||
|
view.webview.options = {
|
||||||
|
enableScripts: true,
|
||||||
|
localResourceRoots: [vscode.Uri.joinPath(this.context.extensionUri, 'media')]
|
||||||
|
};
|
||||||
|
this.configureWebview(view.webview);
|
||||||
|
const updateVisibility = () => view.visible ? this.startPolling() : this.stopPolling();
|
||||||
|
view.onDidChangeVisibility(updateVisibility, undefined, this.context.subscriptions);
|
||||||
|
view.onDidDispose(() => {
|
||||||
|
if (this.view === view) { this.view = undefined; }
|
||||||
|
if (!this.panel) { this.stopPolling(); this.activeWebview = undefined; }
|
||||||
|
}, undefined, this.context.subscriptions);
|
||||||
|
updateVisibility();
|
||||||
|
}
|
||||||
|
private configureWebview(webview: vscode.Webview) {
|
||||||
|
const nonce = randomBytes(16).toString('hex');
|
||||||
|
const media = (name: string) => webview.asWebviewUri(vscode.Uri.joinPath(this.context.extensionUri, 'media', name));
|
||||||
|
const template = fs.readFileSync(path.join(this.context.extensionPath, 'media', 'logExplorer.html'), 'utf8');
|
||||||
|
webview.html = template.replaceAll('{{CSP}}', webview.cspSource).replaceAll('{{NONCE}}', nonce)
|
||||||
|
.replace('{{CSS}}', media('logExplorer.css').toString()).replace('{{JS}}', media('logExplorer.js').toString());
|
||||||
|
this.messageListener?.dispose();
|
||||||
|
this.messageListener = webview.onDidReceiveMessage(async message => {
|
||||||
|
if (message.type === 'copy' && typeof message.text === 'string' && message.text.length <= 1024 * 1024) {
|
||||||
|
try { await vscode.env.clipboard.writeText(message.text); webview.postMessage({ type: 'copied' }); }
|
||||||
|
catch (error) { vscode.window.showWarningMessage(`Unable to copy: ${String(error)}`); }
|
||||||
|
}
|
||||||
|
if (message.type === 'ready') { this.publish(); }
|
||||||
|
if (message.type === 'open' && typeof message.id === 'string') {
|
||||||
|
const entry = this.entries.get(message.id);
|
||||||
|
if (!entry?.file) { return; }
|
||||||
|
try {
|
||||||
|
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(entry.file));
|
||||||
|
const editor = await vscode.window.showTextDocument(doc, { preview: true, viewColumn: vscode.ViewColumn.Beside });
|
||||||
|
const index = doc.getText().indexOf(entry.raw || entry.message);
|
||||||
|
if (index >= 0) {
|
||||||
|
const pos = doc.positionAt(index);
|
||||||
|
editor.selection = new vscode.Selection(pos, pos);
|
||||||
|
editor.revealRange(new vscode.Range(pos, pos));
|
||||||
|
} else { vscode.window.showInformationMessage('This entry has moved after log rotation.'); }
|
||||||
|
} catch (error) { vscode.window.showWarningMessage(`Unable to read log: ${String(error)}`); }
|
||||||
|
}
|
||||||
|
if (message.type === 'source' && typeof message.id === 'string') {
|
||||||
|
const source = this.entries.get(message.id)?.source;
|
||||||
|
const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||||
|
if (!source || !root || typeof source.path !== 'string' || !Number.isInteger(source.line)) { return; }
|
||||||
|
const target = source.path.startsWith('/conf/') ? path.resolve(root, source.path.slice(6)) : path.resolve(source.path);
|
||||||
|
const relative = path.relative(root, target);
|
||||||
|
if (relative.startsWith('..') || path.isAbsolute(relative)) { vscode.window.showInformationMessage('The source is outside the workspace.'); return; }
|
||||||
|
try {
|
||||||
|
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(target));
|
||||||
|
const editor = await vscode.window.showTextDocument(doc, { preview: true, viewColumn: vscode.ViewColumn.Beside });
|
||||||
|
const position = new vscode.Position(Math.max(0, Math.min(doc.lineCount - 1, source.line - 1)), 0);
|
||||||
|
editor.selection = new vscode.Selection(position, position);
|
||||||
|
editor.revealRange(new vscode.Range(position, position));
|
||||||
|
} catch (error) { vscode.window.showWarningMessage(`Source unavailable: ${String(error)}`); }
|
||||||
|
}
|
||||||
|
if (message.type === 'import') {
|
||||||
|
const files = await vscode.window.showOpenDialog({ canSelectMany: true, filters: { Logs: ['log', '1', '2', '3'] } });
|
||||||
|
files?.forEach(uri => this.imported.add(uri.fsPath));
|
||||||
|
this.poll();
|
||||||
|
}
|
||||||
|
}, undefined, this.context.subscriptions);
|
||||||
|
this.activeWebview = webview;
|
||||||
|
}
|
||||||
|
private startPolling() {
|
||||||
|
if (this.timer) { return; }
|
||||||
|
this.poll();
|
||||||
|
this.timer = setInterval(() => this.poll(), 1000);
|
||||||
|
}
|
||||||
|
private stopPolling() {
|
||||||
|
if (this.timer) { clearInterval(this.timer); this.timer = undefined; }
|
||||||
|
}
|
||||||
|
private files(): string[] {
|
||||||
|
const folder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||||
|
if (!folder) { return [...this.imported]; }
|
||||||
|
const config = vscode.workspace.getConfiguration('appdaemon');
|
||||||
|
const configured = config.get<string[]>('logExplorerPaths', ['logs/appdaemon.log', 'logs/error.log', 'logs/virtualsensors.log']);
|
||||||
|
return [...new Set([...configured.map(p => path.resolve(folder, p)), ...this.imported])];
|
||||||
|
}
|
||||||
|
private poll() {
|
||||||
|
if (this.busy) { return; }
|
||||||
|
this.busy = true;
|
||||||
|
let changed = false;
|
||||||
|
try {
|
||||||
|
const encoding = vscode.workspace.getConfiguration('appdaemon').get<string>('logEncoding', 'auto');
|
||||||
|
const zone = vscode.workspace.getConfiguration('appdaemon').get<string>('logTimezone', 'Europe/Berlin');
|
||||||
|
for (const file of this.files()) {
|
||||||
|
if (!fs.existsSync(file)) { continue; }
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(file);
|
||||||
|
const identity = `${stat.dev}:${stat.ino}`;
|
||||||
|
let cursor = this.cursors.get(file);
|
||||||
|
if (!cursor || cursor.identity !== identity || stat.size < cursor.offset) {
|
||||||
|
const offset = Math.max(0, stat.size - 2 * 1024 * 1024);
|
||||||
|
cursor = { offset, identity, pending: Buffer.alloc(0), discard: offset > 0, generation: randomBytes(8).toString('hex') };
|
||||||
|
this.cursors.set(file, cursor);
|
||||||
|
}
|
||||||
|
if (stat.size <= cursor.offset) { continue; }
|
||||||
|
const length = Math.min(512 * 1024, stat.size - cursor.offset);
|
||||||
|
const buffer = Buffer.alloc(length);
|
||||||
|
const fd = fs.openSync(file, 'r');
|
||||||
|
let bytes: number;
|
||||||
|
try { bytes = fs.readSync(fd, buffer, 0, length, cursor.offset); } finally { fs.closeSync(fd); }
|
||||||
|
const start = cursor.offset - cursor.pending.length;
|
||||||
|
cursor.offset += bytes;
|
||||||
|
const content = Buffer.concat([cursor.pending, buffer.subarray(0, bytes)]);
|
||||||
|
let from = 0;
|
||||||
|
for (let end = content.indexOf(10); end >= 0; end = content.indexOf(10, from)) {
|
||||||
|
const line = decodeLogLine(content.subarray(from, end), file.includes('jsonl') ? 'utf8' : encoding).replace(/\r$/, '');
|
||||||
|
const position = start + from;
|
||||||
|
from = end + 1;
|
||||||
|
if (cursor.discard) { cursor.discard = false; continue; }
|
||||||
|
const entry = parseLine(line, file, position, zone);
|
||||||
|
if (entry) {
|
||||||
|
if (!entry.trace_id && !line.startsWith('{')) { entry.event_id += ':' + cursor.generation; }
|
||||||
|
const existing = this.entries.get(entry.event_id);
|
||||||
|
if (!existing || entry.raw?.startsWith('{')) { this.entries.set(entry.event_id, entry); }
|
||||||
|
cursor.last = this.entries.get(entry.event_id);
|
||||||
|
changed = true;
|
||||||
|
} else if (line.trim() && cursor.last && !file.includes('jsonl')) {
|
||||||
|
// Traceback lines belong to the previous timestamped entry.
|
||||||
|
if ((cursor.last.raw?.length || 0) < 64 * 1024) {
|
||||||
|
cursor.last.message += '\n' + line;
|
||||||
|
cursor.last.raw += '\n' + line;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
} else if (line.trim() && file.includes('jsonl')) { this.warnings.add(`Invalid event ignored: ${path.basename(file)}`); changed = true; }
|
||||||
|
}
|
||||||
|
cursor.pending = content.subarray(from);
|
||||||
|
if (cursor.pending.length > 256 * 1024) { cursor.pending = Buffer.alloc(0); cursor.discard = true; this.warnings.add('An oversized log line was skipped.'); }
|
||||||
|
} catch (error) { this.warnings.add(`${path.basename(file)} : ${String(error)}`); changed = true; }
|
||||||
|
}
|
||||||
|
while (this.entries.size > 10000) { this.entries.delete(this.entries.keys().next().value!); }
|
||||||
|
if (changed) { this.publish(); }
|
||||||
|
} finally { this.busy = false; }
|
||||||
|
}
|
||||||
|
private publish() {
|
||||||
|
const entries = [...this.entries.values()].sort((a, b) => {
|
||||||
|
const time = a.time - b.time;
|
||||||
|
if (time) { return time; }
|
||||||
|
// Include raw app.log() lines written in the same second as initialization markers.
|
||||||
|
const priority = (entry: LogEntry) => /Initialization (?:started|completed|failed)/.test(entry.message)
|
||||||
|
? (entry.message.includes('started') ? -1 : 1) : 0;
|
||||||
|
return priority(a) - priority(b);
|
||||||
|
});
|
||||||
|
// Legacy errors duplicated across main/error logs are merged only on exact match.
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const unique = entries.filter(e => {
|
||||||
|
const key = e.trace_id ? e.event_id : `${e.timestamp}|${e.app}|${e.level}|${e.message}`;
|
||||||
|
if (seen.has(key)) { return false; } seen.add(key); return true;
|
||||||
|
});
|
||||||
|
// Direct app.log() calls have no LoggerInterface tag. Infer their initialization
|
||||||
|
// group while an explicit per-app initialize() scope is open.
|
||||||
|
const activeInitialization = new Map<string, string>();
|
||||||
|
for (const entry of unique) {
|
||||||
|
const marker = entry.message.match(/Initialization (started|completed|failed) \[group=initialization:([a-f0-9]{12})\]/i);
|
||||||
|
if (marker?.[1] === 'started') { activeInitialization.set(entry.app, marker[2]); }
|
||||||
|
const activeId = activeInitialization.get(entry.app);
|
||||||
|
if (activeId && !entry.group_id) {
|
||||||
|
entry.group_id = activeId;
|
||||||
|
entry.group_kind = 'initialization';
|
||||||
|
entry.initialization_id = activeId;
|
||||||
|
entry.phase = 'initialization';
|
||||||
|
entry.group_inferred = true;
|
||||||
|
}
|
||||||
|
if (entry.group_kind === 'initialization') {
|
||||||
|
entry.initialization_id = entry.initialization_id || entry.group_id;
|
||||||
|
entry.phase = 'initialization';
|
||||||
|
}
|
||||||
|
if (marker && (marker[1] === 'completed' || marker[1] === 'failed')) { activeInitialization.delete(entry.app); }
|
||||||
|
}
|
||||||
|
const zone = vscode.workspace.getConfiguration('appdaemon').get<string>('logTimezone', 'Europe/Berlin');
|
||||||
|
this.activeWebview?.postMessage({ type: 'entries', entries: unique, warnings: [...this.warnings], limit: 10000, timezone: zone });
|
||||||
|
}
|
||||||
|
dispose() { this.stopPolling(); this.messageListener?.dispose(); this.panel?.dispose(); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
export interface LogEntry {
|
||||||
|
event_id: string; timestamp: string; app: string; level: string; message: string;
|
||||||
|
kind: string; cause_ids: string[]; probable_cause_ids?: string[];
|
||||||
|
entity?: string; values?: Record<string, unknown>;
|
||||||
|
source?: { path: string; line: number };
|
||||||
|
initialization_id?: string; phase?: string; status?: string;
|
||||||
|
group_id?: string; group_kind?: string; group_inferred?: boolean;
|
||||||
|
file?: string; raw?: string; time: number; trace_id?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
const timeCache = new Map<string, number>();
|
||||||
|
const formatters = new Map<string, Intl.DateTimeFormat>();
|
||||||
|
export function rawTime(date: string, zone: string): number {
|
||||||
|
const cacheKey = zone + ':' + date;
|
||||||
|
const cached = timeCache.get(cacheKey);
|
||||||
|
if (cached !== undefined) { return cached; }
|
||||||
|
const m = date.match(/^(\d\d)-(\d\d)-(\d\d) (\d\d):(\d\d):(\d\d)/);
|
||||||
|
if (!m) { return NaN; }
|
||||||
|
const utc = Date.UTC(2000 + Number(m[3]), Number(m[2]) - 1, Number(m[1]), Number(m[4]), Number(m[5]), Number(m[6]));
|
||||||
|
try {
|
||||||
|
let formatter = formatters.get(zone);
|
||||||
|
if (!formatter) { formatter = new Intl.DateTimeFormat('en-GB', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' }); formatters.set(zone, formatter); }
|
||||||
|
const parts = formatter.formatToParts(utc);
|
||||||
|
const p = Object.fromEntries(parts.map(part => [part.type, part.value]));
|
||||||
|
const time = utc - (Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) - utc);
|
||||||
|
timeCache.set(cacheKey, time);
|
||||||
|
if (timeCache.size > 4096) { timeCache.delete(timeCache.keys().next().value!); }
|
||||||
|
return time;
|
||||||
|
} catch { return utc; }
|
||||||
|
}
|
||||||
|
export function parseLine(line: string, file: string, offset: number, zone = 'Europe/Berlin'): LogEntry | undefined {
|
||||||
|
if (line.startsWith('{')) {
|
||||||
|
try {
|
||||||
|
const e = JSON.parse(line);
|
||||||
|
if (e.version !== 1 || typeof e.event_id !== 'string' || typeof e.message !== 'string' || typeof e.app !== 'string' || typeof e.timestamp !== 'string' || !Array.isArray(e.cause_ids) || !e.cause_ids.every((id: unknown) => typeof id === 'string')) { return undefined; }
|
||||||
|
return { ...e, level: String(e.level || 'INFO'), kind: String(e.kind || 'log'), file, raw: line, time: Date.parse(e.timestamp) };
|
||||||
|
} catch { return undefined; }
|
||||||
|
}
|
||||||
|
const m = line.match(/^(\d{2}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?)\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+([^:]+):\s?(.*)$/);
|
||||||
|
if (!m) { return undefined; }
|
||||||
|
const init = m[4].match(/\[init=([\w-]+)\]/);
|
||||||
|
const group = m[4].match(/\[group=(initialization|smartcondition|value_selector):([a-f0-9]{12})\]/i);
|
||||||
|
const id = m[4].match(/\[event=([\w-]+) trace=([\w-]+)\]/);
|
||||||
|
const groupKind = group?.[1]?.toLowerCase();
|
||||||
|
const initializationId = groupKind === 'initialization' ? group?.[2] : init?.[1];
|
||||||
|
return { event_id: id?.[1] || `${file}:${offset}`, trace_id: id?.[2], initialization_id: initializationId, phase: initializationId ? 'initialization' : undefined, group_id: group?.[2], group_kind: groupKind, timestamp: m[1], time: rawTime(m[1], zone), app: m[3].trim(), level: m[2], message: m[4], kind: 'log', cause_ids: [], file, raw: line };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preserve valid UTF-8 spans, including files containing mixed legacy bytes. */
|
||||||
|
export function decodeLogLine(bytes: Buffer, encoding = 'auto'): string {
|
||||||
|
if (encoding === 'utf8') { return bytes.toString('utf8'); }
|
||||||
|
const legacy = new TextDecoder('windows-1252');
|
||||||
|
if (encoding === 'windows-1252') { return legacy.decode(bytes); }
|
||||||
|
try { return new TextDecoder('utf-8', { fatal: true }).decode(bytes); } catch { /* Mixed legacy log. */ }
|
||||||
|
const output: string[] = [];
|
||||||
|
let start = 0;
|
||||||
|
for (let i = 0; i < bytes.length;) {
|
||||||
|
const first = bytes[i];
|
||||||
|
const width = first < 0x80 ? 1 : first >= 0xc2 && first <= 0xdf ? 2 : first >= 0xe0 && first <= 0xef ? 3 : first >= 0xf0 && first <= 0xf4 ? 4 : 0;
|
||||||
|
let valid = width > 0 && i + width <= bytes.length;
|
||||||
|
for (let j = 1; valid && j < width; j++) { valid = bytes[i + j] >= 0x80 && bytes[i + j] <= 0xbf; }
|
||||||
|
if (valid && width > 1) {
|
||||||
|
const second = bytes[i + 1];
|
||||||
|
valid = !(first === 0xe0 && second < 0xa0 || first === 0xed && second > 0x9f || first === 0xf0 && second < 0x90 || first === 0xf4 && second > 0x8f);
|
||||||
|
}
|
||||||
|
if (valid) { i += width; } else {
|
||||||
|
if (i > start) { output.push(bytes.subarray(start, i).toString('utf8')); }
|
||||||
|
output.push(legacy.decode(bytes.subarray(i, i + 1)));
|
||||||
|
start = ++i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (start < bytes.length) { output.push(bytes.subarray(start).toString('utf8')); }
|
||||||
|
return output.join('');
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ export class StatusBarManager {
|
|||||||
private productionModeItem: vscode.StatusBarItem;
|
private productionModeItem: vscode.StatusBarItem;
|
||||||
private errorItem: vscode.StatusBarItem;
|
private errorItem: vscode.StatusBarItem;
|
||||||
private contextualItem: vscode.StatusBarItem;
|
private contextualItem: vscode.StatusBarItem;
|
||||||
|
private logsItem: vscode.StatusBarItem;
|
||||||
|
|
||||||
constructor(private adClient: ADClient) {
|
constructor(private adClient: ADClient) {
|
||||||
// Reload AppDaemon apps
|
// Reload AppDaemon apps
|
||||||
@@ -36,6 +37,13 @@ export class StatusBarManager {
|
|||||||
this.errorItem.tooltip = 'Show AppDaemon Error Log';
|
this.errorItem.tooltip = 'Show AppDaemon Error Log';
|
||||||
this.errorItem.show();
|
this.errorItem.show();
|
||||||
|
|
||||||
|
// Log explorer shortcut, available even when no editor is open.
|
||||||
|
this.logsItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 96);
|
||||||
|
this.logsItem.command = 'appdaemon.exploreLogs';
|
||||||
|
this.logsItem.text = '$(list-tree) AD Logs';
|
||||||
|
this.logsItem.tooltip = 'Open AppDaemon Log Explorer';
|
||||||
|
this.logsItem.show();
|
||||||
|
|
||||||
// Contextual per-file restart button (hidden by default)
|
// Contextual per-file restart button (hidden by default)
|
||||||
this.contextualItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 101);
|
this.contextualItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 101);
|
||||||
this.contextualItem.command = 'appdaemon.restartCurrentFileApps';
|
this.contextualItem.command = 'appdaemon.restartCurrentFileApps';
|
||||||
@@ -93,6 +101,7 @@ export class StatusBarManager {
|
|||||||
this.restartHAItem.dispose();
|
this.restartHAItem.dispose();
|
||||||
this.productionModeItem.dispose();
|
this.productionModeItem.dispose();
|
||||||
this.errorItem.dispose();
|
this.errorItem.dispose();
|
||||||
|
this.logsItem.dispose();
|
||||||
this.contextualItem.dispose();
|
this.contextualItem.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const Module = require('module');
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ad-log-test-'));
|
||||||
|
const originalLoad = Module._load;
|
||||||
|
let messages = [];
|
||||||
|
const vscode = {workspace:{workspaceFolders:[{uri:{fsPath:root}}], getConfiguration:()=>({get:(key, fallback)=>fallback})}};
|
||||||
|
Module._load = function(id, ...args) { return id === 'vscode' ? vscode : originalLoad.call(this, id, ...args); };
|
||||||
|
const {LogExplorer} = require('../out/logExplorer');
|
||||||
|
Module._load = originalLoad;
|
||||||
|
function explorer() { const e = new LogExplorer({}); const webview={postMessage:m=>messages.push(m)}; e.panel={webview}; e.activeWebview=webview; return e; }
|
||||||
|
function latest() { return messages.at(-1)?.entries || []; }
|
||||||
|
fs.mkdirSync(path.join(root,'logs'));
|
||||||
|
const file=path.join(root,'logs/appdaemon.log');
|
||||||
|
test('follows incomplete UTF-8 lines and multiline exceptions',()=>{
|
||||||
|
const e=explorer(); messages=[];
|
||||||
|
const first='27-09-26 10:12:13 ERROR app: Échec';
|
||||||
|
const bytes=Buffer.from(first+'\n'); const split=bytes.indexOf(Buffer.from('É'))+1;
|
||||||
|
fs.writeFileSync(file,bytes.subarray(0,split));e.poll();assert.equal(latest().length,0);
|
||||||
|
fs.appendFileSync(file,Buffer.concat([bytes.subarray(split),Buffer.from(' File "app.py", line 42\nRuntimeError: failed\n')]));e.poll();
|
||||||
|
assert.equal(latest().length,1);assert.match(latest()[0].message,/Échec\n File/);assert.match(latest()[0].message,/RuntimeError/);
|
||||||
|
});
|
||||||
|
test('detects same-sized inode replacement and truncation',()=>{
|
||||||
|
const e=explorer();messages=[];
|
||||||
|
fs.writeFileSync(file,'27-09-26 10:12:13 INFO app: before\n');e.poll();
|
||||||
|
fs.renameSync(file,file+'.old');fs.writeFileSync(file,'27-09-26 10:12:14 INFO app: after!\n');e.poll();
|
||||||
|
assert.ok(latest().some(x=>x.message==='after!'));
|
||||||
|
fs.writeFileSync(file,'27-09-26 10:12:15 INFO a: x\n');e.poll();assert.ok(latest().some(x=>x.message==='x'));
|
||||||
|
});
|
||||||
|
test('reads base text logs and ignores structured events by default',()=>{
|
||||||
|
const e=explorer();messages=[];
|
||||||
|
fs.writeFileSync(file,'27-09-26 10:12:13 ERROR app: failed\n27-09-26 10:12:14 INFO AppDaemon: AppDaemon is ready\n');
|
||||||
|
fs.writeFileSync(path.join(root,'logs/error.log'),'27-09-26 10:12:13 ERROR app: failed\n');
|
||||||
|
fs.writeFileSync(path.join(root,'logs/events.jsonl'),JSON.stringify({version:1,event_id:'trace-only',timestamp:'2026-09-27T08:12:14Z',level:'INFO',kind:'evaluation',message:'not a raw log',app:'app',cause_ids:[]})+'\n');e.poll();
|
||||||
|
assert.equal(latest().filter(e=>e.message==='failed').length,1);
|
||||||
|
assert.ok(latest().some(e=>e.app==='AppDaemon' && e.message==='AppDaemon is ready'));
|
||||||
|
assert.equal(latest().some(e=>e.event_id==='trace-only'),false);
|
||||||
|
});
|
||||||
|
test('bounds retained history',()=>{
|
||||||
|
const e=explorer();messages=[];
|
||||||
|
fs.writeFileSync(file,Array.from({length:10500},(_,i)=>`27-09-26 10:12:13 INFO app: message ${i}\n`).join(''));
|
||||||
|
e.poll();e.poll();assert.ok(e.entries.size<=10000);
|
||||||
|
});
|
||||||
|
process.on('exit',()=>fs.rmSync(root,{recursive:true,force:true}));
|
||||||
|
|
||||||
|
test('groups plain app.log lines between explicit initialization markers',()=>{
|
||||||
|
const e=explorer();messages=[];
|
||||||
|
fs.writeFileSync(file,[
|
||||||
|
'27-09-26 10:12:13 INFO kitchen: Initialization started [group=initialization:0123456789ab]',
|
||||||
|
'27-09-26 10:12:13 INFO kitchen: Setting up an output through app.log()',
|
||||||
|
'27-09-26 10:12:14 INFO kitchen: Configured helper [group=initialization:0123456789ab]',
|
||||||
|
'27-09-26 10:12:14 INFO kitchen: Initialization completed [group=initialization:0123456789ab]'
|
||||||
|
].join('\n')+'\n');e.poll();
|
||||||
|
const grouped=latest().filter(entry=>entry.app==='kitchen');
|
||||||
|
assert.equal(grouped.length,4);
|
||||||
|
assert.deepEqual(grouped.map(entry=>entry.initialization_id),Array(4).fill('0123456789ab'));
|
||||||
|
assert.equal(grouped[1].group_inferred,true);
|
||||||
|
assert.equal(grouped[2].group_inferred,undefined);
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { parseLine, rawTime } = require('../out/logModel');
|
||||||
|
test('raw log levels, app names and readable IDs', () => {
|
||||||
|
const e = parseLine('27-09-26 10:12:13 WARNING my_app: Alerte : détail [event=e1 trace=t1]', '/logs/main.log', 123);
|
||||||
|
assert.equal(e.event_id, 'e1'); assert.equal(e.app, 'my_app'); assert.equal(e.level, 'WARNING'); assert.equal(e.trace_id, 't1');
|
||||||
|
});
|
||||||
|
test('timestamps use the AppDaemon timezone instead of the workstation timezone', () => {
|
||||||
|
assert.equal(rawTime('27-09-26 10:12:13', 'Europe/Berlin'), Date.parse('2026-09-27T08:12:13Z'));
|
||||||
|
assert.equal(rawTime('27-01-26 10:12:13', 'Europe/Berlin'), Date.parse('2026-01-27T09:12:13Z'));
|
||||||
|
});
|
||||||
|
test('structured events retain causal links and evaluation values', () => {
|
||||||
|
const entry = {version:1,event_id:'e2',cause_ids:['e1'],probable_cause_ids:['e1'],message:'Condition',app:'app',kind:'evaluation',timestamp:'2026-09-27T08:12:13Z',values:{'sensor.a':true}};
|
||||||
|
assert.deepEqual(parseLine(JSON.stringify(entry), '/logs/events.jsonl', 0).values, entry.values);
|
||||||
|
});
|
||||||
|
test('invalid structured records and continuations are rejected', () => {
|
||||||
|
for (const value of ['{broken}', JSON.stringify({version:1,event_id:'e',cause_ids:[null],message:'bad',app:'app',timestamp:'now'}), ' File "app.py", line 3']) assert.equal(parseLine(value,'/logs/log',0),undefined);
|
||||||
|
});
|
||||||
|
test('decodes UTF-8, Windows-1252 and mixed historical log bytes', () => {
|
||||||
|
const { decodeLogLine } = require('../out/logModel');
|
||||||
|
assert.equal(decodeLogLine(Buffer.from('Échec – déjà 🔥')), 'Échec – déjà 🔥');
|
||||||
|
assert.equal(decodeLogLine(Buffer.from([0xc9,0x63,0x68,0x65,0x63,0x20,0x96,0x20,0xe9])), 'Échec – é');
|
||||||
|
assert.equal(decodeLogLine(Buffer.concat([Buffer.from('déjà → '),Buffer.from([0xe9])])), 'déjà → é');
|
||||||
|
assert.equal(decodeLogLine(Buffer.from([0xe9]), 'utf8'), '\ufffd');
|
||||||
|
});
|
||||||
|
test('raw initialization IDs are retained without structured events', () => {
|
||||||
|
const e=parseLine('27-09-26 10:12:13 INFO app: Setting up [event=e1 trace=t1] [init=init1]','log',0);
|
||||||
|
assert.equal(e.initialization_id,'init1'); assert.equal(e.phase,'initialization');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses readable group tags without changing the log message format', () => {
|
||||||
|
const e=parseLine('27-09-26 10:12:13 INFO app: Selected value [group=value_selector:0123456789ab]','log',0);
|
||||||
|
assert.equal(e.group_id,'0123456789ab'); assert.equal(e.group_kind,'value_selector');
|
||||||
|
assert.equal(e.initialization_id,undefined); assert.match(e.message,/\[group=value_selector:/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
const {test}=require('node:test');
|
||||||
|
const assert=require('node:assert/strict');
|
||||||
|
const fs=require('fs');
|
||||||
|
const path=require('path');
|
||||||
|
const {JSDOM,VirtualConsole}=require('jsdom');
|
||||||
|
const media=path.join(__dirname,'../media');
|
||||||
|
function setup() {
|
||||||
|
const errors=[], sent=[];
|
||||||
|
const virtualConsole=new VirtualConsole();virtualConsole.on('jsdomError',e=>errors.push(e));
|
||||||
|
const html=fs.readFileSync(path.join(media,'logExplorer.html'),'utf8').replace(/<meta http-equiv="Content-Security-Policy"[^>]+>/,'').replace(/<script[^>]+src="\{\{JS\}\}"[^>]*><\/script>/,'');
|
||||||
|
const dom=new JSDOM(html,{runScripts:'outside-only',virtualConsole});
|
||||||
|
dom.window.acquireVsCodeApi=()=>({postMessage:message=>sent.push(message),getState:()=>({}),setState() {}});
|
||||||
|
dom.window.eval(fs.readFileSync(path.join(media,'logExplorer.js'),'utf8'));
|
||||||
|
const entry=(id,app,second,message,extra={})=>{const timestamp=`2026-09-27T08:00:${String(second).padStart(2,'0')}Z`;return {event_id:id,app,time:Date.parse(timestamp),timestamp,message,kind:'log',cause_ids:[],level:'INFO',...extra};};
|
||||||
|
const entries=[entry('init1','alpha',1,'Preparing sensors',{initialization_id:'init-one'}),entry('init2','alpha',2,'Ready',{initialization_id:'init-one'}),entry('init3','alpha',3,'Restarting',{initialization_id:'init-two'}),entry('a','alpha',10,'Température déjà disponible'),entry('b','beta',20,'Connection ready')];
|
||||||
|
const send=items=>dom.window.dispatchEvent(new dom.window.MessageEvent('message',{data:{type:'entries',entries:items,warnings:[],limit:10000}}));
|
||||||
|
send(entries);
|
||||||
|
return {dom,d:dom.window.document,entries,sent,errors,send};
|
||||||
|
}
|
||||||
|
test('English interface, latest timestamps, sorting and separate initialization groups',()=>{
|
||||||
|
const {dom,d,errors}=setup();
|
||||||
|
assert.equal(d.documentElement.lang,'en');
|
||||||
|
assert.equal(d.getElementById('copy').textContent,'Copy selected log');
|
||||||
|
assert.deepEqual([...d.querySelectorAll('[data-app]')].map(e=>e.dataset.app),['beta','alpha']);
|
||||||
|
assert.match(d.querySelector('[data-app="alpha"] .last-age').title,/10:00:10/);
|
||||||
|
d.getElementById('sort').value='oldest';d.getElementById('sort').dispatchEvent(new dom.window.Event('input'));
|
||||||
|
assert.equal(d.querySelector('[data-app]').dataset.app,'alpha');
|
||||||
|
const alpha=d.querySelector('[data-app="alpha"]');alpha.open=true;alpha.dispatchEvent(new dom.window.Event('toggle'));
|
||||||
|
assert.equal(alpha.querySelectorAll('.rows>.row').length,3);
|
||||||
|
assert.equal(alpha.querySelectorAll('.event-log-group').length,1);
|
||||||
|
assert.equal(alpha.querySelector('.event-log-group .row-message').textContent,'Preparing sensors\nReady');
|
||||||
|
assert.equal(alpha.querySelectorAll('.rows details').length,0);
|
||||||
|
assert.equal(d.getElementById('group-init'),null);
|
||||||
|
assert.deepEqual(errors,[]);dom.window.close();
|
||||||
|
});
|
||||||
|
test('copies selected logs and entire initialization groups through VS Code transport',()=>{
|
||||||
|
const {dom,d,sent,errors}=setup();const alpha=d.querySelector('[data-app="alpha"]');alpha.open=true;alpha.dispatchEvent(new dom.window.Event('toggle'));
|
||||||
|
[...alpha.querySelectorAll('.row')].find(e=>e.textContent.includes('Température déjà disponible')).click();d.getElementById('copy').click();
|
||||||
|
assert.equal(sent.at(-1).type,'copy');assert.match(sent.at(-1).text,/Température déjà disponible/);
|
||||||
|
const group=alpha.querySelector('.event-log-group');
|
||||||
|
group.click();d.getElementById('copy').click();
|
||||||
|
assert.match(d.getElementById('detail').textContent,/Preparing sensors\s+Ready/);
|
||||||
|
assert.match(sent.at(-1).text,/Preparing sensors/);assert.match(sent.at(-1).text,/Ready/);assert.doesNotMatch(sent.at(-1).text,/Restarting/);
|
||||||
|
const input=d.getElementById('search'),paste=new dom.window.KeyboardEvent('keydown',{key:'v',ctrlKey:true,bubbles:true,cancelable:true});input.dispatchEvent(paste);assert.equal(paste.defaultPrevented,false);
|
||||||
|
assert.deepEqual(errors,[]);dom.window.close();
|
||||||
|
});
|
||||||
|
test('retains selected text during live updates and copies it with Ctrl+C',async()=>{
|
||||||
|
const {dom,d,entries,sent,errors,send}=setup();const alpha=d.querySelector('[data-app="alpha"]');alpha.open=true;alpha.dispatchEvent(new dom.window.Event('toggle'));
|
||||||
|
const text=alpha.querySelector('.row-message'),range=d.createRange();range.selectNodeContents(text);dom.window.getSelection().addRange(range);
|
||||||
|
const original=text.textContent;
|
||||||
|
send([...entries,{...entries[3],event_id:'new',time:30,message:'New log'}]);
|
||||||
|
assert.equal(dom.window.getSelection().toString(),original);assert.match(d.getElementById('status').textContent,/buffered/);
|
||||||
|
text.dispatchEvent(new dom.window.KeyboardEvent('keydown',{key:'c',ctrlKey:true,bubbles:true,cancelable:true}));
|
||||||
|
assert.equal(sent.at(-1).text,original);
|
||||||
|
dom.window.getSelection().removeAllRanges();d.dispatchEvent(new dom.window.Event('selectionchange'));await new Promise(resolve=>setTimeout(resolve,15));
|
||||||
|
assert.match(d.querySelector('[data-app="alpha"]').textContent,/New log/);
|
||||||
|
assert.deepEqual(errors,[]);dom.window.close();
|
||||||
|
});
|
||||||
|
test('log content is never interpreted as HTML',()=>{
|
||||||
|
const {dom,d,entries,send,errors}=setup();send([{...entries[0],app:'<script>bad</script>',initialization_id:undefined,message:'<img src=x onerror=alert(1)>'}]);
|
||||||
|
const app=d.querySelector('[data-app]');app.open=true;app.dispatchEvent(new dom.window.Event('toggle'));
|
||||||
|
assert.equal(d.querySelectorAll('#apps img,#apps script').length,0);assert.match(app.textContent,/<img/);assert.deepEqual(errors,[]);dom.window.close();
|
||||||
|
});
|
||||||
|
test('shows same-event log lines together with one causes link and keeps newer events below',()=>{
|
||||||
|
const {dom,d,entries,send,sent}=setup();
|
||||||
|
const trigger={...entries[3],event_id:'motion-callback',time:entries[3].time+1000,message:'Callback trigger by sensor.kitchen_last_motion(7 => 0)',trace_id:'motion-event'};
|
||||||
|
const evaluation={...entries[3],event_id:'motion-evaluation',time:entries[3].time+1000,message:'[kitchen_occupancy][Evaluating] Triggered by (sensor.kitchen_last_motion[0] <= 5)[True]',trace_id:'motion-event',cause_ids:['state-event']};
|
||||||
|
const newer={...entries[3],event_id:'later-log',time:entries[3].time+2000,message:'Later event'};
|
||||||
|
send([trigger,evaluation,newer].sort((a,b)=>a.time-b.time));
|
||||||
|
const alpha=d.querySelector('[data-app="alpha"]');alpha.open=true;alpha.dispatchEvent(new dom.window.Event('toggle'));
|
||||||
|
const rows=alpha.querySelector('.rows'),combined=rows.children[0];
|
||||||
|
assert.ok(combined.classList.contains('event-log-group'));
|
||||||
|
assert.match(combined.querySelector('.row-message').textContent,/Callback trigger by sensor.kitchen_last_motion/);
|
||||||
|
assert.match(combined.querySelector('.row-message').textContent,/Triggered by \(sensor.kitchen_last_motion/);
|
||||||
|
assert.equal(combined.querySelectorAll('.link-hint').length,1);
|
||||||
|
assert.equal(rows.children[1].querySelector('.row-message').textContent,'Later event');
|
||||||
|
combined.click();d.getElementById('copy').click();
|
||||||
|
assert.match(sent.at(-1).text,/Callback trigger by sensor.kitchen_last_motion/);
|
||||||
|
assert.match(sent.at(-1).text,/Triggered by \(sensor.kitchen_last_motion/);
|
||||||
|
assert.match(d.getElementById('detail').textContent,/Callback trigger by sensor.kitchen_last_motion/);
|
||||||
|
dom.window.close();
|
||||||
|
});
|
||||||
|
test('does not show a mirrored evaluation twice in the cause graph',()=>{
|
||||||
|
const {dom,d,entries,send}=setup();
|
||||||
|
const time=entries[3].time+1000,trace='same-run';
|
||||||
|
const state={...entries[3],event_id:'state-event',kind:'state_changed',message:'sensor.ext_back_humidity.state: 66 → 65',time,trace_id:trace,cause_ids:[]};
|
||||||
|
const message='[living_room_cant_open_window][Evaluating] No valid conditions';
|
||||||
|
const evaluation={...entries[3],event_id:'technical-evaluation',kind:'evaluation',message,time,trace_id:trace,cause_ids:['state-event'],values:{'sensor.ext_back_humidity':'65'}};
|
||||||
|
const callback={...entries[3],event_id:'callback-log',message:'[living_room_cant_open_window] Callback trigger by sensor.ext_back_humidity(66 => 65)',time,trace_id:trace,cause_ids:['state-event']};
|
||||||
|
const mirroredLog={...entries[3],event_id:'evaluation-log',message,time:time+1,trace_id:trace,cause_ids:['technical-evaluation']};
|
||||||
|
send([state,evaluation,callback,mirroredLog].sort((a,b)=>a.time-b.time));
|
||||||
|
const app=d.querySelector('[data-app="alpha"]');app.open=true;app.dispatchEvent(new dom.window.Event('toggle'));
|
||||||
|
const combined=app.querySelector('.event-log-group');
|
||||||
|
assert.ok(combined);
|
||||||
|
assert.equal(combined.querySelectorAll('.link-hint').length,1);
|
||||||
|
combined.click();
|
||||||
|
const matchingNodes=[...d.querySelectorAll('#detail .node .text')].filter(item=>item.textContent.includes('No valid conditions'));
|
||||||
|
assert.equal(matchingNodes.length,1);
|
||||||
|
assert.equal(d.querySelectorAll('#detail .values tr').length,1);
|
||||||
|
dom.window.close();
|
||||||
|
});
|
||||||
|
test('pulses an app when a new log arrives and refreshes its last-log age',async()=>{
|
||||||
|
const {dom,d,entries,send}=setup();
|
||||||
|
const beta=entries.find(e=>e.app==='beta'),nextTime=beta.time+2000;
|
||||||
|
dom.window.Date.now=()=>beta.time+3000;
|
||||||
|
const newer={...beta,event_id:'beta-new',time:nextTime,timestamp:new Date(nextTime).toISOString(),message:'Fresh log'};
|
||||||
|
send([...entries,newer]);
|
||||||
|
const app=d.querySelector('[data-app="beta"]');
|
||||||
|
assert.ok(app.classList.contains('new-log-app'));
|
||||||
|
app.open=true;app.dispatchEvent(new dom.window.Event('toggle'));
|
||||||
|
assert.ok(app.querySelector('.recent-log'));
|
||||||
|
assert.equal(app.querySelector('.last-age').textContent,'Last log: 1 second ago');
|
||||||
|
dom.window.Date.now=()=>nextTime+65000;
|
||||||
|
await new Promise(resolve=>setTimeout(resolve,1100));
|
||||||
|
assert.equal(app.querySelector('.last-age').textContent,'Last log: 1 minute ago');
|
||||||
|
dom.window.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('distinguishes log lines from supplemental records when configured',()=>{
|
||||||
|
const {dom,d,entries,send}=setup();
|
||||||
|
const trace={...entries[3],event_id:'trace-evaluation',kind:'evaluation',message:'Condition was evaluated'};
|
||||||
|
send([...entries,trace]);
|
||||||
|
assert.match(d.getElementById('status').textContent,/5 log lines \+ 1 trace event/);
|
||||||
|
assert.match(d.getElementById('status').title,/configured history files/);
|
||||||
|
dom.window.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows and refreshes relative time on each log entry while retaining exact time on hover',async()=>{
|
||||||
|
const {dom,d,entries}=setup();
|
||||||
|
const entry=entries.find(e=>e.event_id==='a');
|
||||||
|
dom.window.Date.now=()=>entry.time+120000;
|
||||||
|
const app=d.querySelector('[data-app="alpha"]');
|
||||||
|
app.open=true;
|
||||||
|
app.dispatchEvent(new dom.window.Event('toggle'));
|
||||||
|
const row=[...d.querySelectorAll('[data-app="alpha"] .row')].find(item=>item.textContent.includes('Température déjà disponible'));
|
||||||
|
const relativeTime=()=>[...app.querySelectorAll('.row')].find(item=>item.textContent.includes('Température déjà disponible')).querySelector('.relative-time');
|
||||||
|
assert.equal(relativeTime().textContent,'2 minutes ago');
|
||||||
|
assert.match(relativeTime().title,/10:00:10/);
|
||||||
|
dom.window.Date.now=()=>entry.time+3600000;
|
||||||
|
await new Promise(resolve=>setTimeout(resolve,1600));
|
||||||
|
assert.equal(relativeTime().textContent,'1 hour ago');
|
||||||
|
dom.window.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('shows consecutive logs from one explicit group as a single visible row',()=>{
|
||||||
|
const {dom,d,entries,send}=setup();
|
||||||
|
const group=[
|
||||||
|
{...entries[3],event_id:'selector-1',time:entries[3].time+1000,message:'Rejected candidate',group_id:'0123456789ab',group_kind:'value_selector'},
|
||||||
|
{...entries[3],event_id:'selector-2',time:entries[3].time+1000,message:'Selected candidate',group_id:'0123456789ab',group_kind:'value_selector'}
|
||||||
|
];
|
||||||
|
send(group);
|
||||||
|
const app=d.querySelector('[data-app="alpha"]');app.open=true;app.dispatchEvent(new dom.window.Event('toggle'));
|
||||||
|
const rows=[...app.querySelectorAll('.rows>.row')].filter(row=>row.textContent.includes('Rejected candidate'));
|
||||||
|
assert.equal(rows.length,1);assert.ok(rows[0].classList.contains('event-log-group'));
|
||||||
|
assert.match(rows[0].querySelector('.row-message').textContent,/Rejected candidate\nSelected candidate/);
|
||||||
|
assert.doesNotMatch(rows[0].textContent,/\[group=/);
|
||||||
|
dom.window.close();
|
||||||
|
});
|
||||||
Binary file not shown.
Reference in new issue
Block a user