Skip to content

Commit

Permalink
cmd/tsconnect: switch UI to Preact
Browse files Browse the repository at this point in the history
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>
  • Loading branch information
mihaip committed Aug 4, 2022
1 parent f371a1a commit b81d219
Show file tree
Hide file tree
Showing 19 changed files with 407 additions and 320 deletions.
1 change: 1 addition & 0 deletions cmd/tsconnect/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ func commonSetup(dev bool) (*esbuild.BuildOptions, error) {
setupEsbuildTailwind(build, dev)
},
}},
JSXMode: esbuild.JSXModeAutomatic,
}, nil
}

Expand Down
30 changes: 3 additions & 27 deletions cmd/tsconnect/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,37 +8,13 @@
<script src="dist/index.js" defer></script>
</head>
<body class="flex flex-col h-screen overflow-hidden">
<!-- Placeholder so that we don't have an empty page while the JS loads.
It should match the markup generated by Header component. -->
<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" id="state">Loading…</div>
<div class="text-gray-600">Loading…</div>
</header>
</div>
<div
id="content"
class="flex-grow flex flex-col justify-center overflow-hidden"
>
<form
id="ssh-form"
class="container mx-auto px-4 hidden flex justify-center"
>
<input type="text" class="input username" placeholder="Username" />
<div class="select-with-arrow mx-2">
<select class="select"></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>
<div id="no-ssh" class="container mx-auto px-4 hidden 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>
</div>
</body>
</html>
1 change: 1 addition & 0 deletions cmd/tsconnect/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"devDependencies": {
"@types/golang-wasm-exec": "^1.15.0",
"@types/qrcode": "^1.4.2",
"preact": "^10.10.0",
"qrcode": "^1.5.0",
"tailwindcss": "^3.1.6",
"typescript": "^4.7.4",
Expand Down
124 changes: 124 additions & 0 deletions cmd/tsconnect/src/app.tsx
Original file line number Diff line number Diff line change
@@ -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
)
})
}
21 changes: 21 additions & 0 deletions cmd/tsconnect/src/go-panic-display.tsx
Original file line number Diff line number Diff line change
@@ -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>
)
}
40 changes: 40 additions & 0 deletions cmd/tsconnect/src/header.tsx
Original file line number Diff line number Diff line change
@@ -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
4 changes: 0 additions & 4 deletions cmd/tsconnect/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,3 @@
background-color: currentColor;
clip-path: polygon(100% 0%, 0 0%, 50% 100%);
}

body.ssh-active #ssh-form {
@apply hidden;
}
55 changes: 13 additions & 42 deletions cmd/tsconnect/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,55 +4,26 @@

import "./wasm_exec"
import wasmUrl from "./main.wasm"
import { notifyState, notifyNetMap, notifyBrowseToURL } from "./notifier"
import { sessionStateStorage } from "./js-state-store"
import { renderApp } from "./app"

const go = new Go()
WebAssembly.instantiateStreaming(
fetch(`./dist/${wasmUrl}`),
go.importObject
).then((result) => {
async function main() {
const app = await renderApp()
const go = new Go()
const wasmInstance = await WebAssembly.instantiateStreaming(
fetch(`./dist/${wasmUrl}`),
go.importObject
)
// The Go process should never exit, if it does then it's an unhandled panic.
go.run(result.instance).then(() => handleGoPanic())
go.run(wasmInstance.instance).then(() =>
app.handleGoPanic("Unexpected shutdown")
)
const ipn = newIPN({
// Persist IPN state in sessionStorage in development, so that we don't need
// to re-authorize every time we reload the page.
stateStorage: DEBUG ? sessionStateStorage : undefined,
})
ipn.run({
notifyState: notifyState.bind(null, ipn),
notifyNetMap: notifyNetMap.bind(null, ipn),
notifyBrowseToURL: notifyBrowseToURL.bind(null, ipn),
notifyPanicRecover: handleGoPanic,
})
})

function handleGoPanic(err?: string) {
if (DEBUG && err) {
console.error("Go panic", err)
}
if (panicNode) {
panicNode.remove()
}
panicNode = document.createElement("div")
panicNode.className =
"rounded bg-red-500 p-2 absolute top-2 right-2 text-white font-bold text-right cursor-pointer"
panicNode.textContent = "Tailscale has encountered an error."
const panicDetailNode = document.createElement("div")
panicDetailNode.className = "text-sm font-normal"
panicDetailNode.textContent = "Click to reload"
panicNode.appendChild(panicDetailNode)
panicNode.addEventListener("click", () => location.reload(), {
once: true,
})
document.body.appendChild(panicNode)
setTimeout(() => {
panicNode!.remove()
}, 10000)
app.runWithIPN(ipn)
}

let panicNode: HTMLDivElement | undefined

export function getContentNode(): HTMLDivElement {
return document.querySelector("#content") as HTMLDivElement
}
main()
74 changes: 0 additions & 74 deletions cmd/tsconnect/src/login.ts

This file was deleted.

Loading

0 comments on commit b81d219

Please sign in to comment.