feat: support upgrage admin dashboard (#612)

This commit is contained in:
2022-07-08 12:44:29 +08:00
committed by GitHub
parent 57835fe2ce
commit 18a304e49f
10 changed files with 299 additions and 19 deletions

View File

@@ -10,7 +10,7 @@ RUN pnpm bundle
RUN node scripts/download-latest-admin-assets.js
FROM node:16-alpine
RUN apk add zip unzip mongodb-tools bash fish rsync --no-cache
RUN apk add zip unzip mongodb-tools bash fish rsync jq --no-cache
WORKDIR /app
COPY --from=builder /app/out .
COPY --from=builder /app/assets ./assets

View File

@@ -129,6 +129,7 @@
"qs": "6.11.0",
"reflect-metadata": "0.1.13",
"rxjs": "7.5.5",
"semver": "^7",
"slugify": "1.6.5",
"snakecase-keys": "5.4.2",
"ua-parser-js": "1.0.2",
@@ -159,6 +160,7 @@
"@types/node": "^16",
"@types/nodemailer": "6.4.4",
"@types/qs": "6.9.7",
"@types/semver": "7.3.10",
"@types/ua-parser-js": "0.7.36",
"@types/validator": "13.7.4",
"@vercel/ncc": "0.34.0",

27
pnpm-lock.yaml generated
View File

@@ -52,6 +52,7 @@ specifiers:
'@types/node': ^16
'@types/nodemailer': 6.4.4
'@types/qs': 6.9.7
'@types/semver': 7.3.10
'@types/ua-parser-js': 0.7.36
'@types/validator': 13.7.4
'@vercel/ncc': 0.34.0
@@ -110,6 +111,7 @@ specifiers:
reflect-metadata: 0.1.13
rimraf: 3.0.2
rxjs: 7.5.5
semver: ^7
slugify: 1.6.5
snakecase-keys: 5.4.2
socket.io: '*'
@@ -190,6 +192,7 @@ dependencies:
qs: 6.11.0
reflect-metadata: 0.1.13
rxjs: 7.5.5
semver: 7.3.7
slugify: 1.6.5
snakecase-keys: 5.4.2
ua-parser-js: 1.0.2
@@ -224,6 +227,7 @@ devDependencies:
'@types/node': 16.11.33
'@types/nodemailer': 6.4.4
'@types/qs': 6.9.7
'@types/semver': 7.3.10
'@types/ua-parser-js': 0.7.36
'@types/validator': 13.7.4
'@vercel/ncc': 0.34.0
@@ -496,7 +500,6 @@ packages:
'@babel/types': 7.18.7
'@jridgewell/gen-mapping': 0.3.2
jsesc: 2.5.2
dev: true
/@babel/helper-annotate-as-pure/7.18.6:
resolution: {integrity: sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA==}
@@ -832,7 +835,7 @@ packages:
engines: {node: '>=6.9.0'}
dependencies:
'@babel/code-frame': 7.18.6
'@babel/generator': 7.18.6
'@babel/generator': 7.18.7
'@babel/helper-environment-visitor': 7.18.6
'@babel/helper-function-name': 7.18.6
'@babel/helper-hoist-variables': 7.18.6
@@ -1275,8 +1278,8 @@ packages:
resolution: {integrity: sha512-GcHwniMlA2z+WFPWuY8lp3fsza0I8xPFMWL5+n8LYyP6PSvPrXf4+n8stDHZY2DM0zy9sVkRDy1jDI4XGzYVqg==}
engines: {node: '>=6.0.0'}
dependencies:
'@jridgewell/set-array': 1.1.0
'@jridgewell/sourcemap-codec': 1.4.13
'@jridgewell/set-array': 1.1.2
'@jridgewell/sourcemap-codec': 1.4.14
'@jridgewell/trace-mapping': 0.3.14
/@jridgewell/gen-mapping/0.3.2:
@@ -1286,33 +1289,23 @@ packages:
'@jridgewell/set-array': 1.1.2
'@jridgewell/sourcemap-codec': 1.4.14
'@jridgewell/trace-mapping': 0.3.14
dev: true
/@jridgewell/resolve-uri/3.0.7:
resolution: {integrity: sha512-8cXDaBBHOr2pQ7j77Y6Vp5VDT2sIqWyWQ56TjEq4ih/a4iST3dItRe8Q9fp0rrIl9DoKhWQtUQz/YpOxLkXbNA==}
engines: {node: '>=6.0.0'}
/@jridgewell/set-array/1.1.0:
resolution: {integrity: sha512-SfJxIxNVYLTsKwzB3MoOQ1yxf4w/E6MdkvTgrgAt1bfxjSrLUoHMKrDOykwN14q65waezZIdqDneUIPh4/sKxg==}
engines: {node: '>=6.0.0'}
/@jridgewell/set-array/1.1.2:
resolution: {integrity: sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==}
engines: {node: '>=6.0.0'}
dev: true
/@jridgewell/sourcemap-codec/1.4.13:
resolution: {integrity: sha512-GryiOJmNcWbovBxTfZSF71V/mXbgcV3MewDe3kIMCLyIh5e7SKAeUZs+rMnJ8jkMolZ/4/VsdBmMrw3l+VdZ3w==}
/@jridgewell/sourcemap-codec/1.4.14:
resolution: {integrity: sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==}
dev: true
/@jridgewell/trace-mapping/0.3.14:
resolution: {integrity: sha512-bJWEfQ9lPTvm3SneWwRFVLzrh6nhjwqw7TUFFBEMzwvg7t7PCDenf2lDwqo4NQXzdpgBXyFgDWnQA+2vkruksQ==}
dependencies:
'@jridgewell/resolve-uri': 3.0.7
'@jridgewell/sourcemap-codec': 1.4.13
'@jridgewell/sourcemap-codec': 1.4.14
/@jridgewell/trace-mapping/0.3.9:
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
@@ -1935,6 +1928,10 @@ packages:
resolution: {integrity: sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==}
dev: true
/@types/semver/7.3.10:
resolution: {integrity: sha512-zsv3fsC7S84NN6nPK06u79oWgrPVd0NvOyqgghV1haPaFcVxIrP4DLomRwGAXk0ui4HZA7mOcSFL98sMVW9viw==}
dev: true
/@types/stack-utils/2.0.1:
resolution: {integrity: sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==}
dev: true

View File

@@ -1,5 +1,5 @@
#!env node
const { writeFileSync, appendFileSync } = require('fs')
const { appendFileSync } = require('fs')
const { join } = require('path')
const { fetch, $ } = require('zx-cjs')
const {

View File

@@ -44,6 +44,7 @@ import { SitemapModule } from './modules/sitemap/sitemap.module'
import { SnippetModule } from './modules/snippet/snippet.module'
import { ToolModule } from './modules/tool/tool.module'
import { TopicModule } from './modules/topic/topic.module'
import { UpdateModule } from './modules/update/update.module'
import { UserModule } from './modules/user/user.module'
import { DatabaseModule } from './processors/database/database.module'
import { GatewayModule } from './processors/gateway/gateway.module'
@@ -79,6 +80,7 @@ import { RedisModule } from './processors/redis/redis.module'
ProjectModule,
PTYModule,
RecentlyModule,
UpdateModule,
TopicModule,
SayModule,
SearchModule,

View File

@@ -41,10 +41,10 @@ export class DependencyController {
pty.onExit(async ({ exitCode }) => {
if (exitCode != 0) {
subscriber.next(`Error: Exit code: ${exitCode}`)
subscriber.next(chalk.red(`Error: Exit code: ${exitCode}\n`))
}
subscriber.next('任务完成,可关闭此窗口。')
subscriber.next(chalk.green('任务完成,可关闭此窗口。'))
subscriber.complete()
})
})

View File

@@ -0,0 +1,115 @@
import { isSemVer } from 'class-validator'
import { Observable, catchError, lastValueFrom } from 'rxjs'
import { lt, major, minor } from 'semver'
import { Query, Sse } from '@nestjs/common'
import { dashboard } from '~/../package.json'
import { ApiController } from '~/common/decorator/api-controller.decorator'
import { Auth } from '~/common/decorator/auth.decorator'
import { HTTPDecorators } from '~/common/decorator/http.decorator'
import { LOCAL_ADMIN_ASSET_PATH } from '~/constants/path.constant'
import { UpdateAdminDto } from './update.dto'
import { UpdateService } from './update.service'
@ApiController('update')
@Auth()
export class UpdateController {
constructor(private readonly service: UpdateService) {}
@Sse('/upgrade/dashboard')
@HTTPDecorators.Idempotence()
@HTTPDecorators.Bypass
async updateDashboard(
@Query() query: UpdateAdminDto,
): Promise<Observable<string>> {
const { force = false } = query
const sseOutput$ = new Observable<string>((observer) => {
;(async () => {
// 1. check current local admin version if exist.
let { version: currentVersion } = dashboard
const isExistLocalAdmin = fs.pathExistsSync(LOCAL_ADMIN_ASSET_PATH)
if (!isExistLocalAdmin) {
// 2. if not has local admin, then pull remote admin version.
const stream$ = this.service.downloadAdminAsset(currentVersion)
stream$.subscribe((data) => {
observer.next(data)
})
await lastValueFrom(stream$)
observer.complete()
return
}
const versionPath = path.resolve(LOCAL_ADMIN_ASSET_PATH, 'version')
const isHasVersion = fs.existsSync(versionPath)
if (isHasVersion) {
const versionInfo = await fs.promises
.readFile(versionPath, {
encoding: 'utf8',
})
.then((data) => data.split('\n')[0])
.catch(() => '')
if (isSemVer(versionInfo)) {
currentVersion = versionInfo
}
}
// 3. fetch latest admin version
const latestVersion = await this.service
.getLatestAdminVersion()
.catch((err) => {
observer.next(
chalk.red(
`Fetching latest admin version error: ${err.message}\n`,
),
)
observer.complete()
return ''
})
if (!latestVersion) {
return
}
if (!lt(currentVersion, latestVersion)) {
observer.next(chalk.green(`Admin dashboard is up to date.\n`))
observer.complete()
return
}
if (
!force &&
(minor(currentVersion) !== minor(latestVersion) ||
major(currentVersion) !== major(latestVersion))
) {
observer.next(
chalk.red(
`The latest version is ${latestVersion}, current version is ${currentVersion}, can not cross-version upgrade.\n`,
),
)
observer.complete()
return
}
// 4. download latest admin version
const stream$ = this.service.downloadAdminAsset(latestVersion)
stream$.subscribe((data) => {
observer.next(data)
})
await lastValueFrom(stream$)
observer.complete()
})()
})
return sseOutput$.pipe(
catchError((err) => {
console.error(err)
return sseOutput$
}),
)
}
}

View File

@@ -0,0 +1,7 @@
import { IsBoolean, IsOptional } from 'class-validator'
export class UpdateAdminDto {
@IsBoolean()
@IsOptional()
force?: boolean
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common'
import { UpdateController } from './update.controller'
import { UpdateService } from './update.service'
@Module({
controllers: [UpdateController],
providers: [UpdateService],
})
export class UpdateModule {}

View File

@@ -0,0 +1,147 @@
import { appendFile, rm, writeFile } from 'fs/promises'
import { spawn } from 'node-pty'
import { Observable, Subscriber, catchError } from 'rxjs'
import { Stream } from 'stream'
import { inspect } from 'util'
import { Injectable } from '@nestjs/common'
import { dashboard } from '~/../package.json'
import { LOCAL_ADMIN_ASSET_PATH } from '~/constants/path.constant'
import { HttpService } from '~/processors/helper/helper.http.service'
const { repo } = dashboard
@Injectable()
export class UpdateService {
constructor(protected readonly httpService: HttpService) {}
downloadAdminAsset(version: string) {
const observable$ = new Observable<string>((subscriber) => {
;(async () => {
const endpoint = `https://api.github.com/repos/${repo}/releases/tags/v${version}`
subscriber.next(`Downloading admin asset v${version}\n`)
subscriber.next(`Get from ${endpoint}\n`)
const json = await fetch(endpoint)
.then((res) => res.json())
.catch((err) => {
subscriber.next(chalk.red(`Fetching error: ${err.message}`))
subscriber.complete()
return null
})
if (!json) {
return
}
const downloadUrl = json.assets?.find(
(asset) => asset.name === 'release.zip',
)?.browser_download_url
if (!downloadUrl) {
subscriber.next(chalk.red('Download url not found.\n'))
subscriber.next(
chalk.red(
`Full json fetched: \n${inspect(json, false, undefined, true)}`,
),
)
subscriber.complete()
return
}
const buffer = await fetch(downloadUrl)
.then((res) => res.arrayBuffer())
.catch((err) => {
subscriber.next(chalk.red(`Downloading error: ${err.message}`))
subscriber.complete()
return null
})
if (!buffer) {
return
}
await rm('admin-release.zip', { force: true })
await appendFile(
path.resolve(process.cwd(), 'admin-release.zip'),
Buffer.from(buffer),
)
const writable = new Stream.Writable({
autoDestroy: false,
write(chunk) {
subscriber.next(chunk.toString())
},
})
const folder = LOCAL_ADMIN_ASSET_PATH.replace(/\/admin$/, '')
await rm(LOCAL_ADMIN_ASSET_PATH, { force: true, recursive: true })
await $`ls -lh`.pipe(writable)
try {
await this.runShellCommandPipeOutput(
'unzip',
['-o', 'admin-release.zip', '-d', folder],
subscriber,
)
await $`mv ${folder}/dist ${LOCAL_ADMIN_ASSET_PATH}`
await $`rm -f admin-release.zip`
await writeFile(
path.resolve(LOCAL_ADMIN_ASSET_PATH, 'version'),
version,
{
encoding: 'utf8',
},
)
subscriber.next(chalk.green(`Downloading finished.\n`))
} catch (err) {
subscriber.next(chalk.red(`Updating error: ${err.message}\n`))
} finally {
subscriber.complete()
writable.end()
writable.destroy()
}
await rm('admin-release.zip', { force: true })
})()
})
return observable$.pipe(
catchError((err) => {
console.error(err)
return observable$
}),
)
}
async getLatestAdminVersion() {
const endpoint = `https://api.github.com/repos/${repo}/releases/latest`
const res = await this.httpService.axiosRef.get(endpoint)
return res.data.tag_name.replace(/^v/, '')
}
runShellCommandPipeOutput(
command: string,
args: any[],
subscriber: Subscriber<string>,
) {
return new Promise((resolve) => {
subscriber.next(`$ ${command} ${args.join(' ')}\n`)
const pty = spawn(command, args, {})
pty.onData((data) => {
subscriber.next(data.toString())
})
pty.onExit(() => {
resolve(null)
})
})
}
}