Line data Source code
1 : /*
2 : * Copyright (C) 2024 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 339 : import cockpit from "cockpit";
7 :
8 339 : import React, { useEffect } from 'react';
9 339 : import { createRoot } from "react-dom/client";
10 :
11 : import {
12 : Modal, ModalBody, ModalFooter, ModalHeader
13 : } from '@patternfly/react-core/dist/esm/components/Modal/index.js';
14 : import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
15 :
16 : import { WithDialogs } from "dialogs.jsx";
17 : import { useInit, useEvent, useOn, useLoggedInUser } from "hooks";
18 :
19 : import { TopNav } from "./topnav.jsx";
20 : import { SidebarToggle, PageNav } from "./nav.jsx";
21 : import { CockpitHosts, CockpitCurrentHost } from "./hosts.jsx";
22 : import { HostModalState, HostModal, connect_host } from "./hosts_dialog.jsx";
23 : import { Frames } from "./frames.jsx";
24 : import { EarlyFailure, Disconnected, MachineTroubleshoot } from "./failures.jsx";
25 :
26 : import { ShellState } from "./state.jsx";
27 : import { get_session_controller } from "./session";
28 :
29 : import { SkipToContent } from '@patternfly/react-core/dist/esm/components/SkipToContent/index.js';
30 :
31 : import 'cockpit-dark-theme'; // once per page
32 :
33 : import '../lib/patternfly/patternfly-6-cockpit.scss';
34 : import "./shell.scss";
35 :
36 339 : const _ = cockpit.gettext;
37 :
38 : interface SkipLinkProps {
39 : focus_id: string;
40 : children?: React.ReactNode
41 : }
42 :
43 : // We create a wrapper specifically to handle edge-cases like headless browsers
44 : // not getting focus when clicking an anchor to a heading. We can also scroll
45 : // it into view in case it wouldn't do that before - just to be safe.
46 336 : const SkipLink = ({ focus_id, children }: SkipLinkProps) => {
47 336 : return (
48 336 : <SkipToContent
49 336 : href={`#${focus_id}`}
50 0 : onClick={ev => {
51 0 : const el = document.getElementById(focus_id);
52 0 : el?.focus();
53 0 : el?.scrollIntoView();
54 0 : ev.preventDefault();
55 0 : }}>
56 336 : {children}
57 336 : </SkipToContent>
58 : );
59 336 : };
60 :
61 336 : const SessionCountdownModal = () => {
62 336 : const controller = get_session_controller();
63 336 : useOn(controller, "changed");
64 :
65 336 : controller.inhibit_activity_reporting(controller.countdown > 0);
66 :
67 336 : if (controller.countdown <= 0)
68 336 : return null;
69 :
70 62 : return (
71 62 : <Modal isOpen position="top" variant="medium"
72 62 : id="session-timeout-modal">
73 62 : <ModalHeader title={_("Session is about to expire")} />
74 62 : <ModalBody>
75 62 : { cockpit.format(_("You will be logged out in $0 seconds."), controller.countdown) }
76 62 : </ModalBody>
77 62 : <ModalFooter>
78 62 : <Button variant='primary'
79 0 : onClick={() => controller.continue_session()}
80 : >
81 62 : {_("Continue session")}
82 62 : </Button>
83 62 : </ModalFooter>
84 62 : </Modal>
85 : );
86 336 : };
87 :
88 339 : const Shell = () => {
89 336 : const current_user = useLoggedInUser()?.name || "";
90 339 : const state = useInit(() => new ShellState());
91 339 : const session_controller = get_session_controller();
92 339 : const host_modal_state = useInit(() => HostModalState());
93 :
94 339 : useOn(state, "update");
95 339 : useOn(session_controller, "changed");
96 339 : useEvent(host_modal_state, "changed");
97 :
98 339 : useEffect(() => {
99 8 : return state.on("connect", () => {
100 8 : connect_host(host_modal_state, state, state.current_machine);
101 8 : });
102 339 : }, [host_modal_state, state]);
103 :
104 339 : const {
105 339 : ready,
106 339 : config,
107 :
108 339 : current_machine,
109 339 : current_manifest_item,
110 339 : } = state;
111 :
112 339 : const { countdown, problem } = session_controller;
113 :
114 68 : if (problem && !ready)
115 65 : return <EarlyFailure />;
116 :
117 339 : if (!ready)
118 339 : return null;
119 :
120 336 : cockpit.assert(current_machine && current_manifest_item);
121 :
122 339 : const title_parts = [];
123 339 : if (current_manifest_item.label)
124 336 : title_parts.push(current_manifest_item.label);
125 336 : title_parts.push((current_machine.user || current_user) + "@" + current_machine.label);
126 339 : document.title = title_parts.join(" - ");
127 :
128 339 : if (countdown > 0)
129 62 : document.title = "(" + countdown + ") " + document.title;
130 :
131 336 : document.documentElement.lang = config.language;
132 336 : if (config.language_direction)
133 336 : document.documentElement.dir = config.language_direction;
134 :
135 336 : let failure = null;
136 65 : if (problem) {
137 65 : failure = <Disconnected problem={problem} />;
138 62 : } else if (current_machine.state != "connected") {
139 72 : failure = <MachineTroubleshoot machine={current_machine}
140 0 : onClick={() => connect_host(host_modal_state, state, current_machine)} />;
141 72 : }
142 :
143 336 : return (
144 336 : <div id="main" className="page"
145 336 : style={
146 336 : {
147 72 : '--ct-color-host-accent': (current_machine.address == "localhost" ? undefined : current_machine.color)
148 339 : } as React.CSSProperties
149 : }>
150 :
151 339 : <SkipLink focus_id="content">{_("Skip to content")}</SkipLink>
152 339 : <SkipLink focus_id="hosts-sel">{_("Skip main navigation")}</SkipLink>
153 :
154 339 : <div id="sidebar-toggle" className="pf-v6-c-select pf-m-dark sidebar-toggle">
155 339 : <SidebarToggle />
156 339 : </div>
157 :
158 339 : <div id="nav-system" className="area-ct-subnav nav-system-menu sidebar interact">
159 339 : <nav id="host-apps" className="host-apps">
160 339 : <PageNav state={state} />
161 339 : </nav>
162 339 : </div>
163 :
164 339 : <nav id="hosts-sel" className="navbar navbar-default navbar-pf navbar-pf-vertical" tabIndex={-1}>
165 339 : { config.host_switcher_enabled
166 75 : ? <CockpitHosts state={state} host_modal_state={host_modal_state} selector="nav-hosts" />
167 323 : : <CockpitCurrentHost current_user={current_user} machine={current_machine} />
168 : }
169 339 : </nav>
170 :
171 339 : <div id="nav-hosts" className="area-ct-subnav nav-hosts-menu sidebar" />
172 :
173 339 : <div id="topnav" className="header">
174 339 : <TopNav state={state} />
175 339 : </div>
176 :
177 339 : <Frames hidden={!!failure} state={state} />
178 :
179 339 : { failure &&
180 75 : <div id="failure-content" className="area-ct-content" role="main" tabIndex={-1}>
181 75 : { failure }
182 75 : </div>
183 : }
184 :
185 339 : <SessionCountdownModal />
186 339 : <HostModal state={host_modal_state} machines={state.machines} />
187 :
188 339 : </div>);
189 339 : };
190 :
191 : interface ShellWindow extends Window {
192 : features?: object;
193 : }
194 :
195 339 : function init() {
196 339 : cockpit.translate();
197 :
198 : /* Give us a name. This used to (maybe) indicate at some point to
199 : * child frames that we are a cockpit1 router frame. But they
200 : * actually check for a "cockpit1:" prefix of their own name.
201 : */
202 339 : window.name = "cockpit1";
203 :
204 : /* Tell the pages about our features. */
205 339 : (window as ShellWindow).features = {
206 339 : navbar_is_for_current_machine: true
207 339 : };
208 :
209 0 : function follow(arg: unknown) {
210 : /* A promise of some sort */
211 0 : if (arguments.length == 1 && arg && typeof arg == "object" && "then" in arg && typeof arg.then == "function") {
212 0 : arg.then(function(...args: unknown[]) { console.log(...args) },
213 0 : function(...args: unknown[]) { console.error(...args) });
214 0 : if ("stream" in arg && typeof arg.stream == "function")
215 0 : arg.stream(function(...args: unknown[]) { console.log(...args) });
216 0 : }
217 0 : }
218 :
219 339 : let zz_value: unknown;
220 :
221 : /* For debugging in the browser console */
222 339 : Object.defineProperties(window, {
223 339 : cockpit: { value: cockpit },
224 339 : zz: {
225 0 : get: function() { return zz_value },
226 0 : set: function(val) { zz_value = val; follow(val) }
227 339 : }
228 339 : });
229 :
230 339 : const root = createRoot(document.getElementById("shell")!);
231 339 : root.render(<WithDialogs><Shell /></WithDialogs>);
232 339 : }
233 :
234 339 : document.addEventListener("DOMContentLoaded", init);
|