feat: redis sync and image upload

This commit is contained in:
guoyuqi
2026-09-30 17:37:33 +08:00
parent f34418da6d
commit 200ddbfa28
25 changed files with 833 additions and 179 deletions
+24 -3
View File
@@ -86,17 +86,38 @@ func (b *BaseApi) CheckHasCli(c *gin.Context) {
// @Tags Database Redis
// @Summary Install redis-cli
// @Success 200
// @Accept json
// @Param request body dto.RedisCliInstall true "request"
// @Success 200 {object} dto.RedisCliStatus
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/redis/install/cli [post]
func (b *BaseApi) InstallCli(c *gin.Context) {
if err := redisService.InstallCli(); err != nil {
var req dto.RedisCliInstall
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := redisService.InstallCli(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
}
helper.Success(c)
// @Tags Database Redis
// @Summary Load redis-cli installation status
// @Success 200 {object} dto.RedisCliStatus
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/redis/cli/status [get]
func (b *BaseApi) LoadRedisCliStatus(c *gin.Context) {
data, err := redisService.LoadCliStatus()
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
}
// @Tags Database Redis
+11
View File
@@ -22,6 +22,17 @@ type DBBaseInfo struct {
Port int64 `json:"port"`
}
type RedisCliInstall struct {
TaskID string `json:"taskID" validate:"omitempty,uuid"`
}
type RedisCliStatus struct {
Installed bool `json:"installed"`
TaskID string `json:"taskID"`
Status string `json:"status"`
ErrorMsg string `json:"errorMsg"`
}
// mysql
type MysqlDBSearch struct {
PageInfo
+13 -1
View File
@@ -532,6 +532,10 @@ func (u *ContainerService) LoadResourceLimit() (*dto.ResourceLimit, error) {
}
func (u *ContainerService) ContainerCreate(req dto.ContainerOperate, inThread bool) error {
return u.containerCreate(req, inThread, "")
}
func (u *ContainerService) containerCreate(req dto.ContainerOperate, inThread bool, taskName string) error {
client, err := docker.NewDockerClient()
if err != nil {
return err
@@ -545,7 +549,10 @@ func (u *ContainerService) ContainerCreate(req dto.ContainerOperate, inThread bo
return buserr.New("ErrContainerName")
}
taskItem, err := task.NewTaskWithOps(req.Name, task.TaskCreate, task.TaskScopeContainer, req.TaskID, 1)
if taskName == "" {
taskName = task.GetTaskName(req.Name, task.TaskCreate, task.TaskScopeContainer)
}
taskItem, err := task.NewTask(taskName, task.TaskCreate, task.TaskScopeContainer, req.TaskID, 1)
if err != nil {
unlock()
_ = client.Close()
@@ -606,6 +613,11 @@ func (u *ContainerService) ContainerCreate(req dto.ContainerOperate, inThread bo
}, nil)
if inThread {
if err := taskItem.Prepare(); err != nil {
unlock()
_ = client.Close()
return err
}
go func() {
defer unlock()
defer client.Close()
+58 -4
View File
@@ -8,6 +8,12 @@ import (
"os"
"os/exec"
"strings"
"sync"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/global"
@@ -23,6 +29,11 @@ import (
type RedisService struct{}
const redisCliTaskName = "RedisCliEnable"
// The CLI container is shared by all remote Redis databases on this node.
var redisCliInstallMutex sync.Mutex
type IRedisService interface {
UpdateConf(req dto.RedisConfUpdate) error
UpdatePersistenceConf(req dto.RedisConfPersistenceUpdate) error
@@ -33,7 +44,8 @@ type IRedisService interface {
LoadPersistenceConf(req dto.LoadRedisStatus) (*dto.RedisPersistence, error)
CheckHasCli() bool
InstallCli() error
InstallCli(req dto.RedisCliInstall) (*dto.RedisCliStatus, error)
LoadCliStatus() (*dto.RedisCliStatus, error)
}
func NewIRedisService() IRedisService {
@@ -71,20 +83,62 @@ func (u *RedisService) CheckHasCli() bool {
return false
}
for _, item := range containerLists {
if strings.ReplaceAll(item.Names[0], "/", "") == "1Panel-redis-cli-tools" {
if len(item.Names) > 0 && strings.TrimPrefix(item.Names[0], "/") == "1Panel-redis-cli-tools" {
return true
}
}
return false
}
func (u *RedisService) InstallCli() error {
func (u *RedisService) LoadCliStatus() (*dto.RedisCliStatus, error) {
result := &dto.RedisCliStatus{}
latest, err := taskRepo.GetFirst(repo.WithByName(redisCliTaskName), repo.WithByType(task.TaskScopeContainer), repo.WithOrderDesc("created_at"))
if errors.Is(err, gorm.ErrRecordNotFound) {
result.Installed = u.CheckHasCli()
return result, nil
}
if err != nil {
return nil, err
}
result.TaskID = latest.ID
result.Status = latest.Status
result.ErrorMsg = latest.ErrorMsg
result.Installed = u.CheckHasCli()
return result, nil
}
func (u *RedisService) InstallCli(req dto.RedisCliInstall) (*dto.RedisCliStatus, error) {
if !redisCliInstallMutex.TryLock() {
return nil, buserr.New("TaskIsExecuting")
}
defer redisCliInstallMutex.Unlock()
status, err := u.LoadCliStatus()
if err != nil {
return nil, err
}
if status.Status == constant.StatusExecuting || status.Installed {
return status, nil
}
if req.TaskID == "" {
req.TaskID = uuid.NewString()
}
// Never reuse an existing task ID: doing so would truncate its log.
if _, err := taskRepo.GetFirst(taskRepo.WithByID(req.TaskID)); !errors.Is(err, gorm.ErrRecordNotFound) {
if err != nil {
return nil, err
}
return nil, buserr.New("TaskIsExecuting")
}
item := dto.ContainerOperate{
TaskID: req.TaskID,
Name: "1Panel-redis-cli-tools",
Image: "redis:7.4.4",
Networks: []dto.ContainerNetwork{{Network: "1panel-network"}},
}
return NewIContainerService().ContainerCreate(item, false)
if err := (&ContainerService{}).containerCreate(item, true, redisCliTaskName); err != nil {
return nil, err
}
return &dto.RedisCliStatus{TaskID: req.TaskID, Status: constant.StatusExecuting}, nil
}
func (u *RedisService) ChangePassword(req dto.ChangeRedisPass) error {
+8 -16
View File
@@ -5,7 +5,6 @@ import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os"
@@ -325,18 +324,16 @@ func (u *ImageService) ImageLoad(req dto.ImageLoad) error {
}
go func() {
client, err := docker.NewDockerClient()
if err != nil {
taskItem.Log("Failed to create Docker client: " + err.Error())
return
}
defer client.Close()
for _, itemPath := range req.Paths {
currentPath := itemPath
itemName := path.Base(currentPath)
taskItem.AddSubTask(i18n.GetWithName("TaskImport", itemName), func(t *task.Task) error {
taskItem.Logf("----------------- %s -----------------", itemName)
client, err := docker.NewDockerClient()
if err != nil {
return err
}
defer client.Close()
file, err := os.Open(currentPath)
if err != nil {
return err
@@ -347,14 +344,9 @@ func (u *ImageService) ImageLoad(req dto.ImageLoad) error {
return err
}
defer res.Body.Close()
content, err := io.ReadAll(res.Body)
if err != nil {
return err
}
if strings.Contains(string(content), "Error") {
return errors.New(string(content))
}
return nil
return consumeImageLoadResponse(res.Body, func(message string) {
taskItem.Log(message)
})
}, nil)
}
_ = taskItem.Execute()
+37
View File
@@ -0,0 +1,37 @@
package service
import (
"encoding/json"
"errors"
"io"
"strings"
)
// Docker may report load failures in a successful HTTP response's JSON stream.
func consumeImageLoadResponse(reader io.Reader, log func(string)) error {
decoder := json.NewDecoder(reader)
for {
var message struct {
Stream string `json:"stream"`
Error string `json:"error"`
ErrorDetail struct {
Message string `json:"message"`
} `json:"errorDetail"`
}
if err := decoder.Decode(&message); err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return err
}
if message.Error != "" {
return errors.New(message.Error)
}
if message.ErrorDetail.Message != "" {
return errors.New(message.ErrorDetail.Message)
}
if text := strings.TrimSpace(message.Stream); text != "" {
log(text)
}
}
}
+11 -1
View File
@@ -283,8 +283,18 @@ func (t *Task) updateTask(task *model.Task) {
_ = t.taskRepo.Update(context.Background(), task)
}
func (t *Task) Execute() error {
// Prepare makes a task visible before dispatching it to a background worker.
func (t *Task) Prepare() error {
if err := t.taskRepo.Save(context.Background(), t.Task); err != nil {
_ = t.logFile.Close()
global.RemoveTaskCancel(t.TaskID)
return err
}
return nil
}
func (t *Task) Execute() error {
if err := t.Prepare(); err != nil {
return err
}
var err error
+1
View File
@@ -43,6 +43,7 @@ func (s *DatabaseRouter) InitRouter(Router *gin.RouterGroup) {
cmdRouter.POST("/redis/status", baseApi.LoadRedisStatus)
cmdRouter.POST("/redis/conf", baseApi.LoadRedisConf)
cmdRouter.GET("/redis/check", baseApi.CheckHasCli)
cmdRouter.GET("/redis/cli/status", baseApi.LoadRedisCliStatus)
cmdRouter.POST("/redis/install/cli", baseApi.InstallCli)
cmdRouter.POST("/redis/password", baseApi.ChangeRedisPassword)
cmdRouter.POST("/redis/conf/update", baseApi.UpdateRedisConf)
+67 -1
View File
@@ -15010,6 +15010,30 @@ const docTemplate = `{
]
}
},
"/databases/redis/cli/status": {
"get": {
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.RedisCliStatus"
}
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"Timestamp": []
}
],
"summary": "Load redis-cli installation status",
"tags": [
"Database Redis"
]
}
},
"/databases/redis/conf": {
"post": {
"consumes": [
@@ -15092,9 +15116,26 @@ const docTemplate = `{
},
"/databases/redis/install/cli": {
"post": {
"consumes": [
"application/json"
],
"parameters": [
{
"description": "request",
"in": "body",
"name": "request",
"required": true,
"schema": {
"$ref": "#/definitions/dto.RedisCliInstall"
}
}
],
"responses": {
"200": {
"description": "OK"
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.RedisCliStatus"
}
}
},
"security": [
@@ -39731,6 +39772,31 @@ const docTemplate = `{
],
"type": "object"
},
"dto.RedisCliInstall": {
"type": "object",
"properties": {
"taskID": {
"type": "string"
}
}
},
"dto.RedisCliStatus": {
"type": "object",
"properties": {
"errorMsg": {
"type": "string"
},
"installed": {
"type": "boolean"
},
"status": {
"type": "string"
},
"taskID": {
"type": "string"
}
}
},
"dto.RedisConf": {
"properties": {
"containerName": {
+67 -1
View File
@@ -15006,6 +15006,30 @@
]
}
},
"/databases/redis/cli/status": {
"get": {
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.RedisCliStatus"
}
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"Timestamp": []
}
],
"summary": "Load redis-cli installation status",
"tags": [
"Database Redis"
]
}
},
"/databases/redis/conf": {
"post": {
"consumes": [
@@ -15088,9 +15112,26 @@
},
"/databases/redis/install/cli": {
"post": {
"consumes": [
"application/json"
],
"parameters": [
{
"description": "request",
"in": "body",
"name": "request",
"required": true,
"schema": {
"$ref": "#/definitions/dto.RedisCliInstall"
}
}
],
"responses": {
"200": {
"description": "OK"
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.RedisCliStatus"
}
}
},
"security": [
@@ -39727,6 +39768,31 @@
],
"type": "object"
},
"dto.RedisCliInstall": {
"type": "object",
"properties": {
"taskID": {
"type": "string"
}
}
},
"dto.RedisCliStatus": {
"type": "object",
"properties": {
"errorMsg": {
"type": "string"
},
"installed": {
"type": "boolean"
},
"status": {
"type": "string"
},
"taskID": {
"type": "string"
}
}
},
"dto.RedisConf": {
"properties": {
"containerName": {
+7
View File
@@ -1,6 +1,13 @@
import { ReqPage } from '.';
export namespace Database {
export interface RedisCliStatus {
installed: boolean;
taskID: string;
status: string;
errorMsg: string;
}
export interface SearchDBWithPage {
info: string;
database: string;
+7 -2
View File
@@ -141,8 +141,13 @@ export const imagePull = (params: Container.ImagePull) => {
export const imagePush = (params: Container.ImagePush) => {
return http.post<string>(`/containers/image/push`, params);
};
export const imageLoad = (params: Container.ImageLoad) => {
return http.post(`/containers/image/load`, params, TimeoutEnum.T_10M);
export const imageLoad = (params: Container.ImageLoad, currentNode?: string) => {
return http.post(
`/containers/image/load`,
params,
TimeoutEnum.T_10M,
currentNode ? { CurrentNode: currentNode } : undefined,
);
};
export const imageSave = (params: Container.ImageSave) => {
return http.post(`/containers/image/save`, params, TimeoutEnum.T_10M);
+7 -2
View File
@@ -193,8 +193,13 @@ export const redisPersistenceConf = (type: string, database: string) => {
export const checkRedisCli = () => {
return http.get<boolean>(`/databases/redis/check`);
};
export const installRedisCli = () => {
return http.post(`/databases/redis/install/cli`, {}, TimeoutEnum.T_5M);
export const loadRedisCliStatus = (node: string) => {
return http.get<Database.RedisCliStatus>(`/databases/redis/cli/status?operateNode=${encodeURIComponent(node)}`);
};
export const installRedisCli = (taskID: string, node: string) => {
return http.post<Database.RedisCliStatus>(`/databases/redis/install/cli?operateNode=${encodeURIComponent(node)}`, {
taskID,
});
};
export const changeRedisPassword = (database: string, password: string) => {
if (password) {
@@ -16,10 +16,12 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useRoute } from 'vue-router';
defineOptions({ name: 'TableSearch' });
const emit = defineEmits(['search', 'update:searchName']);
const searchInfo = ref();
const route = useRoute();
const props = defineProps({
placeholder: String,
disabled: {
@@ -42,6 +44,16 @@ watch(
{ immediate: true },
);
// Also discard text typed without pressing Enter when leaving a cached tab.
watch(
() => route.path,
() => {
searchInfo.value = '';
emit('update:searchName', '');
},
{ flush: 'sync' },
);
const search = () => {
emit('update:searchName', searchInfo.value);
emit('search');
@@ -167,7 +167,8 @@
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, useAttrs } from 'vue';
import { computed, onActivated, onMounted, ref, useAttrs } from 'vue';
import { useRoute } from 'vue-router';
import { useGlobalStore } from '@/composables/useGlobalStore';
import { flattenVNodes, type FuTableOperationButton } from '@/components/table/shared';
import { useCardColumns } from './useCardColumns';
@@ -177,6 +178,7 @@ import { useTableSelection } from './useTableSelection';
import { useTablePageState } from './useTablePageState';
const slots = useSlots();
const attrs = useAttrs();
const route = useRoute();
const { isMobile, openMenuTabs, currentNode } = useGlobalStore();
defineOptions({ name: 'ComplexTable' });
@@ -465,12 +467,20 @@ watch([currentViewMode, tableData, () => props.syncCardContentHeight], scheduleC
useTablePageState(
() => props.paginationConfig,
() => [
route.path,
currentNode.value,
typeof props.selectionContext === 'function' ? props.selectionContext() : props.selectionContext,
],
clearSelects,
);
// Kept-alive tabs do not mount again. Reload their reset query on subsequent visits.
let hasActivated = false;
onActivated(() => {
if (hasActivated) emit('search');
hasActivated = true;
});
onBeforeUnmount(() => {
window.removeEventListener('resize', calcHeight);
window.removeEventListener('resize', scheduleCardContentHeightSync);
+2 -7
View File
@@ -1,11 +1,6 @@
import { reactive, type UnwrapNestedRefs } from 'vue';
import { useRoute } from 'vue-router';
import { useGlobalStore } from '@/composables/useGlobalStore';
import { getPageState } from '@/utils/page-state-cache';
/** A new page visit starts from its defaults instead of restoring the previous page number. */
export const usePageState = <T extends object>(factory: () => T): UnwrapNestedRefs<T> => {
const route = useRoute();
const { currentNode } = useGlobalStore();
const key = `${currentNode.value}:${String(route.name || route.path)}`;
return reactive(getPageState(key, factory));
return reactive(factory());
};
@@ -1,17 +1,14 @@
import { storeToRefs } from 'pinia';
import GlobalStore from '@/store/modules/global';
import { bindSearchPersistence } from '@/utils/search-persistence';
import router from '@/routers/router';
import { bindSearchReset } from '@/utils/search-reset';
/** Restore the parent's search fields synchronously, before its initial request. */
/** Kept as a compatible entry point; searches now reset on tab, node or resource changes. */
export const useSearchPersistence = (
page: string,
fields: Parameters<typeof bindSearchPersistence>[1],
fields: Parameters<typeof bindSearchReset>[1],
scope?: () => string | number | undefined,
) => {
const { currentNode } = storeToRefs(GlobalStore());
return bindSearchPersistence(() => {
const context = scope?.();
if (scope && context === undefined) return undefined;
return JSON.stringify([currentNode.value, page, context ?? '']);
}, fields);
return bindSearchReset(() => [router.currentRoute.value.path, currentNode.value, page, scope?.()], fields);
};
+10
View File
@@ -739,6 +739,11 @@ const message = {
keyspaceMisses: 'Number of failed attempts to find the database key.',
hit: 'Find the database key hit ratio.',
latestForkUsec: 'The number of microseconds spent on the last fork() operation.',
redisCliEnableTask: 'Enable remote Redis terminal',
redisCliEnabling: 'Enabling remote Redis terminal',
redisCliEnabled: 'Remote Redis terminal enabled',
redisCliEnableFailed: 'Failed to enable remote Redis terminal. Check the task log and retry.',
redisCliTaskSubmitted: 'Task submitted. View progress in the task center.',
redisCliHelper: '"redis-cli" not detected. Enable the service first.',
redisQuickCmd: 'Redis quick commands',
recoverHelper: 'This will overwrite data with [{0}]. Continue?',
@@ -1841,6 +1846,11 @@ const message = {
imageName: 'Image name',
pull: 'Pull',
path: 'Path',
imageImportSource: 'Import source',
imageUploadDrop: 'Drop an image file here, or click to upload',
imageUploadHelper:
'Any file format is allowed. Docker validates the contents. Uploaded files are retained for retrying imports or manual deletion.',
imageImportSubmitted: 'Image import task submitted. Check the task log for the result.',
importImage: 'Import',
buildArgs: 'Build Arguments',
imageBuild: 'Build',
+9
View File
@@ -709,6 +709,11 @@ const message = {
keyspaceMisses: '尋找資料庫鍵失敗的次數',
hit: '尋找資料庫鍵命中率',
latestForkUsec: '最近一次 fork() 操作耗費的微秒數',
redisCliEnableTask: '啟用 Redis 遠端終端',
redisCliEnabling: '正在啟用 Redis 遠端終端',
redisCliEnabled: 'Redis 遠端終端已啟用',
redisCliEnableFailed: 'Redis 遠端終端啟用失敗,請查看任務日誌後重試',
redisCliTaskSubmitted: '啟用任務已提交,可在任務中心查看進度',
redisCliHelper: '未偵測到 redis-cli 服務,請先啟用服務',
redisQuickCmd: 'Redis 快速指令',
recoverHelper: '即將使用 [{0}] 對資料進行覆蓋,是否繼續?',
@@ -1746,6 +1751,10 @@ const message = {
delInsecureHelper: '刪除授信需要重新啟動 docker 服務,是否刪除?',
pull: '拉取',
path: '路徑',
imageImportSource: '匯入方式',
imageUploadDrop: '拖曳映像檔案至此處,或點擊上傳',
imageUploadHelper: '不限制檔案格式,由 Docker 驗證內容。上傳的檔案會保留,可重試匯入或手動刪除。',
imageImportSubmitted: '映像匯入任務已提交,請查看任務日誌',
importImage: '匯入映像',
imageBuild: '構建映像',
buildArgs: '構建參數',
+9
View File
@@ -722,6 +722,11 @@ const message = {
keyspaceMisses: '查找数据库键失败的次数',
hit: '查找数据库键命中率',
latestForkUsec: '最近一次 fork() 操作耗费的微秒数',
redisCliEnableTask: '启用 Redis 远程终端',
redisCliEnabling: '正在启用 Redis 远程终端',
redisCliEnabled: 'Redis 远程终端已启用',
redisCliEnableFailed: 'Redis 远程终端启用失败,请查看任务日志后重试',
redisCliTaskSubmitted: '启用任务已提交,可在任务中心查看进度',
redisCliHelper: '未检测到 redis-cli 服务,请先启用服务!',
redisQuickCmd: 'Redis 快速命令',
@@ -1766,6 +1771,10 @@ const message = {
delInsecureHelper: '删除授信需要重启 docker 服务,是否删除?',
pull: '拉取',
path: '路径',
imageImportSource: '导入方式',
imageUploadDrop: '拖拽镜像文件到此处,或点击上传',
imageUploadHelper: '不限制文件格式,由 Docker 校验内容。上传的文件会保留,可重试导入或手动删除。',
imageImportSubmitted: '镜像导入任务已提交,请查看任务日志',
importImage: '导入镜像',
imageBuild: '构建镜像',
buildArgs: '构建参数',
+17
View File
@@ -0,0 +1,17 @@
import { watch, type Ref } from 'vue';
type SearchFields = Record<string, Ref<string | number | undefined>>;
/** Search terms belong to the current tab visit, not browser storage or cached pages. */
export const bindSearchReset = (context: () => unknown, fields: SearchFields) => {
const emptyValues = Object.fromEntries(
Object.entries(fields).map(([name, field]) => [name, typeof field.value === 'string' ? '' : undefined]),
);
return watch(
() => JSON.stringify(context()),
() => {
for (const [name, field] of Object.entries(fields)) field.value = emptyValues[name];
},
{ immediate: true, flush: 'sync' },
);
};
+1
View File
@@ -24,6 +24,7 @@ const formatSkillInstallTask = () => {
};
const taskTextMap: Record<string, () => string> = {
RedisCliEnable: () => i18n.global.t('database.redisCliEnableTask'),
BatchInstallAgent: () => formatBatchAgentTask('Install'),
DispatchAgentInstallTasks: () => formatDispatchAgentTask('Install'),
BatchUpgradeAgent: () => formatBatchAgentTask('Upgrade'),
+260 -71
View File
@@ -1,105 +1,294 @@
<template>
<DrawerPro v-model="loadVisible" :header="$t('container.importImage')" @close="handleClose" size="small">
<el-form @submit.prevent v-loading="loading" ref="formRef" :model="form" label-position="top">
<el-form-item :label="$t('container.path')" :rules="Rules.requiredInput" prop="path">
<el-input v-model="form.path" @input="handlePathChange">
<template #prepend>
<el-button icon="Folder" @click="fileRef.acceptParams({ dir: false, multiple: true })" />
</template>
</el-input>
<DrawerPro
v-model="loadVisible"
:header="$t('container.importImage')"
:auto-close="!busy"
:confirm-before-close="true"
@before-close="beforeClose"
size="small"
>
<el-form @submit.prevent label-position="top">
<div class="flex items-center gap-3 mb-5">
<el-upload
ref="uploadRef"
:limit="1"
:auto-upload="false"
:show-file-list="false"
:disabled="busy || !!pendingTaskID"
:on-change="onFileChange"
:on-exceed="onFileExceed"
>
<el-button :disabled="busy || !!pendingTaskID" @click="source = 'local'">
{{ $t('database.localUpload') }}
</el-button>
</el-upload>
<el-button :disabled="busy" @click="onServerSelect">
{{ $t('database.hostSelect') }}
</el-button>
</div>
<el-form-item v-if="source === 'server'" :label="$t('container.path')">
<el-input v-model="form.path" :disabled="busy" @input="handlePathChange" />
</el-form-item>
<template v-else>
<el-upload
v-if="!uploadedPath"
ref="dragUploadRef"
drag
:limit="1"
:auto-upload="false"
:show-file-list="false"
:disabled="busy || !!pendingTaskID"
:on-change="onFileChange"
:on-exceed="onFileExceed"
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">{{ $t('container.imageUploadDrop') }}</div>
</el-upload>
<p class="input-help">{{ $t('container.imageUploadHelper') }}</p>
<template v-if="localFile">
<p class="break-all">{{ localFile.name }} ({{ computeSize(localFile.size) }})</p>
<el-progress
:percentage="uploadPercent"
:status="uploadFailed ? 'exception' : uploadedPath ? 'success' : undefined"
>
<template v-if="uploadFailed" #default>
<el-button
link
type="danger"
icon="CircleClose"
:title="$t('commons.button.delete')"
:aria-label="$t('commons.button.delete')"
:disabled="busy || !!pendingTaskID"
@click="removeUpload"
/>
</template>
</el-progress>
<p v-if="uploadedPath" class="input-help break-all">{{ uploadedPath }}</p>
<el-button v-if="uploadFailed" :disabled="busy" @click="uploadLocalFile">
{{ $t('commons.button.retry') }}
</el-button>
<el-button v-if="uploadedPath" :disabled="busy || !!pendingTaskID" @click="removeUpload">
{{ $t('commons.button.delete') }}
</el-button>
</template>
</template>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button :disabled="loading" @click="loadVisible = false">
{{ $t('commons.button.cancel') }}
</el-button>
<el-button
:disabled="loading || !form.path.trim() || form.paths.length === 0"
type="primary"
@click="onSubmit(formRef)"
>
{{ $t('commons.button.import') }}
</el-button>
</span>
<el-button :disabled="busy" @click="loadVisible = false">{{ $t('commons.button.cancel') }}</el-button>
<el-button :disabled="!canImport" :loading="loading" type="primary" @click="onSubmit">
{{ $t('commons.button.import') }}
</el-button>
</template>
</DrawerPro>
<FileList ref="fileRef" @choose="loadLoadDir" />
<TaskLog ref="taskLogRef" width="70%" />
<FileList v-if="loadVisible" ref="fileRef" @choose="loadLoadDir" />
<TaskLog ref="taskLogRef" width="70%" @close="emit('search')" />
</template>
<script lang="ts" setup>
import FileList from '@/components/file-list/index.vue';
import { reactive, ref } from 'vue';
import { Rules } from '@/global/form-rules';
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue';
import { genFileId, type UploadFile, type UploadInstance, type UploadProps, type UploadRawFile } from 'element-plus';
import TaskLog from '@/components/log/task/index.vue';
import i18n from '@/lang';
import { ElForm } from 'element-plus';
import { imageLoad } from '@/api/modules/container';
import { chunkUploadFileData, deleteFileByNode, stopChunkUpload } from '@/api/modules/files';
import { searchTasks } from '@/api/modules/log';
import { loadBaseDir } from '@/api/modules/setting';
import { useGlobalStore } from '@/composables/useGlobalStore';
import { MsgSuccess } from '@/utils/message';
import { computeSize } from '@/utils/size';
import { newUUID } from '@/utils/id';
const { currentNode } = useGlobalStore();
const loading = ref(false);
const uploading = ref(false);
const busy = computed(() => loading.value || uploading.value);
const fileRef = ref();
const taskLogRef = ref();
const uploadRef = ref<UploadInstance>();
const dragUploadRef = ref<UploadInstance>();
const loadVisible = ref(false);
const form = reactive({
path: '',
paths: [] as string[],
taskID: '',
});
const source = ref<'server' | 'local'>('local');
const operateNode = ref('');
const form = reactive({ path: '', paths: [] as string[] });
const localFile = ref<UploadRawFile>();
const uploadPercent = ref(0);
const uploadFailed = ref(false);
const uploadedPath = ref('');
const uploadDir = ref('');
const pendingTaskID = ref('');
let uploadID = '';
let uploadController: AbortController | undefined;
let taskTimer: ReturnType<typeof setTimeout> | undefined;
let disposed = false;
const emit = defineEmits<{ (e: 'search'): void }>();
const canImport = computed(
() =>
!busy.value && !pendingTaskID.value && (source.value === 'local' ? !!uploadedPath.value : !!form.paths.length),
);
const resetLocalFile = () => {
localFile.value = undefined;
uploadedPath.value = '';
uploadDir.value = '';
uploadPercent.value = 0;
uploadFailed.value = false;
uploadRef.value?.clearFiles();
dragUploadRef.value?.clearFiles();
};
const acceptParams = () => {
loadVisible.value = true;
if (busy.value) return;
if (operateNode.value !== currentNode.value) resetLocalFile();
operateNode.value = currentNode.value;
source.value = 'local';
form.path = '';
form.paths = [];
loadVisible.value = true;
};
const handleClose = () => {
loadVisible.value = false;
const beforeClose = (done: () => void) => {
if (!busy.value) done();
};
const onServerSelect = () => {
if (busy.value) return;
source.value = 'server';
fileRef.value.acceptParams({ dir: false, multiple: true });
};
const onFileChange = (file: UploadFile) => {
if (!file.raw || busy.value || pendingTaskID.value) return;
source.value = 'local';
uploadedPath.value = '';
uploadDir.value = '';
localFile.value = file.raw;
void uploadLocalFile();
};
const onFileExceed: UploadProps['onExceed'] = (files) => {
if (busy.value || pendingTaskID.value || !files.length) return;
uploadRef.value?.clearFiles();
dragUploadRef.value?.clearFiles();
const file = files[0] as UploadRawFile;
file.uid = genFileId();
uploadRef.value?.handleStart(file);
};
const emit = defineEmits<{ (e: 'search'): void }>();
type FormInstance = InstanceType<typeof ElForm>;
const formRef = ref<FormInstance>();
const onSubmit = async (formEl: FormInstance | undefined) => {
if (!formEl || !form.path.trim() || form.paths.length === 0) return;
formEl.validate(async (valid) => {
if (!valid) return;
loading.value = true;
form.taskID = newUUID();
await imageLoad({ paths: form.paths, taskID: form.taskID })
.then(() => {
loading.value = false;
loadVisible.value = false;
openTaskLog(form.taskID);
emit('search');
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
})
.catch(() => {
loading.value = false;
const uploadLocalFile = async () => {
const file = localFile.value;
if (!file || busy.value) return;
uploading.value = true;
uploadFailed.value = false;
uploadPercent.value = 0;
const node = operateNode.value;
const id = newUUID();
uploadID = id;
const controller = new AbortController();
uploadController = controller;
try {
const { data: baseDir } = await loadBaseDir(node);
if (controller.signal.aborted) return;
const directory = `${baseDir}/uploads/image/${id}`;
const chunkSize = 1024 * 1024;
// Empty files also reach Docker, which validates the archive contents.
const count = Math.max(1, Math.ceil(file.size / chunkSize));
for (let index = 0; index < count; index++) {
const data = new FormData();
data.append('filename', file.name);
data.append('path', directory);
data.append('uploadID', id);
data.append('chunk', file.slice(index * chunkSize, (index + 1) * chunkSize));
data.append('chunkIndex', String(index));
data.append('chunkCount', String(count));
await chunkUploadFileData(data, {
headers: { CurrentNode: node },
signal: controller.signal,
onUploadProgress: (event) => {
const fraction = event.total ? Math.min(1, event.loaded / event.total) : 0;
uploadPercent.value = Math.min(99, Math.floor(((index + fraction) * 100) / count));
},
});
});
};
const openTaskLog = (taskID: string) => {
taskLogRef.value.openWithTaskID(taskID);
};
const handlePathChange = () => {
if (!form.path.trim()) {
form.paths = [];
}
uploadDir.value = directory;
uploadedPath.value = `${directory}/${file.name}`;
uploadPercent.value = 100;
MsgSuccess(i18n.global.t('file.uploadSuccess'));
} catch {
if (!controller.signal.aborted) uploadFailed.value = true;
await stopChunkUpload(id, node).catch(() => {});
} finally {
uploading.value = false;
uploadController = undefined;
uploadID = '';
}
};
const loadLoadDir = async (paths: string | string[]) => {
const newPaths = Array.isArray(paths) ? paths : [paths];
form.paths = [...new Set(newPaths)];
form.path = form.paths.join('; ');
const removeUpload = async () => {
if (busy.value || pendingTaskID.value || !localFile.value) return;
loading.value = true;
try {
if (uploadDir.value) {
await deleteFileByNode({ path: uploadDir.value, isDir: true, forceDelete: true }, operateNode.value);
}
resetLocalFile();
source.value = 'local';
form.path = '';
form.paths = [];
} finally {
loading.value = false;
}
};
defineExpose({
acceptParams,
const pollTask = async (taskID: string, node: string) => {
try {
const { data } = await searchTasks({ page: 1, pageSize: 1, type: '', status: '', taskID }, node);
const status = data.items?.[0]?.status;
if (status === 'Success' || status === 'Failed' || status === 'Canceled') {
pendingTaskID.value = '';
if (currentNode.value === node) emit('search');
return;
}
} catch {
// Keep the upload protected while the task status is unknown.
}
if (!disposed) taskTimer = setTimeout(() => void pollTask(taskID, node), 3000);
};
const onSubmit = async () => {
if (!canImport.value) return;
loading.value = true;
const taskID = newUUID();
const node = operateNode.value;
try {
await imageLoad({ paths: source.value === 'local' ? [uploadedPath.value] : form.paths, taskID }, node);
pendingTaskID.value = taskID;
loadVisible.value = false;
taskLogRef.value.openWithTaskID(taskID, true, node);
MsgSuccess(i18n.global.t('container.imageImportSubmitted'));
void pollTask(taskID, node);
} finally {
loading.value = false;
}
};
const handlePathChange = () => {
form.paths = [
...new Set(
form.path
.split(';')
.map((path) => path.trim())
.filter(Boolean),
),
];
};
const loadLoadDir = (paths: string | string[]) => {
if (currentNode.value !== operateNode.value) return;
source.value = 'server';
form.paths = [...new Set(Array.isArray(paths) ? paths : [paths])];
form.path = form.paths.join('; ');
};
watch(currentNode, () => {
loadVisible.value = false;
});
onBeforeUnmount(() => {
disposed = true;
clearTimeout(taskTimer);
uploadController?.abort();
if (uploadID) void stopChunkUpload(uploadID, operateNode.value).catch(() => {});
});
defineExpose({ acceptParams });
</script>
+145 -29
View File
@@ -81,8 +81,23 @@
:style="{ height: `calc(100vh - ${loadHeight()})`, 'background-color': '#000' }"
:description="loadErrMsg()"
>
<el-button v-if="currentDB.from === 'remote'" v-permission type="primary" @click="installCli">
{{ $t('commons.button.enable') }}
<el-button
v-if="currentDB.from === 'remote'"
v-permission
type="primary"
:loading="cliSubmitting"
:disabled="!cliStatusLoaded || cliInstalling"
@click="installCli"
>
{{ cliInstalling ? $t('database.redisCliEnabling') : $t('commons.button.enable') }}
</el-button>
<el-button
v-if="currentDB.from === 'remote' && cliTaskID && (cliInstalling || redisCliExist)"
link
type="primary"
@click="openCliTaskLog"
>
{{ $t('commons.button.log') }}
</el-button>
</el-empty>
<div>
@@ -134,6 +149,7 @@
</DialogPro>
<QuickCmd ref="dialogQuickCmdRef" @reload="loadQuickCmd" />
<TaskLog ref="cliTaskLogRef" />
</div>
</template>
@@ -143,17 +159,21 @@ import Conn from '@/views/database/redis/conn/index.vue';
import Terminal from '@/components/terminal/index.vue';
import AppStatus from '@/components/app-status/index.vue';
import QuickCmd from '@/views/database/redis/command/index.vue';
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { checkAppInstalled } from '@/api/modules/app';
import { useGlobalStore } from '@/composables/useGlobalStore';
import { listDatabases, checkRedisCli, installRedisCli } from '@/api/modules/database';
import { listDatabases, loadRedisCliStatus, installRedisCli } from '@/api/modules/database';
import { Database } from '@/api/interface/database';
import { MsgSuccess } from '@/utils/message';
import { MsgError, MsgSuccess } from '@/utils/message';
import i18n from '@/lang';
import { getCommandList } from '@/api/modules/command';
import { routerToName, routerToNameWithQuery } from '@/utils/router';
import { useOperateNodeContext } from '@/composables/useOperateNodeContext';
import TaskLog from '@/components/log/task/index.vue';
import bus from '@/global/bus';
import { newUUID } from '@/utils/id';
const { currentNode, currentRedisDB, openMenuTabs } = useGlobalStore();
useOperateNodeContext(currentNode);
@@ -170,7 +190,21 @@ const appStatusRef = ref();
const open = ref(false);
const redisCliExist = ref();
const redisCliExist = ref(false);
const cliSubmitting = ref(false);
const cliStatusLoaded = ref(false);
const cliTaskID = ref('');
const cliTaskStatus = ref('');
const cliTaskLogRef = ref<InstanceType<typeof TaskLog>>();
const cliInstalling = computed(() => cliTaskStatus.value === 'Executing');
const cliStatusText = computed(() => {
if (cliInstalling.value) return i18n.global.t('database.redisCliEnabling');
if (cliTaskStatus.value === 'Failed') return i18n.global.t('database.redisCliEnableFailed');
return redisCliExist.value ? i18n.global.t('database.redisCliEnabled') : i18n.global.t('database.redisCliHelper');
});
let cliPollTimer: ReturnType<typeof setTimeout>;
let cliGeneration = 0;
let disposed = false;
const appKey = ref('redis');
const appName = ref();
@@ -248,8 +282,10 @@ const changeDatabase = async () => {
};
const loadDBOptions = async () => {
const generation = cliGeneration;
try {
const res = await listDatabases('redis,redis-cluster');
if (disposed || generation !== cliGeneration) return;
let datas = res.data || [];
dbOptionsLocal.value = [];
dbOptionsRemote.value = [];
@@ -286,14 +322,14 @@ const loadDBOptions = async () => {
reOpenTerminal();
}
} finally {
isLoaded.value = true;
if (!disposed && generation === cliGeneration) isLoaded.value = true;
}
};
const loadErrMsg = () => {
return currentDB.value.from === 'local'
? i18n.global.t('commons.service.serviceNotStarted', ['Redis'])
: i18n.global.t('database.redisCliHelper');
: cliStatusText.value;
};
const reOpenTerminal = async () => {
closeTerminal(false);
@@ -301,6 +337,10 @@ const reOpenTerminal = async () => {
};
const initTerminal = async () => {
if (disposed || !currentDB.value) return;
const generation = cliGeneration;
const database = currentDBName.value;
const isCurrent = () => !disposed && generation === cliGeneration && database === currentDBName.value;
loading.value = true;
if (currentDB.value.from === 'remote') {
if (!redisCliExist.value) {
@@ -311,9 +351,10 @@ const initTerminal = async () => {
loading.value = false;
redisIsExist.value = true;
nextTick(() => {
if (!isCurrent()) return;
terminalShow.value = true;
redisStatus.value = 'Running';
terminalRef.value.acceptParams({
terminalRef.value?.acceptParams({
endpoint: '/api/v2/hosts/terminal/container',
args: `source=redis&name=${currentDBName.value}&from=${currentDB.value.from}`,
error: '',
@@ -325,13 +366,15 @@ const initTerminal = async () => {
}
await checkAppInstalled(currentDB.value.type, currentDBName.value)
.then((res) => {
if (!isCurrent()) return;
redisIsExist.value = res.data.isExist;
redisStatus.value = res.data.status;
loading.value = false;
nextTick(() => {
if (!isCurrent()) return;
if (res.data.status === 'Running') {
terminalShow.value = true;
terminalRef.value.acceptParams({
terminalRef.value?.acceptParams({
endpoint: '/api/v2/hosts/terminal/container',
args: `source=${currentDB.value.type}&name=${currentDBName.value}&from=${currentDB.value.from}`,
error: '',
@@ -342,6 +385,7 @@ const initTerminal = async () => {
isRefresh.value = !isRefresh.value;
})
.catch(() => {
if (!isCurrent()) return;
closeTerminal(false);
loading.value = false;
});
@@ -352,28 +396,78 @@ const closeTerminal = async (isKeepShow: boolean) => {
terminalShow.value = isKeepShow;
};
const checkCliValid = async () => {
await checkRedisCli()
.then((res) => {
redisCliExist.value = res.data;
loadDBOptions();
})
.catch(() => {
loadDBOptions();
});
const openCliTaskLog = () => {
if (cliTaskID.value) cliTaskLogRef.value?.openWithTaskID(cliTaskID.value, cliInstalling.value, currentNode.value);
};
const applyCliStatus = (status: Database.RedisCliStatus) => {
const wasInstalling = cliInstalling.value;
const wasInstalled = redisCliExist.value;
redisCliExist.value = status.installed;
cliTaskID.value = status.taskID;
cliTaskStatus.value = status.status;
cliStatusLoaded.value = true;
if (!wasInstalled && status.installed && !cliInstalling.value && currentDB.value?.from === 'remote') {
reOpenTerminal();
}
if (wasInstalling && !cliInstalling.value) {
bus.emit('refreshTask', true);
if (status.status === 'Success' && status.installed) {
MsgSuccess(i18n.global.t('database.redisCliEnabled'));
} else if (status.status === 'Failed') {
MsgError(i18n.global.t('database.redisCliEnableFailed'));
}
}
};
const scheduleCliPoll = (generation: number) => {
clearTimeout(cliPollTimer);
if (disposed || generation !== cliGeneration) return;
cliPollTimer = setTimeout(() => refreshCliStatus(generation), 2000);
};
const refreshCliStatus = async (generation: number) => {
try {
const res = await loadRedisCliStatus(currentNode.value);
if (disposed || generation !== cliGeneration) return;
applyCliStatus(res.data);
if (cliInstalling.value) scheduleCliPoll(generation);
} catch {
// Keep tracking a running task after a transient request failure.
if (!disposed && generation === cliGeneration) scheduleCliPoll(generation);
}
};
const checkCliValid = async () => {
const generation = cliGeneration;
await refreshCliStatus(generation);
if (!disposed && generation === cliGeneration) await loadDBOptions();
};
const installCli = async () => {
loading.value = true;
await installRedisCli()
.then(() => {
loading.value = false;
redisCliExist.value = true;
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
if (cliSubmitting.value || cliInstalling.value) return;
const generation = cliGeneration;
const node = currentNode.value;
cliSubmitting.value = true;
clearTimeout(cliPollTimer);
try {
const res = await installRedisCli(newUUID(), node);
if (disposed || generation !== cliGeneration) return;
applyCliStatus(res.data);
bus.emit('refreshTask', true);
if (cliInstalling.value) {
MsgSuccess(i18n.global.t('database.redisCliTaskSubmitted'));
openCliTaskLog();
scheduleCliPoll(generation);
} else if (res.data.installed && currentDB.value?.from === 'remote') {
reOpenTerminal();
})
.catch(() => {
loading.value = false;
});
}
} catch {
// The server may have accepted the task even if the response was lost.
if (!disposed && generation === cliGeneration) await refreshCliStatus(generation);
} finally {
if (!disposed && generation === cliGeneration) cliSubmitting.value = false;
}
};
const loadQuickCmd = async () => {
@@ -392,6 +486,25 @@ const onSetQuickCmd = () => {
dialogQuickCmdRef.value.acceptParams();
};
watch(currentNode, () => {
cliGeneration++;
clearTimeout(cliPollTimer);
cliTaskLogRef.value?.handleClose();
closeTerminal(false);
redisCliExist.value = false;
cliSubmitting.value = false;
cliStatusLoaded.value = false;
cliTaskID.value = '';
cliTaskStatus.value = '';
currentDB.value = undefined;
redisStatus.value = undefined;
dbOptionsLocal.value = [];
dbOptionsRemote.value = [];
isLoaded.value = false;
loading.value = false;
checkCliValid();
});
onMounted(() => {
loadQuickCmd();
checkCliValid();
@@ -403,6 +516,9 @@ const onAfter = () => {
initTerminal();
};
onBeforeUnmount(() => {
disposed = true;
cliGeneration++;
clearTimeout(cliPollTimer);
closeTerminal(false);
});
</script>
@@ -44,38 +44,40 @@
</div>
</template>
<table style="width: 100%" class="tab-table">
<tr v-for="(row, index) in form.saves" :key="index">
<td width="32%">
<el-input type="number" v-model="row.second"></el-input>
</td>
<td width="80px">
{{ $t('database.rdbHelper1') }}
</td>
<td width="32%">
<el-input type="number" v-model="row.count"></el-input>
</td>
<td width="10%">
{{ $t('database.rdbHelper2') }}
</td>
<td>
<el-button
link
type="primary"
style="font-size: 10px"
v-permission
@click="handleDelete(index)"
>
{{ $t('commons.button.delete') }}
</el-button>
</td>
</tr>
<tr>
<td align="left">
<el-button v-permission @click="handleAdd()">
{{ $t('commons.button.add') }}
</el-button>
</td>
</tr>
<tbody>
<tr v-for="(row, index) in form.saves" :key="index">
<td width="32%">
<el-input type="number" v-model="row.second"></el-input>
</td>
<td width="80px">
{{ $t('database.rdbHelper1') }}
</td>
<td width="32%">
<el-input type="number" v-model="row.count"></el-input>
</td>
<td width="10%">
{{ $t('database.rdbHelper2') }}
</td>
<td>
<el-button
link
type="primary"
style="font-size: 10px"
v-permission
@click="handleDelete(index)"
>
{{ $t('commons.button.delete') }}
</el-button>
</td>
</tr>
<tr>
<td align="left">
<el-button v-permission @click="handleAdd()">
{{ $t('commons.button.add') }}
</el-button>
</td>
</tr>
</tbody>
</table>
<div>
<span style="margin-left: 2px; margin-top: 5px">{{ $t('database.rdbHelper3') }}</span>