client/web: populate device details view

Fills /details page with real values, passed back from the /data
endpoint.

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
This commit is contained in:
Sonia Appasamy 2023-11-08 17:33:27 -05:00 committed by Sonia Appasamy
parent d852c616c6
commit d544e80fc1
6 changed files with 182 additions and 53 deletions

View File

@ -0,0 +1,25 @@
import cx from "classnames"
import React from "react"
import Badge from "src/ui/badge"
/**
* ACLTag handles the display of an ACL tag.
*/
export default function ACLTag({
tag,
className,
}: {
tag: string
className?: string
}) {
return (
<Badge
variant="status"
color="outline"
className={cx("flex text-xs items-center", className)}
>
<span className="font-medium">tag:</span>
<span className="text-gray-500">{tag.replace("tag:", "")}</span>
</Badge>
)
}

View File

@ -1,8 +1,13 @@
import cx from "classnames"
import React from "react"
import { apiFetch } from "src/api"
import { NodeData } from "src/hooks/node-data"
import ProfilePic from "src/ui/profile-pic"
import { useLocation } from "wouter"
import ACLTag from "../acl-tag"
export default function DeviceDetailsView({ node }: { node: NodeData }) {
const [, setLocation] = useLocation()
return (
<div>
<h1 className="mb-10">Device details</h1>
@ -11,37 +16,36 @@ export default function DeviceDetailsView({ node }: { node: NodeData }) {
<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
className={cx("w-2.5 h-2.5 rounded-full", {
"bg-emerald-500": node.Status === "Running",
"bg-gray-300": node.Status !== "Running",
})}
/>
</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
className="px-3 py-2 bg-stone-50 rounded shadow border border-stone-200 text-neutral-800 text-sm font-medium"
onClick={() =>
apiFetch("/local/v0/logout", "POST")
.then(() => setLocation("/"))
.catch((err) => alert("Logout failed: " + err.message))
}
>
Disconnect
</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>
<tr className="flex">
<td>Managed by</td>
<td>{node.Profile.DisplayName}</td>
<td className="flex gap-1 flex-wrap">
{node.IsTagged
? node.Tags.map((t) => <ACLTag key={t} tag={t} />)
: node.Profile.DisplayName}
</td>
</tr>
<tr>
<td>Machine name</td>
@ -49,11 +53,11 @@ export default function DeviceDetailsView({ node }: { node: NodeData }) {
</tr>
<tr>
<td>OS</td>
<td>MacOS</td>
<td>{node.OS}</td>
</tr>
<tr>
<td>ID</td>
<td>nPKyyg3CNTRL</td>
<td>{node.ID}</td>
</tr>
<tr>
<td>Tailscale version</td>
@ -61,7 +65,12 @@ export default function DeviceDetailsView({ node }: { node: NodeData }) {
</tr>
<tr>
<td>Key expiry</td>
<td>3 months from now</td>
<td>
{node.KeyExpired
? "Expired"
: // TODO: present as relative expiry (e.g. "5 months from now")
new Date(node.KeyExpiry).toLocaleString()}
</td>
</tr>
</tbody>
</table>
@ -76,7 +85,7 @@ export default function DeviceDetailsView({ node }: { node: NodeData }) {
</tr>
<tr>
<td>Tailscale IPv6</td>
<td>fd7a:115c:a1e0:ab12:4843:cd96:627a:f179</td>
<td>{node.IPv6}</td>
</tr>
<tr>
<td>Short domain</td>
@ -84,7 +93,9 @@ export default function DeviceDetailsView({ node }: { node: NodeData }) {
</tr>
<tr>
<td>Full domain</td>
<td>{node.DeviceName}.corp.ts.net</td>
<td>
{node.DeviceName}.{node.TailnetName}
</td>
</tr>
</tbody>
</table>

View File

@ -3,9 +3,14 @@ import { apiFetch, setUnraidCsrfToken } from "src/api"
export type NodeData = {
Profile: UserProfile
Status: string
Status: NodeState
DeviceName: string
OS: string
IP: string
IPv6: string
ID: string
KeyExpiry: string
KeyExpired: boolean
AdvertiseExitNode: boolean
AdvertiseRoutes: string
LicensesURL: string
@ -16,10 +21,21 @@ export type NodeData = {
UnraidToken: string
IPNVersion: string
URLPrefix: string
TailnetName: string
IsTagged: boolean
Tags: string[]
DebugMode: "" | "login" | "full" // empty when not running in any debug mode
}
type NodeState =
| "NoState"
| "NeedsLogin"
| "NeedsMachineAuth"
| "Stopped"
| "Starting"
| "Running"
export type UserProfile = {
LoginName: string
DisplayName: string

View File

@ -26,7 +26,7 @@
@apply flex flex-col gap-2;
}
.card td:first-child {
@apply w-40 text-neutral-500 text-sm leading-tight;
@apply w-40 text-neutral-500 text-sm leading-tight flex-shrink-0;
}
.card td:last-child {
@apply text-neutral-800 text-sm leading-tight;

View File

@ -0,0 +1,48 @@
import cx from "classnames"
import React, { HTMLAttributes } from "react"
export type BadgeColor =
| "blue"
| "green"
| "red"
| "orange"
| "yellow"
| "gray"
| "outline"
type Props = {
variant: "tag" | "status"
color: BadgeColor
} & HTMLAttributes<HTMLDivElement>
export default function Badge(props: Props) {
const { className, color, variant, ...rest } = props
return (
<div
className={cx(
"inline-flex items-center align-middle justify-center font-medium",
{
"border border-gray-200 bg-gray-200 text-gray-600": color === "gray",
"border border-green-50 bg-green-50 text-green-600":
color === "green",
"border border-blue-50 bg-blue-50 text-blue-600": color === "blue",
"border border-orange-50 bg-orange-50 text-orange-600":
color === "orange",
"border border-yellow-50 bg-yellow-50 text-yellow-600":
color === "yellow",
"border border-red-50 bg-red-50 text-red-600": color === "red",
"border border-gray-300 bg-white": color === "outline",
"rounded-full px-2 py-1 leading-none": variant === "status",
"rounded-sm px-1": variant === "tag",
},
className
)}
{...rest}
/>
)
}
Badge.defaultProps = {
color: "gray",
}

View File

@ -490,21 +490,35 @@ func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
}
type nodeData struct {
Profile tailcfg.UserProfile
Status string
DeviceName string
IP string
ID tailcfg.StableNodeID
Status string
DeviceName string
TailnetName string // TLS cert name
IP string // IPv4
IPv6 string
OS string
IPNVersion string
Profile tailcfg.UserProfile
IsTagged bool
Tags []string
KeyExpiry string // time.RFC3339
KeyExpired bool
TUNMode bool
IsSynology bool
DSMVersion int // 6 or 7, if IsSynology=true
IsUnraid bool
UnraidToken string
URLPrefix string // if set, the URL prefix the client is served behind
AdvertiseExitNode bool
AdvertiseRoutes string
LicensesURL string
TUNMode bool
IsSynology bool
DSMVersion int // 6 or 7, if IsSynology=true
IsUnraid bool
UnraidToken string
IPNVersion string
DebugMode string // empty when not running in any debug mode
URLPrefix string // if set, the URL prefix the client is served behind
LicensesURL string
DebugMode string // empty when not running in any debug mode
}
func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
@ -518,9 +532,6 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
profile := st.User[st.Self.UserID]
deviceName := strings.Split(st.Self.DNSName, ".")[0]
versionShort := strings.Split(st.Version, "-")[0]
var debugMode string
if s.mode == ManageServerMode {
debugMode = "full"
@ -528,19 +539,40 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
debugMode = "login"
}
data := &nodeData{
Profile: profile,
ID: st.Self.ID,
Status: st.BackendState,
DeviceName: deviceName,
LicensesURL: licenses.LicensesURL(),
DeviceName: strings.Split(st.Self.DNSName, ".")[0],
TailnetName: st.CurrentTailnet.MagicDNSSuffix,
OS: st.Self.OS,
IPNVersion: strings.Split(st.Version, "-")[0],
Profile: st.User[st.Self.UserID],
IsTagged: st.Self.IsTagged(),
KeyExpired: st.Self.Expired,
TUNMode: st.TUN,
IsSynology: distro.Get() == distro.Synology || envknob.Bool("TS_FAKE_SYNOLOGY"),
DSMVersion: distro.DSMVersion(),
IsUnraid: distro.Get() == distro.Unraid,
UnraidToken: os.Getenv("UNRAID_CSRF_TOKEN"),
IPNVersion: versionShort,
URLPrefix: strings.TrimSuffix(s.pathPrefix, "/"),
LicensesURL: licenses.LicensesURL(),
DebugMode: debugMode, // TODO(sonia,will): just pass back s.mode directly?
}
for _, ip := range st.TailscaleIPs {
if ip.Is4() {
data.IP = ip.String()
} else if ip.Is6() {
data.IPv6 = ip.String()
}
if data.IP != "" && data.IPv6 != "" {
break
}
}
if st.Self.Tags != nil {
data.Tags = st.Self.Tags.AsSlice()
}
if st.Self.KeyExpiry != nil {
data.KeyExpiry = st.Self.KeyExpiry.Format(time.RFC3339)
}
for _, r := range prefs.AdvertiseRoutes {
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
data.AdvertiseExitNode = true
@ -551,9 +583,6 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
data.AdvertiseRoutes += r.String()
}
}
if len(st.TailscaleIPs) != 0 {
data.IP = st.TailscaleIPs[0].String()
}
writeJSON(w, *data)
}