mirror-cockpit-project-cockpit/pkg/shell/state.tsx
Viktor Szépe 5ff1ec08a0
Fix typos throughout codebase (#23813)
Several typos and common misspellings of words exists throughout Cockpit. This fixes the spellings.
2026-10-14 14:02:30 +02:00

646 lines
23 KiB
TypeScript

/*
* 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");
}
}