client/{tailscale,web}: add initial webUI frontend for self-updates (#10191)

Updates #10187.

Signed-off-by: Naman Sood <mail@nsood.in>
This commit is contained in:
Naman Sood
2023-11-15 16:04:44 -05:00
committed by GitHub
parent 245ddb157b
commit d5c460e83c
13 changed files with 389 additions and 1 deletions

View File

@ -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

View File

@ -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 }
}