mirror of https://github.com/tailscale/tailscale/
cmd/tsconnect: switch UI to Preact
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>pull/5320/head
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