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 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()