mirror of https://github.com/tailscale/tailscale/
client/{tailscale,web}: add initial webUI frontend for self-updates (#10191)
Updates #10187. Signed-off-by: Naman Sood <mail@nsood.in>pull/10264/head
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 }
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M16 12L12 8L8 12" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 16V8" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 522 B |
@ -0,0 +1,4 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M22 11.08V12C21.9988 14.1564 21.3005 16.2547 20.0093 17.9818C18.7182 19.709 16.9033 20.9725 14.8354 21.5839C12.7674 22.1953 10.5573 22.1219 8.53447 21.3746C6.51168 20.6273 4.78465 19.2461 3.61096 17.4371C2.43727 15.628 1.87979 13.4881 2.02168 11.3363C2.16356 9.18455 2.99721 7.13631 4.39828 5.49706C5.79935 3.85781 7.69279 2.71537 9.79619 2.24013C11.8996 1.7649 14.1003 1.98232 16.07 2.85999" stroke="#1EA672" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M22 4L12 14.01L9 11.01" stroke="#1EA672" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 704 B |
@ -0,0 +1,5 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="red" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M15 9L9 15" stroke="red" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M9 9L15 15" stroke="red" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
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