zmon 1.5.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.
- zmon/__init__.py +8 -0
- zmon/cli.py +295 -0
- zmon/main.py +79 -0
- zmon/public/assets/css/theme-zcfd.min.css +2 -0
- zmon/public/assets/img/box.svg +21 -0
- zmon/public/assets/img/zcfd-logo.svg +15 -0
- zmon/public/assets/index-BwzNocQ4.js +113 -0
- zmon/public/assets/index-D4reR6Dz.css +1 -0
- zmon/public/favicon.svg +1 -0
- zmon/public/icons.svg +24 -0
- zmon/public/index.html +29 -0
- zmon/public/manifest.json +30 -0
- zmon/public/sw.js +21 -0
- zmon/resolve.py +343 -0
- zmon/router.py +495 -0
- zmon/sessions.py +342 -0
- zmon/state.py +146 -0
- zmon-1.5.0.dist-info/METADATA +144 -0
- zmon-1.5.0.dist-info/RECORD +22 -0
- zmon-1.5.0.dist-info/WHEEL +5 -0
- zmon-1.5.0.dist-info/entry_points.txt +2 -0
- zmon-1.5.0.dist-info/top_level.txt +1 -0
zmon/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""zMon — monitoring for zCFD runs.
|
|
2
|
+
|
|
3
|
+
Exposes the monitor API as a FastAPI router so it can be mounted into
|
|
4
|
+
`zui_server_zcfd`, served standalone by `zmon.main`, or driven from the
|
|
5
|
+
`zmon` console script. See docs/packaging-plan.md.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__all__ = ["resolve"]
|
zmon/cli.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The `zmon` command: serve the monitor API, the bundled SPA, and open a browser.
|
|
3
|
+
|
|
4
|
+
This is the only place that knows about static files, terminals, or browsers.
|
|
5
|
+
Inside zui none of that applies — zui owns the frontend and the process — so the
|
|
6
|
+
app itself stays in `zmon.main`.
|
|
7
|
+
|
|
8
|
+
Port selection is deliberately not the user's problem: bind port 0 and let the
|
|
9
|
+
kernel choose, then open the browser on whatever it picked. `--port` exists for
|
|
10
|
+
the case where something else needs to know the number in advance (an SSH
|
|
11
|
+
tunnel), and `ZMON_PORT` covers setting it once per machine.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import asyncio
|
|
18
|
+
import os
|
|
19
|
+
import socket
|
|
20
|
+
import sys
|
|
21
|
+
import webbrowser
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
import uvicorn
|
|
26
|
+
from fastapi import FastAPI
|
|
27
|
+
from fastapi.staticfiles import StaticFiles
|
|
28
|
+
|
|
29
|
+
from . import main as main_module
|
|
30
|
+
from . import router as router_module
|
|
31
|
+
|
|
32
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
33
|
+
|
|
34
|
+
# Seconds to wait for a first client before giving up, and to wait after the
|
|
35
|
+
# last one leaves. Only consulted when --exit-when-idle is passed.
|
|
36
|
+
STARTUP_GRACE_SECONDS = 30.0
|
|
37
|
+
IDLE_GRACE_SECONDS = 5.0
|
|
38
|
+
|
|
39
|
+
_WATCHDOG_TICK_SECONDS = 0.25
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _say(message: str) -> None:
|
|
43
|
+
"""
|
|
44
|
+
Tell the user something, now. stdout is block-buffered when it isn't a
|
|
45
|
+
terminal, so without an explicit flush the URL stays invisible for a
|
|
46
|
+
redirected or backgrounded run — which is how this gets used on a cluster.
|
|
47
|
+
"""
|
|
48
|
+
print(message, flush=True)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _default_port() -> int:
|
|
52
|
+
"""
|
|
53
|
+
0 means "let the kernel pick", which is what we want unless the user has
|
|
54
|
+
pinned a port. PORT is honoured for compatibility but is far too generic a
|
|
55
|
+
name to rely on — zmon-dev exported it, and so do many other tools.
|
|
56
|
+
"""
|
|
57
|
+
for name in ("ZMON_PORT", "PORT"):
|
|
58
|
+
raw = os.environ.get(name)
|
|
59
|
+
if raw:
|
|
60
|
+
try:
|
|
61
|
+
return int(raw)
|
|
62
|
+
except ValueError:
|
|
63
|
+
print(f"Ignoring {name}={raw!r}: not a port number", file=sys.stderr)
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _static_dir() -> Optional[Path]:
|
|
68
|
+
"""
|
|
69
|
+
The built SPA. Wheels install as real directories, so a __file__-relative
|
|
70
|
+
lookup is enough; the frontend/dist fallback is for a source checkout whose
|
|
71
|
+
assets haven't been copied into the package yet.
|
|
72
|
+
"""
|
|
73
|
+
packaged = Path(__file__).parent / "public"
|
|
74
|
+
if packaged.is_dir():
|
|
75
|
+
return packaged
|
|
76
|
+
|
|
77
|
+
dev = Path(__file__).resolve().parents[1] / "frontend" / "dist"
|
|
78
|
+
if dev.is_dir():
|
|
79
|
+
return dev
|
|
80
|
+
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def build_app() -> FastAPI:
|
|
85
|
+
"""The standalone app: the API, plus the SPA at the root if we have one."""
|
|
86
|
+
app = main_module.create_app()
|
|
87
|
+
|
|
88
|
+
static = _static_dir()
|
|
89
|
+
if static is not None:
|
|
90
|
+
# Mounted last, so the API routes registered above win.
|
|
91
|
+
app.mount("/", StaticFiles(directory=static, html=True), name="frontend")
|
|
92
|
+
else:
|
|
93
|
+
print(
|
|
94
|
+
"No built frontend found; serving the API only "
|
|
95
|
+
f"({main_module.API_PREFIX}).",
|
|
96
|
+
file=sys.stderr,
|
|
97
|
+
flush=True,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return app
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
104
|
+
parser = argparse.ArgumentParser(
|
|
105
|
+
prog="zmon",
|
|
106
|
+
description="Monitor a zCFD run in your browser.",
|
|
107
|
+
)
|
|
108
|
+
parser.add_argument(
|
|
109
|
+
"file",
|
|
110
|
+
nargs="?",
|
|
111
|
+
help="control file (.py) or report CSV to open. Without it, pick one in "
|
|
112
|
+
"the browser.",
|
|
113
|
+
)
|
|
114
|
+
parser.add_argument(
|
|
115
|
+
"--port",
|
|
116
|
+
type=int,
|
|
117
|
+
default=None,
|
|
118
|
+
help="port to listen on. Defaults to ZMON_PORT, else an unused port "
|
|
119
|
+
"chosen by the OS.",
|
|
120
|
+
)
|
|
121
|
+
parser.add_argument(
|
|
122
|
+
"--host",
|
|
123
|
+
default=DEFAULT_HOST,
|
|
124
|
+
help=f"address to bind. Defaults to {DEFAULT_HOST}; anything else "
|
|
125
|
+
"exposes the file browser to other users on this machine.",
|
|
126
|
+
)
|
|
127
|
+
parser.add_argument(
|
|
128
|
+
"--no-browser",
|
|
129
|
+
action="store_true",
|
|
130
|
+
help="don't try to open a browser.",
|
|
131
|
+
)
|
|
132
|
+
parser.add_argument(
|
|
133
|
+
"--exit-when-idle",
|
|
134
|
+
action="store_true",
|
|
135
|
+
help="shut down once no browser is connected. Off by default: it is "
|
|
136
|
+
"unsafe behind an SSH tunnel, where reconnects are routine.",
|
|
137
|
+
)
|
|
138
|
+
parser.add_argument("--version", action="version", version=_version())
|
|
139
|
+
return parser
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _version() -> str:
|
|
143
|
+
try:
|
|
144
|
+
from importlib.metadata import version
|
|
145
|
+
|
|
146
|
+
return version("zmon")
|
|
147
|
+
except Exception:
|
|
148
|
+
return "unknown"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _open_browser(url: str) -> None:
|
|
152
|
+
try:
|
|
153
|
+
opened = webbrowser.open(url)
|
|
154
|
+
except Exception:
|
|
155
|
+
opened = False
|
|
156
|
+
|
|
157
|
+
if not opened:
|
|
158
|
+
# Headless login node, or no browser configured.
|
|
159
|
+
print(f"Open {url} in a browser to view.", file=sys.stderr)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _bound_url(server: uvicorn.Server, host: str) -> Optional[str]:
|
|
163
|
+
"""The URL to actually visit, with the port the kernel handed us."""
|
|
164
|
+
for started in server.servers:
|
|
165
|
+
for sock in started.sockets:
|
|
166
|
+
try:
|
|
167
|
+
_, port = sock.getsockname()[:2]
|
|
168
|
+
except (OSError, ValueError):
|
|
169
|
+
continue
|
|
170
|
+
shown = "localhost" if host in ("0.0.0.0", "::", "127.0.0.1") else host
|
|
171
|
+
return f"http://{shown}:{port}"
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
async def _announce(server: uvicorn.Server, host: str, open_browser: bool) -> None:
|
|
176
|
+
while not server.started:
|
|
177
|
+
await asyncio.sleep(0.05)
|
|
178
|
+
if server.should_exit:
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
url = _bound_url(server, host)
|
|
182
|
+
if url is None:
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
_say(f"zMon is at {url}")
|
|
186
|
+
if open_browser:
|
|
187
|
+
_open_browser(url)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
async def _idle_watchdog(server: uvicorn.Server) -> None:
|
|
191
|
+
"""
|
|
192
|
+
Stop the server once the last browser has gone, mirroring the old Bun
|
|
193
|
+
backend. Opt-in, because zui owns process lifecycle and an SSH tunnel drops
|
|
194
|
+
SSE connections often enough that this would kill a live session.
|
|
195
|
+
"""
|
|
196
|
+
store = router_module.store
|
|
197
|
+
loop = asyncio.get_running_loop()
|
|
198
|
+
|
|
199
|
+
if not await _wait_for_subscriber(store, loop.time() + STARTUP_GRACE_SECONDS):
|
|
200
|
+
print("No browser connected; shutting down.", file=sys.stderr)
|
|
201
|
+
server.should_exit = True
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
while not server.should_exit:
|
|
205
|
+
if store.subscriber_count == 0:
|
|
206
|
+
deadline = loop.time() + IDLE_GRACE_SECONDS
|
|
207
|
+
while store.subscriber_count == 0:
|
|
208
|
+
if loop.time() >= deadline:
|
|
209
|
+
print("No browsers left; shutting down.", file=sys.stderr)
|
|
210
|
+
server.should_exit = True
|
|
211
|
+
return
|
|
212
|
+
await asyncio.sleep(_WATCHDOG_TICK_SECONDS)
|
|
213
|
+
await asyncio.sleep(_WATCHDOG_TICK_SECONDS)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
async def _wait_for_subscriber(store, deadline: float) -> bool:
|
|
217
|
+
loop = asyncio.get_running_loop()
|
|
218
|
+
while store.subscriber_count == 0:
|
|
219
|
+
if loop.time() >= deadline:
|
|
220
|
+
return False
|
|
221
|
+
await asyncio.sleep(_WATCHDOG_TICK_SECONDS)
|
|
222
|
+
return True
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
async def _run(args: argparse.Namespace) -> int:
|
|
226
|
+
initial: Optional[Path] = None
|
|
227
|
+
if args.file:
|
|
228
|
+
initial = Path(args.file).expanduser()
|
|
229
|
+
if not initial.exists():
|
|
230
|
+
print(f"File not found: {initial}", file=sys.stderr)
|
|
231
|
+
initial = None
|
|
232
|
+
|
|
233
|
+
# Where the file browser starts when the client doesn't say.
|
|
234
|
+
router_module.configure(initial_file=initial)
|
|
235
|
+
|
|
236
|
+
if initial is not None:
|
|
237
|
+
try:
|
|
238
|
+
session = await router_module.store.create(initial)
|
|
239
|
+
_say(f"Watching {session.name} ({session.type})")
|
|
240
|
+
except Exception as exc:
|
|
241
|
+
print(f"Could not open {initial}: {exc}", file=sys.stderr)
|
|
242
|
+
else:
|
|
243
|
+
_say("No file given; choose one in the browser.")
|
|
244
|
+
|
|
245
|
+
port = args.port if args.port is not None else _default_port()
|
|
246
|
+
config = uvicorn.Config(
|
|
247
|
+
build_app(),
|
|
248
|
+
host=args.host,
|
|
249
|
+
port=port,
|
|
250
|
+
log_level="warning",
|
|
251
|
+
)
|
|
252
|
+
server = uvicorn.Server(config)
|
|
253
|
+
|
|
254
|
+
tasks = [asyncio.create_task(_announce(server, args.host, not args.no_browser))]
|
|
255
|
+
if args.exit_when_idle:
|
|
256
|
+
tasks.append(asyncio.create_task(_idle_watchdog(server)))
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
await server.serve()
|
|
260
|
+
finally:
|
|
261
|
+
for task in tasks:
|
|
262
|
+
task.cancel()
|
|
263
|
+
|
|
264
|
+
return 0
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
268
|
+
args = build_parser().parse_args(argv)
|
|
269
|
+
|
|
270
|
+
if args.port and args.port != 0 and _port_in_use(args.host, args.port):
|
|
271
|
+
print(
|
|
272
|
+
f"Port {args.port} is already in use on {args.host}.",
|
|
273
|
+
file=sys.stderr,
|
|
274
|
+
flush=True,
|
|
275
|
+
)
|
|
276
|
+
return 1
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
return asyncio.run(_run(args))
|
|
280
|
+
except KeyboardInterrupt:
|
|
281
|
+
return 0
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _port_in_use(host: str, port: int) -> bool:
|
|
285
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
|
286
|
+
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
287
|
+
try:
|
|
288
|
+
probe.bind((host, port))
|
|
289
|
+
except OSError:
|
|
290
|
+
return True
|
|
291
|
+
return False
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
if __name__ == "__main__":
|
|
295
|
+
raise SystemExit(main())
|
zmon/main.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Standalone ASGI application.
|
|
3
|
+
|
|
4
|
+
`uvicorn zmon.main:app` serves the monitor API and nothing else. The SPA and the
|
|
5
|
+
browser-opening live in `zmon.cli`; inside zui the frontend is zui's own.
|
|
6
|
+
|
|
7
|
+
Auth mirrors `zui_server_zcfd`: the `X-ZUI-Token` header is required only when
|
|
8
|
+
`ZUI_API_TOKEN` is set in the environment. Standalone, it usually isn't, and a
|
|
9
|
+
loopback bind or a 0600 Unix socket is what protects the process.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from contextlib import asynccontextmanager
|
|
16
|
+
|
|
17
|
+
from fastapi import FastAPI, Request
|
|
18
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
19
|
+
from fastapi.responses import JSONResponse
|
|
20
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
21
|
+
|
|
22
|
+
from . import router as router_module
|
|
23
|
+
|
|
24
|
+
API_PREFIX = "/api/monitor"
|
|
25
|
+
|
|
26
|
+
TOKEN_HEADER = "X-ZUI-Token"
|
|
27
|
+
TOKEN_ENV_VAR = "ZUI_API_TOKEN"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TokenAuthMiddleware(BaseHTTPMiddleware):
|
|
31
|
+
async def dispatch(self, request: Request, call_next):
|
|
32
|
+
if request.method != "OPTIONS":
|
|
33
|
+
expected = os.environ.get(TOKEN_ENV_VAR)
|
|
34
|
+
if expected and not self._authorised(request, expected):
|
|
35
|
+
return JSONResponse(
|
|
36
|
+
status_code=403,
|
|
37
|
+
content={"error": "Access forbidden: Invalid or missing token"},
|
|
38
|
+
)
|
|
39
|
+
return await call_next(request)
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def _authorised(request: Request, expected: str) -> bool:
|
|
43
|
+
if request.headers.get(TOKEN_HEADER) == expected:
|
|
44
|
+
return True
|
|
45
|
+
# EventSource cannot set headers, so the change stream has to be able to
|
|
46
|
+
# authenticate by query parameter. Safe enough here: the stream is only
|
|
47
|
+
# ever reached over loopback or an SSH tunnel.
|
|
48
|
+
return request.query_params.get("token") == expected
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@asynccontextmanager
|
|
52
|
+
async def _lifespan(app: FastAPI):
|
|
53
|
+
# A session may already exist (zmon.cli creates one from argv before
|
|
54
|
+
# serving), in which case the file poller needs starting now that there is
|
|
55
|
+
# a running loop.
|
|
56
|
+
router_module.store.start()
|
|
57
|
+
try:
|
|
58
|
+
yield
|
|
59
|
+
finally:
|
|
60
|
+
router_module.store.stop()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def create_app() -> FastAPI:
|
|
64
|
+
app = FastAPI(title="zMon", lifespan=_lifespan)
|
|
65
|
+
|
|
66
|
+
app.add_middleware(
|
|
67
|
+
CORSMiddleware,
|
|
68
|
+
allow_origins=["*"],
|
|
69
|
+
allow_credentials=True,
|
|
70
|
+
allow_methods=["*"],
|
|
71
|
+
allow_headers=["*"],
|
|
72
|
+
)
|
|
73
|
+
app.add_middleware(TokenAuthMiddleware)
|
|
74
|
+
|
|
75
|
+
app.include_router(router_module.router, prefix=API_PREFIX)
|
|
76
|
+
return app
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
app = create_app()
|