client/web: add device details view

Initial addition of device details view on the frontend. A little
more backend piping work to come to fill all of the detail fields,
for now using placeholders.

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
pull/10149/head
Sonia Appasamy 1 year ago committed by Sonia Appasamy
parent 3e9026efda
commit d73e923b73

@ -1,31 +1,57 @@
import cx from "classnames" import cx from "classnames"
import React from "react" import React, { useEffect } from "react"
import LegacyClientView from "src/components/views/legacy-client-view" import LegacyClientView from "src/components/views/legacy-client-view"
import LoginClientView from "src/components/views/login-client-view" import LoginClientView from "src/components/views/login-client-view"
import ManagementClientView from "src/components/views/management-client-view" import ManagementClientView from "src/components/views/management-client-view"
import ReadonlyClientView from "src/components/views/readonly-client-view" import ReadonlyClientView from "src/components/views/readonly-client-view"
import useAuth, { AuthResponse, SessionsCallbacks } from "src/hooks/auth" import useAuth, { AuthResponse, SessionsCallbacks } from "src/hooks/auth"
import useNodeData from "src/hooks/node-data" import useNodeData, { NodeData, NodeUpdate } from "src/hooks/node-data"
import { Route, Switch } from "wouter" import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg"
import ProfilePic from "src/ui/profile-pic"
import { Link, Route, Switch, useLocation } from "wouter"
import DeviceDetailsView from "./views/device-details-view"
export default function App() { export default function App() {
const { data: auth, loading: loadingAuth, sessions } = useAuth() const { data: auth, loading: loadingAuth, sessions } = useAuth()
const { data, refreshData, updateNode } = useNodeData()
useEffect(() => {
refreshData()
}, [auth, refreshData])
return ( return (
<main className="flex flex-col items-center min-w-sm max-w-lg mx-auto py-14"> <main className="min-w-sm max-w-lg mx-auto py-14 px-5">
{loadingAuth ? ( {loadingAuth || !data ? (
<div className="text-center py-14">Loading...</div> // TODO(sonia): add a loading view <div className="text-center py-14">Loading...</div> // TODO(sonia): add a loading view
) : ( ) : (
<>
{/* TODO(sonia): get rid of the conditions here once full/readonly
* views live on same components */}
{data.DebugMode === "full" && auth?.ok && <Header node={data} />}
<Switch> <Switch>
<Route path="/"> <Route path="/">
<HomeView auth={auth} sessions={sessions} /> <HomeView
auth={auth}
data={data}
sessions={sessions}
refreshData={refreshData}
updateNode={updateNode}
/>
</Route>
{data.DebugMode !== "" && (
<>
<Route path="/details">
<DeviceDetailsView node={data} />
</Route> </Route>
<Route path="/details">{/* TODO */}Device details</Route>
<Route path="/subnets">{/* TODO */}Subnet router</Route> <Route path="/subnets">{/* TODO */}Subnet router</Route>
<Route path="/ssh">{/* TODO */}Tailscale SSH server</Route> <Route path="/ssh">{/* TODO */}Tailscale SSH server</Route>
<Route path="/serve">{/* TODO */}Share local content</Route> <Route path="/serve">{/* TODO */}Share local content</Route>
<Route>Page not found</Route> </>
)}
<Route>
<h2 className="mt-8">Page not found</h2>
</Route>
</Switch> </Switch>
</>
)} )}
</main> </main>
) )
@ -33,18 +59,20 @@ export default function App() {
function HomeView({ function HomeView({
auth, auth,
data,
sessions, sessions,
refreshData,
updateNode,
}: { }: {
auth?: AuthResponse auth?: AuthResponse
data: NodeData
sessions: SessionsCallbacks sessions: SessionsCallbacks
refreshData: () => Promise<void>
updateNode: (update: NodeUpdate) => void
}) { }) {
const { data, refreshData, updateNode } = useNodeData()
return ( return (
<> <>
{!data ? ( {data?.Status === "NeedsLogin" || data?.Status === "NoState" ? (
<div className="text-center py-14">Loading...</div> // TODO(sonia): add a loading view
) : data?.Status === "NeedsLogin" || data?.Status === "NoState" ? (
// Client not on a tailnet, render login. // Client not on a tailnet, render login.
<LoginClientView <LoginClientView
data={data} data={data}
@ -64,19 +92,47 @@ function HomeView({
updateNode={updateNode} updateNode={updateNode}
/> />
)} )}
{data && <Footer licensesURL={data.LicensesURL} />} {<Footer licensesURL={data.LicensesURL} />}
</> </>
) )
} }
export function Footer(props: { licensesURL: string; className?: string }) { function Header({ node }: { node: NodeData }) {
const [loc] = useLocation()
return ( return (
<footer <>
className={cx("container max-w-lg mx-auto text-center", props.className)} <div className="flex justify-between mb-12">
<TailscaleIcon />
<div className="flex">
<p className="mr-2">{node.Profile.LoginName}</p>
<ProfilePic url={node.Profile.ProfilePicURL} />
</div>
</div>
{loc !== "/" && (
<Link
to="/"
className="text-indigo-500 font-medium leading-snug block mb-[10px]"
> >
&larr; Back to {node.DeviceName}
</Link>
)}
</>
)
}
export function Footer({
licensesURL,
className,
}: {
licensesURL: string
className?: string
}) {
return (
<footer className={cx("container max-w-lg mx-auto text-center", className)}>
<a <a
className="text-xs text-gray-500 hover:text-gray-600" className="text-xs text-gray-500 hover:text-gray-600"
href={props.licensesURL} href={licensesURL}
> >
Open Source Licenses Open Source Licenses
</a> </a>

@ -0,0 +1,107 @@
import React from "react"
import { NodeData } from "src/hooks/node-data"
import ProfilePic from "src/ui/profile-pic"
export default function DeviceDetailsView({ node }: { node: NodeData }) {
return (
<div>
<h1 className="mb-10">Device details</h1>
<div className="flex flex-col gap-4">
<div className="card">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h1>{node.DeviceName}</h1>
{/* TODO: connected status */}
<div className="w-2.5 h-2.5 bg-emerald-500 rounded-full" />
</div>
<button className="px-3 py-2 bg-stone-50 rounded shadow border border-stone-200 text-neutral-800 text-sm font-medium">
Log out
</button>
</div>
<hr className="my-5" />
<div className="text-neutral-500 text-sm leading-tight mb-1">
Managed by
</div>
<div className="flex">
{/* TODO: tags display */}
<ProfilePic size="small" url={node.Profile.ProfilePicURL} />
<div className="ml-2 text-neutral-800 text-sm leading-tight">
{node.Profile.LoginName}
</div>
</div>
</div>
<div className="card">
<h2 className="mb-2">General</h2>
<table>
<tbody>
{/* TODO: pipe through these values */}
<tr>
<td>Creator</td>
<td>{node.Profile.DisplayName}</td>
</tr>
<tr>
<td>Managed by</td>
<td>{node.Profile.DisplayName}</td>
</tr>
<tr>
<td>Machine name</td>
<td>{node.DeviceName}</td>
</tr>
<tr>
<td>OS</td>
<td>MacOS</td>
</tr>
<tr>
<td>ID</td>
<td>nPKyyg3CNTRL</td>
</tr>
<tr>
<td>Tailscale version</td>
<td>{node.IPNVersion}</td>
</tr>
<tr>
<td>Key expiry</td>
<td>3 months from now</td>
</tr>
</tbody>
</table>
</div>
<div className="card">
<h2 className="mb-2">Addresses</h2>
<table>
<tbody>
<tr>
<td>Tailscale IPv4</td>
<td>{node.IP}</td>
</tr>
<tr>
<td>Tailscale IPv6</td>
<td>fd7a:115c:a1e0:ab12:4843:cd96:627a:f179</td>
</tr>
<tr>
<td>Short domain</td>
<td>{node.DeviceName}</td>
</tr>
<tr>
<td>Full domain</td>
<td>{node.DeviceName}.corp.ts.net</td>
</tr>
</tbody>
</table>
</div>
<p className="text-neutral-500 text-sm leading-tight text-center">
Want even more details? Visit{" "}
<a
// TODO: pipe control serve url from backend
href="https://login.tailscale.com/admin"
target="_blank"
className="text-indigo-700 text-sm"
>
this devices page
</a>{" "}
in the admin console.
</p>
</div>
</div>
)
}

@ -4,30 +4,18 @@ import { NodeData } from "src/hooks/node-data"
import { ReactComponent as ArrowRight } from "src/icons/arrow-right.svg" import { ReactComponent as ArrowRight } from "src/icons/arrow-right.svg"
import { ReactComponent as ChevronDown } from "src/icons/chevron-down.svg" import { ReactComponent as ChevronDown } from "src/icons/chevron-down.svg"
import { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg" import { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg"
import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg" import { Link } from "wouter"
import ProfilePic from "src/ui/profile-pic"
export default function ManagementClientView(props: NodeData) { export default function ManagementClientView(props: NodeData) {
return ( return (
<div className="px-5 mb-12 w-full"> <div className="mb-12 w-full">
<div className="flex justify-between mb-12"> <h2 className="mb-3">This device</h2>
<TailscaleIcon />
<div className="flex">
<p className="mr-2">{props.Profile.LoginName}</p>
<ProfilePic url={props.Profile.ProfilePicURL} />
</div>
</div>
<h1 className="mb-3">This device</h1>
<div className="-mx-5 card mb-9"> <div className="-mx-5 card mb-9">
<div className="flex justify-between items-center text-lg mb-5"> <div className="flex justify-between items-center text-lg mb-5">
<div className="flex items-center"> <div className="flex items-center">
<ConnectedDeviceIcon /> <ConnectedDeviceIcon />
<div className="ml-3"> <div className="ml-3">
<p className="text-neutral-800 text-lg font-medium leading-snug"> <h1>{props.DeviceName}</h1>
{props.DeviceName}
</p>
{/* TODO(sonia): display actual status */} {/* TODO(sonia): display actual status */}
<p className="text-neutral-500 text-sm">Connected</p> <p className="text-neutral-500 text-sm">Connected</p>
</div> </div>
@ -37,23 +25,28 @@ export default function ManagementClientView(props: NodeData) {
</p> </p>
</div> </div>
<ExitNodeSelector className="mb-5" /> <ExitNodeSelector className="mb-5" />
<a className="text-indigo-500 font-medium leading-snug"> <Link
className="text-indigo-500 font-medium leading-snug"
to="/details"
>
View device details &rarr; View device details &rarr;
</a> </Link>
</div> </div>
<h2 className="mb-3">Settings</h2>
<h1 className="mb-3">Settings</h1>
<SettingsCard <SettingsCard
link="/subnets"
className="mb-3" className="mb-3"
title="Subnet router" title="Subnet router"
body="Add devices to your tailnet without installing Tailscale on them." body="Add devices to your tailnet without installing Tailscale on them."
/> />
<SettingsCard <SettingsCard
link="/ssh"
className="mb-3" className="mb-3"
title="Tailscale SSH server" title="Tailscale SSH server"
body="Run a Tailscale SSH server on this device and allow other devices in your tailnet to SSH into it." body="Run a Tailscale SSH server on this device and allow other devices in your tailnet to SSH into it."
/> />
<SettingsCard <SettingsCard
link="/serve"
title="Share local content" title="Share local content"
body="Share local ports, services, and content to your Tailscale network or to the broader internet." body="Share local ports, services, and content to your Tailscale network or to the broader internet."
/> />
@ -79,15 +72,18 @@ function ExitNodeSelector({ className }: { className?: string }) {
function SettingsCard({ function SettingsCard({
title, title,
link,
body, body,
className, className,
}: { }: {
title: string title: string
link: string
body: string body: string
className?: string className?: string
}) { }) {
return ( return (
<div <Link
to={link}
className={cx( className={cx(
"-mx-5 card flex justify-between items-center cursor-pointer", "-mx-5 card flex justify-between items-center cursor-pointer",
className className
@ -102,6 +98,6 @@ function SettingsCard({
<div> <div>
<ArrowRight className="ml-3" /> <ArrowRight className="ml-3" />
</div> </div>
</div> </Link>
) )
} }

@ -4,6 +4,10 @@
@layer base { @layer base {
h1 { h1 {
@apply text-neutral-800 text-[22px] font-medium leading-[30.80px];
}
h2 {
@apply text-neutral-500 text-sm font-medium uppercase leading-tight tracking-wide; @apply text-neutral-500 text-sm font-medium uppercase leading-tight tracking-wide;
} }
} }
@ -12,6 +16,21 @@
.card { .card {
@apply p-5 bg-white rounded-lg border border-gray-200; @apply p-5 bg-white rounded-lg border border-gray-200;
} }
.card h1 {
@apply text-neutral-800 text-lg font-medium leading-snug;
}
.card h2 {
@apply text-neutral-500 text-xs font-semibold uppercase tracking-wide;
}
.card tbody {
@apply flex flex-col gap-2;
}
.card td:first-child {
@apply w-40 text-neutral-500 text-sm leading-tight;
}
.card td:last-child {
@apply text-neutral-800 text-sm leading-tight;
}
.hover-button { .hover-button {
@apply px-2 py-1.5 bg-white rounded-[1px] cursor-pointer; @apply px-2 py-1.5 bg-white rounded-[1px] cursor-pointer;

@ -1,18 +1,30 @@
import cx from "classnames"
import React from "react" import React from "react"
export default function ProfilePic({ url }: { url: string }) { export default function ProfilePic({
url,
size = "medium",
}: {
url: string
size?: "small" | "medium"
}) {
return ( return (
<div className="relative flex-shrink-0 w-8 h-8 rounded-full overflow-hidden"> <div
className={cx("relative flex-shrink-0 rounded-full overflow-hidden", {
"w-5 h-5": size === "small",
"w-8 h-8": size === "medium",
})}
>
{url ? ( {url ? (
<div <div
className="w-8 h-8 flex pointer-events-none rounded-full bg-gray-200" className="w-full h-full flex pointer-events-none rounded-full bg-gray-200"
style={{ style={{
backgroundImage: `url(${url})`, backgroundImage: `url(${url})`,
backgroundSize: "cover", backgroundSize: "cover",
}} }}
/> />
) : ( ) : (
<div className="w-8 h-8 flex pointer-events-none rounded-full border border-gray-400 border-dashed" /> <div className="w-full h-full flex pointer-events-none rounded-full border border-gray-400 border-dashed" />
)} )}
</div> </div>
) )

Loading…
Cancel
Save