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:
parent
d852c616c6
commit
d544e80fc1
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue