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
|
@@ -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
|
labtasker_webui/py.typed
ADDED
|
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}
|