client/web: small UI cleanups

Updates:
* Card component used throughout instead of custom card class
* SSH toggle changed to non-editable text/status icon in readonly
* Red error text on subnet route input when route post failed

Updates #10261

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
pull/10517/head
Sonia Appasamy 12 months ago committed by Sonia Appasamy
parent e5e5ebda44
commit d5d42d0293

@ -12,6 +12,8 @@ import SubnetRouterView from "src/components/views/subnet-router-view"
import { UpdatingView } from "src/components/views/updating-view" import { UpdatingView } from "src/components/views/updating-view"
import useAuth, { AuthResponse } from "src/hooks/auth" import useAuth, { AuthResponse } from "src/hooks/auth"
import { Feature, featureDescription, NodeData } from "src/types" import { Feature, featureDescription, NodeData } from "src/types"
import Card from "src/ui/card"
import EmptyState from "src/ui/empty-state"
import LoadingDots from "src/ui/loading-dots" import LoadingDots from "src/ui/loading-dots"
import useSWR from "swr" import useSWR from "swr"
import { Link, Route, Router, Switch, useLocation } from "wouter" import { Link, Route, Router, Switch, useLocation } from "wouter"
@ -64,7 +66,7 @@ function WebClient({
<FeatureRoute path="/ssh" feature="ssh" node={node}> <FeatureRoute path="/ssh" feature="ssh" node={node}>
<SSHView readonly={!auth.canManageNode} node={node} /> <SSHView readonly={!auth.canManageNode} node={node} />
</FeatureRoute> </FeatureRoute>
<Route path="/serve">{/* TODO */}Share local content</Route> {/* <Route path="/serve">Share local content</Route> */}
<FeatureRoute path="/update" feature="auto-update" node={node}> <FeatureRoute path="/update" feature="auto-update" node={node}>
<UpdatingView <UpdatingView
versionInfo={node.ClientVersion} versionInfo={node.ClientVersion}
@ -72,7 +74,9 @@ function WebClient({
/> />
</FeatureRoute> </FeatureRoute>
<Route> <Route>
<div className="mt-8 card">Page not found</div> <Card className="mt-8">
<EmptyState description="Page not found" />
</Card>
</Route> </Route>
</Switch> </Switch>
</Router> </Router>
@ -100,9 +104,13 @@ function FeatureRoute({
return ( return (
<Route path={path}> <Route path={path}>
{!node.Features[feature] ? ( {!node.Features[feature] ? (
<div className="mt-8 card"> <Card className="mt-8">
{featureDescription(feature)} not available on this device. <EmptyState
</div> description={`${featureDescription(
feature
)} not available on this device.`}
/>
</Card>
) : ( ) : (
children children
)} )}

@ -4,6 +4,7 @@
import React from "react" import React from "react"
import { VersionInfo } from "src/types" import { VersionInfo } from "src/types"
import Button from "src/ui/button" import Button from "src/ui/button"
import Card from "src/ui/card"
import { useLocation } from "wouter" import { useLocation } from "wouter"
export function UpdateAvailableNotification({ export function UpdateAvailableNotification({
@ -14,7 +15,7 @@ export function UpdateAvailableNotification({
const [, setLocation] = useLocation() const [, setLocation] = useLocation()
return ( return (
<div className="card"> <Card>
<h2 className="mb-2"> <h2 className="mb-2">
Update available{" "} Update available{" "}
{details.LatestVersion && `(v${details.LatestVersion})`} {details.LatestVersion && `(v${details.LatestVersion})`}
@ -32,7 +33,7 @@ export function UpdateAvailableNotification({
> >
Update now Update now
</Button> </Button>
</div> </Card>
) )
} }

@ -10,6 +10,7 @@ import NiceIP from "src/components/nice-ip"
import { UpdateAvailableNotification } from "src/components/update-available" import { UpdateAvailableNotification } from "src/components/update-available"
import { NodeData } from "src/types" import { NodeData } from "src/types"
import Button from "src/ui/button" import Button from "src/ui/button"
import Card from "src/ui/card"
import QuickCopy from "src/ui/quick-copy" import QuickCopy from "src/ui/quick-copy"
import { useLocation } from "wouter" import { useLocation } from "wouter"
@ -27,7 +28,7 @@ export default function DeviceDetailsView({
<> <>
<h1 className="mb-10">Device details</h1> <h1 className="mb-10">Device details</h1>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="-mx-5 card"> <Card noPadding className="-mx-5 p-5 details-card">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h1>{node.DeviceName}</h1> <h1>{node.DeviceName}</h1>
@ -49,14 +50,14 @@ export default function DeviceDetailsView({
</Button> </Button>
)} )}
</div> </div>
</div> </Card>
{node.Features["auto-update"] && {node.Features["auto-update"] &&
!readonly && !readonly &&
node.ClientVersion && node.ClientVersion &&
!node.ClientVersion.RunningLatest && ( !node.ClientVersion.RunningLatest && (
<UpdateAvailableNotification details={node.ClientVersion} /> <UpdateAvailableNotification details={node.ClientVersion} />
)} )}
<div className="-mx-5 card"> <Card noPadding className="-mx-5 p-5 details-card">
<h2 className="mb-2">General</h2> <h2 className="mb-2">General</h2>
<table> <table>
<tbody> <tbody>
@ -109,8 +110,8 @@ export default function DeviceDetailsView({
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </Card>
<div className="-mx-5 card"> <Card noPadding className="-mx-5 p-5 details-card">
<h2 className="mb-2">Addresses</h2> <h2 className="mb-2">Addresses</h2>
<table> <table>
<tbody> <tbody>
@ -160,7 +161,7 @@ export default function DeviceDetailsView({
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </Card>
<footer className="text-gray-500 text-sm leading-tight text-center"> <footer className="text-gray-500 text-sm leading-tight text-center">
<Control.AdminContainer node={node}> <Control.AdminContainer node={node}>
Want even more details? Visit{" "} Want even more details? Visit{" "}

@ -9,6 +9,7 @@ import { ReactComponent as Machine } from "src/assets/icons/machine.svg"
import AddressCard from "src/components/address-copy-card" import AddressCard from "src/components/address-copy-card"
import ExitNodeSelector from "src/components/exit-node-selector" import ExitNodeSelector from "src/components/exit-node-selector"
import { NodeData } from "src/types" import { NodeData } from "src/types"
import Card from "src/ui/card"
import { pluralize } from "src/utils/util" import { pluralize } from "src/utils/util"
import { Link, useLocation } from "wouter" import { Link, useLocation } from "wouter"
@ -30,14 +31,16 @@ export default function HomeView({
return ( return (
<div className="mb-12 w-full"> <div className="mb-12 w-full">
<h2 className="mb-3">This device</h2> <h2 className="mb-3">This device</h2>
<div className="-mx-5 card mb-9"> <Card noPadding className="-mx-5 p-5 mb-9">
<div className="flex justify-between items-center text-lg mb-5"> <div className="flex justify-between items-center text-lg mb-5">
<Link className="flex items-center" to="/details"> <Link className="flex items-center" to="/details">
<div className="w-10 h-10 bg-gray-100 rounded-full justify-center items-center inline-flex"> <div className="w-10 h-10 bg-gray-100 rounded-full justify-center items-center inline-flex">
<Machine /> <Machine />
</div> </div>
<div className="ml-3"> <div className="ml-3">
<h1>{node.DeviceName}</h1> <div className="text-gray-800 text-lg font-medium leading-snug">
{node.DeviceName}
</div>
<p className="text-gray-500 text-sm leading-[18.20px] flex items-center gap-2"> <p className="text-gray-500 text-sm leading-[18.20px] flex items-center gap-2">
<span <span
className={cx("w-2 h-2 inline-block rounded-full", { className={cx("w-2 h-2 inline-block rounded-full", {
@ -69,7 +72,7 @@ export default function HomeView({
> >
View device details &rarr; View device details &rarr;
</Link> </Link>
</div> </Card>
<h2 className="mb-3">Settings</h2> <h2 className="mb-3">Settings</h2>
<div className="grid gap-3"> <div className="grid gap-3">
{node.Features["advertise-routes"] && ( {node.Features["advertise-routes"] && (
@ -108,9 +111,7 @@ export default function HomeView({
node.RunningSSHServer node.RunningSSHServer
? { ? {
text: "Running", text: "Running",
icon: ( icon: <div className="w-2 h-2 bg-green-300 rounded-full" />,
<div className="w-2 h-2 bg-emerald-500 rounded-full" />
),
} }
: undefined : undefined
} }
@ -148,37 +149,36 @@ function SettingsCard({
const [, setLocation] = useLocation() const [, setLocation] = useLocation()
return ( return (
<button <button onClick={() => setLocation(link)}>
className={cx("-mx-5 card cursor-pointer", { "pb-4": footer }, className)} <Card noPadding className={cx("-mx-5 p-5", className)}>
onClick={() => setLocation(link)} <div className="flex justify-between items-center">
> <div>
<div className="flex justify-between items-center"> <div className="flex gap-2">
<div> <p className="text-gray-800 font-medium leading-tight mb-2">
<div className="flex gap-2"> {title}
<p className="text-gray-800 font-medium leading-tight mb-2"> </p>
{title} {badge && (
</p> <div className="h-5 px-2 bg-gray-100 rounded-full flex items-center gap-2">
{badge && ( {badge.icon}
<div className="h-5 px-2 bg-gray-100 rounded-full flex items-center gap-2"> <div className="text-gray-500 text-xs font-medium">
{badge.icon} {badge.text}
<div className="text-gray-500 text-xs font-medium"> </div>
{badge.text}
</div> </div>
</div> )}
)} </div>
<p className="text-gray-500 text-sm leading-tight">{body}</p>
</div>
<div>
<ArrowRight className="ml-3" />
</div> </div>
<p className="text-gray-500 text-sm leading-tight">{body}</p>
</div>
<div>
<ArrowRight className="ml-3" />
</div> </div>
</div> {footer && (
{footer && ( <>
<> <hr className="my-3" />
<hr className="my-3" /> <div className="text-gray-500 text-sm leading-tight">{footer}</div>
<div className="text-gray-500 text-sm leading-tight">{footer}</div> </>
</> )}
)} </Card>
</button> </button>
) )
} }

@ -1,6 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS // Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
import React from "react" import React from "react"
import { useAPI } from "src/api" import { useAPI } from "src/api"
import * as Control from "src/components/control-components" import * as Control from "src/components/control-components"
@ -32,36 +33,49 @@ export default function SSHView({
Learn more &rarr; Learn more &rarr;
</a> </a>
</p> </p>
<Card className="-mx-5 p-4"> <Card noPadding className="-mx-5 p-5">
<label className="flex gap-3 items-center"> {!readonly ? (
<Toggle <label className="flex gap-3 items-center">
checked={node.RunningSSHServer} <Toggle
onChange={() => checked={node.RunningSSHServer}
api({ onChange={() =>
action: "update-prefs", api({
data: { action: "update-prefs",
RunSSHSet: true, data: {
RunSSH: !node.RunningSSHServer, RunSSHSet: true,
}, RunSSH: !node.RunningSSHServer,
}) },
} })
disabled={readonly} }
/> />
<div className="text-black text-sm font-medium leading-tight"> <div className="text-black text-sm font-medium leading-tight">
Run Tailscale SSH server Run Tailscale SSH server
</div>
</label>
) : (
<div className="inline-flex items-center gap-3">
<span
className={cx("w-2 h-2 rounded-full", {
"bg-green-300": node.RunningSSHServer,
"bg-gray-300": !node.RunningSSHServer,
})}
/>
{node.RunningSSHServer ? "Running" : "Not running"}
</div> </div>
</label> )}
</Card> </Card>
<Control.AdminContainer {node.RunningSSHServer && (
className="text-gray-500 text-sm leading-tight mt-3" <Control.AdminContainer
node={node} className="text-gray-500 text-sm leading-tight mt-3"
> node={node}
Remember to make sure that the{" "} >
<Control.AdminLink node={node} path="/acls"> Remember to make sure that the{" "}
tailnet policy file <Control.AdminLink node={node} path="/acls">
</Control.AdminLink>{" "} tailnet policy file
allows other devices to SSH into this device. </Control.AdminLink>{" "}
</Control.AdminContainer> allows other devices to SSH into this device.
</Control.AdminContainer>
)}
</> </>
) )
} }

@ -1,6 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS // Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
import React, { useCallback, useMemo, useState } from "react" import React, { useCallback, useMemo, useState } from "react"
import { useAPI } from "src/api" import { useAPI } from "src/api"
import { ReactComponent as CheckCircle } from "src/assets/icons/check-circle.svg" import { ReactComponent as CheckCircle } from "src/assets/icons/check-circle.svg"
@ -21,6 +22,7 @@ export default function SubnetRouterView({
node: NodeData node: NodeData
}) { }) {
const api = useAPI() const api = useAPI()
const [advertisedRoutes, hasRoutes, hasUnapprovedRoutes] = useMemo(() => { const [advertisedRoutes, hasRoutes, hasUnapprovedRoutes] = useMemo(() => {
const routes = node.AdvertisedRoutes || [] const routes = node.AdvertisedRoutes || []
return [routes, routes.length > 0, routes.find((r) => !r.Approved)] return [routes, routes.length > 0, routes.find((r) => !r.Approved)]
@ -30,9 +32,11 @@ export default function SubnetRouterView({
advertisedRoutes.length === 0 && !readonly advertisedRoutes.length === 0 && !readonly
) )
const [inputText, setInputText] = useState<string>("") const [inputText, setInputText] = useState<string>("")
const [postError, setPostError] = useState<string>()
const resetInput = useCallback(() => { const resetInput = useCallback(() => {
setInputText("") setInputText("")
setPostError("")
setInputOpen(false) setInputOpen(false)
}, []) }, [])
@ -52,7 +56,7 @@ export default function SubnetRouterView({
</p> </p>
{!readonly && {!readonly &&
(inputOpen ? ( (inputOpen ? (
<div className="-mx-5 card !border-0 shadow-popover"> <Card noPadding className="-mx-5 p-5 !border-0 shadow-popover">
<p className="font-medium leading-snug mb-3"> <p className="font-medium leading-snug mb-3">
Advertise new routes Advertise new routes
</p> </p>
@ -61,10 +65,19 @@ export default function SubnetRouterView({
className="text-sm" className="text-sm"
placeholder="192.168.0.0/24" placeholder="192.168.0.0/24"
value={inputText} value={inputText}
onChange={(e) => setInputText(e.target.value)} onChange={(e) => {
setPostError("")
setInputText(e.target.value)
}}
/> />
<p className="my-2 h-6 text-gray-500 text-sm leading-tight"> <p
Add multiple routes by providing a comma-separated list. className={cx("my-2 h-6 text-sm leading-tight", {
"text-gray-500": !postError,
"text-red-400": postError,
})}
>
{postError ||
"Add multiple routes by providing a comma-separated list."}
</p> </p>
<div className="flex gap-3"> <div className="flex gap-3">
<Button <Button
@ -78,15 +91,17 @@ export default function SubnetRouterView({
.split(",") .split(",")
.map((r) => ({ Route: r, Approved: false })), .map((r) => ({ Route: r, Approved: false })),
], ],
}).then(resetInput) })
.then(resetInput)
.catch((err: Error) => setPostError(err.message))
} }
disabled={!inputText} disabled={!inputText || postError !== ""}
> >
Advertise {hasRoutes && "new "}routes Advertise {hasRoutes && "new "}routes
</Button> </Button>
{hasRoutes && <Button onClick={resetInput}>Cancel</Button>} {hasRoutes && <Button onClick={resetInput}>Cancel</Button>}
</div> </div>
</div> </Card>
) : ( ) : (
<Button <Button
intent="primary" intent="primary"
@ -99,7 +114,7 @@ export default function SubnetRouterView({
<div className="-mx-5 mt-10"> <div className="-mx-5 mt-10">
{hasRoutes ? ( {hasRoutes ? (
<> <>
<div className="px-5 py-3 bg-white rounded-lg border border-gray-200"> <Card noPadding className="px-5 py-3">
{advertisedRoutes.map((r) => ( {advertisedRoutes.map((r) => (
<div <div
className="flex justify-between items-center pb-2.5 mb-2.5 border-b border-b-gray-200 last:pb-0 last:mb-0 last:border-b-0" className="flex justify-between items-center pb-2.5 mb-2.5 border-b border-b-gray-200 last:pb-0 last:mb-0 last:border-b-0"
@ -141,7 +156,7 @@ export default function SubnetRouterView({
</div> </div>
</div> </div>
))} ))}
</div> </Card>
{hasUnapprovedRoutes && ( {hasUnapprovedRoutes && (
<Control.AdminContainer <Control.AdminContainer
className="mt-3 w-full text-center text-gray-500 text-sm leading-tight" className="mt-3 w-full text-center text-gray-500 text-sm leading-tight"

@ -166,28 +166,25 @@
} }
@layer components { @layer components {
.card { .details-card h1 {
@apply p-5 bg-white rounded-lg border border-gray-200;
}
.card h1 {
@apply text-gray-800 text-lg font-medium leading-snug; @apply text-gray-800 text-lg font-medium leading-snug;
} }
.card h2 { .details-card h2 {
@apply text-gray-500 text-xs font-semibold uppercase tracking-wide; @apply text-gray-500 text-xs font-semibold uppercase tracking-wide;
} }
.card table { .details-card table {
@apply w-full; @apply w-full;
} }
.card tbody { .details-card tbody {
@apply flex flex-col gap-2; @apply flex flex-col gap-2;
} }
.card tr { .details-card tr {
@apply grid grid-flow-col grid-cols-3 gap-2; @apply grid grid-flow-col grid-cols-3 gap-2;
} }
.card td:first-child { .details-card td:first-child {
@apply text-gray-500 text-sm leading-tight truncate; @apply text-gray-500 text-sm leading-tight truncate;
} }
.card td:last-child { .details-card td:last-child {
@apply col-span-2 text-gray-800 text-sm leading-tight; @apply col-span-2 text-gray-800 text-sm leading-tight;
} }

Loading…
Cancel
Save