Compare commits

...
14 Commits
Author SHA1 Message Date
Pierre 5ae930c710 add support for ascii encode 2026-10-10 15:25:00 +02:00
Pierre cf8839db6a remove the warning for default entity creation 2026-10-04 23:42:54 +02:00
Pierre 1d14219b93 fix most of the entity creation warning 2026-10-04 23:10:22 +02:00
Pierre d8756928a5 fixed create_entity warnings 2026-10-04 22:08:43 +02:00
Pierre caf9051864 added a warning button 2026-10-04 20:01:17 +02:00
Pierre 3171433770 some logs improvement 2026-10-04 19:00:06 +02:00
Pierre 0fa40dc2ff Did some logs improvement 2026-10-04 15:29:01 +02:00
Pierre 6838cc54d0 better error display 2026-10-04 14:45:44 +02:00
Pierre 3aa0120e3d Simplify the log grouping logic 2026-10-04 14:29:14 +02:00
Pierre f57e23d76a added a shortcut to open the loging app 2026-10-04 14:06:31 +02:00
Pierre 5614ad049d clean up deprecated buttons 2026-10-04 13:43:50 +02:00
Pierre 99422d8cfc added a scrollbox for each app logs 2026-10-04 13:12:14 +02:00
Pierre 6c345ccf81 added last log preview 2026-10-04 12:54:31 +02:00
Pierre cfb5334666 Improved error feedbacks 2026-10-04 12:39:38 +02:00
23 changed files with 563 additions and 274 deletions

No files matched your search

