client/{tailscale,web}: add initial webUI frontend for self-updates (#10191)
Updates #10187. Signed-off-by: Naman Sood <mail@nsood.in>main
parent
245ddb157b
commit
d5c460e83c
@ -0,0 +1,59 @@ |
||||
import React from "react" |
||||
import { VersionInfo } from "src/hooks/self-update" |
||||
import { Link } from "wouter" |
||||
|
||||
export function UpdateAvailableNotification({ |
||||
details, |
||||
}: { |
||||
details: VersionInfo |
||||
}) { |
||||
return ( |
||||
<div className="card"> |
||||
<h2 className="mb-2"> |
||||
Update available{" "} |
||||
{details.LatestVersion && `(v${details.LatestVersion})`} |
||||
</h2> |
||||
<p className="text-sm mb-1 mt-1"> |
||||
{details.LatestVersion |
||||
? `Version ${details.LatestVersion}` |
||||
: "A new update"}{" "} |
||||
is now available. <ChangelogText version={details.LatestVersion} /> |
||||
</p> |
||||
<Link |
||||
className="button button-blue mt-3 text-sm inline-block" |
||||
to="/update" |
||||
> |
||||
Update now |
||||
</Link> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
// 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{" "} |
||||
<a href="https://tailscale.com/changelog/" className="link"> |
||||
release notes |
||||
</a>{" "} |
||||
to find out what's new! |
||||
</> |
||||
) |
||||
} |
||||
@ -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 ( |
||||
<> |
||||
<div className="flex-1 flex flex-col justify-center items-center text-center mt-56"> |
||||
{updateState === UpdateState.InProgress ? ( |
||||
<> |
||||
<Spinner size="sm" className="text-gray-400" /> |
||||
<h1 className="text-2xl m-3">Update in progress</h1> |
||||
<p className="text-gray-400"> |
||||
The update shouldn't take more than a couple of minutes. Once it's |
||||
completed, you will be asked to log in again. |
||||
</p> |
||||
</> |
||||
) : updateState === UpdateState.Complete ? ( |
||||
<> |
||||
<CheckCircleIcon /> |
||||
<h1 className="text-2xl m-3">Update complete!</h1> |
||||
<p className="text-gray-400"> |
||||
You updated Tailscale |
||||
{versionInfo && versionInfo.LatestVersion |
||||
? ` to ${versionInfo.LatestVersion}` |
||||
: null} |
||||
. <ChangelogText version={versionInfo?.LatestVersion} /> |
||||
</p> |
||||
<Link className="button button-blue text-sm m-3" to="/"> |
||||
Log in to access |
||||
</Link> |
||||
</> |
||||
) : updateState === UpdateState.UpToDate ? ( |
||||
<> |
||||
<CheckCircleIcon /> |
||||
<h1 className="text-2xl m-3">Up to date!</h1> |
||||
<p className="text-gray-400"> |
||||
You are already running Tailscale {currentVersion}, which is the |
||||
newest version available. |
||||
</p> |
||||
<Link className="button button-blue text-sm m-3" to="/"> |
||||
Return |
||||
</Link> |
||||
</> |
||||
) : ( |
||||
/* TODO(naman,sonia): Figure out the body copy and design for this view. */ |
||||
<> |
||||
<XCircleIcon /> |
||||
<h1 className="text-2xl m-3">Update failed</h1> |
||||
<p className="text-gray-400"> |
||||
Update |
||||
{versionInfo && versionInfo.LatestVersion |
||||
? ` to ${versionInfo.LatestVersion}` |
||||
: null}{" "} |
||||
failed. |
||||
</p> |
||||
<Link className="button button-blue text-sm m-3" to="/"> |
||||
Return |
||||
</Link> |
||||
</> |
||||
)} |
||||
<pre className="h-64 overflow-scroll m-3"> |
||||
<code>{updateLog}</code> |
||||
</pre> |
||||
</div> |
||||
</> |
||||
) |
||||
} |
||||
@ -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<UpdateState>( |
||||
cv.RunningLatest ? UpdateState.UpToDate : UpdateState.Available |
||||
) |
||||
|
||||
const [updateLog, setUpdateLog] = useState<string>("") |
||||
|
||||
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 } |
||||
} |
||||
|
After Width: | Height: | Size: 522 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 506 B |
@ -0,0 +1,29 @@ |
||||
import cx from "classnames" |
||||
import React, { HTMLAttributes } from "react" |
||||
|
||||
type Props = { |
||||
className?: string |
||||
size: "sm" | "md" |
||||
} & HTMLAttributes<HTMLDivElement> |
||||
|
||||
export default function Spinner(props: Props) { |
||||
const { className, size, ...rest } = props |
||||
|
||||
return ( |
||||
<div |
||||
className={cx( |
||||
"spinner inline-block rounded-full align-middle", |
||||
{ |
||||
"border-2 w-4 h-4": size === "sm", |
||||
"border-4 w-8 h-8": size === "md", |
||||
}, |
||||
className |
||||
)} |
||||
{...rest} |
||||
/> |
||||
) |
||||
} |
||||
|
||||
Spinner.defaultProps = { |
||||
size: "md", |
||||
} |
||||
Loading…
Reference in new issue