🏡 index : quine-global-labs/mirror-cockpit-project-cockpit.git

/*
 * Copyright (C) 2024 Red Hat, Inc.
 * SPDX-License-Identifier: LGPL-2.1-or-later
 */

import cockpit from "cockpit";

import { EventEmitter } from "cockpit/event";
import { Status } from "notifications";

import { Router } from "./router.jsx";
import {
    machines as machines_factory,
    Machine, Machines, Loader
} from "./machines/machines.js";
import {
    decode_location, decode_window_location, push_window_location, replace_window_location,
    compile_manifests, compute_frame_url,
    Location, ManifestItem, CompiledComponents,
} from "./util.jsx";
import { Manifest, ShellManifest, import_ShellManifest } from "./manifests";
import { validate } from "import-json";

export interface ShellConfig {
    language: string;
    language_direction: string;
    host_switcher_enabled: boolean;
    manifest: ShellManifest;
}

export interface ShellFrame {
    name: string;
    host: string;
    path: string;
    title: string;
    url: string | null;
    hash: string;
    ready: boolean;
    loaded: boolean;
}

export interface ShellStateEvents {
    update: () => void;
    connect: () => void;
}

export class ShellState extends EventEmitter<ShellStateEvents> {
    constructor() {
        super();
        this.config = this.#init_config();

        this.machines = this.#init_machines();
        this.loader = this.#init_loader();
        this.router = this.#init_router();

        this.#init_oops();
        this.#init_page_status();

        this.#on_ready();
    }

    /* READINESS STATE
     */

    ready: boolean = false;
    has_oops: boolean = false;

