mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-10-13 00:00:36 +00:00
feat: redis sync and image upload
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '構建參數',
|
||||
|
||||
@@ -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: '构建参数',
|
||||
|
||||
@@ -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' },
|
||||
);
|
||||
};
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user