This copies the existing go template frontend into very crude react components that will be driven by a simple JSON api for fetching and updating data. For now, this returns a static set of test data. This just implements the simple existing UI, so I've put these all in a "legacy" component, with the expectation that we will rebuild this with more properly defined components, some pulled from corp. Updates tailscale/corp#13775 Signed-off-by: Will Norris <will@tailscale.com>main
parent
ddba4824c4
commit
9c4364e0b7
@ -1,4 +1,29 @@ |
||||
<!doctype html> |
||||
<html class="bg-gray-50"> |
||||
<head> |
||||
<title>Tailscale</title> |
||||
<meta charset="utf-8" /> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1" /> |
||||
<link rel="shortcut icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAQAAADZc7J/AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QA/4ePzL8AAAAHdElNRQflAx4QGA4EvmzDAAAA30lEQVRIx2NgGAWMCKa8JKM4A8Ovt88ekyLCDGOoyDBJMjExMbFy8zF8/EKsCAMDE8yAPyIwFps48SJIBpAL4AZwvoSx/r0lXgQpDN58EWL5x/7/H+vL20+JFxluQKVe5b3Ke5V+0kQQCamfoYKBg4GDwUKI8d0BYkWQkrLKewYBKPPDHUFiRaiZkBgmwhj/F5IgggyUJ6i8V3mv0kCayDAAeEsklXqGAgYGhgV3CnGrwVciYSYk0kokhgS44/JxqqFpiYSZbEgskd4dEBRk1GD4wdB5twKXmlHAwMDAAACdEZau06NQUwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyMC0wNy0xNVQxNTo1Mzo0MCswMDowMCVXsDIAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjAtMDctMTVUMTU6NTM6NDArMDA6MDBUCgiOAAAAAElFTkSuQmCC" /> |
||||
<link rel="stylesheet" type="text/css" href="/src/index.css" /> |
||||
</head> |
||||
<body> |
||||
<div class="min-h-screen py-10 flex justify-center items-center" style="display: none"> |
||||
<div class="max-w-md"> |
||||
<h3 class="font-semibold text-lg mb-4">Your web browser is unsupported.</h3> |
||||
<p class="mb-2"> |
||||
Update to a modern browser to access the Tailscale web client. You can use |
||||
<a class="link" href="https://www.mozilla.org/en-US/firefox/new/" target="_blank">Firefox</a>, |
||||
<a class="link" href="https://www.microsoft.com/en-us/edge" target="_blank">Edge</a>, |
||||
<a class="link" href="https://www.apple.com/safari/" target="_blank">Safari</a>, |
||||
or <a class="link" href="https://www.google.com/chrome/" target="_blank">Chrome</a>.</p> |
||||
<p>If you need any help, feel free to <a href="mailto:support+webclient@tailscale.com" class="link">contact us</a></p> |
||||
</div> |
||||
</div> |
||||
<noscript> |
||||
<p class="mb-2">You need to enable Javascript to access the Tailscale web client.</p> |
||||
<p>If you need any help, feel free to <a href="mailto:support+webclient@tailscale.com" class="link">contact us</a>.</p> |
||||
</noscript> |
||||
<script type="module" src="/src/index.tsx"></script> |
||||
</html> |
||||
</body> |
||||
</html> |
||||
|
||||
@ -1,5 +1,18 @@ |
||||
import React from "react" |
||||
import { Footer, Header, IP, State } from "src/components/legacy" |
||||
import useNodeData from "src/hooks/node-data" |
||||
|
||||
export default function App() { |
||||
return <div className="text-center">Hello world</div> |
||||
const data = useNodeData() |
||||
|
||||
return ( |
||||
<div className="py-14"> |
||||
<main className="container max-w-lg mx-auto mb-8 py-6 px-8 bg-white rounded-md shadow-2xl"> |
||||
<Header data={data} /> |
||||
<IP data={data} /> |
||||
<State data={data} /> |
||||
</main> |
||||
<Footer data={data} /> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
@ -0,0 +1,272 @@ |
||||
import React from "react" |
||||
import { NodeData } from "src/hooks/node-data" |
||||
|
||||
// TODO(tailscale/corp#13775): legacy.tsx contains a set of components
|
||||
// that (crudely) implement the pre-2023 web client. These are implemented
|
||||
// purely to ease migration to the new React-based web client, and will
|
||||
// eventually be completely removed.
|
||||
|
||||
export function Header(props: { data: NodeData }) { |
||||
const { data } = props |
||||
|
||||
return ( |
||||
<header className="flex justify-between items-center min-width-0 py-2 mb-8"> |
||||
<svg |
||||
width="26" |
||||
height="26" |
||||
viewBox="0 0 23 23" |
||||
fill="none" |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
className="flex-shrink-0 mr-4" |
||||
> |
||||
<circle |
||||
opacity="0.2" |
||||
cx="3.4" |
||||
cy="3.25" |
||||
r="2.7" |
||||
fill="currentColor" |
||||
></circle> |
||||
<circle cx="3.4" cy="11.3" r="2.7" fill="currentColor"></circle> |
||||
<circle |
||||
opacity="0.2" |
||||
cx="3.4" |
||||
cy="19.5" |
||||
r="2.7" |
||||
fill="currentColor" |
||||
></circle> |
||||
<circle cx="11.5" cy="11.3" r="2.7" fill="currentColor"></circle> |
||||
<circle cx="11.5" cy="19.5" r="2.7" fill="currentColor"></circle> |
||||
<circle |
||||
opacity="0.2" |
||||
cx="11.5" |
||||
cy="3.25" |
||||
r="2.7" |
||||
fill="currentColor" |
||||
></circle> |
||||
<circle |
||||
opacity="0.2" |
||||
cx="19.5" |
||||
cy="3.25" |
||||
r="2.7" |
||||
fill="currentColor" |
||||
></circle> |
||||
<circle cx="19.5" cy="11.3" r="2.7" fill="currentColor"></circle> |
||||
<circle |
||||
opacity="0.2" |
||||
cx="19.5" |
||||
cy="19.5" |
||||
r="2.7" |
||||
fill="currentColor" |
||||
></circle> |
||||
</svg> |
||||
<div className="flex items-center justify-end space-x-2 w-2/3"> |
||||
{data.Profile && ( |
||||
<> |
||||
<div className="text-right w-full leading-4"> |
||||
<h4 className="truncate leading-normal"> |
||||
{data.Profile.LoginName} |
||||
</h4> |
||||
<div className="text-xs text-gray-500 text-right"> |
||||
<a href="#" className="hover:text-gray-700 js-loginButton"> |
||||
Switch account |
||||
</a>{" "} |
||||
|{" "} |
||||
<a href="#" className="hover:text-gray-700 js-loginButton"> |
||||
Reauthenticate |
||||
</a>{" "} |
||||
|{" "} |
||||
<a href="#" className="hover:text-gray-700 js-logoutButton"> |
||||
Logout |
||||
</a> |
||||
</div> |
||||
</div> |
||||
<div className="relative flex-shrink-0 w-8 h-8 rounded-full overflow-hidden"> |
||||
{data.Profile.ProfilePicURL ? ( |
||||
<div |
||||
className="w-8 h-8 flex pointer-events-none rounded-full bg-gray-200" |
||||
style={{ |
||||
backgroundImage: `url(${data.Profile.ProfilePicURL})`, |
||||
backgroundSize: "cover", |
||||
}} |
||||
/> |
||||
) : ( |
||||
<div className="w-8 h-8 flex pointer-events-none rounded-full border border-gray-400 border-dashed" /> |
||||
)} |
||||
</div> |
||||
</> |
||||
)} |
||||
</div> |
||||
</header> |
||||
) |
||||
} |
||||
|
||||
export function IP(props: { data: NodeData }) { |
||||
const { data } = props |
||||
|
||||
if (!data.IP) { |
||||
return null |
||||
} |
||||
|
||||
return ( |
||||
<> |
||||
<div className="border border-gray-200 bg-gray-50 rounded-md p-2 pl-3 pr-3 width-full flex items-center justify-between"> |
||||
<div className="flex items-center min-width-0"> |
||||
<svg |
||||
className="flex-shrink-0 text-gray-600 mr-3 ml-1" |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
width="20" |
||||
height="20" |
||||
viewBox="0 0 24 24" |
||||
fill="none" |
||||
stroke="currentColor" |
||||
strokeWidth="2" |
||||
strokeLinecap="round" |
||||
strokeLinejoin="round" |
||||
> |
||||
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect> |
||||
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect> |
||||
<line x1="6" y1="6" x2="6.01" y2="6"></line> |
||||
<line x1="6" y1="18" x2="6.01" y2="18"></line> |
||||
</svg> |
||||
<div> |
||||
<h4 className="font-semibold truncate mr-2">{data.DeviceName}</h4> |
||||
</div> |
||||
</div> |
||||
<h5>{data.IP}</h5> |
||||
</div> |
||||
<p className="mt-1 ml-1 mb-6 text-xs text-gray-600"> |
||||
Debug info: Tailscale {data.IPNVersion}, tun={data.TUNMode.toString()} |
||||
{data.IsSynology && ( |
||||
<> |
||||
, DSM{data.DSMVersion} |
||||
{data.TUNMode || ( |
||||
<> |
||||
{" "} |
||||
( |
||||
<a |
||||
href="https://tailscale.com/kb/1152/synology-outbound/" |
||||
className="link-underline text-gray-600" |
||||
target="_blank" |
||||
aria-label="Configure outbound synology traffic" |
||||
rel="noopener noreferrer" |
||||
> |
||||
outgoing access not configured |
||||
</a> |
||||
) |
||||
</> |
||||
)} |
||||
</> |
||||
)} |
||||
</p> |
||||
</> |
||||
) |
||||
} |
||||
|
||||
export function State(props: { data: NodeData }) { |
||||
const { data } = props |
||||
|
||||
switch (data.Status) { |
||||
case "NeedsLogin": |
||||
case "NoState": |
||||
if (data.IP) { |
||||
return ( |
||||
<> |
||||
<div className="mb-6"> |
||||
<p className="text-gray-700"> |
||||
Your device's key has expired. Reauthenticate this device by |
||||
logging in again, or{" "} |
||||
<a |
||||
href="https://tailscale.com/kb/1028/key-expiry" |
||||
className="link" |
||||
target="_blank" |
||||
> |
||||
learn more |
||||
</a> |
||||
. |
||||
</p> |
||||
</div> |
||||
<a href="#" className="mb-4 js-loginButton" target="_blank"> |
||||
<button className="button button-blue w-full"> |
||||
Reauthenticate |
||||
</button> |
||||
</a> |
||||
</> |
||||
) |
||||
} else { |
||||
return ( |
||||
<> |
||||
<div className="mb-6"> |
||||
<h3 className="text-3xl font-semibold mb-3">Log in</h3> |
||||
<p className="text-gray-700"> |
||||
Get started by logging in to your Tailscale network. |
||||
Or, learn more at{" "} |
||||
<a |
||||
href="https://tailscale.com/" |
||||
className="link" |
||||
target="_blank" |
||||
> |
||||
tailscale.com |
||||
</a> |
||||
. |
||||
</p> |
||||
</div> |
||||
<a href="#" className="mb-4 js-loginButton" target="_blank"> |
||||
<button className="button button-blue w-full">Log In</button> |
||||
</a> |
||||
</> |
||||
) |
||||
} |
||||
case "NeedsMachineAuth": |
||||
return ( |
||||
<div className="mb-4"> |
||||
This device is authorized, but needs approval from a network admin |
||||
before it can connect to the network. |
||||
</div> |
||||
) |
||||
default: |
||||
return ( |
||||
<> |
||||
<div className="mb-4"> |
||||
<p> |
||||
You are connected! Access this device over Tailscale using the |
||||
device name or IP address above. |
||||
</p> |
||||
</div> |
||||
<div className="mb-4"> |
||||
<a href="#" className="mb-4 js-advertiseExitNode"> |
||||
{data.AdvertiseExitNode ? ( |
||||
<button |
||||
className="button button-red button-medium" |
||||
id="enabled" |
||||
> |
||||
Stop advertising Exit Node |
||||
</button> |
||||
) : ( |
||||
<button |
||||
className="button button-blue button-medium" |
||||
id="enabled" |
||||
> |
||||
Advertise as Exit Node |
||||
</button> |
||||
)} |
||||
</a> |
||||
</div> |
||||
</> |
||||
) |
||||
} |
||||
} |
||||
|
||||
export function Footer(props: { data: NodeData }) { |
||||
const { data } = props |
||||
|
||||
return ( |
||||
<footer className="container max-w-lg mx-auto text-center"> |
||||
<a |
||||
className="text-xs text-gray-500 hover:text-gray-600" |
||||
href={data.LicensesURL} |
||||
> |
||||
Open Source Licenses |
||||
</a> |
||||
</footer> |
||||
) |
||||
} |
||||
@ -0,0 +1,48 @@ |
||||
export type UserProfile = { |
||||
LoginName: string |
||||
DisplayName: string |
||||
ProfilePicURL: string |
||||
} |
||||
|
||||
export type NodeData = { |
||||
Profile: UserProfile |
||||
Status: string |
||||
DeviceName: string |
||||
IP: string |
||||
AdvertiseExitNode: boolean |
||||
AdvertiseRoutes: string |
||||
LicensesURL: string |
||||
TUNMode: boolean |
||||
IsSynology: boolean |
||||
DSMVersion: number |
||||
IsUnraid: boolean |
||||
UnraidToken: string |
||||
IPNVersion: string |
||||
} |
||||
|
||||
// testData is static set of nodedata used during development.
|
||||
// This can be removed once we have a real node data API.
|
||||
const testData: NodeData = { |
||||
Profile: { |
||||
LoginName: "amelie", |
||||
DisplayName: "Amelie Pangolin", |
||||
ProfilePicURL: "https://login.tailscale.com/logo192.png", |
||||
}, |
||||
Status: "Running", |
||||
DeviceName: "amelies-laptop", |
||||
IP: "100.1.2.3", |
||||
AdvertiseExitNode: false, |
||||
AdvertiseRoutes: "", |
||||
LicensesURL: "https://tailscale.com/licenses/tailscale", |
||||
TUNMode: false, |
||||
IsSynology: true, |
||||
DSMVersion: 7, |
||||
IsUnraid: false, |
||||
UnraidToken: "", |
||||
IPNVersion: "0.1.0", |
||||
} |
||||
|
||||
// useNodeData returns basic data about the current node.
|
||||
export default function useNodeData() { |
||||
return testData |
||||
} |
||||
@ -1,3 +1,130 @@ |
||||
@tailwind base; |
||||
@tailwind components; |
||||
@tailwind utilities; |
||||
|
||||
/** |
||||
* Non-Tailwind styles begin here. |
||||
*/ |
||||
|
||||
.bg-gray-0 { |
||||
--tw-bg-opacity: 1; |
||||
background-color: rgba(250, 249, 248, var(--tw-bg-opacity)); |
||||
} |
||||
|
||||
.bg-gray-50 { |
||||
--tw-bg-opacity: 1; |
||||
background-color: rgba(249, 247, 246, var(--tw-bg-opacity)); |
||||
} |
||||
|
||||
html { |
||||
letter-spacing: -0.015em; |
||||
text-rendering: optimizeLegibility; |
||||
-webkit-font-smoothing: antialiased; |
||||
-moz-osx-font-smoothing: grayscale; |
||||
} |
||||
|
||||
.link { |
||||
--text-opacity: 1; |
||||
color: #4b70cc; |
||||
color: rgba(75, 112, 204, var(--text-opacity)); |
||||
} |
||||
|
||||
.link:hover, |
||||
.link:active { |
||||
--text-opacity: 1; |
||||
color: #19224a; |
||||
color: rgba(25, 34, 74, var(--text-opacity)); |
||||
} |
||||
|
||||
.link-underline { |
||||
text-decoration: underline; |
||||
} |
||||
|
||||
.link-underline:hover, |
||||
.link-underline:active { |
||||
text-decoration: none; |
||||
} |
||||
|
||||
.link-muted { |
||||
/* same as text-gray-500 */ |
||||
--tw-text-opacity: 1; |
||||
color: rgba(112, 110, 109, var(--tw-text-opacity)); |
||||
} |
||||
|
||||
.link-muted:hover, |
||||
.link-muted:active { |
||||
/* same as text-gray-500 */ |
||||
--tw-text-opacity: 1; |
||||
color: rgba(68, 67, 66, var(--tw-text-opacity)); |
||||
} |
||||
|
||||
.button { |
||||
font-weight: 500; |
||||
padding-top: 0.45rem; |
||||
padding-bottom: 0.45rem; |
||||
padding-left: 1rem; |
||||
padding-right: 1rem; |
||||
border-radius: 0.375rem; |
||||
border-width: 1px; |
||||
border-color: transparent; |
||||
transition-property: background-color, border-color, color, box-shadow; |
||||
transition-duration: 120ms; |
||||
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04); |
||||
min-width: 80px; |
||||
} |
||||
|
||||
.button:focus { |
||||
outline: 0; |
||||
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.5); |
||||
} |
||||
|
||||
.button:disabled { |
||||
cursor: not-allowed; |
||||
-webkit-user-select: none; |
||||
-ms-user-select: none; |
||||
user-select: none; |
||||
} |
||||
|
||||
.button-blue { |
||||
--bg-opacity: 1; |
||||
background-color: #4b70cc; |
||||
background-color: rgba(75, 112, 204, var(--bg-opacity)); |
||||
--border-opacity: 1; |
||||
border-color: #4b70cc; |
||||
border-color: rgba(75, 112, 204, var(--border-opacity)); |
||||
--text-opacity: 1; |
||||
color: #fff; |
||||
color: rgba(255, 255, 255, var(--text-opacity)); |
||||
} |
||||
|
||||
.button-blue:enabled:hover { |
||||
--bg-opacity: 1; |
||||
background-color: #3f5db3; |
||||
background-color: rgba(63, 93, 179, var(--bg-opacity)); |
||||
--border-opacity: 1; |
||||
border-color: #3f5db3; |
||||
border-color: rgba(63, 93, 179, var(--border-opacity)); |
||||
} |
||||
|
||||
.button-blue:disabled { |
||||
--text-opacity: 1; |
||||
color: #cedefd; |
||||
color: rgba(206, 222, 253, var(--text-opacity)); |
||||
--bg-opacity: 1; |
||||
background-color: #6c94ec; |
||||
background-color: rgba(108, 148, 236, var(--bg-opacity)); |
||||
--border-opacity: 1; |
||||
border-color: #6c94ec; |
||||
border-color: rgba(108, 148, 236, var(--border-opacity)); |
||||
} |
||||
|
||||
.button-red { |
||||
background-color: #d04841; |
||||
border-color: #d04841; |
||||
color: #fff; |
||||
} |
||||
|
||||
.button-red:enabled:hover { |
||||
background-color: #b22d30; |
||||
border-color: #b22d30; |
||||
} |
||||
|
||||
Loading…
Reference in new issue