ghost-api-cli 0.2.0__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.
- ghost_api/__init__.py +3 -0
- ghost_api/__main__.py +5 -0
- ghost_api/adopt.py +173 -0
- ghost_api/agent/__init__.py +0 -0
- ghost_api/agent/orchestrator.py +413 -0
- ghost_api/agent/prompts.py +149 -0
- ghost_api/agent/tools.py +84 -0
- ghost_api/analysis/__init__.py +0 -0
- ghost_api/analysis/analyzer.py +130 -0
- ghost_api/analysis/detectors.py +188 -0
- ghost_api/analysis/exposure.py +80 -0
- ghost_api/analysis/graphql.py +67 -0
- ghost_api/analysis/models.py +64 -0
- ghost_api/analysis/schema.py +109 -0
- ghost_api/analysis/url_grouping.py +217 -0
- ghost_api/api.py +264 -0
- ghost_api/audit.py +78 -0
- ghost_api/browser/__init__.py +0 -0
- ghost_api/browser/auth_state.py +172 -0
- ghost_api/browser/driver.py +273 -0
- ghost_api/browser/egress.py +183 -0
- ghost_api/browser/gate.py +127 -0
- ghost_api/browser/install.py +28 -0
- ghost_api/browser/snapshot.py +221 -0
- ghost_api/capture/__init__.py +0 -0
- ghost_api/capture/filters.py +255 -0
- ghost_api/capture/har.py +148 -0
- ghost_api/capture/models.py +41 -0
- ghost_api/capture/personal.py +188 -0
- ghost_api/capture/recorder.py +181 -0
- ghost_api/cli/__init__.py +44 -0
- ghost_api/cli/_common.py +219 -0
- ghost_api/cli/client.py +487 -0
- ghost_api/cli/data.py +492 -0
- ghost_api/cli/explore.py +379 -0
- ghost_api/cli/manage.py +437 -0
- ghost_api/config.py +132 -0
- ghost_api/drift.py +344 -0
- ghost_api/drive.py +211 -0
- ghost_api/errors.py +106 -0
- ghost_api/events.py +213 -0
- ghost_api/exitcodes.py +21 -0
- ghost_api/export/__init__.py +71 -0
- ghost_api/export/confidence.py +45 -0
- ghost_api/export/html.py +41 -0
- ghost_api/export/openapi.py +201 -0
- ghost_api/export/report.py +227 -0
- ghost_api/export/site_data.py +196 -0
- ghost_api/export/tabular.py +88 -0
- ghost_api/export/viewer/report.html +286 -0
- ghost_api/generator/__init__.py +221 -0
- ghost_api/generator/cassettes.py +80 -0
- ghost_api/generator/models_gen.py +144 -0
- ghost_api/generator/naming.py +174 -0
- ghost_api/generator/package.py +197 -0
- ghost_api/generator/python_gen.py +172 -0
- ghost_api/generator/spec.py +486 -0
- ghost_api/generator/templates/client.py.j2 +518 -0
- ghost_api/generator/templates/client.test.ts.j2 +106 -0
- ghost_api/generator/templates/client.ts.j2 +339 -0
- ghost_api/generator/templates/models.py.j2 +45 -0
- ghost_api/generator/templates/package_readme.md.j2 +111 -0
- ghost_api/generator/templates/pyproject.toml.j2 +22 -0
- ghost_api/generator/templates/test_client.py.j2 +105 -0
- ghost_api/generator/ts_gen.py +232 -0
- ghost_api/integrations/__init__.py +0 -0
- ghost_api/integrations/claude_code/commands/ghost-data.md +10 -0
- ghost_api/integrations/claude_code/commands/ghost-diff.md +10 -0
- ghost_api/integrations/claude_code/commands/ghost-explore.md +10 -0
- ghost_api/integrations/claude_code/skills/ghost-api/SKILL.md +46 -0
- ghost_api/integrations/setup.py +84 -0
- ghost_api/llm/__init__.py +0 -0
- ghost_api/llm/doctor.py +60 -0
- ghost_api/llm/gateway.py +476 -0
- ghost_api/llm/providers.py +124 -0
- ghost_api/memory/__init__.py +0 -0
- ghost_api/memory/knowledge.py +157 -0
- ghost_api/memory/store.py +883 -0
- ghost_api/net.py +235 -0
- ghost_api/netguard.py +203 -0
- ghost_api/pipeline.py +745 -0
- ghost_api/probe/__init__.py +0 -0
- ghost_api/probe/prober.py +311 -0
- ghost_api/probe/replay.py +313 -0
- ghost_api/project.py +186 -0
- ghost_api/py.typed +0 -0
- ghost_api/runtime.py +397 -0
- ghost_api/serve/__init__.py +101 -0
- ghost_api/serve/explorer_mcp.py +305 -0
- ghost_api/serve/mcp.py +121 -0
- ghost_api/serve/mock.py +130 -0
- ghost_api/serve/rest.py +132 -0
- ghost_api/site.py +84 -0
- ghost_api/specdiff.py +122 -0
- ghost_api/ui/__init__.py +0 -0
- ghost_api/ui/keys.py +134 -0
- ghost_api/ui/repl.py +453 -0
- ghost_api/ui/terminal.py +573 -0
- ghost_api/verify/__init__.py +0 -0
- ghost_api/verify/classify.py +84 -0
- ghost_api/verify/repair.py +40 -0
- ghost_api/verify/runner.py +110 -0
- ghost_api/verify/verifier.py +291 -0
- ghost_api/watch.py +313 -0
- ghost_api/web/__init__.py +1 -0
- ghost_api/web/server.py +690 -0
- ghost_api/web/static/assets/Endpoints-zPnjlcM8.js +46 -0
- ghost_api/web/static/assets/Run-D85zWHey.js +91 -0
- ghost_api/web/static/assets/Settings-D0aWHFAt.js +11 -0
- ghost_api/web/static/assets/Site-a88shEmP.js +71 -0
- ghost_api/web/static/assets/Watches-CqZWIa9G.js +16 -0
- ghost_api/web/static/assets/circle-x-BcTE_1tr.js +11 -0
- ghost_api/web/static/assets/index-Ds2e5B9f.js +141 -0
- ghost_api/web/static/assets/index-xQpyIBbG.css +1 -0
- ghost_api/web/static/assets/minus-C2xqP1eQ.js +6 -0
- ghost_api/web/static/assets/refresh-cw-CxdR_8Oi.js +6 -0
- ghost_api/web/static/favicon.svg +1 -0
- ghost_api/web/static/index.html +26 -0
- ghost_api/workflows.py +203 -0
- ghost_api_cli-0.2.0.dist-info/METADATA +1019 -0
- ghost_api_cli-0.2.0.dist-info/RECORD +125 -0
- ghost_api_cli-0.2.0.dist-info/WHEEL +5 -0
- ghost_api_cli-0.2.0.dist-info/entry_points.txt +2 -0
- ghost_api_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- ghost_api_cli-0.2.0.dist-info/top_level.txt +1 -0
ghost_api/watch.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Watch & alerts: re-check an endpoint on a schedule and report what's new, changed or gone.
|
|
2
|
+
|
|
3
|
+
A watch is (site, operation, inputs). Each check fetches the pages through `SiteRuntime`,
|
|
4
|
+
keys every record by its ID-like field (or a content hash), compares it with the last
|
|
5
|
+
snapshot, stores the changes, and sends notifications. The first check only records a
|
|
6
|
+
baseline. Personal data stays hidden, as everywhere else.
|
|
7
|
+
|
|
8
|
+
Notification targets:
|
|
9
|
+
ntfy:TOPIC push to your phone with the free ntfy app (https://ntfy.sh). Topics are
|
|
10
|
+
public by name, so pick a long, unguessable one.
|
|
11
|
+
webhook:URL POST the changes as JSON (Discord, Slack, Zapier, your own server...).
|
|
12
|
+
desktop a desktop notification (needs `pip install plyer`).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import time
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from dataclasses import asdict, dataclass, field
|
|
22
|
+
from datetime import UTC, datetime, timedelta
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
from ghost_api.analysis.detectors import item_fingerprints
|
|
28
|
+
from ghost_api.memory.store import Store
|
|
29
|
+
from ghost_api.runtime import OperationError, SiteRuntime, flatten
|
|
30
|
+
|
|
31
|
+
# Fields that change on every visit and would make everything look "changed".
|
|
32
|
+
_VOLATILE_RE = re.compile(
|
|
33
|
+
r"(^|[._])(updated|modified|fetched|crawled|last_seen|timestamp|time|ts|now|distance|"
|
|
34
|
+
r"rank|ranking|position|score|views?|view_count|seconds_ago|ago)($|[._])|"
|
|
35
|
+
r"(_at|At|_time|Time)$|_ranking|highlight",
|
|
36
|
+
re.I,
|
|
37
|
+
)
|
|
38
|
+
_TITLE_FIELDS = ("name", "title", "label", "headline", "venue.name", "text", "slug", "id")
|
|
39
|
+
MAX_CHANGES_LISTED = 8
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Change:
|
|
44
|
+
kind: str # "new", "changed" or "gone"
|
|
45
|
+
key: str
|
|
46
|
+
title: str
|
|
47
|
+
fields: dict[str, list[Any]] = field(default_factory=dict) # name -> [old, new]
|
|
48
|
+
|
|
49
|
+
def line(self) -> str:
|
|
50
|
+
if self.kind == "new":
|
|
51
|
+
return f"+ {self.title}"
|
|
52
|
+
if self.kind == "gone":
|
|
53
|
+
return f"- {self.title}"
|
|
54
|
+
diffs = ", ".join(f"{k}: {_short(o)} -> {_short(n)}" for k, (o, n) in self.fields.items())
|
|
55
|
+
return f"~ {self.title}: {diffs}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class CheckResult:
|
|
60
|
+
watch: dict[str, Any]
|
|
61
|
+
rows: int = 0
|
|
62
|
+
baseline: bool = False
|
|
63
|
+
complete: bool = True # every page was read, so "gone" can be trusted
|
|
64
|
+
changes: list[Change] = field(default_factory=list)
|
|
65
|
+
error: str = ""
|
|
66
|
+
notified: list[str] = field(default_factory=list)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def counts(self) -> dict[str, int]:
|
|
70
|
+
out = {"new": 0, "changed": 0, "gone": 0}
|
|
71
|
+
for c in self.changes:
|
|
72
|
+
out[c.kind] += 1
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
def headline(self) -> str:
|
|
76
|
+
w = self.watch
|
|
77
|
+
where = f"{w['site']} · {w['operation']}"
|
|
78
|
+
if self.error:
|
|
79
|
+
return f"{where}: check failed ({self.error})"
|
|
80
|
+
if self.baseline:
|
|
81
|
+
return f"{where}: watching {self.rows} items (first check, nothing to compare yet)"
|
|
82
|
+
if not self.changes:
|
|
83
|
+
return f"{where}: no changes in {self.rows} items"
|
|
84
|
+
parts = [f"{n} {k}" for k, n in self.counts.items() if n]
|
|
85
|
+
return f"{where}: {', '.join(parts)}"
|
|
86
|
+
|
|
87
|
+
def text(self) -> str:
|
|
88
|
+
lines = [self.headline()]
|
|
89
|
+
lines += [c.line() for c in self.changes[:MAX_CHANGES_LISTED]]
|
|
90
|
+
if len(self.changes) > MAX_CHANGES_LISTED:
|
|
91
|
+
lines.append(f"... and {len(self.changes) - MAX_CHANGES_LISTED} more")
|
|
92
|
+
return "\n".join(lines)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# --- schedule -------------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def parse_every(text: str) -> int:
|
|
99
|
+
"""'30m' -> 30, '6h' -> 360, '1d' -> 1440, '90' -> 90 (minutes)."""
|
|
100
|
+
match = re.fullmatch(r"\s*(\d+)\s*([mhd]?)\s*", text.lower())
|
|
101
|
+
if not match:
|
|
102
|
+
raise ValueError(f"Not a duration: {text!r} (use e.g. 30m, 6h, 1d)")
|
|
103
|
+
minutes = int(match.group(1)) * {"": 1, "m": 1, "h": 60, "d": 1440}[match.group(2)]
|
|
104
|
+
if minutes < 5:
|
|
105
|
+
raise ValueError("Check at most every 5 minutes, to be gentle with the site")
|
|
106
|
+
return minutes
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def is_due(watch: dict[str, Any], now: datetime | None = None) -> bool:
|
|
110
|
+
if not watch.get("last_check_at"):
|
|
111
|
+
return True
|
|
112
|
+
last = datetime.fromisoformat(watch["last_check_at"])
|
|
113
|
+
return (now or datetime.now(UTC)) >= last + timedelta(minutes=watch["every_minutes"])
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def every_label(minutes: int) -> str:
|
|
117
|
+
if minutes % 1440 == 0:
|
|
118
|
+
return f"{minutes // 1440}d"
|
|
119
|
+
if minutes % 60 == 0:
|
|
120
|
+
return f"{minutes // 60}h"
|
|
121
|
+
return f"{minutes}m"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# --- one check ------------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def check(
|
|
128
|
+
store: Store,
|
|
129
|
+
watch: dict[str, Any],
|
|
130
|
+
runtime: SiteRuntime | None = None,
|
|
131
|
+
notify: bool = True,
|
|
132
|
+
sender: Callable[[str, CheckResult], str] | None = None,
|
|
133
|
+
) -> CheckResult:
|
|
134
|
+
"""Fetch, compare with the last snapshot, store, notify. Never raises for site errors."""
|
|
135
|
+
result = CheckResult(watch)
|
|
136
|
+
owned = runtime is None
|
|
137
|
+
try:
|
|
138
|
+
rt = runtime or SiteRuntime(store, watch["site"])
|
|
139
|
+
try:
|
|
140
|
+
op = rt.operation(watch["operation"])
|
|
141
|
+
records: list[Any] = []
|
|
142
|
+
pages = 0
|
|
143
|
+
for page in rt.iterate(op, watch["params"], watch["max_pages"], 5000):
|
|
144
|
+
pages += 1
|
|
145
|
+
if not page.result.ok:
|
|
146
|
+
if not records:
|
|
147
|
+
result.error = page.result.error or f"HTTP {page.result.status}"
|
|
148
|
+
result.complete = False
|
|
149
|
+
break
|
|
150
|
+
records += page.records
|
|
151
|
+
if pages >= watch["max_pages"]:
|
|
152
|
+
result.complete = False # there may be more pages we didn't read
|
|
153
|
+
finally:
|
|
154
|
+
if owned:
|
|
155
|
+
rt.close()
|
|
156
|
+
except OperationError as exc:
|
|
157
|
+
result.error = str(exc)
|
|
158
|
+
except Exception as exc: # noqa: BLE001 - a watch must keep going on network errors
|
|
159
|
+
result.error = f"{type(exc).__name__}: {exc}"
|
|
160
|
+
if result.error:
|
|
161
|
+
store.mark_watch_checked(watch["id"], f"error: {result.error}")
|
|
162
|
+
return result
|
|
163
|
+
|
|
164
|
+
current = {
|
|
165
|
+
key: flatten(record)
|
|
166
|
+
for key, record in zip(item_fingerprints(records), records, strict=False)
|
|
167
|
+
}
|
|
168
|
+
result.rows = len(current)
|
|
169
|
+
previous = store.watch_items(watch["id"])
|
|
170
|
+
result.baseline = not previous
|
|
171
|
+
track = set(watch.get("track") or [])
|
|
172
|
+
if not result.baseline:
|
|
173
|
+
result.changes = diff(previous, current, track, result.complete)
|
|
174
|
+
gone = [c.key for c in result.changes if c.kind == "gone"]
|
|
175
|
+
status = "baseline" if result.baseline else f"{len(result.changes)} changes"
|
|
176
|
+
store.save_watch_check(watch["id"], current, gone, [asdict(c) for c in result.changes], status)
|
|
177
|
+
if notify and result.changes:
|
|
178
|
+
for target in watch.get("notify") or []:
|
|
179
|
+
outcome = (sender or send)(target, result)
|
|
180
|
+
result.notified.append(outcome)
|
|
181
|
+
return result
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def diff(
|
|
185
|
+
previous: dict[str, dict[str, Any]],
|
|
186
|
+
current: dict[str, dict[str, Any]],
|
|
187
|
+
track: set[str],
|
|
188
|
+
complete: bool,
|
|
189
|
+
) -> list[Change]:
|
|
190
|
+
changes = []
|
|
191
|
+
for key, row in current.items():
|
|
192
|
+
old = previous.get(key)
|
|
193
|
+
if old is None or old["gone"]:
|
|
194
|
+
changes.append(Change("new", key, title_of(row)))
|
|
195
|
+
continue
|
|
196
|
+
fields = {
|
|
197
|
+
name: [old["data"].get(name), row.get(name)]
|
|
198
|
+
for name in sorted(set(row) | set(old["data"]))
|
|
199
|
+
if _tracked(name, track) and _differs(old["data"].get(name), row.get(name))
|
|
200
|
+
}
|
|
201
|
+
if fields:
|
|
202
|
+
changes.append(Change("changed", key, title_of(row), fields))
|
|
203
|
+
if complete: # only then is "not seen" the same as "gone"
|
|
204
|
+
for key, old in previous.items():
|
|
205
|
+
if key not in current and not old["gone"]:
|
|
206
|
+
changes.append(Change("gone", key, title_of(old["data"])))
|
|
207
|
+
order = {"new": 0, "changed": 1, "gone": 2}
|
|
208
|
+
return sorted(changes, key=lambda c: (order[c.kind], c.title))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def title_of(row: dict[str, Any]) -> str:
|
|
212
|
+
for name in _TITLE_FIELDS:
|
|
213
|
+
value = row.get(name)
|
|
214
|
+
if value not in (None, ""):
|
|
215
|
+
return _short(value, 60)
|
|
216
|
+
return _short(next(iter(row.values()), "item"), 60)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _tracked(name: str, track: set[str]) -> bool:
|
|
220
|
+
if track:
|
|
221
|
+
return name in track or any(name.endswith("." + t) for t in track)
|
|
222
|
+
return not _VOLATILE_RE.search(name) and not name.startswith("_")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _differs(old: Any, new: Any) -> bool:
|
|
226
|
+
return str(old) != str(new)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _short(value: Any, limit: int = 40) -> str:
|
|
230
|
+
text = str(value)
|
|
231
|
+
return text if len(text) <= limit else text[: limit - 1] + "…"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# --- notifications --------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def send(target: str, result: CheckResult) -> str:
|
|
238
|
+
"""Deliver one notification; returns what happened (never raises)."""
|
|
239
|
+
kind, _, where = target.partition(":")
|
|
240
|
+
try:
|
|
241
|
+
if kind == "ntfy" and where:
|
|
242
|
+
base = os.getenv("GHOST_API_NTFY_URL", "https://ntfy.sh").rstrip("/")
|
|
243
|
+
httpx.post(
|
|
244
|
+
f"{base}/{where}",
|
|
245
|
+
content=result.text().encode("utf-8"),
|
|
246
|
+
headers={"Title": "Ghost API: changes found", "Tags": "ghost"},
|
|
247
|
+
timeout=15,
|
|
248
|
+
).raise_for_status()
|
|
249
|
+
elif kind == "webhook" and where:
|
|
250
|
+
payload = {
|
|
251
|
+
"text": result.text(), # Slack / generic
|
|
252
|
+
"content": result.text()[:1900], # Discord
|
|
253
|
+
"site": result.watch["site"],
|
|
254
|
+
"operation": result.watch["operation"],
|
|
255
|
+
"counts": result.counts,
|
|
256
|
+
"changes": [asdict(c) for c in result.changes],
|
|
257
|
+
}
|
|
258
|
+
httpx.post(where, json=payload, timeout=15).raise_for_status()
|
|
259
|
+
elif kind == "desktop":
|
|
260
|
+
return _desktop(result)
|
|
261
|
+
else:
|
|
262
|
+
return f"{target}: unknown target (use ntfy:TOPIC, webhook:URL or desktop)"
|
|
263
|
+
except httpx.HTTPError as exc:
|
|
264
|
+
return f"{kind}: failed ({type(exc).__name__})"
|
|
265
|
+
return f"{kind}: sent"
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _desktop(result: CheckResult) -> str:
|
|
269
|
+
try:
|
|
270
|
+
from plyer import (
|
|
271
|
+
notification, # type: ignore[import-not-found,import-untyped,unused-ignore]
|
|
272
|
+
)
|
|
273
|
+
except ImportError:
|
|
274
|
+
return "desktop: install it with `pip install plyer`"
|
|
275
|
+
try:
|
|
276
|
+
notification.notify(title="Ghost API", message=result.text()[:250], timeout=10)
|
|
277
|
+
except Exception as exc: # noqa: BLE001 - notification backends vary by system
|
|
278
|
+
return f"desktop: failed ({type(exc).__name__})"
|
|
279
|
+
return "desktop: sent"
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# --- running many ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def run_due(store: Store, only_due: bool = True) -> list[CheckResult]:
|
|
286
|
+
return [check(store, w) for w in store.watches() if not only_due or is_due(w)]
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def loop(store: Store, on_result: Callable[[CheckResult], None], tick_s: float = 60) -> None:
|
|
290
|
+
"""Check due watches forever (until Ctrl+C)."""
|
|
291
|
+
while True:
|
|
292
|
+
for result in run_due(store):
|
|
293
|
+
on_result(result)
|
|
294
|
+
time.sleep(tick_s)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def describe(watch: dict[str, Any]) -> str:
|
|
298
|
+
inputs = " ".join(f"{k}={v}" for k, v in watch["params"].items())
|
|
299
|
+
return f"{watch['site']} {watch['operation']} {inputs}".strip()
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def to_json(result: CheckResult) -> dict[str, Any]:
|
|
303
|
+
return {
|
|
304
|
+
"watch_id": result.watch["id"],
|
|
305
|
+
"headline": result.headline(),
|
|
306
|
+
"rows": result.rows,
|
|
307
|
+
"baseline": result.baseline,
|
|
308
|
+
"complete": result.complete,
|
|
309
|
+
"error": result.error,
|
|
310
|
+
"counts": result.counts,
|
|
311
|
+
"changes": [asdict(c) for c in result.changes],
|
|
312
|
+
"notified": result.notified,
|
|
313
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Local web dashboard (Phase 3)."""
|