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