portacode 0.3.16.dev10__py3-none-any.whl → 1.4.11.dev1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of portacode might be problematic. Click here for more details.
- portacode/_version.py +16 -3
- portacode/cli.py +143 -17
- portacode/connection/client.py +149 -10
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +928 -42
- portacode/connection/handlers/__init__.py +34 -5
- portacode/connection/handlers/base.py +78 -16
- portacode/connection/handlers/chunked_content.py +244 -0
- portacode/connection/handlers/diff_handlers.py +603 -0
- portacode/connection/handlers/file_handlers.py +902 -17
- portacode/connection/handlers/project_aware_file_handlers.py +226 -0
- portacode/connection/handlers/project_state/README.md +312 -0
- portacode/connection/handlers/project_state/__init__.py +92 -0
- portacode/connection/handlers/project_state/file_system_watcher.py +179 -0
- portacode/connection/handlers/project_state/git_manager.py +1502 -0
- portacode/connection/handlers/project_state/handlers.py +875 -0
- portacode/connection/handlers/project_state/manager.py +1331 -0
- portacode/connection/handlers/project_state/models.py +108 -0
- portacode/connection/handlers/project_state/utils.py +50 -0
- portacode/connection/handlers/project_state_handlers.py +45 -948
- portacode/connection/handlers/proxmox_infra.py +361 -0
- portacode/connection/handlers/registry.py +15 -4
- portacode/connection/handlers/session.py +483 -32
- portacode/connection/handlers/system_handlers.py +147 -8
- portacode/connection/handlers/tab_factory.py +389 -0
- portacode/connection/handlers/terminal_handlers.py +21 -8
- portacode/connection/handlers/update_handler.py +61 -0
- portacode/connection/multiplex.py +60 -2
- portacode/connection/terminal.py +256 -17
- portacode/keypair.py +63 -1
- portacode/link_capture/__init__.py +38 -0
- portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
- portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
- portacode/link_capture/bin/elinks +3 -0
- portacode/link_capture/bin/gio-open +3 -0
- portacode/link_capture/bin/gnome-open +3 -0
- portacode/link_capture/bin/gvfs-open +3 -0
- portacode/link_capture/bin/kde-open +3 -0
- portacode/link_capture/bin/kfmclient +3 -0
- portacode/link_capture/bin/link_capture_exec.sh +11 -0
- portacode/link_capture/bin/link_capture_wrapper.py +75 -0
- portacode/link_capture/bin/links +3 -0
- portacode/link_capture/bin/links2 +3 -0
- portacode/link_capture/bin/lynx +3 -0
- portacode/link_capture/bin/mate-open +3 -0
- portacode/link_capture/bin/netsurf +3 -0
- portacode/link_capture/bin/sensible-browser +3 -0
- portacode/link_capture/bin/w3m +3 -0
- portacode/link_capture/bin/x-www-browser +3 -0
- portacode/link_capture/bin/xdg-open +3 -0
- portacode/logging_categories.py +140 -0
- portacode/pairing.py +103 -0
- portacode/static/js/test-ntp-clock.html +63 -0
- portacode/static/js/utils/ntp-clock.js +232 -0
- portacode/utils/NTP_ARCHITECTURE.md +136 -0
- portacode/utils/__init__.py +1 -0
- portacode/utils/diff_apply.py +456 -0
- portacode/utils/diff_renderer.py +371 -0
- portacode/utils/ntp_clock.py +65 -0
- portacode-1.4.11.dev1.dist-info/METADATA +298 -0
- portacode-1.4.11.dev1.dist-info/RECORD +97 -0
- {portacode-0.3.16.dev10.dist-info → portacode-1.4.11.dev1.dist-info}/WHEEL +1 -1
- portacode-1.4.11.dev1.dist-info/top_level.txt +3 -0
- test_modules/README.md +296 -0
- test_modules/__init__.py +1 -0
- test_modules/test_device_online.py +44 -0
- test_modules/test_file_operations.py +743 -0
- test_modules/test_git_status_ui.py +370 -0
- test_modules/test_login_flow.py +50 -0
- test_modules/test_navigate_testing_folder.py +361 -0
- test_modules/test_play_store_screenshots.py +294 -0
- test_modules/test_terminal_buffer_performance.py +261 -0
- test_modules/test_terminal_interaction.py +80 -0
- test_modules/test_terminal_loading_race_condition.py +95 -0
- test_modules/test_terminal_start.py +56 -0
- testing_framework/.env.example +21 -0
- testing_framework/README.md +334 -0
- testing_framework/__init__.py +17 -0
- testing_framework/cli.py +326 -0
- testing_framework/core/__init__.py +1 -0
- testing_framework/core/base_test.py +336 -0
- testing_framework/core/cli_manager.py +177 -0
- testing_framework/core/hierarchical_runner.py +577 -0
- testing_framework/core/playwright_manager.py +520 -0
- testing_framework/core/runner.py +447 -0
- testing_framework/core/shared_cli_manager.py +234 -0
- testing_framework/core/test_discovery.py +112 -0
- testing_framework/requirements.txt +12 -0
- portacode-0.3.16.dev10.dist-info/METADATA +0 -238
- portacode-0.3.16.dev10.dist-info/RECORD +0 -29
- portacode-0.3.16.dev10.dist-info/top_level.txt +0 -1
- {portacode-0.3.16.dev10.dist-info → portacode-1.4.11.dev1.dist-info}/entry_points.txt +0 -0
- {portacode-0.3.16.dev10.dist-info → portacode-1.4.11.dev1.dist-info/licenses}/LICENSE +0 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Simple link capture wrapper that never executes a native browser."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
LINK_CHANNEL_ENV = "PORTACODE_LINK_CHANNEL"
|
|
13
|
+
TERMINAL_ID_ENV = "PORTACODE_TERMINAL_ID"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _find_link_argument(args):
|
|
17
|
+
for arg in args:
|
|
18
|
+
if not isinstance(arg, str):
|
|
19
|
+
continue
|
|
20
|
+
parsed = urlparse(arg)
|
|
21
|
+
if parsed.scheme and parsed.netloc:
|
|
22
|
+
return arg
|
|
23
|
+
if arg.startswith("file://"):
|
|
24
|
+
return arg
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _write_capture_event(cmd_name, args, link):
|
|
29
|
+
channel = os.environ.get(LINK_CHANNEL_ENV)
|
|
30
|
+
terminal_id = os.environ.get(TERMINAL_ID_ENV)
|
|
31
|
+
if not channel or not link:
|
|
32
|
+
return
|
|
33
|
+
payload = {
|
|
34
|
+
"terminal_id": terminal_id,
|
|
35
|
+
"command": cmd_name,
|
|
36
|
+
"args": args,
|
|
37
|
+
"url": link,
|
|
38
|
+
"timestamp": time.time(),
|
|
39
|
+
}
|
|
40
|
+
directory = Path(channel)
|
|
41
|
+
try:
|
|
42
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
except Exception:
|
|
44
|
+
return
|
|
45
|
+
temp_file = directory / f".{uuid.uuid4().hex}.tmp"
|
|
46
|
+
term_label = terminal_id or "unknown"
|
|
47
|
+
base_name = f"{int(time.time() * 1000)}-{term_label}"
|
|
48
|
+
final_file = directory / f"{base_name}.json"
|
|
49
|
+
suffix = 0
|
|
50
|
+
while final_file.exists():
|
|
51
|
+
suffix += 1
|
|
52
|
+
final_file = directory / f"{base_name}-{suffix}.json"
|
|
53
|
+
try:
|
|
54
|
+
temp_file.write_text(json.dumps(payload), encoding="utf-8")
|
|
55
|
+
temp_file.replace(final_file)
|
|
56
|
+
except Exception:
|
|
57
|
+
if temp_file.exists():
|
|
58
|
+
temp_file.unlink(missing_ok=True)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main() -> None:
|
|
62
|
+
if len(sys.argv) < 2:
|
|
63
|
+
sys.stderr.write("link_capture: missing target command name\n")
|
|
64
|
+
sys.exit(1)
|
|
65
|
+
cmd_name = sys.argv[1]
|
|
66
|
+
cmd_args = sys.argv[2:]
|
|
67
|
+
link = _find_link_argument(cmd_args)
|
|
68
|
+
if link:
|
|
69
|
+
_write_capture_event(cmd_name, cmd_args, link)
|
|
70
|
+
# Never run a real browser; capture and exit successfully.
|
|
71
|
+
sys.exit(0)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
main()
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Categorized logging system for Portacode CLI.
|
|
3
|
+
|
|
4
|
+
This module provides a logging system that allows filtering by categories
|
|
5
|
+
to help developers focus on specific aspects of the application during debugging.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Set, Optional
|
|
12
|
+
from enum import Enum
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LogCategory(Enum):
|
|
16
|
+
"""Available log categories for filtering."""
|
|
17
|
+
CONNECTION = "connection"
|
|
18
|
+
AUTHENTICATION = "auth"
|
|
19
|
+
WEBSOCKET = "websocket"
|
|
20
|
+
TERMINAL = "terminal"
|
|
21
|
+
PROJECT_STATE = "project_state"
|
|
22
|
+
FILE_SYSTEM = "filesystem"
|
|
23
|
+
GIT = "git"
|
|
24
|
+
HANDLERS = "handlers"
|
|
25
|
+
MULTIPLEXER = "mux"
|
|
26
|
+
SYSTEM = "system"
|
|
27
|
+
DEBUG = "debug"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CategorizedLogger:
|
|
31
|
+
"""
|
|
32
|
+
A logger that supports category-based filtering.
|
|
33
|
+
|
|
34
|
+
This wraps the standard Python logger but adds category support
|
|
35
|
+
for fine-grained filtering during debugging.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, name: str, enabled_categories: Optional[Set[str]] = None):
|
|
39
|
+
self.logger = logging.getLogger(name)
|
|
40
|
+
self.enabled_categories = enabled_categories or set()
|
|
41
|
+
self._all_enabled = len(self.enabled_categories) == 0 # If no categories specified, show all
|
|
42
|
+
|
|
43
|
+
def _should_log(self, category: LogCategory) -> bool:
|
|
44
|
+
"""Check if a log with this category should be output."""
|
|
45
|
+
if self._all_enabled:
|
|
46
|
+
return True
|
|
47
|
+
return category.value in self.enabled_categories
|
|
48
|
+
|
|
49
|
+
def _format_message(self, msg: str, category: LogCategory) -> str:
|
|
50
|
+
"""Format message with category prefix."""
|
|
51
|
+
return f"[{category.value.upper()}] {msg}"
|
|
52
|
+
|
|
53
|
+
def _parse_args(self, *args) -> tuple[LogCategory, tuple]:
|
|
54
|
+
"""Parse arguments to extract category and remaining args."""
|
|
55
|
+
if args and isinstance(args[0], LogCategory):
|
|
56
|
+
return args[0], args[1:]
|
|
57
|
+
else:
|
|
58
|
+
return LogCategory.DEBUG, args
|
|
59
|
+
|
|
60
|
+
def debug(self, msg: str, *args, **kwargs):
|
|
61
|
+
"""Log debug message with optional category."""
|
|
62
|
+
category, remaining_args = self._parse_args(*args)
|
|
63
|
+
if self._should_log(category):
|
|
64
|
+
formatted_msg = self._format_message(msg, category)
|
|
65
|
+
self.logger.debug(formatted_msg, *remaining_args, **kwargs)
|
|
66
|
+
|
|
67
|
+
def info(self, msg: str, *args, **kwargs):
|
|
68
|
+
"""Log info message with optional category."""
|
|
69
|
+
category, remaining_args = self._parse_args(*args)
|
|
70
|
+
if self._should_log(category):
|
|
71
|
+
formatted_msg = self._format_message(msg, category)
|
|
72
|
+
self.logger.info(formatted_msg, *remaining_args, **kwargs)
|
|
73
|
+
|
|
74
|
+
def warning(self, msg: str, *args, **kwargs):
|
|
75
|
+
"""Log warning message with optional category."""
|
|
76
|
+
category, remaining_args = self._parse_args(*args)
|
|
77
|
+
if self._should_log(category):
|
|
78
|
+
formatted_msg = self._format_message(msg, category)
|
|
79
|
+
self.logger.warning(formatted_msg, *remaining_args, **kwargs)
|
|
80
|
+
|
|
81
|
+
def error(self, msg: str, *args, **kwargs):
|
|
82
|
+
"""Log error message with optional category."""
|
|
83
|
+
category, remaining_args = self._parse_args(*args)
|
|
84
|
+
if self._should_log(category):
|
|
85
|
+
formatted_msg = self._format_message(msg, category)
|
|
86
|
+
self.logger.error(formatted_msg, *remaining_args, **kwargs)
|
|
87
|
+
|
|
88
|
+
def exception(self, msg: str, *args, **kwargs):
|
|
89
|
+
"""Log exception message with optional category."""
|
|
90
|
+
category, remaining_args = self._parse_args(*args)
|
|
91
|
+
if self._should_log(category):
|
|
92
|
+
formatted_msg = self._format_message(msg, category)
|
|
93
|
+
self.logger.exception(formatted_msg, *remaining_args, **kwargs)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# Global registry of enabled categories
|
|
97
|
+
_enabled_categories: Set[str] = set()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def configure_logging_categories(categories: Set[str]) -> None:
|
|
101
|
+
"""Configure which log categories should be enabled globally."""
|
|
102
|
+
global _enabled_categories
|
|
103
|
+
_enabled_categories = categories
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def get_categorized_logger(name: str) -> CategorizedLogger:
|
|
107
|
+
"""Get a categorized logger instance with current category settings."""
|
|
108
|
+
return CategorizedLogger(name, _enabled_categories)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def list_available_categories() -> list[str]:
|
|
112
|
+
"""Return a list of all available log categories."""
|
|
113
|
+
return [category.value for category in LogCategory]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def parse_category_string(category_str: str) -> Set[str]:
|
|
117
|
+
"""
|
|
118
|
+
Parse a comma-separated string of categories into a set.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
category_str: Comma-separated category names (e.g., "connection,auth,git")
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Set of valid category names
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
ValueError: If any category is invalid
|
|
128
|
+
"""
|
|
129
|
+
if not category_str.strip():
|
|
130
|
+
return set()
|
|
131
|
+
|
|
132
|
+
categories = {cat.strip().lower() for cat in category_str.split(',')}
|
|
133
|
+
valid_categories = {cat.value for cat in LogCategory}
|
|
134
|
+
|
|
135
|
+
invalid = categories - valid_categories
|
|
136
|
+
if invalid:
|
|
137
|
+
raise ValueError(f"Invalid categories: {', '.join(invalid)}. "
|
|
138
|
+
f"Valid categories: {', '.join(sorted(valid_categories))}")
|
|
139
|
+
|
|
140
|
+
return categories
|
portacode/pairing.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import socket
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import websockets
|
|
11
|
+
|
|
12
|
+
PAIRING_URL_ENV = "PORTACODE_PAIRING_URL"
|
|
13
|
+
DEFAULT_PAIRING_URL = "wss://portacode.com/ws/pairing/device/"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PairingError(Exception):
|
|
17
|
+
"""Raised when pairing fails or is rejected."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class PairingResult:
|
|
22
|
+
status: str
|
|
23
|
+
payload: dict
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def _pair_device(
|
|
27
|
+
url: str,
|
|
28
|
+
public_key_b64: str,
|
|
29
|
+
pairing_code: str,
|
|
30
|
+
device_name: str,
|
|
31
|
+
project_paths: list[str] | None = None,
|
|
32
|
+
timeout: float = 300.0,
|
|
33
|
+
) -> PairingResult:
|
|
34
|
+
async with websockets.connect(url) as ws:
|
|
35
|
+
# Initial ready/event
|
|
36
|
+
try:
|
|
37
|
+
ready = await asyncio.wait_for(ws.recv(), timeout=10)
|
|
38
|
+
except asyncio.TimeoutError as exc:
|
|
39
|
+
raise PairingError("Gateway did not acknowledge pairing request") from exc
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
data = json.loads(ready)
|
|
43
|
+
if data.get("event") != "pairing_ready":
|
|
44
|
+
raise PairingError(f"Unexpected response from pairing gateway: {ready}")
|
|
45
|
+
except json.JSONDecodeError:
|
|
46
|
+
raise PairingError(f"Unexpected response from pairing gateway: {ready}") from None
|
|
47
|
+
|
|
48
|
+
payload = {
|
|
49
|
+
"code": pairing_code,
|
|
50
|
+
"device_name": device_name,
|
|
51
|
+
"public_key": public_key_b64,
|
|
52
|
+
}
|
|
53
|
+
if project_paths:
|
|
54
|
+
payload["project_paths"] = project_paths
|
|
55
|
+
await ws.send(json.dumps(payload))
|
|
56
|
+
|
|
57
|
+
while True:
|
|
58
|
+
try:
|
|
59
|
+
message = await asyncio.wait_for(ws.recv(), timeout=timeout)
|
|
60
|
+
except asyncio.TimeoutError as exc:
|
|
61
|
+
raise PairingError("Pairing timed out waiting for approval") from exc
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
data = json.loads(message)
|
|
65
|
+
except json.JSONDecodeError:
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
if data.get("event") != "pairing_status":
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
status = data.get("status")
|
|
72
|
+
if status == "pending":
|
|
73
|
+
continue
|
|
74
|
+
if status == "approved":
|
|
75
|
+
return PairingResult(status="approved", payload=data)
|
|
76
|
+
reason = data.get("reason") or status or "unknown_error"
|
|
77
|
+
raise PairingError(reason)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def pair_device_with_code(
|
|
81
|
+
keypair,
|
|
82
|
+
pairing_code: str,
|
|
83
|
+
device_name: Optional[str] = None,
|
|
84
|
+
project_paths: list[str] | None = None,
|
|
85
|
+
*,
|
|
86
|
+
timeout: float = 300.0,
|
|
87
|
+
) -> PairingResult:
|
|
88
|
+
"""Run the pairing workflow synchronously."""
|
|
89
|
+
pairing_url = os.getenv(PAIRING_URL_ENV, DEFAULT_PAIRING_URL)
|
|
90
|
+
normalized_url = pairing_url if pairing_url.startswith("ws") else f"wss://{pairing_url.lstrip('/')}"
|
|
91
|
+
friendly_name = device_name or socket.gethostname() or "Portacode Device"
|
|
92
|
+
|
|
93
|
+
public_key_b64 = keypair.public_key_der_b64()
|
|
94
|
+
return asyncio.run(
|
|
95
|
+
_pair_device(
|
|
96
|
+
normalized_url,
|
|
97
|
+
public_key_b64=public_key_b64,
|
|
98
|
+
pairing_code=pairing_code,
|
|
99
|
+
device_name=friendly_name,
|
|
100
|
+
project_paths=project_paths,
|
|
101
|
+
timeout=timeout,
|
|
102
|
+
)
|
|
103
|
+
)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>NTP Clock Test</title>
|
|
5
|
+
<style>
|
|
6
|
+
body {
|
|
7
|
+
font-family: monospace;
|
|
8
|
+
padding: 20px;
|
|
9
|
+
background: #1e1e1e;
|
|
10
|
+
color: #d4d4d4;
|
|
11
|
+
}
|
|
12
|
+
h1 {
|
|
13
|
+
color: #4ec9b0;
|
|
14
|
+
}
|
|
15
|
+
#status {
|
|
16
|
+
background: #252526;
|
|
17
|
+
padding: 15px;
|
|
18
|
+
border-radius: 5px;
|
|
19
|
+
border: 1px solid #3e3e42;
|
|
20
|
+
}
|
|
21
|
+
.synced {
|
|
22
|
+
color: #4ec9b0;
|
|
23
|
+
}
|
|
24
|
+
.not-synced {
|
|
25
|
+
color: #f48771;
|
|
26
|
+
}
|
|
27
|
+
.label {
|
|
28
|
+
color: #9cdcfe;
|
|
29
|
+
font-weight: bold;
|
|
30
|
+
}
|
|
31
|
+
</style>
|
|
32
|
+
</head>
|
|
33
|
+
<body>
|
|
34
|
+
<h1>NTP Clock Test - time.cloudflare.com</h1>
|
|
35
|
+
<div id="status"></div>
|
|
36
|
+
<script type="module">
|
|
37
|
+
import ntpClock from './utils/ntp-clock.js';
|
|
38
|
+
|
|
39
|
+
function updateDisplay() {
|
|
40
|
+
const status = ntpClock.getStatus();
|
|
41
|
+
const syncClass = status.isSynced ? 'synced' : 'not-synced';
|
|
42
|
+
|
|
43
|
+
document.getElementById('status').innerHTML = `
|
|
44
|
+
<p><span class="label">Sync Status:</span> <span class="${syncClass}">${status.isSynced ? '✅ SYNCED' : '❌ NOT SYNCED'}</span></p>
|
|
45
|
+
<p><span class="label">NTP Server:</span> ${status.server}</p>
|
|
46
|
+
<p><span class="label">NTP Time:</span> ${ntpClock.nowISO() || 'null (not synced)'}</p>
|
|
47
|
+
<p><span class="label">Local Time:</span> ${new Date().toISOString()}</p>
|
|
48
|
+
<p><span class="label">Offset:</span> ${status.offset !== null ? status.offset.toFixed(2) + 'ms' : 'null'}</p>
|
|
49
|
+
<p><span class="label">Last Sync:</span> ${status.lastSync || 'Never'}</p>
|
|
50
|
+
<p><span class="label">Time Since Sync:</span> ${status.timeSinceSync ? (status.timeSinceSync / 1000).toFixed(0) + 's' : 'N/A'}</p>
|
|
51
|
+
`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Update display every 100ms
|
|
55
|
+
setInterval(updateDisplay, 100);
|
|
56
|
+
|
|
57
|
+
// Log status to console every 5 seconds
|
|
58
|
+
setInterval(() => {
|
|
59
|
+
console.log('NTP Clock Status:', ntpClock.getStatus());
|
|
60
|
+
}, 5000);
|
|
61
|
+
</script>
|
|
62
|
+
</body>
|
|
63
|
+
</html>
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NTP Clock - Synchronized time source for distributed tracing
|
|
3
|
+
*
|
|
4
|
+
* Provides synchronization by requesting the server clock across the WebSocket
|
|
5
|
+
* channel defined in `WEBSOCKET_PROTOCOL`. The client measures round-trip time
|
|
6
|
+
* and applies an offset that compensates for latency before exposing timestamps.
|
|
7
|
+
*/
|
|
8
|
+
class NTPClock {
|
|
9
|
+
constructor() {
|
|
10
|
+
this.ntpServer = 'portacode-gateway';
|
|
11
|
+
this.offset = null;
|
|
12
|
+
this.lastSync = null;
|
|
13
|
+
this.lastLatencyMs = null;
|
|
14
|
+
this.syncInterval = 60 * 1000;
|
|
15
|
+
this.clockSyncTimeout = 20 * 1000;
|
|
16
|
+
this.maxSyncFailures = 3;
|
|
17
|
+
|
|
18
|
+
this._clockSyncSender = null;
|
|
19
|
+
this._failureCallback = null;
|
|
20
|
+
this._clockSyncTimer = null;
|
|
21
|
+
this._clockSyncTimeoutHandle = null;
|
|
22
|
+
this._pendingRequest = null;
|
|
23
|
+
this._autoSyncStarted = false;
|
|
24
|
+
this._clockSyncFailures = 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Register the function responsible for piping `clock_sync_request`
|
|
29
|
+
* packets over the dashboard WebSocket.
|
|
30
|
+
*/
|
|
31
|
+
setClockSyncSender(sender) {
|
|
32
|
+
this._clockSyncSender = sender;
|
|
33
|
+
if (this._autoSyncStarted && !this._pendingRequest) {
|
|
34
|
+
this._scheduleSync(0);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Callback invoked when multiple sync failures occur in a row.
|
|
40
|
+
* Useful for triggering a transport reconnect.
|
|
41
|
+
*/
|
|
42
|
+
onClockSyncFailure(callback) {
|
|
43
|
+
this._failureCallback = callback;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Start the auto-sync loop (idempotent). Will skip sending until a sender
|
|
48
|
+
* has been registered.
|
|
49
|
+
*/
|
|
50
|
+
startAutoSync() {
|
|
51
|
+
if (this._autoSyncStarted) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
this._autoSyncStarted = true;
|
|
55
|
+
this._scheduleSync(0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Stop the auto-sync loop (used primarily in tests).
|
|
60
|
+
*/
|
|
61
|
+
stopAutoSync() {
|
|
62
|
+
this._autoSyncStarted = false;
|
|
63
|
+
if (this._clockSyncTimer) {
|
|
64
|
+
clearTimeout(this._clockSyncTimer);
|
|
65
|
+
this._clockSyncTimer = null;
|
|
66
|
+
}
|
|
67
|
+
this._clearPendingRequest();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
sync() {
|
|
71
|
+
return this._performClockSync();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get current NTP-synchronized timestamp in milliseconds since epoch
|
|
76
|
+
* Returns null if not synced
|
|
77
|
+
*/
|
|
78
|
+
now() {
|
|
79
|
+
if (this.offset === null) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return Date.now() + this.offset;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Get current NTP-synchronized timestamp in ISO format
|
|
87
|
+
* Returns null if not synced
|
|
88
|
+
*/
|
|
89
|
+
nowISO() {
|
|
90
|
+
const ts = this.now();
|
|
91
|
+
if (ts === null) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
return new Date(ts).toISOString();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
handleServerSync(payload) {
|
|
98
|
+
if (!payload) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const receiveTime = Date.now();
|
|
102
|
+
let roundTrip = 0;
|
|
103
|
+
if (
|
|
104
|
+
payload.request_id &&
|
|
105
|
+
this._pendingRequest &&
|
|
106
|
+
payload.request_id === this._pendingRequest.requestId
|
|
107
|
+
) {
|
|
108
|
+
roundTrip = Math.max(receiveTime - this._pendingRequest.sentAt, 0);
|
|
109
|
+
this._clockSyncFailures = 0;
|
|
110
|
+
this._clearPendingRequest();
|
|
111
|
+
}
|
|
112
|
+
const serverSend = typeof payload.server_send_time === 'number' ? payload.server_send_time : payload.server_time;
|
|
113
|
+
const serverReceive = payload.server_receive_time;
|
|
114
|
+
const serverAvg = (typeof serverReceive === 'number' && typeof serverSend === 'number')
|
|
115
|
+
? (serverReceive + serverSend) / 2
|
|
116
|
+
: typeof serverSend === 'number'
|
|
117
|
+
? serverSend
|
|
118
|
+
: undefined;
|
|
119
|
+
if (typeof serverAvg === 'number') {
|
|
120
|
+
this.updateFromServer(serverAvg, roundTrip);
|
|
121
|
+
}
|
|
122
|
+
if (payload.server_time_iso) {
|
|
123
|
+
this.serverTimeIso = payload.server_time_iso;
|
|
124
|
+
}
|
|
125
|
+
this._scheduleSync(this.syncInterval);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
applyServerSync(payload) {
|
|
129
|
+
this.handleServerSync(payload);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
updateFromServer(serverTimeMs, roundTripMs = 0) {
|
|
133
|
+
const receiveTime = Date.now();
|
|
134
|
+
const halfLatency = (roundTripMs || 0) / 2;
|
|
135
|
+
const estimatedServerReceived = receiveTime - halfLatency;
|
|
136
|
+
this.offset = serverTimeMs - estimatedServerReceived;
|
|
137
|
+
this.lastLatencyMs = roundTripMs;
|
|
138
|
+
this.lastSync = receiveTime;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
getStatus() {
|
|
142
|
+
return {
|
|
143
|
+
server: this.ntpServer,
|
|
144
|
+
offset: this.offset,
|
|
145
|
+
lastSync: this.lastSync ? new Date(this.lastSync).toISOString() : null,
|
|
146
|
+
lastLatencyMs: this.lastLatencyMs,
|
|
147
|
+
timeSinceSync: this.lastSync ? Date.now() - this.lastSync : null,
|
|
148
|
+
isSynced: this.offset !== null
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
_scheduleSync(delay) {
|
|
153
|
+
if (!this._autoSyncStarted) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (this._clockSyncTimer) {
|
|
157
|
+
clearTimeout(this._clockSyncTimer);
|
|
158
|
+
}
|
|
159
|
+
this._clockSyncTimer = setTimeout(() => this._performClockSync(), delay);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
_performClockSync() {
|
|
163
|
+
if (!this._autoSyncStarted) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
if (!this._clockSyncSender) {
|
|
167
|
+
this._scheduleSync(Math.min(this.syncInterval, 3000));
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
if (this._pendingRequest) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
const requestId = this._generateRequestId();
|
|
174
|
+
const payload = {
|
|
175
|
+
event: 'clock_sync_request',
|
|
176
|
+
request_id: requestId,
|
|
177
|
+
};
|
|
178
|
+
this._pendingRequest = {
|
|
179
|
+
requestId,
|
|
180
|
+
sentAt: Date.now(),
|
|
181
|
+
};
|
|
182
|
+
const sent = this._clockSyncSender(payload);
|
|
183
|
+
if (!sent) {
|
|
184
|
+
this._handleClockSyncFailure();
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
this._clockSyncTimeoutHandle = setTimeout(() => this._handleClockSyncTimeout(), this.clockSyncTimeout);
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
_handleClockSyncTimeout() {
|
|
192
|
+
this._clockSyncFailures += 1;
|
|
193
|
+
this._clearPendingRequest();
|
|
194
|
+
if (
|
|
195
|
+
this._clockSyncFailures >= this.maxSyncFailures &&
|
|
196
|
+
typeof this._failureCallback === 'function'
|
|
197
|
+
) {
|
|
198
|
+
this._failureCallback();
|
|
199
|
+
}
|
|
200
|
+
this._scheduleSync(this.syncInterval);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
_handleClockSyncFailure() {
|
|
204
|
+
this._clockSyncFailures += 1;
|
|
205
|
+
this._clearPendingRequest();
|
|
206
|
+
if (
|
|
207
|
+
this._clockSyncFailures >= this.maxSyncFailures &&
|
|
208
|
+
typeof this._failureCallback === 'function'
|
|
209
|
+
) {
|
|
210
|
+
this._failureCallback();
|
|
211
|
+
}
|
|
212
|
+
this._scheduleSync(this.syncInterval);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
_clearPendingRequest() {
|
|
216
|
+
if (this._clockSyncTimeoutHandle) {
|
|
217
|
+
clearTimeout(this._clockSyncTimeoutHandle);
|
|
218
|
+
this._clockSyncTimeoutHandle = null;
|
|
219
|
+
}
|
|
220
|
+
this._pendingRequest = null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
_generateRequestId() {
|
|
224
|
+
return `clock_sync:${Date.now()}:${Math.floor(Math.random() * 1000000)}`;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Global instance - auto-starts sync
|
|
229
|
+
const ntpClock = new NTPClock();
|
|
230
|
+
ntpClock.startAutoSync();
|
|
231
|
+
|
|
232
|
+
export default ntpClock;
|