-
- nodeUpdaters.postSubnetRoutes(
- advertisedRoutes
- .map((it) => it.Route)
- .filter((it) => it !== r.Route)
- )
- }
- disabled={readonly}
- >
- Stop advertising…
-
+ {!readonly && (
+
+ nodeUpdaters.postSubnetRoutes(
+ advertisedRoutes
+ .map((it) => it.Route)
+ .filter((it) => it !== r.Route)
+ )
+ }
+ >
+ Stop advertising…
+
+ )}
))}
-
- To approve routes, in the admin console go to{" "}
-
- the machine’s route settings
-
- .
-
+ {hasUnapprovedRoutes && (
+
+ To approve routes, in the admin console go to{" "}
+
+ the machine’s route settings
+
+ .
+
+ )}
>
) : (
-
+
Not advertising any routes
)}
diff --git a/client/web/src/components/views/updating-view.tsx b/client/web/src/components/views/updating-view.tsx
index dc1d692a7..b6c76d072 100644
--- a/client/web/src/components/views/updating-view.tsx
+++ b/client/web/src/components/views/updating-view.tsx
@@ -10,8 +10,9 @@ import {
useInstallUpdate,
VersionInfo,
} from "src/hooks/self-update"
+import Button from "src/ui/button"
import Spinner from "src/ui/spinner"
-import { Link } from "wouter"
+import { useLocation } from "wouter"
/**
* UpdatingView is rendered when the user initiates a Tailscale update, and
@@ -24,6 +25,7 @@ export function UpdatingView({
versionInfo?: VersionInfo
currentVersion: string
}) {
+ const [, setLocation] = useLocation()
const { updateState, updateLog } = useInstallUpdate(
currentVersion,
versionInfo
@@ -51,9 +53,13 @@ export function UpdatingView({
: null}
.
-
+ setLocation("/")}
+ >
Log in to access
-
+
>
) : updateState === UpdateState.UpToDate ? (
<>
@@ -63,9 +69,13 @@ export function UpdatingView({
You are already running Tailscale {currentVersion}, which is the
newest version available.
-
+ setLocation("/")}
+ >
Return
-
+
>
) : (
/* TODO(naman,sonia): Figure out the body copy and design for this view. */
@@ -79,9 +89,13 @@ export function UpdatingView({
: null}{" "}
failed.
-
+ setLocation("/")}
+ >
Return
-
+
>
)}
diff --git a/client/web/src/index.css b/client/web/src/index.css
index 1575e2205..b6da3af80 100644
--- a/client/web/src/index.css
+++ b/client/web/src/index.css
@@ -175,14 +175,20 @@
.card h2 {
@apply text-gray-500 text-xs font-semibold uppercase tracking-wide;
}
+ .card table {
+ @apply w-full;
+ }
.card tbody {
@apply flex flex-col gap-2;
}
+ .card tr {
+ @apply grid grid-flow-col grid-cols-3 gap-2;
+ }
.card td:first-child {
- @apply w-40 text-gray-500 text-sm leading-tight flex-shrink-0;
+ @apply text-gray-500 text-sm leading-tight truncate;
}
.card td:last-child {
- @apply text-gray-800 text-sm leading-tight;
+ @apply col-span-2 text-gray-800 text-sm leading-tight truncate;
}
.description {
@@ -286,6 +292,39 @@
@apply w-[0.675rem] translate-x-[0.55rem];
}
+ /**
+ * .button encapsulates all the base button styles we use across the app.
+ */
+
+ .button {
+ @apply relative inline-flex flex-nowrap items-center justify-center font-medium py-2 px-4 rounded-md border border-transparent text-center whitespace-nowrap;
+ transition-property: background-color, border-color, color, box-shadow;
+ transition-duration: 120ms;
+ box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04);
+ }
+ .button:focus-visible {
+ @apply outline-none ring;
+ }
+ .button:disabled {
+ @apply pointer-events-none select-none;
+ }
+
+ .button-group {
+ @apply whitespace-nowrap;
+ }
+
+ .button-group .button {
+ @apply min-w-[60px];
+ }
+
+ .button-group .button:not(:first-child) {
+ @apply rounded-l-none;
+ }
+
+ .button-group .button:not(:last-child) {
+ @apply rounded-r-none border-r-0;
+ }
+
/**
* .input defines default text input field styling. These styles should
* correspond to .button, sharing a similar height and rounding, since .input
@@ -321,6 +360,104 @@
.input-error {
@apply border-red-200;
}
+
+ /**
+ * .loading-dots creates a set of three dots that pulse for indicating loading
+ * states where a more horizontal appearance is helpful.
+ */
+
+ .loading-dots {
+ @apply inline-flex items-center;
+ }
+
+ .loading-dots span {
+ @apply inline-block w-[0.35rem] h-[0.35rem] rounded-full bg-current mx-[0.15em];
+ animation-name: loading-dots-blink;
+ animation-duration: 1.4s;
+ animation-iteration-count: infinite;
+ animation-fill-mode: both;
+ }
+
+ .loading-dots span:nth-child(2) {
+ animation-delay: 200ms;
+ }
+
+ .loading-dots span:nth-child(3) {
+ animation-delay: 400ms;
+ }
+
+ @keyframes loading-dots-blink {
+ 0% {
+ opacity: 0.2;
+ }
+ 20% {
+ opacity: 1;
+ }
+ 100% {
+ opacity: 0.2;
+ }
+ }
+
+ /**
+ * .spinner creates a circular animated spinner, most often used to indicate a
+ * loading state. The .spinner element must define a width, height, and
+ * border-width for the spinner to apply.
+ */
+
+ @keyframes spin {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+ }
+
+ .spinner {
+ @apply border-transparent border-t-current border-l-current rounded-full;
+ animation: spin 700ms linear infinite;
+ }
+
+ /**
+ * .link applies standard styling to links across the app. By default we unstyle
+ * all anchor tags. While this might sound crazy for a website, it's _very_
+ * helpful in an app, since anchor tags can be used to wrap buttons, icons,
+ * and all manner of UI component. As a result, all anchor tags intended to look
+ * like links should have a .link class.
+ */
+
+ .link {
+ @apply text-text-primary;
+ }
+
+ .link:hover,
+ .link:active {
+ @apply text-blue-700;
+ }
+
+ .link-destructive {
+ @apply text-text-danger;
+ }
+
+ .link-destructive:hover,
+ .link-destructive:active {
+ @apply text-red-700;
+ }
+
+ .link-fade {
+ }
+
+ .link-fade:hover {
+ @apply opacity-75;
+ }
+
+ .link-underline {
+ @apply underline;
+ }
+
+ .link-underline:hover {
+ @apply opacity-75;
+ }
}
@layer utilities {
@@ -328,150 +465,3 @@
@apply h-[2.375rem];
}
}
-
-/**
- * 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;
-}
-
-/**
- * .spinner creates a circular animated spinner, most often used to indicate a
- * loading state. The .spinner element must define a width, height, and
- * border-width for the spinner to apply.
- */
-
-@keyframes spin {
- 0% {
- transform: rotate(0deg);
- }
- 100% {
- transform: rotate(360deg);
- }
-}
-
-.spinner {
- @apply border-transparent border-t-current border-l-current rounded-full;
- animation: spin 700ms linear infinite;
-}
diff --git a/client/web/src/ui/button.tsx b/client/web/src/ui/button.tsx
index aaee82c64..18dc2939f 100644
--- a/client/web/src/ui/button.tsx
+++ b/client/web/src/ui/button.tsx
@@ -2,32 +2,148 @@
// SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
-import React, { ButtonHTMLAttributes } from "react"
+import React, { HTMLProps } from "react"
+import LoadingDots from "src/ui/loading-dots"
type Props = {
- intent?: "primary" | "secondary"
-} & ButtonHTMLAttributes
+ type?: "button" | "submit" | "reset"
+ sizeVariant?: "input" | "small" | "medium" | "large"
+ /**
+ * variant is the visual style of the button. By default, this is a filled
+ * button. For a less prominent button, use minimal.
+ */
+ variant?: Variant
+ /**
+ * intent describes the semantic meaning of the button's action. For
+ * dangerous or destructive actions, use danger. For actions that should
+ * be the primary focus, use primary.
+ */
+ intent?: Intent
-export default function Button(props: Props) {
- const { intent = "primary", className, disabled, children, ...rest } = props
+ active?: boolean
+ /**
+ * prefixIcon is an icon or piece of content shown at the start of a button.
+ */
+ prefixIcon?: React.ReactNode
+ /**
+ * suffixIcon is an icon or piece of content shown at the end of a button.
+ */
+ suffixIcon?: React.ReactNode
+ /**
+ * loading displays a loading indicator inside the button when set to true.
+ * The sizing of the button is not affected by this prop.
+ */
+ loading?: boolean
+ /**
+ * iconOnly indicates that the button contains only an icon. This is used to
+ * adjust styles to be appropriate for an icon-only button.
+ */
+ iconOnly?: boolean
+ /**
+ * textAlign align the text center or left. If left aligned, any icons will
+ * move to the sides of the button.
+ */
+ textAlign?: "center" | "left"
+} & HTMLProps
+
+export type Variant = "filled" | "minimal"
+export type Intent = "base" | "primary" | "warning" | "danger" | "black"
+
+const Button = React.forwardRef((props, ref) => {
+ const {
+ className,
+ variant = "filled",
+ intent = "base",
+ sizeVariant = "large",
+ disabled,
+ children,
+ loading,
+ active,
+ iconOnly,
+ prefixIcon,
+ suffixIcon,
+ textAlign,
+ ...rest
+ } = props
+
+ const hasIcon = Boolean(prefixIcon || suffixIcon)
return (
- {children}
+ {prefixIcon && {prefixIcon}}
+ {loading && (
+
+ )}
+ {children && (
+
+ {children}
+
+ )}
+ {suffixIcon && {suffixIcon}}
)
-}
+})
+
+export default Button
diff --git a/client/web/src/ui/collapsible.tsx b/client/web/src/ui/collapsible.tsx
index e954ce008..409464c1e 100644
--- a/client/web/src/ui/collapsible.tsx
+++ b/client/web/src/ui/collapsible.tsx
@@ -24,7 +24,7 @@ export default function Collapsible(props: CollapsibleProps) {
onOpenChange?.(open)
}}
>
-
+
diff --git a/client/web/src/ui/loading-dots.tsx b/client/web/src/ui/loading-dots.tsx
new file mode 100644
index 000000000..6b47552a9
--- /dev/null
+++ b/client/web/src/ui/loading-dots.tsx
@@ -0,0 +1,23 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+import cx from "classnames"
+import React, { HTMLAttributes } from "react"
+
+type Props = HTMLAttributes
+
+/**
+ * LoadingDots provides a set of horizontal dots to indicate a loading state.
+ * These dots are helpful in horizontal contexts (like buttons) where a spinner
+ * doesn't fit as well.
+ */
+export default function LoadingDots(props: Props) {
+ const { className, ...rest } = props
+ return (
+
+
+
+
+
+ )
+}
diff --git a/client/web/src/util.ts b/client/web/src/util.ts
index 57c46d75e..fa5cbe3b8 100644
--- a/client/web/src/util.ts
+++ b/client/web/src/util.ts
@@ -8,3 +8,14 @@
export function assertNever(a: never): never {
return a
}
+
+/**
+ * pluralize is a very simple function that returns either
+ * the singular or plural form of a string based on the given
+ * quantity.
+ *
+ * TODO: Ideally this would use a localized pluralization.
+ */
+export function pluralize(signular: string, plural: string, qty: number) {
+ return qty === 1 ? signular : plural
+}