feat: support upgrage admin dashboard (#612)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
27
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
115
src/modules/update/update.controller.ts
Normal file
115
src/modules/update/update.controller.ts
Normal 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$
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
7
src/modules/update/update.dto.ts
Normal file
7
src/modules/update/update.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsBoolean, IsOptional } from 'class-validator'
|
||||
|
||||
export class UpdateAdminDto {
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
force?: boolean
|
||||
}
|
||||
10
src/modules/update/update.module.ts
Normal file
10
src/modules/update/update.module.ts
Normal 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 {}
|
||||
147
src/modules/update/update.service.ts
Normal file
147
src/modules/update/update.service.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user