diff --git a/client/tailscale/localclient.go b/client/tailscale/localclient.go index e5d42dd9c..73e93dfbb 100644 --- a/client/tailscale/localclient.go +++ b/client/tailscale/localclient.go @@ -1394,6 +1394,21 @@ func (lc *LocalClient) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) }, nil } +// CheckUpdate returns a tailcfg.ClientVersion indicating whether or not an update is available +// to be installed via the LocalAPI. In case the LocalAPI can't install updates, it returns a +// ClientVersion that says that we are up to date. +func (lc *LocalClient) CheckUpdate(ctx context.Context) (*tailcfg.ClientVersion, error) { + body, err := lc.get200(ctx, "/localapi/v0/update/check") + if err != nil { + return nil, err + } + cv, err := decodeJSON[tailcfg.ClientVersion](body) + if err != nil { + return nil, err + } + return &cv, nil +} + // IPNBusWatcher is an active subscription (watch) of the local tailscaled IPN bus. // It's returned by LocalClient.WatchIPNBus. // diff --git a/client/web/src/components/app.tsx b/client/web/src/components/app.tsx index 1f2f9d4f6..c9bf441be 100644 --- a/client/web/src/components/app.tsx +++ b/client/web/src/components/app.tsx @@ -6,6 +6,7 @@ import HomeView from "src/components/views/home-view" import LegacyClientView from "src/components/views/legacy-client-view" import LoginClientView from "src/components/views/login-client-view" import SSHView from "src/components/views/ssh-view" +import { UpdatingView } from "src/components/views/updating-view" import useAuth, { AuthResponse } from "src/hooks/auth" import useNodeData, { NodeData } from "src/hooks/node-data" import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg" @@ -81,6 +82,12 @@ function WebClient({ /> {/* TODO */}Share local content + + +

Page not found

