Line data Source code
1 341 : /*
2 : * Copyright (C) 2024 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 341 : import cockpit from "cockpit";
7 :
8 : import { EventEmitter } from "cockpit/event";
9 : import { Status } from "notifications";
10 :
11 : import { Router } from "./router.jsx";
12 : import {
13 : machines as machines_factory,
14 : Machine, Machines, Loader
15 : } from "./machines/machines.js";
16 : import {
17 : decode_location, decode_window_location, push_window_location, replace_window_location,
18 : compile_manifests, compute_frame_url,
19 : Location, ManifestItem, CompiledComponents,
20 : } from "./util.jsx";
21 : import { Manifest, ShellManifest, import_ShellManifest } from "./manifests";
22 : import { validate } from "import-json";
23 :
24 : export interface ShellConfig {
25 : language: string;
26 : language_direction: string;
27 : host_switcher_enabled: boolean;
28 : manifest: ShellManifest;
29 : }
30 :
31 : export interface ShellFrame {
32 : name: string;
33 : host: string;
34 : path: string;
35 : title: string;
36 : url: string | null;
37 : hash: string;
38 : ready: boolean;
39 : loaded: boolean;
40 : }
41 :
42 : export interface ShellStateEvents {
43 : update: () => void;
44 : connect: () => void;
45 : }
46 :
47 341 : export class ShellState extends EventEmitter<ShellStateEvents> {
48 341 : constructor() {
49 341 : super();
50 341 : this.config = this.#init_config();
51 :
52 341 : this.machines = this.#init_machines();
53 341 : this.loader = this.#init_loader();
54 341 : this.router = this.#init_router();
55 :
56 341 : this.#init_oops();
57 341 : this.#init_page_status();
58 :
59 341 : this.#on_ready();
60 341 : }
61 :
62 : /* READINESS STATE
63 : */
64 :
65 341 : ready: boolean = false;
66 341 : has_oops: boolean = false;
67 :
68 341 : #on_ready() {
69 338 : if (this.machines.ready && this.#config_ready) {
70 338 : this.ready = true;
71 1 : window.addEventListener("popstate", () => {
72 1 : this.update();
73 1 : this.ensure_frame_loaded();
74 1 : this.ensure_connection();
75 1 : });
76 :
77 338 : this.update();
78 338 : this.ensure_frame_loaded();
79 338 : this.ensure_connection();
80 338 : }
81 341 : }
82 :
83 : /* CONFIG
84 : */
85 :
86 : config: ShellConfig;
87 :
88 341 : #config_ready: boolean = false;
89 :
90 341 : #init_config() {
91 341 : let language = document.cookie.replace(/(?:(?:^|.*;\s*)CockpitLang\s*=\s*([^;]*).*$)|^.*$/, "$1");
92 341 : if (!language)
93 341 : language = navigator.language.toLowerCase(); // Default to Accept-Language header
94 :
95 341 : const config = {
96 341 : language,
97 341 : language_direction: cockpit.language_direction,
98 341 : host_switcher_enabled: false,
99 341 : manifest: validate("manifests.shell", cockpit.manifests.shell, import_ShellManifest,
100 341 : { docs: undefined, locales: undefined }),
101 341 : };
102 :
103 : /* Host switcher enabled? */
104 341 : const meta_multihost = document.head.querySelector("meta[name='allow-multihost']");
105 341 : if (meta_multihost instanceof HTMLMetaElement && meta_multihost.content == "yes")
106 76 : config.host_switcher_enabled = true;
107 :
108 : /* Should show warning before connecting? */
109 341 : this.#config_ready = false;
110 341 : cockpit.dbus(null, { bus: "internal" }).call("/config", "cockpit.Config", "GetString",
111 341 : ["Session", "WarnBeforeConnecting"], {})
112 1 : .then(([result]) => {
113 0 : if (result == "false" || result == "no") {
114 1 : window.sessionStorage.setItem("connection-warning-shown", "yes");
115 1 : }
116 1 : })
117 340 : .catch(e => {
118 340 : if (e.name != "cockpit.Config.KeyError")
119 65 : console.warn("Error reading WarnBeforeConnecting configuration:", e.message);
120 340 : })
121 341 : .finally(() => {
122 341 : this.#config_ready = true;
123 341 : this.#on_ready();
124 341 : });
125 :
126 341 : return config;
127 341 : }
128 :
129 : /* MACHINES AND LOADER
130 : *
131 : * These are part of the machinery in the basement that maintains
132 : * the database of all hosts (including "localhost"), and monitors
133 : * their manifests.
134 : */
135 :
136 : machines: Machines;
137 : loader: Loader;
138 :
139 341 : #init_machines() {
140 341 : const machines = machines_factory.instance();
141 :
142 338 : machines.addEventListener("ready", () => this.#on_ready());
143 :
144 0 : machines.addEventListener("removed", (_, machine) => {
145 0 : this.#remove_machine_frames(machine);
146 0 : });
147 338 : machines.addEventListener("added", (_, machine) => {
148 338 : this.#preload_machine_frames(machine);
149 338 : });
150 338 : machines.addEventListener("updated", (_, machine) => {
151 338 : if (!machine.visible || machine.problem)
152 67 : this.#remove_machine_frames(machine);
153 : else
154 338 : this.#preload_machine_frames(machine);
155 338 : });
156 :
157 341 : return machines;
158 341 : }
159 :
160 341 : #init_loader() {
161 341 : return machines_factory.loader(this.machines);
162 341 : }
163 :
164 : /* OOPS HANDLING
165 : */
166 :
167 341 : #init_oops() {
168 341 : const old_onerror = window.onerror;
169 0 : window.onerror = (msg, url, line) => {
170 : // Errors with url == "" are not logged apparently, so let's
171 : // not show the "Oops" for them either.
172 0 : if (url != "") {
173 0 : this.has_oops = true;
174 0 : this.update();
175 0 : }
176 0 : if (old_onerror)
177 0 : return old_onerror(msg, url, line);
178 0 : return false;
179 0 : };
180 341 : }
181 :
182 : /* FRAMES
183 : *
184 : * Frames are created on-demand when navigating to them for the
185 : * first time, by calling ensure_frame().
186 : *
187 : * Once a frame object is created it doesn't change anymore except
188 : * for its "ready", "loaded", and "hash" properties.
189 : *
190 : * The "ready" property starts out false and goes to true once the
191 : * corresponding iframe has loaded its URL. The "loaded" property
192 : * starts out false and goes true once the code loaded into the
193 : * frame has sent its "init" message.
194 : *
195 : * Removing things (frames) is complicated, as usual. We need to
196 : * be able to represent the state "The current frame has been
197 : * removed" without any call to update() re-creating it
198 : * spontaneously. Thus, a frame has a special "dead" state where
199 : * its "url" property is null. Actually clicking on navigation
200 : * elements will call the "ensure_frame_loaded" hook, which will
201 : * bring the current frame back to life if necessary. This happens
202 : * in the "jump" method.
203 : */
204 :
205 341 : frames: { [name: string]: ShellFrame } = { };
206 :
207 365 : #ensure_frame(machine: Machine, path: string, hash: string | null, title: string): ShellFrame | null {
208 : /* Never create new frames for machines that are not
209 : connected yet. That would open a channel to them (for
210 : loading the URL), which woould trigger the bridge to
211 : attempt a log in. We want all logins to happen in a
212 : single place (in hosts.jsx) so that we can get the
213 : options right, and show a warning dialog.
214 : */
215 99 : if (machine.address != "localhost" && machine.state !== "connected")
216 99 : return null;
217 :
218 365 : const name = "cockpit1:" + machine.connection_string + "/" + path;
219 365 : let frame = this.frames[name];
220 :
221 365 : if (!frame) {
222 365 : frame = this.frames[name] = {
223 365 : name,
224 365 : host: machine.address,
225 365 : path,
226 365 : url: compute_frame_url(machine, path),
227 365 : hash: hash || "/",
228 365 : title,
229 365 : ready: false,
230 365 : loaded: false,
231 365 : };
232 365 : } else {
233 : // XXX - shouldn't we leave the hash alone when it is null here?
234 365 : frame.hash = hash || "/";
235 365 : }
236 365 : return frame;
237 365 : }
238 :
239 338 : ensure_frame_loaded (): void {
240 63 : if (this.current_frame && this.current_frame.url == null) {
241 : // Let update() recreate the frame.
242 63 : delete this.frames[this.current_frame.name];
243 63 : this.current_frame = null;
244 63 : this.update();
245 63 : }
246 338 : }
247 :
248 6 : #kill_frame(name: string): void {
249 : // Only mark frame as dead, it gets removed for real during
250 : // the call to "update".
251 6 : this.frames[name].url = null;
252 6 : }
253 :
254 1 : remove_frame (name: string): void {
255 1 : this.#kill_frame(name);
256 1 : this.update();
257 1 : }
258 :
259 6 : #remove_machine_frames (machine: Machine): void {
260 6 : const names = Object.keys(this.frames);
261 6 : for (const n of names) {
262 6 : if (this.frames[n].host == machine.address)
263 6 : this.#kill_frame(n);
264 6 : }
265 6 : this.update();
266 6 : }
267 :
268 338 : #preload_machine_frames (machine: Machine) {
269 338 : const manifests = machine.manifests;
270 338 : const compiled = compile_manifests(manifests);
271 338 : for (const c in manifests) {
272 338 : const preload = manifests[c].preload as unknown as string[];
273 329 : if (preload && preload.length) {
274 329 : for (const p of preload) {
275 68 : const path = (p == "index") ? c : c + "/" + p;
276 329 : const item = compiled.find_path_item(path);
277 329 : this.#ensure_frame(machine, path, null, item.label);
278 329 : }
279 329 : }
280 338 : }
281 338 : this.update();
282 338 : }
283 :
284 : /* PAGE STATUS
285 : *
286 : * Page status notifications arrive from the Router (see
287 : * below). We also store them in the session storage so that
288 : * individual pages have access to all collected statuses.
289 : */
290 :
291 341 : page_status: { [host: string]: { [page: string]: Status } } = { };
292 :
293 341 : #init_page_status() {
294 341 : sessionStorage.removeItem("cockpit:page_status");
295 341 : }
296 :
297 29 : #notify_page_status(host: string, page: string, status: Status) {
298 29 : if (!this.page_status[host])
299 29 : this.page_status[host] = { };
300 29 : this.page_status[host][page] = status;
301 29 : sessionStorage.setItem("cockpit:page_status", JSON.stringify(this.page_status));
302 29 : this.update();
303 29 : }
304 :
305 : /* ROUTER
306 : *
307 : * The router is the machinery in our basement that forwards
308 : * Cockpit protocol messages between the WebSocket and the
309 : * frames. Some messages are also meant for the Shell itself, and
310 : * we pass a big object with callback function to the router to
311 : * process these and other noteworthy events.
312 : */
313 :
314 : router: Router;
315 :
316 341 : #init_router() {
317 341 : const callbacks = {
318 : /* The router has just processed the "init" message of the
319 : * code loaded into the frame named FRAME_NAME.
320 : *
321 : * We set the "loaded" property to help the tests, and also
322 : * tell the frame whether it is visible or not.
323 : */
324 337 : frame_is_initialized: (frame_name: string) => {
325 337 : const frame = this.frames[frame_name];
326 337 : if (frame) {
327 337 : frame.loaded = true;
328 337 : this.update();
329 337 : }
330 337 : this.#send_frame_hidden_hint(frame_name);
331 337 : },
332 :
333 : /* The frame named FRAME_NAME wants the shell to jump to
334 : * LOCATION.
335 : *
336 : * Only requests from the current frame are honored. But the
337 : * tests also use this extensively for navigation, and might
338 : * send messages from the top-most window, which we know is
339 : * named "cockpit1".
340 : */
341 50 : perform_frame_jump_command: (frame_name: string, location: string) => {
342 38 : if (frame_name == "cockpit1" || (this.current_frame && this.current_frame.name == frame_name)) {
343 50 : this.jump(location);
344 50 : this.ensure_connection();
345 50 : }
346 50 : },
347 :
348 : /* The frame named FRAME_NAMED has just changed the hash part
349 : * of its URL. That's how frames navigate within themselves.
350 : *
351 : * When the current frame does that, we need to reflect the
352 : * hash change in the shell URL as well.
353 : */
354 337 : perform_frame_hash_track: (frame_name: string, hash: string) => {
355 : /* Note that we ignore tracking for old shell code */
356 337 : if (this.current_frame && this.current_frame.name === frame_name &&
357 337 : frame_name && frame_name.indexOf("/shell/shell") === -1) {
358 : /* The browser has already pushed an appropriate entry to
359 : the history, so let's just replace it with one that
360 : includes the right hash.
361 : */
362 337 : const location = Object.assign({}, decode_window_location(), { hash });
363 337 : replace_window_location(location);
364 337 : this.#remember_location(location.host, location.path, location.hash);
365 337 : this.update();
366 337 : }
367 337 : },
368 :
369 : /* A notification has been received from a frame. We only
370 : * handle page status notifications, such as the ones that
371 : * tell you when software updates are available. PAGE is the
372 : * "well-known name" of a page, such as "system",
373 : * "network/firewall", or "updates".
374 : */
375 29 : handle_notifications: (host: string, page: string, data: { page_status?: Status }) => {
376 29 : if (data.page_status !== undefined)
377 29 : this.#notify_page_status(host, page, data.page_status);
378 29 : },
379 :
380 : /* One of the frames has experienced a unhandled JavaScript exception.
381 : */
382 1 : show_oops: () => {
383 1 : this.has_oops = true;
384 1 : this.update();
385 1 : },
386 :
387 : /* The host with address HOST has just initiated a restart. We
388 : * tell the loader.
389 : */
390 0 : expect_restart: (host: string) => {
391 0 : this.loader.expect_restart(host);
392 0 : },
393 341 : };
394 :
395 341 : return new Router(callbacks);
396 341 : }
397 :
398 338 : #send_frame_hidden_hint (frame_name: string) {
399 338 : const hidden = !this.current_frame || this.current_frame.name != frame_name;
400 338 : this.router.hint(frame_name, { hidden });
401 338 : }
402 :
403 : /* NAVIGATION
404 : *
405 : * The main navigation function, jump(), will change
406 : * window.location as requested and then trigger a general
407 : * ShellState update. The update processing will look at
408 : * window.location and update the various "current_*" properties
409 : * of the shell state accordingly. (The update processing might
410 : * also change window.location again itself, in order to
411 : * canonicalize it.)
412 : *
413 : * The new location given to jump() can be partial; the missing
414 : * pieces are filled in from the browsing history in a (almost)
415 : * natural way. If the HOST part is missing, it will be taken from
416 : * the current location. If the PATH part is missing, the last
417 : * path visited on the given host is used. And if the HASH is
418 : * missing, the last one from the given HOST/PATH combination is
419 : * used. But only, and this is a historical quirk, when the new
420 : * host/path differs from the current host/path. Don't rely on
421 : * that, always use "/" as the hash when jumping to the top
422 : * sub-page.
423 : *
424 : * Calling jump() will also make sure that the (newly) current
425 : * frame will now be loaded again in the case that it was
426 : * explicitly removed earlier. (This also happens when
427 : * window.location isn't actually changed by jump().)
428 : *
429 : * But jump() will never open a new connection to a HOST that is
430 : * not yet connected. If you want that, call ensure_connection()
431 : * right after jump(). However, it is better to first connect to
432 : * the host using the connect_host function from hosts_dialog.jsx
433 : * and only call jump() when that has succeeded.
434 : *
435 : * Calling ensure_connection() will start a user interaction to
436 : * open a connection to the host of the current navigation
437 : * location, but will not wait for this to be complete.
438 : */
439 :
440 341 : #last_path_for_host: Record<string, string> = { };
441 341 : #last_hash_for_host_path: Record<string, Record<string, string>> = { };
442 :
443 12 : most_recent_path_for_host(host: string) {
444 11 : return this.#last_path_for_host[host] || "";
445 12 : }
446 :
447 39 : #most_recent_hash_for_path(host: string, path: string) {
448 39 : if (this.#last_hash_for_host_path[host])
449 34 : return this.#last_hash_for_host_path[host][path] || null;
450 15 : return null;
451 39 : }
452 :
453 365 : #remember_location(host: string, path: string, hash: string) {
454 365 : this.#last_path_for_host[host] = path;
455 365 : if (!this.#last_hash_for_host_path[host])
456 365 : this.#last_hash_for_host_path[host] = { };
457 365 : this.#last_hash_for_host_path[host][path] = hash;
458 365 : }
459 :
460 58 : jump (location: Partial<Location> | string): boolean {
461 58 : if (typeof location === "string")
462 51 : location = decode_location(location);
463 :
464 58 : const current = decode_window_location();
465 :
466 : /* Fill in the missing pieces, in order.
467 : */
468 :
469 58 : if (!location.host)
470 5 : location.host = current.host || "localhost";
471 :
472 58 : if (!location.path)
473 15 : location.path = this.most_recent_path_for_host(location.host);
474 :
475 48 : if (!location.hash) {
476 41 : if (location.host != current.host || location.path != current.path)
477 8 : location.hash = this.#most_recent_hash_for_path(location.host, location.path) || "/";
478 : else
479 17 : console.warn('Shell jump with hash and no frame change. Please use "/" as the hash to jump to the top sub-page.');
480 48 : }
481 :
482 58 : if (location.host !== current.host ||
483 51 : location.path !== current.path ||
484 22 : location.hash !== current.hash) {
485 57 : push_window_location(location);
486 57 : this.update();
487 57 : this.ensure_frame_loaded();
488 57 : return true;
489 57 : }
490 :
491 9 : this.ensure_frame_loaded();
492 9 : return false;
493 58 : }
494 :
495 338 : ensure_connection() {
496 338 : if (this.current_machine) {
497 : // Handle localhost right here, we never need user
498 : // interactions for it, and it is kind of important to not
499 : // mess up connecting to localhost. So we avoid relying on
500 : // the bigger machinery for it.
501 : //
502 338 : if (this.current_machine.connection_string == "localhost") {
503 338 : this.loader.connect("localhost");
504 338 : return;
505 338 : }
506 :
507 69 : this.emit("connect");
508 69 : }
509 338 : }
510 :
511 : /* STATE
512 : *
513 : * Whenever the shell state changes, the "updated" event is
514 : * dispatched.
515 : *
516 : * The main part of the shell state is the information related to
517 : * the current navigation location:
518 : *
519 : * - current_location
520 : *
521 : * A object with "host", "path", and "hash" fields that reflect
522 : * the current location. "hash" does not have the "#" character.
523 : *
524 : * - current_machine
525 : *
526 : * The machine object (see machines/machines.js) for the "host"
527 : * part of "current_location". This is never null when
528 : * "current_location" isn't null. But the machine might not be
529 : * connected, and might not have manifests, etc.
530 : *
531 : * - current_manifest_item
532 : *
533 : * The manifest item corresponding to the "path" part of
534 : * "current_location". This is a piece of the current machines
535 : * manifests, from the "menu", "tools", or "dashboard" arrays.
536 : *
537 : * The item describes the navigation item in the sidebar that gets
538 : * highlighted for "path". The correspondence between the two is
539 : * not always straightforward. For example, both "network" and
540 : * "network/firewall" will have the same item, the one for
541 : * "Networking". But "system/logs" has its own item, "Logs",
542 : * even though it comes from the same package as the "system" path.
543 : *
544 : * And then, the "metrics" path has the "Overview" item associated
545 : * with it, although the two come from different packages.
546 : *
547 : * - current_manifest
548 : *
549 : * The manifest corresponding to the "path" part of
550 : * "current_location". The "current_manifest_item" is not
551 : * necessarily part of this manifest, but this manifest is always
552 : * from the same package as the files loaded for the current
553 : * location.
554 : *
555 : * For example, for the "metrics" path the "current_manifest" will
556 : * be for the "metrics" package, while "current_manifest_item" is
557 : * for the "Overview" menu entry from the "system" package.
558 : */
559 :
560 341 : current_location: Location | null = null;
561 341 : current_machine: Machine | null = null;
562 341 : current_manifest_item: ManifestItem | null = null;
563 341 : current_machine_manifest_items: CompiledComponents | null = null;
564 341 : current_manifest: Manifest | null = null;
565 :
566 341 : current_frame: ShellFrame | null = null;
567 :
568 365 : update() {
569 365 : if (!this.ready) {
570 365 : this.emit("update");
571 365 : return;
572 365 : }
573 :
574 365 : const location = decode_window_location();
575 :
576 : // Force a redirect to localhost when the host switcher is
577 : // disabled. That way, people won't accidentally connect to
578 : // remote machines via URL bookmarks or similar that point to
579 : // them.
580 352 : if (!this.config.host_switcher_enabled) {
581 352 : location.host = "localhost";
582 352 : replace_window_location(location);
583 352 : }
584 :
585 365 : let machine = this.machines.lookup(location.host);
586 :
587 : /* No such machine */
588 96 : if (!machine || !machine.visible) {
589 96 : machine = {
590 96 : key: location.host,
591 96 : connection_string: location.host,
592 96 : address: location.host,
593 96 : label: location.host,
594 96 : state: "failed",
595 96 : problem: "not-found",
596 96 : };
597 96 : }
598 :
599 365 : const compiled = compile_manifests(machine.manifests);
600 114 : if (machine.manifests && !location.path) {
601 : // Find the default path based on the manifest.
602 114 : const menu_items = compiled.ordered("menu");
603 114 : if (menu_items.length > 0 && menu_items[0])
604 90 : location.path = menu_items[0].path;
605 : else
606 90 : location.path = "system";
607 114 : replace_window_location(location);
608 114 : }
609 :
610 : // Remember the most recent history for each host, and each
611 : // host/path combination. This is used by JUMP to complete
612 : // partial locations.
613 : //
614 365 : this.#remember_location(location.host, location.path, location.hash);
615 :
616 365 : const item = compiled.find_path_item(location.path);
617 :
618 365 : this.current_location = location;
619 365 : this.current_machine = machine;
620 365 : this.current_machine_manifest_items = compiled;
621 365 : this.current_manifest_item = item;
622 365 : this.current_manifest = compiled.find_path_manifest(location.path);
623 :
624 365 : let frame = null;
625 365 : if (location.path && (machine.state == "connected" || machine.state == "connecting"))
626 365 : frame = this.#ensure_frame(machine, location.path, location.hash, item.label);
627 :
628 365 : if (frame != this.current_frame) {
629 365 : const prev_frame = this.current_frame;
630 365 : this.current_frame = frame;
631 :
632 365 : if (prev_frame)
633 126 : this.#send_frame_hidden_hint(prev_frame.name);
634 365 : if (frame)
635 365 : this.#send_frame_hidden_hint(frame.name);
636 365 : }
637 :
638 : // Remove all dead frames that are not the current one.
639 365 : for (const n of Object.keys(this.frames)) {
640 95 : if (this.frames[n].url == null && this.frames[n] != this.current_frame)
641 95 : delete this.frames[n];
642 365 : }
643 :
644 365 : this.emit("update");
645 365 : }
646 341 : }
|