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.
@@ -0,0 +1,197 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import hmac
6
+ import secrets
7
+ import time
8
+ from dataclasses import asdict, dataclass, field
9
+ from typing import Any
10
+ from urllib.parse import quote
11
+
12
+ from .sessions import Connection
13
+ from .upstream import Upstream, UpstreamError
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class Outcome:
18
+ task_id: str
19
+ status: str
20
+ code: str | None = None
21
+ message: str | None = None
22
+
23
+
24
+ @dataclass(slots=True)
25
+ class Operation:
26
+ id: str
27
+ queue: str
28
+ task_ids: tuple[str, ...]
29
+ connection: Connection | None
30
+ owner_digest: bytes
31
+ created_at: float = field(default_factory=time.monotonic)
32
+ updated_at: float = field(default_factory=time.monotonic)
33
+ outcomes: list[Outcome] = field(default_factory=list)
34
+ stopped: bool = False
35
+ done: bool = False
36
+
37
+ def public(self) -> dict[str, Any]:
38
+ counts = {"deleted": 0, "absent": 0, "failed": 0, "stopped": 0}
39
+ for item in self.outcomes:
40
+ counts[item.status] = counts.get(item.status, 0) + 1
41
+ return {
42
+ "id": self.id,
43
+ "queue": self.queue,
44
+ "total": len(self.task_ids),
45
+ "completed": len(self.outcomes),
46
+ "stopped": self.stopped,
47
+ "done": self.done,
48
+ "counts": counts,
49
+ "outcomes": [asdict(item) for item in self.outcomes],
50
+ }
51
+
52
+
53
+ class OperationStore:
54
+ def __init__(
55
+ self,
56
+ upstream: Upstream,
57
+ *,
58
+ concurrency: int = 5,
59
+ ttl_seconds: int = 60 * 60,
60
+ maximum: int = 256,
61
+ ) -> None:
62
+ self.upstream = upstream
63
+ self.concurrency = concurrency
64
+ self.ttl_seconds = ttl_seconds
65
+ self.maximum = maximum
66
+ self.items: dict[str, Operation] = {}
67
+ self._tasks: set[asyncio.Task[None]] = set()
68
+
69
+ def start(self, connection: Connection, queue: str, task_ids: list[str]) -> Operation:
70
+ self.cleanup()
71
+ unique_ids = tuple(dict.fromkeys(task_ids))
72
+ if not unique_ids or len(unique_ids) > 1000:
73
+ raise ValueError("A batch must contain between 1 and 1,000 unique Task IDs.")
74
+ while len(self.items) >= self.maximum:
75
+ completed = [key for key, item in self.items.items() if item.done]
76
+ if not completed:
77
+ raise UpstreamError(
78
+ 503,
79
+ "operation_capacity",
80
+ "Too many active delete operations. Wait for one to finish and try again.",
81
+ )
82
+ oldest = min(completed, key=lambda key: self.items[key].updated_at)
83
+ self.items.pop(oldest, None)
84
+ operation = Operation(
85
+ secrets.token_urlsafe(18),
86
+ queue,
87
+ unique_ids,
88
+ connection,
89
+ self._connection_digest(connection),
90
+ )
91
+ self.items[operation.id] = operation
92
+ task = asyncio.create_task(self._run(operation))
93
+ self._tasks.add(task)
94
+ task.add_done_callback(self._tasks.discard)
95
+ return operation
96
+
97
+ def get(
98
+ self,
99
+ operation_id: str,
100
+ connection: Connection | None = None,
101
+ ) -> Operation | None:
102
+ self.cleanup()
103
+ operation = self.items.get(operation_id)
104
+ if operation is None or (
105
+ connection is not None
106
+ and not hmac.compare_digest(
107
+ operation.owner_digest,
108
+ self._connection_digest(connection),
109
+ )
110
+ ):
111
+ return None
112
+ return operation
113
+
114
+ def retry_failed(
115
+ self,
116
+ operation_id: str,
117
+ connection: Connection | None = None,
118
+ ) -> Operation | None:
119
+ previous = self.get(operation_id, connection)
120
+ if previous is None or not previous.done:
121
+ return None
122
+ failed = [item.task_id for item in previous.outcomes if item.status == "failed"]
123
+ if not failed:
124
+ return None
125
+ retry_connection = connection or previous.connection
126
+ if retry_connection is None:
127
+ return None
128
+ return self.start(retry_connection, previous.queue, failed)
129
+
130
+ def stop(
131
+ self,
132
+ operation_id: str,
133
+ connection: Connection | None = None,
134
+ ) -> Operation | None:
135
+ operation = self.get(operation_id, connection)
136
+ if operation is not None and not operation.done:
137
+ operation.stopped = True
138
+ operation.updated_at = time.monotonic()
139
+ return operation
140
+
141
+ def cleanup(self) -> None:
142
+ now = time.monotonic()
143
+ expired = [
144
+ key
145
+ for key, value in self.items.items()
146
+ if value.done and now - value.updated_at > self.ttl_seconds
147
+ ]
148
+ for key in expired:
149
+ self.items.pop(key, None)
150
+
151
+ @staticmethod
152
+ def _connection_digest(connection: Connection) -> bytes:
153
+ value = f"{connection.server_url}\0{connection.token or ''}".encode()
154
+ return hashlib.sha256(value).digest()
155
+
156
+ async def _run(self, operation: Operation) -> None:
157
+ active_connection = operation.connection
158
+ if active_connection is None:
159
+ operation.done = True
160
+ return
161
+ pending = asyncio.Queue[str]()
162
+ for task_id in operation.task_ids:
163
+ pending.put_nowait(task_id)
164
+
165
+ async def worker() -> None:
166
+ while not operation.stopped:
167
+ try:
168
+ task_id = pending.get_nowait()
169
+ except asyncio.QueueEmpty:
170
+ return
171
+ try:
172
+ await self.upstream.request(
173
+ active_connection,
174
+ "DELETE",
175
+ f"/api/v2/queues/{quote(operation.queue, safe='')}/tasks/"
176
+ f"{quote(task_id, safe='')}",
177
+ )
178
+ operation.outcomes.append(Outcome(task_id, "deleted"))
179
+ except UpstreamError as exc:
180
+ if exc.status == 404:
181
+ operation.outcomes.append(Outcome(task_id, "absent", exc.code, exc.message))
182
+ else:
183
+ operation.outcomes.append(Outcome(task_id, "failed", exc.code, exc.message))
184
+ finally:
185
+ operation.updated_at = time.monotonic()
186
+ pending.task_done()
187
+
188
+ await asyncio.gather(*(worker() for _ in range(self.concurrency)))
189
+ while not pending.empty():
190
+ task_id = pending.get_nowait()
191
+ operation.outcomes.append(
192
+ Outcome(task_id, "stopped", "not_dispatched", "Deletion was not started.")
193
+ )
194
+ pending.task_done()
195
+ operation.done = True
196
+ operation.connection = None
197
+ operation.updated_at = time.monotonic()
@@ -0,0 +1,89 @@
1
+ """Project-local WebUI profile; credentials never enter profile API responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tempfile
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ UI_KEYS = {
12
+ "labtasker:queueLayouts:v1",
13
+ "labtasker:views:v1",
14
+ "labtasker:column-widths",
15
+ "labtasker:columns:v3",
16
+ "labtasker:customColumns:v1",
17
+ "labtasker:columnOrder:v1",
18
+ "labtasker:drawerWidth",
19
+ "labtasker:lastQueue",
20
+ "labtasker:filterPresets:v1",
21
+ "labtasker:filters:v1",
22
+ }
23
+
24
+
25
+ class Profile:
26
+ def __init__(self, path: Path | None) -> None:
27
+ self.path = path
28
+ self.token_path = path.with_name("webui-token") if path else None
29
+ self.data: dict[str, Any] = {"version": 1, "connection": None, "ui": {}}
30
+ if path and path.exists():
31
+ if path.is_symlink():
32
+ raise ValueError("WebUI profile must not be a symbolic link.")
33
+ data = json.loads(path.read_text())
34
+ if not isinstance(data, dict) or data.get("version") != 1:
35
+ raise ValueError("Unsupported WebUI profile format.")
36
+ self.data.update(data)
37
+ os.chmod(path, 0o600)
38
+ connection = self.data.get("connection")
39
+ if isinstance(connection, dict):
40
+ if "token" in connection:
41
+ # Migrate the earlier combined profile without exposing the token.
42
+ self.set_connection(connection["server_url"], connection.get("token"))
43
+ elif self.token_path and self.token_path.exists():
44
+ if self.token_path.is_symlink():
45
+ raise ValueError("WebUI token must not be a symbolic link.")
46
+ os.chmod(self.token_path, 0o600)
47
+ connection["token"] = self.token_path.read_text().strip() or None
48
+
49
+ @staticmethod
50
+ def _write(path: Path, text: str) -> None:
51
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
52
+ fd, temporary = tempfile.mkstemp(prefix=".webui-profile-", dir=path.parent)
53
+ try:
54
+ with os.fdopen(fd, "w") as stream:
55
+ stream.write(text)
56
+ stream.flush()
57
+ os.fsync(stream.fileno())
58
+ os.replace(temporary, path)
59
+ finally:
60
+ if os.path.exists(temporary):
61
+ os.unlink(temporary)
62
+
63
+ def save(self) -> None:
64
+ if not self.path:
65
+ return
66
+ data = dict(self.data)
67
+ if isinstance(data.get("connection"), dict):
68
+ data["connection"] = {"server_url": data["connection"]["server_url"]}
69
+ self._write(self.path, json.dumps(data, ensure_ascii=False, indent=2))
70
+
71
+ def set_connection(self, url: str | None, token: str | None = None) -> None:
72
+ if self.token_path:
73
+ if url and token:
74
+ self._write(self.token_path, token)
75
+ else:
76
+ self.token_path.unlink(missing_ok=True)
77
+ self.data["connection"] = {"server_url": url, "token": token} if url else None
78
+ self.save()
79
+
80
+ def update_ui(self, values: dict[str, str]) -> None:
81
+ if any(key not in UI_KEYS or len(value) > 100_000 for key, value in values.items()):
82
+ raise ValueError("Unsupported or oversized UI setting.")
83
+ previous = self.data["ui"]
84
+ self.data["ui"] = {**previous, **values}
85
+ try:
86
+ self.save()
87
+ except OSError:
88
+ self.data["ui"] = previous
89
+ raise
File without changes
@@ -0,0 +1,80 @@
1
+ from datetime import datetime
2
+ from typing import Any, Literal
3
+
4
+ from pydantic import BaseModel, ConfigDict, Field
5
+
6
+ TaskStatus = Literal["pending", "running", "succeeded", "failed", "cancelled"]
7
+ TaskOrderField = Literal[
8
+ "id",
9
+ "name",
10
+ "status",
11
+ "priority",
12
+ "attempt",
13
+ "max_attempts",
14
+ "last_route",
15
+ "created_at",
16
+ "updated_at",
17
+ "started_at",
18
+ "finished_at",
19
+ ]
20
+
21
+
22
+ class ConnectRequest(BaseModel):
23
+ server_url: str = ""
24
+ mode: Literal["http", "local"] = "http"
25
+ directory: str = "."
26
+ token: str | None = None
27
+
28
+
29
+ class SelectRequest(BaseModel):
30
+ status: TaskStatus | None = None
31
+ name: str | None = None
32
+ filter: str | None = None
33
+
34
+
35
+ class BatchRequest(BaseModel):
36
+ task_ids: list[str] = Field(min_length=1, max_length=1000)
37
+
38
+
39
+ class QueueResponse(BaseModel):
40
+ name: str
41
+
42
+
43
+ class LastErrorResponse(BaseModel):
44
+ type: str
45
+ message: str
46
+ traceback: str | None
47
+ occurred_at: datetime
48
+ attempt: int
49
+ run_id: str
50
+
51
+
52
+ class TaskResponse(BaseModel):
53
+ model_config = ConfigDict(extra="allow")
54
+
55
+ id: str
56
+ queue: str
57
+ status: TaskStatus
58
+ name: str | None
59
+ args: dict[str, Any]
60
+ metadata: dict[str, Any]
61
+ priority: int
62
+ attempt: int
63
+ max_attempts: int
64
+ routes: list[str]
65
+ result: dict[str, Any]
66
+ last_error: LastErrorResponse | None
67
+ last_route: str | None
68
+ created_at: datetime
69
+ updated_at: datetime
70
+ started_at: datetime | None
71
+ finished_at: datetime | None
72
+
73
+
74
+ class TaskPageResponse(BaseModel):
75
+ items: list[TaskResponse]
76
+ next_cursor: str | None
77
+
78
+
79
+ class CountResponse(BaseModel):
80
+ count: int = Field(ge=0)
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ import ipaddress
4
+ import socket
5
+ from urllib.parse import urlsplit, urlunsplit
6
+
7
+ from .config import normalize_origin
8
+
9
+
10
+ class DestinationBlocked(ValueError):
11
+ pass
12
+
13
+
14
+ def validate_server_url(value: str, allowed_origins: tuple[str, ...], strict: bool) -> str:
15
+ parsed = urlsplit(value.strip())
16
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname:
17
+ raise DestinationBlocked("Server URL must start with http:// or https://.")
18
+ if parsed.username or parsed.password:
19
+ raise DestinationBlocked("Embedded URL credentials are not allowed.")
20
+ try:
21
+ origin = normalize_origin(value)
22
+ except ValueError as exc:
23
+ raise DestinationBlocked(str(exc)) from exc
24
+ if strict and origin not in allowed_origins:
25
+ raise DestinationBlocked("This Server origin is not in the WebUI upstream allowlist.")
26
+ path = parsed.path.rstrip("/")
27
+ return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
28
+
29
+
30
+ def resolve_addresses(
31
+ hostname: str, port: int
32
+ ) -> set[ipaddress.IPv4Address | ipaddress.IPv6Address]:
33
+ try:
34
+ addresses = {
35
+ ipaddress.ip_address(item[4][0]) for item in socket.getaddrinfo(hostname, port)
36
+ }
37
+ if not addresses:
38
+ raise DestinationBlocked(f"The Server host resolved to no addresses: {hostname}")
39
+ return addresses
40
+ except socket.gaierror as exc:
41
+ raise DestinationBlocked(f"The Server host could not be resolved: {hostname}") from exc
42
+
43
+
44
+ def validate_resolved_destination(url: str, allowed_origins: tuple[str, ...], strict: bool) -> None:
45
+ parsed = urlsplit(url)
46
+ if strict and normalize_origin(url) not in allowed_origins:
47
+ raise DestinationBlocked(
48
+ "The resolved Server origin is not in the WebUI upstream allowlist."
49
+ )
50
+ # Resolution is performed on every request so DNS changes cannot silently bypass validation.
51
+ resolve_addresses(
52
+ parsed.hostname or "", parsed.port or (443 if parsed.scheme == "https" else 80)
53
+ )
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ import time
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(slots=True)
9
+ class Connection:
10
+ server_url: str
11
+ token: str | None
12
+ touched_at: float
13
+ socket_path: str | None = None
14
+
15
+
16
+ class SessionStore:
17
+ def __init__(self, ttl_seconds: int = 12 * 60 * 60, maximum: int = 2048) -> None:
18
+ self.ttl_seconds = ttl_seconds
19
+ self.maximum = maximum
20
+ self._items: dict[str, Connection] = {}
21
+
22
+ def create(self, server_url: str, token: str | None, socket_path: str | None = None) -> str:
23
+ self.cleanup()
24
+ if len(self._items) >= self.maximum:
25
+ oldest = min(self._items, key=lambda key: self._items[key].touched_at)
26
+ self._items.pop(oldest, None)
27
+ session_id = secrets.token_urlsafe(32)
28
+ self._items[session_id] = Connection(server_url, token, time.monotonic(), socket_path)
29
+ return session_id
30
+
31
+ def get(self, session_id: str | None) -> Connection | None:
32
+ if not session_id:
33
+ return None
34
+ item = self._items.get(session_id)
35
+ if not item or time.monotonic() - item.touched_at > self.ttl_seconds:
36
+ self._items.pop(session_id, None)
37
+ return None
38
+ item.touched_at = time.monotonic()
39
+ return item
40
+
41
+ def delete(self, session_id: str | None) -> None:
42
+ if session_id:
43
+ self._items.pop(session_id, None)
44
+
45
+ def cleanup(self) -> None:
46
+ now = time.monotonic()
47
+ expired = [
48
+ key for key, value in self._items.items() if now - value.touched_at > self.ttl_seconds
49
+ ]
50
+ for key in expired:
51
+ self._items.pop(key, None)
@@ -0,0 +1 @@
1
+ :root{--font-ui:-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;--font-code:ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;--text-small:11px;--text-body:12px;--text-title:22px;--header-height:40px;--control-height:32px;font-family:var(--font-ui);font-size:var(--text-body);color:#202631;font-synthesis:none;--blue:#1769d2;--line:#dfe3e8;--muted:#687385;--panel:#fff;--red:#c93636;background:#f8f9fb;font-weight:400;line-height:1.5}*{box-sizing:border-box}#toast{z-index:40;color:#344054;max-width:420px;font-size:var(--text-body);opacity:0;pointer-events:none;background:#fff;border:1px solid #cbd8e8;border-radius:7px;padding:11px 14px;transition:all .16s;position:fixed;bottom:20px;right:20px;transform:translateY(8px);box-shadow:0 10px 30px #20395624}#toast.visible{opacity:1;transform:none}body{min-width:320px;margin:0}button,input,select{font:inherit;letter-spacing:inherit}input,select{font-weight:400}h1,h2,h3,strong,b{font-weight:600}button{cursor:pointer;white-space:nowrap}:where(button,input,select,summary,a,[tabindex]):focus-visible{outline:2px solid var(--blue);outline-offset:3px}.top{height:var(--header-height);border-bottom:1px solid var(--line);z-index:8;background:#fff;justify-content:space-between;align-items:center;padding:0 20px;display:flex;position:sticky;top:0}.brand{align-items:center;gap:8px;font-size:17px;line-height:24px;display:flex}.logo{object-fit:contain;flex-shrink:0;width:24px;height:24px;display:block}.server{font-size:var(--text-body);color:var(--muted);align-items:center;gap:8px;display:flex}.live{background:#2c9a67;border-radius:50%;width:8px;height:8px}.page,.workspace{max-width:none;margin:auto;padding:14px 20px 16px}.page-head,.workspace-head{justify-content:space-between;align-items:flex-start;gap:16px;margin-bottom:18px;display:flex}.eyebrow{letter-spacing:.13em;color:#6d788a;font-size:11px;font-weight:600}.page h1,.workspace h1{font-size:var(--text-title);letter-spacing:-.025em;margin:5px 0 4px;font-weight:600;line-height:1.25}.page-head p,.workspace-head p{color:var(--muted);font-size:var(--text-body);margin:0}.primary,.secondary,.toolbar button,.pager button,.actions button,.modal-actions button{color:#303846;min-height:var(--control-height);font-weight:500;font-size:var(--text-body);background:#fff;border:1px solid #cdd3da;border-radius:6px;padding:0 14px;line-height:1.25}.primary,.toolbar button.primary{background:var(--blue);border-color:var(--blue);color:#fff}.toolbar button.danger-link:not(:disabled){color:var(--red)}.secondary{flex-shrink:0;align-items:center;gap:8px;display:flex}.compact{min-height:var(--control-height)}.link,.danger-link{color:var(--blue);font-size:var(--text-body);background:0 0;border:0;padding:3px}.danger-link{color:var(--red)}button:disabled{opacity:.45;cursor:default}.spinner{border:2px solid;border-right-color:#0000;border-radius:50%;width:14px;height:14px;animation:.8s linear infinite spin;display:inline-block}@keyframes spin{to{transform:rotate(360deg)}}.loading{text-align:center;color:var(--muted);justify-content:center;gap:10px;padding:50px;display:flex}.queue-grid{grid-template-columns:repeat(auto-fill,minmax(min(100%,430px),1fr));gap:18px;display:grid}.queue-card{border:1px solid var(--line);cursor:pointer;background:#fff;border-radius:8px;padding:16px;transition:all .15s}.queue-card:hover,.queue-card:focus{border-color:#94b9e8;box-shadow:0 4px 18px #182a4410}.queue-title{justify-content:space-between;align-items:flex-start;margin-bottom:14px;display:flex}.queue-title h2{letter-spacing:-.015em;margin:0 0 3px;font-size:18px;line-height:1.35}.queue-title span{color:var(--muted);font-size:var(--text-small)}.queue-title .arrow{color:#1769d2;font-size:19px}.status-row{border:1px solid #e4e7eb;border-radius:6px;grid-template-columns:repeat(5,1fr);display:grid;overflow:hidden}.status-row button{text-align:left;background:#fbfcfd;border:0;border-right:1px solid #e4e7eb;padding:9px 8px}.status-row button:last-child{border:0}.status-row i{border-radius:50%;width:6px;height:6px;margin-right:5px;display:inline-block}.status-row b{font-size:var(--text-body)}.status-row span{color:var(--muted);margin:3px 0 0 11px;font-size:11px;display:block}.pending{--state:#c18a18}.running{--state:#1776df}.succeeded{--state:#23855b}.failed{--state:#d04545}.cancelled{--state:#87919f}.status-row i,.badge i{background:var(--state)}.progress{background:#edf0f3;border-radius:5px;height:5px;margin:18px 0 12px;overflow:hidden}.progress span{background:#4b98e9;height:100%;display:block}.queue-foot{font-size:var(--text-small);color:var(--muted);flex-direction:column;align-items:stretch;gap:12px;min-height:25px;display:flex}.queue-metrics{font-variant-numeric:tabular-nums;justify-content:space-between;gap:14px;display:flex}.recent-task{text-overflow:ellipsis;white-space:nowrap;max-width:100%;overflow:hidden}.badge{color:#3e4755;text-transform:capitalize;white-space:nowrap;background:#f2f4f6;border-radius:12px;align-items:center;gap:6px;padding:3px 8px;font-size:11px;display:inline-flex}.badge i{border-radius:50%;width:6px;height:6px}.duration{white-space:nowrap;font-variant-numeric:tabular-nums;align-items:center;gap:5px;display:inline-flex}.duration i{background:#2c9a67;border-radius:50%;width:6px;height:6px}.empty{text-align:center;color:var(--muted);padding:70px 20px}.empty h2{color:#333b47;margin:10px 0 4px;font-size:18px}.empty p{font-size:var(--text-body);margin:0}.empty-icon{color:#9aa3af;font-size:28px}.connect{background:linear-gradient(115deg,#f6f9fd,#fff);place-items:center;min-height:100vh;display:grid}.connect-card{border:1px solid var(--line);background:#fff;border-radius:10px;width:min(450px,100vw - 32px);padding:32px;box-shadow:0 18px 50px #1d365318}.connect-copy{margin:40px 0 25px}.connect-copy h1{font-size:var(--text-title);letter-spacing:-.025em;margin:7px 0;line-height:1.25}.connect-copy p,.privacy{color:var(--muted);font-size:var(--text-body);line-height:1.55}.connect form label{font-size:var(--text-body);margin:16px 0;font-weight:500;display:block}.connect label span{float:right;color:#8b94a2;font-weight:400}.connect input{font-size:var(--text-body);border:1px solid #ccd3dc;border-radius:6px;width:100%;height:40px;margin-top:7px;padding:0 11px;font-weight:400;display:block}.connect form .primary{width:100%;margin-top:8px}.privacy{text-align:center;margin:20px 20px 0}.error,.stale{color:#9b3030;font-size:var(--text-small);background:#fff1f1;border:1px solid #f0cccc;border-radius:5px;margin:10px 0;padding:10px 12px}.field-error{color:#a23838;font-size:var(--text-small);margin:-6px 10px 12px}.stale{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.stale>div{overflow-wrap:anywhere;flex:280px;min-width:0}.stale button{color:inherit;background:0 0;border:1px solid;border-radius:5px;flex:none;padding:5px 9px}.stale{color:#7f651a;background:#fff8e5;border-color:#eadca7}.splash{place-content:center;justify-items:center;gap:25px;height:100vh;display:grid}.crumb{font-size:var(--text-body);color:var(--muted);align-items:center;gap:10px;margin-bottom:8px;display:flex}.crumb button{color:var(--blue);background:0 0;border:0;padding:0}.workspace-head{margin-bottom:12px}.stats{border:1px solid var(--line);background:#fff;border-radius:7px;grid-template-columns:repeat(5,1fr);margin-bottom:14px;display:grid}.stats button{border:0;border-right:1px solid var(--line);text-align:left;background:0 0;padding:8px 12px}.stats button:last-child{border:0}.stats span{color:var(--muted);text-transform:capitalize;font-size:11px}.stats strong{font-variant-numeric:tabular-nums;margin-top:2px;font-size:18px;line-height:1.3;display:block}.toolbar{border:1px solid var(--line);background:#fff;border-radius:7px;flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:12px;padding:10px;display:flex}.toolbar input:not([type=checkbox]),.toolbar select{height:var(--control-height);font-size:var(--text-body);background:#fff;border:1px solid #ccd3dc;border-radius:5px;padding:0 10px}.toolbar .filter{flex:180px;min-width:0}.column-menu{flex:none;position:relative}.column-menu summary{height:var(--control-height);color:#303846;font-size:var(--text-body);cursor:pointer;border:1px solid #ccd3dc;border-radius:5px;align-items:center;padding:0 10px;list-style:none;display:flex}.column-menu>div{z-index:6;border:1px solid var(--line);background:#fff;border-radius:6px;width:176px;max-height:min(280px,40dvh);padding:4px;position:fixed;top:calc(100% + 6px);left:0;overflow-y:auto;box-shadow:0 8px 22px #1d36531c}.column-menu summary{gap:12px}.column-menu summary::-webkit-details-marker{display:none}.column-menu summary:after{content:"";border-bottom:1.5px solid;border-right:1.5px solid;width:6px;height:6px;transform:translateY(-2px)rotate(45deg)}.column-menu[open] summary{background:#eceef1}.column-menu input[type=checkbox]{width:14px;height:14px;accent-color:var(--blue);flex:0 0 14px;margin:0;padding:0}.column-menu label:hover,.column-menu label:focus-within{background:#f3f5f7}.column-menu label{cursor:pointer;color:#303846;min-height:32px;font-size:var(--text-body);border-radius:3px;align-items:center;gap:10px;padding:6px 10px;display:flex}.selection{height:45px;font-size:var(--text-body);background:#f0f7ff;border:1px solid #a9caef;border-radius:6px;align-items:center;gap:10px;margin-bottom:10px;padding:0 14px;display:flex}.selection .danger-link{margin-left:auto}.table-wrap{border:1px solid var(--line);background:#fff;border-radius:7px;min-height:260px;overflow:auto}table{border-collapse:collapse;width:100%;line-height:1.35;font-size:var(--text-body)}th{text-align:left;color:#657083;border-bottom:1px solid var(--line);white-space:nowrap;background:#f7f8fa;padding:8px 10px;font-weight:500}td{color:#4c5665;white-space:nowrap;border-bottom:1px solid #edf0f2;padding:5px 10px}tbody tr{cursor:pointer}tbody tr:hover,tbody tr.chosen{background:#f4f8fd}td b{color:#2b3440;font-size:var(--text-body);margin-bottom:1px;display:block}td code{display:block}td code,.drawer code{color:var(--muted);font-family:var(--font-code);font-size:11px;font-weight:400;line-height:1.5}.chips{gap:3px;display:flex}.chips span{background:#f8f9fa;border:1px solid #dce1e7;border-radius:4px;padding:2px 5px}.pager{color:var(--muted);font-size:var(--text-small);justify-content:space-between;align-items:center;margin-top:13px;display:flex}.pager button{margin-left:7px}.drawer{right:0;top:var(--header-height);z-index:7;background:#fff;border-left:1px solid #ccd3da;flex-direction:column;width:min(600px,100vw);display:flex;position:fixed;bottom:0;box-shadow:-12px 0 35px #253b581a}.drawer-resize{z-index:2;cursor:col-resize;outline:none;width:10px;position:absolute;inset:0 auto 0 -5px}.drawer-resize:hover,.drawer-resize:focus-visible{border-left:2px solid var(--blue)}.drawer-head{border-bottom:1px solid var(--line);justify-content:space-between;padding:20px 24px;display:flex}.drawer-head h2{letter-spacing:-.015em;margin:5px 0 3px;font-size:20px;line-height:1.3}.close{color:#536071;background:#f2f4f6;border:0;border-radius:5px;width:32px;height:32px;font-size:23px}.drawer-body{padding:20px 24px 60px;overflow:auto}.task-summary{justify-content:space-between;align-items:center;margin-bottom:15px;display:flex}.actions{gap:7px;display:flex}.detail-section{border-top:1px solid var(--line);padding:18px 0}.detail-section h3{font-size:var(--text-body);margin:0 0 13px}.detail-section dl{font-size:var(--text-body);grid-template-columns:110px 1fr;gap:9px;margin:0;display:grid}.detail-section dt{color:var(--muted)}.detail-section dd{margin:0}.section-title{align-items:center;display:flex}.section-title h3{margin-right:auto}.detail-section pre{max-height:300px;font:12px/1.6 var(--font-code);background:#f5f7f9;border:1px solid #e4e7eb;border-radius:6px;padding:13px;overflow:auto}.json-tree{max-height:420px;font:12px/1.6 var(--font-code);background:#f8fafc;border:1px solid #e4e7eb;border-radius:6px;padding:10px 12px;overflow:auto}.json-node summary{cursor:pointer;color:#4b5665;list-style-position:outside}.json-node>.json-copy,.json-leaf button{visibility:hidden;color:var(--blue);font:inherit;background:0 0;border:0;margin-left:8px;padding:0}.json-node:hover>.json-copy,.json-node:focus-within>.json-copy,.json-leaf:hover button,.json-leaf:focus-within button{visibility:visible}.json-node{position:relative}.json-node>.json-copy{position:absolute;top:0;right:0}.json-children{border-left:1px solid #dce2e9;margin-left:7px;padding-left:16px}.json-key{color:#7652a2}.json-key:after{content:":"}.json-kind,.json-empty,.json-null{color:#87919f}.json-string{color:#27704e;overflow-wrap:anywhere}.json-scalar{color:#1769d2}.json-leaf{min-height:20px}.json-leaf a{color:var(--blue);overflow-wrap:anywhere}.failure{color:#9f3333}.modal-back{z-index:20;background:#26314066;place-items:center;display:grid;position:fixed;inset:0}.modal{z-index:21;background:#fff;border-radius:9px;width:min(460px,100vw - 32px);max-height:calc(100dvh - 32px);padding:26px;position:fixed;top:50%;left:50%;overflow:auto;transform:translate(-50%,-50%);box-shadow:0 24px 70px #1119}.batch-modal{width:min(620px,100vw - 32px)}.delete-scope{border:1px solid var(--line);font-size:var(--text-small);background:#f8f9fb;border-radius:6px;grid-template-columns:78px 1fr;gap:7px 12px;margin:18px 0;padding:12px;display:grid}.delete-scope dt{color:var(--muted)}.delete-scope dd{overflow-wrap:anywhere;margin:0}.operation-counts{color:var(--muted);gap:18px;display:flex}.failure-list{max-height:180px;overflow:auto}.failure-list .error{grid-template-columns:150px 1fr;gap:8px;display:grid}.modal h2{margin:8px 0;font-size:20px}.modal p{font-size:var(--text-body);color:var(--muted);line-height:1.55}.danger-mark{width:34px;height:var(--control-height);color:var(--red);background:#fff0f0;border-radius:50%;place-items:center;font-weight:700;display:grid}.modal-actions{justify-content:flex-end;gap:8px;margin-top:22px;display:flex}.danger{color:#fff!important;background:#c93636!important;border-color:#c93636!important}.operation{margin-top:20px}.operation>div:first-child{font-size:var(--text-small);justify-content:space-between;display:flex}.operation .progress{margin-top:8px}@media (width<=900px){body{min-width:0}.page,.workspace{padding:16px 18px}.queue-grid{grid-template-columns:1fr}.drawer{z-index:10;top:0;width:100%!important}.drawer-resize{display:none}.top{padding:0 18px}.status-row,.stats{grid-template-columns:repeat(5,minmax(65px,1fr));overflow:auto}.toolbar{flex-wrap:wrap}.toolbar .filter{flex-basis:100%}.table-wrap{overflow:auto}}@media (width<=600px){.top{height:auto;min-height:var(--header-height);flex-wrap:wrap;gap:10px;padding:8px 18px}.server{overflow-wrap:anywhere;flex-wrap:wrap;min-width:0}.live{flex:0 0 8px}.queue-card{min-width:0;padding:16px}.status-row,.stats{grid-template-columns:repeat(5,minmax(0,1fr))}.status-row button,.stats button{text-align:center;min-width:0;padding:10px 4px}.status-row span{margin-left:0}.stats span{font-size:11px}.toolbar{grid-template-columns:repeat(2,minmax(0,1fr));display:grid}.toolbar>*{width:100%;min-width:0}.toolbar .filter{grid-column:1/-1}.column-menu summary{justify-content:center}.column-menu>div{left:auto;right:0}.pager,.selection,.task-summary{flex-wrap:wrap;gap:12px;height:auto}.selection{padding:12px}.pager>div{margin-left:auto}.drawer-head,.drawer-body{padding-left:18px;padding-right:18px}.drawer-head>div{overflow-wrap:anywhere;min-width:0}.close{flex-shrink:0}.detail-section dd{overflow-wrap:anywhere;min-width:0}}@media (prefers-reduced-motion:reduce){*,:before,:after{transition-duration:.01ms!important;animation-duration:.01ms!important}}.workspace{height:calc(100dvh - var(--header-height));flex-direction:column;min-height:560px;display:flex}.workspace>*{flex-shrink:0}.workspace .table-wrap{flex:auto;min-height:180px}.table-wrap th{z-index:1;position:sticky;top:0}.task-name{text-overflow:ellipsis;max-width:clamp(240px,26vw,520px);overflow:hidden}@media (width<=900px){.workspace{height:auto;min-height:calc(100dvh - var(--header-height))}.workspace .table-wrap{flex:none;max-height:65dvh}}.list-summary{color:var(--muted);font-size:var(--text-small);margin-top:8px}.load-more{min-height:1px;color:var(--muted);font-size:var(--text-small);justify-content:center;align-items:center;gap:8px;display:flex}.load-more:not(:empty){padding:12px}.toolbar>input,.toolbar>select,.toolbar>button,.column-menu>summary{box-sizing:border-box;height:var(--control-height);min-height:var(--control-height);font-family:var(--font-ui);font-size:var(--text-body);vertical-align:middle;border:1px solid #ccd3dc;border-radius:5px;margin:0;padding:0 10px;line-height:18px}.toolbar>button,.column-menu>summary{justify-content:center;align-items:center;display:inline-flex}.toolbar>select{appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='m3 4.5 3 3 3-3' fill='none' stroke='%23515c6c' stroke-width='1.5'/%3E%3C/svg%3E");background-position:right 9px center;background-repeat:no-repeat;padding-right:28px}.column-menu>summary:after{width:5px;height:5px}td{line-height:16px}td code{line-height:14px}@media (pointer:coarse){.toolbar{--control-height:36px}.column-menu label{min-height:36px}}.virtual-table{table-layout:fixed}.virtual-table tbody tr:not(.virtual-spacer){height:42px}.virtual-table td{text-overflow:ellipsis;overflow:hidden}.virtual-table .task-name{max-width:100%}.virtual-table .chips{overflow:hidden}.virtual-table td code{text-overflow:ellipsis;overflow:hidden}.virtual-spacer td{border:0;padding:0}.column-resizer{cursor:col-resize;touch-action:none;z-index:2;width:7px;position:absolute;inset:0 0 0 auto}.column-resizer:hover,.column-resizer:focus-visible{background:#1769d244}.instant-tooltip{z-index:10000;pointer-events:none;color:#263140;white-space:pre-wrap;overflow-wrap:anywhere;background:#fff;border:1px solid #d5dbe3;border-radius:5px;max-width:min(640px,100vw - 16px);max-height:calc(100vh - 16px);padding:7px 10px;font:12px/1.5 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;position:fixed;overflow:hidden;box-shadow:0 3px 12px #18223026}.priority-value{white-space:nowrap;font-variant-numeric:tabular-nums;align-items:baseline;gap:4px;display:inline-flex}.priority-direction{color:#657083}.virtual-table .table-filler{border-left:0;border-right:0;padding:0}.ui-select{flex:none;position:relative}.toolbar .ui-select>button{width:100%;height:var(--control-height);min-height:var(--control-height);font:inherit;color:#202631;background:#fff;border:1px solid #ccd4df;border-radius:5px;justify-content:space-between;align-items:center;gap:14px;padding:0 10px;font-weight:400;display:flex}.ui-select-menu{z-index:100;border:1px solid var(--line);width:max-content;min-width:100%;font-size:var(--text-body);background:#fff;border-radius:5px;padding:4px;position:absolute;top:calc(100% + 4px);left:0;box-shadow:0 3px 12px #18223020}.ui-select-menu [role=option]{cursor:pointer;border-radius:3px;justify-content:space-between;align-items:center;gap:20px;min-height:28px;padding:4px 8px;display:flex}.ui-select-menu [role=option].highlighted{background:#edf3fb}.ui-select-menu [aria-selected=true]{color:#1769cc}.priority-direction.positive{color:#c93636}.priority-direction.negative{color:#2275c9}.filter-input{position:relative}.toolbar .filter-input>input{width:100%;padding-right:32px}.toolbar .filter-help-button{color:#687385;background:0 0;border:0;border-radius:4px;width:22px;height:22px;min-height:0;padding:0;font-size:12px;position:absolute;top:5px;right:5px}.toolbar .filter-help-button:hover,.toolbar .filter-help-button[aria-expanded=true]{color:#1769d2;background:#edf3fb}.filter-help{z-index:110;border:1px solid var(--line);background:#fff;border-radius:6px;width:min(460px,100vw - 48px);padding:14px;font-size:12px;line-height:1.5;position:absolute;top:calc(100% + 6px);left:0;box-shadow:0 4px 16px #18223020}.filter-help p{margin:8px 0 0}.filter-help code{font-size:11px}.filter-examples{gap:3px;margin:10px 0;display:grid}.toolbar .filter-examples button{text-align:left;background:#f8f9fb;border:0;border-radius:3px;flex-wrap:wrap;justify-content:space-between;gap:4px 12px;width:100%;height:auto;min-height:30px;padding:5px 7px;font-size:12px;font-weight:400;display:flex}.toolbar .filter-examples button:hover{background:#edf3fb}.column-menu>div{width:370px;max-width:calc(100vw - 24px);max-height:min(520px,65dvh)}.column-list{max-height:360px;overflow-y:auto}.column-choice{align-items:center;gap:3px;display:flex}.column-menu .column-choice label{flex:1;min-width:0;padding:5px 6px}.column-choice label span{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.toolbar .column-choice button{background:0 0;border:0;width:24px;height:26px;min-height:0;padding:0}.custom-column-form{border-top:1px solid var(--line);margin-top:6px;padding:10px 6px 4px}.column-menu .custom-column-form label{padding:0 0 6px}.custom-column-form>div{gap:6px;display:flex}.toolbar .custom-column-form input{width:100%;min-width:0}.custom-column-form small{color:var(--muted);margin-top:6px;display:block}.column-drag-handle{cursor:grab;color:var(--muted);touch-action:none}.column-drag-handle:active{cursor:grabbing}.column-choice.drop-target{box-shadow:inset 0 2px var(--blue);background:0 0}.column-menu .column-choice.drop-target label{background:0 0}.virtual-table th{-webkit-user-select:none;user-select:none}.filter-help{max-height:min(650px,70vh);overflow-y:auto}.save-filter,.saved-filter-row{gap:6px;display:flex}.toolbar .save-filter input{flex:1;width:0;min-width:0}.toolbar .saved-filter-row>button:last-child{flex:none;width:26px}.queue-navigation{justify-content:space-between;align-items:center;gap:12px;margin-bottom:12px;display:flex}.queue-navigation .crumb{min-width:0;margin:0}.queue-navigation .crumb b{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.connection-mode{border:1px solid var(--line);border-radius:4px;padding:0 6px;font-size:11px;line-height:20px}.connection-address{text-overflow:ellipsis;white-space:nowrap;max-width:min(50vw,650px);overflow:hidden}.queue-navigation-actions,.views{align-items:center;gap:6px;display:flex}.views{position:relative}.views .ui-select{width:180px}.views button,.views input{border:1px solid var(--line);color:#303846;background:#fff;border-radius:5px;min-height:32px;padding:0 10px}.views .ui-select>button{justify-content:space-between;align-items:center;gap:8px;width:100%;display:flex}.views .ui-select>button span{text-overflow:ellipsis;overflow:hidden}.views-menu{border:1px solid var(--line);z-index:40;background:#fff;border-radius:6px;width:180px;padding:12px;position:absolute;top:calc(100% + 6px);right:0;box-shadow:0 4px 16px #15223814}.views-menu p{color:var(--muted);margin:6px 0 10px;font-size:12px}.views-menu input{box-sizing:border-box;width:100%}.view-actions{grid-template-columns:1fr 1fr;gap:6px;margin-top:8px;display:grid}@media (width<=600px){.queue-navigation{flex-wrap:wrap}.views-menu{width:min(180px,80vw);left:0;right:auto}}.connect .ui-select>button{border:1px solid var(--line);background:#fff;border-radius:5px;justify-content:space-between;align-items:center;width:100%;min-height:32px;padding:0 10px;display:flex}.connect label small{color:var(--muted);margin-top:6px;font-weight:400;display:block}.view-selector{position:relative}.select-value{align-items:center;gap:6px;min-width:0;display:inline-flex}.select-value-text{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.ui-select>button>svg{flex-shrink:0}.view-dirty{background:var(--blue);border-radius:50%;flex:0 0 6px;width:6px;height:6px;display:inline-block}.views .view-save{color:var(--blue);border-color:#a6c6ed}.views .select-action{border:0;border-top:1px solid var(--line);text-align:left;width:100%;color:var(--blue);border-radius:0;margin-top:4px;padding:6px 8px;display:block}.views-menu{width:180px;padding:4px}.views-menu button{text-align:left;border:0;border-radius:3px;width:100%;font-weight:400;display:block}.views-menu button:not(:disabled):hover,.views-menu button:focus-visible{background:#edf3fb}.views-menu [role=separator]{border-top:1px solid var(--line);margin:4px}.views-menu .view-delete{color:var(--red)}.view-dialog{width:min(380px,100vw - 32px);padding:20px}.view-dialog h2{overflow-wrap:anywhere;font-size:17px}.view-dialog label{margin:16px 0 6px;display:block}.view-dialog input{border:1px solid var(--line);border-radius:5px;width:100%;height:34px;padding:0 10px}.view-dialog .view-name-error{color:var(--red);margin:6px 0}.stats .pending{--count-color:#956600}.stats .running{--count-color:#1768bd}.stats .succeeded{--count-color:#23855b}.stats .failed{--count-color:#c93636}.stats .cancelled{--count-color:#687385}.stats span:before{content:"";background:var(--state);vertical-align:1px;border-radius:50%;width:6px;height:6px;margin-right:6px;display:inline-block}.stats strong{color:var(--count-color)}.stats button:hover{background:color-mix(in srgb, var(--state) 5%, white)}.stats button.active{box-shadow:inset 0 -2px var(--state);background:color-mix(in srgb, var(--state) 9%, white)}.profile-save-error{z-index:10000;color:#624719;background:#fff9ed;border:1px solid #e3ba6c;border-radius:6px;align-items:center;gap:12px;max-width:calc(100vw - 32px);padding:10px 12px;font-size:13px;display:flex;position:fixed;bottom:16px;right:16px}.ui-select-menu,.filter-help,.views-menu{overscroll-behavior:contain;overflow-wrap:anywhere;position:fixed;overflow-y:auto}.ui-select-menu [role=option]>span:first-child{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}.ui-select-menu [role=option]>span:last-child{flex-shrink:0}