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

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

@ -10,6 +10,7 @@ import NiceIP from "src/components/nice-ip"
import { UpdateAvailableNotification } from "src/components/update-available"
import { NodeData } from "src/types"
import Button from "src/ui/button"
import Card from "src/ui/card"
import QuickCopy from "src/ui/quick-copy"
import { useLocation } from "wouter"
@ -27,7 +28,7 @@ export default function DeviceDetailsView({
<>
<h1 className="mb-10">Device details</h1>
<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 gap-2">
<h1>{node.DeviceName}</h1>
@ -49,14 +50,14 @@ export default function DeviceDetailsView({
</Button>
)}
</div>
</div>
</Card>
{node.Features["auto-update"] &&
!readonly &&
node.ClientVersion &&
!node.ClientVersion.RunningLatest && (
<UpdateAvailableNotification details={node.ClientVersion} />
)}
<div className="-mx-5 card">
<Card noPadding className="-mx-5 p-5 details-card">
<h2 className="mb-2">General</h2>
<table>
<tbody>
@ -109,8 +110,8 @@ export default function DeviceDetailsView({
</tr>
</tbody>
</table>
</div>
<div className="-mx-5 card">
</Card>
<Card noPadding className="-mx-5 p-5 details-card">
<h2 className="mb-2">Addresses</h2>
<table>
<tbody>
@ -160,7 +161,7 @@ export default function DeviceDetailsView({
</tr>
</tbody>
</table>
</div>
</Card>
<footer className="text-gray-500 text-sm leading-tight text-center">
<Control.AdminContainer node={node}>
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 ExitNodeSelector from "src/components/exit-node-selector"
import { NodeData } from "src/types"
import Card from "src/ui/card"
import { pluralize } from "src/utils/util"
import { Link, useLocation } from "wouter"
@ -30,14 +31,16 @@ export default function HomeView({
return (
<div className="mb-12 w-full">
<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">
<Link className="flex items-center" to="/details">
<div className="w-10 h-10 bg-gray-100 rounded-full justify-center items-center inline-flex">
<Machine />
</div>
<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">
<span
className={cx("w-2 h-2 inline-block rounded-full", {
@ -69,7 +72,7 @@ export default function HomeView({
>
View device details &rarr;
</Link>
</div>
</Card>
<h2 className="mb-3">Settings</h2>
<div className="grid gap-3">
{node.Features["advertise-routes"] && (
@ -108,9 +111,7 @@ export default function HomeView({
node.RunningSSHServer
? {
text: "Running",
icon: (
<div className="w-2 h-2 bg-emerald-500 rounded-full" />
),
icon: <div className="w-2 h-2 bg-green-300 rounded-full" />,
}
: undefined
}
@ -148,37 +149,36 @@ function SettingsCard({
const [, setLocation] = useLocation()
return (
<button
className={cx("-mx-5 card cursor-pointer", { "pb-4": footer }, className)}
onClick={() => setLocation(link)}
>
<div className="flex justify-between items-center">
<div>
<div className="flex gap-2">
<p className="text-gray-800 font-medium leading-tight mb-2">
{title}
</p>
{badge && (
<div className="h-5 px-2 bg-gray-100 rounded-full flex items-center gap-2">
{badge.icon}
<div className="text-gray-500 text-xs font-medium">
{badge.text}
<button onClick={() => setLocation(link)}>
<Card noPadding className={cx("-mx-5 p-5", className)}>
<div className="flex justify-between items-center">
<div>
<div className="flex gap-2">
<p className="text-gray-800 font-medium leading-tight mb-2">
{title}
</p>
{badge && (
<div className="h-5 px-2 bg-gray-100 rounded-full flex items-center gap-2">
{badge.icon}
<div className="text-gray-500 text-xs font-medium">
{badge.text}
</div>
</div>
</div>
)}
)}
</div>
<p className="text-gray-500 text-sm leading-tight">{body}</p>
</div>
<div>
<ArrowRight className="ml-3" />
</div>
<p className="text-gray-500 text-sm leading-tight">{body}</p>
</div>
<div>
<ArrowRight className="ml-3" />
</div>
</div>
{footer && (
<>
<hr className="my-3" />
<div className="text-gray-500 text-sm leading-tight">{footer}</div>
</>
)}
{footer && (
<>
<hr className="my-3" />
<div className="text-gray-500 text-sm leading-tight">{footer}</div>
</>
)}
</Card>
</button>
)
}

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

@ -1,6 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
import React, { useCallback, useMemo, useState } from "react"
import { useAPI } from "src/api"
import { ReactComponent as CheckCircle } from "src/assets/icons/check-circle.svg"
@ -21,6 +22,7 @@ export default function SubnetRouterView({
node: NodeData
}) {
const api = useAPI()
const [advertisedRoutes, hasRoutes, hasUnapprovedRoutes] = useMemo(() => {
const routes = node.AdvertisedRoutes || []
return [routes, routes.length > 0, routes.find((r) => !r.Approved)]
@ -30,9 +32,11 @@ export default function SubnetRouterView({
advertisedRoutes.length === 0 && !readonly
)
const [inputText, setInputText] = useState<string>("")
const [postError, setPostError] = useState<string>()
const resetInput = useCallback(() => {
setInputText("")
setPostError("")
setInputOpen(false)
}, [])
@ -52,7 +56,7 @@ export default function SubnetRouterView({
</p>
{!readonly &&
(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">
Advertise new routes
</p>
@ -61,10 +65,19 @@ export default function SubnetRouterView({
className="text-sm"
placeholder="192.168.0.0/24"
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">
Add multiple routes by providing a comma-separated list.
<p
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>
<div className="flex gap-3">
<Button
@ -78,15 +91,17 @@ export default function SubnetRouterView({
.split(",")
.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
</Button>
{hasRoutes && <Button onClick={resetInput}>Cancel</Button>}
</div>
</div>
</Card>
) : (
<Button
intent="primary"
@ -99,7 +114,7 @@ export default function SubnetRouterView({
<div className="-mx-5 mt-10">
{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) => (
<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"
@ -141,7 +156,7 @@ export default function SubnetRouterView({
</div>
</div>
))}
</div>
</Card>
{hasUnapprovedRoutes && (
<Control.AdminContainer
className="mt-3 w-full text-center text-gray-500 text-sm leading-tight"

@ -166,28 +166,25 @@
}
@layer components {
.card {
@apply p-5 bg-white rounded-lg border border-gray-200;
}
.card h1 {
.details-card h1 {
@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;
}
.card table {
.details-card table {
@apply w-full;
}
.card tbody {
.details-card tbody {
@apply flex flex-col gap-2;
}
.card tr {
.details-card tr {
@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;
}
.card td:last-child {
.details-card td:last-child {
@apply col-span-2 text-gray-800 text-sm leading-tight;
}

Loading…
Cancel
Save