+2
View File
@@ -43,6 +43,7 @@ class ServiceCaller(ConditionalAction):
class EventsForwarder(hass.Hass, LoggerInterface):
def initialize(self):
with self.log_group("App Initialization"):
self.initialize_logger_interface(self.get_ad_api())
self.mute_logger(self.args.get('mute', False))
self.forwarders = {}
@@ -60,6 +61,7 @@ class EventsForwarder(hass.Hass, LoggerInterface):
self, block['events_to_listen'], self.on_event, key))
except ParsingException as error:
self.log_error(f"Invalid forwarder {key}: {error}")
self.log_info("Initialisation completed")
def on_event(self, event_name, event_data, forwarder_key):
for sender in self.forwarders.get(forwarder_key, []):
+13 -8
View File
@@ -27,14 +27,19 @@ class LoggerInterface:
def __init__(self):
self.initialized = False
def __init__(self, ad_api, default_log='main_log', mute=False):
self.initialize_logger_interface(ad_api, default_log, mute)
def __init__(self, ad_api, default_log='main_log', mute=False, *, ascii_encode=None):
self.initialize_logger_interface(ad_api, default_log, mute, ascii_encode=ascii_encode)
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, *, ascii_encode=None):
self.ad_api = ad_api
self.default_log = default_log
self.mute = False
self.initialized = True
self._log_options = {} if ascii_encode is None else {'ascii_encode': ascii_encode}
def _write_log(self, message, level, log):
self.ad_api.log(self._tag_log_message(message), level=level, log=log,
**self._log_options)
def log_group(self, kind, label=None):
return log_group(kind, label)
@@ -51,17 +56,17 @@ class LoggerInterface:
def mute_logger(self, mute: bool):
if mute:
self.ad_api.log(self._tag_log_message('Muting logger'), level='INFO', log=self.default_log)
self._write_log('Muting logger', 'INFO', self.default_log)
self.mute = bool(mute)
def log_info(self, message):
assert self.initialized
if not self.mute:
self.ad_api.log(self._tag_log_message(message), level='INFO', log=self.default_log)
self._write_log(message, 'INFO', self.default_log)
def log_warning(self, message):
assert self.initialized
self.ad_api.log(self._tag_log_message(message), level='WARNING', log=self.default_log)
self._write_log(message, 'WARNING', self.default_log)
def log_error(self, message, stop_app=False, dump_stack=False):
assert self.initialized
@@ -74,8 +79,8 @@ class LoggerInterface:
stack = ''
message = self._tag_log_message(message)
self.ad_api.log(message, level='ERROR', log=self.default_log)
self.ad_api.log(message + stack, level='ERROR', log='error_log')
self._write_log(message, 'ERROR', self.default_log)
self._write_log(message + stack, 'ERROR', 'error_log')
if stop_app:
self.stop_app(self.name)
+11 -5
View File
@@ -57,7 +57,13 @@ 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
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')
log_group = parser.parse_args('log_group',None)
if log_group is not None:
self.__log_group = str(log_group)
elif isinstance(condition_name, list):
self.__log_group = ', '.join(map(str, condition_name))
else:
self.__log_group = str(condition_name or 'condition')
if condition_name:
if isinstance(condition_name,list):
for name in condition_name:
@@ -163,17 +169,17 @@ class Evaluator():
raise e
def __log_group(self):
def __log_group_scope(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 group_scope(self.__log_group)
return nullcontext()
def evaluate(self,log = True):
with self.__log_group():
with self.__log_group_scope():
try: result = self.__evaluate()
except EvaluationException as e:
self.__log(f"{e}",level = "ERROR")
@@ -503,7 +509,7 @@ class Evaluator():
self.__last_evaluation_result = new_result
def __trigger_callbacks(self,trigger_reason,triggered_from_activation = False):
with self.__log_group():
with self.__log_group_scope():
return self.__trigger_callbacks_in_group(trigger_reason, triggered_from_activation)
def __trigger_callbacks_in_group(self,trigger_reason,triggered_from_activation = False):
+6 -6
View File
@@ -114,10 +114,10 @@ class SmartObject(hass.Hass,LoggerInterface):
if state_class is not None: attributes['state_class'] = state_class
if state is not None:
entity.set_state(state=state, attributes=attributes)
entity.set_state(state=state, attributes=attributes, check_existence=False)
else:
if not entity.exists():
entity.set_state(state='unknown')
entity.set_state(state='unknown', check_existence=False)
entity.set_state(attributes=attributes)
return entity
@@ -184,16 +184,16 @@ class SmartObject(hass.Hass,LoggerInterface):
# → attributes_override → dataset restore → on_initialize_smart_object()
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']}]")
with self.log_group('App Initialization') as group:
self.log(f"Initialization started [group={group['kind']}:{group['id']}]")
try:
succeeded = self._initialize_smart_object()
except BaseException:
self.log(f"Initialization failed [group=initialization:{group['id']}]", level='ERROR')
self.log(f"Initialization failed [group={group['kind']}:{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)
self.log(f"Initialization {status} [group={group['kind']}:{group['id']}]", level=level)
def _initialize_smart_object(self):
try:
+17
View File
@@ -1,4 +1,5 @@
#import appdaemon.plugins.hass.hassapi as hass
from contextlib import nullcontext
from datetime import datetime
from adexpressioncontext import ADExpressionContext
from expressionparser import ExpressionParser,ParsingException,EvaluationException
@@ -31,6 +32,8 @@ 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
expression_name = parser.parse_args('expression_name',None)
log_group = parser.parse_args('log_group',None)
self.__log_group = str(log_group if log_group is not None else expression_name or 'value')
if expression_name:
self.__debug_log_prefixes.append(f"[{expression_name}]")
@@ -96,7 +99,17 @@ class Evaluator():
self.__debug_log_prefixes.pop()
return result
def __log_group_scope(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(self.__log_group)
return nullcontext()
def evaluate(self,log = True):
with self.__log_group_scope():
try: result = self.__evaluate()
except EvaluationException as e:
self.__log(f"{e}",level = "ERROR")
@@ -220,6 +233,10 @@ class Evaluator():
self.__state_callbacks_handle = list()
def __trigger_callbacks(self,trigger_reason,triggered_from_activation = False):
with self.__log_group_scope():
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_evaluation_result = False
+15 -2
View File
@@ -78,8 +78,8 @@ class LoggerGroupingTests(unittest.TestCase):
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():
evaluator._Evaluator__log_group = 'smart_conditions'
with evaluator._Evaluator__log_group_scope():
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(']')
@@ -87,6 +87,19 @@ class LoggerGroupingTests(unittest.TestCase):
self.assertEqual(marker_a, marker_b)
self.assertTrue(marker_a.startswith('smartcondition:'))
def test_log_group_takes_priority_over_condition_name(self):
app = FakeApp()
evaluator = smartcondition.Evaluator(app, {}, activated=False,
condition_name='condition-a', log_group='group-a')
self.assertEqual(evaluator._Evaluator__log_group, 'group-a')
self.assertEqual(evaluator._Evaluator__log_to_string('message'), '[condition-a] message')
with evaluator._Evaluator__log_group_scope() as group:
self.assertEqual(group['label'], 'group-a')
fallback = smartcondition.Evaluator(app, {}, activated=False,
condition_name=['condition-a', 'condition-b'])
self.assertEqual(fallback._Evaluator__log_group, 'condition-a, condition-b')
def test_nested_scopes_inherit_outer_group_and_reset_after_exception(self):
app = FakeApp()
with app.log_group('value_selector', 'sensor.a') as outer:
+45 -22
View File
@@ -46,6 +46,11 @@ class VirtualSensorBase:
if super_entity_id != None:
self.constants['super'] = super_entity_id
self.entity_existed = self.ad_api.entity_exists(self.sensor_name)
if not self.entity_existed:
self.ad_api.set_state(self.sensor_name, state='unknown',attributes=self.attributes, check_existence=False)
self.entity = self.ad_api.get_entity(self.sensor_name)
if self_initialize:
self._initialize()
@@ -56,13 +61,19 @@ class VirtualSensorBase:
# assert len(splitted_name) == 2,f"Invalid virtual sensor name : {virtual_sensor_name}"
# return f"sensor.{splitted_name[1]}"
def log(self,message): self.logger_interface.log_info(f"[{self.sensor_name}]{message}")
# def log(self,message): self.logger_interface.log_info(f"[{self.sensor_name}]{message}")
def log_info(self,message): self.logger_interface.log_info(f"[{self.sensor_name}]{message}")
# def log_info(self,message): self.logger_interface.log_info(f"[{self.sensor_name}]{message}")
def log_warning(self,message): self.logger_interface.log_warning(f"[{self.sensor_name}]{message}")
# def log_warning(self,message): self.logger_interface.log_warning(f"[{self.sensor_name}]{message}")
def log_error(self,message): self.logger_interface.log_error(f"[{self.sensor_name}]{message}")
# def log_error(self,message): self.logger_interface.log_error(f"[{self.sensor_name}]{message}")
def log_info(self,message): self.logger_interface.log_info(message)
def log_warning(self,message): self.logger_interface.log_warning(message)
def log_error(self,message): self.logger_interface.log_error(message)
def generate_dependencies_list(self): return []
@@ -329,7 +340,7 @@ class ValueSelector(VirtualSensorBase):
def process_config(self) -> list[SelectorConfig]:
configs = list()
#self.log(f"yaml_block:{self.yaml_block}")
#self.log_info(f"yaml_block:{self.yaml_block}")
if 'values' in self.yaml_block: values_block = self.yaml_block['values']
else: values_block = self.yaml_block
@@ -354,7 +365,7 @@ class ValueSelector(VirtualSensorBase):
selectors_config = self.process_config()
for config in selectors_config:
#self.log(f"config: name = {config.name},conditions = {config.conditions},value = {config.value}")
#self.log_info(f"config: name = {config.name},conditions = {config.conditions},value = {config.value}")
conditions = SmartCondition.Evaluator(self.ad_api,config.conditions, logger_interface = self.logger_interface,condition_name = config.name, sensor_existence_validation = False, constants = self.constants, log_init = False)
self.extract_dependencies_from_entities_to_listen(conditions.get_entities_to_listen_as_list(),dependencies)
@@ -374,6 +385,7 @@ class ValueSelector(VirtualSensorBase):
self.output_sensor.set_state(state = value)
def update_value(self):
with self.logger_interface.log_group(self.sensor_name):
previous_value = str(self.output_sensor.get_state())
for selector in self.value_selectors:
@@ -389,6 +401,8 @@ class ValueSelector(VirtualSensorBase):
self.set_value(new_value)
break
self.log_info(f"{self.sensor_name} = {self.output_sensor.get_state()}")
class ContinuousCondition(VirtualSensorBase):
def initialize(self):
self.cb_handle = None
@@ -397,19 +411,19 @@ class ContinuousCondition(VirtualSensorBase):
except KeyError: self.ignore_unavailable_condition = False
try: self.invert = self.yaml_block['invert']
except KeyError: self.invert = False
self.log(f"Initializing continuous condition for {self.sensor_name}")
self.log_info(f"Initializing continuous condition for {self.sensor_name}")
self.output_sensor = self.ad_api.get_entity(self.sensor_name)
if not self.output_sensor.exists():
self.log(f"{self.sensor_name} does not exist")
self.log_info(f"{self.sensor_name} does not exist")
try:
self.output_sensor.set_state(state = self.yaml_block['default_value'])
self.log(f"adding {self.sensor_name} with default state {self.yaml_block['default_value']}")
self.log_info(f"adding {self.sensor_name} with default state {self.yaml_block['default_value']}")
except KeyError:
self.output_sensor.set_state(state = 'off')
self.log(f"adding {self.sensor_name} with default state off")
self.log_info(f"adding {self.sensor_name} with default state off")
else:
self.log(f"{self.sensor_name} already exist with state = {self.output_sensor.get_state()}")
self.log_info(f"{self.sensor_name} already exist with state = {self.output_sensor.get_state()}")
if self.invert:
succeed_cb = self.on_condition_fail
@@ -466,19 +480,19 @@ class RetainCondition(VirtualSensorBase):
self.retain_time = self.yaml_block['retain_time']
try: self.reset_retain_time_on_new_success = self.yaml_block['reset_retain_time_on_new_success']
except KeyError: self.reset_retain_time_on_new_success = True
self.log(f"Initializing retain condition for {self.sensor_name}")
self.log_info(f"Initializing retain condition for {self.sensor_name}")
self.output_sensor = self.ad_api.get_entity(self.sensor_name)
if not self.output_sensor.exists():
self.log(f"{self.sensor_name} does not exist")
self.log_info(f"{self.sensor_name} does not exist")
try:
self.output_sensor.set_state(state = self.yaml_block['default_value'])
self.log(f"adding {self.sensor_name} with default state {self.yaml_block['default_value']}")
self.log_info(f"adding {self.sensor_name} with default state {self.yaml_block['default_value']}")
except KeyError:
self.output_sensor.set_state(state = 'off')
self.log(f"adding {self.sensor_name} with default state off")
self.log_info(f"adding {self.sensor_name} with default state off")
else:
self.log(f"{self.sensor_name} already exist with state = {self.output_sensor.get_state()}")
self.log_info(f"{self.sensor_name} already exist with state = {self.output_sensor.get_state()}")
self.condition = SmartCondition.Evaluator(self.ad_api,self.yaml_block['conditions'], logger_interface = self.logger_interface,condition_name = self.sensor_name,on_succeed_cb = self.on_condition_succeed, on_fail_cb = self.on_condition_fail, constants = self.constants, templates_library = self.templates_library)
# new_value = 'on' if self.condition.evaluate() == SmartCondition.Result.Succeeded else 'off'
@@ -537,15 +551,23 @@ class BinarySensor(VirtualSensorBase):
self.output_sensor.set_state(state = 'off',attributes = self.attributes)
#self.ad_api.log(f"Creating Binary Sensor: binary_sensor = {self.virtual_sensor_name}, yaml_block = {self.yaml_block}")
self.condition = SmartCondition.Evaluator(self.ad_api,self.get_expression_yaml(), logger_interface = self.logger_interface, unavaibility_result = SmartCondition.Result.Unavailable,condition_name = self.virtual_sensor_name,on_change_cb = self.on_sensor_change, constants = self.constants, templates_library = self.templates_library)
self.condition = SmartCondition.Evaluator(self.ad_api,self.get_expression_yaml(), logger_interface = self.logger_interface, unavaibility_result = SmartCondition.Result.Unavailable,condition_name = self.virtual_sensor_name,on_change_cb = self.on_sensor_change, constants = self.constants, templates_library = self.templates_library, log_group = self.sensor_name)
new_value = 'on' if self.condition.evaluate() == SmartCondition.Result.Succeeded else 'off'
self.output_sensor.set_state(state = new_value)
def on_sensor_change(self,prev_result,result):
if result == SmartCondition.Result.Unavailable: self.output_sensor.set_state(state = 'unavailable')
elif result == SmartCondition.Result.Succeeded: self.output_sensor.set_state(state = 'on')
elif result == SmartCondition.Result.Failed: self.output_sensor.set_state(state = 'off')
# else: Disabled means we keep the previous value
target_state = None
if result == SmartCondition.Result.Unavailable: target_state = 'unavailable'
elif result == SmartCondition.Result.Succeeded: target_state = 'on'
elif result == SmartCondition.Result.Failed: target_state = 'off'
else: target_state = None #Disabled means we keep the previous value
if target_state != None:
self.output_sensor.set_state(state = target_state)
self.log_info(f"{self.sensor_name} = {target_state}")
else:
self.log_info(f"SmartCondition of {self.sensor_name} is disabled")
def generate_dependencies_list(self):
dependencies = list()
@@ -640,7 +662,7 @@ class VirtualSensors():
for sensor_name,default_value in default_values.items():
if not self.ad_api.entity_exists(sensor_name):
self.logger_interface.log_info(f"Creating sensor {sensor_name} = {default_value}")
self.ad_api.set_state(sensor_name, state = str(default_value))
self.ad_api.set_state(sensor_name, state = str(default_value),check_existence=False)
averagers = parser.parse_args('averagers',{})
for averager in averagers:
@@ -744,4 +766,5 @@ class VirtualSensors():
class VirtualSensorsApp(hass.Hass):
def initialize(self):
self.logger_interface = LoggerInterface(self.get_ad_api(),default_log = "virtualsensors_log")
with self.logger_interface.log_group("App Initialization"):
self.virtual_sensors = VirtualSensors(ad_api = self.get_ad_api(),logger_interface = self.logger_interface,yaml_block = self.args,args_to_ignore_in_validation = ['module','class','global_dependencies','priority','name','config_path'],app_name = self.name)
+7 -5
View File
@@ -17,9 +17,11 @@ By default, it reads the existing AppDaemon text logs from the workspace:
- `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 explorer groups lines by the app name in the standard AppDaemon log format. It provides INFO/WARNING/ERROR counts, expandable app histories, links to app definitions, relative timestamps, sorting by latest activity or app name, text/time/level filters, live updates, and raw-log navigation. 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.
**AD Logs** turns red when the loaded logs contain an ERROR or CRITICAL entry. **Clear logs** empties the configured log files and clears the current explorer history.
Lines carrying the same `[group=<name>:<id>]` marker are grouped together, and the supplied name is shown in the explorer. Ordinary AppDaemon lines without a group marker remain individual log entries. Trace metadata can still be used for causal links where present.
## Settings
@@ -59,8 +61,8 @@ settings file.
- Home Assistant entity completion and hover information in Python/YAML.
- Navigation to AppDaemon app and virtual-sensor definitions.
- AppDaemon errors as VS Code diagnostics and an output channel.
- Status bar actions for app reload, Home Assistant restart, production mode,
error handling and apps defined in the active YAML file.
- Status bar actions for app reload, Home Assistant restart, log exploration,
and apps defined in the active YAML file.
| Shortcut | Action |
| --- | --- |
@@ -70,7 +72,7 @@ settings file.
Commands are available under **AppDaemon:** in the Command Palette: **Go to
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**.
List**, **Clear Error Diagnostics**, and **Explore Logs**.
## Verification
+8
View File
@@ -6,3 +6,11 @@
.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}
#error-count,#warning-count{font-weight:700;font-size:14px}#error-count.has-errors{color:var(--vscode-editorError-foreground,#ffacac);border-color:currentColor;background:var(--vscode-inputValidation-errorBackground,#5b3039)}#warning-count.has-warnings{color:var(--vscode-editorWarning-foreground,#f3cd80);border-color:currentColor;background:var(--vscode-inputValidation-warningBackground,#594925)}
.last-log{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
summary{display:grid;grid-template-columns:auto minmax(0,1fr) repeat(3,max-content);align-items:center;column-gap:10px;row-gap:5px}
summary .last-log{grid-column:1/-1;min-width:0}
.rows{max-height:min(45vh,420px);overflow-y:auto;scrollbar-gutter:stable;overflow-anchor:none}
.app-label{flex-direction:row;align-items:center;gap:6px}.app-label .name{min-width:0}.app-link{color:var(--accent);font-size:13px;line-height:1;text-decoration:none;white-space:nowrap}.app-link:hover{text-decoration:underline}.app-link:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
+1 -1
View File
@@ -3,7 +3,7 @@
<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>
<header><div><h1>AppDaemon <span>Logs</span></h1><p id="status" role="status">Reading logs…</p></div><div class="actions"><button id="error-count" title="Show errors across all apps in loaded history">0 errors</button><button id="warning-count" title="Show warnings across all apps in loaded history">0 warnings</button><button id="clear" title="Empty configured log files">Clear logs</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>
+133 -57
View File
@@ -1,26 +1,20 @@
/* 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 vscode = typeof acquireVsCodeApi === 'function' ? acquireVsCodeApi() : {
postMessage() {}, 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;
let entries = [], latest, appliedMessage, displayZone = 'Europe/Berlin', selected = saved.selected, mode = saved.mode || 'graph', origin = saved.origin || saved.selected;
const expanded = new Set(saved.expanded || []);
const visibleEventGroups = new Map();
const logScrollPositions = new Map();
const recentEvents = new Set();
const recentApps = new Set();
const appFlashTokens = new Map();
let receivedInitialData = false;
let pointerSelecting = false;
let countView;
let activeCountLevel;
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;
@@ -33,14 +27,13 @@
&& (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 cleanMessage = e => e.message.replace(/ \[event=[\w-]+ trace=[\w-]+\]/g, '').replace(/ \[init=[^\]]+\]/g, '').replace(/ \[group=[^\]]+:[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`;
@@ -55,7 +48,19 @@
const years = Math.floor(days / 365);
return `${years} year${years === 1 ? '' : 's'} ago`;
}
function copy(text) { if (text) vscode.postMessage({ type: 'copy', text }); }
function shortTimeSince(time) {
const seconds = Math.max(0, Math.floor((Date.now() - Number(time)) / 1000));
if (seconds < 60) return `${seconds} sec ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} min ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} hr ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days} d ago`;
const months = Math.floor(days / 30);
if (days < 365) return `${months} mo ago`;
return `${Math.floor(days / 365)} yr ago`;
}
function logMessages(items, cls) {
const container = node('div', undefined, cls);
items.forEach((entry, index) => {
@@ -64,13 +69,12 @@
});
return container;
}
function blockTitle(items, groupKey) {
function blockTitle(items) {
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');
const kind = items.find(entry => entry.group_kind)?.group_kind;
const name = kind || (items.length > 1 || first.trace_id ? 'Event' : 'Log');
const label = items.map(entry => entry.group_label).find(Boolean) || first.entity || first.app;
return node('div', kind ? name : `${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' : ''}`);
@@ -124,6 +128,16 @@
});
}
function renderApps() {
const appList = $('apps').parentElement;
const appListTop = appList.scrollTop;
document.querySelectorAll('#apps details[data-app]').forEach(detail => {
const rows = detail.querySelector('.rows');
if (!rows) return;
logScrollPositions.set(detail.dataset.app, {
top: rows.scrollTop,
follow: rows.scrollHeight - rows.clientHeight - rows.scrollTop <= 24
});
});
visibleEventGroups.clear();
const container = $('apps'); container.replaceChildren();
const groups = new Map();
@@ -132,9 +146,11 @@
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);
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;
$('error-count').setAttribute('aria-pressed', String($('level').value === 'ERROR'));
$('warning-count').setAttribute('aria-pressed', String($('level').value === 'WARNING'));
[...groups].sort(([a, al], [b, bl]) => {
const order = $('sort').value;
if (order === 'name') return a.localeCompare(b);
@@ -144,45 +160,67 @@
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);
let preview;
label.append(node('span', app, 'name'));
const appLink = node('a', '↗', 'app-link');
appLink.href = '#';
appLink.title = `Open ${app} definition`;
appLink.setAttribute('aria-label', `Open ${app} definition`);
appLink.onclick = event => {
event.preventDefault(); event.stopPropagation();
vscode.postMessage({ type: 'app', app });
};
label.append(appLink);
if (last) {
const age = node('span', `Last log: ${timeSince(last.time)}`, 'last-log last-age');
const line = node('span', undefined, `last-log log-message level-${normalLevel(last)}`);
const age = node('span', shortTimeSince(last.time), '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'));
const message = cleanMessage(last);
line.title = message;
line.append(age, document.createTextNode(' : '), node('span', message.split(/\r?\n/, 1)[0].trim() || '—', 'last-message'));
preview = line;
} else preview = 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}`)));
summary.append(preview);
detail.append(summary);
function fill() {
detail.querySelector('.rows')?.remove();
if (!detail.open) return;
const rows = node('div', undefined, 'rows');
const units = [], initGroups = new Map();
const units = [], groupUnits = 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);
if (e.group_id) {
const key = `group:${e.group_id}`;
if (!groupUnits.has(key)) { const unit = { key, entries: [] }; groupUnits.set(key, unit); units.push(unit); }
groupUnits.get(key).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 key = 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;
const startTime = unit => 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);
units.slice(-150).forEach(unit => {
appendEventRows(rows, unit.entries, unit.key);
});
detail.append(rows);
const position = logScrollPositions.get(app);
rows.scrollTop = !position || position.follow ? rows.scrollHeight : position.top;
}
detail.addEventListener('toggle', event => {
if (event.target !== detail) return;
if (detail.open) expanded.add(app);
else { expanded.delete(app); logScrollPositions.delete(app); }
save(); fill();
});
container.append(detail); fill();
});
appList.scrollTop = appListTop;
}
function renderDetail() {
$('graph-mode').setAttribute('aria-pressed', String(mode === 'graph'));
@@ -193,7 +231,15 @@
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); }
const sourceEntries = selectedGroup.filter(entry => entry.source);
const sourceLocations = new Set();
sourceEntries.forEach(entry => {
const location = `${entry.source.path}:${entry.source.line}`;
if (sourceLocations.has(location)) return;
sourceLocations.add(location);
const source = node('button', 'Open source'); source.title = location;
source.onclick = () => vscode.postMessage({ type: 'source', id: entry.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();
@@ -313,37 +359,67 @@
? `${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.'
? 'Showing AppDaemon logs and additional records from configured log files.'
: 'Reading the configured AppDaemon text logs. Causal links require additional metadata that the base log format does not contain.';
const errorCount = entries.filter(e => (e.kind === 'log' || e.kind.startsWith('initialization_')) && normalLevel(e) === 'ERROR').length;
$('error-count').textContent = `${errorCount} error${errorCount === 1 ? '' : 's'}`;
$('error-count').classList.toggle('has-errors', errorCount > 0);
$('error-count').title = 'Errors and critical logs across all apps in loaded history. Click to show them.';
const warningCount = entries.filter(e => (e.kind === 'log' || e.kind.startsWith('initialization_')) && normalLevel(e) === 'WARNING').length;
$('warning-count').textContent = `${warningCount} warning${warningCount === 1 ? '' : 's'}`;
$('warning-count').classList.toggle('has-warnings', warningCount > 0);
$('warning-count').title = 'Warnings across all apps in loaded history. Click to show them.';
$('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('.last-age[data-time]').forEach(label => { label.textContent = shortTimeSince(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 === 'clear-result') {
$('clear').disabled = false;
$('clear').textContent = 'Clear logs';
if (event.data.failures?.length) $('warnings').textContent = event.data.failures.join(' · ');
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';
if (!selectionActive()) apply(latest);
else $('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); }
document.addEventListener('selectionchange', () => { if (!selectionActive() && latest && latest !== appliedMessage) setTimeout(() => { if (!selectionActive() && latest !== appliedMessage) apply(latest); }, 0); });
['search', 'period', 'level', 'sort'].forEach(id => $(id).addEventListener('input', () => {
countView = undefined; activeCountLevel = undefined; selected = undefined; origin = undefined;
save(); renderApps(); renderDetail();
}));
function toggleCountView(level) {
const leaving = activeCountLevel === level;
if (leaving) {
$('search').value = countView?.search || '';
$('period').value = countView?.period || '0';
$('level').value = countView?.level || 'all';
if (countView) { expanded.clear(); countView.expanded.forEach(app => expanded.add(app)); }
countView = undefined; activeCountLevel = undefined;
} else {
if (!activeCountLevel) countView = { search: $('search').value, period: $('period').value, level: $('level').value, expanded: [...expanded] };
activeCountLevel = level;
$('search').value = ''; $('period').value = '0'; $('level').value = level;
entries.filter(e => normalLevel(e) === level).forEach(e => expanded.add(e.app));
}
});
$('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' });
selected = undefined; origin = undefined;
save(); renderApps(); renderDetail();
if (!leaving) {
const first = document.querySelector('#apps .row');
if (first) { first.click(); first.closest('.rows').scrollTop = 0; first.focus({ preventScroll: true }); }
}
}
$('error-count').onclick = () => toggleCountView('ERROR');
$('warning-count').onclick = () => toggleCountView('WARNING');
$('clear').onclick = () => { selected = undefined; origin = undefined; save(); $('clear').disabled = true; $('clear').textContent = 'Clearing…'; vscode.postMessage({ type: 'clear' }); };
$('graph-mode').onclick = () => { mode = 'graph'; save(); renderDetail(); };
$('tree-mode').onclick = () => { mode = 'tree'; save(); renderDetail(); };
$('sort').value = saved.sort || 'latest';
-4
View File
@@ -113,10 +113,6 @@
"title": "AppDaemon: Refresh Entity List",
"icon": "$(sync)"
},
{
"command": "appdaemon.handleErrors",
"title": "AppDaemon: Handle Errors"
},
{
"command": "appdaemon.clearErrors",
"title": "AppDaemon: Clear Error Diagnostics"
-5
View File
@@ -14,8 +14,6 @@ function normalizeUrl(raw: string): string {
export class ADClient {
private productionMode = false;
private _onProductionModeChanged = new vscode.EventEmitter<boolean>();
readonly onProductionModeChanged = this._onProductionModeChanged.event;
private log: vscode.OutputChannel;
constructor(log: vscode.OutputChannel) {
@@ -120,7 +118,6 @@ export class ADClient {
async setProductionMode(mode: boolean): Promise<void> {
await this.request('POST', '/api/appdaemon/service/admin/production_mode/set', { mode });
this.productionMode = mode;
this._onProductionModeChanged.fire(mode);
}
/**
@@ -135,7 +132,6 @@ export class ADClient {
if (key.includes('production_mode')) {
const val = (state[key] as { state?: unknown })?.state;
this.productionMode = val === true || val === 'True' || val === 'true';
this._onProductionModeChanged.fire(this.productionMode);
return this.productionMode;
}
}
@@ -155,6 +151,5 @@ export class ADClient {
}
dispose() {
this._onProductionModeChanged.dispose();
}
}
-14
View File
@@ -11,10 +11,6 @@ export class ErrorViewer {
private diagnosticCollection: vscode.DiagnosticCollection;
private watcher: fs.FSWatcher | undefined;
private lastSize = 0;
private errorCount = 0;
private _onErrorCountChanged = new vscode.EventEmitter<number>();
readonly onErrorCountChanged = this._onErrorCountChanged.event;
constructor() {
this.outputChannel = vscode.window.createOutputChannel('AppDaemon Errors');
@@ -30,12 +26,6 @@ export class ErrorViewer {
clearDiagnostics() {
this.diagnosticCollection.clear();
this.errorCount = 0;
this._onErrorCountChanged.fire(0);
}
getErrorCount(): number {
return this.errorCount;
}
// ── file watching ────────────────────────────────────────────────────────
@@ -115,12 +105,9 @@ export class ErrorViewer {
this.outputChannel.appendLine(trimmed);
if (/\b(ERROR|CRITICAL|WARNING)\b/.test(trimmed)) {
this.errorCount++;
this.parseDiagnostic(trimmed);
}
}
this._onErrorCountChanged.fire(this.errorCount);
} finally {
fs.closeSync(fd);
}
@@ -183,6 +170,5 @@ export class ErrorViewer {
}
this.outputChannel.dispose();
this.diagnosticCollection.dispose();
this._onErrorCountChanged.dispose();
}
}
+9 -21
View File
@@ -345,12 +345,10 @@ export async function activate(context: vscode.ExtensionContext) {
haClient = new HAClient(outputChannel);
adClient = new ADClient(outputChannel);
errorViewer = new ErrorViewer();
const logExplorer = new LogExplorer(context);
const logExplorer = new LogExplorer(context, count => statusBar.setLogErrorCount(count));
context.subscriptions.push(logExplorer, vscode.commands.registerCommand('appdaemon.exploreLogs', () => logExplorer.show()));
statusBar = new StatusBarManager(adClient);
// Wire error count → status bar badge
errorViewer.onErrorCountChanged((count) => statusBar.setErrorCount(count));
statusBar = new StatusBarManager();
logExplorer.startPolling();
// ── Contextual per-file restart button ───────────────────────────────────
updateContextualButton(vscode.window.activeTextEditor);
@@ -446,10 +444,12 @@ export async function activate(context: vscode.ExtensionContext) {
editor.revealRange(new vscode.Range(pos, pos), vscode.TextEditorRevealType.InCenter);
}),
vscode.commands.registerCommand('appdaemon.goToApp', async () => {
const apps = await findAllApps();
vscode.commands.registerCommand('appdaemon.goToApp', async (requestedName?: string) => {
const apps = (await findAllApps()).filter(app => !requestedName || app.appName === requestedName);
if (apps.length === 0) {
vscode.window.showWarningMessage('AppDaemon: No app definitions found in workspace');
vscode.window.showWarningMessage(requestedName
? `AppDaemon: No definition found for ${requestedName}`
: 'AppDaemon: No app definitions found in workspace');
return;
}
@@ -461,7 +461,7 @@ export async function activate(context: vscode.ExtensionContext) {
app
}));
const picked = await vscode.window.showQuickPick(items, {
const picked = apps.length === 1 && requestedName ? items[0] : await vscode.window.showQuickPick(items, {
placeHolder: 'Go to AppDaemon app…',
matchOnDescription: true,
matchOnDetail: false
@@ -543,18 +543,6 @@ export async function activate(context: vscode.ExtensionContext) {
vscode.commands.registerCommand('appdaemon.showErrors', () => {
errorViewer.show();
}),
vscode.commands.registerCommand('appdaemon.handleErrors', async () => {
const pick = await vscode.window.showErrorMessage(
'AppDaemon errors detected',
'Show log',
'Clear'
);
if (pick === 'Show log') {
errorViewer.show();
} else if (pick === 'Clear') {
errorViewer.clearDiagnostics();
}
}),
vscode.commands.registerCommand('appdaemon.refreshEntities', async () => {
const entities = await haClient.fetchEntities();
vscode.window.showInformationMessage(`AppDaemon: ${entities.length} entities loaded`);
+97 -49
View File
@@ -3,8 +3,9 @@ import * as fs from 'fs';
import * as path from 'path';
import { randomBytes } from 'crypto';
import { LogEntry, parseLine, decodeLogLine } from './logModel';
import { findLogSource, SourceDocument } from './logSource';
type Cursor = { offset: number; identity: string; pending: Buffer; last?: LogEntry; discard: boolean; generation: string };
type Cursor = { offset: number; identity: string; pending: Buffer; last?: LogEntry; errorBlock?: boolean; discard: boolean; generation: string };
export class LogExplorer implements vscode.Disposable {
private panel?: vscode.WebviewPanel;
private view?: vscode.WebviewView;
@@ -13,10 +14,12 @@ export class LogExplorer implements 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) {}
private sourceDocuments?: Promise<SourceDocument[]>;
private sourceDocumentsAt = 0;
private publication = 0;
constructor(private context: vscode.ExtensionContext, private onErrorCountChanged?: (count: number) => void) {}
show() {
if (this.view) { this.view.show(); return; }
if (this.panel) { this.panel.reveal(); return; }
@@ -27,7 +30,7 @@ export class LogExplorer implements vscode.Disposable {
this.configureWebview(this.panel.webview);
this.panel.onDidDispose(() => {
this.panel = undefined;
if (!this.view) { this.stopPolling(); this.activeWebview = undefined; }
if (!this.view) { this.activeWebview = undefined; }
});
this.startPolling();
}
@@ -38,11 +41,11 @@ export class LogExplorer implements vscode.Disposable {
localResourceRoots: [vscode.Uri.joinPath(this.context.extensionUri, 'media')]
};
this.configureWebview(view.webview);
const updateVisibility = () => view.visible ? this.startPolling() : this.stopPolling();
const updateVisibility = () => { if (view.visible) { this.startPolling(); } };
view.onDidChangeVisibility(updateVisibility, undefined, this.context.subscriptions);
view.onDidDispose(() => {
if (this.view === view) { this.view = undefined; }
if (!this.panel) { this.stopPolling(); this.activeWebview = undefined; }
if (!this.panel) { this.activeWebview = undefined; }
}, undefined, this.context.subscriptions);
updateVisibility();
}
@@ -54,11 +57,10 @@ export class LogExplorer implements vscode.Disposable {
.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 === 'app' && typeof message.app === 'string') {
await vscode.commands.executeCommand('appdaemon.goToApp', message.app);
}
if (message.type === 'open' && typeof message.id === 'string') {
const entry = this.entries.get(message.id);
if (!entry?.file) { return; }
@@ -77,7 +79,7 @@ export class LogExplorer implements vscode.Disposable {
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 target = source.path.startsWith('/conf/') ? path.resolve(root, source.path.slice(6)) : path.resolve(root, 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 {
@@ -88,15 +90,11 @@ export class LogExplorer implements vscode.Disposable {
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();
}
if (message.type === 'clear') { this.clearLogs(); }
}, undefined, this.context.subscriptions);
this.activeWebview = webview;
}
private startPolling() {
startPolling() {
if (this.timer) { return; }
this.poll();
this.timer = setInterval(() => this.poll(), 1000);
@@ -104,12 +102,36 @@ export class LogExplorer implements vscode.Disposable {
private stopPolling() {
if (this.timer) { clearInterval(this.timer); this.timer = undefined; }
}
private files(): string[] {
private configuredFiles(): string[] {
const folder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!folder) { return [...this.imported]; }
if (!folder) { return []; }
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])];
return [...new Set(configured.map(p => path.resolve(folder, p)))];
}
private clearLogs() {
const folder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
const cleared = new Set<string>();
const failures: string[] = [];
if (folder) {
const realRoot = fs.realpathSync(folder);
for (const file of this.configuredFiles()) {
try {
const relative = path.relative(realRoot, fs.realpathSync(file));
if (relative.startsWith('..') || path.isAbsolute(relative)) { throw new Error('outside the workspace'); }
if (!fs.statSync(file).isFile()) { throw new Error('not a regular file'); }
fs.truncateSync(file, 0);
this.cursors.delete(file);
cleared.add(file);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { failures.push(`${path.basename(file)}: ${String(error)}`); }
}
}
}
for (const [id, entry] of this.entries) { if (entry.file && cleared.has(entry.file)) { this.entries.delete(id); } }
failures.forEach(failure => this.warnings.add(`Unable to clear ${failure}`));
this.publish();
this.activeWebview?.postMessage({ type: 'clear-result', cleared: cleared.size, failures });
}
private poll() {
if (this.busy) { return; }
@@ -118,7 +140,7 @@ export class LogExplorer implements vscode.Disposable {
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()) {
for (const file of this.configuredFiles()) {
if (!fs.existsSync(file)) { continue; }
try {
const stat = fs.statSync(file);
@@ -146,6 +168,25 @@ export class LogExplorer implements vscode.Disposable {
if (cursor.discard) { cursor.discard = false; continue; }
const entry = parseLine(line, file, position, zone);
if (entry) {
const isErrorLog = path.basename(file) === 'error.log';
const failedApp = isErrorLog && entry.app === 'Error' && entry.level === 'ERROR'
? entry.message.match(/^=+\s+Failed to start '([^']+)'/)
: undefined;
if (failedApp) {
entry.app = failedApp[1];
entry.message = `Failed to start '${failedApp[1]}'`;
cursor.errorBlock = true;
} else if (isErrorLog && cursor.errorBlock && cursor.last && entry.app === 'Error' && entry.level === 'ERROR') {
if ((cursor.last.raw?.length || 0) < 64 * 1024) {
cursor.last.message += '\n' + entry.message;
cursor.last.raw += '\n' + line;
}
if (/^=+\s*$/.test(entry.message)) { cursor.errorBlock = false; }
changed = true;
continue;
} else if (cursor.errorBlock) {
cursor.errorBlock = false;
}
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); }
@@ -168,43 +209,50 @@ export class LogExplorer implements vscode.Disposable {
if (changed) { this.publish(); }
} finally { this.busy = false; }
}
private async resolveSources(entries: LogEntry[], publication: number) {
if (!entries.some(entry => !entry.source && entry.message.includes('Error parsing string')) || !vscode.workspace.findFiles) { return; }
if (!this.sourceDocuments || Date.now() - this.sourceDocumentsAt > 5000) {
this.sourceDocumentsAt = Date.now();
this.sourceDocuments = (async () => {
const files = await vscode.workspace.findFiles('**/*.{yaml,yml}', '**/{node_modules,.git}/**');
const documents: SourceDocument[] = [];
for (const uri of files) {
try {
const doc = await vscode.workspace.openTextDocument(uri);
documents.push({ path: uri.fsPath, text: doc.getText() });
} catch { /* A configuration file may have been removed. */ }
}
return documents;
})();
}
try {
const documents = await this.sourceDocuments;
if (publication !== this.publication) { return; }
let changed = false;
for (const entry of entries) {
if (entry.source) { continue; }
const source = findLogSource(entry, documents);
if (source) { entry.source = source; changed = true; }
}
if (changed) {
const zone = vscode.workspace.getConfiguration('appdaemon').get<string>('logTimezone', 'Europe/Berlin');
this.activeWebview?.postMessage({ type: 'entries', entries, warnings: [...this.warnings], limit: 10000, timezone: zone });
}
} catch { this.sourceDocuments = undefined; }
}
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);
});
const publication = ++this.publication;
const entries = [...this.entries.values()].sort((a, b) => a.time - b.time);
// 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); }
}
this.onErrorCountChanged?.(unique.filter(e => (e.kind === 'log' || e.kind.startsWith('initialization_')) && (e.level === 'ERROR' || e.level === 'CRITICAL')).length);
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 });
void this.resolveSources(unique, publication);
}
dispose() { this.stopPolling(); this.messageListener?.dispose(); this.panel?.dispose(); }
}
+14 -10
View File
@@ -3,8 +3,8 @@ export interface LogEntry {
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;
status?: string;
group_id?: string; group_kind?: string;
file?: string; raw?: string; time: number; trace_id?: string;
[key: string]: unknown;
}
@@ -14,9 +14,15 @@ 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)/);
const m = date.match(/^(?:(\d{4})-(\d\d)-(\d\d)|(\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]));
const year = Number(m[1] || (2000 + Number(m[6])));
const month = Number(m[2] || m[5]);
const day = Number(m[3] || m[4]);
const hour = Number(m[7]);
const minute = Number(m[8]);
const second = Number(m[9]);
const utc = Date.UTC(year, month - 1, day, hour, minute, second);
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); }
@@ -36,14 +42,12 @@ export function parseLine(line: string, file: string, offset: number, zone = 'Eu
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?(.*)$/);
const m = line.match(/^((?:\d{2}-\d{2}-\d{2}|\d{4}-\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 group = m[4].match(/\[group=([^\]\r\n]+):([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 };
const groupKind = group?.[1]?.trim();
return { event_id: id?.[1] || `${file}:${offset}`, trace_id: id?.[2], 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. */
+25
View File
@@ -0,0 +1,25 @@
import { LogEntry } from './logModel';
export type SourceDocument = { path: string; text: string };
/** Resolve parsing failures to the expression in their app's YAML configuration. */
export function findLogSource(entry: LogEntry, documents: SourceDocument[]): LogEntry['source'] | undefined {
const expression = entry.message.match(/Error parsing string "([^\n]+)"(?: at character|:|\n|$)/)?.[1];
if (!expression) { return undefined; }
const matches: Array<{ path: string; line: number; app: string }> = [];
for (const document of documents) {
let app = '';
document.text.split(/\r?\n/).forEach((line, index) => {
const key = line.match(/^([^\s#][^:]*):(?:\s|$)/);
if (key) { app = key[1].replace(/^['"]|['"]$/g, ''); }
if (!line.trimStart().startsWith('#') && line.includes(expression)) {
matches.push({ path: document.path, line: index + 1, app });
}
});
}
const local = matches.filter(match => match.app === entry.app);
const candidates = local.length ? local : matches;
if (candidates.length !== 1) { return undefined; }
const { path, line } = candidates[0];
return { path, line };
}
+6 -47
View File
@@ -1,15 +1,12 @@
import * as vscode from 'vscode';
import { ADClient } from './adClient';
export class StatusBarManager {
private restartADItem: vscode.StatusBarItem;
private restartHAItem: vscode.StatusBarItem;
private productionModeItem: vscode.StatusBarItem;
private errorItem: vscode.StatusBarItem;
private contextualItem: vscode.StatusBarItem;
private logsItem: vscode.StatusBarItem;
constructor(private adClient: ADClient) {
constructor() {
// Reload AppDaemon apps
this.restartADItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100);
this.restartADItem.command = 'appdaemon.restartAD';
@@ -24,19 +21,6 @@ export class StatusBarManager {
this.restartHAItem.tooltip = 'Restart Home Assistant';
this.restartHAItem.show();
// Production mode toggle
this.productionModeItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 98);
this.productionModeItem.command = 'appdaemon.toggleProductionMode';
this.updateProductionModeDisplay();
this.productionModeItem.show();
// Error log shortcut
this.errorItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 97);
this.errorItem.command = 'appdaemon.showErrors';
this.errorItem.text = '$(check) AD Errors';
this.errorItem.tooltip = 'Show AppDaemon Error Log';
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';
@@ -47,9 +31,6 @@ export class StatusBarManager {
// Contextual per-file restart button (hidden by default)
this.contextualItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 101);
this.contextualItem.command = 'appdaemon.restartCurrentFileApps';
// React to production mode changes
adClient.onProductionModeChanged(() => this.updateProductionModeDisplay());
}
/**
@@ -69,38 +50,16 @@ export class StatusBarManager {
this.contextualItem.show();
}
updateProductionModeDisplay() {
const isProduction = this.adClient.isProductionMode();
if (isProduction) {
this.productionModeItem.text = '$(eye-closed) Prod ON';
this.productionModeItem.tooltip = 'Production Mode: ON — click to disable';
this.productionModeItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground');
} else {
this.productionModeItem.text = '$(eye) Prod OFF';
this.productionModeItem.tooltip = 'Production Mode: OFF — click to enable';
this.productionModeItem.backgroundColor = undefined;
}
}
setErrorCount(count: number) {
if (count > 0) {
this.errorItem.text = `$(warning) AD Errors (${count})`;
this.errorItem.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground');
this.errorItem.tooltip = `${count} error(s) — click to show or clear`;
this.errorItem.command = 'appdaemon.handleErrors';
} else {
this.errorItem.text = '$(check) AD Errors';
this.errorItem.backgroundColor = undefined;
this.errorItem.tooltip = 'No AppDaemon errors — click to view log';
this.errorItem.command = 'appdaemon.showErrors';
}
setLogErrorCount(count: number) {
this.logsItem.backgroundColor = count > 0 ? new vscode.ThemeColor('statusBarItem.errorBackground') : undefined;
this.logsItem.tooltip = count > 0
? `${count} error${count === 1 ? '' : 's'} in loaded AppDaemon logs — open Log Explorer`
: 'Open AppDaemon Log Explorer';
}
dispose() {
this.restartADItem.dispose();
this.restartHAItem.dispose();
this.productionModeItem.dispose();
this.errorItem.dispose();
this.logsItem.dispose();
this.contextualItem.dispose();
}
@@ -60,3 +60,21 @@ test('groups plain app.log lines between explicit initialization markers',()=>{
assert.equal(grouped[1].group_inferred,true);
assert.equal(grouped[2].group_inferred,undefined);
});
test('clears configured log files and updates the log error count',()=>{
const counts=[];const e=new LogExplorer({},count=>counts.push(count));
messages=[];const webview={postMessage:m=>messages.push(m)};e.panel={webview};e.activeWebview=webview;
const errorFile=path.join(root,'logs/error.log');
fs.writeFileSync(file,'27-09-26 10:12:13 ERROR app: failed\n');
fs.writeFileSync(errorFile,'27-09-26 10:12:13 ERROR app: failed\n');
e.poll();
assert.equal(counts.at(-1),1);
e.clearLogs();
assert.equal(fs.statSync(file).size,0);
assert.equal(fs.statSync(errorFile).size,0);
assert.equal(counts.at(-1),0);
assert.deepEqual(messages.filter(message=>message.type==='entries').at(-1).entries,[]);
fs.appendFileSync(file,'27-09-26 10:12:15 CRITICAL app: new failure\n');e.poll();
assert.equal(counts.at(-1),1);
assert.equal(latest()[0].message,'new failure');
});
+17
View File
@@ -0,0 +1,17 @@
const {test}=require('node:test');
const assert=require('node:assert/strict');
const {findLogSource}=require('../out/logSource');
const expression='sensor.target_temperature_heatpump + 0.5';
const entry={app:'living_room_heatpump',message:`Error parsing string "${expression}" at character 0:Unknown variable : sensor.target_temperature_heatpump`};
test('resolves the failing expression inside the matching app rather than another app',()=>{
const docs=[{path:'/conf/apps/heating.yaml',text:`bedroom_heatpump:\n target_temperature: ${expression}\nliving_room_heatpump:\n target_temperature: ${expression}\n`}];
assert.deepEqual(findLogSource(entry,docs),{path:'/conf/apps/heating.yaml',line:4});
});
test('resolves a unique expression in a shared template and ignores comments',()=>{
assert.deepEqual(findLogSource(entry,[{path:'/conf/apps/templates.yml',text:`# ${expression}\nlibrary:\n value: ${expression}`}]),{path:'/conf/apps/templates.yml',line:3});
});
test('does not guess when a source is missing or ambiguous',()=>{
assert.equal(findLogSource(entry,[]),undefined);
assert.equal(findLogSource(entry,[{path:'a.yaml',text:`living_room_heatpump:\n a: ${expression}\n b: ${expression}`}]),undefined);
assert.equal(findLogSource({...entry,message:'Initialization failed'},[]),undefined);
});
+119 -18
View File
@@ -20,9 +20,16 @@ function setup() {
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.equal(d.getElementById('copy'),null);
assert.equal(d.getElementById('import'),null);
assert.equal(d.getElementById('pause'),null);
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/);
assert.match(d.querySelector('[data-app="alpha"] summary .last-log').textContent,/ : Température déjà disponible$/);
const summary=d.querySelector('[data-app="alpha"] summary');
assert.deepEqual([...summary.children].slice(0,4).map(child=>child.className),['app-label','badge INFO','badge WARNING','badge ERROR']);
assert.ok(summary.children[4].classList.contains('last-log'));
assert.equal(summary.querySelector('.app-label .last-log'),null);
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'));
@@ -33,25 +40,36 @@ test('English interface, latest timestamps, sorting and separate initialization
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/);
test('selects individual logs and entire initialization groups',()=>{
const {dom,d,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();
assert.match(d.getElementById('detail').textContent,/Température déjà disponible/);
const group=alpha.querySelector('.event-log-group');
group.click();d.getElementById('copy').click();
group.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/);
assert.doesNotMatch(d.getElementById('detail').textContent,/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'));
test('app link opens its YAML definition without expanding the log group',async()=>{
const {dom,d,sent}=setup();
const app=d.querySelector('[data-app="alpha"]'),link=app.querySelector('.app-link');
assert.equal(link.getAttribute('aria-label'),'Open alpha definition');
link.click();
await new Promise(resolve=>setTimeout(resolve,0));
assert.equal(sent.at(-1).type,'app');assert.equal(sent.at(-1).app,'alpha');
assert.equal(app.open,false);
dom.window.close();
});
test('retains selected text during live updates and leaves Ctrl+C to the browser',async()=>{
const {dom,d,entries,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);
const copy=new dom.window.KeyboardEvent('keydown',{key:'c',ctrlKey:true,bubbles:true,cancelable:true});
text.dispatchEvent(copy);assert.equal(copy.defaultPrevented,false);
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();
@@ -62,7 +80,7 @@ test('log content is never interpreted as HTML',()=>{
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 {dom,d,entries,send}=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'};
@@ -74,9 +92,7 @@ test('shows same-event log lines together with one causes link and keeps newer e
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/);
combined.click();
assert.match(d.getElementById('detail').textContent,/Callback trigger by sensor.kitchen_last_motion/);
dom.window.close();
});
@@ -109,10 +125,41 @@ test('pulses an app when a new log arrives and refreshes its last-log age',async
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');
assert.equal(app.querySelector('.last-age').textContent,'1 sec ago');
assert.equal(app.querySelector('summary .last-log').textContent,'1 sec ago : Fresh log');
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');
assert.equal(app.querySelector('.last-age').textContent,'1 min ago');
assert.equal(app.querySelector('summary .last-log').textContent,'1 min ago : Fresh log');
dom.window.close();
});
test('expanded logs follow new events at the bottom and keep position while reading history',async()=>{
const {dom,d,entries,send}=setup();
Object.defineProperty(dom.window.HTMLElement.prototype,'scrollHeight',{configurable:true,get(){return this.classList.contains('rows') ? this.children.length*100 : 0;}});
Object.defineProperty(dom.window.HTMLElement.prototype,'clientHeight',{configurable:true,get(){return this.classList.contains('rows') ? 100 : 0;}});
const app=d.querySelector('[data-app="alpha"]');app.open=true;app.dispatchEvent(new dom.window.Event('toggle'));
const rows=()=>d.querySelector('[data-app="alpha"] .rows');
assert.equal(rows().scrollTop,rows().scrollHeight);
const first={...entries[3],event_id:'new-tail',time:entries[3].time+1000,message:'Newest'};
send([...entries,first]);
assert.equal(rows().scrollTop,rows().scrollHeight);
rows().scrollTop=20;
const second={...entries[3],event_id:'new-tail-2',time:entries[3].time+2000,message:'Newer'};
send([...entries,first,second]);
assert.equal(rows().scrollTop,20);
assert.match(rows().textContent,/Newer/);
dom.window.close();
});
test('the newest log stays at the bottom when older groups are omitted',()=>{
const {dom,d,entries,send}=setup();
const base=entries[3];
send(Array.from({length:152},(_,index)=>({...base,event_id:`event-${index}`,time:base.time+index*1000,message:`Message ${index}`})));
const app=d.querySelector('[data-app="alpha"]');app.open=true;app.dispatchEvent(new dom.window.Event('toggle'));
const rows=app.querySelector('.rows');
assert.ok(rows.firstElementChild.classList.contains('more'));
assert.match(rows.lastElementChild.textContent,/Message 151/);
dom.window.close();
});
@@ -121,7 +168,7 @@ test('distinguishes log lines from supplemental records when configured',()=>{
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/);
assert.match(d.getElementById('status').title,/configured log files/);
dom.window.close();
});
@@ -157,3 +204,57 @@ test('shows consecutive logs from one explicit group as a single visible row',()
assert.doesNotMatch(rows[0].textContent,/\[group=/);
dom.window.close();
});
test('global error counter shows all loaded errors and opens them despite active filters',()=>{
const {dom,d,entries,send}=setup();
send([...entries,{...entries[3],event_id:'alpha-error',level:'ERROR',message:'Alpha failed'},
{...entries[4],event_id:'beta-critical',level:'CRITICAL',message:'Beta failed'},
{...entries[4],event_id:'supplement',kind:'evaluation',level:'ERROR',message:'Supplemental'}]);
const counter=d.getElementById('error-count');
assert.equal(counter.textContent,'2 errors');assert.ok(counter.classList.contains('has-errors'));
d.getElementById('search').value='nothing';d.getElementById('period').value='15';
counter.click();
assert.equal(d.getElementById('search').value,'');assert.equal(d.getElementById('period').value,'0');
assert.equal(d.getElementById('level').value,'ERROR');
assert.equal(d.querySelectorAll('#apps .row').length,2);
assert.match(d.getElementById('detail').textContent,/failed/);
dom.window.close();
});
test('exits errors with the counter or All levels and restores normal app sorting',()=>{
const {dom,d,entries,send}=setup();
send([...entries,{...entries[3],event_id:'old-alpha-error',time:entries[0].time,level:'ERROR',message:'Old error'}]);
const counter=d.getElementById('error-count');
counter.click();assert.equal(counter.getAttribute('aria-pressed'),'true');
assert.equal(d.querySelector('[data-app]').dataset.app,'alpha');
counter.click();
assert.equal(d.getElementById('level').value,'all');
assert.equal(counter.getAttribute('aria-pressed'),'false');
assert.equal(d.querySelector('[data-app]').dataset.app,'beta');
counter.click();
d.getElementById('level').value='all';d.getElementById('level').dispatchEvent(new dom.window.Event('input'));
assert.equal(d.querySelector('[data-app]').dataset.app,'beta');
assert.equal(d.querySelectorAll('.row.selected').length,0);
assert.equal(counter.getAttribute('aria-pressed'),'false');
dom.window.close();
});
test('Open source in a group targets the parsing error even when the last entry has no source',()=>{
const {dom,d,entries,send,sent}=setup();
send([{...entries[0],event_id:'parse-error',level:'ERROR',source:{path:'/conf/apps/heating.yaml',line:160}},
{...entries[1],event_id:'init-failed',level:'ERROR',message:'Initialization failed'}]);
const app=d.querySelector('[data-app="alpha"]');app.open=true;app.dispatchEvent(new dom.window.Event('toggle'));
app.querySelector('.row').click();
const source=[...d.querySelectorAll('#detail button')].find(button=>button.textContent==='Open source');
assert.ok(source);source.click();assert.equal(sent.at(-1).type,'source');assert.equal(sent.at(-1).id,'parse-error');
dom.window.close();
});
test('Clear logs sends a request and resets its button after completion',()=>{
const {dom,d,sent}=setup();
const button=d.getElementById('clear');button.click();
assert.equal(sent.at(-1).type,'clear');assert.equal(button.disabled,true);
dom.window.dispatchEvent(new dom.window.MessageEvent('message',{data:{type:'clear-result',cleared:2,failures:[]}}));
assert.equal(button.disabled,false);assert.equal(button.textContent,'Clear logs');
dom.window.close();
});
Binary file not shown.