mirror of
https://github.com/tailscale/tailscale.git
synced 2025-04-16 19:51:41 +00:00

This commit makes some restructural changes to how we handle api posting from the web client frontend. Now that we're using SWR, we have less of a need for hooks like useNodeData that return a useSWR response alongside some mutation callbacks. SWR makes it easy to mutate throughout the UI without needing access to the original data state in order to reflect updates. So, we can fetch data without having to tie it to post callbacks that have to be passed around through components. In an effort to consolidate our posting endpoints, and make it easier to add more api handlers cleanly in the future, this change introduces a new `useAPI` hook that returns a single `api` callback that can make any changes from any component in the UI. The hook itself handles using SWR to mutate the relevant data keys, which get globally reflected throughout the UI. As a concurrent cleanup, node types are also moved to their own types.ts file, to consolidate data types across the app. Updates #10261 Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
153 lines
4.3 KiB
TypeScript
153 lines
4.3 KiB
TypeScript
// Copyright (c) Tailscale Inc & AUTHORS
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
import React from "react"
|
|
import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg"
|
|
import LoginToggle from "src/components/login-toggle"
|
|
import DeviceDetailsView from "src/components/views/device-details-view"
|
|
import HomeView from "src/components/views/home-view"
|
|
import LoginView from "src/components/views/login-view"
|
|
import SSHView from "src/components/views/ssh-view"
|
|
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 LoadingDots from "src/ui/loading-dots"
|
|
import useSWR from "swr"
|
|
import { Link, Route, Router, Switch, useLocation } from "wouter"
|
|
|
|
export default function App() {
|
|
const { data: auth, loading: loadingAuth, newSession } = useAuth()
|
|
|
|
return (
|
|
<main className="min-w-sm max-w-lg mx-auto py-4 sm:py-14 px-5">
|
|
{loadingAuth || !auth ? (
|
|
<LoadingView />
|
|
) : (
|
|
<WebClient auth={auth} newSession={newSession} />
|
|
)}
|
|
</main>
|
|
)
|
|
}
|
|
|
|
function WebClient({
|
|
auth,
|
|
newSession,
|
|
}: {
|
|
auth: AuthResponse
|
|
newSession: () => Promise<void>
|
|
}) {
|
|
const { data: node } = useSWR<NodeData>("/data")
|
|
|
|
return !node ? (
|
|
<LoadingView />
|
|
) : node.Status === "NeedsLogin" ||
|
|
node.Status === "NoState" ||
|
|
node.Status === "Stopped" ? (
|
|
// Client not on a tailnet, render login.
|
|
<LoginView data={node} />
|
|
) : (
|
|
// Otherwise render the new web client.
|
|
<>
|
|
<Router base={node.URLPrefix}>
|
|
<Header node={node} auth={auth} newSession={newSession} />
|
|
<Switch>
|
|
<Route path="/">
|
|
<HomeView readonly={!auth.canManageNode} node={node} />
|
|
</Route>
|
|
<Route path="/details">
|
|
<DeviceDetailsView readonly={!auth.canManageNode} node={node} />
|
|
</Route>
|
|
<FeatureRoute path="/subnets" feature="advertise-routes" node={node}>
|
|
<SubnetRouterView readonly={!auth.canManageNode} node={node} />
|
|
</FeatureRoute>
|
|
<FeatureRoute path="/ssh" feature="ssh" node={node}>
|
|
<SSHView readonly={!auth.canManageNode} node={node} />
|
|
</FeatureRoute>
|
|
<Route path="/serve">{/* TODO */}Share local content</Route>
|
|
<FeatureRoute path="/update" feature="auto-update" node={node}>
|
|
<UpdatingView
|
|
versionInfo={node.ClientVersion}
|
|
currentVersion={node.IPNVersion}
|
|
/>
|
|
</FeatureRoute>
|
|
<Route>
|
|
<div className="mt-8 card">Page not found</div>
|
|
</Route>
|
|
</Switch>
|
|
</Router>
|
|
</>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* FeatureRoute renders a Route component,
|
|
* but only displays the child view if the specified feature is
|
|
* available for use on this node's platform. If not available,
|
|
* a not allowed view is rendered instead.
|
|
*/
|
|
function FeatureRoute({
|
|
path,
|
|
node,
|
|
feature,
|
|
children,
|
|
}: {
|
|
path: string
|
|
node: NodeData
|
|
feature: Feature
|
|
children: React.ReactNode
|
|
}) {
|
|
return (
|
|
<Route path={path}>
|
|
{!node.Features[feature] ? (
|
|
<div className="mt-8 card">
|
|
{featureDescription(feature)} not available on this device.
|
|
</div>
|
|
) : (
|
|
children
|
|
)}
|
|
</Route>
|
|
)
|
|
}
|
|
|
|
function Header({
|
|
node,
|
|
auth,
|
|
newSession,
|
|
}: {
|
|
node: NodeData
|
|
auth: AuthResponse
|
|
newSession: () => Promise<void>
|
|
}) {
|
|
const [loc] = useLocation()
|
|
|
|
return (
|
|
<>
|
|
<div className="flex flex-wrap gap-4 justify-between items-center mb-9 md:mb-12">
|
|
<Link to="/" className="flex gap-3 overflow-hidden">
|
|
<TailscaleIcon />
|
|
<div className="inline text-gray-800 text-lg font-medium leading-snug truncate">
|
|
{node.DomainName}
|
|
</div>
|
|
</Link>
|
|
<LoginToggle node={node} auth={auth} newSession={newSession} />
|
|
</div>
|
|
{loc !== "/" && loc !== "/update" && (
|
|
<Link to="/" className="link font-medium block mb-2">
|
|
← Back to {node.DeviceName}
|
|
</Link>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* LoadingView fills its container with small animated loading dots
|
|
* in the center.
|
|
*/
|
|
export function LoadingView() {
|
|
return (
|
|
<LoadingDots className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" />
|
|
)
|
|
}
|