pycodex-ws 0.4.4__tar.gz → 0.4.6__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.4 → pycodex_ws-0.4.6}/PKG-INFO +29 -2
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/README.md +27 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/pycodex_router/index.html +279 -47
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/pycodex_router/server.py +41 -8
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/pyproject.toml +2 -2
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/app.py +46 -9
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/workspace.html +6 -2
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/.gitignore +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/LICENSE +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/pycodex_router/__init__.py +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/pycodex_router/__main__.py +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/pycodex_router/browser.py +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/pycodex_router/client.py +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/pycodex_router/protocol.py +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/pycodex_ws/__init__.py +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/pycodex_ws/feishu_card.py +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/pycodex_ws/feishu_link.py +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/pycodex_ws/feishu_oauth.py +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/__init__.py +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/__main__.py +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/content_views.LICENSE.txt +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/content_views.css +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/content_views.mjs +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/editor.css +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/editor.mjs +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/frontend/.gitignore +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/frontend/build.mjs +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/frontend/composer.mjs +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/frontend/content_views.css +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/frontend/content_views.mjs +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/frontend/editor.css +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/frontend/editor.mjs +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/frontend/editor_context.mjs +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/frontend/package-lock.json +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/frontend/package.json +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/frontend/touch_selection.mjs +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/frontend/view_labels.mjs +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/owned_process.py +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/workspace_views.py +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/workspaces.html +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/workspaces.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.6
|
|
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.6
|
|
9
9
|
Requires-Dist: websockets<16,>=11
|
|
10
10
|
Description-Content-Type: text/markdown
|
|
11
11
|
|
|
@@ -95,6 +95,33 @@ The resolved `state_path` identifies the workspace; two entries cannot share it.
|
|
|
95
95
|
Creating a workspace through the management page or API without a prefix
|
|
96
96
|
generates a `/tmp/pcws-*` prefix and saves it in the configuration.
|
|
97
97
|
|
|
98
|
+
The router directory collects unread workspace shortcuts and their device names
|
|
99
|
+
in a distinct Unread panel at the top. Read workspaces leave that panel.
|
|
100
|
+
Drag a handle in Online devices to reorder devices; both lists share the saved
|
|
101
|
+
order. The focused handle also supports the up and down arrow keys. Escape or
|
|
102
|
+
dropping outside the list cancels a drag. On touch screens, drag the handle;
|
|
103
|
+
the rest of the card still scrolls normally. List updates wait until the drag
|
|
104
|
+
and save finish.
|
|
105
|
+
The router saves the order in the existing `CODEX_HOME/router-aliases.json`,
|
|
106
|
+
shared by all browsers and retained across router restarts. Its device entry
|
|
107
|
+
order is the display order; an empty alias means to show the original name.
|
|
108
|
+
Existing alias files remain readable. Offline device positions are
|
|
109
|
+
remembered, newly registered devices go last, and each device's workspaces keep
|
|
110
|
+
their configuration order. Reordering requires router login; if connected
|
|
111
|
+
devices change during a drag, refresh and try again.
|
|
112
|
+
Device groups start collapsed, retain their expanded state during updates, and
|
|
113
|
+
show Manage and the number of working workspaces in their headings. Click a
|
|
114
|
+
device heading or its name to expand or collapse the group. To rename a device,
|
|
115
|
+
use its pencil button in the Online devices list, then press Enter or Save;
|
|
116
|
+
Escape or Cancel discards the edit. Leaving the field does not save it.
|
|
117
|
+
Aliases must be nonblank and contain 1 to 128 characters.
|
|
118
|
+
Device headings and the Online devices list share an unread dot
|
|
119
|
+
whenever any of that device's workspaces is unread. Final replies and failed
|
|
120
|
+
turns both trigger shared unread dots; viewing the reply or error marks it as read.
|
|
121
|
+
|
|
122
|
+
Login pages state whether you are logging in to the router or workspace service
|
|
123
|
+
and show the server hostname and access address.
|
|
124
|
+
|
|
98
125
|
`enable_board` defaults to `true`. Setting it to `false` disables the Canvas
|
|
99
126
|
tab, HTML/image/status routes, polling and Canvas context injection. Sessions
|
|
100
127
|
and file views still save and restore from the same prefix, so changing this
|
|
@@ -84,6 +84,33 @@ The resolved `state_path` identifies the workspace; two entries cannot share it.
|
|
|
84
84
|
Creating a workspace through the management page or API without a prefix
|
|
85
85
|
generates a `/tmp/pcws-*` prefix and saves it in the configuration.
|
|
86
86
|
|
|
87
|
+
The router directory collects unread workspace shortcuts and their device names
|
|
88
|
+
in a distinct Unread panel at the top. Read workspaces leave that panel.
|
|
89
|
+
Drag a handle in Online devices to reorder devices; both lists share the saved
|
|
90
|
+
order. The focused handle also supports the up and down arrow keys. Escape or
|
|
91
|
+
dropping outside the list cancels a drag. On touch screens, drag the handle;
|
|
92
|
+
the rest of the card still scrolls normally. List updates wait until the drag
|
|
93
|
+
and save finish.
|
|
94
|
+
The router saves the order in the existing `CODEX_HOME/router-aliases.json`,
|
|
95
|
+
shared by all browsers and retained across router restarts. Its device entry
|
|
96
|
+
order is the display order; an empty alias means to show the original name.
|
|
97
|
+
Existing alias files remain readable. Offline device positions are
|
|
98
|
+
remembered, newly registered devices go last, and each device's workspaces keep
|
|
99
|
+
their configuration order. Reordering requires router login; if connected
|
|
100
|
+
devices change during a drag, refresh and try again.
|
|
101
|
+
Device groups start collapsed, retain their expanded state during updates, and
|
|
102
|
+
show Manage and the number of working workspaces in their headings. Click a
|
|
103
|
+
device heading or its name to expand or collapse the group. To rename a device,
|
|
104
|
+
use its pencil button in the Online devices list, then press Enter or Save;
|
|
105
|
+
Escape or Cancel discards the edit. Leaving the field does not save it.
|
|
106
|
+
Aliases must be nonblank and contain 1 to 128 characters.
|
|
107
|
+
Device headings and the Online devices list share an unread dot
|
|
108
|
+
whenever any of that device's workspaces is unread. Final replies and failed
|
|
109
|
+
turns both trigger shared unread dots; viewing the reply or error marks it as read.
|
|
110
|
+
|
|
111
|
+
Login pages state whether you are logging in to the router or workspace service
|
|
112
|
+
and show the server hostname and access address.
|
|
113
|
+
|
|
87
114
|
`enable_board` defaults to `true`. Setting it to `false` disables the Canvas
|
|
88
115
|
tab, HTML/image/status routes, polling and Canvas context injection. Sessions
|
|
89
116
|
and file views still save and restore from the same prefix, so changing this
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
--green: #26734f;
|
|
18
18
|
--green-soft: #eaf5ee;
|
|
19
19
|
--danger: #b54739;
|
|
20
|
+
--unread-bg: #fff7f4;
|
|
21
|
+
--unread-line: #ead0c8;
|
|
20
22
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
21
23
|
}
|
|
22
24
|
@media (prefers-color-scheme: dark) {
|
|
@@ -31,12 +33,14 @@
|
|
|
31
33
|
--green: #8ad5ad;
|
|
32
34
|
--green-soft: #233b31;
|
|
33
35
|
--danger: #ffa396;
|
|
36
|
+
--unread-bg: #2b2221;
|
|
37
|
+
--unread-line: #62433d;
|
|
34
38
|
}
|
|
35
39
|
}
|
|
36
40
|
* { box-sizing: border-box; }
|
|
37
41
|
body { margin: 0; min-height: 100vh; background: var(--bg); color: var(--ink); font-size: 13px; }
|
|
38
42
|
a { color: inherit; text-decoration: none; }
|
|
39
|
-
a:focus-visible, .
|
|
43
|
+
a:focus-visible, button:focus-visible, .workspace-group-heading:focus-visible { outline: 2px solid var(--accent); outline-offset: 5px; border-radius: 3px; }
|
|
40
44
|
svg { width: 20px; height: 20px; flex: 0 0 auto; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
|
|
41
45
|
h1, h2, h3, h4, p { margin: 0; }
|
|
42
46
|
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
@@ -60,8 +64,8 @@
|
|
|
60
64
|
.registration-content { display: grid; gap: 8px; margin-top: 12px; }
|
|
61
65
|
.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
66
|
.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; }
|
|
67
|
+
#alias-status, #order-status { margin-bottom: 12px; color: var(--danger); font-size: 12px; }
|
|
68
|
+
#alias-status:empty, #order-status:empty { display: none; }
|
|
65
69
|
.layout { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: 20px; align-items: start; }
|
|
66
70
|
.layout > * { min-width: 0; }
|
|
67
71
|
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
|
|
@@ -70,7 +74,10 @@
|
|
|
70
74
|
#activity { color: var(--green); font-size: 12px; }
|
|
71
75
|
#workspaces { display: grid; gap: 16px; }
|
|
72
76
|
.workspace-table, .nodes { background: var(--panel); border: 1px solid var(--line); border-radius: 9px; }
|
|
73
|
-
.workspace-group-heading { display: flex; align-items: center; gap: 10px; padding: 12px 14px; }
|
|
77
|
+
.workspace-group-heading { display: flex; align-items: center; gap: 10px; padding: 12px 14px; cursor: pointer; list-style: none; }
|
|
78
|
+
.workspace-group-heading::-webkit-details-marker { display: none; }
|
|
79
|
+
.disclosure-icon { width: 14px; height: 14px; color: var(--muted); }
|
|
80
|
+
.workspace-group[open] .disclosure-icon { transform: rotate(90deg); }
|
|
74
81
|
.workspace-group-name { flex: 1; min-width: 0; font-size: 12px; font-weight: 600; line-height: 1.6; overflow-wrap: anywhere; }
|
|
75
82
|
.workspace-group-heading .count { flex: 0 0 auto; }
|
|
76
83
|
.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; }
|
|
@@ -79,7 +86,16 @@
|
|
|
79
86
|
.workspace-row > * { min-width: 0; }
|
|
80
87
|
.workspace-row:hover { background: var(--soft); }
|
|
81
88
|
.workspace-row:last-child { border-radius: 0 0 9px 9px; }
|
|
89
|
+
#unread-workspaces { background: var(--unread-bg); border-color: var(--unread-line); border-left: 3px solid var(--danger); }
|
|
90
|
+
.unread-heading { display: flex; align-items: center; gap: 9px; padding: 14px; color: var(--danger); }
|
|
91
|
+
.unread-heading svg { width: 17px; height: 17px; }
|
|
92
|
+
.unread-heading h3 { flex: 1; font-size: 13px; font-weight: 600; }
|
|
93
|
+
.unread-heading .count { color: var(--panel); background: var(--danger); }
|
|
94
|
+
#unread-workspaces .workspace-row { border-color: var(--unread-line); }
|
|
95
|
+
#unread-workspaces .workspace-row:hover { background: var(--panel); }
|
|
82
96
|
.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; }
|
|
97
|
+
.activity-badge[hidden] { display: none; }
|
|
98
|
+
.workspace-group-name .device-working { margin-left: 8px; white-space: nowrap; }
|
|
83
99
|
.activity-badge[data-state="working"] { color: var(--green); background: var(--green-soft); }
|
|
84
100
|
.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
101
|
@keyframes spinner-pulse {
|
|
@@ -94,7 +110,7 @@
|
|
|
94
110
|
.workspace-title { font-size: 12px; line-height: 1.6; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
95
111
|
.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; }
|
|
96
112
|
.state-path { color: var(--muted); }
|
|
97
|
-
.session-count { color: var(--muted); font-size: 11px; line-height: 1.7; }
|
|
113
|
+
.session-count { display: block; color: var(--muted); font-size: 11px; line-height: 1.7; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
98
114
|
.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; }
|
|
99
115
|
.open-link svg, .manage-link svg { width: 13px; height: 13px; }
|
|
100
116
|
.open-link::after { content: ""; position: absolute; inset: 0; }
|
|
@@ -104,10 +120,21 @@
|
|
|
104
120
|
.device-heading { display: flex; align-items: start; gap: 9px; }
|
|
105
121
|
.device-heading svg { width: 17px; height: 17px; margin-top: 1px; color: var(--muted); }
|
|
106
122
|
.device-name { flex: 1; min-width: 0; font-size: 11px; font-weight: 500; line-height: 1.6; overflow-wrap: anywhere; }
|
|
107
|
-
.device-label {
|
|
108
|
-
.device-
|
|
123
|
+
.device-label { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
124
|
+
.device-rename, .device-drag, .device-alias-actions button { display: inline-flex; align-items: center; justify-content: center; min-height: 28px; padding: 4px 8px; border: 1px solid var(--line); border-radius: 4px; background: var(--panel); color: var(--muted); font: inherit; cursor: pointer; }
|
|
125
|
+
.device-rename:hover, .device-drag:hover, .device-alias-actions button:hover { background: var(--soft); color: var(--ink); }
|
|
126
|
+
.device-rename, .device-drag { flex: 0 0 28px; padding: 4px; border-color: transparent; }
|
|
127
|
+
.device-rename[hidden] { display: none; }
|
|
128
|
+
.device-rename svg, .device-drag svg { width: 14px; height: 14px; margin: 0; color: inherit; }
|
|
129
|
+
.device-drag { touch-action: none; user-select: none; cursor: grab; }
|
|
130
|
+
.device-drag:disabled { opacity: .35; cursor: default; }
|
|
131
|
+
.device[data-dragging] { background: var(--soft); opacity: .65; }
|
|
132
|
+
.device-drop-before { box-shadow: 0 -2px var(--accent); }
|
|
133
|
+
.nodes.device-drop-end { box-shadow: inset 0 -2px var(--accent); }
|
|
109
134
|
.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; }
|
|
135
|
+
.device-alias-actions { display: flex; gap: 6px; margin-top: 6px; }
|
|
110
136
|
.online-dot { margin-top: 7px; }
|
|
137
|
+
.device-heading > .unread-dot { margin-top: 7px; }
|
|
111
138
|
.device-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 2px 8px; margin-top: 6px; }
|
|
112
139
|
.device-detail { color: var(--muted); font-size: 10px; line-height: 1.5; }
|
|
113
140
|
.empty { grid-column: 1 / -1; display: grid; justify-items: center; gap: 8px; padding: 32px 20px; text-align: center; }
|
|
@@ -129,6 +156,8 @@
|
|
|
129
156
|
.workspace-paths { grid-column: 1 / -1; grid-row: 2; }
|
|
130
157
|
.workspace-status { grid-column: 2; grid-row: 1; }
|
|
131
158
|
.open-link { grid-column: 3; grid-row: 1; min-height: 36px; }
|
|
159
|
+
.device-rename, .device-drag, .device-alias-actions button { min-height: 36px; }
|
|
160
|
+
.device-rename, .device-drag { flex-basis: 36px; }
|
|
132
161
|
}
|
|
133
162
|
</style>
|
|
134
163
|
</head>
|
|
@@ -160,6 +189,7 @@
|
|
|
160
189
|
</div>
|
|
161
190
|
</details>
|
|
162
191
|
<p id="alias-status" role="alert"></p>
|
|
192
|
+
<p id="order-status" role="alert"></p>
|
|
163
193
|
<div class="layout">
|
|
164
194
|
<section aria-labelledby="workspace-heading">
|
|
165
195
|
<div class="section-heading">
|
|
@@ -179,17 +209,33 @@
|
|
|
179
209
|
</div>
|
|
180
210
|
</main>
|
|
181
211
|
</div>
|
|
182
|
-
<template id="
|
|
183
|
-
<section class="workspace-table
|
|
184
|
-
<header class="
|
|
185
|
-
<
|
|
212
|
+
<template id="unread-template">
|
|
213
|
+
<section id="unread-workspaces" class="workspace-table" aria-labelledby="unread-heading">
|
|
214
|
+
<header class="unread-heading">
|
|
215
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9m-8 12h4"/></svg>
|
|
216
|
+
<h3 id="unread-heading">Unread</h3>
|
|
186
217
|
<span class="count"></span>
|
|
187
218
|
</header>
|
|
219
|
+
<div class="workspace-group-list"></div>
|
|
220
|
+
</section>
|
|
221
|
+
</template>
|
|
222
|
+
<template id="workspace-group-template">
|
|
223
|
+
<details class="workspace-table workspace-group">
|
|
224
|
+
<summary class="workspace-group-heading">
|
|
225
|
+
<svg class="disclosure-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m9 6 6 6-6 6"/></svg>
|
|
226
|
+
<h3 class="workspace-group-name">
|
|
227
|
+
<span class="device-label"></span>
|
|
228
|
+
<span class="activity-badge device-working" data-state="working" hidden></span>
|
|
229
|
+
</h3>
|
|
230
|
+
<span class="unread-dot device-unread" hidden role="img" aria-label="Unread workspaces" title="Unread workspaces"></span>
|
|
231
|
+
<span class="count"></span>
|
|
232
|
+
<a class="manage-link">Manage <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14m-6-6 6 6-6 6"/></svg></a>
|
|
233
|
+
</summary>
|
|
188
234
|
<div class="list-head" aria-hidden="true">
|
|
189
235
|
<span>Workspace</span><span>Directory / state</span><span>Status</span><span></span>
|
|
190
236
|
</div>
|
|
191
237
|
<div class="workspace-group-list"></div>
|
|
192
|
-
</
|
|
238
|
+
</details>
|
|
193
239
|
</template>
|
|
194
240
|
<template id="workspace-template">
|
|
195
241
|
<article class="workspace-row">
|
|
@@ -211,16 +257,31 @@
|
|
|
211
257
|
<template id="device-template">
|
|
212
258
|
<article class="device">
|
|
213
259
|
<div class="device-heading">
|
|
214
|
-
<
|
|
215
|
-
|
|
260
|
+
<button type="button" class="device-drag" title="Drag to reorder, or use the up and down arrow keys">
|
|
261
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5h.01M15 5h.01M9 12h.01M15 12h.01M9 19h.01M15 19h.01" stroke-width="3"/></svg>
|
|
262
|
+
</button>
|
|
263
|
+
<h3 class="device-name"><span class="device-label"></span></h3>
|
|
264
|
+
<button type="button" class="device-rename" title="Rename device">
|
|
265
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m16 4 4 4M4 20l4-1L20 7a2.1 2.1 0 0 0-3-3L5 16l-1 4Z"/></svg>
|
|
266
|
+
</button>
|
|
267
|
+
<span class="unread-dot device-unread" hidden role="img" aria-label="Unread workspaces" title="Unread workspaces"></span>
|
|
216
268
|
<span class="online-dot" aria-label="Online"></span>
|
|
217
269
|
</div>
|
|
218
270
|
<div class="device-footer">
|
|
219
271
|
<p class="device-detail"></p>
|
|
220
|
-
<
|
|
272
|
+
<span class="activity-badge device-working" data-state="working" hidden></span>
|
|
221
273
|
</div>
|
|
222
274
|
</article>
|
|
223
275
|
</template>
|
|
276
|
+
<template id="device-alias-template">
|
|
277
|
+
<span class="device-alias-editor">
|
|
278
|
+
<input type="text" class="device-alias-input" minlength="1" maxlength="128" required aria-label="Device alias" title="Use 1 to 128 characters.">
|
|
279
|
+
<span class="device-alias-actions">
|
|
280
|
+
<button type="button" class="device-alias-save">Save</button>
|
|
281
|
+
<button type="button" class="device-alias-cancel">Cancel</button>
|
|
282
|
+
</span>
|
|
283
|
+
</span>
|
|
284
|
+
</template>
|
|
224
285
|
<template id="empty-template">
|
|
225
286
|
<div class="empty">
|
|
226
287
|
<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>
|
|
@@ -235,18 +296,23 @@
|
|
|
235
296
|
const layout = document.querySelector(".layout");
|
|
236
297
|
const workspaces = document.getElementById("workspaces");
|
|
237
298
|
const nodes = document.getElementById("nodes");
|
|
299
|
+
const unreadTemplate = document.getElementById("unread-template");
|
|
238
300
|
const workspaceGroupTemplate = document.getElementById("workspace-group-template");
|
|
239
301
|
const workspaceTemplate = document.getElementById("workspace-template");
|
|
240
302
|
const deviceTemplate = document.getElementById("device-template");
|
|
303
|
+
const deviceAliasTemplate = document.getElementById("device-alias-template");
|
|
241
304
|
const emptyTemplate = document.getElementById("empty-template");
|
|
242
305
|
const deviceConnection = document.getElementById("device-connection");
|
|
243
306
|
const registrationKey = document.getElementById("registration-key");
|
|
244
307
|
const registrationStatus = document.getElementById("registration-status");
|
|
245
308
|
const aliasStatus = document.getElementById("alias-status");
|
|
309
|
+
const orderStatus = document.getElementById("order-status");
|
|
246
310
|
let refreshing = false;
|
|
247
311
|
let signature = "";
|
|
248
312
|
let editingDevice = null;
|
|
249
|
-
let
|
|
313
|
+
let inventoryRevision = 0;
|
|
314
|
+
let reordering = false;
|
|
315
|
+
let drag = null;
|
|
250
316
|
|
|
251
317
|
registrationKey.addEventListener("focus", () => registrationKey.select());
|
|
252
318
|
deviceConnection.addEventListener("toggle", async () => {
|
|
@@ -283,35 +349,32 @@
|
|
|
283
349
|
layout.dataset.stale = String(state === "error");
|
|
284
350
|
}
|
|
285
351
|
|
|
286
|
-
function
|
|
352
|
+
function setDeviceLabel(label, node) {
|
|
287
353
|
label.textContent = node.alias || node.name;
|
|
288
|
-
label.title =
|
|
289
|
-
label.setAttribute("aria-label", `Rename device ${node.alias || node.name}`);
|
|
290
|
-
label.addEventListener("click", () => editDeviceAlias(label, node, data));
|
|
354
|
+
label.title = node.name;
|
|
291
355
|
}
|
|
292
356
|
|
|
293
|
-
function editDeviceAlias(label, node, data) {
|
|
294
|
-
if (editingDevice !== null) return;
|
|
357
|
+
function editDeviceAlias(label, button, node, data) {
|
|
358
|
+
if (editingDevice !== null || reordering) return;
|
|
295
359
|
editingDevice = node.id;
|
|
296
360
|
aliasStatus.textContent = "";
|
|
297
|
-
const
|
|
298
|
-
input
|
|
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.";
|
|
361
|
+
const editor = deviceAliasTemplate.content.firstElementChild.cloneNode(true);
|
|
362
|
+
const input = editor.querySelector("input");
|
|
303
363
|
input.value = node.alias || "";
|
|
304
364
|
input.placeholder = node.name;
|
|
305
365
|
label.hidden = true;
|
|
306
|
-
|
|
366
|
+
button.hidden = true;
|
|
367
|
+
label.before(editor);
|
|
307
368
|
let finished = false;
|
|
308
369
|
const finish = async save => {
|
|
309
370
|
if (finished) return;
|
|
310
371
|
finished = true;
|
|
311
372
|
const alias = input.value.trim();
|
|
312
|
-
|
|
373
|
+
editor.remove();
|
|
313
374
|
label.hidden = false;
|
|
375
|
+
button.hidden = false;
|
|
314
376
|
try {
|
|
377
|
+
if (save && !alias) throw new Error("Device alias must contain at least one character.");
|
|
315
378
|
if (save && alias !== (node.alias || "")) {
|
|
316
379
|
const response = await fetch(`api/nodes/${encodeURIComponent(node.id)}/alias`, {
|
|
317
380
|
method: "POST",
|
|
@@ -321,7 +384,7 @@
|
|
|
321
384
|
if (!response.ok) throw new Error(await response.text());
|
|
322
385
|
const result = await response.json();
|
|
323
386
|
node.alias = result.alias;
|
|
324
|
-
|
|
387
|
+
inventoryRevision += 1;
|
|
325
388
|
render(data);
|
|
326
389
|
signature = JSON.stringify(data);
|
|
327
390
|
}
|
|
@@ -329,29 +392,168 @@
|
|
|
329
392
|
aliasStatus.textContent = `Could not rename device: ${error.message}`;
|
|
330
393
|
} finally {
|
|
331
394
|
editingDevice = null;
|
|
395
|
+
nodes.querySelector(`.device[data-node-id="${CSS.escape(node.id)}"] .device-rename`).focus();
|
|
332
396
|
refresh();
|
|
333
397
|
}
|
|
334
398
|
};
|
|
335
|
-
|
|
399
|
+
editor.addEventListener("keydown", event => {
|
|
336
400
|
if (event.isComposing) return;
|
|
337
|
-
if (event.key === "
|
|
401
|
+
if (event.key === "Escape" || (event.key === "Enter" && event.target === input)) {
|
|
338
402
|
event.preventDefault();
|
|
339
403
|
finish(event.key === "Enter");
|
|
340
|
-
label.focus();
|
|
341
404
|
}
|
|
342
405
|
});
|
|
343
|
-
|
|
406
|
+
editor.querySelector(".device-alias-save").addEventListener("click", () => finish(true));
|
|
407
|
+
editor.querySelector(".device-alias-cancel").addEventListener("click", () => finish(false));
|
|
344
408
|
input.focus();
|
|
345
409
|
input.select();
|
|
346
410
|
}
|
|
347
411
|
|
|
412
|
+
async function saveDeviceOrder(data, order, nodeId) {
|
|
413
|
+
reordering = true;
|
|
414
|
+
orderStatus.textContent = "";
|
|
415
|
+
try {
|
|
416
|
+
if (order.some((id, index) => id !== data.nodes[index].id)) {
|
|
417
|
+
const response = await fetch("api/nodes/order", {
|
|
418
|
+
method: "POST",
|
|
419
|
+
headers: {"Content-Type": "application/json"},
|
|
420
|
+
body: JSON.stringify({order}),
|
|
421
|
+
});
|
|
422
|
+
if (!response.ok) throw new Error(await response.text());
|
|
423
|
+
const result = await response.json();
|
|
424
|
+
const byId = new Map(data.nodes.map(node => [node.id, node]));
|
|
425
|
+
data.nodes = result.order.map(id => byId.get(id));
|
|
426
|
+
data.workspaces = result.order.flatMap(
|
|
427
|
+
id => data.workspaces.filter(workspace => workspace.node_id === id));
|
|
428
|
+
inventoryRevision += 1;
|
|
429
|
+
render(data);
|
|
430
|
+
signature = JSON.stringify(data);
|
|
431
|
+
}
|
|
432
|
+
} catch (error) {
|
|
433
|
+
orderStatus.textContent = `Could not reorder devices: ${error.message}`;
|
|
434
|
+
} finally {
|
|
435
|
+
reordering = false;
|
|
436
|
+
nodes.querySelector(`.device[data-node-id="${CSS.escape(nodeId)}"] .device-drag`).focus({preventScroll: true});
|
|
437
|
+
refresh();
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function clearDropMarker() {
|
|
442
|
+
nodes.classList.remove("device-drop-end");
|
|
443
|
+
for (const card of nodes.querySelectorAll(".device-drop-before")) {
|
|
444
|
+
card.classList.remove("device-drop-before");
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function markDropTarget() {
|
|
449
|
+
clearDropMarker();
|
|
450
|
+
drag.before = Array.from(nodes.children).find(card => {
|
|
451
|
+
const rect = card.getBoundingClientRect();
|
|
452
|
+
return card !== drag.card && drag.y < (rect.top + rect.bottom) / 2;
|
|
453
|
+
});
|
|
454
|
+
(drag.before || nodes).classList.add(drag.before ? "device-drop-before" : "device-drop-end");
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function scrollDrag() {
|
|
458
|
+
const step = drag.y < 56 ? -12 : drag.y > innerHeight - 56 ? 12 : 0;
|
|
459
|
+
if (step) {
|
|
460
|
+
window.scrollBy(0, step);
|
|
461
|
+
markDropTarget();
|
|
462
|
+
}
|
|
463
|
+
drag.frame = requestAnimationFrame(scrollDrag);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function finishDrag(commit, event) {
|
|
467
|
+
const current = drag;
|
|
468
|
+
if (!current) return;
|
|
469
|
+
drag = null;
|
|
470
|
+
cancelAnimationFrame(current.frame);
|
|
471
|
+
current.card.removeAttribute("data-dragging");
|
|
472
|
+
clearDropMarker();
|
|
473
|
+
if (nodes.hasPointerCapture(current.pointerId)) {
|
|
474
|
+
nodes.releasePointerCapture(current.pointerId);
|
|
475
|
+
}
|
|
476
|
+
const rect = nodes.getBoundingClientRect();
|
|
477
|
+
if (commit && current.moved && event.clientX >= rect.left
|
|
478
|
+
&& event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom) {
|
|
479
|
+
const cards = Array.from(nodes.children).filter(card => card !== current.card);
|
|
480
|
+
const index = current.before ? cards.indexOf(current.before) : cards.length;
|
|
481
|
+
cards.splice(index, 0, current.card);
|
|
482
|
+
saveDeviceOrder(current.data, cards.map(card => card.dataset.nodeId), current.card.dataset.nodeId);
|
|
483
|
+
} else {
|
|
484
|
+
reordering = false;
|
|
485
|
+
refresh();
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
nodes.addEventListener("pointermove", event => {
|
|
490
|
+
if (!drag || event.pointerId !== drag.pointerId) return;
|
|
491
|
+
drag.y = event.clientY;
|
|
492
|
+
if (!drag.moved) {
|
|
493
|
+
if (Math.hypot(event.clientX - drag.x, event.clientY - drag.startY) < 5) return;
|
|
494
|
+
drag.moved = true;
|
|
495
|
+
drag.card.setAttribute("data-dragging", "");
|
|
496
|
+
drag.frame = requestAnimationFrame(scrollDrag);
|
|
497
|
+
}
|
|
498
|
+
markDropTarget();
|
|
499
|
+
});
|
|
500
|
+
nodes.addEventListener("pointerup", event => {
|
|
501
|
+
if (drag && event.pointerId === drag.pointerId) finishDrag(true, event);
|
|
502
|
+
});
|
|
503
|
+
nodes.addEventListener("pointercancel", () => finishDrag(false));
|
|
504
|
+
nodes.addEventListener("lostpointercapture", () => finishDrag(false));
|
|
505
|
+
nodes.addEventListener("keydown", event => {
|
|
506
|
+
if (drag && event.key === "Escape") {
|
|
507
|
+
event.preventDefault();
|
|
508
|
+
finishDrag(false);
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
function bindDeviceDrag(card, node, data) {
|
|
513
|
+
const handle = card.querySelector(".device-drag");
|
|
514
|
+
handle.disabled = data.nodes.length < 2;
|
|
515
|
+
handle.setAttribute("aria-label", `Reorder device ${node.alias || node.name}`);
|
|
516
|
+
handle.addEventListener("pointerdown", event => {
|
|
517
|
+
if (event.button !== 0 || reordering || editingDevice !== null) return;
|
|
518
|
+
event.preventDefault();
|
|
519
|
+
handle.focus({preventScroll: true});
|
|
520
|
+
reordering = true;
|
|
521
|
+
orderStatus.textContent = "";
|
|
522
|
+
drag = {card, data, pointerId: event.pointerId, x: event.clientX,
|
|
523
|
+
startY: event.clientY, y: event.clientY, moved: false, frame: null, before: null};
|
|
524
|
+
nodes.setPointerCapture(event.pointerId);
|
|
525
|
+
});
|
|
526
|
+
handle.addEventListener("keydown", event => {
|
|
527
|
+
if (reordering || editingDevice !== null || !["ArrowUp", "ArrowDown"].includes(event.key)) return;
|
|
528
|
+
event.preventDefault();
|
|
529
|
+
const order = data.nodes.map(item => item.id);
|
|
530
|
+
const index = order.indexOf(node.id);
|
|
531
|
+
const target = index + (event.key === "ArrowUp" ? -1 : 1);
|
|
532
|
+
if (target < 0 || target >= order.length) return;
|
|
533
|
+
[order[index], order[target]] = [order[target], order[index]];
|
|
534
|
+
saveDeviceOrder(data, order, node.id);
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
348
538
|
function render(data) {
|
|
539
|
+
const focusedHandle = nodes.contains(document.activeElement)
|
|
540
|
+
&& document.activeElement.matches(".device-drag")
|
|
541
|
+
? document.activeElement.closest(".device").dataset.nodeId : null;
|
|
542
|
+
const expanded = new Set(Array.from(
|
|
543
|
+
workspaces.querySelectorAll(".workspace-group[open]"), group => group.dataset.nodeId));
|
|
349
544
|
const workspaceList = document.createDocumentFragment();
|
|
545
|
+
const unreadGroup = unreadTemplate.content.firstElementChild.cloneNode(true);
|
|
546
|
+
const unreadList = unreadGroup.querySelector(".workspace-group-list");
|
|
350
547
|
const groups = new Map();
|
|
351
548
|
for (const node of data.nodes) {
|
|
352
549
|
const group = workspaceGroupTemplate.content.firstElementChild.cloneNode(true);
|
|
550
|
+
group.dataset.nodeId = node.id;
|
|
551
|
+
group.open = expanded.has(node.id);
|
|
353
552
|
group.setAttribute("aria-label", node.alias || node.name);
|
|
354
|
-
|
|
553
|
+
setDeviceLabel(group.querySelector(".device-label"), node);
|
|
554
|
+
const link = group.querySelector(".manage-link");
|
|
555
|
+
link.href = node.url;
|
|
556
|
+
link.setAttribute("aria-label", `Manage workspaces on ${node.alias || node.name}`);
|
|
355
557
|
groups.set(node.id, {node, group});
|
|
356
558
|
}
|
|
357
559
|
for (const workspace of data.workspaces) {
|
|
@@ -375,29 +577,55 @@
|
|
|
375
577
|
const {node, group} = groups.get(workspace.node_id);
|
|
376
578
|
link.setAttribute("aria-label", `Open ${workspace.id} on ${node.alias || node.name}`);
|
|
377
579
|
group.querySelector(".workspace-group-list").append(card);
|
|
580
|
+
if (workspace.unread) {
|
|
581
|
+
group.querySelector(".device-unread").hidden = false;
|
|
582
|
+
const unreadCard = card.cloneNode(true);
|
|
583
|
+
const detail = unreadCard.querySelector(".session-count");
|
|
584
|
+
detail.textContent = `${node.alias || node.name} · ${detail.textContent}`;
|
|
585
|
+
detail.title = detail.textContent;
|
|
586
|
+
unreadList.append(unreadCard);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if (unreadList.childElementCount) {
|
|
590
|
+
unreadGroup.querySelector(".count").textContent = countLabel(
|
|
591
|
+
unreadList.childElementCount, "workspace");
|
|
592
|
+
workspaceList.append(unreadGroup);
|
|
378
593
|
}
|
|
379
594
|
for (const {group} of groups.values()) {
|
|
380
595
|
const count = group.querySelector(".workspace-group-list").childElementCount;
|
|
381
|
-
if (!count) continue;
|
|
382
596
|
group.querySelector(".count").textContent = countLabel(count, "workspace");
|
|
597
|
+
if (!count) {
|
|
598
|
+
group.querySelector(".list-head").remove();
|
|
599
|
+
group.querySelector(".workspace-group-list").append(
|
|
600
|
+
empty("No workspaces yet", "Use Manage to create a workspace on this device."));
|
|
601
|
+
}
|
|
383
602
|
workspaceList.append(group);
|
|
384
603
|
}
|
|
385
|
-
if (!data.
|
|
386
|
-
workspaceList.append(empty("No workspaces yet",
|
|
387
|
-
|
|
388
|
-
: "Your workspaces will appear when a device connects."));
|
|
604
|
+
if (!data.nodes.length) {
|
|
605
|
+
workspaceList.append(empty("No workspaces yet",
|
|
606
|
+
"Your workspaces will appear when a device connects."));
|
|
389
607
|
}
|
|
390
608
|
|
|
391
609
|
const nodeList = document.createDocumentFragment();
|
|
392
610
|
for (const node of data.nodes) {
|
|
393
611
|
const card = deviceTemplate.content.firstElementChild.cloneNode(true);
|
|
394
|
-
|
|
612
|
+
card.dataset.nodeId = node.id;
|
|
613
|
+
bindDeviceDrag(card, node, data);
|
|
614
|
+
const label = card.querySelector(".device-label");
|
|
615
|
+
setDeviceLabel(label, node);
|
|
616
|
+
const rename = card.querySelector(".device-rename");
|
|
617
|
+
rename.setAttribute("aria-label", `Rename device ${node.alias || node.name}`);
|
|
618
|
+
rename.addEventListener("click", () => editDeviceAlias(label, rename, node, data));
|
|
395
619
|
const owned = data.workspaces.filter(workspace => workspace.node_id === node.id);
|
|
620
|
+
card.querySelector(".device-unread").hidden = !owned.some(workspace => workspace.unread);
|
|
396
621
|
const sessions = owned.reduce((total, workspace) => total + workspace.session_count, 0);
|
|
397
622
|
card.querySelector(".device-detail").textContent = `${countLabel(owned.length, "workspace")} · ${countLabel(sessions, "session")}`;
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
623
|
+
const working = owned.filter(workspace => workspace.running).length;
|
|
624
|
+
for (const container of [groups.get(node.id).group, card]) {
|
|
625
|
+
const badge = container.querySelector(".device-working");
|
|
626
|
+
badge.hidden = !working;
|
|
627
|
+
badge.textContent = working ? `${working} working` : "";
|
|
628
|
+
}
|
|
401
629
|
nodeList.append(card);
|
|
402
630
|
}
|
|
403
631
|
if (!data.nodes.length) {
|
|
@@ -405,6 +633,10 @@
|
|
|
405
633
|
}
|
|
406
634
|
workspaces.replaceChildren(workspaceList);
|
|
407
635
|
nodes.replaceChildren(nodeList);
|
|
636
|
+
if (focusedHandle) {
|
|
637
|
+
const handle = nodes.querySelector(`.device[data-node-id="${CSS.escape(focusedHandle)}"] .device-drag`);
|
|
638
|
+
if (handle) handle.focus({preventScroll: true});
|
|
639
|
+
}
|
|
408
640
|
document.getElementById("workspace-count").textContent = data.workspaces.length;
|
|
409
641
|
document.getElementById("device-count").textContent = data.nodes.length;
|
|
410
642
|
const running = data.workspaces.filter(workspace => workspace.running).length;
|
|
@@ -414,7 +646,7 @@
|
|
|
414
646
|
async function refresh() {
|
|
415
647
|
if (refreshing) return;
|
|
416
648
|
refreshing = true;
|
|
417
|
-
const revision =
|
|
649
|
+
const revision = inventoryRevision;
|
|
418
650
|
try {
|
|
419
651
|
const response = await fetch("api/workspaces", {cache: "no-store"});
|
|
420
652
|
if (response.status === 401) {
|
|
@@ -424,7 +656,7 @@
|
|
|
424
656
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
425
657
|
const data = await response.json();
|
|
426
658
|
const next = JSON.stringify(data);
|
|
427
|
-
if (editingDevice === null && revision ===
|
|
659
|
+
if (editingDevice === null && !reordering && revision === inventoryRevision && next !== signature) {
|
|
428
660
|
render(data);
|
|
429
661
|
signature = next;
|
|
430
662
|
}
|
|
@@ -63,10 +63,11 @@ def load_registration_key(path):
|
|
|
63
63
|
def normalize_alias(value):
|
|
64
64
|
if (
|
|
65
65
|
not isinstance(value, str)
|
|
66
|
+
or not value.strip()
|
|
66
67
|
or len(value) > 128
|
|
67
68
|
or any(ord(char) < 32 for char in value)
|
|
68
69
|
):
|
|
69
|
-
raise ValueError("Device alias must be a single line of
|
|
70
|
+
raise ValueError("Device alias must be a single line of 1 to 128 characters.")
|
|
70
71
|
value.encode("utf-8")
|
|
71
72
|
return value.strip()
|
|
72
73
|
|
|
@@ -175,8 +176,8 @@ class Router:
|
|
|
175
176
|
aliases = json.loads(self.aliases_path.read_text(encoding="utf-8"))
|
|
176
177
|
if not isinstance(aliases, dict) or any(
|
|
177
178
|
not re.fullmatch("[a-z2-7]{52}", node_id)
|
|
178
|
-
or not alias
|
|
179
|
-
or normalize_alias(alias) != alias
|
|
179
|
+
or not isinstance(alias, str)
|
|
180
|
+
or (alias and normalize_alias(alias) != alias)
|
|
180
181
|
for node_id, alias in aliases.items()
|
|
181
182
|
):
|
|
182
183
|
raise ValueError("invalid router aliases file")
|
|
@@ -200,6 +201,33 @@ class Router:
|
|
|
200
201
|
temporary_path.unlink()
|
|
201
202
|
self.aliases = aliases
|
|
202
203
|
|
|
204
|
+
async def reorder_devices(self, request):
|
|
205
|
+
if not self.browser_token or not _auth_cookie_matches(
|
|
206
|
+
self.browser_token, request.cookies.get("pycodex_router_auth")
|
|
207
|
+
):
|
|
208
|
+
raise HTTPException(401, "Authentication required.")
|
|
209
|
+
payload = await _json_object_body(request, max_bytes=64 * 1024)
|
|
210
|
+
order = payload.get("order")
|
|
211
|
+
if (
|
|
212
|
+
not isinstance(order, list)
|
|
213
|
+
or any(
|
|
214
|
+
not isinstance(node_id, str)
|
|
215
|
+
or not re.fullmatch("[a-z2-7]{52}", node_id)
|
|
216
|
+
for node_id in order
|
|
217
|
+
)
|
|
218
|
+
or len(set(order)) != len(order)
|
|
219
|
+
):
|
|
220
|
+
raise HTTPException(422, "Device order must be a list of unique device IDs.")
|
|
221
|
+
if set(order) != set(self.nodes):
|
|
222
|
+
raise HTTPException(409, "Connected devices changed. Refresh and try again.")
|
|
223
|
+
online_order = iter(order)
|
|
224
|
+
saved_order = [
|
|
225
|
+
next(online_order) if node_id in self.nodes else node_id
|
|
226
|
+
for node_id in self.aliases
|
|
227
|
+
]
|
|
228
|
+
self._save_aliases({node_id: self.aliases[node_id] for node_id in saved_order})
|
|
229
|
+
return JSONResponse({"order": order})
|
|
230
|
+
|
|
203
231
|
async def rename_device(self, request):
|
|
204
232
|
if not self.browser_token or not _auth_cookie_matches(
|
|
205
233
|
self.browser_token, request.cookies.get("pycodex_router_auth")
|
|
@@ -214,10 +242,7 @@ class Router:
|
|
|
214
242
|
except ValueError as exc:
|
|
215
243
|
raise HTTPException(422, str(exc))
|
|
216
244
|
aliases = dict(self.aliases)
|
|
217
|
-
|
|
218
|
-
aliases[node_id] = alias
|
|
219
|
-
else:
|
|
220
|
-
aliases.pop(node_id, None)
|
|
245
|
+
aliases[node_id] = alias
|
|
221
246
|
self._save_aliases(aliases)
|
|
222
247
|
return JSONResponse({"alias": alias})
|
|
223
248
|
|
|
@@ -265,6 +290,10 @@ class Router:
|
|
|
265
290
|
name,
|
|
266
291
|
validate_inventory(message["workspaces"]),
|
|
267
292
|
)
|
|
293
|
+
if node_id not in self.aliases:
|
|
294
|
+
aliases = dict(self.aliases)
|
|
295
|
+
aliases[node_id] = ""
|
|
296
|
+
self._save_aliases(aliases)
|
|
268
297
|
self.nodes[node_id] = node
|
|
269
298
|
await websocket.send_text(encode({"type": "registered", "id": node_id}))
|
|
270
299
|
while True:
|
|
@@ -425,7 +454,10 @@ class Router:
|
|
|
425
454
|
async def workspaces(self, request):
|
|
426
455
|
nodes = []
|
|
427
456
|
workspaces = []
|
|
428
|
-
for
|
|
457
|
+
for node_id in self.aliases:
|
|
458
|
+
if node_id not in self.nodes:
|
|
459
|
+
continue
|
|
460
|
+
node = self.nodes[node_id]
|
|
429
461
|
base = request.scope.get("root_path", "") + "/n/" + node.id + "/"
|
|
430
462
|
nodes.append({
|
|
431
463
|
"id": node.id,
|
|
@@ -480,6 +512,7 @@ def create_app(
|
|
|
480
512
|
Route("/", index),
|
|
481
513
|
Route("/api/workspaces", router.workspaces),
|
|
482
514
|
Route("/api/registration-key", registration_key_info),
|
|
515
|
+
Route("/api/nodes/order", router.reorder_devices, methods=["POST"]),
|
|
483
516
|
Route("/api/nodes/{node_id}/alias", router.rename_device, methods=["POST"]),
|
|
484
517
|
]
|
|
485
518
|
)
|
|
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pycodex-ws"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.6"
|
|
8
8
|
description = "Browser workspace and optional Feishu integration for python-codex"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.7"
|
|
11
11
|
dependencies = [
|
|
12
|
-
"python-codex==0.4.
|
|
12
|
+
"python-codex==0.4.6",
|
|
13
13
|
"pillow>=9.0.0",
|
|
14
14
|
"websockets>=11,<16",
|
|
15
15
|
]
|
|
@@ -7,6 +7,7 @@ import json
|
|
|
7
7
|
import mimetypes
|
|
8
8
|
import os
|
|
9
9
|
import secrets
|
|
10
|
+
import socket
|
|
10
11
|
import subprocess
|
|
11
12
|
import sys
|
|
12
13
|
import threading
|
|
@@ -479,6 +480,8 @@ class WebSessionView:
|
|
|
479
480
|
if isinstance(event, TurnFailedEvent):
|
|
480
481
|
turn["status"] = "error"
|
|
481
482
|
turn["error"] = event.visualize()
|
|
483
|
+
turn["final_id"] = event.turn_id
|
|
484
|
+
self._last_final_id = event.turn_id
|
|
482
485
|
self._stream_buffer = ""
|
|
483
486
|
return
|
|
484
487
|
|
|
@@ -1132,10 +1135,15 @@ def _install_auth(
|
|
|
1132
1135
|
)
|
|
1133
1136
|
|
|
1134
1137
|
async def login_page(request: Request) -> HTMLResponse:
|
|
1135
|
-
|
|
1138
|
+
address = str(request.url.replace(
|
|
1139
|
+
path=request.scope.get("root_path", "") + "/", query="",
|
|
1140
|
+
))
|
|
1141
|
+
return _html_response(_render_login_shell(
|
|
1142
|
+
login_title, socket.gethostname(), address,
|
|
1143
|
+
))
|
|
1136
1144
|
|
|
1137
1145
|
async def login(request: Request) -> JSONResponse:
|
|
1138
|
-
payload = await _json_object_body(request)
|
|
1146
|
+
payload = await _json_object_body(request, max_bytes=4096)
|
|
1139
1147
|
client_ip = request.client.host if blocked_ips is not None else None
|
|
1140
1148
|
# Another login can block this IP while the request body is arriving.
|
|
1141
1149
|
if blocked_ips is not None and client_ip in blocked_ips:
|
|
@@ -1182,14 +1190,25 @@ def _install_auth(
|
|
|
1182
1190
|
return token
|
|
1183
1191
|
|
|
1184
1192
|
|
|
1185
|
-
async def _json_object_body(
|
|
1193
|
+
async def _json_object_body(
|
|
1194
|
+
request: Request, max_bytes: "typing.Union[int, None]" = None,
|
|
1195
|
+
) -> "typing.Dict[str, object]":
|
|
1186
1196
|
media_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
|
1187
1197
|
if media_type != "application/json" and not (
|
|
1188
1198
|
media_type.startswith("application/") and media_type.endswith("+json")
|
|
1189
1199
|
):
|
|
1190
1200
|
raise HTTPException(status_code=422, detail="request body must be JSON")
|
|
1201
|
+
body = bytearray()
|
|
1202
|
+
async for chunk in request.stream():
|
|
1203
|
+
if max_bytes is not None and len(body) + len(chunk) > max_bytes:
|
|
1204
|
+
raise HTTPException(
|
|
1205
|
+
status_code=413,
|
|
1206
|
+
detail="request body exceeds {0} bytes".format(max_bytes),
|
|
1207
|
+
headers={"Connection": "close"},
|
|
1208
|
+
)
|
|
1209
|
+
body.extend(chunk)
|
|
1191
1210
|
try:
|
|
1192
|
-
payload =
|
|
1211
|
+
payload = json.loads(body)
|
|
1193
1212
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
1194
1213
|
raise HTTPException(status_code=422, detail="invalid JSON body")
|
|
1195
1214
|
if not isinstance(payload, dict):
|
|
@@ -1203,13 +1222,13 @@ def _auth_cookie_matches(token: str, cookie: "typing.Union[str, None]") -> bool:
|
|
|
1203
1222
|
return bool(cookie) and secrets.compare_digest(str(cookie), token)
|
|
1204
1223
|
|
|
1205
1224
|
|
|
1206
|
-
def _render_login_shell(title: str
|
|
1225
|
+
def _render_login_shell(title: str, hostname: str, address: str) -> str:
|
|
1207
1226
|
return """<!doctype html>
|
|
1208
1227
|
<html>
|
|
1209
1228
|
<head>
|
|
1210
1229
|
<meta charset="utf-8">
|
|
1211
1230
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1212
|
-
<title>__LOGIN_TITLE__</title>
|
|
1231
|
+
<title>Log in to __LOGIN_TITLE__</title>
|
|
1213
1232
|
<style>
|
|
1214
1233
|
:root {
|
|
1215
1234
|
color-scheme: light dark;
|
|
@@ -1232,6 +1251,12 @@ def _render_login_shell(title: str = "pycodex workspace") -> str:
|
|
|
1232
1251
|
margin: 0;
|
|
1233
1252
|
font-size: 22px;
|
|
1234
1253
|
}
|
|
1254
|
+
.login-target {
|
|
1255
|
+
margin: 0;
|
|
1256
|
+
font-size: 13px;
|
|
1257
|
+
line-height: 1.6;
|
|
1258
|
+
overflow-wrap: anywhere;
|
|
1259
|
+
}
|
|
1235
1260
|
input, button {
|
|
1236
1261
|
min-height: 38px;
|
|
1237
1262
|
border-radius: 7px;
|
|
@@ -1251,10 +1276,11 @@ def _render_login_shell(title: str = "pycodex workspace") -> str:
|
|
|
1251
1276
|
</head>
|
|
1252
1277
|
<body>
|
|
1253
1278
|
<form id="loginForm">
|
|
1254
|
-
<h1>__LOGIN_TITLE__</h1>
|
|
1279
|
+
<h1>Log in to __LOGIN_TITLE__</h1>
|
|
1280
|
+
<p class="login-target">Server: <strong>__LOGIN_HOST__</strong><br><code>__LOGIN_ADDRESS__</code></p>
|
|
1255
1281
|
<label for="passwordInput">Password</label>
|
|
1256
1282
|
<input id="passwordInput" type="password" autocomplete="current-password" autofocus>
|
|
1257
|
-
<button type="submit">
|
|
1283
|
+
<button type="submit">Log in</button>
|
|
1258
1284
|
<div id="status" class="status" role="status"></div>
|
|
1259
1285
|
</form>
|
|
1260
1286
|
<script>
|
|
@@ -1280,7 +1306,9 @@ def _render_login_shell(title: str = "pycodex workspace") -> str:
|
|
|
1280
1306
|
});
|
|
1281
1307
|
</script>
|
|
1282
1308
|
</body>
|
|
1283
|
-
</html>""".replace("__LOGIN_TITLE__", html.escape(title))
|
|
1309
|
+
</html>""".replace("__LOGIN_TITLE__", html.escape(title)).replace(
|
|
1310
|
+
"__LOGIN_HOST__", html.escape(hostname),
|
|
1311
|
+
).replace("__LOGIN_ADDRESS__", html.escape(address))
|
|
1284
1312
|
|
|
1285
1313
|
|
|
1286
1314
|
async def _http_exception_response(request: Request, exc: HTTPException) -> JSONResponse:
|
|
@@ -1637,6 +1665,15 @@ async def _websocket_session_handler(
|
|
|
1637
1665
|
manager: "WorkspaceSessionManager",
|
|
1638
1666
|
websocket: WebSocket,
|
|
1639
1667
|
) -> None:
|
|
1668
|
+
url = websocket.url
|
|
1669
|
+
scheme = "https" if url.scheme == "wss" else "http"
|
|
1670
|
+
authority = url.netloc.lower()
|
|
1671
|
+
if url.port == (443 if scheme == "https" else 80):
|
|
1672
|
+
authority = authority.rsplit(":", 1)[0]
|
|
1673
|
+
# Browsers send Origin; non-browser clients may omit it.
|
|
1674
|
+
if websocket.headers.get("origin") not in (None, scheme + "://" + authority):
|
|
1675
|
+
await websocket.close(code=1008)
|
|
1676
|
+
return
|
|
1640
1677
|
await websocket.accept()
|
|
1641
1678
|
session_id = str(websocket.query_params.get("session_id") or "")
|
|
1642
1679
|
try:
|
|
@@ -2249,11 +2249,15 @@
|
|
|
2249
2249
|
const response = renderEntry(
|
|
2250
2250
|
`${key}:response`, turn.response, isControl ? "control" : "response", !isControl
|
|
2251
2251
|
);
|
|
2252
|
-
response.dataset.finalId = isControl ? "" : (turn.final_id || "");
|
|
2252
|
+
response.dataset.finalId = isControl || turn.error ? "" : (turn.final_id || "");
|
|
2253
2253
|
response.dataset.sessionId = activeSessionId;
|
|
2254
2254
|
}
|
|
2255
2255
|
if (turn.thinking) renderEntry(`${key}:thinking`, turn.thinking, "thinking");
|
|
2256
|
-
if (turn.error)
|
|
2256
|
+
if (turn.error) {
|
|
2257
|
+
const error = renderEntry(`${key}:error`, turn.error, "error");
|
|
2258
|
+
error.dataset.finalId = isControl ? "" : (turn.final_id || "");
|
|
2259
|
+
error.dataset.sessionId = activeSessionId;
|
|
2260
|
+
}
|
|
2257
2261
|
}
|
|
2258
2262
|
|
|
2259
2263
|
async function markVisibleFinalRead() {
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|