labtasker-webui 0.1.1__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.
- labtasker_webui/__init__.py +3 -0
- labtasker_webui/__main__.py +3 -0
- labtasker_webui/app.py +503 -0
- labtasker_webui/cli.py +33 -0
- labtasker_webui/config.py +63 -0
- labtasker_webui/local.py +22 -0
- labtasker_webui/operations.py +197 -0
- labtasker_webui/profile.py +89 -0
- labtasker_webui/py.typed +0 -0
- labtasker_webui/schemas.py +80 -0
- labtasker_webui/security.py +53 -0
- labtasker_webui/sessions.py +51 -0
- labtasker_webui/static/assets/index-CscGvBx9.css +1 -0
- labtasker_webui/static/assets/index-D2PfKj6B.js +54 -0
- labtasker_webui/static/index.html +15 -0
- labtasker_webui/static/labtasker-icon.png +0 -0
- labtasker_webui/upstream.py +140 -0
- labtasker_webui-0.1.1.dist-info/METADATA +177 -0
- labtasker_webui-0.1.1.dist-info/RECORD +22 -0
- labtasker_webui-0.1.1.dist-info/WHEEL +4 -0
- labtasker_webui-0.1.1.dist-info/entry_points.txt +2 -0
- labtasker_webui-0.1.1.dist-info/licenses/LICENSE +201 -0
labtasker_webui/app.py
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated, Any, cast
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
from fastapi import Cookie, FastAPI, HTTPException, Query, Request, Response
|
|
9
|
+
from fastapi.exceptions import RequestValidationError
|
|
10
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
11
|
+
from fastapi.staticfiles import StaticFiles
|
|
12
|
+
from pydantic import TypeAdapter, ValidationError
|
|
13
|
+
|
|
14
|
+
from .config import Settings, is_loopback_host
|
|
15
|
+
from .local import local_connection
|
|
16
|
+
from .operations import OperationStore
|
|
17
|
+
from .profile import Profile
|
|
18
|
+
from .schemas import (
|
|
19
|
+
BatchRequest,
|
|
20
|
+
ConnectRequest,
|
|
21
|
+
CountResponse,
|
|
22
|
+
QueueResponse,
|
|
23
|
+
SelectRequest,
|
|
24
|
+
TaskOrderField,
|
|
25
|
+
TaskPageResponse,
|
|
26
|
+
TaskResponse,
|
|
27
|
+
TaskStatus,
|
|
28
|
+
)
|
|
29
|
+
from .security import DestinationBlocked, validate_server_url
|
|
30
|
+
from .sessions import Connection, SessionStore
|
|
31
|
+
from .upstream import Upstream, UpstreamError
|
|
32
|
+
|
|
33
|
+
COOKIE = "labtasker_webui_session"
|
|
34
|
+
STATUSES = ("pending", "running", "succeeded", "failed", "cancelled")
|
|
35
|
+
QUEUES_ADAPTER = TypeAdapter(list[QueueResponse])
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def validated(value: Any, model: Any) -> Any:
|
|
39
|
+
try:
|
|
40
|
+
if isinstance(model, TypeAdapter):
|
|
41
|
+
parsed = model.validate_python(value)
|
|
42
|
+
return model.dump_python(parsed, mode="json")
|
|
43
|
+
parsed = model.model_validate(value)
|
|
44
|
+
return parsed.model_dump(mode="json")
|
|
45
|
+
except ValidationError as exc:
|
|
46
|
+
raise UpstreamError(
|
|
47
|
+
502,
|
|
48
|
+
"malformed_upstream",
|
|
49
|
+
"Labtasker Server returned a response that does not match API v2.",
|
|
50
|
+
{"errors": exc.errors(include_input=False)},
|
|
51
|
+
) from exc
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def create_app(settings: Settings | None = None) -> FastAPI:
|
|
55
|
+
settings = settings or Settings()
|
|
56
|
+
local = (
|
|
57
|
+
local_connection(settings.local_directory) if settings.local_directory is not None else None
|
|
58
|
+
)
|
|
59
|
+
locked = settings.server_url is not None or local is not None
|
|
60
|
+
profile = Profile(settings.profile_path)
|
|
61
|
+
sessions = SessionStore()
|
|
62
|
+
upstream = Upstream(settings.allowed_server_origins, strict=not is_loopback_host(settings.host))
|
|
63
|
+
operations = OperationStore(upstream)
|
|
64
|
+
app = FastAPI(title="Labtasker WebUI", version="0.1.0", docs_url=None, redoc_url=None)
|
|
65
|
+
|
|
66
|
+
@app.middleware("http")
|
|
67
|
+
async def enforce_same_origin(request: Request, call_next: Any) -> Response:
|
|
68
|
+
origin = request.headers.get("origin")
|
|
69
|
+
if origin and request.method not in {"GET", "HEAD", "OPTIONS"}:
|
|
70
|
+
try:
|
|
71
|
+
origin_value = validate_server_url(origin, (), strict=False)
|
|
72
|
+
webui_value = validate_server_url(str(request.base_url), (), strict=False)
|
|
73
|
+
except DestinationBlocked:
|
|
74
|
+
origin_value = ""
|
|
75
|
+
webui_value = "different"
|
|
76
|
+
if origin_value != webui_value:
|
|
77
|
+
return JSONResponse(
|
|
78
|
+
status_code=403,
|
|
79
|
+
content={
|
|
80
|
+
"error": {
|
|
81
|
+
"code": "cross_origin_request_blocked",
|
|
82
|
+
"message": "The WebUI rejected a cross-origin write request.",
|
|
83
|
+
"details": {},
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
return cast(Response, await call_next(request))
|
|
88
|
+
|
|
89
|
+
@app.exception_handler(UpstreamError)
|
|
90
|
+
async def upstream_error(_: Request, exc: UpstreamError) -> JSONResponse:
|
|
91
|
+
return JSONResponse(
|
|
92
|
+
content={"error": {"code": exc.code, "message": exc.message, "details": exc.details}},
|
|
93
|
+
status_code=exc.status,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
@app.exception_handler(DestinationBlocked)
|
|
97
|
+
async def blocked(_: Request, exc: DestinationBlocked) -> JSONResponse:
|
|
98
|
+
return JSONResponse(
|
|
99
|
+
content={"error": {"code": "destination_blocked", "message": str(exc), "details": {}}},
|
|
100
|
+
status_code=400,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
@app.exception_handler(HTTPException)
|
|
104
|
+
async def webui_http_error(_: Request, exc: HTTPException) -> JSONResponse:
|
|
105
|
+
details: Any
|
|
106
|
+
if isinstance(exc.detail, dict):
|
|
107
|
+
code = str(exc.detail.get("code", "webui_error"))
|
|
108
|
+
message = str(exc.detail.get("message", "The WebUI request failed."))
|
|
109
|
+
details = exc.detail.get("details", {})
|
|
110
|
+
else:
|
|
111
|
+
code = "webui_error"
|
|
112
|
+
message = str(exc.detail)
|
|
113
|
+
details = {}
|
|
114
|
+
return JSONResponse(
|
|
115
|
+
content={"error": {"code": code, "message": message, "details": details}},
|
|
116
|
+
status_code=exc.status_code,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
@app.exception_handler(RequestValidationError)
|
|
120
|
+
async def webui_validation_error(_: Request, exc: RequestValidationError) -> JSONResponse:
|
|
121
|
+
return JSONResponse(
|
|
122
|
+
content={
|
|
123
|
+
"error": {
|
|
124
|
+
"code": "invalid_request",
|
|
125
|
+
"message": "The WebUI request is invalid.",
|
|
126
|
+
"details": {"errors": exc.errors()},
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
status_code=422,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def attach_local(directory: str) -> Connection:
|
|
133
|
+
if not is_loopback_host(settings.host):
|
|
134
|
+
raise UpstreamError(
|
|
135
|
+
403, "local_forbidden", "Local connections require a loopback WebUI bind."
|
|
136
|
+
)
|
|
137
|
+
path = Path(directory).expanduser().resolve()
|
|
138
|
+
if not path.is_dir():
|
|
139
|
+
raise UpstreamError(
|
|
140
|
+
422, "local_directory_missing", "The local project directory does not exist."
|
|
141
|
+
)
|
|
142
|
+
try:
|
|
143
|
+
return local_connection(path)
|
|
144
|
+
except (ValueError, ImportError) as exc:
|
|
145
|
+
raise UpstreamError(422, "local_unavailable", str(exc)) from exc
|
|
146
|
+
|
|
147
|
+
def connection(session_id: str | None) -> Connection:
|
|
148
|
+
if local is not None:
|
|
149
|
+
return local
|
|
150
|
+
if settings.server_url:
|
|
151
|
+
return Connection(settings.server_url, settings.server_token, 0)
|
|
152
|
+
item = sessions.get(session_id)
|
|
153
|
+
if not item and profile.path and profile.data.get("connection"):
|
|
154
|
+
saved = profile.data["connection"]
|
|
155
|
+
item = (
|
|
156
|
+
attach_local(saved["server_url"][6:])
|
|
157
|
+
if saved["server_url"].startswith("local:")
|
|
158
|
+
else Connection(saved["server_url"], saved.get("token"), 0)
|
|
159
|
+
)
|
|
160
|
+
if not item:
|
|
161
|
+
raise HTTPException(
|
|
162
|
+
401, {"code": "connection_required", "message": "Connect to a Labtasker Server."}
|
|
163
|
+
)
|
|
164
|
+
return item
|
|
165
|
+
|
|
166
|
+
@app.get("/api/webui/status")
|
|
167
|
+
async def status(
|
|
168
|
+
session_id: Annotated[str | None, Cookie(alias=COOKIE)] = None,
|
|
169
|
+
) -> dict[str, Any]:
|
|
170
|
+
item = None
|
|
171
|
+
connection_error = None
|
|
172
|
+
try:
|
|
173
|
+
if (
|
|
174
|
+
locked
|
|
175
|
+
or sessions.get(session_id)
|
|
176
|
+
or (profile.path and profile.data.get("connection"))
|
|
177
|
+
):
|
|
178
|
+
item = connection(session_id)
|
|
179
|
+
if item and (locked or item.socket_path or not sessions.get(session_id)):
|
|
180
|
+
await upstream.verify(item)
|
|
181
|
+
except UpstreamError as exc:
|
|
182
|
+
connection_error = {"code": exc.code, "message": exc.message}
|
|
183
|
+
return {
|
|
184
|
+
"connected": item is not None and connection_error is None,
|
|
185
|
+
"locked": locked,
|
|
186
|
+
"mode": "local" if item and item.socket_path else "http",
|
|
187
|
+
"server_url": item.server_url if item else None,
|
|
188
|
+
"connection_error": connection_error,
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
@app.post("/api/webui/connect")
|
|
192
|
+
async def connect(
|
|
193
|
+
payload: ConnectRequest, request: Request, response: Response
|
|
194
|
+
) -> dict[str, Any]:
|
|
195
|
+
if locked:
|
|
196
|
+
raise HTTPException(409, "Connection is locked by deployment configuration.")
|
|
197
|
+
if payload.mode == "local":
|
|
198
|
+
if payload.token:
|
|
199
|
+
raise HTTPException(422, "Local connections do not use a token.")
|
|
200
|
+
candidate = attach_local(payload.directory)
|
|
201
|
+
else:
|
|
202
|
+
url = validate_server_url(
|
|
203
|
+
payload.server_url,
|
|
204
|
+
settings.allowed_server_origins,
|
|
205
|
+
strict=not is_loopback_host(settings.host),
|
|
206
|
+
)
|
|
207
|
+
candidate = Connection(url, payload.token or None, 0)
|
|
208
|
+
health = await upstream.verify(candidate)
|
|
209
|
+
if profile.path:
|
|
210
|
+
profile.set_connection(candidate.server_url, candidate.token)
|
|
211
|
+
session_id = sessions.create(candidate.server_url, candidate.token, candidate.socket_path)
|
|
212
|
+
response.set_cookie(
|
|
213
|
+
COOKIE,
|
|
214
|
+
session_id,
|
|
215
|
+
httponly=True,
|
|
216
|
+
samesite="strict",
|
|
217
|
+
secure=request.url.scheme == "https",
|
|
218
|
+
max_age=12 * 60 * 60,
|
|
219
|
+
)
|
|
220
|
+
return {
|
|
221
|
+
"connected": True,
|
|
222
|
+
"server_url": candidate.server_url,
|
|
223
|
+
"api_version": health.get("api_version"),
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
@app.delete("/api/webui/connect", status_code=204)
|
|
227
|
+
async def disconnect(
|
|
228
|
+
response: Response, session_id: Annotated[str | None, Cookie(alias=COOKIE)] = None
|
|
229
|
+
) -> None:
|
|
230
|
+
if locked:
|
|
231
|
+
raise HTTPException(409, "Connection is locked by deployment configuration.")
|
|
232
|
+
sessions.delete(session_id)
|
|
233
|
+
if profile.path:
|
|
234
|
+
profile.set_connection(None)
|
|
235
|
+
response.delete_cookie(COOKIE)
|
|
236
|
+
|
|
237
|
+
@app.get("/api/webui/profile")
|
|
238
|
+
async def get_profile() -> dict[str, Any]:
|
|
239
|
+
return {
|
|
240
|
+
"enabled": profile.path is not None,
|
|
241
|
+
"ui": profile.data["ui"] if profile.path else {},
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
@app.patch("/api/webui/profile")
|
|
245
|
+
async def update_profile(values: dict[str, str]) -> dict[str, bool]:
|
|
246
|
+
if not profile.path:
|
|
247
|
+
raise HTTPException(409, "Project profile is disabled.")
|
|
248
|
+
try:
|
|
249
|
+
profile.update_ui(values)
|
|
250
|
+
except ValueError as exc:
|
|
251
|
+
raise HTTPException(422, str(exc)) from exc
|
|
252
|
+
return {"saved": True}
|
|
253
|
+
|
|
254
|
+
@app.get("/api/webui/queues")
|
|
255
|
+
async def queues(
|
|
256
|
+
session_id: Annotated[str | None, Cookie(alias=COOKIE)] = None,
|
|
257
|
+
) -> list[dict[str, Any]]:
|
|
258
|
+
conn = connection(session_id)
|
|
259
|
+
queue_list = validated(
|
|
260
|
+
await upstream.request(conn, "GET", "/api/v2/queues"),
|
|
261
|
+
QUEUES_ADAPTER,
|
|
262
|
+
)
|
|
263
|
+
semaphore = asyncio.Semaphore(6)
|
|
264
|
+
|
|
265
|
+
async def limited_request(path: str, params: dict[str, Any]) -> Any:
|
|
266
|
+
async with semaphore:
|
|
267
|
+
return await upstream.request(conn, "GET", path, params=params)
|
|
268
|
+
|
|
269
|
+
async def summarize(item: dict[str, str]) -> dict[str, Any]:
|
|
270
|
+
name = item["name"]
|
|
271
|
+
path = f"/api/v2/queues/{quote(name, safe='')}/tasks"
|
|
272
|
+
values = await asyncio.gather(
|
|
273
|
+
*(limited_request(path + "/count", {"status": value}) for value in STATUSES),
|
|
274
|
+
limited_request(
|
|
275
|
+
path,
|
|
276
|
+
{"limit": 1, "order_by": "updated_at", "descending": "true"},
|
|
277
|
+
),
|
|
278
|
+
)
|
|
279
|
+
counts = {
|
|
280
|
+
state: validated(values[index], CountResponse)["count"]
|
|
281
|
+
for index, state in enumerate(STATUSES)
|
|
282
|
+
}
|
|
283
|
+
recent_page = validated(values[-1], TaskPageResponse)
|
|
284
|
+
recent_items = recent_page["items"]
|
|
285
|
+
return {
|
|
286
|
+
"name": name,
|
|
287
|
+
"counts": counts,
|
|
288
|
+
"recent": recent_items[0] if recent_items else None,
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return await asyncio.gather(*(summarize(item) for item in queue_list))
|
|
292
|
+
|
|
293
|
+
@app.get("/api/webui/queues/{queue}/tasks")
|
|
294
|
+
async def list_tasks(
|
|
295
|
+
queue: str,
|
|
296
|
+
session_id: Annotated[str | None, Cookie(alias=COOKIE)] = None,
|
|
297
|
+
status: TaskStatus | None = None,
|
|
298
|
+
name: str | None = None,
|
|
299
|
+
filter_expression: Annotated[str | None, Query(alias="filter")] = None,
|
|
300
|
+
order_by: TaskOrderField = "created_at",
|
|
301
|
+
descending: bool = True,
|
|
302
|
+
cursor: str | None = None,
|
|
303
|
+
) -> Any:
|
|
304
|
+
params = {"limit": 100, "order_by": order_by, "descending": str(descending).lower()}
|
|
305
|
+
params.update(
|
|
306
|
+
{
|
|
307
|
+
k: v
|
|
308
|
+
for k, v in {
|
|
309
|
+
"status": status,
|
|
310
|
+
"name": name,
|
|
311
|
+
"filter": filter_expression,
|
|
312
|
+
"cursor": cursor,
|
|
313
|
+
}.items()
|
|
314
|
+
if v
|
|
315
|
+
}
|
|
316
|
+
)
|
|
317
|
+
return validated(
|
|
318
|
+
await upstream.request(
|
|
319
|
+
connection(session_id),
|
|
320
|
+
"GET",
|
|
321
|
+
f"/api/v2/queues/{quote(queue, safe='')}/tasks",
|
|
322
|
+
params=params,
|
|
323
|
+
),
|
|
324
|
+
TaskPageResponse,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
@app.get("/api/webui/queues/{queue}/tasks/count")
|
|
328
|
+
async def count_tasks(
|
|
329
|
+
queue: str,
|
|
330
|
+
session_id: Annotated[str | None, Cookie(alias=COOKIE)] = None,
|
|
331
|
+
status: TaskStatus | None = None,
|
|
332
|
+
name: str | None = None,
|
|
333
|
+
filter_expression: Annotated[str | None, Query(alias="filter")] = None,
|
|
334
|
+
) -> Any:
|
|
335
|
+
params = {
|
|
336
|
+
k: v
|
|
337
|
+
for k, v in {"status": status, "name": name, "filter": filter_expression}.items()
|
|
338
|
+
if v
|
|
339
|
+
}
|
|
340
|
+
return validated(
|
|
341
|
+
await upstream.request(
|
|
342
|
+
connection(session_id),
|
|
343
|
+
"GET",
|
|
344
|
+
f"/api/v2/queues/{quote(queue, safe='')}/tasks/count",
|
|
345
|
+
params=params,
|
|
346
|
+
),
|
|
347
|
+
CountResponse,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
@app.get("/api/webui/queues/{queue}/tasks/{task_id}")
|
|
351
|
+
async def get_task(
|
|
352
|
+
queue: str, task_id: str, session_id: Annotated[str | None, Cookie(alias=COOKIE)] = None
|
|
353
|
+
) -> Any:
|
|
354
|
+
return validated(
|
|
355
|
+
await upstream.request(
|
|
356
|
+
connection(session_id),
|
|
357
|
+
"GET",
|
|
358
|
+
f"/api/v2/queues/{quote(queue, safe='')}/tasks/{quote(task_id, safe='')}",
|
|
359
|
+
),
|
|
360
|
+
TaskResponse,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
@app.post("/api/webui/queues/{queue}/tasks/{task_id}/{action}")
|
|
364
|
+
async def lifecycle(
|
|
365
|
+
queue: str,
|
|
366
|
+
task_id: str,
|
|
367
|
+
action: str,
|
|
368
|
+
session_id: Annotated[str | None, Cookie(alias=COOKIE)] = None,
|
|
369
|
+
) -> Any:
|
|
370
|
+
if action not in {"cancel", "requeue"}:
|
|
371
|
+
raise HTTPException(404)
|
|
372
|
+
return validated(
|
|
373
|
+
await upstream.request(
|
|
374
|
+
connection(session_id),
|
|
375
|
+
"POST",
|
|
376
|
+
f"/api/v2/queues/{quote(queue, safe='')}/tasks/{quote(task_id, safe='')}/{action}",
|
|
377
|
+
),
|
|
378
|
+
TaskResponse,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
@app.post("/api/webui/queues/{queue}/delete-snapshot")
|
|
382
|
+
async def delete_snapshot(
|
|
383
|
+
queue: str,
|
|
384
|
+
selector: SelectRequest,
|
|
385
|
+
session_id: Annotated[str | None, Cookie(alias=COOKIE)] = None,
|
|
386
|
+
) -> dict[str, Any]:
|
|
387
|
+
if not any([selector.status, selector.name, selector.filter and selector.filter.strip()]):
|
|
388
|
+
raise HTTPException(
|
|
389
|
+
422,
|
|
390
|
+
{
|
|
391
|
+
"code": "selector_required",
|
|
392
|
+
"message": "At least one non-empty selector is required.",
|
|
393
|
+
},
|
|
394
|
+
)
|
|
395
|
+
conn = connection(session_id)
|
|
396
|
+
params = {
|
|
397
|
+
k: v
|
|
398
|
+
for k, v in {
|
|
399
|
+
"status": selector.status,
|
|
400
|
+
"name": selector.name,
|
|
401
|
+
"filter": selector.filter,
|
|
402
|
+
}.items()
|
|
403
|
+
if v
|
|
404
|
+
}
|
|
405
|
+
ids: list[str] = []
|
|
406
|
+
cursor: str | None = None
|
|
407
|
+
while True:
|
|
408
|
+
page_params = {**params, "limit": 1000}
|
|
409
|
+
if cursor:
|
|
410
|
+
page_params["cursor"] = cursor
|
|
411
|
+
page = validated(
|
|
412
|
+
await upstream.request(
|
|
413
|
+
conn,
|
|
414
|
+
"GET",
|
|
415
|
+
f"/api/v2/queues/{quote(queue, safe='')}/tasks",
|
|
416
|
+
params=page_params,
|
|
417
|
+
),
|
|
418
|
+
TaskPageResponse,
|
|
419
|
+
)
|
|
420
|
+
ids.extend(item["id"] for item in page["items"])
|
|
421
|
+
if len(ids) > 1000:
|
|
422
|
+
raise HTTPException(
|
|
423
|
+
422,
|
|
424
|
+
{
|
|
425
|
+
"code": "batch_too_large",
|
|
426
|
+
"message": "More than 1,000 Tasks match. Refine the filter.",
|
|
427
|
+
"details": {"limit": 1000},
|
|
428
|
+
},
|
|
429
|
+
)
|
|
430
|
+
cursor = page.get("next_cursor")
|
|
431
|
+
if not cursor:
|
|
432
|
+
break
|
|
433
|
+
return {
|
|
434
|
+
"queue": queue,
|
|
435
|
+
"task_ids": ids,
|
|
436
|
+
"count": len(ids),
|
|
437
|
+
"selector": selector.model_dump(),
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
@app.post("/api/webui/queues/{queue}/delete-operations", status_code=202)
|
|
441
|
+
async def start_delete(
|
|
442
|
+
queue: str,
|
|
443
|
+
payload: BatchRequest,
|
|
444
|
+
session_id: Annotated[str | None, Cookie(alias=COOKIE)] = None,
|
|
445
|
+
) -> dict[str, Any]:
|
|
446
|
+
return operations.start(connection(session_id), queue, payload.task_ids).public()
|
|
447
|
+
|
|
448
|
+
@app.get("/api/webui/delete-operations/{operation_id}")
|
|
449
|
+
async def operation(
|
|
450
|
+
operation_id: str,
|
|
451
|
+
session_id: Annotated[str | None, Cookie(alias=COOKIE)] = None,
|
|
452
|
+
) -> dict[str, Any]:
|
|
453
|
+
item = operations.get(operation_id, connection(session_id))
|
|
454
|
+
if not item:
|
|
455
|
+
raise HTTPException(
|
|
456
|
+
404,
|
|
457
|
+
{
|
|
458
|
+
"code": "operation_history_lost",
|
|
459
|
+
"message": (
|
|
460
|
+
"Operation history was not found; it may have been lost after restart. "
|
|
461
|
+
"Re-run the current filter to inspect actual Server state."
|
|
462
|
+
),
|
|
463
|
+
},
|
|
464
|
+
)
|
|
465
|
+
return item.public()
|
|
466
|
+
|
|
467
|
+
@app.post("/api/webui/delete-operations/{operation_id}/stop")
|
|
468
|
+
async def stop_operation(
|
|
469
|
+
operation_id: str,
|
|
470
|
+
session_id: Annotated[str | None, Cookie(alias=COOKIE)] = None,
|
|
471
|
+
) -> dict[str, Any]:
|
|
472
|
+
item = operations.stop(operation_id, connection(session_id))
|
|
473
|
+
if not item:
|
|
474
|
+
raise HTTPException(404)
|
|
475
|
+
return item.public()
|
|
476
|
+
|
|
477
|
+
@app.post("/api/webui/delete-operations/{operation_id}/retry", status_code=202)
|
|
478
|
+
async def retry_operation(
|
|
479
|
+
operation_id: str,
|
|
480
|
+
session_id: Annotated[str | None, Cookie(alias=COOKIE)] = None,
|
|
481
|
+
) -> dict[str, Any]:
|
|
482
|
+
item = operations.retry_failed(operation_id, connection(session_id))
|
|
483
|
+
if not item:
|
|
484
|
+
raise HTTPException(
|
|
485
|
+
409,
|
|
486
|
+
"Only a completed operation with failures can be retried.",
|
|
487
|
+
)
|
|
488
|
+
return item.public()
|
|
489
|
+
|
|
490
|
+
static_dir = Path(__file__).with_name("static")
|
|
491
|
+
if not static_dir.exists():
|
|
492
|
+
static_dir = Path(__file__).resolve().parents[2] / "frontend" / "dist"
|
|
493
|
+
if static_dir.exists():
|
|
494
|
+
app.mount("/assets", StaticFiles(directory=static_dir / "assets"), name="assets")
|
|
495
|
+
|
|
496
|
+
@app.get("/{path:path}", include_in_schema=False)
|
|
497
|
+
async def frontend(path: str) -> FileResponse:
|
|
498
|
+
candidate = (static_dir / path).resolve()
|
|
499
|
+
if path and candidate.is_file() and static_dir.resolve() in candidate.parents:
|
|
500
|
+
return FileResponse(candidate)
|
|
501
|
+
return FileResponse(static_dir / "index.html")
|
|
502
|
+
|
|
503
|
+
return app
|
labtasker_webui/cli.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import uvicorn
|
|
8
|
+
|
|
9
|
+
from .app import create_app
|
|
10
|
+
from .config import Settings, is_loopback_host
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main() -> None:
|
|
14
|
+
parser = argparse.ArgumentParser(
|
|
15
|
+
prog="labtasker-webui", description="Web interface for Labtasker v2"
|
|
16
|
+
)
|
|
17
|
+
parser.add_argument("--host", default=os.getenv("LABTASKER_WEBUI_HOST", "127.0.0.1"))
|
|
18
|
+
parser.add_argument("--port", type=int, default=int(os.getenv("LABTASKER_WEBUI_PORT", "8080")))
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"--no-profile", action="store_true", help="Disable project profile persistence"
|
|
21
|
+
)
|
|
22
|
+
args = parser.parse_args()
|
|
23
|
+
configured_origins = os.getenv("LABTASKER_WEBUI_ALLOWED_SERVER_ORIGINS", "")
|
|
24
|
+
origins = tuple(item.strip() for item in configured_origins.split(",") if item.strip())
|
|
25
|
+
settings = Settings(
|
|
26
|
+
host=args.host,
|
|
27
|
+
port=args.port,
|
|
28
|
+
allowed_server_origins=origins,
|
|
29
|
+
profile_path=Path.cwd() / ".labtasker" / "webui-profile.json"
|
|
30
|
+
if is_loopback_host(args.host) and not args.no_profile
|
|
31
|
+
else None,
|
|
32
|
+
)
|
|
33
|
+
uvicorn.run(create_app(settings), host=settings.host, port=settings.port)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def is_loopback_host(host: str) -> bool:
|
|
10
|
+
if host.lower() == "localhost":
|
|
11
|
+
return True
|
|
12
|
+
try:
|
|
13
|
+
return ipaddress.ip_address(host).is_loopback
|
|
14
|
+
except ValueError:
|
|
15
|
+
return False
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def normalize_origin(value: str) -> str:
|
|
19
|
+
parsed = urlsplit(value)
|
|
20
|
+
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
|
21
|
+
raise ValueError("Server origins must be absolute HTTP(S) URLs.")
|
|
22
|
+
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
|
23
|
+
raise ValueError("Server origins cannot contain credentials, query, or fragment.")
|
|
24
|
+
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
|
25
|
+
default = (parsed.scheme == "https" and port == 443) or (parsed.scheme == "http" and port == 80)
|
|
26
|
+
return f"{parsed.scheme}://{parsed.hostname.lower()}{'' if default else f':{port}'}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class Settings:
|
|
31
|
+
host: str = "127.0.0.1"
|
|
32
|
+
port: int = 8080
|
|
33
|
+
server_url: str | None = None
|
|
34
|
+
server_token: str | None = None
|
|
35
|
+
allowed_server_origins: tuple[str, ...] = ()
|
|
36
|
+
|
|
37
|
+
profile_path: Path | None = None
|
|
38
|
+
local_directory: Path | None = None
|
|
39
|
+
|
|
40
|
+
def __post_init__(self) -> None:
|
|
41
|
+
if self.local_directory is not None:
|
|
42
|
+
if self.server_url or self.server_token:
|
|
43
|
+
raise ValueError("--local cannot be combined with a Server URL or token.")
|
|
44
|
+
if not is_loopback_host(self.host):
|
|
45
|
+
raise ValueError("Local attachment is available only on a loopback bind.")
|
|
46
|
+
directory = self.local_directory.resolve()
|
|
47
|
+
if not directory.is_dir():
|
|
48
|
+
raise ValueError("The local project directory does not exist.")
|
|
49
|
+
object.__setattr__(self, "local_directory", directory)
|
|
50
|
+
if not 1 <= self.port <= 65535:
|
|
51
|
+
raise ValueError("port must be between 1 and 65535.")
|
|
52
|
+
if self.profile_path and not is_loopback_host(self.host):
|
|
53
|
+
raise ValueError("Project profiles are available only on a loopback bind.")
|
|
54
|
+
origins = tuple(normalize_origin(item) for item in self.allowed_server_origins)
|
|
55
|
+
object.__setattr__(self, "allowed_server_origins", origins)
|
|
56
|
+
if not is_loopback_host(self.host) and not origins:
|
|
57
|
+
raise ValueError("A non-loopback bind requires --allowed-server-origin.")
|
|
58
|
+
if self.server_token and not self.server_url:
|
|
59
|
+
raise ValueError("LABTASKER_WEBUI_SERVER_TOKEN requires --server-url.")
|
|
60
|
+
if self.server_url:
|
|
61
|
+
origin = normalize_origin(self.server_url)
|
|
62
|
+
if not is_loopback_host(self.host) and origin not in origins:
|
|
63
|
+
raise ValueError("The configured Server URL must match the upstream allowlist.")
|
labtasker_webui/local.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Attach-only adapter for Labtasker's project-local transport."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .sessions import Connection
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def local_connection(directory: Path) -> Connection:
|
|
13
|
+
if os.name != "posix":
|
|
14
|
+
raise ValueError("Local connections require POSIX Unix sockets.")
|
|
15
|
+
# Import only the pure path resolver. Never construct a Client or call ensure/start.
|
|
16
|
+
try:
|
|
17
|
+
local = importlib.import_module("labtasker.local")
|
|
18
|
+
except ImportError as exc:
|
|
19
|
+
raise ValueError("Local attachment requires labtasker-webui[local].") from exc
|
|
20
|
+
local.require_local_capabilities()
|
|
21
|
+
paths = local.local_paths(directory)
|
|
22
|
+
return Connection(f"local:{paths.directory}", None, 0, str(paths.socket))
|