Reduces the amount of boilerplate to render the UI and makes it easier to respond to state changes (e.g. machine getting authorized, netmap changing, etc.) Preact adds ~13K to our bundle size (5K after Brotli) thus is a neglibible size contribution. We mitigate the delay in rendering the UI by having a static placeholder in the HTML. Required bumping the esbuild version to pick up evanw/esbuild#2349, which makes it easier to support Preact's JSX code generation. Fixes #5137 Fixes #5273 Signed-off-by: Mihai Parparita <mihai@tailscale.com>main
parent
15b8665787
commit
ab159f748b
@ -0,0 +1,124 @@ |
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
import { render, Component } from "preact" |
||||
import { IPNState } from "./wasm_js" |
||||
import { URLDisplay } from "./url-display" |
||||
import { Header } from "./header" |
||||
import { GoPanicDisplay } from "./go-panic-display" |
||||
import { SSH } from "./ssh" |
||||
|
||||
type AppState = { |
||||
ipn?: IPN |
||||
ipnState: IPNState |
||||
netMap?: IPNNetMap |
||||
browseToURL?: string |
||||
goPanicError?: string |
||||
} |
||||
|
||||
class App extends Component<{}, AppState> { |
||||
state: AppState = { ipnState: IPNState.NoState } |
||||
#goPanicTimeout?: number |
||||
|
||||
render() { |
||||
const { ipn, ipnState, goPanicError, netMap, browseToURL } = this.state |
||||
|
||||
let goPanicDisplay |
||||
if (goPanicError) { |
||||
goPanicDisplay = ( |
||||
<GoPanicDisplay error={goPanicError} dismiss={this.clearGoPanic} /> |
||||
) |
||||
} |
||||
|
||||
let urlDisplay |
||||
if (browseToURL) { |
||||
urlDisplay = <URLDisplay url={browseToURL} /> |
||||
} |
||||
|
||||
let machineAuthInstructions |
||||
if (ipnState === IPNState.NeedsMachineAuth) { |
||||
machineAuthInstructions = ( |
||||
<div class="container mx-auto px-4 text-center"> |
||||
An administrator needs to authorize this device. |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
let ssh |
||||
if (ipn && ipnState === IPNState.Running && netMap) { |
||||
ssh = <SSH netMap={netMap} ipn={ipn} /> |
||||
} |
||||
|
||||
return ( |
||||
<> |
||||
<Header state={ipnState} ipn={ipn} /> |
||||
{goPanicDisplay} |
||||
<div class="flex-grow flex flex-col justify-center overflow-hidden"> |
||||
{urlDisplay} |
||||
{machineAuthInstructions} |
||||
{ssh} |
||||
</div> |
||||
</> |
||||
) |
||||
} |
||||
|
||||
runWithIPN(ipn: IPN) { |
||||
this.setState({ ipn }, () => { |
||||
ipn.run({ |
||||
notifyState: this.handleIPNState, |
||||
notifyNetMap: this.handleNetMap, |
||||
notifyBrowseToURL: this.handleBrowseToURL, |
||||
notifyPanicRecover: this.handleGoPanic, |
||||
}) |
||||
}) |
||||
} |
||||
|
||||
handleIPNState = (state: IPNState) => { |
||||
const { ipn } = this.state |
||||
this.setState({ ipnState: state }) |
||||
if (state == IPNState.NeedsLogin) { |
||||
ipn?.login() |
||||
} else if ([IPNState.Running, IPNState.NeedsMachineAuth].includes(state)) { |
||||
this.setState({ browseToURL: undefined }) |
||||
} |
||||
} |
||||
|
||||
handleNetMap = (netMapStr: string) => { |
||||
const netMap = JSON.parse(netMapStr) as IPNNetMap |
||||
if (DEBUG) { |
||||
console.log("Received net map: " + JSON.stringify(netMap, null, 2)) |
||||
} |
||||
this.setState({ netMap }) |
||||
} |
||||
|
||||
handleBrowseToURL = (url: string) => { |
||||
this.setState({ browseToURL: url }) |
||||
} |
||||
|
||||
handleGoPanic = (error: string) => { |
||||
if (DEBUG) { |
||||
console.error("Go panic", error) |
||||
} |
||||
this.setState({ goPanicError: error }) |
||||
if (this.#goPanicTimeout) { |
||||
window.clearTimeout(this.#goPanicTimeout) |
||||
} |
||||
this.#goPanicTimeout = window.setTimeout(this.clearGoPanic, 10000) |
||||
} |
||||
|
||||
clearGoPanic = () => { |
||||
window.clearTimeout(this.#goPanicTimeout) |
||||
this.#goPanicTimeout = undefined |
||||
this.setState({ goPanicError: undefined }) |
||||
} |
||||
} |
||||
|
||||
export function renderApp(): Promise<App> { |
||||
return new Promise((resolve) => { |
||||
render( |
||||
<App ref={(app) => (app ? resolve(app) : undefined)} />, |
||||
document.body |
||||
) |
||||
}) |
||||
} |
||||
@ -0,0 +1,21 @@ |
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
export function GoPanicDisplay({ |
||||
error, |
||||
dismiss, |
||||
}: { |
||||
error: string |
||||
dismiss: () => void |
||||
}) { |
||||
return ( |
||||
<div |
||||
class="rounded bg-red-500 p-2 absolute top-2 right-2 text-white font-bold text-right cursor-pointer" |
||||
onClick={dismiss} |
||||
> |
||||
Tailscale has encountered an error. |
||||
<div class="text-sm font-normal">Click to reload</div> |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,40 @@ |
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
import { IPNState } from "./wasm_js" |
||||
|
||||
export function Header({ state, ipn }: { state: IPNState; ipn?: IPN }) { |
||||
const stateText = STATE_LABELS[state] |
||||
|
||||
let logoutButton |
||||
if (state === IPNState.Running) { |
||||
logoutButton = ( |
||||
<button |
||||
class="button bg-gray-500 border-gray-500 text-white hover:bg-gray-600 hover:border-gray-600 ml-2 font-bold" |
||||
onClick={() => ipn?.logout()} |
||||
> |
||||
Logout |
||||
</button> |
||||
) |
||||
} |
||||
return ( |
||||
<div class="bg-gray-100 border-b border-gray-200 pt-4 pb-2"> |
||||
<header class="container mx-auto px-4 flex flex-row items-center"> |
||||
<h1 class="text-3xl font-bold grow">Tailscale Connect</h1> |
||||
<div class="text-gray-600">{stateText}</div> |
||||
{logoutButton} |
||||
</header> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
const STATE_LABELS = { |
||||
[IPNState.NoState]: "Initializing…", |
||||
[IPNState.InUseOtherUser]: "In-use by another user", |
||||
[IPNState.NeedsLogin]: "Needs login", |
||||
[IPNState.NeedsMachineAuth]: "Needs authorization", |
||||
[IPNState.Stopped]: "Stopped", |
||||
[IPNState.Starting]: "Starting…", |
||||
[IPNState.Running]: "Running", |
||||
} as const |
||||
@ -1,74 +0,0 @@ |
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
import * as qrcode from "qrcode" |
||||
import { getContentNode } from "./index" |
||||
|
||||
export async function showLoginURL(url: string) { |
||||
if (loginNode) { |
||||
loginNode.remove() |
||||
} |
||||
loginNode = document.createElement("div") |
||||
loginNode.className = "flex flex-col items-center justify-items-center" |
||||
const linkNode = document.createElement("a") |
||||
linkNode.className = "link" |
||||
linkNode.href = url |
||||
linkNode.target = "_blank" |
||||
loginNode.appendChild(linkNode) |
||||
|
||||
try { |
||||
const dataURL = await qrcode.toDataURL(url, { width: 512 }) |
||||
const imageNode = document.createElement("img") |
||||
imageNode.className = "mx-auto" |
||||
imageNode.src = dataURL |
||||
imageNode.width = 256 |
||||
imageNode.height = 256 |
||||
linkNode.appendChild(imageNode) |
||||
} catch (err) { |
||||
console.error("Could not generate QR code:", err) |
||||
} |
||||
|
||||
linkNode.appendChild(document.createTextNode(url)) |
||||
|
||||
getContentNode().appendChild(loginNode) |
||||
} |
||||
|
||||
export function hideLoginURL() { |
||||
if (!loginNode) { |
||||
return |
||||
} |
||||
loginNode.remove() |
||||
loginNode = undefined |
||||
} |
||||
|
||||
let loginNode: HTMLDivElement | undefined |
||||
|
||||
export function showLogoutButton(ipn: IPN) { |
||||
if (logoutButtonNode) { |
||||
logoutButtonNode.remove() |
||||
} |
||||
logoutButtonNode = document.createElement("button") |
||||
logoutButtonNode.className = |
||||
"button bg-gray-500 border-gray-500 text-white hover:bg-gray-600 hover:border-gray-600 ml-2 font-bold" |
||||
logoutButtonNode.textContent = "Logout" |
||||
logoutButtonNode.addEventListener( |
||||
"click", |
||||
() => { |
||||
ipn.logout() |
||||
}, |
||||
{ once: true } |
||||
) |
||||
const headerNode = document.getElementsByTagName("header")[0]! |
||||
headerNode.appendChild(logoutButtonNode) |
||||
} |
||||
|
||||
export function hideLogoutButton() { |
||||
if (!logoutButtonNode) { |
||||
return |
||||
} |
||||
logoutButtonNode.remove() |
||||
logoutButtonNode = undefined |
||||
} |
||||
|
||||
let logoutButtonNode: HTMLButtonElement | undefined |
||||
@ -1,65 +0,0 @@ |
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
import { |
||||
showLoginURL, |
||||
hideLoginURL, |
||||
showLogoutButton, |
||||
hideLogoutButton, |
||||
} from "./login" |
||||
import { showSSHForm, hideSSHForm } from "./ssh" |
||||
import { IPNState } from "./wasm_js" |
||||
|
||||
/** |
||||
* @fileoverview Notification callback functions (bridged from ipn.Notify) |
||||
*/ |
||||
|
||||
export function notifyState(ipn: IPN, state: IPNState) { |
||||
let stateLabel |
||||
switch (state) { |
||||
case IPNState.NoState: |
||||
stateLabel = "Initializing…" |
||||
break |
||||
case IPNState.InUseOtherUser: |
||||
stateLabel = "In-use by another user" |
||||
break |
||||
case IPNState.NeedsLogin: |
||||
stateLabel = "Needs Login" |
||||
hideLogoutButton() |
||||
hideSSHForm() |
||||
ipn.login() |
||||
break |
||||
case IPNState.NeedsMachineAuth: |
||||
stateLabel = "Needs authorization" |
||||
break |
||||
case IPNState.Stopped: |
||||
stateLabel = "Stopped" |
||||
hideLogoutButton() |
||||
hideSSHForm() |
||||
break |
||||
case IPNState.Starting: |
||||
stateLabel = "Starting…" |
||||
break |
||||
case IPNState.Running: |
||||
stateLabel = "Running" |
||||
hideLoginURL() |
||||
showLogoutButton(ipn) |
||||
break |
||||
} |
||||
const stateNode = document.querySelector("#state") as HTMLDivElement |
||||
stateNode.textContent = stateLabel ?? "" |
||||
} |
||||
|
||||
export function notifyNetMap(ipn: IPN, netMapStr: string) { |
||||
const netMap = JSON.parse(netMapStr) as IPNNetMap |
||||
if (DEBUG) { |
||||
console.log("Received net map: " + JSON.stringify(netMap, null, 2)) |
||||
} |
||||
|
||||
showSSHForm(netMap.peers, ipn) |
||||
} |
||||
|
||||
export function notifyBrowseToURL(ipn: IPN, url: string) { |
||||
showLoginURL(url) |
||||
} |
||||
@ -1,98 +0,0 @@ |
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
import { Terminal } from "xterm" |
||||
import { FitAddon } from "xterm-addon-fit" |
||||
import { getContentNode } from "./index" |
||||
|
||||
export function showSSHForm(peers: IPNNetMapPeerNode[], ipn: IPN) { |
||||
const formNode = document.querySelector("#ssh-form") as HTMLDivElement |
||||
const noSSHNode = document.querySelector("#no-ssh") as HTMLDivElement |
||||
|
||||
const sshPeers = peers.filter( |
||||
(p) => p.tailscaleSSHEnabled && p.online !== false |
||||
) |
||||
if (sshPeers.length == 0) { |
||||
formNode.classList.add("hidden") |
||||
noSSHNode.classList.remove("hidden") |
||||
return |
||||
} |
||||
sshPeers.sort((a, b) => a.name.localeCompare(b.name)) |
||||
|
||||
const selectNode = formNode.querySelector("select")! |
||||
selectNode.innerHTML = "" |
||||
for (const p of sshPeers) { |
||||
const option = document.createElement("option") |
||||
option.textContent = p.name.split(".")[0] |
||||
option.value = p.name |
||||
selectNode.appendChild(option) |
||||
} |
||||
|
||||
const usernameNode = formNode.querySelector(".username") as HTMLInputElement |
||||
formNode.onsubmit = (e) => { |
||||
e.preventDefault() |
||||
const hostname = selectNode.value |
||||
ssh(hostname, usernameNode.value, ipn) |
||||
} |
||||
|
||||
noSSHNode.classList.add("hidden") |
||||
formNode.classList.remove("hidden") |
||||
} |
||||
|
||||
export function hideSSHForm() { |
||||
const formNode = document.querySelector("#ssh-form") as HTMLDivElement |
||||
formNode.classList.add("hidden") |
||||
} |
||||
|
||||
function ssh(hostname: string, username: string, ipn: IPN) { |
||||
document.body.classList.add("ssh-active") |
||||
const termContainerNode = document.createElement("div") |
||||
termContainerNode.className = "flex-grow bg-black p-2 overflow-hidden" |
||||
getContentNode().appendChild(termContainerNode) |
||||
|
||||
const term = new Terminal({ |
||||
cursorBlink: true, |
||||
}) |
||||
const fitAddon = new FitAddon() |
||||
term.loadAddon(fitAddon) |
||||
term.open(termContainerNode) |
||||
fitAddon.fit() |
||||
|
||||
let onDataHook: ((data: string) => void) | undefined |
||||
term.onData((e) => { |
||||
onDataHook?.(e) |
||||
}) |
||||
|
||||
term.focus() |
||||
|
||||
const sshSession = ipn.ssh(hostname, username, { |
||||
writeFn: (input) => term.write(input), |
||||
setReadFn: (hook) => (onDataHook = hook), |
||||
rows: term.rows, |
||||
cols: term.cols, |
||||
onDone: () => { |
||||
resizeObserver.disconnect() |
||||
term.dispose() |
||||
termContainerNode.remove() |
||||
document.body.classList.remove("ssh-active") |
||||
window.removeEventListener("beforeunload", beforeUnloadListener) |
||||
}, |
||||
}) |
||||
|
||||
// Make terminal and SSH session track the size of the containing DOM node.
|
||||
const resizeObserver = new ResizeObserver((entries) => { |
||||
fitAddon.fit() |
||||
}) |
||||
resizeObserver.observe(termContainerNode) |
||||
term.onResize(({ rows, cols }) => { |
||||
sshSession.resize(rows, cols) |
||||
}) |
||||
|
||||
// Close the session if the user closes the window without an explicit
|
||||
// exit.
|
||||
const beforeUnloadListener = () => { |
||||
sshSession.close() |
||||
} |
||||
window.addEventListener("beforeunload", beforeUnloadListener) |
||||
} |
||||
@ -0,0 +1,156 @@ |
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
import { useState, useCallback } from "preact/hooks" |
||||
import { Terminal } from "xterm" |
||||
import { FitAddon } from "xterm-addon-fit" |
||||
|
||||
type SSHSessionDef = { |
||||
username: string |
||||
hostname: string |
||||
} |
||||
|
||||
export function SSH({ netMap, ipn }: { netMap: IPNNetMap; ipn: IPN }) { |
||||
const [sshSessionDef, setSSHSessionDef] = useState<SSHSessionDef | null>(null) |
||||
const clearSSHSessionDef = useCallback(() => setSSHSessionDef(null), []) |
||||
if (sshSessionDef) { |
||||
return ( |
||||
<SSHSession def={sshSessionDef} ipn={ipn} onDone={clearSSHSessionDef} /> |
||||
) |
||||
} |
||||
const sshPeers = netMap.peers.filter( |
||||
(p) => p.tailscaleSSHEnabled && p.online !== false |
||||
) |
||||
|
||||
if (sshPeers.length == 0) { |
||||
return <NoSSHPeers /> |
||||
} |
||||
|
||||
return <SSHForm sshPeers={sshPeers} onSubmit={setSSHSessionDef} /> |
||||
} |
||||
|
||||
function SSHSession({ |
||||
def, |
||||
ipn, |
||||
onDone, |
||||
}: { |
||||
def: SSHSessionDef |
||||
ipn: IPN |
||||
onDone: () => void |
||||
}) { |
||||
return ( |
||||
<div |
||||
class="flex-grow bg-black p-2 overflow-hidden" |
||||
ref={(node) => { |
||||
if (node) { |
||||
// Run the SSH session aysnchronously, so that the React render
|
||||
// loop is complete (otherwise the SSH form may still be visible,
|
||||
// which affects the size of the terminal, leading to a spurious
|
||||
// initial resize).
|
||||
setTimeout(() => runSSHSession(node, def, ipn, onDone), 0) |
||||
} |
||||
}} |
||||
/> |
||||
) |
||||
} |
||||
|
||||
function runSSHSession( |
||||
termContainerNode: HTMLDivElement, |
||||
def: SSHSessionDef, |
||||
ipn: IPN, |
||||
onDone: () => void |
||||
) { |
||||
const term = new Terminal({ |
||||
cursorBlink: true, |
||||
}) |
||||
const fitAddon = new FitAddon() |
||||
term.loadAddon(fitAddon) |
||||
term.open(termContainerNode) |
||||
fitAddon.fit() |
||||
|
||||
let onDataHook: ((data: string) => void) | undefined |
||||
term.onData((e) => { |
||||
onDataHook?.(e) |
||||
}) |
||||
|
||||
term.focus() |
||||
|
||||
const sshSession = ipn.ssh(def.hostname, def.username, { |
||||
writeFn: (input) => term.write(input), |
||||
setReadFn: (hook) => (onDataHook = hook), |
||||
rows: term.rows, |
||||
cols: term.cols, |
||||
onDone: () => { |
||||
resizeObserver.disconnect() |
||||
term.dispose() |
||||
window.removeEventListener("beforeunload", handleBeforeUnload) |
||||
onDone() |
||||
}, |
||||
}) |
||||
|
||||
// Make terminal and SSH session track the size of the containing DOM node.
|
||||
const resizeObserver = new ResizeObserver(() => fitAddon.fit()) |
||||
resizeObserver.observe(termContainerNode) |
||||
term.onResize(({ rows, cols }) => sshSession.resize(rows, cols)) |
||||
|
||||
// Close the session if the user closes the window without an explicit
|
||||
// exit.
|
||||
const handleBeforeUnload = () => sshSession.close() |
||||
window.addEventListener("beforeunload", handleBeforeUnload) |
||||
} |
||||
|
||||
function NoSSHPeers() { |
||||
return ( |
||||
<div class="container mx-auto px-4 text-center"> |
||||
None of your machines have |
||||
<a href="https://tailscale.com/kb/1193/tailscale-ssh/" class="link"> |
||||
Tailscale SSH |
||||
</a> |
||||
enabled. Give it a try! |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
function SSHForm({ |
||||
sshPeers, |
||||
onSubmit, |
||||
}: { |
||||
sshPeers: IPNNetMapPeerNode[] |
||||
onSubmit: (def: SSHSessionDef) => void |
||||
}) { |
||||
sshPeers = sshPeers.slice().sort((a, b) => a.name.localeCompare(b.name)) |
||||
const [username, setUsername] = useState("") |
||||
const [hostname, setHostname] = useState(sshPeers[0].name) |
||||
return ( |
||||
<form |
||||
class="container mx-auto px-4 flex justify-center" |
||||
onSubmit={(e) => { |
||||
e.preventDefault() |
||||
onSubmit({ username, hostname }) |
||||
}} |
||||
> |
||||
<input |
||||
type="text" |
||||
class="input username" |
||||
placeholder="Username" |
||||
onChange={(e) => setUsername(e.currentTarget.value)} |
||||
/> |
||||
<div class="select-with-arrow mx-2"> |
||||
<select |
||||
class="select" |
||||
onChange={(e) => setHostname(e.currentTarget.value)} |
||||
> |
||||
{sshPeers.map((p) => ( |
||||
<option key={p.nodeKey}>{p.name.split(".")[0]}</option> |
||||
))} |
||||
</select> |
||||
</div> |
||||
<input |
||||
type="submit" |
||||
class="button bg-green-500 border-green-500 text-white hover:bg-green-600 hover:border-green-600" |
||||
value="SSH" |
||||
/> |
||||
</form> |
||||
) |
||||
} |
||||
@ -0,0 +1,32 @@ |
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
import { useState } from "preact/hooks" |
||||
import * as qrcode from "qrcode" |
||||
|
||||
export function URLDisplay({ url }: { url: string }) { |
||||
const [dataURL, setDataURL] = useState("") |
||||
qrcode.toDataURL(url, { width: 512 }, (err, dataURL) => { |
||||
if (err) { |
||||
console.error("Error generating QR code", err) |
||||
} else { |
||||
setDataURL(dataURL) |
||||
} |
||||
}) |
||||
|
||||
return ( |
||||
<div class="flex flex-col items-center justify-items-center"> |
||||
<a href={url} class="link" target="_blank"> |
||||
<img |
||||
src={dataURL} |
||||
class="mx-auto" |
||||
width="256" |
||||
height="256" |
||||
alt="QR Code of URL" |
||||
/> |
||||
{url} |
||||
</a> |
||||
</div> |
||||
) |
||||
} |
||||
Loading…
Reference in new issue