    #on_ready() {
        if (this.machines.ready && this.#config_ready) {
            this.ready = true;
            window.addEventListener("popstate", () => {
                this.update();
                this.ensure_frame_loaded();
                this.ensure_connection();
            });

            this.update();
            this.ensure_frame_loaded();
            this.ensure_connection();
        }
    }

    /* CONFIG
     */

    config: ShellConfig;

    #config_ready: boolean = false;

    #init_config() {
        let language = document.cookie.replace(/(?:(?:^|.*;\s*)CockpitLang\s*=\s*([^;]*).*$)|^.*$/, "$1");
        if (!language)
            language = navigator.language.toLowerCase(); // Default to Accept-Language header

        const config = {
            language,
            language_direction: cockpit.language_direction,
            host_switcher_enabled: false,
            manifest: validate("manifests.shell", cockpit.manifests.shell, import_ShellManifest,
                               { docs: undefined, locales: undefined }),
        };

        /* Host switcher enabled? */
        const meta_multihost = document.head.querySelector("meta[name='allow-multihost']");
        if (meta_multihost instanceof HTMLMetaElement && meta_multihost.content === "yes")
            config.host_switcher_enabled = true;

        /* Should show warning before connecting? */
        this.#config_ready = false;
        cockpit.dbus(null, { bus: "internal" }).call("/config", "cockpit.Config", "GetString",
                                                     ["Session", "WarnBeforeConnecting"], {})
                .then(([result]) => {
                    if (result === "false" || result === "no") {
                        window.sessionStorage.setItem("connection-warning-shown", "yes");
                    }
                })
                .catch(e => {
                    if (e.name !== "cockpit.Config.KeyError")
                        console.warn("Error reading WarnBeforeConnecting configuration:", e.message);
                })
                .finally(() => {
                    this.#config_ready = true;
                    this.#on_ready();
                });

        return config;
    }

    /* MACHINES AND LOADER
     *
     * These are part of the machinery in the basement that maintains
     * the database of all hosts (including "localhost"), and monitors
     * their manifests.
     */

    machines: Machines;
    loader: Loader;

    #init_machines() {
        const machines = machines_factory.instance();

        machines.addEventListener("ready", () => this.#on_ready());

        machines.addEventListener("removed", (_, machine) => {
            this.#remove_machine_frames(machine);
        });
        machines.addEventListener("added", (_, machine) => {
            this.#preload_machine_frames(machine);
        });
        machines.addEventListener("updated", (_, machine) => {
            if (!machine.visible || machine.problem)
                this.#remove_machine_frames(machine);
            else
                this.#preload_machine_frames(machine);
        });

        return machines;
    }

    #init_loader() {
        return machines_factory.loader(this.machines);
    }

    /* OOPS HANDLING
     */

    #init_oops() {
        const old_onerror = window.onerror;
        window.onerror = (msg, url, line) => {
            // Errors with url == "" are not logged apparently, so let's
            // not show the "Oops" for them either.
            if (url !== "") {
                this.has_oops = true;
                this.update();
            }
            if (old_onerror)
                return old_onerror(msg, url, line);
            return false;
        };
    }

    /* FRAMES
     *
     * Frames are created on-demand when navigating to them for the
     * first time, by calling ensure_frame().
     *
     * Once a frame object is created it doesn't change anymore except
     * for its "ready", "loaded", and "hash" properties.
     *
     * The "ready" property starts out false and goes to true once the
     * corresponding iframe has loaded its URL. The "loaded" property
     * starts out false and goes true once the code loaded into the
     * frame has sent its "init" message.
     *
     * Removing things (frames) is complicated, as usual.  We need to
     * be able to represent the state "The current frame has been
     * removed" without any call to update() re-creating it
     * spontaneously. Thus, a frame has a special "dead" state where
     * its "url" property is null. Actually clicking on navigation
     * elements will call the "ensure_frame_loaded" hook, which will
     * bring the current frame back to life if necessary. This happens
     * in the "jump" method.
     */

    frames: { [name: string]: ShellFrame } = { };

    #ensure_frame(machine: Machine, path: string, hash: string | null, title: string): ShellFrame | null {
        /* Never create new frames for machines that are not
           connected yet. That would open a channel to them (for
           loading the URL), which would trigger the bridge to
           attempt a log in. We want all logins to happen in a
           single place (in hosts.jsx) so that we can get the
           options right, and show a warning dialog.
        */
        if (machine.address !== "localhost" && machine.state !== "connected")
            return null;

        const name = "cockpit1:" + machine.connection_string + "/" + path;
        let frame = this.frames[name];

        if (!frame) {
            frame = this.frames[name] = {
                name,
                host: machine.address,
                path,
                url: compute_frame_url(machine, path),
                hash: hash || "/",
                title,
                ready: false,
                loaded: false,
            };
        } else {
            // XXX - shouldn't we leave the hash alone when it is null here?
            frame.hash = hash || "/";
        }
        return frame;
    }

    ensure_frame_loaded (): void {
        if (this.current_frame && this.current_frame.url == null) {
            // Let update() recreate the frame.
            delete this.frames[this.current_frame.name];
            this.current_frame = null;
            this.update();
        }
    }

    #kill_frame(name: string): void {
        // Only mark frame as dead, it gets removed for real during
        // the call to "update".
        this.frames[name].url = null;
    }

    remove_frame (name: string): void {
        this.#kill_frame(name);
        this.update();
    }

    #remove_machine_frames (machine: Machine): void {
        const names = Object.keys(this.frames);
        for (const n of names) {
            if (this.frames[n].host === machine.address)
                this.#kill_frame(n);
        }
        this.update();
    }

    #preload_machine_frames (machine: Machine) {
        const manifests = machine.manifests;
        const compiled = compile_manifests(manifests);
        for (const c in manifests) {
            const preload = manifests[c].preload as unknown as string[];
            if (preload && preload.length) {
                for (const p of preload) {
                    const path = (p === "index") ? c : c + "/" + p;
                    const item = compiled.find_path_item(path);
                    this.#ensure_frame(machine, path, null, item.label);
                }
            }
        }
        this.update();
    }

    /* PAGE STATUS
     *
     * Page status notifications arrive from the Router (see
     * below). We also store them in the session storage so that
     * individual pages have access to all collected statuses.
     */

    page_status: { [host: string]: { [page: string]: Status } } = { };

    #init_page_status() {
        sessionStorage.removeItem("cockpit:page_status");
    }

    #notify_page_status(host: string, page: string, status: Status) {
        if (!this.page_status[host])
            this.page_status[host] = { };
        this.page_status[host][page] = status;
        sessionStorage.setItem("cockpit:page_status", JSON.stringify(this.page_status));
        this.update();
    }

    /* ROUTER
     *
     * The router is the machinery in our basement that forwards
     * Cockpit protocol messages between the WebSocket and the
     * frames. Some messages are also meant for the Shell itself, and
     * we pass a big object with callback function to the router to
     * process these and other noteworthy events.
     */

    router: Router;

    #init_router() {
        const callbacks = {
            /* The router has just processed the "init" message of the
             * code loaded into the frame named FRAME_NAME.
             *
             * We set the "loaded" property to help the tests, and also
             * tell the frame whether it is visible or not.
             */
            frame_is_initialized: (frame_name: string) => {
                const frame = this.frames[frame_name];
                if (frame) {
                    frame.loaded = true;
                    this.update();
                }
                this.#send_frame_hidden_hint(frame_name);
            },

            /* The frame named FRAME_NAME wants the shell to jump to
             * LOCATION.
             *
             * Only requests from the current frame are honored.  But the
             * tests also use this extensively for navigation, and might
             * send messages from the top-most window, which we know is
             * named "cockpit1".
             */
            perform_frame_jump_command: (frame_name: string, location: string) => {
                if (frame_name === "cockpit1" || (this.current_frame && this.current_frame.name === frame_name)) {
                    this.jump(location);
                    this.ensure_connection();
                }
            },

            /* The frame named FRAME_NAMED has just changed the hash part
             * of its URL. That's how frames navigate within themselves.
             *
             * When the current frame does that, we need to reflect the
             * hash change in the shell URL as well.
             */
            perform_frame_hash_track: (frame_name: string, hash: string) => {
                /* Note that we ignore tracking for old shell code */
                if (this.current_frame && this.current_frame.name === frame_name &&
                    frame_name && frame_name.indexOf("/shell/shell") === -1) {
                    /* The browser has already pushed an appropriate entry to
                       the history, so let's just replace it with one that
                       includes the right hash.
                     */
                    const location = Object.assign({}, decode_window_location(), { hash });
                    replace_window_location(location);
                    this.#remember_location(location.host, location.path, location.hash);
                    this.update();
                }
            },

            /* A notification has been received from a frame. We only
             * handle page status notifications, such as the ones that
             * tell you when software updates are available.  PAGE is the
             * "well-known name" of a page, such as "system",
             * "network/firewall", or "updates".
             */
            handle_notifications: (host: string, page: string, data: { page_status?: Status }) => {
                if (data.page_status !== undefined)
                    this.#notify_page_status(host, page, data.page_status);
            },

            /* One of the frames has experienced a unhandled JavaScript exception.
             */
            show_oops: () => {
                this.has_oops = true;
                this.update();
            },

            /* The host with address HOST has just initiated a restart. We
             * tell the loader.
             */
            expect_restart: (host: string) => {
                this.loader.expect_restart(host);
            },
        };

        return new Router(callbacks);
    }

    #send_frame_hidden_hint (frame_name: string) {
        const hidden = !this.current_frame || this.current_frame.name !== frame_name;
        this.router.hint(frame_name, { hidden });
    }

    /* NAVIGATION
     *
     * The main navigation function, jump(), will change
     * window.location as requested and then trigger a general
     * ShellState update. The update processing will look at
     * window.location and update the various "current_*" properties
     * of the shell state accordingly.  (The update processing might
     * also change window.location again itself, in order to
     * canonicalize it.)
     *
     * The new location given to jump() can be partial; the missing
     * pieces are filled in from the browsing history in a (almost)
     * natural way. If the HOST part is missing, it will be taken from
     * the current location. If the PATH part is missing, the last
     * path visited on the given host is used. And if the HASH is
     * missing, the last one from the given HOST/PATH combination is
     * used. But only, and this is a historical quirk, when the new
     * host/path differs from the current host/path. Don't rely on
     * that, always use "/" as the hash when jumping to the top
     * sub-page.
     *
     * Calling jump() will also make sure that the (newly) current
     * frame will now be loaded again in the case that it was
     * explicitly removed earlier. (This also happens when
     * window.location isn't actually changed by jump().)
     *
     * But jump() will never open a new connection to a HOST that is
     * not yet connected. If you want that, call ensure_connection()
     * right after jump().  However, it is better to first connect to
     * the host using the connect_host function from hosts_dialog.jsx
     * and only call jump() when that has succeeded.
     *
     * Calling ensure_connection() will start a user interaction to
     * open a connection to the host of the current navigation
     * location, but will not wait for this to be complete.
     */

    #last_path_for_host: Record<string, string> = { };
    #last_hash_for_host_path: Record<string, Record<string, string>> = { };

    most_recent_path_for_host(host: string) {
        return this.#last_path_for_host[host] || "";
    }

    #most_recent_hash_for_path(host: string, path: string) {
        if (this.#last_hash_for_host_path[host])
            return this.#last_hash_for_host_path[host][path] || null;
        return null;
    }

    #remember_location(host: string, path: string, hash: string) {
        this.#last_path_for_host[host] = path;
        if (!this.#last_hash_for_host_path[host])
            this.#last_hash_for_host_path[host] = { };
        this.#last_hash_for_host_path[host][path] = hash;
    }

    jump (location: Partial<Location> | string): boolean {
        if (typeof location === "string")
            location = decode_location(location);

        const current = decode_window_location();

        /* Fill in the missing pieces, in order.
         */

        if (!location.host)
            location.host = current.host || "localhost";

        if (!location.path)
            location.path = this.most_recent_path_for_host(location.host);

        if (!location.hash) {
            if (location.host !== current.host || location.path !== current.path)
                location.hash = this.#most_recent_hash_for_path(location.host, location.path) || "/";
            else
                console.warn('Shell jump with hash and no frame change. Please use "/" as the hash to jump to the top sub-page.');
        }

        if (location.host !== current.host ||
            location.path !== current.path ||
            location.hash !== current.hash) {
            push_window_location(location);
            this.update();
            this.ensure_frame_loaded();
            return true;
        }

        this.ensure_frame_loaded();
        return false;
    }

    ensure_connection() {
        if (this.current_machine) {
            // Handle localhost right here, we never need user
            // interactions for it, and it is kind of important to not
            // mess up connecting to localhost. So we avoid relying on
            // the bigger machinery for it.
            //
            if (this.current_machine.connection_string === "localhost") {
                this.loader.connect("localhost");
                return;
            }

            this.emit("connect");
        }
    }

    /* STATE
     *
     * Whenever the shell state changes, the "updated" event is
     * dispatched.
     *
     * The main part of the shell state is the information related to
     * the current navigation location:
     *
     * - current_location
     *
     * A object with "host", "path", and "hash" fields that reflect
     * the current location. "hash" does not have the "#" character.
     *
     * - current_machine
     *
     * The machine object (see machines/machines.js) for the "host"
     * part of "current_location". This is never null when
     * "current_location" isn't null. But the machine might not be
     * connected, and might not have manifests, etc.
     *
     * - current_manifest_item
     *
     * The manifest item corresponding to the "path" part of
     * "current_location". This is a piece of the current machines
     * manifests, from the "menu", "tools", or "dashboard" arrays.
     *
     * The item describes the navigation item in the sidebar that gets
     * highlighted for "path". The correspondence between the two is
     * not always straightforward. For example, both "network" and
     * "network/firewall" will have the same item, the one for
     * "Networking". But "system/logs" has its own item, "Logs",
     * even though it comes from the same package as the "system" path.
     *
     * And then, the "metrics" path has the "Overview" item associated
     * with it, although the two come from different packages.
     *
     * - current_manifest
     *
     * The manifest corresponding to the "path" part of
     * "current_location". The "current_manifest_item" is not
     * necessarily part of this manifest, but this manifest is always
     * from the same package as the files loaded for the current
     * location.
     *
     * For example, for the "metrics" path the "current_manifest" will
     * be for the "metrics" package, while "current_manifest_item" is
     * for the "Overview" menu entry from the "system" package.
     */

    current_location: Location | null = null;
    current_machine: Machine | null = null;
    current_manifest_item: ManifestItem | null = null;
    current_machine_manifest_items: CompiledComponents | null = null;
    current_manifest: Manifest | null = null;

    current_frame: ShellFrame | null = null;

    update() {
        if (!this.ready) {
            this.emit("update");
            return;
        }

        const location = decode_window_location();

        // Force a redirect to localhost when the host switcher is
        // disabled. That way, people won't accidentally connect to
        // remote machines via URL bookmarks or similar that point to
        // them.
        if (!this.config.host_switcher_enabled) {
            location.host = "localhost";
            replace_window_location(location);
        }

        let machine = this.machines.lookup(location.host);

        /* No such machine */
        if (!machine || !machine.visible) {
            machine = {
                key: location.host,
                connection_string: location.host,
                address: location.host,
                label: location.host,
                state: "failed",
                problem: "not-found",
            };
        }

        const compiled = compile_manifests(machine.manifests);
        if (machine.manifests && !location.path) {
            // Find the default path based on the manifest.
            const menu_items = compiled.ordered("menu");
            if (menu_items.length > 0 && menu_items[0])
                location.path = menu_items[0].path;
            else
                location.path = "system";
            replace_window_location(location);
        }

        // Remember the most recent history for each host, and each
        // host/path combination.  This is used by JUMP to complete
        // partial locations.
        //
        this.#remember_location(location.host, location.path, location.hash);

        const item = compiled.find_path_item(location.path);

        this.current_location = location;
        this.current_machine = machine;
        this.current_machine_manifest_items = compiled;
        this.current_manifest_item = item;
        this.current_manifest = compiled.find_path_manifest(location.path);

        let frame = null;
        if (location.path && (machine.state === "connected" || machine.state === "connecting"))
            frame = this.#ensure_frame(machine, location.path, location.hash, item.label);

        if (frame !== this.current_frame) {
            const prev_frame = this.current_frame;
            this.current_frame = frame;

            if (prev_frame)
                this.#send_frame_hidden_hint(prev_frame.name);
            if (frame)
                this.#send_frame_hidden_hint(frame.name);
        }

        // Remove all dead frames that are not the current one.
        for (const n of Object.keys(this.frames)) {
            if (this.frames[n].url == null && this.frames[n] !== this.current_frame)
                delete this.frames[n];
        }

        this.emit("update");
    }
}