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>
This commit is contained in:
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
|
||||||
) : (
|
) : (
|
||||||
<Switch>
|
<>
|
||||||
<Route path="/">
|
{/* TODO(sonia): get rid of the conditions here once full/readonly
|
||||||
<HomeView auth={auth} sessions={sessions} />
|
* views live on same components */}
|
||||||
</Route>
|
{data.DebugMode === "full" && auth?.ok && <Header node={data} />}
|
||||||
<Route path="/details">{/* TODO */}Device details</Route>
|
<Switch>
|
||||||
<Route path="/subnets">{/* TODO */}Subnet router</Route>
|
<Route path="/">
|
||||||
<Route path="/ssh">{/* TODO */}Tailscale SSH server</Route>
|
<HomeView
|
||||||
<Route path="/serve">{/* TODO */}Share local content</Route>
|
auth={auth}
|
||||||
<Route>Page not found</Route>
|
data={data}
|
||||||
</Switch>
|
sessions={sessions}
|
||||||
|
refreshData={refreshData}
|
||||||
|
updateNode={updateNode}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
{data.DebugMode !== "" && (
|
||||||
|
<>
|
||||||
|
<Route path="/details">
|
||||||
|
<DeviceDetailsView node={data} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/subnets">{/* TODO */}Subnet router</Route>
|
||||||
|
<Route path="/ssh">{/* TODO */}Tailscale SSH server</Route>
|
||||||
|
<Route path="/serve">{/* TODO */}Share local content</Route>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Route>
|
||||||
|
<h2 className="mt-8">Page not found</h2>
|
||||||
|
</Route>
|
||||||
|
</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]"
|
||||||
|
>
|
||||||
|
← 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 device’s 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 →
|
View device details →
|
||||||
</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…
Reference in New Issue