python-rac 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.
pyrac/web/workspace.py ADDED
@@ -0,0 +1,1905 @@
1
+ """Local multi-RAS workspace. Endpoints live in memory; no credentials are stored."""
2
+ import html
3
+ import hmac
4
+ import re
5
+ import asyncio
6
+ from uuid import UUID
7
+ from urllib.parse import parse_qs, quote, unquote_to_bytes, urlencode, urlsplit
8
+
9
+ from ..client import RacClient
10
+ from ..read_service import PROFILE_ACL_LISTS
11
+ from .app import create_app, render_document
12
+
13
+
14
+ def _search_text(value):
15
+ if not isinstance(value, str) or '\x00' in value:
16
+ return False
17
+ try:
18
+ return len(value.encode('utf-8')) <= 1024
19
+ except UnicodeError:
20
+ return False
21
+
22
+ _ENDPOINT = re.compile(r'^/ras/([0-9]+)/?(.*)$')
23
+ _LOOPBACK = re.compile(r'^(localhost|127\.0\.0\.1|\[::1\])(?::[0-9]{1,5})?$')
24
+ _HOST = re.compile(r'^[A-Za-z0-9][A-Za-z0-9.-]{0,252}$')
25
+ _RAS_SERVER = re.compile(r'([A-Za-z0-9][A-Za-z0-9.-]{0,252}):([0-9]+)/(?:(16\.0)|(10\.0)|(auto))\Z')
26
+
27
+
28
+ def parse_ras_server(spec):
29
+ """Parse a startup workspace endpoint without connecting to it."""
30
+ match = _RAS_SERVER.fullmatch(spec)
31
+ if match is None or not 1 <= int(match.group(2)) <= 65535:
32
+ raise ValueError('Invalid RAS endpoint (expected HOST:PORT/16.0|10.0|auto)')
33
+ return match.group(1), int(match.group(2)), next(part for part in match.groups()[2:] if part)
34
+
35
+
36
+ def startup_endpoints(host, port, protocol, specs):
37
+ endpoints = [(host, port, protocol)]
38
+ endpoints.extend(parse_ras_server(spec) for spec in specs)
39
+ if len(endpoints) > 64 or len(set(endpoints)) != len(endpoints):
40
+ raise ValueError('Invalid RAS endpoint list (maximum 64 unique servers)')
41
+ return endpoints
42
+ SEARCH_ENDPOINT_TIMEOUT = 30.0
43
+ SEARCH_CLUSTER_GROUPS = {
44
+ 'connections': ('connection', True), 'managers': ('manager', True),
45
+ 'limits': ('name', False), 'counters': ('name', False),
46
+ }
47
+ SEARCH_SERVER_GROUPS = {'rules': ('list_rules', 'rule', 'object-type'),
48
+ 'service-settings': ('list_service_settings', 'setting', 'service-name')}
49
+ SEARCH_PROFILE_ACL = {'profile-directories': ('directory', 'alias'),
50
+ **{'profile-' + kind: (kind, 'name')
51
+ for kind in PROFILE_ACL_LISTS if kind != 'directory'}}
52
+ SEARCH_KIND_NAMES = {
53
+ 'clusters': 'Кластеры', 'infobases': 'Базы данных',
54
+ 'agent-admins': 'Администраторы агента',
55
+ 'cluster-admins': 'Администраторы кластера',
56
+ 'profile-directories': 'Каталоги профиля',
57
+ **{'profile-' + kind: 'Профиль: ' + label for kind, label in {
58
+ 'com': 'COM', 'addin': 'внешние компоненты', 'module': 'модули',
59
+ 'app': 'приложения', 'inet': 'Интернет',
60
+ }.items()},
61
+ }
62
+ SECTION_NAMES = {
63
+ 'servers': 'Рабочие серверы', 'processes': 'Рабочие процессы',
64
+ 'infobases': 'Базы данных', 'sessions': 'Сеансы',
65
+ 'connections': 'Соединения', 'profiles': 'Профили безопасности',
66
+ 'counters': 'Счётчики', 'limits': 'Ограничения', 'managers': 'Менеджеры',
67
+ 'services': 'Сервисы', 'locks': 'Блокировки', 'rules': 'Правила',
68
+ 'service-settings': 'Настройки сервисов', 'transfer-directories': 'Каталоги переноса',
69
+ 'summary': 'Краткая информация', 'info': 'Сведения',
70
+ 'binary-storages': 'Двоичные хранилища',
71
+ 'directory': 'Каталоги', 'com': 'COM', 'addin': 'Внешние компоненты',
72
+ 'module': 'Модули', 'app': 'Приложения', 'inet': 'Интернет',
73
+ }
74
+ INSPECTOR_SCRIPT = '''"use strict";
75
+ document.addEventListener("DOMContentLoaded", () => {
76
+ const panel = document.querySelector(".console-inspector");
77
+ if (!panel) return;
78
+ const fields = [["RAS", "ras"], ["Protocol", "protocol"], ["Type", "type"],
79
+ ["Object", "object"]];
80
+ function select(row) {
81
+ document.querySelectorAll(".selectable-row, [data-searchable-row]").forEach(item => {
82
+ item.classList.toggle("is-selected", item === row);
83
+ item.setAttribute("aria-selected", item === row ? "true" : "false");
84
+ });
85
+ panel.replaceChildren();
86
+ const title = document.createElement("h2");
87
+ title.textContent = "Свойства";
88
+ panel.append(title);
89
+ const properties = document.createElement("dl");
90
+ const visible = row.matches("[data-searchable-row]") && !row.hasAttribute("data-inspect-type")
91
+ ? Array.from(row.querySelectorAll("td")).map((cell, index) => [
92
+ row.closest("table").querySelectorAll("thead th")[index]?.textContent || "Field",
93
+ cell.textContent
94
+ ]).filter(([label]) => label !== "Actions")
95
+ : fields.map(([label, key]) => [label,
96
+ row.dataset["inspect" + key[0].toUpperCase() + key.slice(1)] || "—"]);
97
+ visible.forEach(([label, text]) => {
98
+ const term = document.createElement("dt");
99
+ const value = document.createElement("dd");
100
+ term.textContent = label;
101
+ value.textContent = text;
102
+ properties.append(term, value);
103
+ });
104
+ panel.append(properties);
105
+ }
106
+ document.querySelectorAll(".selectable-row, [data-searchable-row]").forEach(row => {
107
+ if (row.matches("[data-searchable-row]")) {
108
+ row.classList.add("selectable-row");
109
+ row.tabIndex = 0;
110
+ row.setAttribute("aria-selected", "false");
111
+ }
112
+ row.addEventListener("click", event => {
113
+ if (!event.target.closest("a,button")) select(row);
114
+ });
115
+ row.addEventListener("keydown", event => {
116
+ if (event.target !== row) return;
117
+ if (event.key === "Enter" || event.key === " ") {
118
+ event.preventDefault();
119
+ select(row);
120
+ }
121
+ });
122
+ });
123
+ });
124
+ '''
125
+
126
+
127
+ def create_workspace_app(endpoints, *, csrf_token):
128
+ """Wrap the read-only dashboard without changing its mutation guards."""
129
+ initial = list(endpoints)
130
+ if not initial or not isinstance(csrf_token, str) or len(csrf_token) < 16:
131
+ raise ValueError('Workspace needs an endpoint and CSRF token')
132
+ if len(initial) > 64 or any(
133
+ not isinstance(endpoint, (tuple, list)) or len(endpoint) != 3 or
134
+ not isinstance(endpoint[0], str) or not _HOST.fullmatch(endpoint[0]) or
135
+ type(endpoint[1]) is not int or not 1 <= endpoint[1] <= 65535 or
136
+ endpoint[2] not in ('16.0', '10.0', 'auto') for endpoint in initial):
137
+ raise ValueError('Invalid initial RAS endpoint')
138
+ servers = dict(enumerate(initial))
139
+ next_index = len(servers)
140
+
141
+ def safe_tree_name(value, fallback):
142
+ if not isinstance(value, str) or not value.strip() or '\x00' in value:
143
+ return fallback
144
+ try:
145
+ return value if len(value.encode('utf-8')) <= 1024 else fallback
146
+ except UnicodeError:
147
+ return fallback
148
+
149
+ def navigation(expanded_index=None, clusters=(), expanded_cluster=None, servers_for_cluster=(),
150
+ expanded_server=None, processes_for_server=(), expanded_process=None,
151
+ connections_for_process=(), profiles_for_cluster=()):
152
+ from ..read_service import CLUSTER_LISTS, SERVER_LISTS
153
+ entries = []
154
+ for index, (host, port, protocol) in servers.items():
155
+ label = html.escape(f'{host}:{port}')
156
+ children = ''
157
+ if index == expanded_index:
158
+ for cluster_id, name, infobases in clusters:
159
+ server_children = ''
160
+ if cluster_id == expanded_cluster:
161
+ for server_id, server_name in servers_for_cluster:
162
+ base = f'/ras/{index}/clusters/{cluster_id}/servers/{server_id}'
163
+ processes = (''.join(
164
+ f'<li><a href="/ras/{index}/clusters/{cluster_id}/processes/{process_id}">'
165
+ f'{"Working process" if pid == "Working process" else "PID " + html.escape(pid)}</a><ul><li><a href="{base}/processes/'
166
+ f'{process_id}/connections/tree">Expand connections</a><ul>' +
167
+ (''.join(f'<li><a href="/ras/{index}/clusters/{cluster_id}/connections/'
168
+ f'{connection_id}">{html.escape(connection_host)}</a><ul>'
169
+ f'<li><a href="/ras/{index}/clusters/{cluster_id}/connections/'
170
+ f'{connection_id}/locks">Locks</a></li></ul></li>'
171
+ for connection_id, connection_host in connections_for_process)
172
+ if process_id == expanded_process else '') +
173
+ f'</ul></li><li><a href="/ras/{index}/clusters/{cluster_id}/processes/'
174
+ f'{process_id}/connections">Connections</a></li></ul></li>'
175
+ for process_id, pid in processes_for_server)
176
+ if server_id == expanded_server else '')
177
+ server_children += (f'<li><a href="{base}">{html.escape(server_name)}</a><ul>' +
178
+ f'<li><a href="{base}/processes/tree">Развернуть процессы</a>'
179
+ f'<ul>{processes}</ul></li>' +
180
+ ''.join(f'<li><a href="{base}/{kind}">'
181
+ f'{SECTION_NAMES.get(kind, kind)}'
182
+ '</a></li>' for kind in ('processes', *SERVER_LISTS)) +
183
+ '</ul></li>')
184
+ bases = ''.join(
185
+ (f'<li><a href="/ras/{index}/clusters/{cluster_id}/infobases/{base_id}/summary">'
186
+ f'{html.escape(base_name)}</a><ul>' + ''.join(
187
+ f'<li><a href="/ras/{index}/clusters/{cluster_id}/infobases/{base_id}/{kind}">'
188
+ f'{SECTION_NAMES.get(kind, kind)}</a></li>'
189
+ for kind in ('summary', 'info', 'sessions', 'connections',
190
+ 'locks', 'binary-storages')) + '</ul></li>' if base_id is not None else
191
+ f'<li>{html.escape(base_name)}</li>')
192
+ for base_id, base_name in infobases)
193
+ profile_children = ''
194
+ if cluster_id == expanded_cluster:
195
+ for profile_name in profiles_for_cluster:
196
+ profile_url = (f'/ras/{index}/clusters/{cluster_id}/profiles/'
197
+ f'{quote(profile_name, safe="")}')
198
+ profile_children += (f'<li><a href="/ras/{index}/clusters/{cluster_id}'
199
+ f'/profiles/overview?{urlencode({"name": profile_name})}">'
200
+ f'{html.escape(profile_name)}</a><ul>' + ''.join(
201
+ f'<li><a href="{profile_url}/acl/{kind}">'
202
+ f'{SECTION_NAMES.get(kind, kind)}</a></li>'
203
+ for kind in PROFILE_ACL_LISTS) + '</ul></li>')
204
+ children += (f'<li><a href="/ras/{index}/clusters/{cluster_id}">'
205
+ f'{html.escape(name)}</a><ul>' + ''.join(
206
+ f'<li><a href="/ras/{index}/clusters/{cluster_id}/{kind}">'
207
+ f'{SECTION_NAMES.get(kind, kind)}</a></li>'
208
+ for kind in CLUSTER_LISTS) +
209
+ f'<li><a href="/ras/{index}/clusters/{cluster_id}/profiles/tree">'
210
+ 'Развернуть профили безопасности</a><ul>' + profile_children + '</ul></li>' +
211
+ f'<li><a href="/ras/{index}/clusters/{cluster_id}/servers/tree">'
212
+ 'Развернуть рабочие серверы</a><ul>' + server_children + '</ul></li>' +
213
+ f'<li><a href="/ras/{index}/clusters/{cluster_id}/admins">'
214
+ 'Администраторы</a></li>' +
215
+ f'<li><a href="/ras/{index}/clusters/{cluster_id}/info">'
216
+ 'Сведения о кластере</a></li>' + bases + '</ul></li>')
217
+ entries.append(f'<li><a class="tree-node" href="/ras/{index}/tree">'
218
+ f'{"▾" if index == expanded_index else "▸"} {label}</a><ul>'
219
+ f'<li><a href="/ras/{index}/clusters">Кластеры</a></li>' + children +
220
+ f'<li><a href="/ras/{index}/agent/admins">Администраторы агента</a></li>'
221
+ f'<li><a href="/ras/{index}/agent/version">Версия агента</a></li>'
222
+ f'<li><small>{html.escape(protocol)}</small></li></ul></li>')
223
+ return '<h2>Подключения</h2><a href="/ras"><span class="ui-icon" aria-hidden="true">▣</span> Все серверы RAS</a><ul>' + ''.join(entries) + '</ul>'
224
+
225
+ def page(environ, start_response, body, status='200 OK', *, inspector=None,
226
+ expanded_index=None, clusters=(), expanded_cluster=None, servers_for_cluster=(),
227
+ expanded_server=None, processes_for_server=(), expanded_process=None,
228
+ connections_for_process=(), profiles_for_cluster=()):
229
+ start_response(status, [('Content-Type', 'text/html; charset=utf-8'),
230
+ ('Cache-Control', 'no-store'), ('X-Content-Type-Options', 'nosniff'),
231
+ ('Referrer-Policy', 'no-referrer'), ('X-Frame-Options', 'DENY')])
232
+ return [render_document('RAS workspace', body,
233
+ workspace_tree=navigation(expanded_index, clusters,
234
+ expanded_cluster, servers_for_cluster,
235
+ expanded_server, processes_for_server,
236
+ expanded_process, connections_for_process,
237
+ profiles_for_cluster),
238
+ inspector=inspector).encode('utf-8')]
239
+
240
+ def inventory_filter(label):
241
+ name = html.escape(label)
242
+ return ('<label>Search ' + name.lower() +
243
+ ' <input type="search" aria-label="Search ' + name.lower() +
244
+ '" oninput="this.closest(\'section\').querySelectorAll(\'[data-searchable-row]\')'
245
+ '.forEach(row=>row.hidden=!(row.textContent.toLocaleLowerCase().includes('
246
+ 'this.value.toLocaleLowerCase())||row.dataset.searchKey?.toLocaleLowerCase().includes('
247
+ 'this.value.toLocaleLowerCase())))" placeholder="Name or UUID"></label>')
248
+
249
+ def workspace(environ, start_response):
250
+ nonlocal next_index
251
+ path = environ.get('PATH_INFO', '/')
252
+ method = environ.get('REQUEST_METHOD', 'GET')
253
+ if (environ.get('REMOTE_ADDR') not in ('127.0.0.1', '::1') or
254
+ not _LOOPBACK.fullmatch(environ.get('HTTP_HOST', ''))):
255
+ start_response('403 Forbidden', [('Content-Type', 'text/plain')])
256
+ return [b'Local access only']
257
+ if path == '/ras/inspector.js':
258
+ if method != 'GET':
259
+ start_response('405 Method Not Allowed', [('Allow', 'GET')])
260
+ return [b'']
261
+ start_response('200 OK', [('Content-Type', 'text/javascript; charset=utf-8'),
262
+ ('Cache-Control', 'no-store'),
263
+ ('X-Content-Type-Options', 'nosniff')])
264
+ return [INSPECTOR_SCRIPT.encode('utf-8')]
265
+ if path == '/ras/remove':
266
+ if method != 'POST':
267
+ start_response('405 Method Not Allowed', [('Allow', 'POST')])
268
+ return [b'']
269
+ origin = urlsplit(environ.get('HTTP_ORIGIN', ''))
270
+ if (origin.scheme != 'http' or origin.netloc != environ.get('HTTP_HOST') or
271
+ origin.path or origin.query or origin.fragment or
272
+ environ.get('CONTENT_TYPE', '').split(';', 1)[0] != 'application/x-www-form-urlencoded'):
273
+ return page(environ, start_response, '<p>Invalid origin</p>', '403 Forbidden')
274
+ try:
275
+ length = int(environ.get('CONTENT_LENGTH', '-1'))
276
+ if not 0 <= length <= 2048:
277
+ raise ValueError('length')
278
+ raw = environ['wsgi.input'].read(length)
279
+ if len(raw) != length:
280
+ raise ValueError('truncated')
281
+ fields = parse_qs(raw.decode('utf-8'), strict_parsing=True)
282
+ if set(fields) != {'csrf_token', 'index', 'confirm'} or any(
283
+ len(values) != 1 for values in fields.values()):
284
+ raise ValueError('fields')
285
+ if not hmac.compare_digest(fields['csrf_token'][0], csrf_token):
286
+ raise ValueError('token')
287
+ index_text = fields['index'][0]
288
+ if not index_text.isascii() or not index_text.isdecimal() or len(index_text) > 16:
289
+ raise ValueError('index')
290
+ index = int(index_text)
291
+ if index not in servers or len(servers) <= 1:
292
+ raise ValueError('index')
293
+ host, port, protocol = servers[index]
294
+ if not hmac.compare_digest(fields['confirm'][0], f'{host}:{port}/{protocol}'):
295
+ raise ValueError('confirmation')
296
+ del servers[index]
297
+ except (ValueError, UnicodeError, KeyError):
298
+ return page(environ, start_response, '<p>Invalid RAS endpoint</p>', '403 Forbidden')
299
+ return page(environ, start_response, '<p>RAS endpoint removed from this workspace. '
300
+ '<a href="/ras">Back to servers</a></p>')
301
+ if path == '/ras/clusters':
302
+ if method != 'GET':
303
+ start_response('405 Method Not Allowed', [('Allow', 'GET')])
304
+ return [b'']
305
+
306
+ async def inventory():
307
+ semaphore = asyncio.Semaphore(4)
308
+ deadline = asyncio.get_running_loop().time() + SEARCH_ENDPOINT_TIMEOUT
309
+
310
+ async def list_one(index, host, port, protocol):
311
+ found = []
312
+ try:
313
+ async with asyncio.timeout_at(deadline), semaphore:
314
+ async with RacClient(host, port, protocol) as client:
315
+ for row in await client.list_clusters():
316
+ if not isinstance(row, dict):
317
+ continue
318
+ try:
319
+ cluster = str(UUID(str(row['cluster'])))
320
+ except (KeyError, TypeError, ValueError):
321
+ continue
322
+ name, object_host = row.get('name'), row.get('host')
323
+ if (not isinstance(name, str) or not name.strip()
324
+ or not _search_text(name)):
325
+ name = (object_host if isinstance(object_host, str)
326
+ and object_host.strip() and _search_text(object_host)
327
+ else 'Unnamed cluster')
328
+ found.append((index, host, port, protocol, cluster, name))
329
+ except Exception:
330
+ pass # Keep healthy RAS; do not render exception text.
331
+ return found
332
+
333
+ groups = await asyncio.gather(*(list_one(index, *endpoint)
334
+ for index, endpoint in tuple(servers.items())))
335
+ return [row for group in groups for row in group]
336
+
337
+ rows = asyncio.run(inventory())
338
+ items = ''.join(
339
+ f'<tr data-searchable-row data-search-key="{cluster}"><td>'
340
+ f'{html.escape(f"{host}:{port}")}</td><td>{html.escape(protocol)}</td>'
341
+ f'<td><a href="/ras/{index}/clusters/{cluster}">{html.escape(name)}</a>'
342
+ '</td></tr>' for index, host, port, protocol, cluster, name in rows)
343
+ body = ('<section><h1>Кластеры всех RAS</h1><p>'
344
+ '<a href="/ras">Все серверы RAS</a></p>' + inventory_filter('Кластеры') +
345
+ '<div class="table-scroll"><table class="inventory-grid"><thead><tr>'
346
+ '<th>RAS</th><th>Протокол</th><th>Кластер</th>'
347
+ '</tr></thead><tbody>' + items + '</tbody></table></div></section>')
348
+ return page(environ, start_response, body)
349
+ if path == '/ras/servers':
350
+ if method != 'GET':
351
+ start_response('405 Method Not Allowed', [('Allow', 'GET')])
352
+ return [b'']
353
+
354
+ async def inventory():
355
+ semaphore = asyncio.Semaphore(4)
356
+ deadline = asyncio.get_running_loop().time() + SEARCH_ENDPOINT_TIMEOUT
357
+
358
+ async def list_one(index, host, port, protocol):
359
+ found = []
360
+ try:
361
+ async with asyncio.timeout_at(deadline), semaphore:
362
+ async with RacClient(host, port, protocol) as client:
363
+ for record in await client.list_clusters():
364
+ if not isinstance(record, dict):
365
+ continue
366
+ try:
367
+ cluster = str(UUID(str(record['cluster'])))
368
+ except (KeyError, TypeError, ValueError):
369
+ continue
370
+ cluster_name = record.get('name')
371
+ cluster_host = record.get('host')
372
+ if (not isinstance(cluster_name, str) or not cluster_name.strip()
373
+ or not _search_text(cluster_name)):
374
+ cluster_name = (cluster_host if isinstance(cluster_host, str)
375
+ and cluster_host.strip() and _search_text(cluster_host)
376
+ else 'Unnamed cluster')
377
+ try:
378
+ for row in await client.list_servers(UUID(cluster)):
379
+ if not isinstance(row, dict):
380
+ continue
381
+ try:
382
+ server_id = str(UUID(str(row['server'])))
383
+ except (KeyError, TypeError, ValueError):
384
+ continue
385
+ name, server_host = row.get('name'), row.get('host_name')
386
+ if (not isinstance(name, str) or not name.strip()
387
+ or not _search_text(name)):
388
+ name = (server_host if isinstance(server_host, str)
389
+ and server_host.strip() and _search_text(server_host)
390
+ else 'Unnamed server')
391
+ found.append((index, host, port, protocol, cluster,
392
+ cluster_name, server_id, name))
393
+ except Exception:
394
+ continue
395
+ except Exception:
396
+ pass # Preserve healthy clusters without rendering private errors.
397
+ return found
398
+
399
+ groups = await asyncio.gather(*(list_one(index, *endpoint)
400
+ for index, endpoint in tuple(servers.items())))
401
+ return [row for group in groups for row in group]
402
+
403
+ rows = asyncio.run(inventory())
404
+ items = ''.join(
405
+ f'<tr data-searchable-row data-search-key="{server_id}"><td>'
406
+ f'{html.escape(f"{host}:{port}")}</td><td>{html.escape(protocol)}</td>'
407
+ f'<td><a href="/ras/{index}/clusters/{cluster}">{html.escape(cluster_name)}</a></td>'
408
+ f'<td><a href="/ras/{index}/clusters/{cluster}/servers/{server_id}">'
409
+ f'{html.escape(name)}</a></td></tr>'
410
+ for index, host, port, protocol, cluster, cluster_name, server_id, name in rows)
411
+ body = ('<section><h1>Working servers across RAS</h1><p>'
412
+ '<a href="/ras">All RAS servers</a></p>' + inventory_filter('Working servers') +
413
+ '<div class="table-scroll"><table class="inventory-grid"><thead><tr>'
414
+ '<th>RAS</th><th>Protocol</th><th>Cluster</th><th>Working server</th>'
415
+ '</tr></thead><tbody>' + items + '</tbody></table></div></section>')
416
+ return page(environ, start_response, body)
417
+ if path == '/ras/processes':
418
+ if method != 'GET':
419
+ start_response('405 Method Not Allowed', [('Allow', 'GET')])
420
+ return [b'']
421
+
422
+ async def inventory():
423
+ semaphore = asyncio.Semaphore(4)
424
+ deadline = asyncio.get_running_loop().time() + SEARCH_ENDPOINT_TIMEOUT
425
+
426
+ async def list_one(index, host, port, protocol):
427
+ found = []
428
+ try:
429
+ async with asyncio.timeout_at(deadline), semaphore:
430
+ async with RacClient(host, port, protocol) as client:
431
+ for record in await client.list_clusters():
432
+ if not isinstance(record, dict):
433
+ continue
434
+ try:
435
+ cluster = str(UUID(str(record['cluster'])))
436
+ except (KeyError, TypeError, ValueError):
437
+ continue
438
+ cluster_name, cluster_host = record.get('name'), record.get('host')
439
+ if (not isinstance(cluster_name, str) or not cluster_name.strip()
440
+ or not _search_text(cluster_name)):
441
+ cluster_name = (cluster_host if isinstance(cluster_host, str)
442
+ and cluster_host.strip() and _search_text(cluster_host)
443
+ else 'Unnamed cluster')
444
+ try:
445
+ for row in await client.list_processes(UUID(cluster)):
446
+ if not isinstance(row, dict):
447
+ continue
448
+ try:
449
+ process = str(UUID(str(row['process'])))
450
+ except (KeyError, TypeError, ValueError):
451
+ continue
452
+ pid = row.get('pid')
453
+ name = (f'PID {pid}' if type(pid) is str and pid.isascii()
454
+ and pid.isdecimal() and len(pid) <= 20
455
+ else 'Working process')
456
+ found.append((index, host, port, protocol, cluster,
457
+ cluster_name, process, name))
458
+ except Exception:
459
+ continue
460
+ except Exception:
461
+ pass # Preserve completed clusters; do not expose private errors.
462
+ return found
463
+
464
+ groups = await asyncio.gather(*(list_one(index, *endpoint)
465
+ for index, endpoint in tuple(servers.items())))
466
+ return [row for group in groups for row in group]
467
+
468
+ rows = asyncio.run(inventory())
469
+ items = ''.join(
470
+ f'<tr data-searchable-row data-search-key="{process}"><td>'
471
+ f'{html.escape(f"{host}:{port}")}</td><td>{html.escape(protocol)}</td>'
472
+ f'<td><a href="/ras/{index}/clusters/{cluster}">{html.escape(cluster_name)}</a></td>'
473
+ f'<td><a href="/ras/{index}/clusters/{cluster}/processes/{process}">'
474
+ f'{html.escape(name)}</a></td></tr>'
475
+ for index, host, port, protocol, cluster, cluster_name, process, name in rows)
476
+ body = ('<section><h1>Working processes across RAS</h1><p>'
477
+ '<a href="/ras">All RAS servers</a></p>' + inventory_filter('Working processes') +
478
+ '<div class="table-scroll"><table class="inventory-grid"><thead><tr>'
479
+ '<th>RAS</th><th>Protocol</th><th>Cluster</th><th>Working process</th>'
480
+ '</tr></thead><tbody>' + items + '</tbody></table></div></section>')
481
+ return page(environ, start_response, body)
482
+ if path == '/ras/sessions':
483
+ if method != 'GET':
484
+ start_response('405 Method Not Allowed', [('Allow', 'GET')])
485
+ return [b'']
486
+
487
+ async def inventory():
488
+ semaphore = asyncio.Semaphore(4)
489
+ deadline = asyncio.get_running_loop().time() + SEARCH_ENDPOINT_TIMEOUT
490
+
491
+ async def list_one(index, host, port, protocol):
492
+ found = []
493
+ try:
494
+ async with asyncio.timeout_at(deadline), semaphore:
495
+ async with RacClient(host, port, protocol) as client:
496
+ for record in await client.list_clusters():
497
+ if not isinstance(record, dict):
498
+ continue
499
+ try:
500
+ cluster = str(UUID(str(record['cluster'])))
501
+ except (KeyError, TypeError, ValueError):
502
+ continue
503
+ cluster_name, cluster_host = record.get('name'), record.get('host')
504
+ if (not isinstance(cluster_name, str) or not cluster_name.strip()
505
+ or not _search_text(cluster_name)):
506
+ cluster_name = (cluster_host if isinstance(cluster_host, str)
507
+ and cluster_host.strip() and _search_text(cluster_host)
508
+ else 'Unnamed cluster')
509
+ try:
510
+ for row in await client.list_sessions(UUID(cluster)):
511
+ if not isinstance(row, dict):
512
+ continue
513
+ try:
514
+ session = str(UUID(str(row['session_id'])))
515
+ except (KeyError, TypeError, ValueError):
516
+ continue
517
+ name = row.get('user_name')
518
+ if (not isinstance(name, str) or not name.strip()
519
+ or not _search_text(name)):
520
+ name = 'Unnamed session'
521
+ found.append((index, host, port, protocol, cluster,
522
+ cluster_name, session, name))
523
+ except Exception:
524
+ continue
525
+ except Exception:
526
+ pass # Preserve completed clusters; do not expose private errors.
527
+ return found
528
+
529
+ groups = await asyncio.gather(*(list_one(index, *endpoint)
530
+ for index, endpoint in tuple(servers.items())))
531
+ return [row for group in groups for row in group]
532
+
533
+ rows = asyncio.run(inventory())
534
+ items = ''.join(
535
+ f'<tr data-searchable-row data-search-key="{session}"><td>'
536
+ f'{html.escape(f"{host}:{port}")}</td><td>{html.escape(protocol)}</td>'
537
+ f'<td><a href="/ras/{index}/clusters/{cluster}">{html.escape(cluster_name)}</a></td>'
538
+ f'<td><a href="/ras/{index}/clusters/{cluster}/sessions/{session}">'
539
+ f'{html.escape(name)}</a></td></tr>'
540
+ for index, host, port, protocol, cluster, cluster_name, session, name in rows)
541
+ body = ('<section><h1>Sessions across RAS</h1><p>'
542
+ '<a href="/ras">All RAS servers</a></p>' + inventory_filter('Sessions') +
543
+ '<div class="table-scroll"><table class="inventory-grid"><thead><tr>'
544
+ '<th>RAS</th><th>Protocol</th><th>Cluster</th><th>Session</th>'
545
+ '</tr></thead><tbody>' + items + '</tbody></table></div></section>')
546
+ return page(environ, start_response, body)
547
+ if path == '/ras/connections':
548
+ if method != 'GET':
549
+ start_response('405 Method Not Allowed', [('Allow', 'GET')])
550
+ return [b'']
551
+
552
+ async def inventory():
553
+ semaphore = asyncio.Semaphore(4)
554
+ deadline = asyncio.get_running_loop().time() + SEARCH_ENDPOINT_TIMEOUT
555
+
556
+ async def list_one(index, host, port, protocol):
557
+ found = []
558
+ try:
559
+ async with asyncio.timeout_at(deadline), semaphore:
560
+ async with RacClient(host, port, protocol) as client:
561
+ for record in await client.list_clusters():
562
+ if not isinstance(record, dict):
563
+ continue
564
+ try:
565
+ cluster = str(UUID(str(record['cluster'])))
566
+ except (KeyError, TypeError, ValueError):
567
+ continue
568
+ cluster_name, cluster_host = record.get('name'), record.get('host')
569
+ if (not isinstance(cluster_name, str) or not cluster_name.strip()
570
+ or not _search_text(cluster_name)):
571
+ cluster_name = (cluster_host if isinstance(cluster_host, str)
572
+ and cluster_host.strip() and _search_text(cluster_host)
573
+ else 'Unnamed cluster')
574
+ try:
575
+ for row in await client.list_connections(UUID(cluster)):
576
+ if not isinstance(row, dict):
577
+ continue
578
+ try:
579
+ connection = str(UUID(str(row['connection'])))
580
+ except (KeyError, TypeError, ValueError):
581
+ continue
582
+ name = row.get('host')
583
+ if (not isinstance(name, str) or not name.strip()
584
+ or not _search_text(name)):
585
+ name = 'Unnamed connection'
586
+ found.append((index, host, port, protocol, cluster,
587
+ cluster_name, connection, name))
588
+ except Exception:
589
+ continue
590
+ except Exception:
591
+ pass # Preserve completed clusters; do not expose private errors.
592
+ return found
593
+
594
+ groups = await asyncio.gather(*(list_one(index, *endpoint)
595
+ for index, endpoint in tuple(servers.items())))
596
+ return [row for group in groups for row in group]
597
+
598
+ rows = asyncio.run(inventory())
599
+ items = ''.join(
600
+ f'<tr data-searchable-row data-search-key="{connection}"><td>'
601
+ f'{html.escape(f"{host}:{port}")}</td><td>{html.escape(protocol)}</td>'
602
+ f'<td><a href="/ras/{index}/clusters/{cluster}">{html.escape(cluster_name)}</a></td>'
603
+ f'<td><a href="/ras/{index}/clusters/{cluster}/connections/{connection}">'
604
+ f'{html.escape(name)}</a></td></tr>'
605
+ for index, host, port, protocol, cluster, cluster_name, connection, name in rows)
606
+ body = ('<section><h1>Connections across RAS</h1><p>'
607
+ '<a href="/ras">All RAS servers</a></p>' + inventory_filter('Connections') +
608
+ '<div class="table-scroll"><table class="inventory-grid"><thead><tr>'
609
+ '<th>RAS</th><th>Protocol</th><th>Cluster</th><th>Connection</th>'
610
+ '</tr></thead><tbody>' + items + '</tbody></table></div></section>')
611
+ return page(environ, start_response, body)
612
+ if path == '/ras/locks':
613
+ if method != 'GET':
614
+ start_response('405 Method Not Allowed', [('Allow', 'GET')])
615
+ return [b'']
616
+
617
+ async def inventory():
618
+ semaphore = asyncio.Semaphore(4)
619
+ deadline = asyncio.get_running_loop().time() + SEARCH_ENDPOINT_TIMEOUT
620
+
621
+ async def list_one(index, host, port, protocol):
622
+ found = []
623
+ try:
624
+ async with asyncio.timeout_at(deadline), semaphore:
625
+ async with RacClient(host, port, protocol) as client:
626
+ for record in await client.list_clusters():
627
+ if not isinstance(record, dict):
628
+ continue
629
+ try:
630
+ cluster = str(UUID(str(record['cluster'])))
631
+ except (KeyError, TypeError, ValueError):
632
+ continue
633
+ cluster_name = safe_tree_name(record.get('name'),
634
+ safe_tree_name(record.get('host'),
635
+ 'Unnamed cluster'))
636
+ try:
637
+ for row in await client.list_locks(UUID(cluster)):
638
+ if not isinstance(row, dict):
639
+ continue
640
+ try:
641
+ connection = str(UUID(str(row['connection'])))
642
+ UUID(str(row['object']))
643
+ except (KeyError, TypeError, ValueError):
644
+ continue
645
+ label = row.get('lock-descr')
646
+ name = (label if isinstance(label, str) and label.strip()
647
+ and _search_text(label) else 'Lock')
648
+ found.append((index, host, port, protocol, cluster,
649
+ cluster_name, connection, name))
650
+ except Exception:
651
+ continue
652
+ except Exception:
653
+ pass # Retain completed clusters; never expose exception text.
654
+ return found
655
+
656
+ batches = await asyncio.gather(*(list_one(index, *endpoint)
657
+ for index, endpoint in tuple(servers.items())))
658
+ return [row for batch in batches for row in batch]
659
+
660
+ rows = asyncio.run(inventory())
661
+ items = ''.join(
662
+ f'<tr data-searchable-row data-search-key="{html.escape(name, quote=True)}">'
663
+ f'<td>{html.escape(f"{host}:{port}")}</td><td>{html.escape(protocol)}</td>'
664
+ f'<td><a href="/ras/{index}/clusters/{cluster}">{html.escape(cluster_name)}</a></td>'
665
+ f'<td><a href="/ras/{index}/clusters/{cluster}/connections/{connection}/locks">'
666
+ f'{html.escape(name)}</a></td></tr>'
667
+ for index, host, port, protocol, cluster, cluster_name, connection, name in rows)
668
+ body = ('<section><h1>Locks across RAS</h1><p><a href="/ras">All RAS servers</a>'
669
+ '</p>' + inventory_filter('Locks') +
670
+ '<div class="table-scroll"><table class="inventory-grid"><thead><tr>'
671
+ '<th>RAS</th><th>Protocol</th><th>Cluster</th><th>Lock</th>'
672
+ '</tr></thead><tbody>' + items + '</tbody></table></div></section>')
673
+ return page(environ, start_response, body)
674
+ if path == '/ras/services':
675
+ if method != 'GET':
676
+ start_response('405 Method Not Allowed', [('Allow', 'GET')])
677
+ return [b'']
678
+
679
+ async def inventory():
680
+ semaphore = asyncio.Semaphore(4)
681
+ deadline = asyncio.get_running_loop().time() + SEARCH_ENDPOINT_TIMEOUT
682
+
683
+ async def list_one(index, host, port, protocol):
684
+ found = []
685
+ try:
686
+ async with asyncio.timeout_at(deadline), semaphore:
687
+ async with RacClient(host, port, protocol) as client:
688
+ for record in await client.list_clusters():
689
+ if not isinstance(record, dict):
690
+ continue
691
+ try:
692
+ cluster = str(UUID(str(record['cluster'])))
693
+ except (KeyError, TypeError, ValueError):
694
+ continue
695
+ cluster_name, cluster_host = record.get('name'), record.get('host')
696
+ if (not isinstance(cluster_name, str) or not cluster_name.strip()
697
+ or not _search_text(cluster_name)):
698
+ cluster_name = (cluster_host if isinstance(cluster_host, str)
699
+ and cluster_host.strip() and _search_text(cluster_host)
700
+ else 'Unnamed cluster')
701
+ try:
702
+ for row in await client.list_services(UUID(cluster)):
703
+ if not isinstance(row, dict):
704
+ continue
705
+ name = row.get('name')
706
+ if (not isinstance(name, str) or not name.strip()
707
+ or not _search_text(name)):
708
+ continue
709
+ found.append((index, host, port, protocol, cluster,
710
+ cluster_name, name))
711
+ except Exception:
712
+ continue
713
+ except Exception:
714
+ pass # Preserve completed clusters without exposing server errors.
715
+ return found
716
+
717
+ groups = await asyncio.gather(*(list_one(index, *endpoint)
718
+ for index, endpoint in tuple(servers.items())))
719
+ return [row for group in groups for row in group]
720
+
721
+ rows = asyncio.run(inventory())
722
+ items = ''.join(
723
+ f'<tr data-searchable-row><td>{html.escape(f"{host}:{port}")}</td>'
724
+ f'<td>{html.escape(protocol)}</td>'
725
+ f'<td><a href="/ras/{index}/clusters/{cluster}">{html.escape(cluster_name)}</a></td>'
726
+ f'<td><a href="/ras/{index}/clusters/{cluster}/services">'
727
+ f'{html.escape(name)}</a></td></tr>'
728
+ for index, host, port, protocol, cluster, cluster_name, name in rows)
729
+ body = ('<section><h1>Services across RAS</h1><p>'
730
+ '<a href="/ras">All RAS servers</a></p>' + inventory_filter('Services') +
731
+ '<div class="table-scroll"><table class="inventory-grid"><thead><tr>'
732
+ '<th>RAS</th><th>Protocol</th><th>Cluster</th><th>Service</th>'
733
+ '</tr></thead><tbody>' + items + '</tbody></table></div></section>')
734
+ return page(environ, start_response, body)
735
+ if path == '/ras/server-inventory':
736
+ if method != 'GET':
737
+ start_response('405 Method Not Allowed', [('Allow', 'GET')])
738
+ return [b'']
739
+ kind = parse_qs(environ.get('QUERY_STRING', '')).get('kind', [''])[0]
740
+ if kind not in (*SEARCH_SERVER_GROUPS, 'transfer-directories'):
741
+ return page(environ, start_response, '<p>Invalid inventory kind</p>', '400 Bad Request')
742
+ method_name, selector_key, label_key = SEARCH_SERVER_GROUPS.get(
743
+ kind, ('list_service_data_dirs_for_transfer', 'service-name', 'service-name'))
744
+
745
+ async def inventory():
746
+ semaphore = asyncio.Semaphore(4)
747
+ deadline = asyncio.get_running_loop().time() + SEARCH_ENDPOINT_TIMEOUT
748
+
749
+ async def list_one(index, host, port, protocol):
750
+ found = []
751
+ try:
752
+ async with asyncio.timeout_at(deadline), semaphore:
753
+ async with RacClient(host, port, protocol) as client:
754
+ for cluster_row in await client.list_clusters():
755
+ if not isinstance(cluster_row, dict):
756
+ continue
757
+ try:
758
+ cluster = str(UUID(str(cluster_row['cluster'])))
759
+ except (KeyError, TypeError, ValueError):
760
+ continue
761
+ cluster_name = safe_tree_name(cluster_row.get('name'),
762
+ safe_tree_name(cluster_row.get('host'),
763
+ 'Unnamed cluster'))
764
+ try:
765
+ working_servers = await client.list_servers(UUID(cluster))
766
+ except Exception:
767
+ continue
768
+ for server_row in working_servers:
769
+ if not isinstance(server_row, dict):
770
+ continue
771
+ try:
772
+ server_id = str(UUID(str(server_row['server'])))
773
+ except (KeyError, TypeError, ValueError):
774
+ continue
775
+ server_name = safe_tree_name(server_row.get('name'),
776
+ safe_tree_name(server_row.get('host_name'),
777
+ 'Unnamed server'))
778
+ try:
779
+ records = await getattr(client, method_name)(
780
+ UUID(cluster), UUID(server_id))
781
+ for row in records:
782
+ if not isinstance(row, dict):
783
+ continue
784
+ if kind == 'transfer-directories':
785
+ selector = row.get('service-name')
786
+ if (not isinstance(selector, str) or not selector.strip()
787
+ or not _search_text(selector)):
788
+ continue
789
+ name = selector
790
+ else:
791
+ try:
792
+ selector = str(UUID(str(row[selector_key])))
793
+ except (KeyError, TypeError, ValueError):
794
+ continue
795
+ name = safe_tree_name(row.get(label_key),
796
+ 'Assignment rule' if kind == 'rules'
797
+ else 'Service setting')
798
+ found.append((index, host, port, protocol, cluster,
799
+ cluster_name, server_id, server_name,
800
+ selector, name))
801
+ except Exception:
802
+ continue
803
+ except Exception:
804
+ pass # Preserve completed server reads, not private errors.
805
+ return found
806
+
807
+ batches = await asyncio.gather(*(list_one(index, *endpoint)
808
+ for index, endpoint in tuple(servers.items())))
809
+ return [row for batch in batches for row in batch]
810
+
811
+ rows = asyncio.run(inventory())
812
+ items = ''.join(
813
+ f'<tr data-searchable-row data-search-key="{html.escape(selector, quote=True)}">'
814
+ f'<td>{html.escape(f"{host}:{port}")}</td><td>{html.escape(protocol)}</td>'
815
+ f'<td><a href="/ras/{index}/clusters/{cluster}">{html.escape(cluster_name)}</a></td>'
816
+ f'<td><a href="/ras/{index}/clusters/{cluster}/servers/{server_id}">'
817
+ f'{html.escape(server_name)}</a></td>'
818
+ f'<td><a href="/ras/{index}/clusters/{cluster}/servers/{server_id}/{kind}'
819
+ f'{"?" + urlencode({"service_name": selector}, quote_via=quote) if kind == "transfer-directories" else "/" + selector}">'
820
+ f'{html.escape(name)}</a></td></tr>'
821
+ for index, host, port, protocol, cluster, cluster_name,
822
+ server_id, server_name, selector, name in rows)
823
+ title = kind.replace('-', ' ').title()
824
+ body = (f'<section><h1>{title} across RAS</h1><p><a href="/ras">All RAS servers</a>'
825
+ '</p>' + inventory_filter(title) +
826
+ '<div class="table-scroll"><table class="inventory-grid"><thead><tr>'
827
+ f'<th>RAS</th><th>Protocol</th><th>Cluster</th><th>Working server</th><th>{title}</th>'
828
+ '</tr></thead><tbody>' + items + '</tbody></table></div></section>')
829
+ return page(environ, start_response, body)
830
+ if path == '/ras/administrators':
831
+ if method != 'GET':
832
+ start_response('405 Method Not Allowed', [('Allow', 'GET')])
833
+ return [b'']
834
+ kind = parse_qs(environ.get('QUERY_STRING', '')).get('kind', [''])[0]
835
+ if kind not in ('agent-admins', 'cluster-admins'):
836
+ return page(environ, start_response, '<p>Invalid inventory kind</p>', '400 Bad Request')
837
+
838
+ async def inventory():
839
+ semaphore = asyncio.Semaphore(4)
840
+ deadline = asyncio.get_running_loop().time() + SEARCH_ENDPOINT_TIMEOUT
841
+
842
+ async def list_one(index, host, port, protocol):
843
+ found = []
844
+ try:
845
+ async with asyncio.timeout_at(deadline), semaphore:
846
+ async with RacClient(host, port, protocol) as client:
847
+ if kind == 'agent-admins':
848
+ for row in await client.list_agent_admins():
849
+ if not isinstance(row, dict):
850
+ continue
851
+ name = row.get('name')
852
+ if (isinstance(name, str) and name.strip()
853
+ and _search_text(name)):
854
+ found.append((index, host, port, protocol, '', '', name))
855
+ else:
856
+ for record in await client.list_clusters():
857
+ if not isinstance(record, dict):
858
+ continue
859
+ try:
860
+ cluster = str(UUID(str(record['cluster'])))
861
+ except (KeyError, TypeError, ValueError):
862
+ continue
863
+ cluster_name = safe_tree_name(record.get('name'),
864
+ safe_tree_name(record.get('host'),
865
+ 'Unnamed cluster'))
866
+ try:
867
+ for row in await client.list_cluster_admins(UUID(cluster)):
868
+ if not isinstance(row, dict):
869
+ continue
870
+ name = row.get('name')
871
+ if (isinstance(name, str) and name.strip()
872
+ and _search_text(name)):
873
+ found.append((index, host, port, protocol,
874
+ cluster, cluster_name, name))
875
+ except Exception:
876
+ continue
877
+ except Exception:
878
+ pass # Keep completed clusters, never expose private exception text.
879
+ return found
880
+
881
+ batches = await asyncio.gather(*(list_one(index, *endpoint)
882
+ for index, endpoint in tuple(servers.items())))
883
+ return [row for batch in batches for row in batch]
884
+
885
+ rows = asyncio.run(inventory())
886
+ items = ''.join(
887
+ f'<tr data-searchable-row data-search-key="{html.escape(name, quote=True)}">'
888
+ f'<td>{html.escape(f"{host}:{port}")}</td><td>{html.escape(protocol)}</td>'
889
+ + (f'<td><a href="/ras/{index}/clusters/{cluster}">'
890
+ f'{html.escape(cluster_name)}</a></td>' if cluster else '<td>—</td>')
891
+ + (f'<td><a href="/ras/{index}/clusters/{cluster}/admins">' if cluster else
892
+ f'<td><a href="/ras/{index}/agent/admins">')
893
+ + html.escape(name) + '</a></td></tr>'
894
+ for index, host, port, protocol, cluster, cluster_name, name in rows)
895
+ title = 'Agent administrators' if kind == 'agent-admins' else 'Cluster administrators'
896
+ body = (f'<section><h1>{title} across RAS</h1><p><a href="/ras">All RAS servers</a>'
897
+ '</p>' + inventory_filter(title) +
898
+ '<div class="table-scroll"><table class="inventory-grid"><thead><tr>'
899
+ '<th>RAS</th><th>Protocol</th><th>Cluster</th><th>Administrator</th>'
900
+ '</tr></thead><tbody>' + items + '</tbody></table></div></section>')
901
+ return page(environ, start_response, body)
902
+ if path == '/ras/cluster-inventory':
903
+ if method != 'GET':
904
+ start_response('405 Method Not Allowed', [('Allow', 'GET')])
905
+ return [b'']
906
+ kind = parse_qs(environ.get('QUERY_STRING', '')).get('kind', [''])[0]
907
+ if kind not in ('managers', 'counters', 'limits'):
908
+ return page(environ, start_response, '<p>Invalid inventory kind</p>', '400 Bad Request')
909
+ selector_key, is_uuid = SEARCH_CLUSTER_GROUPS[kind]
910
+
911
+ async def inventory():
912
+ semaphore = asyncio.Semaphore(4)
913
+ deadline = asyncio.get_running_loop().time() + SEARCH_ENDPOINT_TIMEOUT
914
+
915
+ async def list_one(index, host, port, protocol):
916
+ found = []
917
+ try:
918
+ async with asyncio.timeout_at(deadline), semaphore:
919
+ async with RacClient(host, port, protocol) as client:
920
+ for record in await client.list_clusters():
921
+ if not isinstance(record, dict):
922
+ continue
923
+ try:
924
+ cluster = str(UUID(str(record['cluster'])))
925
+ except (KeyError, TypeError, ValueError):
926
+ continue
927
+ cluster_name = safe_tree_name(record.get('name'),
928
+ safe_tree_name(record.get('host'),
929
+ 'Unnamed cluster'))
930
+ try:
931
+ for row in await getattr(client, 'list_' + kind)(UUID(cluster)):
932
+ if not isinstance(row, dict):
933
+ continue
934
+ try:
935
+ selector = (str(UUID(str(row[selector_key]))) if is_uuid
936
+ else row[selector_key])
937
+ except (KeyError, TypeError, ValueError):
938
+ continue
939
+ if not is_uuid and (not isinstance(selector, str)
940
+ or not selector.strip() or not _search_text(selector)):
941
+ continue
942
+ name = (safe_tree_name(row.get('host'), 'Cluster manager')
943
+ if is_uuid else selector)
944
+ found.append((index, host, port, protocol, cluster,
945
+ cluster_name, selector, name))
946
+ except Exception:
947
+ continue
948
+ except Exception:
949
+ pass # Retain completed clusters, not private exception text.
950
+ return found
951
+
952
+ batches = await asyncio.gather(*(list_one(index, *endpoint)
953
+ for index, endpoint in tuple(servers.items())))
954
+ return [row for batch in batches for row in batch]
955
+
956
+ rows = asyncio.run(inventory())
957
+ items = ''.join(
958
+ f'<tr data-searchable-row data-search-key="{html.escape(selector, quote=True)}">'
959
+ f'<td>{html.escape(f"{host}:{port}")}</td><td>{html.escape(protocol)}</td>'
960
+ f'<td><a href="/ras/{index}/clusters/{cluster}">{html.escape(cluster_name)}</a></td>'
961
+ f'<td><a href="/ras/{index}/clusters/{cluster}/{kind}/{quote(selector, safe="")}">'
962
+ f'{html.escape(name)}</a></td></tr>'
963
+ for index, host, port, protocol, cluster, cluster_name, selector, name in rows)
964
+ title = kind.title()
965
+ body = (f'<section><h1>{title} across RAS</h1><p><a href="/ras">All RAS servers</a>'
966
+ '</p>' + inventory_filter(title) +
967
+ '<div class="table-scroll"><table class="inventory-grid"><thead><tr>'
968
+ f'<th>RAS</th><th>Protocol</th><th>Cluster</th><th>{title}</th>'
969
+ '</tr></thead><tbody>' + items + '</tbody></table></div></section>')
970
+ return page(environ, start_response, body)
971
+ if path == '/ras/profiles':
972
+ if method != 'GET':
973
+ start_response('405 Method Not Allowed', [('Allow', 'GET')])
974
+ return [b'']
975
+
976
+ async def inventory():
977
+ semaphore = asyncio.Semaphore(4)
978
+ deadline = asyncio.get_running_loop().time() + SEARCH_ENDPOINT_TIMEOUT
979
+
980
+ async def list_one(index, host, port, protocol):
981
+ found = []
982
+ try:
983
+ async with asyncio.timeout_at(deadline), semaphore:
984
+ async with RacClient(host, port, protocol) as client:
985
+ for record in await client.list_clusters():
986
+ if not isinstance(record, dict):
987
+ continue
988
+ try:
989
+ cluster = str(UUID(str(record['cluster'])))
990
+ except (KeyError, TypeError, ValueError):
991
+ continue
992
+ cluster_name, cluster_host = record.get('name'), record.get('host')
993
+ if (not isinstance(cluster_name, str) or not cluster_name.strip()
994
+ or not _search_text(cluster_name)):
995
+ cluster_name = (cluster_host if isinstance(cluster_host, str)
996
+ and cluster_host.strip() and _search_text(cluster_host)
997
+ else 'Unnamed cluster')
998
+ try:
999
+ for row in await client.list_profiles(UUID(cluster)):
1000
+ if not isinstance(row, dict):
1001
+ continue
1002
+ name = row.get('name')
1003
+ if (not isinstance(name, str) or not name.strip()
1004
+ or not _search_text(name)):
1005
+ continue
1006
+ found.append((index, host, port, protocol, cluster,
1007
+ cluster_name, name))
1008
+ except Exception:
1009
+ continue
1010
+ except Exception:
1011
+ pass # Preserve completed clusters without exposing server errors.
1012
+ return found
1013
+
1014
+ groups = await asyncio.gather(*(list_one(index, *endpoint)
1015
+ for index, endpoint in tuple(servers.items())))
1016
+ return [row for group in groups for row in group]
1017
+
1018
+ rows = asyncio.run(inventory())
1019
+ items = ''.join(
1020
+ f'<tr data-searchable-row><td>{html.escape(f"{host}:{port}")}</td>'
1021
+ f'<td>{html.escape(protocol)}</td>'
1022
+ f'<td><a href="/ras/{index}/clusters/{cluster}">{html.escape(cluster_name)}</a></td>'
1023
+ f'<td><a href="/ras/{index}/clusters/{cluster}/profiles/overview?'
1024
+ f'{urlencode({"name": name}, quote_via=quote)}">{html.escape(name)}</a></td></tr>'
1025
+ for index, host, port, protocol, cluster, cluster_name, name in rows)
1026
+ body = ('<section><h1>Security profiles across RAS</h1><p>'
1027
+ '<a href="/ras">All RAS servers</a></p>' + inventory_filter('Security profiles') +
1028
+ '<div class="table-scroll"><table class="inventory-grid"><thead><tr>'
1029
+ '<th>RAS</th><th>Protocol</th><th>Cluster</th><th>Profile</th>'
1030
+ '</tr></thead><tbody>' + items + '</tbody></table></div></section>')
1031
+ return page(environ, start_response, body)
1032
+ if path == '/ras/infobases':
1033
+ if method != 'GET':
1034
+ start_response('405 Method Not Allowed', [('Allow', 'GET')])
1035
+ return [b'']
1036
+
1037
+ async def inventory():
1038
+ semaphore = asyncio.Semaphore(4)
1039
+ deadline = asyncio.get_running_loop().time() + SEARCH_ENDPOINT_TIMEOUT
1040
+
1041
+ async def list_one(index, host, port, protocol):
1042
+ found = []
1043
+ try:
1044
+ async with asyncio.timeout_at(deadline), semaphore:
1045
+ async with RacClient(host, port, protocol) as client:
1046
+ for record in await client.list_clusters():
1047
+ if not isinstance(record, dict):
1048
+ continue
1049
+ try:
1050
+ cluster = str(UUID(str(record['cluster'])))
1051
+ except (KeyError, TypeError, ValueError):
1052
+ continue
1053
+ cluster_name = record.get('name')
1054
+ cluster_host = record.get('host')
1055
+ if (not isinstance(cluster_name, str) or not cluster_name.strip()
1056
+ or not _search_text(cluster_name)):
1057
+ cluster_name = (cluster_host if isinstance(cluster_host, str)
1058
+ and cluster_host.strip() and _search_text(cluster_host)
1059
+ else 'Unnamed cluster')
1060
+ try:
1061
+ for row in await client.list_infobases(UUID(cluster)):
1062
+ if not isinstance(row, dict):
1063
+ continue
1064
+ try:
1065
+ infobase = str(UUID(str(row['infobase'])))
1066
+ except (KeyError, TypeError, ValueError):
1067
+ continue
1068
+ name = row.get('name')
1069
+ if (not isinstance(name, str) or not name.strip()
1070
+ or not _search_text(name)):
1071
+ name = 'Unnamed infobase'
1072
+ found.append((index, host, port, protocol, cluster,
1073
+ cluster_name, infobase, name))
1074
+ except Exception:
1075
+ continue
1076
+ except Exception:
1077
+ pass # Keep completed clusters, never expose RAS errors.
1078
+ return found
1079
+
1080
+ groups = await asyncio.gather(*(list_one(index, *endpoint)
1081
+ for index, endpoint in tuple(servers.items())))
1082
+ return [row for group in groups for row in group]
1083
+
1084
+ rows = asyncio.run(inventory())
1085
+ items = ''.join(
1086
+ f'<tr data-searchable-row data-search-key="{base}"><td>'
1087
+ f'{html.escape(f"{host}:{port}")}</td><td>{html.escape(protocol)}</td>'
1088
+ f'<td><a href="/ras/{index}/clusters/{cluster}">{html.escape(cluster_name)}</a></td>'
1089
+ f'<td><a href="/ras/{index}/clusters/{cluster}/infobases/{base}/summary">'
1090
+ f'{html.escape(name)}</a></td></tr>'
1091
+ for index, host, port, protocol, cluster, cluster_name, base, name in rows)
1092
+ body = ('<section><h1>Infobases across RAS</h1><p>'
1093
+ '<a href="/ras">All RAS servers</a></p>' + inventory_filter('Infobases') +
1094
+ '<div class="table-scroll"><table class="inventory-grid"><thead><tr>'
1095
+ '<th>RAS</th><th>Protocol</th><th>Cluster</th><th>Infobase</th>'
1096
+ '</tr></thead><tbody>' + items + '</tbody></table></div></section>')
1097
+ return page(environ, start_response, body)
1098
+ if path == '/ras/search':
1099
+ if method != 'GET':
1100
+ start_response('405 Method Not Allowed', [('Allow', 'GET')])
1101
+ return [b'']
1102
+ query = environ.get('QUERY_STRING', '')
1103
+ if len(query) > 512:
1104
+ return page(environ, start_response, '<p>Invalid search</p>', '400 Bad Request')
1105
+ try:
1106
+ if re.search(r'%(?![0-9A-Fa-f]{2})', query):
1107
+ raise ValueError('escape')
1108
+ unquote_to_bytes(query).decode('utf-8')
1109
+ fields = parse_qs(query, strict_parsing=bool(query))
1110
+ if set(fields) - {'q', 'scope'} or any(len(values) != 1 for values in fields.values()):
1111
+ raise ValueError('query')
1112
+ scope = fields.get('scope', ['inventory'])[0]
1113
+ if scope not in ('inventory', 'clusters', 'infobases', 'agent-admins', 'cluster-admins',
1114
+ 'services', 'locks', 'transfer-directories',
1115
+ 'servers', 'processes', 'sessions', 'profiles', *SEARCH_PROFILE_ACL,
1116
+ *SEARCH_CLUSTER_GROUPS, *SEARCH_SERVER_GROUPS):
1117
+ raise ValueError('scope')
1118
+ term = fields.get('q', [''])[0].strip()
1119
+ if len(term) > 100 or '\x00' in term:
1120
+ raise ValueError('query')
1121
+ except (ValueError, UnicodeError):
1122
+ return page(environ, start_response, '<p>Invalid search</p>', '400 Bad Request')
1123
+
1124
+ async def inventory():
1125
+ semaphore = asyncio.Semaphore(4)
1126
+ deadline = asyncio.get_running_loop().time() + SEARCH_ENDPOINT_TIMEOUT
1127
+
1128
+ async def query_server(index, host, port, protocol):
1129
+ found = []
1130
+ try:
1131
+ async with asyncio.timeout_at(deadline):
1132
+ async with semaphore:
1133
+ async with RacClient(host, port, protocol) as client:
1134
+ if scope == 'agent-admins':
1135
+ for admin in await client.list_agent_admins():
1136
+ if not isinstance(admin, dict):
1137
+ continue
1138
+ name = admin.get('name')
1139
+ os_user = admin.get('os-user', '')
1140
+ if (not isinstance(name, str) or not name or not _search_text(name)):
1141
+ continue
1142
+ if term.casefold() in name.casefold() or (
1143
+ isinstance(os_user, str) and _search_text(os_user)
1144
+ and term.casefold() in os_user.casefold()):
1145
+ found.append((index, host, port, protocol, scope, '', '', name))
1146
+ return found
1147
+ clusters = await client.list_clusters()
1148
+ for cluster in clusters:
1149
+ try:
1150
+ ident = str(UUID(str(cluster['cluster'])))
1151
+ except (KeyError, TypeError, ValueError):
1152
+ continue
1153
+ cluster_name = cluster.get('name', '')
1154
+ valid_name = _search_text(cluster_name) and bool(cluster_name.strip())
1155
+ cluster_host = cluster.get('host', '')
1156
+ valid_host = _search_text(cluster_host) and bool(cluster_host.strip())
1157
+ if scope in ('inventory', 'clusters') and (
1158
+ term.casefold() in ident.casefold()
1159
+ or valid_name and term.casefold() in cluster_name.casefold()
1160
+ or valid_host and term.casefold() in cluster_host.casefold()):
1161
+ found.append((index, host, port, protocol, 'cluster', ident,
1162
+ '', cluster_name if valid_name else
1163
+ cluster_host if valid_host and cluster_host else 'Unnamed cluster'))
1164
+ try:
1165
+ if scope == 'cluster-admins':
1166
+ for admin in await client.list_cluster_admins(UUID(ident)):
1167
+ if not isinstance(admin, dict):
1168
+ continue
1169
+ name = admin.get('name')
1170
+ os_user = admin.get('os-user', '')
1171
+ if (not isinstance(name, str) or not name or not _search_text(name)):
1172
+ continue
1173
+ if term.casefold() in name.casefold() or (
1174
+ isinstance(os_user, str) and _search_text(os_user)
1175
+ and term.casefold() in os_user.casefold()):
1176
+ found.append((index, host, port, protocol, scope,
1177
+ ident, '', name))
1178
+ elif scope in (*SEARCH_SERVER_GROUPS, 'transfer-directories'):
1179
+ for server in await client.list_servers(UUID(ident)):
1180
+ try:
1181
+ server_id = str(UUID(str(server['server'])))
1182
+ except (KeyError, TypeError, ValueError):
1183
+ continue
1184
+ try:
1185
+ if scope == 'transfer-directories':
1186
+ records = await client.list_service_data_dirs_for_transfer(
1187
+ UUID(ident), UUID(server_id))
1188
+ for record in records:
1189
+ if not isinstance(record, dict):
1190
+ continue
1191
+ name = record.get('service-name')
1192
+ infobase = record.get('infobase-name', '')
1193
+ if (not isinstance(name, str) or not name
1194
+ or not _search_text(name)):
1195
+ continue
1196
+ valid_infobase = (isinstance(infobase, str)
1197
+ and _search_text(infobase))
1198
+ if (term.casefold() in name.casefold()
1199
+ or isinstance(infobase, str)
1200
+ and valid_infobase
1201
+ and term.casefold() in infobase.casefold()):
1202
+ found.append((index, host, port, protocol, scope,
1203
+ ident, server_id, name))
1204
+ else:
1205
+ method, key, label_key = SEARCH_SERVER_GROUPS[scope]
1206
+ for record in await getattr(client, method)(UUID(ident), UUID(server_id)):
1207
+ try:
1208
+ record_id = str(UUID(str(record[key])))
1209
+ except (KeyError, TypeError, ValueError):
1210
+ continue
1211
+ name = record.get(label_key, '')
1212
+ valid_name = _search_text(name) and isinstance(name, str) and bool(name.strip())
1213
+ if scope == 'rules':
1214
+ secondary = (record.get('infobase-name', ''),
1215
+ record.get('application-ext', ''))
1216
+ else:
1217
+ secondary = (record.get('infobase-name', ''),)
1218
+ fields = (name, *secondary, record_id)
1219
+ if any(isinstance(field, str) and _search_text(field)
1220
+ and term.casefold() in field.casefold()
1221
+ for field in fields):
1222
+ label = (' · '.join(field for field in fields[:-1]
1223
+ if isinstance(field, str) and field.strip()
1224
+ and _search_text(field))
1225
+ if scope == 'rules' else
1226
+ name if valid_name else '')
1227
+ found.append((index, host, port, protocol, scope,
1228
+ ident, f'{server_id}/{record_id}',
1229
+ label or ('Assignment rule' if scope == 'rules'
1230
+ else 'Service setting')))
1231
+ except Exception:
1232
+ found.append((index, host, port, protocol, 'error', '', '', 'Unavailable'))
1233
+ elif scope == 'services':
1234
+ for record in await client.list_services(UUID(ident)):
1235
+ if not isinstance(record, dict):
1236
+ continue
1237
+ name = record.get('name')
1238
+ description = record.get('descr', '')
1239
+ if not isinstance(name, str) or not name or not _search_text(name):
1240
+ continue
1241
+ manager = record.get('manager')
1242
+ if (term.casefold() in name.casefold()
1243
+ or _search_text(description)
1244
+ and term.casefold() in description.casefold()
1245
+ or isinstance(manager, str) and _search_text(manager)
1246
+ and term.casefold() in manager.casefold()):
1247
+ found.append((index, host, port, protocol, scope,
1248
+ ident, '', name))
1249
+ elif scope == 'locks':
1250
+ for record in await client.list_locks(UUID(ident)):
1251
+ if not isinstance(record, dict):
1252
+ continue
1253
+ try:
1254
+ connection_id = str(UUID(str(record['connection'])))
1255
+ object_id = str(UUID(str(record['object'])))
1256
+ except (KeyError, TypeError, ValueError):
1257
+ continue
1258
+ description = record.get('lock-descr', '')
1259
+ valid_description = _search_text(description)
1260
+ if (term.casefold() in object_id.casefold()
1261
+ or term.casefold() in connection_id.casefold()
1262
+ or valid_description and term.casefold() in description.casefold()):
1263
+ label = description if valid_description and description.strip() else 'Lock'
1264
+ found.append((index, host, port, protocol, scope,
1265
+ ident, connection_id,
1266
+ label))
1267
+ elif scope == 'servers':
1268
+ for server in await client.list_servers(UUID(ident)):
1269
+ try:
1270
+ server_id = str(UUID(str(server['server'])))
1271
+ except (KeyError, TypeError, ValueError):
1272
+ continue
1273
+ server_name = server.get('name', '')
1274
+ valid_name = _search_text(server_name) and bool(server_name.strip())
1275
+ server_host = server.get('host_name', '')
1276
+ valid_host = _search_text(server_host) and bool(server_host.strip())
1277
+ if (term.casefold() in server_id.casefold()
1278
+ or valid_name and term.casefold() in server_name.casefold()
1279
+ or valid_host and term.casefold() in server_host.casefold()):
1280
+ found.append((index, host, port, protocol, 'server', ident,
1281
+ server_id, server_name if valid_name else
1282
+ server_host if valid_host and server_host else 'Unnamed server'))
1283
+ elif scope == 'processes':
1284
+ for process in await client.list_processes(UUID(ident)):
1285
+ try:
1286
+ process_id = str(UUID(str(process['process'])))
1287
+ except (KeyError, TypeError, ValueError):
1288
+ continue
1289
+ pid = process.get('pid')
1290
+ valid_pid = (type(pid) is str and pid.isascii()
1291
+ and pid.isdecimal() and len(pid) <= 20)
1292
+ process_host = process.get('host', '')
1293
+ if (isinstance(pid, str) and valid_pid
1294
+ and term.casefold() in pid.casefold()
1295
+ or term.casefold() in process_id.casefold()
1296
+ or _search_text(process_host)
1297
+ and term.casefold() in process_host.casefold()):
1298
+ label = f'PID {pid}' if valid_pid else 'Working process'
1299
+ found.append((index, host, port, protocol, 'process', ident,
1300
+ process_id, label))
1301
+ elif scope == 'sessions':
1302
+ for session in await client.list_sessions(UUID(ident)):
1303
+ try:
1304
+ session_id = str(UUID(str(session['session_id'])))
1305
+ except (KeyError, TypeError, ValueError):
1306
+ continue
1307
+ user = session.get('user_name', '')
1308
+ valid_user = _search_text(user) and bool(user.strip())
1309
+ number = session.get('id')
1310
+ number_matches = (type(number) is int and number >= 0
1311
+ and term == str(number))
1312
+ if (number_matches or any(isinstance(field, str)
1313
+ and _search_text(field)
1314
+ and term.casefold() in field.casefold()
1315
+ for field in (user, session.get('host'),
1316
+ session.get('app_id'), session_id))):
1317
+ display = (user if valid_user else
1318
+ f'Session {number}' if type(number) is int and number >= 0
1319
+ else 'Session')
1320
+ found.append((index, host, port, protocol, 'session', ident,
1321
+ session_id, display))
1322
+ elif scope == 'profiles':
1323
+ for profile in await client.list_profiles(UUID(ident)):
1324
+ if not isinstance(profile, dict):
1325
+ continue
1326
+ name = profile.get('name')
1327
+ if (not isinstance(name, str) or not name or not _search_text(name)):
1328
+ continue
1329
+ if term.casefold() in name.casefold():
1330
+ found.append((index, host, port, protocol, 'profile', ident,
1331
+ '', name))
1332
+ elif scope in SEARCH_PROFILE_ACL:
1333
+ acl_type, field = SEARCH_PROFILE_ACL[scope]
1334
+ for profile in await client.list_profiles(UUID(ident)):
1335
+ if not isinstance(profile, dict):
1336
+ continue
1337
+ name = profile.get('name')
1338
+ if (not isinstance(name, str) or not name.strip() or not _search_text(name)):
1339
+ continue
1340
+ try:
1341
+ records = await getattr(client, PROFILE_ACL_LISTS[acl_type])(
1342
+ UUID(ident), name)
1343
+ for record in records:
1344
+ if not isinstance(record, dict):
1345
+ continue
1346
+ alias = record.get(field)
1347
+ if (isinstance(alias, str) and alias and _search_text(alias)
1348
+ and term.casefold() in alias.casefold()):
1349
+ found.append((index, host, port, protocol, scope,
1350
+ ident, name, alias))
1351
+ except Exception:
1352
+ found.append((index, host, port, protocol, 'error', '', '',
1353
+ 'Unavailable'))
1354
+ elif scope in SEARCH_CLUSTER_GROUPS:
1355
+ field, is_uuid = SEARCH_CLUSTER_GROUPS[scope]
1356
+ for record in await getattr(client, 'list_' + scope)(UUID(ident)):
1357
+ try:
1358
+ value = record[field]
1359
+ if is_uuid:
1360
+ value = str(UUID(str(value)))
1361
+ elif (not isinstance(value, str) or not value or
1362
+ '\x00' in value or len(value.encode('utf-8')) > 1024):
1363
+ continue
1364
+ except (KeyError, TypeError, ValueError):
1365
+ continue
1366
+ if scope == 'managers':
1367
+ manager_host = record.get('host', '')
1368
+ valid_host = _search_text(manager_host) and bool(manager_host.strip())
1369
+ fields = (value, manager_host, record.get('descr', ''),
1370
+ record.get('pid', ''))
1371
+ label = manager_host if valid_host and manager_host else 'Cluster manager'
1372
+ elif scope == 'connections':
1373
+ connection_host = record.get('host', '')
1374
+ valid_host = _search_text(connection_host) and bool(connection_host.strip())
1375
+ fields = (value, connection_host, record.get('application'))
1376
+ number = record.get('conn-id')
1377
+ if type(number) is int and number >= 0 and term == str(number):
1378
+ fields += (str(number),)
1379
+ label = (connection_host if valid_host and connection_host else
1380
+ f'Connection {number}' if type(number) is int and number >= 0
1381
+ else 'Connection')
1382
+ else:
1383
+ fields = (value, record.get('descr'))
1384
+ label = value
1385
+ if any(isinstance(field, str) and _search_text(field)
1386
+ and term.casefold() in field.casefold()
1387
+ for field in fields):
1388
+ found.append((index, host, port, protocol, scope, ident,
1389
+ value, label or value))
1390
+ elif scope in ('inventory', 'infobases'):
1391
+ for base in await client.list_infobases(UUID(ident)):
1392
+ try:
1393
+ base_id = str(UUID(str(base['infobase'])))
1394
+ except (KeyError, TypeError, ValueError):
1395
+ continue
1396
+ base_name = base.get('name', '')
1397
+ valid_name = _search_text(base_name) and bool(base_name.strip())
1398
+ if (term.casefold() in base_id.casefold()
1399
+ or valid_name and term.casefold() in base_name.casefold()):
1400
+ found.append((index, host, port, protocol, 'infobase', ident,
1401
+ base_id, base_name if valid_name else 'Unnamed infobase'))
1402
+ except Exception:
1403
+ found.append((index, host, port, protocol, 'error', '', '', 'Unavailable'))
1404
+ except Exception:
1405
+ # No server exception text in HTML (may contain private connection data).
1406
+ found.append((index, host, port, protocol, 'error', '', '', 'Unavailable'))
1407
+ return found
1408
+
1409
+ groups = await asyncio.gather(*(query_server(index, *endpoint)
1410
+ for index, endpoint in tuple(servers.items())))
1411
+ return [row for group in groups for row in group]
1412
+
1413
+ rows = asyncio.run(inventory()) if term else []
1414
+ items = []
1415
+ for index, host, port, protocol, kind, cluster, base, name in rows:
1416
+ address = html.escape(f'{host}:{port}')
1417
+ label = html.escape(name)
1418
+ if kind == 'error':
1419
+ items.append(f'<tr><td>{address}</td><td>{html.escape(protocol)}</td>'
1420
+ '<td>Unavailable</td><td>—</td></tr>')
1421
+ continue
1422
+ target = f'/ras/{index}/clusters/{cluster}'
1423
+ if kind == 'agent-admins':
1424
+ target = f'/ras/{index}/agent/admins'
1425
+ elif kind == 'cluster-admins':
1426
+ target += '/admins'
1427
+ elif kind == 'services':
1428
+ target += '/services'
1429
+ elif kind == 'locks':
1430
+ target += f'/connections/{base}/locks'
1431
+ elif kind == 'infobase':
1432
+ target += f'/infobases/{base}/summary'
1433
+ elif kind == 'server':
1434
+ target += f'/servers/{base}'
1435
+ elif kind == 'process':
1436
+ target += f'/processes/{base}'
1437
+ elif kind == 'session':
1438
+ target += f'/sessions/{base}'
1439
+ elif kind == 'profile':
1440
+ target += '/profiles/overview?' + urlencode({'name': name})
1441
+ elif kind in SEARCH_PROFILE_ACL:
1442
+ target += (f'/profiles/{quote(base, safe="")}/acl/{SEARCH_PROFILE_ACL[kind][0]}'
1443
+ '?' + urlencode({'name': name}))
1444
+ elif kind in SEARCH_CLUSTER_GROUPS:
1445
+ target += f'/{kind}/{quote(base, safe="")}'
1446
+ elif kind == 'transfer-directories':
1447
+ target += f'/servers/{base}/transfer-directories?' + urlencode({'service_name': name})
1448
+ elif kind in SEARCH_SERVER_GROUPS:
1449
+ server_id, record_id = base.split('/')
1450
+ target += f'/servers/{server_id}/{kind}/{record_id}'
1451
+ attributes = {'ras': f'{host}:{port}', 'protocol': protocol, 'type': kind,
1452
+ 'object': name, 'cluster': cluster or '—'}
1453
+ inspection = ''.join(f' data-inspect-{key}="{html.escape(value, quote=True)}"'
1454
+ for key, value in attributes.items())
1455
+ items.append(f'<tr class="selectable-row" tabindex="0" aria-selected="false"{inspection}>'
1456
+ f'<td>{address}</td><td>{html.escape(protocol)}</td>'
1457
+ f'<td>{html.escape(SEARCH_KIND_NAMES.get(kind, SECTION_NAMES.get(kind, kind)))}</td><td><a href="{html.escape(target, quote=True)}">'
1458
+ f'{label}</a></td></tr>')
1459
+ body = ('<section><h1>Поиск по серверам RAS</h1><p><a href="/ras">Серверы RAS</a></p>'
1460
+ '<form method="get" action="/ras/search"><label>Имя, пользователь, PID или UUID '
1461
+ f'<input name="q" value="{html.escape(term, quote=True)}" maxlength="100"></label>'
1462
+ '<label>Область поиска <select name="scope"><option value="inventory"' +
1463
+ (' selected' if scope == 'inventory' else '') + '>Кластеры и базы</option>'
1464
+ '<option value="clusters"' + (' selected' if scope == 'clusters' else '') +
1465
+ '>Только кластеры</option>'
1466
+ '<option value="infobases"' + (' selected' if scope == 'infobases' else '') +
1467
+ '>Только базы</option>'
1468
+ '<option value="agent-admins"' + (' selected' if scope == 'agent-admins' else '') +
1469
+ '>Администраторы агента</option>'
1470
+ '<option value="cluster-admins"' + (' selected' if scope == 'cluster-admins' else '') +
1471
+ '>Администраторы кластера</option>'
1472
+ '<option value="services"' + (' selected' if scope == 'services' else '') +
1473
+ '>Сервисы кластера</option>'
1474
+ '<option value="locks"' + (' selected' if scope == 'locks' else '') +
1475
+ '>Блокировки кластера</option>'
1476
+ '<option value="servers"' + (' selected' if scope == 'servers' else '') +
1477
+ '>Кластеры и рабочие серверы</option>'
1478
+ '<option value="processes"' + (' selected' if scope == 'processes' else '') +
1479
+ '>Кластеры и процессы</option>'
1480
+ '<option value="sessions"' + (' selected' if scope == 'sessions' else '') +
1481
+ '>Кластеры и сеансы</option>'
1482
+ '<option value="profiles"' + (' selected' if scope == 'profiles' else '') +
1483
+ '>Кластеры и профили безопасности</option>' +
1484
+ ''.join(f'<option value="{kind}"' + (' selected' if scope == kind else '') +
1485
+ f'>{SEARCH_KIND_NAMES.get(kind, SECTION_NAMES.get(kind, kind))}</option>'
1486
+ for kind in SEARCH_PROFILE_ACL) +
1487
+ ''.join(f'<option value="{kind}"' + (' selected' if scope == kind else '') +
1488
+ f'>Кластеры и {SECTION_NAMES.get(kind, kind)}</option>' for kind in SEARCH_CLUSTER_GROUPS) +
1489
+ ''.join(f'<option value="{kind}"' + (' selected' if scope == kind else '') +
1490
+ f'>Серверы и {SECTION_NAMES.get(kind, kind)}</option>' for kind in SEARCH_SERVER_GROUPS) +
1491
+ '<option value="transfer-directories"' +
1492
+ (' selected' if scope == 'transfer-directories' else '') +
1493
+ '>Серверы и каталоги переноса</option>' +
1494
+ '</select></label>'
1495
+ '<button>Найти</button></form><div class="table-scroll">'
1496
+ '<table class="inventory-grid"><thead><tr><th scope="col">RAS</th>'
1497
+ '<th scope="col">Протокол</th><th scope="col">Тип</th>'
1498
+ '<th scope="col">Объект</th></tr></thead><tbody>' + ''.join(items) + '</tbody></table></div>'
1499
+ f'<p>Найдено объектов: {len(items)}</p></section>')
1500
+ detail = ('<h2>Выбор</h2><p>Выберите объект в таблице для просмотра подробностей.</p>'
1501
+ '<h2>Область поиска</h2><p>' +
1502
+ html.escape(SEARCH_KIND_NAMES.get(scope, SECTION_NAMES.get(scope, scope))) + '</p>')
1503
+ return page(environ, start_response, body, inspector=detail)
1504
+ if path in ('/ras', '/ras/'):
1505
+ if method == 'POST':
1506
+ host_header = environ.get('HTTP_HOST', '')
1507
+ origin = urlsplit(environ.get('HTTP_ORIGIN', ''))
1508
+ if (environ.get('REMOTE_ADDR') not in ('127.0.0.1', '::1') or
1509
+ not _LOOPBACK.fullmatch(host_header) or origin.scheme != 'http' or
1510
+ origin.netloc != host_header or origin.path or origin.query or origin.fragment or
1511
+ environ.get('CONTENT_TYPE', '').split(';', 1)[0] != 'application/x-www-form-urlencoded'):
1512
+ return page(environ, start_response, '<p>Invalid origin</p>', '403 Forbidden')
1513
+ try:
1514
+ length = int(environ.get('CONTENT_LENGTH', '-1'))
1515
+ if not 0 <= length <= 2048:
1516
+ raise ValueError('length')
1517
+ raw = environ['wsgi.input'].read(length)
1518
+ if len(raw) != length:
1519
+ raise ValueError('truncated')
1520
+ fields = parse_qs(raw.decode('utf-8'), strict_parsing=True)
1521
+ if set(fields) != {'csrf_token', 'host', 'port', 'protocol'} or any(
1522
+ len(values) != 1 for values in fields.values()):
1523
+ raise ValueError('fields')
1524
+ if not hmac.compare_digest(fields['csrf_token'][0], csrf_token):
1525
+ raise ValueError('token')
1526
+ host, port, protocol = (fields[name][0] for name in ('host', 'port', 'protocol'))
1527
+ if (not _HOST.fullmatch(host) or not port.isascii() or not port.isdecimal() or
1528
+ not 1 <= int(port) <= 65535 or protocol not in ('16.0', '10.0', 'auto') or
1529
+ len(servers) >= 64):
1530
+ raise ValueError('endpoint')
1531
+ endpoint = (host, int(port), protocol)
1532
+ if endpoint not in servers.values():
1533
+ servers[next_index] = endpoint
1534
+ next_index += 1
1535
+ except (ValueError, UnicodeError, KeyError):
1536
+ return page(environ, start_response, '<p>Invalid RAS endpoint</p>', '403 Forbidden')
1537
+ elif method != 'GET':
1538
+ start_response('405 Method Not Allowed', [('Allow', 'GET, POST')])
1539
+ return [b'']
1540
+ cards = ''.join(f'<tr><td><a href="/ras/{index}/clusters">{html.escape(host)}:{port}'
1541
+ f'</a></td><td>{html.escape(protocol)}</td><td>Локальное подключение</td><td>'
1542
+ '<form method="post" action="/ras/remove">'
1543
+ f'<input type="hidden" name="csrf_token" value="{html.escape(csrf_token, quote=True)}">'
1544
+ f'<input type="hidden" name="index" value="{index}">'
1545
+ f'<input type="hidden" name="confirm" value="{html.escape(f"{host}:{port}/{protocol}", quote=True)}">'
1546
+ '<button type="submit" class="danger-button">Удалить</button></form></td></tr>'
1547
+ for index, (host, port, protocol) in servers.items())
1548
+ body = ('<section><h1>Серверы RAS</h1><p>Выберите подключение для просмотра кластеров и баз. '
1549
+ '<a href="/ras/search">Поиск по серверам</a> · '
1550
+ '<a href="/ras/clusters">Все кластеры</a> · '
1551
+ '<a href="/ras/infobases">Все базы</a> · '
1552
+ '<a href="/ras/servers">Рабочие серверы</a> · '
1553
+ '<a href="/ras/processes">Рабочие процессы</a> · '
1554
+ '<a href="/ras/sessions">Сеансы</a> · '
1555
+ '<a href="/ras/connections">Соединения</a> · '
1556
+ '<a href="/ras/locks">Блокировки</a> · '
1557
+ '<a href="/ras/services">Сервисы</a> · '
1558
+ '<a href="/ras/profiles">Профили безопасности</a> · '
1559
+ '<a href="/ras/administrators?kind=agent-admins">Администраторы агента</a> · '
1560
+ '<a href="/ras/administrators?kind=cluster-admins">Администраторы кластера</a> · '
1561
+ '<a href="/ras/cluster-inventory?kind=managers">Менеджеры</a> · '
1562
+ '<a href="/ras/cluster-inventory?kind=counters">Счётчики</a> · '
1563
+ '<a href="/ras/cluster-inventory?kind=limits">Ограничения</a> · '
1564
+ '<a href="/ras/server-inventory?kind=rules">Правила</a> · '
1565
+ '<a href="/ras/server-inventory?kind=service-settings">Настройки сервисов</a> · '
1566
+ '<a href="/ras/server-inventory?kind=transfer-directories">Каталоги переноса</a></p>'
1567
+ '<div class="table-scroll"><table class="inventory-grid"><thead><tr>'
1568
+ '<th>Сервер RAS</th><th>Протокол</th><th>Режим</th><th>Действия</th>'
1569
+ '</tr></thead><tbody>' + cards + '</tbody></table></div><form method="post" action="/ras">'
1570
+ '<h2><span class="ui-icon" aria-hidden="true">+</span> Добавить сервер RAS</h2><input type="hidden" name="csrf_token" value="' +
1571
+ html.escape(csrf_token, quote=True) + '"><label>Адрес <input name="host" required></label>'
1572
+ '<label>Порт <input name="port" type="number" min="1" max="65535" value="1545" required></label>'
1573
+ '<label>Протокол <select name="protocol"><option>16.0</option><option>10.0</option><option>auto</option>'
1574
+ '</select></label><button>Добавить сервер</button></form></section>')
1575
+ return page(environ, start_response, body,
1576
+ inspector='<h2>Рабочая область</h2><dl><dt>Режим</dt><dd>Только чтение</dd>'
1577
+ f'<dt>Серверов</dt><dd>{len(servers)}</dd></dl>'
1578
+ '<p>Подключения хранятся только в памяти. Учётные данные не сохраняются.</p>')
1579
+ match = _ENDPOINT.fullmatch(path)
1580
+ if match:
1581
+ if method != 'GET':
1582
+ start_response('405 Method Not Allowed', [('Allow', 'GET')])
1583
+ return [b'']
1584
+ if len(match.group(1)) > 16:
1585
+ start_response('404 Not Found', [('Content-Type', 'text/plain')])
1586
+ return [b'Unknown RAS server']
1587
+ index = int(match.group(1))
1588
+ if index not in servers:
1589
+ start_response('404 Not Found', [('Content-Type', 'text/plain')])
1590
+ return [b'Unknown RAS server']
1591
+ host, port, protocol = servers[index]
1592
+ server_tree = re.fullmatch(
1593
+ r'clusters/([0-9a-fA-F-]{36})/servers/tree', match.group(2))
1594
+ profile_tree = re.fullmatch(
1595
+ r'clusters/([0-9a-fA-F-]{36})/profiles/tree', match.group(2))
1596
+ process_tree = re.fullmatch(
1597
+ r'clusters/([0-9a-fA-F-]{36})/servers/([0-9a-fA-F-]{36})/processes/tree',
1598
+ match.group(2))
1599
+ connection_tree = re.fullmatch(
1600
+ r'clusters/([0-9a-fA-F-]{36})/servers/([0-9a-fA-F-]{36})/processes/'
1601
+ r'([0-9a-fA-F-]{36})/connections/tree', match.group(2))
1602
+ if match.group(2) == 'tree' or server_tree or profile_tree or process_tree or connection_tree:
1603
+ def has_uuid(rows, field, selected):
1604
+ for row in rows:
1605
+ try:
1606
+ if str(UUID(str(row[field]))) == selected:
1607
+ return True
1608
+ except (KeyError, TypeError, ValueError):
1609
+ continue
1610
+ return False
1611
+
1612
+ selected_cluster = None
1613
+ selected_server = None
1614
+ selected_process = None
1615
+ tree_match = server_tree or profile_tree or process_tree or connection_tree
1616
+ if tree_match is not None:
1617
+ try:
1618
+ selected_cluster = str(UUID(tree_match.group(1)))
1619
+ if process_tree or connection_tree:
1620
+ selected_server = str(UUID(tree_match.group(2)))
1621
+ if connection_tree:
1622
+ selected_process = str(UUID(connection_tree.group(3)))
1623
+ except ValueError:
1624
+ start_response('404 Not Found', [('Content-Type', 'text/plain')])
1625
+ return [b'Unknown cluster']
1626
+ async def load_tree():
1627
+ result = []
1628
+ async with asyncio.timeout(SEARCH_ENDPOINT_TIMEOUT):
1629
+ async with RacClient(host, port, protocol) as client:
1630
+ for cluster in await client.list_clusters():
1631
+ try:
1632
+ cluster_id = str(UUID(str(cluster['cluster'])))
1633
+ except (ValueError, KeyError, TypeError):
1634
+ continue
1635
+ bases = []
1636
+ if selected_cluster is None:
1637
+ try:
1638
+ for base in await client.list_infobases(UUID(cluster_id)):
1639
+ try:
1640
+ base_id = str(UUID(str(base['infobase'])))
1641
+ except (KeyError, TypeError, ValueError):
1642
+ continue
1643
+ base_name = safe_tree_name(base.get('name'), 'Unnamed infobase')
1644
+ bases.append((base_id, base_name))
1645
+ except Exception:
1646
+ bases.append((None, 'Unavailable'))
1647
+ cluster_name = safe_tree_name(cluster.get('name'), 'Unnamed cluster')
1648
+ result.append((cluster_id, cluster_name, bases))
1649
+ working_servers = []
1650
+ profiles = []
1651
+ profile_error = False
1652
+ if profile_tree and selected_cluster is not None and any(
1653
+ row[0] == selected_cluster for row in result):
1654
+ try:
1655
+ for profile in await client.list_profiles(UUID(selected_cluster)):
1656
+ if not isinstance(profile, dict):
1657
+ continue
1658
+ name = profile.get('name')
1659
+ if (isinstance(name, str) and name.strip() and '\x00' not in name
1660
+ and safe_tree_name(name, '') == name):
1661
+ profiles.append(name)
1662
+ except Exception:
1663
+ profiles = []
1664
+ profile_error = True
1665
+ if selected_cluster is not None and not profile_tree and any(
1666
+ row[0] == selected_cluster for row in result):
1667
+ working_servers = await client.list_servers(UUID(selected_cluster))
1668
+ processes = []
1669
+ if selected_server is not None and has_uuid(working_servers, 'server', selected_server):
1670
+ for process in await client.list_processes(
1671
+ UUID(selected_cluster), UUID(selected_server)):
1672
+ if not isinstance(process, dict):
1673
+ continue
1674
+ try:
1675
+ UUID(str(process['process']))
1676
+ except (KeyError, TypeError, ValueError):
1677
+ continue
1678
+ pid = process.get('pid')
1679
+ if (type(pid) is not str or not pid.isascii() or not pid.isdecimal()
1680
+ or len(pid) > 20):
1681
+ process = dict(process, pid='Working process')
1682
+ processes.append(process)
1683
+ connections = []
1684
+ if selected_process is not None and has_uuid(processes, 'process', selected_process):
1685
+ for connection in await client.list_connections(
1686
+ UUID(selected_cluster), process_id=UUID(selected_process)):
1687
+ if not isinstance(connection, dict):
1688
+ continue
1689
+ try:
1690
+ UUID(str(connection['connection']))
1691
+ except (KeyError, TypeError, ValueError):
1692
+ continue
1693
+ connection_host = safe_tree_name(connection.get('host'), 'Connection')
1694
+ connections.append(dict(connection, host=connection_host))
1695
+ return result, working_servers, processes, connections, profiles, profile_error
1696
+
1697
+ try:
1698
+ clusters, working_servers, processes, connections, profiles, profile_error = asyncio.run(load_tree())
1699
+ except Exception:
1700
+ clusters = ()
1701
+ working_servers = ()
1702
+ processes = ()
1703
+ connections = ()
1704
+ profiles = ()
1705
+ profile_error = False
1706
+ status = 'Unavailable'
1707
+ else:
1708
+ status = f'{len(clusters)} clusters'
1709
+ if selected_cluster is not None:
1710
+ if status == 'Unavailable':
1711
+ return page(environ, start_response, '<p>RAS unavailable</p>',
1712
+ '503 Service Unavailable')
1713
+ if not any(row[0] == selected_cluster for row in clusters):
1714
+ start_response('404 Not Found', [('Content-Type', 'text/plain')])
1715
+ return [b'Unknown cluster']
1716
+ if selected_server is not None and not has_uuid(working_servers, 'server', selected_server):
1717
+ start_response('404 Not Found', [('Content-Type', 'text/plain')])
1718
+ return [b'Unknown server']
1719
+ if selected_process is not None and not has_uuid(processes, 'process', selected_process):
1720
+ start_response('404 Not Found', [('Content-Type', 'text/plain')])
1721
+ return [b'Unknown process']
1722
+ from ..read_service import SERVER_LISTS
1723
+ rows = []
1724
+ server_nodes = []
1725
+ for server in working_servers:
1726
+ try:
1727
+ server_id = str(UUID(str(server['server'])))
1728
+ except (KeyError, ValueError, TypeError):
1729
+ continue
1730
+ base = f'/ras/{index}/clusters/{selected_cluster}/servers/{server_id}'
1731
+ server_name = (safe_tree_name(server.get('name'), '') or
1732
+ safe_tree_name(server.get('host_name'), '') or 'Unnamed server')
1733
+ server_nodes.append((server_id, server_name))
1734
+ label = html.escape(server_name)
1735
+ links = (f'<a href="{base}">info</a> ' + ' '.join(
1736
+ f'<a href="{base}/{kind}">{html.escape(kind.replace("-", " "))}</a>'
1737
+ for kind in ('processes', *SERVER_LISTS)))
1738
+ attributes = (f'data-inspect-ras="{html.escape(f"{host}:{port}", quote=True)}" '
1739
+ f'data-inspect-protocol="{html.escape(protocol, quote=True)}" '
1740
+ f'data-inspect-cluster="{selected_cluster}" '
1741
+ 'data-inspect-type="server" '
1742
+ f'data-inspect-object="{html.escape(server_name, quote=True)}"')
1743
+ rows.append(f'<tr data-searchable-row data-search-key="{server_id}" class="selectable-row" tabindex="0" aria-selected="false" '
1744
+ f'{attributes}><td>{label}</td>'
1745
+ f'<td>{links}</td></tr>')
1746
+ body = ('<section><h1>Working servers</h1><p>Cluster ' +
1747
+ html.escape(next((name for ident, name, _ in clusters if ident == selected_cluster),
1748
+ 'Unnamed cluster')) + '</p>' + inventory_filter('Working servers') +
1749
+ '<div class="table-scroll">'
1750
+ '<table class="inventory-grid"><thead><tr><th>Name</th>'
1751
+ '<th>Sections</th></tr></thead><tbody>' +
1752
+ ''.join(rows) + '</tbody></table></div></section>')
1753
+ if profile_tree:
1754
+ profile_rows = []
1755
+ for name in profiles:
1756
+ profile_url = (f'/ras/{index}/clusters/{selected_cluster}/profiles/'
1757
+ f'{quote(name, safe="")}')
1758
+ overview = (f'/ras/{index}/clusters/{selected_cluster}/profiles/overview?'
1759
+ + urlencode({'name': name}))
1760
+ links = ' '.join(f'<a href="{profile_url}/acl/{kind}">'
1761
+ f'{html.escape(kind.title())}</a>' for kind in PROFILE_ACL_LISTS)
1762
+ attributes = (f'data-inspect-ras="{html.escape(f"{host}:{port}", quote=True)}" '
1763
+ f'data-inspect-protocol="{html.escape(protocol, quote=True)}" '
1764
+ f'data-inspect-cluster="{selected_cluster}" '
1765
+ 'data-inspect-type="profile" '
1766
+ f'data-inspect-object="{html.escape(name, quote=True)}"')
1767
+ profile_rows.append(
1768
+ '<tr data-searchable-row class="selectable-row" tabindex="0" '
1769
+ f'aria-selected="false" {attributes}><td><a href="{overview}">'
1770
+ f'{html.escape(name)}</a></td><td>{links}</td></tr>')
1771
+ body = ('<section><h1>Security profiles</h1><p>Cluster ' +
1772
+ html.escape(next((name for ident, name, _ in clusters if ident == selected_cluster),
1773
+ 'Unnamed cluster')) + '</p>' + inventory_filter('Security profiles') +
1774
+ '<div class="table-scroll"><table class="inventory-grid">'
1775
+ '<thead><tr><th>Name</th><th>ACL sections</th></tr></thead><tbody>' +
1776
+ (('<tr><td>Unavailable</td><td>—</td></tr>') if profile_error else
1777
+ ''.join(profile_rows)) + '</tbody></table></div></section>')
1778
+ process_nodes = []
1779
+ connection_nodes = []
1780
+ if selected_server is not None:
1781
+ process_rows = []
1782
+ for process in processes:
1783
+ try:
1784
+ process_id = str(UUID(str(process['process'])))
1785
+ except (KeyError, ValueError, TypeError):
1786
+ continue
1787
+ pid = str(process.get('pid', ''))
1788
+ process_nodes.append((process_id, pid))
1789
+ url = f'/ras/{index}/clusters/{selected_cluster}/processes/{process_id}'
1790
+ attributes = (f'data-inspect-ras="{html.escape(f"{host}:{port}", quote=True)}" '
1791
+ f'data-inspect-protocol="{html.escape(protocol, quote=True)}" '
1792
+ f'data-inspect-cluster="{selected_cluster}" '
1793
+ 'data-inspect-type="process" '
1794
+ f'data-inspect-object="{html.escape(pid, quote=True)}"')
1795
+ process_rows.append(
1796
+ f'<tr data-searchable-row data-search-key="{process_id}" class="selectable-row" tabindex="0" aria-selected="false" '
1797
+ f'{attributes}><td><a href="{url}">{html.escape(pid)}</a></td>'
1798
+ f'<td><a href="{url}/connections">'
1799
+ 'connections</a></td></tr>')
1800
+ body += ('<section><h2>Processes</h2>' + inventory_filter('Processes') +
1801
+ '<div class="table-scroll">'
1802
+ '<table class="inventory-grid"><thead><tr><th>PID</th>'
1803
+ '<th>Sections</th></tr></thead><tbody>' + ''.join(process_rows) +
1804
+ '</tbody></table></div></section>')
1805
+ if selected_process is not None:
1806
+ connection_rows = []
1807
+ for connection in connections:
1808
+ try:
1809
+ connection_id = str(UUID(str(connection['connection'])))
1810
+ except (KeyError, ValueError, TypeError):
1811
+ continue
1812
+ connection_host = str(connection.get('host', ''))
1813
+ connection_nodes.append((connection_id, connection_host))
1814
+ url = f'/ras/{index}/clusters/{selected_cluster}/connections/{connection_id}'
1815
+ attributes = (f'data-inspect-ras="{html.escape(f"{host}:{port}", quote=True)}" '
1816
+ f'data-inspect-protocol="{html.escape(protocol, quote=True)}" '
1817
+ f'data-inspect-cluster="{selected_cluster}" '
1818
+ 'data-inspect-type="connection" '
1819
+ f'data-inspect-object="{html.escape(connection_host, quote=True)}"')
1820
+ connection_rows.append(
1821
+ f'<tr data-searchable-row data-search-key="{connection_id}" class="selectable-row" tabindex="0" '
1822
+ f'aria-selected="false" {attributes}><td><a href="{url}">'
1823
+ f'{html.escape(connection_host)}</a></td>'
1824
+ f'<td><a href="{url}/locks">locks</a></td></tr>')
1825
+ body += ('<section><h2>Connections</h2>' + inventory_filter('Connections') +
1826
+ '<div class="table-scroll"><table class="inventory-grid">'
1827
+ '<thead><tr><th>Host</th><th>Sections</th></tr></thead>'
1828
+ '<tbody>' + ''.join(connection_rows) + '</tbody></table></div></section>')
1829
+ return page(environ, start_response, body,
1830
+ expanded_index=index, clusters=clusters,
1831
+ expanded_cluster=selected_cluster, servers_for_cluster=server_nodes,
1832
+ expanded_server=selected_server, processes_for_server=process_nodes,
1833
+ expanded_process=selected_process, connections_for_process=connection_nodes,
1834
+ profiles_for_cluster=profiles)
1835
+ rows = []
1836
+ for cluster_id, name, bases in clusters:
1837
+ cluster_url = f'/ras/{index}/clusters/{cluster_id}'
1838
+ cluster_label = html.escape(name)
1839
+ attributes = (f'data-inspect-ras="{html.escape(f"{host}:{port}", quote=True)}" '
1840
+ f'data-inspect-protocol="{html.escape(protocol, quote=True)}" '
1841
+ f'data-inspect-cluster="{cluster_id}"')
1842
+ rows.append(f'<tr data-searchable-row data-search-key="{cluster_id}" class="selectable-row" tabindex="0" aria-selected="false" '
1843
+ f'{attributes} data-inspect-type="cluster" '
1844
+ f'data-inspect-object="{html.escape(name, quote=True)}">'
1845
+ f'<td><a href="{cluster_url}">{cluster_label}</a></td>'
1846
+ '<td>Cluster</td></tr>')
1847
+ for base_id, base_name in bases:
1848
+ if base_id is None:
1849
+ rows.append('<tr><td>Unavailable</td><td>Infobases</td></tr>')
1850
+ continue
1851
+ base_url = f'{cluster_url}/infobases/{base_id}/summary'
1852
+ rows.append(f'<tr data-searchable-row data-search-key="{base_id}" class="selectable-row" tabindex="0" aria-selected="false" '
1853
+ f'{attributes} data-inspect-type="infobase" '
1854
+ f'data-inspect-object="{html.escape(base_name, quote=True)}">'
1855
+ f'<td>↳ <a href="{base_url}">{html.escape(base_name)}</a></td>'
1856
+ '<td>Infobase</td></tr>')
1857
+ body = ('<section><h1>RAS navigation</h1><p>' + html.escape(f'{host}:{port}') +
1858
+ ' · ' + html.escape(protocol) + '</p><p>' + status +
1859
+ '</p><p>Select a cluster or infobase in the tree, or '
1860
+ f'<a href="/ras/{index}/clusters">open the cluster table</a>.</p>' +
1861
+ inventory_filter('Clusters and infobases') +
1862
+ '<div class="table-scroll"><table class="inventory-grid"><thead><tr>'
1863
+ '<th>Name</th><th>Type</th></tr></thead><tbody>' +
1864
+ ''.join(rows) + '</tbody></table></div></section>')
1865
+ return page(environ, start_response, body, expanded_index=index, clusters=clusters,
1866
+ inspector='<h2>Selected RAS</h2><dl><dt>Endpoint</dt><dd>' +
1867
+ html.escape(f'{host}:{port}') + '</dd><dt>Protocol</dt><dd>' +
1868
+ html.escape(protocol) + '</dd></dl>')
1869
+ prefix = f'/ras/{index}'
1870
+ scoped = dict(environ, PATH_INFO='/' + match.group(2))
1871
+ # Prefix only local absolute links/forms. External links are never rewritten.
1872
+ def respond(status, headers, exc_info=None):
1873
+ scoped['_response_status'] = status
1874
+ scoped['_response_headers'] = headers
1875
+ return start_response(status, headers, exc_info)
1876
+ chunks = create_app(lambda: RacClient(host, port, protocol),
1877
+ read_timeout=SEARCH_ENDPOINT_TIMEOUT)(scoped, respond)
1878
+ content_type = next((value for name, value in scoped.get('_response_headers', [])
1879
+ if name.lower() == 'content-type'), '')
1880
+ if 'text/html' not in content_type:
1881
+ return chunks
1882
+ scoped_html = b''.join(re.sub(
1883
+ rb'\b(href|action)="/(?!ras(?:/|"))',
1884
+ lambda hit: hit.group(1) + b'="' + prefix.encode() + b'/', chunk)
1885
+ for chunk in chunks)
1886
+ if scoped.get('_response_status') != '200 OK':
1887
+ return [scoped_html]
1888
+ # The read-only dashboard returns a complete document. Reuse only its
1889
+ # generated main content, not its standalone navigation or scripts.
1890
+ _head, marker, rest = scoped_html.partition(b'<main>')
1891
+ content, end, after = rest.rpartition(b'</main>')
1892
+ if not marker or not end or b'</body>' not in after:
1893
+ return [scoped_html]
1894
+ return [render_document('RAS workspace', content.decode('utf-8'),
1895
+ workspace_tree=navigation(),
1896
+ inspector='<h2>Selected RAS</h2><dl><dt>Endpoint</dt><dd>' +
1897
+ html.escape(f'{host}:{port}') + '</dd><dt>Protocol</dt><dd>' +
1898
+ html.escape(protocol) + '</dd></dl>').encode('utf-8')]
1899
+ if path in ('/', '/clusters'):
1900
+ return page(environ, start_response,
1901
+ '<section><h1>RAS workspace</h1><p><a href="/ras">Open servers →</a></p></section>')
1902
+ start_response('404 Not Found', [('Content-Type', 'text/plain')])
1903
+ return [b'Not found']
1904
+
1905
+ return workspace