pycodex-ws 0.4.1__tar.gz → 0.4.3__tar.gz
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.
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/PKG-INFO +2 -2
- pycodex_ws-0.4.3/pycodex_router/browser.py +107 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/pycodex_router/client.py +10 -4
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/pycodex_router/index.html +170 -33
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/pycodex_router/protocol.py +27 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/pycodex_router/server.py +202 -25
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/pyproject.toml +2 -2
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/app.py +39 -1
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/workspace.html +69 -4
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/workspaces.html +87 -20
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/workspaces.py +16 -0
- pycodex_ws-0.4.1/pycodex_router/browser.py +0 -175
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/.gitignore +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/LICENSE +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/README.md +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/pycodex_router/__init__.py +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/pycodex_router/__main__.py +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/pycodex_ws/__init__.py +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/pycodex_ws/feishu_card.py +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/pycodex_ws/feishu_link.py +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/pycodex_ws/feishu_oauth.py +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/__init__.py +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/__main__.py +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/content_views.LICENSE.txt +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/content_views.css +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/content_views.mjs +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/editor.css +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/editor.mjs +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/frontend/.gitignore +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/frontend/build.mjs +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/frontend/composer.mjs +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/frontend/content_views.css +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/frontend/content_views.mjs +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/frontend/editor.css +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/frontend/editor.mjs +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/frontend/editor_context.mjs +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/frontend/package-lock.json +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/frontend/package.json +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/frontend/view_labels.mjs +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/owned_process.py +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/workspace_views.py +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: pycodex-ws
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.3
|
|
4
4
|
Summary: Browser workspace and optional Feishu integration for python-codex
|
|
5
5
|
License-File: LICENSE
|
|
6
6
|
Requires-Python: >=3.7
|
|
7
7
|
Requires-Dist: pillow>=9.0.0
|
|
8
|
-
Requires-Dist: python-codex==0.4.
|
|
8
|
+
Requires-Dist: python-codex==0.4.3
|
|
9
9
|
Requires-Dist: websockets<16,>=11
|
|
10
10
|
Description-Content-Type: text/markdown
|
|
11
11
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Browser access checks for trusted devices sharing a router origin."""
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import re
|
|
5
|
+
from urllib.parse import urlencode, urlsplit
|
|
6
|
+
|
|
7
|
+
from starlette.exceptions import HTTPException
|
|
8
|
+
from starlette.requests import HTTPConnection
|
|
9
|
+
from starlette.responses import JSONResponse, RedirectResponse
|
|
10
|
+
|
|
11
|
+
from .protocol import route_path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def request_origin(connection):
|
|
15
|
+
try:
|
|
16
|
+
parsed = urlsplit("//" + connection.headers["host"])
|
|
17
|
+
port = parsed.port
|
|
18
|
+
except ValueError:
|
|
19
|
+
raise HTTPException(400, "Invalid router host.")
|
|
20
|
+
host = parsed.hostname
|
|
21
|
+
if (
|
|
22
|
+
not host
|
|
23
|
+
or parsed.username is not None
|
|
24
|
+
or parsed.password is not None
|
|
25
|
+
or parsed.path
|
|
26
|
+
or parsed.query
|
|
27
|
+
or parsed.fragment
|
|
28
|
+
):
|
|
29
|
+
raise HTTPException(400, "Invalid router host.")
|
|
30
|
+
try:
|
|
31
|
+
ipaddress.ip_address(host)
|
|
32
|
+
except ValueError:
|
|
33
|
+
if len(host) > 253 or any(
|
|
34
|
+
not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label)
|
|
35
|
+
for label in host.split(".")
|
|
36
|
+
):
|
|
37
|
+
raise HTTPException(400, "Invalid router host.")
|
|
38
|
+
scheme = "https" if connection.scope["scheme"] in {"https", "wss"} else "http"
|
|
39
|
+
suffix = "" if port in (None, 443 if scheme == "https" else 80) else ":" + str(port)
|
|
40
|
+
authority = "[" + host + "]" if ":" in host else host
|
|
41
|
+
return scheme + "://" + authority + suffix
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class BrowserAccess:
|
|
45
|
+
def __init__(self, router, app):
|
|
46
|
+
self.router = router
|
|
47
|
+
self.app = app
|
|
48
|
+
|
|
49
|
+
async def __call__(self, scope, receive, send):
|
|
50
|
+
connection = HTTPConnection(scope)
|
|
51
|
+
origin = request_origin(connection)
|
|
52
|
+
|
|
53
|
+
async def secured_send(message):
|
|
54
|
+
if message["type"] == "http.response.start":
|
|
55
|
+
message = dict(
|
|
56
|
+
message,
|
|
57
|
+
headers=list(message["headers"])
|
|
58
|
+
+ [
|
|
59
|
+
(b"content-security-policy", b"frame-ancestors 'self'"),
|
|
60
|
+
(b"x-frame-options", b"SAMEORIGIN"),
|
|
61
|
+
(b"x-content-type-options", b"nosniff"),
|
|
62
|
+
(b"referrer-policy", b"no-referrer"),
|
|
63
|
+
(b"permissions-policy", b"document-domain=()"),
|
|
64
|
+
(b"origin-agent-cluster", b"?1"),
|
|
65
|
+
],
|
|
66
|
+
)
|
|
67
|
+
await send(message)
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
if connection.client.host in self.router.blocked_ips:
|
|
71
|
+
raise HTTPException(
|
|
72
|
+
403,
|
|
73
|
+
"Too many incorrect passwords. This IP is blocked "
|
|
74
|
+
"until the router restarts.",
|
|
75
|
+
)
|
|
76
|
+
if connection.headers.get("origin") not in (None, origin):
|
|
77
|
+
raise HTTPException(
|
|
78
|
+
403, "Cross-origin router requests are not allowed."
|
|
79
|
+
)
|
|
80
|
+
await self.app(scope, receive, secured_send)
|
|
81
|
+
except HTTPException as exc:
|
|
82
|
+
if scope["type"] == "websocket":
|
|
83
|
+
await send({"type": "websocket.close", "code": 1008})
|
|
84
|
+
else:
|
|
85
|
+
await JSONResponse({"error": exc.detail}, exc.status_code)(
|
|
86
|
+
scope, receive, secured_send
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def require_login(scope, receive, send):
|
|
91
|
+
connection = HTTPConnection(scope)
|
|
92
|
+
if scope["type"] == "websocket":
|
|
93
|
+
await send({"type": "websocket.close", "code": 1008})
|
|
94
|
+
elif "/api/" in route_path(scope) or scope["method"] not in {"GET", "HEAD"}:
|
|
95
|
+
await JSONResponse({"error": "authentication required"}, 401)(
|
|
96
|
+
scope, receive, send
|
|
97
|
+
)
|
|
98
|
+
else:
|
|
99
|
+
node_id = scope["path_params"]["node_id"]
|
|
100
|
+
prefix = scope.get("root_path", "")
|
|
101
|
+
root = prefix[: -len("/n/" + node_id)]
|
|
102
|
+
target = prefix + route_path(scope)
|
|
103
|
+
if connection.url.query:
|
|
104
|
+
target += "?" + connection.url.query
|
|
105
|
+
await RedirectResponse(
|
|
106
|
+
root + "/login?" + urlencode({"next": target}), 303
|
|
107
|
+
)(scope, receive, send)
|
|
@@ -4,9 +4,10 @@ import secrets
|
|
|
4
4
|
import socket
|
|
5
5
|
from contextlib import asynccontextmanager
|
|
6
6
|
|
|
7
|
-
from websockets.exceptions import ConnectionClosed, InvalidHandshake
|
|
7
|
+
from websockets.exceptions import ConnectionClosed, InvalidHandshake, InvalidStatusCode
|
|
8
8
|
from websockets.legacy.client import connect
|
|
9
|
-
|
|
9
|
+
|
|
10
|
+
from .protocol import decode, device_id, encode, parse_target, send_event, tunnel_url
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
class NodeIdInUse(Exception):
|
|
@@ -25,8 +26,9 @@ class RouterClient:
|
|
|
25
26
|
reconnect_seconds=1.0,
|
|
26
27
|
):
|
|
27
28
|
self.app = app
|
|
28
|
-
self.
|
|
29
|
-
self.
|
|
29
|
+
address, self._registration_key = parse_target(target)
|
|
30
|
+
self.control_url = tunnel_url(address, "control")
|
|
31
|
+
self.data_url = tunnel_url(address, "data")
|
|
30
32
|
self._identity_key = secrets.token_hex(32)
|
|
31
33
|
self.node_id = device_id(self._identity_key)
|
|
32
34
|
self.name = name
|
|
@@ -43,6 +45,7 @@ class RouterClient:
|
|
|
43
45
|
def _connect(self, url):
|
|
44
46
|
return connect(
|
|
45
47
|
url,
|
|
48
|
+
extra_headers={"Authorization": "Bearer " + self._registration_key},
|
|
46
49
|
open_timeout=10,
|
|
47
50
|
close_timeout=2,
|
|
48
51
|
ping_interval=20,
|
|
@@ -101,6 +104,8 @@ class RouterClient:
|
|
|
101
104
|
)
|
|
102
105
|
if isinstance(failure, ConnectionClosed) and failure.rcvd is not None:
|
|
103
106
|
reason += ", close_code={0}".format(failure.rcvd.code)
|
|
107
|
+
elif isinstance(failure, InvalidStatusCode):
|
|
108
|
+
reason += ", status_code={0}".format(failure.status_code)
|
|
104
109
|
self._logger.warning(
|
|
105
110
|
"Router node %s disconnected (%s); retrying in %g seconds",
|
|
106
111
|
self.name,
|
|
@@ -230,6 +235,7 @@ def attach_router(app, registry, target, port):
|
|
|
230
235
|
"enable_board": workspace["enable_board"],
|
|
231
236
|
"session_count": len(workspace["sessions"]),
|
|
232
237
|
"running": any(session["running"] for session in workspace["sessions"]),
|
|
238
|
+
"unread": any(session["unread"] for session in workspace["sessions"]),
|
|
233
239
|
}
|
|
234
240
|
for workspace in registry.list_workspaces()
|
|
235
241
|
]
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
* { box-sizing: border-box; }
|
|
37
37
|
body { margin: 0; min-height: 100vh; background: var(--bg); color: var(--ink); font-size: 13px; }
|
|
38
38
|
a { color: inherit; text-decoration: none; }
|
|
39
|
-
a:focus-visible { outline: 2px solid var(--accent); outline-offset: 5px; border-radius: 3px; }
|
|
39
|
+
a:focus-visible, .device-label:focus-visible { outline: 2px solid var(--accent); outline-offset: 5px; border-radius: 3px; }
|
|
40
40
|
svg { width: 20px; height: 20px; flex: 0 0 auto; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
|
|
41
|
-
h1, h2, h3, p { margin: 0; }
|
|
41
|
+
h1, h2, h3, h4, p { margin: 0; }
|
|
42
42
|
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
43
43
|
.page { width: min(1440px, calc(100% - 48px)); margin: 0 auto; padding-bottom: 32px; }
|
|
44
44
|
.topbar { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 16px 0; border-bottom: 1px solid var(--line); }
|
|
@@ -55,36 +55,58 @@
|
|
|
55
55
|
.intro { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 18px; padding: 24px 0; }
|
|
56
56
|
h1 { font-size: 24px; line-height: 1.3; font-weight: 650; letter-spacing: -.7px; }
|
|
57
57
|
.intro p { color: var(--muted); font-size: 12px; line-height: 1.6; }
|
|
58
|
+
.registration { max-width: 640px; margin-bottom: 24px; }
|
|
59
|
+
.registration summary { width: fit-content; color: var(--accent); cursor: pointer; }
|
|
60
|
+
.registration-content { display: grid; gap: 8px; margin-top: 12px; }
|
|
61
|
+
.registration input { width: 100%; min-width: 0; padding: 8px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); color: var(--ink); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
62
|
+
.registration p { color: var(--muted); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
|
|
63
|
+
#alias-status { margin-bottom: 12px; color: var(--danger); font-size: 12px; }
|
|
64
|
+
#alias-status:empty { display: none; }
|
|
58
65
|
.layout { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: 20px; align-items: start; }
|
|
59
66
|
.layout > * { min-width: 0; }
|
|
60
67
|
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
|
|
61
68
|
h2 { display: flex; align-items: center; gap: 9px; font-size: 13px; font-weight: 600; }
|
|
62
69
|
.count { min-width: 23px; padding: 2px 7px; border-radius: 6px; background: var(--soft); color: var(--muted); font-size: 11px; text-align: center; font-variant-numeric: tabular-nums; }
|
|
63
70
|
#activity { color: var(--green); font-size: 12px; }
|
|
71
|
+
#workspaces { display: grid; gap: 16px; }
|
|
64
72
|
.workspace-table, .nodes { background: var(--panel); border: 1px solid var(--line); border-radius: 9px; }
|
|
65
|
-
.
|
|
66
|
-
.
|
|
67
|
-
.workspace-
|
|
73
|
+
.workspace-group-heading { display: flex; align-items: center; gap: 10px; padding: 12px 14px; }
|
|
74
|
+
.workspace-group-name { flex: 1; min-width: 0; font-size: 12px; font-weight: 600; line-height: 1.6; overflow-wrap: anywhere; }
|
|
75
|
+
.workspace-group-heading .count { flex: 0 0 auto; }
|
|
76
|
+
.list-head, .workspace-row { display: grid; grid-template-columns: minmax(94px, .85fr) minmax(120px, 1.85fr) 76px 44px; gap: 12px; align-items: center; padding: 10px 14px; }
|
|
77
|
+
.list-head { position: sticky; top: 0; z-index: 1; background: var(--soft); color: var(--muted); font-size: 10px; font-weight: 550; letter-spacing: .3px; }
|
|
78
|
+
.workspace-row { position: relative; min-height: 62px; border-top: 1px solid var(--line); }
|
|
68
79
|
.workspace-row > * { min-width: 0; }
|
|
69
80
|
.workspace-row:hover { background: var(--soft); }
|
|
70
81
|
.workspace-row:last-child { border-radius: 0 0 9px 9px; }
|
|
71
|
-
.activity-badge {
|
|
82
|
+
.activity-badge { display: inline-flex; align-items: center; gap: 5px; padding: 3px 6px; border-radius: 4px; color: var(--muted); background: var(--soft); font-size: 10px; font-weight: 500; }
|
|
72
83
|
.activity-badge[data-state="working"] { color: var(--green); background: var(--green-soft); }
|
|
73
|
-
.activity-badge[data-state="working"]::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
|
|
84
|
+
.activity-badge[data-state="working"]::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; animation: spinner-pulse 1.2s cubic-bezier(0.4, 0, 0.2, 1) infinite; }
|
|
85
|
+
@keyframes spinner-pulse {
|
|
86
|
+
0%, 100% { opacity: .45; transform: scale(.82); }
|
|
87
|
+
50% { opacity: 1; transform: scale(1.08); }
|
|
88
|
+
}
|
|
89
|
+
@media (prefers-reduced-motion: reduce) {
|
|
90
|
+
.activity-badge[data-state="working"]::before { animation: none; opacity: .8; transform: none; }
|
|
91
|
+
}
|
|
92
|
+
.workspace-status { display: inline-flex; align-items: center; gap: 6px; justify-self: start; }
|
|
93
|
+
.unread-dot { flex: 0 0 6px; width: 6px; height: 6px; border-radius: 50%; background: var(--danger); }
|
|
74
94
|
.workspace-title { font-size: 12px; line-height: 1.6; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
75
95
|
.workspace-path, .state-path { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; line-height: 1.8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
76
96
|
.state-path { color: var(--muted); }
|
|
77
|
-
.workspace-device { color: var(--muted); }
|
|
78
|
-
.workspace-device code { display: block; min-width: 0; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
79
97
|
.session-count { color: var(--muted); font-size: 11px; line-height: 1.7; }
|
|
80
98
|
.open-link, .manage-link { display: inline-flex; align-items: center; justify-content: flex-end; gap: 4px; min-height: 28px; color: var(--accent); font-size: 11px; font-weight: 550; white-space: nowrap; }
|
|
81
99
|
.open-link svg, .manage-link svg { width: 13px; height: 13px; }
|
|
100
|
+
.open-link::after { content: ""; position: absolute; inset: 0; }
|
|
82
101
|
.open-link:hover, .manage-link:hover { text-decoration: underline; text-underline-offset: 4px; }
|
|
83
102
|
.device { min-width: 0; padding: 12px 14px; }
|
|
84
103
|
.device + .device { border-top: 1px solid var(--line); }
|
|
85
104
|
.device-heading { display: flex; align-items: start; gap: 9px; }
|
|
86
105
|
.device-heading svg { width: 17px; height: 17px; margin-top: 1px; color: var(--muted); }
|
|
87
106
|
.device-name { flex: 1; min-width: 0; font-size: 11px; font-weight: 500; line-height: 1.6; overflow-wrap: anywhere; }
|
|
107
|
+
.device-label { display: block; width: 100%; padding: 0; border: 0; background: none; color: inherit; font: inherit; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; text-align: left; overflow-wrap: anywhere; cursor: text; }
|
|
108
|
+
.device-label[hidden] { display: none; }
|
|
109
|
+
.device-alias-input { width: 100%; min-width: 0; padding: 3px 6px; border: 1px solid var(--accent); border-radius: 4px; background: var(--panel); color: var(--ink); font: inherit; outline: none; }
|
|
88
110
|
.online-dot { margin-top: 7px; }
|
|
89
111
|
.device-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 2px 8px; margin-top: 6px; }
|
|
90
112
|
.device-detail { color: var(--muted); font-size: 10px; line-height: 1.5; }
|
|
@@ -104,10 +126,8 @@
|
|
|
104
126
|
.intro { padding: 20px 0; }
|
|
105
127
|
.list-head { display: none; }
|
|
106
128
|
.workspace-row { grid-template-columns: minmax(0, 1fr) auto auto; gap: 6px 10px; padding: 10px 12px; }
|
|
107
|
-
.workspace-row:first-child { border-top: 0; border-radius: 9px 9px 0 0; }
|
|
108
129
|
.workspace-paths { grid-column: 1 / -1; grid-row: 2; }
|
|
109
|
-
.workspace-
|
|
110
|
-
.activity-badge { grid-column: 2; grid-row: 1; }
|
|
130
|
+
.workspace-status { grid-column: 2; grid-row: 1; }
|
|
111
131
|
.open-link { grid-column: 3; grid-row: 1; min-height: 36px; }
|
|
112
132
|
}
|
|
113
133
|
</style>
|
|
@@ -130,19 +150,24 @@
|
|
|
130
150
|
<h1>Your workspaces</h1>
|
|
131
151
|
<p>Across your connected devices.</p>
|
|
132
152
|
</div>
|
|
153
|
+
<details id="device-connection" class="registration">
|
|
154
|
+
<summary>Connect a trusted device</summary>
|
|
155
|
+
<div class="registration-content">
|
|
156
|
+
<label for="registration-key">Registration key</label>
|
|
157
|
+
<input id="registration-key" readonly autocomplete="off" spellcheck="false">
|
|
158
|
+
<p id="registration-status" role="status"></p>
|
|
159
|
+
<p>On the device, use <code>pycodex-ws --listen-to KEY@ROUTER_ADDRESS:6000</code>. Replace <code>KEY</code> with this key and use your router's device port.</p>
|
|
160
|
+
</div>
|
|
161
|
+
</details>
|
|
162
|
+
<p id="alias-status" role="alert"></p>
|
|
133
163
|
<div class="layout">
|
|
134
164
|
<section aria-labelledby="workspace-heading">
|
|
135
165
|
<div class="section-heading">
|
|
136
166
|
<h2 id="workspace-heading">Workspaces <span id="workspace-count" class="count">—</span></h2>
|
|
137
167
|
<span id="activity"></span>
|
|
138
168
|
</div>
|
|
139
|
-
<div
|
|
140
|
-
<div class="
|
|
141
|
-
<span>Workspace</span><span>Directory / state</span><span>Device</span><span>Status</span><span></span>
|
|
142
|
-
</div>
|
|
143
|
-
<div id="workspaces" aria-busy="true">
|
|
144
|
-
<div class="empty"><h3>Loading workspaces…</h3></div>
|
|
145
|
-
</div>
|
|
169
|
+
<div id="workspaces" aria-busy="true">
|
|
170
|
+
<div class="empty"><h3>Loading workspaces…</h3></div>
|
|
146
171
|
</div>
|
|
147
172
|
</section>
|
|
148
173
|
<aside aria-labelledby="device-heading">
|
|
@@ -154,20 +179,32 @@
|
|
|
154
179
|
</div>
|
|
155
180
|
</main>
|
|
156
181
|
</div>
|
|
182
|
+
<template id="workspace-group-template">
|
|
183
|
+
<section class="workspace-table workspace-group">
|
|
184
|
+
<header class="workspace-group-heading">
|
|
185
|
+
<h3 class="workspace-group-name"><button type="button" class="device-label"></button></h3>
|
|
186
|
+
<span class="count"></span>
|
|
187
|
+
</header>
|
|
188
|
+
<div class="list-head" aria-hidden="true">
|
|
189
|
+
<span>Workspace</span><span>Directory / state</span><span>Status</span><span></span>
|
|
190
|
+
</div>
|
|
191
|
+
<div class="workspace-group-list"></div>
|
|
192
|
+
</section>
|
|
193
|
+
</template>
|
|
157
194
|
<template id="workspace-template">
|
|
158
195
|
<article class="workspace-row">
|
|
159
196
|
<div class="workspace-main">
|
|
160
|
-
<
|
|
197
|
+
<h4 class="workspace-title"></h4>
|
|
161
198
|
<span class="session-count"></span>
|
|
162
199
|
</div>
|
|
163
200
|
<div class="workspace-paths">
|
|
164
201
|
<p class="workspace-path"></p>
|
|
165
202
|
<p class="state-path"></p>
|
|
166
203
|
</div>
|
|
167
|
-
<
|
|
168
|
-
<
|
|
169
|
-
|
|
170
|
-
|
|
204
|
+
<span class="workspace-status">
|
|
205
|
+
<span class="unread-dot" role="img" aria-label="Unread reply" title="Unread reply"></span>
|
|
206
|
+
<span class="activity-badge"></span>
|
|
207
|
+
</span>
|
|
171
208
|
<a class="open-link">Open <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14m-6-6 6 6-6 6"/></svg></a>
|
|
172
209
|
</article>
|
|
173
210
|
</template>
|
|
@@ -175,7 +212,7 @@
|
|
|
175
212
|
<article class="device">
|
|
176
213
|
<div class="device-heading">
|
|
177
214
|
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8m-4-4v4"/></svg>
|
|
178
|
-
<h3 class="device-name"><
|
|
215
|
+
<h3 class="device-name"><button type="button" class="device-label"></button></h3>
|
|
179
216
|
<span class="online-dot" aria-label="Online"></span>
|
|
180
217
|
</div>
|
|
181
218
|
<div class="device-footer">
|
|
@@ -198,11 +235,36 @@
|
|
|
198
235
|
const layout = document.querySelector(".layout");
|
|
199
236
|
const workspaces = document.getElementById("workspaces");
|
|
200
237
|
const nodes = document.getElementById("nodes");
|
|
238
|
+
const workspaceGroupTemplate = document.getElementById("workspace-group-template");
|
|
201
239
|
const workspaceTemplate = document.getElementById("workspace-template");
|
|
202
240
|
const deviceTemplate = document.getElementById("device-template");
|
|
203
241
|
const emptyTemplate = document.getElementById("empty-template");
|
|
242
|
+
const deviceConnection = document.getElementById("device-connection");
|
|
243
|
+
const registrationKey = document.getElementById("registration-key");
|
|
244
|
+
const registrationStatus = document.getElementById("registration-status");
|
|
245
|
+
const aliasStatus = document.getElementById("alias-status");
|
|
204
246
|
let refreshing = false;
|
|
205
247
|
let signature = "";
|
|
248
|
+
let editingDevice = null;
|
|
249
|
+
let aliasRevision = 0;
|
|
250
|
+
|
|
251
|
+
registrationKey.addEventListener("focus", () => registrationKey.select());
|
|
252
|
+
deviceConnection.addEventListener("toggle", async () => {
|
|
253
|
+
registrationKey.value = "";
|
|
254
|
+
registrationStatus.textContent = "";
|
|
255
|
+
if (!deviceConnection.open) return;
|
|
256
|
+
registrationStatus.textContent = "Loading…";
|
|
257
|
+
try {
|
|
258
|
+
const response = await fetch("api/registration-key", {cache: "no-store"});
|
|
259
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
260
|
+
const data = await response.json();
|
|
261
|
+
if (!deviceConnection.open) return;
|
|
262
|
+
registrationKey.value = data.key;
|
|
263
|
+
registrationStatus.textContent = "Select the key to copy it.";
|
|
264
|
+
} catch (error) {
|
|
265
|
+
if (deviceConnection.open) registrationStatus.textContent = `Could not load key · ${error.message}`;
|
|
266
|
+
}
|
|
267
|
+
});
|
|
206
268
|
|
|
207
269
|
function countLabel(count, label) {
|
|
208
270
|
return `${count} ${label}${count === 1 ? "" : "s"}`;
|
|
@@ -221,30 +283,104 @@
|
|
|
221
283
|
layout.dataset.stale = String(state === "error");
|
|
222
284
|
}
|
|
223
285
|
|
|
286
|
+
function bindDeviceLabel(label, node, data) {
|
|
287
|
+
label.textContent = node.alias || node.name;
|
|
288
|
+
label.title = `${node.name}\nClick to rename device`;
|
|
289
|
+
label.setAttribute("aria-label", `Rename device ${node.alias || node.name}`);
|
|
290
|
+
label.addEventListener("click", () => editDeviceAlias(label, node, data));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function editDeviceAlias(label, node, data) {
|
|
294
|
+
if (editingDevice !== null) return;
|
|
295
|
+
editingDevice = node.id;
|
|
296
|
+
aliasStatus.textContent = "";
|
|
297
|
+
const input = document.createElement("input");
|
|
298
|
+
input.type = "text";
|
|
299
|
+
input.className = "device-alias-input";
|
|
300
|
+
input.maxLength = 128;
|
|
301
|
+
input.setAttribute("aria-label", "Device alias");
|
|
302
|
+
input.title = "Leave empty to use the original device name.";
|
|
303
|
+
input.value = node.alias || "";
|
|
304
|
+
input.placeholder = node.name;
|
|
305
|
+
label.hidden = true;
|
|
306
|
+
label.before(input);
|
|
307
|
+
let finished = false;
|
|
308
|
+
const finish = async save => {
|
|
309
|
+
if (finished) return;
|
|
310
|
+
finished = true;
|
|
311
|
+
const alias = input.value.trim();
|
|
312
|
+
input.remove();
|
|
313
|
+
label.hidden = false;
|
|
314
|
+
try {
|
|
315
|
+
if (save && alias !== (node.alias || "")) {
|
|
316
|
+
const response = await fetch(`api/nodes/${encodeURIComponent(node.id)}/alias`, {
|
|
317
|
+
method: "POST",
|
|
318
|
+
headers: {"Content-Type": "application/json"},
|
|
319
|
+
body: JSON.stringify({alias}),
|
|
320
|
+
});
|
|
321
|
+
if (!response.ok) throw new Error(await response.text());
|
|
322
|
+
const result = await response.json();
|
|
323
|
+
node.alias = result.alias;
|
|
324
|
+
aliasRevision += 1;
|
|
325
|
+
render(data);
|
|
326
|
+
signature = JSON.stringify(data);
|
|
327
|
+
}
|
|
328
|
+
} catch (error) {
|
|
329
|
+
aliasStatus.textContent = `Could not rename device: ${error.message}`;
|
|
330
|
+
} finally {
|
|
331
|
+
editingDevice = null;
|
|
332
|
+
refresh();
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
input.addEventListener("keydown", event => {
|
|
336
|
+
if (event.isComposing) return;
|
|
337
|
+
if (event.key === "Enter" || event.key === "Escape") {
|
|
338
|
+
event.preventDefault();
|
|
339
|
+
finish(event.key === "Enter");
|
|
340
|
+
label.focus();
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
input.addEventListener("blur", () => finish(true));
|
|
344
|
+
input.focus();
|
|
345
|
+
input.select();
|
|
346
|
+
}
|
|
347
|
+
|
|
224
348
|
function render(data) {
|
|
225
349
|
const workspaceList = document.createDocumentFragment();
|
|
350
|
+
const groups = new Map();
|
|
351
|
+
for (const node of data.nodes) {
|
|
352
|
+
const group = workspaceGroupTemplate.content.firstElementChild.cloneNode(true);
|
|
353
|
+
group.setAttribute("aria-label", node.alias || node.name);
|
|
354
|
+
bindDeviceLabel(group.querySelector(".device-label"), node, data);
|
|
355
|
+
groups.set(node.id, {node, group});
|
|
356
|
+
}
|
|
226
357
|
for (const workspace of data.workspaces) {
|
|
227
358
|
const card = workspaceTemplate.content.firstElementChild.cloneNode(true);
|
|
228
359
|
const title = card.querySelector(".workspace-title");
|
|
229
360
|
title.textContent = workspace.id;
|
|
230
361
|
title.title = workspace.id;
|
|
362
|
+
card.querySelector(".unread-dot").hidden = !workspace.unread;
|
|
231
363
|
const path = card.querySelector(".workspace-path");
|
|
232
364
|
path.textContent = workspace.work_dir;
|
|
233
365
|
path.title = workspace.work_dir;
|
|
234
366
|
const statePath = card.querySelector(".state-path");
|
|
235
367
|
statePath.textContent = workspace.state_path;
|
|
236
368
|
statePath.title = workspace.state_path;
|
|
237
|
-
const device = card.querySelector(".workspace-device code");
|
|
238
|
-
device.textContent = workspace.node_name;
|
|
239
|
-
device.title = workspace.node_name;
|
|
240
369
|
const activity = card.querySelector(".activity-badge");
|
|
241
370
|
activity.dataset.state = workspace.running ? "working" : "ready";
|
|
242
371
|
activity.textContent = workspace.running ? "Working" : "Ready";
|
|
243
372
|
card.querySelector(".session-count").textContent = countLabel(workspace.session_count, "session");
|
|
244
373
|
const link = card.querySelector(".open-link");
|
|
245
374
|
link.href = workspace.url;
|
|
246
|
-
|
|
247
|
-
|
|
375
|
+
const {node, group} = groups.get(workspace.node_id);
|
|
376
|
+
link.setAttribute("aria-label", `Open ${workspace.id} on ${node.alias || node.name}`);
|
|
377
|
+
group.querySelector(".workspace-group-list").append(card);
|
|
378
|
+
}
|
|
379
|
+
for (const {group} of groups.values()) {
|
|
380
|
+
const count = group.querySelector(".workspace-group-list").childElementCount;
|
|
381
|
+
if (!count) continue;
|
|
382
|
+
group.querySelector(".count").textContent = countLabel(count, "workspace");
|
|
383
|
+
workspaceList.append(group);
|
|
248
384
|
}
|
|
249
385
|
if (!data.workspaces.length) {
|
|
250
386
|
workspaceList.append(empty("No workspaces yet", data.nodes.length
|
|
@@ -255,13 +391,13 @@
|
|
|
255
391
|
const nodeList = document.createDocumentFragment();
|
|
256
392
|
for (const node of data.nodes) {
|
|
257
393
|
const card = deviceTemplate.content.firstElementChild.cloneNode(true);
|
|
258
|
-
card.querySelector(".device-
|
|
394
|
+
bindDeviceLabel(card.querySelector(".device-label"), node, data);
|
|
259
395
|
const owned = data.workspaces.filter(workspace => workspace.node_id === node.id);
|
|
260
396
|
const sessions = owned.reduce((total, workspace) => total + workspace.session_count, 0);
|
|
261
397
|
card.querySelector(".device-detail").textContent = `${countLabel(owned.length, "workspace")} · ${countLabel(sessions, "session")}`;
|
|
262
398
|
const link = card.querySelector(".manage-link");
|
|
263
399
|
link.href = node.url;
|
|
264
|
-
link.setAttribute("aria-label", `Manage workspaces on ${node.name}`);
|
|
400
|
+
link.setAttribute("aria-label", `Manage workspaces on ${node.alias || node.name}`);
|
|
265
401
|
nodeList.append(card);
|
|
266
402
|
}
|
|
267
403
|
if (!data.nodes.length) {
|
|
@@ -278,6 +414,7 @@
|
|
|
278
414
|
async function refresh() {
|
|
279
415
|
if (refreshing) return;
|
|
280
416
|
refreshing = true;
|
|
417
|
+
const revision = aliasRevision;
|
|
281
418
|
try {
|
|
282
419
|
const response = await fetch("api/workspaces", {cache: "no-store"});
|
|
283
420
|
if (response.status === 401) {
|
|
@@ -287,7 +424,7 @@
|
|
|
287
424
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
288
425
|
const data = await response.json();
|
|
289
426
|
const next = JSON.stringify(data);
|
|
290
|
-
if (next !== signature) {
|
|
427
|
+
if (editingDevice === null && revision === aliasRevision && next !== signature) {
|
|
291
428
|
render(data);
|
|
292
429
|
signature = next;
|
|
293
430
|
}
|
|
@@ -48,6 +48,33 @@ def tunnel_url(target, endpoint):
|
|
|
48
48
|
)
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
def parse_target(target):
|
|
52
|
+
"""Separate a registration key from the address used on the wire."""
|
|
53
|
+
try:
|
|
54
|
+
has_scheme = "://" in target
|
|
55
|
+
parsed = urlsplit(target if has_scheme else "ws://" + target)
|
|
56
|
+
key = parsed.username
|
|
57
|
+
if (
|
|
58
|
+
not key
|
|
59
|
+
or not re.fullmatch("[1-9A-HJ-NP-Za-km-z]{1,22}", key)
|
|
60
|
+
or parsed.password is not None
|
|
61
|
+
):
|
|
62
|
+
raise ValueError("invalid registration key")
|
|
63
|
+
authority = parsed.netloc.rsplit("@", 1)[1]
|
|
64
|
+
if not has_scheme and parsed.port is None:
|
|
65
|
+
authority += ":6000"
|
|
66
|
+
address = urlunsplit(
|
|
67
|
+
(parsed.scheme, authority, parsed.path, parsed.query, parsed.fragment)
|
|
68
|
+
)
|
|
69
|
+
tunnel_url(address, "control")
|
|
70
|
+
except ValueError:
|
|
71
|
+
raise ValueError(
|
|
72
|
+
"router address must be KEY@host[:port] or ws(s)://KEY@host[:port], "
|
|
73
|
+
"using the router-generated registration key"
|
|
74
|
+
) from None
|
|
75
|
+
return address, key
|
|
76
|
+
|
|
77
|
+
|
|
51
78
|
def encode(value):
|
|
52
79
|
def binary(item):
|
|
53
80
|
if isinstance(item, bytes):
|