@@ -112,7 +119,7 @@ function Header({ - {loc !== "/" && ( + {loc !== "/" && loc !== "/update" && ( +

+ Update available{" "} + {details.LatestVersion && `(v${details.LatestVersion})`} +

+

+ {details.LatestVersion + ? `Version ${details.LatestVersion}` + : "A new update"}{" "} + is now available. +

+ + Update now + + + ) +} + +// isStableTrack takes a Tailscale version string +// of form X.Y.Z (or vX.Y.Z) and returns whether +// it is a stable release (even value of Y) +// or unstable (odd value of Y). +// eg. isStableTrack("1.48.0") === true +// eg. isStableTrack("1.49.112") === false +function isStableTrack(ver: string): boolean { + const middle = ver.split(".")[1] + if (middle && Number(middle) % 2 === 0) { + return true + } + return false +} + +export function ChangelogText({ version }: { version?: string }) { + if (!version || !isStableTrack(version)) { + return null + } + return ( + <> + Check out the{" "} + + release notes + {" "} + to find out what's new! + + ) +} diff --git a/client/web/src/components/views/device-details-view.tsx b/client/web/src/components/views/device-details-view.tsx index 7942d770c..d792372fe 100644 --- a/client/web/src/components/views/device-details-view.tsx +++ b/client/web/src/components/views/device-details-view.tsx @@ -1,6 +1,7 @@ import cx from "classnames" import React from "react" import { apiFetch } from "src/api" +import { UpdateAvailableNotification } from "src/components/update-available" import { NodeData } from "src/hooks/node-data" import { useLocation } from "wouter" import ACLTag from "../acl-tag" @@ -45,6 +46,11 @@ export default function DeviceDetailsView({ + {node.ClientVersion && + !node.ClientVersion.RunningLatest && + !readonly && ( + + )}

General

diff --git a/client/web/src/components/views/updating-view.tsx b/client/web/src/components/views/updating-view.tsx new file mode 100644 index 000000000..8abd43bda --- /dev/null +++ b/client/web/src/components/views/updating-view.tsx @@ -0,0 +1,90 @@ +import React from "react" +import { ChangelogText } from "src/components/update-available" +import { + UpdateState, + useInstallUpdate, + VersionInfo, +} from "src/hooks/self-update" +import { ReactComponent as CheckCircleIcon } from "src/icons/check-circle.svg" +import { ReactComponent as XCircleIcon } from "src/icons/x-circle.svg" +import Spinner from "src/ui/spinner" +import { Link } from "wouter" + +/** + * UpdatingView is rendered when the user initiates a Tailscale update, and + * the update is in-progress, failed, or completed. + */ +export function UpdatingView({ + versionInfo, + currentVersion, +}: { + versionInfo?: VersionInfo + currentVersion: string +}) { + const { updateState, updateLog } = useInstallUpdate( + currentVersion, + versionInfo + ) + return ( + <> +
+ {updateState === UpdateState.InProgress ? ( + <> + +

Update in progress

+

+ The update shouldn't take more than a couple of minutes. Once it's + completed, you will be asked to log in again. +

+ + ) : updateState === UpdateState.Complete ? ( + <> + +

Update complete!

+

+ You updated Tailscale + {versionInfo && versionInfo.LatestVersion + ? ` to ${versionInfo.LatestVersion}` + : null} + . +

+ + Log in to access + + + ) : updateState === UpdateState.UpToDate ? ( + <> + +

Up to date!

+

+ You are already running Tailscale {currentVersion}, which is the + newest version available. +

+ + Return + + + ) : ( + /* TODO(naman,sonia): Figure out the body copy and design for this view. */ + <> + +

Update failed

+

+ Update + {versionInfo && versionInfo.LatestVersion + ? ` to ${versionInfo.LatestVersion}` + : null}{" "} + failed. +

+ + Return + + + )} +
+          {updateLog}
+        
+
+ + ) +} diff --git a/client/web/src/hooks/node-data.ts b/client/web/src/hooks/node-data.ts index 434ba02e4..c1dfee607 100644 --- a/client/web/src/hooks/node-data.ts +++ b/client/web/src/hooks/node-data.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from "react" import { apiFetch, setUnraidCsrfToken } from "src/api" +import { VersionInfo } from "src/hooks/self-update" export type NodeData = { Profile: UserProfile @@ -20,6 +21,7 @@ export type NodeData = { IsUnraid: boolean UnraidToken: string IPNVersion: string + ClientVersion?: VersionInfo URLPrefix: string DomainName: string TailnetName: string diff --git a/client/web/src/hooks/self-update.ts b/client/web/src/hooks/self-update.ts new file mode 100644 index 000000000..8c12a3709 --- /dev/null +++ b/client/web/src/hooks/self-update.ts @@ -0,0 +1,135 @@ +import { useCallback, useEffect, useState } from "react" +import { apiFetch } from "src/api" + +// this type is deserialized from tailcfg.ClientVersion, +// so it should not include fields not included in that type. +export type VersionInfo = { + RunningLatest: boolean + LatestVersion?: string +} + +// see ipnstate.UpdateProgress +export type UpdateProgress = { + status: "UpdateFinished" | "UpdateInProgress" | "UpdateFailed" + message: string + version: string +} + +export enum UpdateState { + UpToDate, + Available, + InProgress, + Complete, + Failed, +} + +// useInstallUpdate initiates and tracks a Tailscale self-update via the LocalAPI, +// and returns state messages showing the progress of the update. +export function useInstallUpdate(currentVersion: string, cv?: VersionInfo) { + if (!cv) { + return { + updateState: UpdateState.UpToDate, + updateLog: "", + } + } + + const [updateState, setUpdateState] = useState( + cv.RunningLatest ? UpdateState.UpToDate : UpdateState.Available + ) + + const [updateLog, setUpdateLog] = useState("") + + const appendUpdateLog = useCallback( + (msg: string) => { + setUpdateLog(updateLog + msg + "\n") + }, + [updateLog, setUpdateLog] + ) + + useEffect(() => { + if (updateState !== UpdateState.Available) { + // useEffect cleanup function + return () => {} + } + + setUpdateState(UpdateState.InProgress) + + apiFetch("/local/v0/update/install", "POST").catch((err) => { + console.error(err) + setUpdateState(UpdateState.Failed) + }) + + let tsAwayForPolls = 0 + let updateMessagesRead = 0 + + let timer = 0 + + function poll() { + apiFetch("/local/v0/update/progress", "GET") + .then((res) => res.json()) + .then((res: UpdateProgress[]) => { + // res contains a list of UpdateProgresses that is strictly increasing + // in size, so updateMessagesRead keeps track (across calls of poll()) + // of how many of those we have already read. This is why it is not + // initialized to zero here and we don't just use res.forEach() + for (; updateMessagesRead < res.length; ++updateMessagesRead) { + const up = res[updateMessagesRead] + if (up.status === "UpdateFailed") { + setUpdateState(UpdateState.Failed) + if (up.message) appendUpdateLog("ERROR: " + up.message) + return + } + + if (up.status === "UpdateFinished") { + // if update finished and tailscaled did not go away (ie. did not restart), + // then the version being the same might not be an error, it might just require + // the user to restart Tailscale manually (this is required in some cases in the + // clientupdate package). + if (up.version === currentVersion && tsAwayForPolls > 0) { + setUpdateState(UpdateState.Failed) + appendUpdateLog( + "ERROR: Update failed, still running Tailscale " + up.version + ) + if (up.message) appendUpdateLog("ERROR: " + up.message) + } else { + setUpdateState(UpdateState.Complete) + if (up.message) appendUpdateLog("INFO: " + up.message) + } + return + } + + setUpdateState(UpdateState.InProgress) + if (up.message) appendUpdateLog("INFO: " + up.message) + } + + // If we have gone through the entire loop without returning out of the function, + // the update is still in progress. So we want to poll again for further status + // updates. + timer = setTimeout(poll, 1000) + }) + .catch((err) => { + ++tsAwayForPolls + if (tsAwayForPolls >= 5 * 60) { + setUpdateState(UpdateState.Failed) + appendUpdateLog( + "ERROR: tailscaled went away but did not come back!" + ) + appendUpdateLog("ERROR: last error received:") + appendUpdateLog(err.toString()) + } else { + timer = setTimeout(poll, 1000) + } + }) + } + + poll() + + // useEffect cleanup function + return () => { + if (timer) clearTimeout(timer) + timer = 0 + } + }, []) + + return { updateState, updateLog } +} diff --git a/client/web/src/icons/arrow-up-circle.svg b/client/web/src/icons/arrow-up-circle.svg new file mode 100644 index 000000000..e9d009eb6 --- /dev/null +++ b/client/web/src/icons/arrow-up-circle.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/client/web/src/icons/check-circle.svg b/client/web/src/icons/check-circle.svg new file mode 100644 index 000000000..4daeed514 --- /dev/null +++ b/client/web/src/icons/check-circle.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/web/src/icons/x-circle.svg b/client/web/src/icons/x-circle.svg new file mode 100644 index 000000000..49afc5a03 --- /dev/null +++ b/client/web/src/icons/x-circle.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/client/web/src/index.css b/client/web/src/index.css index 4863dc97b..7293d6350 100644 --- a/client/web/src/index.css +++ b/client/web/src/index.css @@ -260,3 +260,23 @@ html { 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/spinner.tsx b/client/web/src/ui/spinner.tsx new file mode 100644 index 000000000..989d56b23 --- /dev/null +++ b/client/web/src/ui/spinner.tsx @@ -0,0 +1,29 @@ +import cx from "classnames" +import React, { HTMLAttributes } from "react" + +type Props = { + className?: string + size: "sm" | "md" +} & HTMLAttributes + +export default function Spinner(props: Props) { + const { className, size, ...rest } = props + + return ( +
+ ) +} + +Spinner.defaultProps = { + size: "md", +} diff --git a/client/web/web.go b/client/web/web.go index 7e56c365d..fc98ba747 100644 --- a/client/web/web.go +++ b/client/web/web.go @@ -541,6 +541,8 @@ type nodeData struct { AdvertiseRoutes string RunningSSHServer bool + ClientVersion *tailcfg.ClientVersion + LicensesURL string DebugMode string // empty when not running in any debug mode @@ -582,6 +584,12 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) { LicensesURL: licenses.LicensesURL(), DebugMode: debugMode, // TODO(sonia,will): just pass back s.mode directly? } + cv, err := s.lc.CheckUpdate(r.Context()) + if err != nil { + s.logf("could not check for updates: %v", err) + } else { + data.ClientVersion = cv + } for _, ip := range st.TailscaleIPs { if ip.Is4() { data.IP = ip.String() @@ -807,6 +815,9 @@ func (s *Server) proxyRequestToLocalAPI(w http.ResponseWriter, r *http.Request) var localapiAllowlist = []string{ "/v0/logout", "/v0/prefs", + "/v0/update/check", + "/v0/update/install", + "/v0/update/progress", } // csrfKey returns a key that can be used for CSRF protection.