mirror of
https://github.com/acepanel/panel.git
synced 2026-02-06 11:23:44 +08:00
feat: 添加容器终端功能 (#1216)
* Initial plan * feat: 添加容器终端功能 - 通过 WebSocket 进入容器执行命令 Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com> * fix: 将 fmt.Errorf 错误信息改为英语 Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com> * feat: 前端优化 * feat: 容器优化 * feat: 前端优化 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com> Co-authored-by: 耗子 <haozi@loli.email>
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
type ContainerImageRepo interface {
|
||||
List() ([]types.ContainerImage, error)
|
||||
Exist(name string) (bool, error)
|
||||
Pull(req *request.ContainerImagePull) error
|
||||
Remove(id string) error
|
||||
Prune() error
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/moby/moby/api/types/registry"
|
||||
"github.com/moby/moby/client"
|
||||
|
||||
@@ -58,6 +59,25 @@ func (r *containerImageRepo) List() ([]types.ContainerImage, error) {
|
||||
return images, nil
|
||||
}
|
||||
|
||||
// Exist 检查镜像是否存在
|
||||
func (r *containerImageRepo) Exist(name string) (bool, error) {
|
||||
apiClient, err := getDockerClient("/var/run/docker.sock")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func(apiClient *client.Client) { _ = apiClient.Close() }(apiClient)
|
||||
|
||||
_, err = apiClient.ImageInspect(context.Background(), name)
|
||||
if err != nil {
|
||||
if cerrdefs.IsNotFound(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Pull 拉取镜像
|
||||
func (r *containerImageRepo) Pull(req *request.ContainerImagePull) error {
|
||||
apiClient, err := getDockerClient("/var/run/docker.sock")
|
||||
|
||||
@@ -391,6 +391,7 @@ func (route *Http) Register(r *chi.Mux) {
|
||||
})
|
||||
r.Route("/image", func(r chi.Router) {
|
||||
r.Get("/", route.containerImage.List)
|
||||
r.Get("/exist", route.containerImage.Exist)
|
||||
r.Post("/", route.containerImage.Pull)
|
||||
r.Delete("/{id}", route.containerImage.Remove)
|
||||
r.Post("/prune", route.containerImage.Prune)
|
||||
|
||||
@@ -20,5 +20,7 @@ func (route *Ws) Register(r *chi.Mux) {
|
||||
r.Route("/api/ws", func(r chi.Router) {
|
||||
r.Get("/ssh", route.ws.Session)
|
||||
r.Get("/exec", route.ws.Exec)
|
||||
r.Get("/container/{id}", route.ws.ContainerTerminal)
|
||||
r.Get("/container/image/pull", route.ws.ContainerImagePull)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -34,6 +34,22 @@ func (s *ContainerImageService) List(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ContainerImageService) Exist(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := Bind[request.ContainerImagePull](r)
|
||||
if err != nil {
|
||||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
exist, err := s.containerImageRepo.Exist(req.Name)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, exist)
|
||||
}
|
||||
|
||||
func (s *ContainerImageService) Pull(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := Bind[request.ContainerImagePull](r)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,16 +3,21 @@ package service
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/leonelquinteros/gotext"
|
||||
"github.com/moby/moby/api/types/registry"
|
||||
"github.com/moby/moby/client"
|
||||
stdssh "golang.org/x/crypto/ssh"
|
||||
|
||||
"github.com/acepanel/panel/internal/biz"
|
||||
"github.com/acepanel/panel/internal/http/request"
|
||||
"github.com/acepanel/panel/pkg/config"
|
||||
"github.com/acepanel/panel/pkg/docker"
|
||||
"github.com/acepanel/panel/pkg/shell"
|
||||
"github.com/acepanel/panel/pkg/ssh"
|
||||
)
|
||||
@@ -114,6 +119,42 @@ func (s *WsService) Exec(w http.ResponseWriter, r *http.Request) {
|
||||
s.readLoop(ctx, ws)
|
||||
}
|
||||
|
||||
// ContainerTerminal 容器终端 WebSocket 处理
|
||||
func (s *WsService) ContainerTerminal(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := Bind[request.ContainerID](r)
|
||||
if err != nil {
|
||||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
ws, err := s.upgrade(w, r)
|
||||
if err != nil {
|
||||
s.log.Warn("[Websocket] upgrade container terminal ws error", slog.Any("err", err))
|
||||
return
|
||||
}
|
||||
defer func(ws *websocket.Conn) { _ = ws.CloseNow() }(ws)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// 默认使用 bash 作为 shell,如果不存在则回退到 sh
|
||||
turn, err := docker.NewTurn(ctx, ws, req.ID, []string{"/bin/bash"})
|
||||
if err != nil {
|
||||
turn, err = docker.NewTurn(ctx, ws, req.ID, []string{"/bin/sh"})
|
||||
if err != nil {
|
||||
_ = ws.Close(websocket.StatusNormalClosure, s.t.Get("failed to start container terminal: %v", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer turn.Close()
|
||||
_ = turn.Handle(ctx)
|
||||
}()
|
||||
|
||||
turn.Wait()
|
||||
}
|
||||
|
||||
func (s *WsService) upgrade(w http.ResponseWriter, r *http.Request) (*websocket.Conn, error) {
|
||||
opts := &websocket.AcceptOptions{
|
||||
CompressionMode: websocket.CompressionContextTakeover,
|
||||
@@ -136,3 +177,95 @@ func (s *WsService) readLoop(ctx context.Context, c *websocket.Conn) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ContainerImagePull 镜像拉取 WebSocket 处理
|
||||
func (s *WsService) ContainerImagePull(w http.ResponseWriter, r *http.Request) {
|
||||
ws, err := s.upgrade(w, r)
|
||||
if err != nil {
|
||||
s.log.Warn("[Websocket] upgrade image pull ws error", slog.Any("err", err))
|
||||
return
|
||||
}
|
||||
defer func(ws *websocket.Conn) { _ = ws.CloseNow() }(ws)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
_, message, err := ws.Read(ctx)
|
||||
if err != nil {
|
||||
_ = ws.Close(websocket.StatusNormalClosure, s.t.Get("failed to read params: %v", err))
|
||||
return
|
||||
}
|
||||
var req request.ContainerImagePull
|
||||
if err = json.Unmarshal(message, &req); err != nil {
|
||||
_ = ws.Close(websocket.StatusNormalClosure, s.t.Get("invalid params: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// 创建 Docker 客户端
|
||||
apiClient, err := client.New(client.WithHost("unix:///var/run/docker.sock"))
|
||||
if err != nil {
|
||||
_ = ws.Close(websocket.StatusNormalClosure, s.t.Get("failed to create docker client: %v", err))
|
||||
return
|
||||
}
|
||||
defer func(apiClient *client.Client) { _ = apiClient.Close() }(apiClient)
|
||||
|
||||
// 构建拉取选项
|
||||
options := client.ImagePullOptions{}
|
||||
if req.Auth {
|
||||
authConfig := registry.AuthConfig{
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
}
|
||||
encodedJSON, err := json.Marshal(authConfig)
|
||||
if err != nil {
|
||||
_ = ws.Close(websocket.StatusNormalClosure, s.t.Get("failed to encode auth: %v", err))
|
||||
return
|
||||
}
|
||||
options.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON)
|
||||
}
|
||||
|
||||
// 拉取镜像
|
||||
resp, err := apiClient.ImagePull(ctx, req.Name, options)
|
||||
if err != nil {
|
||||
_ = ws.Close(websocket.StatusNormalClosure, s.t.Get("failed to pull image: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// 迭代进度
|
||||
for msg, err := range resp.JSONMessages(ctx) {
|
||||
if err != nil {
|
||||
s.log.Warn("[Websocket] image pull error", slog.Any("err", err))
|
||||
errorMsg, _ := json.Marshal(map[string]any{
|
||||
"status": "error",
|
||||
"error": err.Error(),
|
||||
})
|
||||
_ = ws.Write(ctx, websocket.MessageText, errorMsg)
|
||||
return
|
||||
}
|
||||
|
||||
// 如果有错误,发送错误消息
|
||||
if msg.Error != nil {
|
||||
errorMsg, _ := json.Marshal(map[string]any{
|
||||
"status": "error",
|
||||
"error": msg.Error.Message,
|
||||
})
|
||||
_ = ws.Write(ctx, websocket.MessageText, errorMsg)
|
||||
return
|
||||
}
|
||||
|
||||
// 转发进度信息
|
||||
progressMsg, _ := json.Marshal(msg)
|
||||
if err = ws.Write(ctx, websocket.MessageText, progressMsg); err != nil {
|
||||
s.log.Warn("[Websocket] write image pull progress error", slog.Any("err", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 拉取完成
|
||||
completeMsg, _ := json.Marshal(map[string]any{
|
||||
"status": "complete",
|
||||
"complete": true,
|
||||
})
|
||||
_ = ws.Write(ctx, websocket.MessageText, completeMsg)
|
||||
_ = ws.Close(websocket.StatusNormalClosure, "")
|
||||
}
|
||||
|
||||
126
pkg/docker/turn.go
Normal file
126
pkg/docker/turn.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/moby/moby/client"
|
||||
)
|
||||
|
||||
// MessageResize 终端大小调整消息
|
||||
type MessageResize struct {
|
||||
Resize bool `json:"resize"`
|
||||
Columns uint `json:"columns"`
|
||||
Rows uint `json:"rows"`
|
||||
}
|
||||
|
||||
// Turn 容器终端转发器
|
||||
type Turn struct {
|
||||
ctx context.Context
|
||||
ws *websocket.Conn
|
||||
client *client.Client
|
||||
execID string
|
||||
hijack client.ExecAttachResult
|
||||
closeOnce bool
|
||||
}
|
||||
|
||||
// NewTurn 创建容器终端转发器
|
||||
func NewTurn(ctx context.Context, ws *websocket.Conn, containerID string, command []string) (*Turn, error) {
|
||||
apiClient, err := client.New(client.WithHost("unix:///var/run/docker.sock"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create docker client: %w", err)
|
||||
}
|
||||
|
||||
// 创建 exec 实例
|
||||
execCreateResp, err := apiClient.ExecCreate(ctx, containerID, client.ExecCreateOptions{
|
||||
Cmd: command,
|
||||
AttachStdin: true,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
TTY: true,
|
||||
})
|
||||
if err != nil {
|
||||
_ = apiClient.Close()
|
||||
return nil, fmt.Errorf("failed to create exec instance: %w", err)
|
||||
}
|
||||
|
||||
// 附加到 exec 实例
|
||||
hijack, err := apiClient.ExecAttach(ctx, execCreateResp.ID, client.ExecAttachOptions{
|
||||
TTY: true,
|
||||
})
|
||||
if err != nil {
|
||||
_ = apiClient.Close()
|
||||
return nil, fmt.Errorf("failed to attach to exec instance: %w", err)
|
||||
}
|
||||
|
||||
turn := &Turn{
|
||||
ctx: ctx,
|
||||
ws: ws,
|
||||
client: apiClient,
|
||||
execID: execCreateResp.ID,
|
||||
hijack: hijack,
|
||||
}
|
||||
|
||||
return turn, nil
|
||||
}
|
||||
|
||||
// Write 实现 io.Writer 接口,将容器输出写入 WebSocket
|
||||
func (t *Turn) Write(p []byte) (n int, err error) {
|
||||
if err = t.ws.Write(t.ctx, websocket.MessageText, p); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// Close 关闭连接
|
||||
func (t *Turn) Close() {
|
||||
if t.closeOnce {
|
||||
return
|
||||
}
|
||||
t.closeOnce = true
|
||||
t.hijack.Close()
|
||||
_ = t.client.Close()
|
||||
}
|
||||
|
||||
// Handle 处理 WebSocket 消息
|
||||
func (t *Turn) Handle(ctx context.Context) error {
|
||||
var resize MessageResize
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
_, data, err := t.ws.Read(ctx)
|
||||
if err != nil {
|
||||
// 通常是客户端关闭连接
|
||||
return fmt.Errorf("failed to read ws message: %w", err)
|
||||
}
|
||||
|
||||
// 判断是否是 resize 消息
|
||||
if err = json.Unmarshal(data, &resize); err == nil {
|
||||
if resize.Resize && resize.Columns > 0 && resize.Rows > 0 {
|
||||
if _, err = t.client.ExecResize(ctx, t.execID, client.ExecResizeOptions{
|
||||
Height: resize.Rows,
|
||||
Width: resize.Columns,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to resize terminal: %w", err)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err = t.hijack.Conn.Write(data); err != nil {
|
||||
return fmt.Errorf("failed to write to container stdin: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait 等待容器输出并转发到 WebSocket
|
||||
func (t *Turn) Wait() {
|
||||
_, _ = io.Copy(t, t.hijack.Reader)
|
||||
}
|
||||
@@ -55,6 +55,8 @@ export default {
|
||||
// 获取镜像列表
|
||||
imageList: (page: number, limit: number): any =>
|
||||
http.Get(`/container/image`, { params: { page, limit } }),
|
||||
// 检查镜像是否存在
|
||||
imageExist: (name: string): any => http.Get(`/container/image/exist`, { params: { name } }),
|
||||
// 拉取镜像
|
||||
imagePull: (config: any): any => http.Post(`/container/image`, config),
|
||||
// 删除镜像
|
||||
|
||||
@@ -20,5 +20,26 @@ export default {
|
||||
ws.onopen = () => resolve(ws)
|
||||
ws.onerror = (e) => reject(e)
|
||||
})
|
||||
},
|
||||
// 连接容器终端
|
||||
container: (id: string): Promise<WebSocket> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(`${base}/container/${id}`)
|
||||
ws.onopen = () => resolve(ws)
|
||||
ws.onerror = (e) => reject(e)
|
||||
})
|
||||
},
|
||||
// 拉取镜像
|
||||
imagePull: (name: string, auth?: { username: string; password: string }): Promise<WebSocket> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(`${base}/container/image/pull`)
|
||||
ws.onopen = () => {
|
||||
ws.send(
|
||||
JSON.stringify({ name, auth: !!auth, username: auth?.username, password: auth?.password })
|
||||
)
|
||||
resolve(ws)
|
||||
}
|
||||
ws.onerror = (e) => reject(e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useThemeStore } from '@/store'
|
||||
import { getMonaco } from '@/utils/monaco'
|
||||
import type * as Monaco from 'monaco-editor'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
|
||||
const value = defineModel<string>('value', { type: String, required: true })
|
||||
const props = defineProps({
|
||||
@@ -27,6 +28,7 @@ const monacoRef = shallowRef<typeof Monaco>()
|
||||
const loading = ref(true)
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const themeVars = useThemeVars()
|
||||
|
||||
async function initEditor() {
|
||||
if (!containerRef.value) return
|
||||
@@ -37,7 +39,7 @@ async function initEditor() {
|
||||
editorRef.value = monaco.editor.create(containerRef.value, {
|
||||
value: value.value,
|
||||
language: props.lang,
|
||||
theme: 'vs-dark',
|
||||
theme: 'vs' + (themeStore.darkMode ? '-dark' : ''),
|
||||
readOnly: props.readOnly,
|
||||
automaticLayout: true,
|
||||
smoothScrolling: true,
|
||||
@@ -92,7 +94,7 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="common-editor" :style="{ height: props.height }">
|
||||
<div class="common-editor" :style="{ height: props.height, borderColor: themeVars.borderColor }">
|
||||
<div v-if="loading" class="editor-loading">
|
||||
<n-spin size="medium" />
|
||||
</div>
|
||||
@@ -104,6 +106,9 @@ onBeforeUnmount(() => {
|
||||
.common-editor {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border: 1px solid;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-loading {
|
||||
|
||||
@@ -10,7 +10,7 @@ const { $gettext } = useGettext()
|
||||
const fileStore = useFileStore()
|
||||
const router = useRouter()
|
||||
|
||||
const forcePush = ref(false)
|
||||
const forcePull = ref(false)
|
||||
|
||||
const createModel = ref({
|
||||
name: '',
|
||||
@@ -19,6 +19,8 @@ const createModel = ref({
|
||||
})
|
||||
const createModal = ref(false)
|
||||
|
||||
const selectedRowKeys = ref<any>([])
|
||||
|
||||
const updateModel = ref({
|
||||
name: '',
|
||||
compose: '',
|
||||
@@ -27,6 +29,7 @@ const updateModel = ref({
|
||||
const updateModal = ref(false)
|
||||
|
||||
const columns: any = [
|
||||
{ type: 'selection', fixed: 'left' },
|
||||
{
|
||||
title: $gettext('Name'),
|
||||
key: 'name',
|
||||
@@ -104,10 +107,10 @@ const columns: any = [
|
||||
const messageReactive = window.$message.loading($gettext('Starting...'), {
|
||||
duration: 0
|
||||
})
|
||||
useRequest(container.composeUp(row.name, forcePush.value))
|
||||
useRequest(container.composeUp(row.name, forcePull.value))
|
||||
.onSuccess(() => {
|
||||
refresh()
|
||||
forcePush.value = false
|
||||
forcePull.value = false
|
||||
window.$message.success($gettext('Start successful'))
|
||||
})
|
||||
.onComplete(() => {
|
||||
@@ -137,8 +140,8 @@ const columns: any = [
|
||||
h(
|
||||
NCheckbox,
|
||||
{
|
||||
checked: forcePush.value,
|
||||
onUpdateChecked: (v) => (forcePush.value = v)
|
||||
checked: forcePull.value,
|
||||
onUpdateChecked: (v) => (forcePull.value = v)
|
||||
},
|
||||
{ default: () => $gettext('Force pull images') }
|
||||
)
|
||||
@@ -171,7 +174,7 @@ const columns: any = [
|
||||
useRequest(container.composeDown(row.name))
|
||||
.onSuccess(() => {
|
||||
refresh()
|
||||
forcePush.value = false
|
||||
forcePull.value = false
|
||||
window.$message.success($gettext('Stop successful'))
|
||||
})
|
||||
.onComplete(() => {
|
||||
@@ -282,6 +285,15 @@ const handleUpdate = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
const promises = selectedRowKeys.value.map((name: any) => container.composeRemove(name))
|
||||
await Promise.all(promises)
|
||||
|
||||
selectedRowKeys.value = []
|
||||
refresh()
|
||||
window.$message.success($gettext('Delete successful'))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refresh()
|
||||
})
|
||||
@@ -290,9 +302,17 @@ onMounted(() => {
|
||||
<template>
|
||||
<n-flex vertical :size="20">
|
||||
<n-flex>
|
||||
<n-button type="primary" @click="createModal = true">{{
|
||||
$gettext('Create Compose')
|
||||
}}</n-button>
|
||||
<n-button type="primary" @click="createModal = true">
|
||||
{{ $gettext('Create Compose') }}
|
||||
</n-button>
|
||||
<n-popconfirm @positive-click="handleBatchDelete">
|
||||
<template #trigger>
|
||||
<n-button type="error" :disabled="selectedRowKeys.length === 0" ghost>
|
||||
{{ $gettext('Delete') }}
|
||||
</n-button>
|
||||
</template>
|
||||
{{ $gettext('Are you sure you want to delete the selected composes?') }}
|
||||
</n-popconfirm>
|
||||
</n-flex>
|
||||
<n-data-table
|
||||
striped
|
||||
@@ -302,6 +322,7 @@ onMounted(() => {
|
||||
:data="data"
|
||||
:columns="columns"
|
||||
:row-key="(row: any) => row.name"
|
||||
v-model:checked-row-keys="selectedRowKeys"
|
||||
v-model:page="page"
|
||||
v-model:pageSize="pageSize"
|
||||
:pagination="{
|
||||
@@ -329,11 +350,7 @@ onMounted(() => {
|
||||
<n-input v-model:value="createModel.name" type="text" />
|
||||
</n-form-item>
|
||||
<n-form-item path="compose" :label="$gettext('Compose')">
|
||||
<n-input
|
||||
v-model:value="createModel.compose"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 10, maxRows: 20 }"
|
||||
/>
|
||||
<common-editor v-model:value="createModel.compose" lang="yaml" height="40vh" />
|
||||
</n-form-item>
|
||||
<n-form-item path="envs" :label="$gettext('Environment Variables')">
|
||||
<n-dynamic-input
|
||||
@@ -359,11 +376,7 @@ onMounted(() => {
|
||||
>
|
||||
<n-form :model="updateModel">
|
||||
<n-form-item path="compose" :label="$gettext('Compose')">
|
||||
<n-input
|
||||
v-model:value="updateModel.compose"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 10, maxRows: 20 }"
|
||||
/>
|
||||
<common-editor v-model:value="updateModel.compose" lang="yaml" height="40vh" />
|
||||
</n-form-item>
|
||||
<n-form-item path="envs" :label="$gettext('Environment Variables')">
|
||||
<n-dynamic-input
|
||||
|
||||
@@ -1,55 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import container from '@/api/panel/container'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import ImagePullModal from './ImagePullModal.vue'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
const show = defineModel<boolean>('show', { type: Boolean, required: true })
|
||||
|
||||
const { show } = toRefs(props)
|
||||
const doSubmit = ref(false)
|
||||
const currentTab = ref('basic')
|
||||
|
||||
// 镜像拉取
|
||||
const showPullModal = ref(false)
|
||||
|
||||
const createModel = reactive({
|
||||
name: '',
|
||||
image: '',
|
||||
publish_all_ports: false,
|
||||
ports: [
|
||||
{
|
||||
container_start: 80,
|
||||
container_end: 80,
|
||||
host_start: 80,
|
||||
host_end: 80,
|
||||
host: '',
|
||||
protocol: 'tcp'
|
||||
}
|
||||
],
|
||||
ports: [] as {
|
||||
container_start: number
|
||||
container_end: number
|
||||
host_start: number
|
||||
host_end: number
|
||||
host: string
|
||||
protocol: string
|
||||
}[],
|
||||
network: '',
|
||||
volumes: [
|
||||
{
|
||||
host: '/www',
|
||||
container: '/www',
|
||||
mode: 'rw'
|
||||
}
|
||||
],
|
||||
volumes: [] as {
|
||||
host: string
|
||||
container: string
|
||||
mode: string
|
||||
}[],
|
||||
cpus: 0,
|
||||
memory: 0,
|
||||
env: [],
|
||||
command: [],
|
||||
tty: false,
|
||||
restart_policy: 'no',
|
||||
labels: [],
|
||||
entrypoint: [],
|
||||
auto_remove: false,
|
||||
image: '',
|
||||
cpu_shares: 1024,
|
||||
privileged: false,
|
||||
open_stdin: false
|
||||
env: [] as { key: string; value: string }[],
|
||||
labels: [] as { key: string; value: string }[],
|
||||
command: [] as string[],
|
||||
entrypoint: [] as string[],
|
||||
restart_policy: 'no',
|
||||
tty: false,
|
||||
open_stdin: false,
|
||||
auto_remove: false,
|
||||
privileged: false
|
||||
})
|
||||
const networks = ref<any>({})
|
||||
|
||||
const networks = ref<{ label: string; value: string }[]>([])
|
||||
|
||||
const restartPolicyOptions = [
|
||||
{ label: $gettext('None'), value: 'no' },
|
||||
@@ -58,319 +54,498 @@ const restartPolicyOptions = [
|
||||
{ label: $gettext('Unless stopped'), value: 'unless-stopped' }
|
||||
]
|
||||
|
||||
const addPortRow = () => {
|
||||
createModel.ports.push({
|
||||
container_start: 80,
|
||||
container_end: 80,
|
||||
host_start: 80,
|
||||
host_end: 80,
|
||||
host: '',
|
||||
protocol: 'tcp'
|
||||
})
|
||||
}
|
||||
const protocolOptions = [
|
||||
{ label: 'TCP', value: 'tcp' },
|
||||
{ label: 'UDP', value: 'udp' }
|
||||
]
|
||||
|
||||
const removePortRow = (index: number) => {
|
||||
createModel.ports.splice(index, 1)
|
||||
}
|
||||
const volumeModeOptions = [
|
||||
{ label: $gettext('Read-Write'), value: 'rw' },
|
||||
{ label: $gettext('Read-Only'), value: 'ro' }
|
||||
]
|
||||
|
||||
const addVolumeRow = () => {
|
||||
createModel.volumes.push({
|
||||
host: '/www',
|
||||
container: '/www',
|
||||
mode: 'rw'
|
||||
})
|
||||
}
|
||||
// 端口映射操作
|
||||
const onCreatePort = () => ({
|
||||
container_start: 80,
|
||||
container_end: 80,
|
||||
host_start: 80,
|
||||
host_end: 80,
|
||||
host: '',
|
||||
protocol: 'tcp'
|
||||
})
|
||||
|
||||
const removeVolumeRow = (index: number) => {
|
||||
createModel.volumes.splice(index, 1)
|
||||
}
|
||||
// 挂载卷操作
|
||||
const onCreateVolume = () => ({
|
||||
host: '/www',
|
||||
container: '/www',
|
||||
mode: 'rw'
|
||||
})
|
||||
|
||||
// 环境变量操作
|
||||
const onCreateEnv = () => ({ key: '', value: '' })
|
||||
|
||||
// 标签操作
|
||||
const onCreateLabel = () => ({ key: '', value: '' })
|
||||
|
||||
const getNetworks = () => {
|
||||
useRequest(container.networkList(1, 1000)).onSuccess(({ data }) => {
|
||||
networks.value = data.items.map((item: any) => {
|
||||
return {
|
||||
label: item.name,
|
||||
value: item.id
|
||||
}
|
||||
})
|
||||
networks.value = data.items.map((item: any) => ({
|
||||
label: item.name,
|
||||
value: item.id
|
||||
}))
|
||||
if (networks.value.length > 0) {
|
||||
createModel.network = networks.value[0].value
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
// 创建容器
|
||||
const createContainer = () => {
|
||||
doSubmit.value = true
|
||||
useRequest(container.containerCreate(createModel))
|
||||
.onSuccess(() => {
|
||||
window.$message.success($gettext('Created successfully'))
|
||||
handleClose()
|
||||
show.value = false
|
||||
})
|
||||
.onComplete(() => {
|
||||
doSubmit.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close')
|
||||
// 镜像拉取成功后创建容器
|
||||
const onPullSuccess = () => {
|
||||
createContainer()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getNetworks()
|
||||
// 提交处理
|
||||
const handleSubmit = () => {
|
||||
if (!createModel.image) {
|
||||
window.$message.warning($gettext('Please enter image name'))
|
||||
return
|
||||
}
|
||||
|
||||
doSubmit.value = true
|
||||
|
||||
// 检查镜像是否存在
|
||||
useRequest(container.imageExist(createModel.image))
|
||||
.onSuccess(({ data }) => {
|
||||
if (data) {
|
||||
// 镜像存在,直接创建容器
|
||||
createContainer()
|
||||
} else {
|
||||
// 镜像不存在,显示拉取弹窗
|
||||
showPullModal.value = true
|
||||
}
|
||||
})
|
||||
.onComplete(() => {
|
||||
if (!showPullModal.value) {
|
||||
doSubmit.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
createModel.name = ''
|
||||
createModel.image = ''
|
||||
createModel.publish_all_ports = false
|
||||
createModel.ports = []
|
||||
createModel.volumes = []
|
||||
createModel.cpus = 0
|
||||
createModel.memory = 0
|
||||
createModel.cpu_shares = 1024
|
||||
createModel.env = []
|
||||
createModel.labels = []
|
||||
createModel.command = []
|
||||
createModel.entrypoint = []
|
||||
createModel.restart_policy = 'no'
|
||||
createModel.tty = false
|
||||
createModel.open_stdin = false
|
||||
createModel.auto_remove = false
|
||||
createModel.privileged = false
|
||||
currentTab.value = 'basic'
|
||||
showPullModal.value = false
|
||||
}
|
||||
|
||||
watch(show, (val) => {
|
||||
if (val) {
|
||||
resetForm()
|
||||
getNetworks()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
v-model:show="show"
|
||||
:title="$gettext('Create Container')"
|
||||
preset="card"
|
||||
style="width: 60vw"
|
||||
style="width: 70vw"
|
||||
size="huge"
|
||||
:show="show"
|
||||
:bordered="false"
|
||||
:segmented="false"
|
||||
@close="handleClose"
|
||||
@mask-click="handleClose"
|
||||
:mask-closable="!doSubmit"
|
||||
:closable="!doSubmit"
|
||||
>
|
||||
<n-form :model="createModel">
|
||||
<n-form-item path="name" :label="$gettext('Container Name')">
|
||||
<n-input v-model:value="createModel.name" type="text" @keydown.enter.prevent />
|
||||
</n-form-item>
|
||||
<n-form-item path="name" :label="$gettext('Image')">
|
||||
<n-input v-model:value="createModel.image" type="text" @keydown.enter.prevent />
|
||||
</n-form-item>
|
||||
<n-form-item path="exposedAll" :label="$gettext('Ports')">
|
||||
<n-radio
|
||||
:checked="!createModel.publish_all_ports"
|
||||
:value="false"
|
||||
@change="createModel.publish_all_ports = !$event.target.value"
|
||||
>
|
||||
{{ $gettext('Map Ports') }}
|
||||
</n-radio>
|
||||
<n-radio
|
||||
:checked="createModel.publish_all_ports"
|
||||
:value="true"
|
||||
@change="createModel.publish_all_ports = !!$event.target.value"
|
||||
>
|
||||
{{ $gettext('Expose All') }}
|
||||
</n-radio>
|
||||
</n-form-item>
|
||||
<n-form-item
|
||||
path="ports"
|
||||
:label="$gettext('Port Mapping')"
|
||||
v-if="!createModel.publish_all_ports"
|
||||
>
|
||||
<n-space vertical>
|
||||
<n-table striped>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP</th>
|
||||
<th>{{ $gettext('Host (Start)') }}</th>
|
||||
<th>{{ $gettext('Host (End)') }}</th>
|
||||
<th>{{ $gettext('Container (Start)') }}</th>
|
||||
<th>{{ $gettext('Container (End)') }}</th>
|
||||
<th>{{ $gettext('Protocol') }}</th>
|
||||
<th>{{ $gettext('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, index) in createModel.ports" :key="index">
|
||||
<td>
|
||||
<n-input
|
||||
v-model:value="item.host"
|
||||
type="text"
|
||||
@keydown.enter.prevent
|
||||
:placeholder="$gettext('Optional')"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<n-input-number
|
||||
v-model:value="item.host_start"
|
||||
type="text"
|
||||
@keydown.enter.prevent
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<n-input-number
|
||||
v-model:value="item.host_end"
|
||||
type="text"
|
||||
@keydown.enter.prevent
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<n-input-number
|
||||
v-model:value="item.container_start"
|
||||
type="text"
|
||||
@keydown.enter.prevent
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<n-input-number
|
||||
v-model:value="item.container_end"
|
||||
type="text"
|
||||
@keydown.enter.prevent
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<n-radio
|
||||
:checked="item.protocol === 'tcp'"
|
||||
value="tcp"
|
||||
name="protocol"
|
||||
@change="item.protocol = $event.target.value"
|
||||
>
|
||||
TCP
|
||||
</n-radio>
|
||||
<n-radio
|
||||
:checked="item.protocol === 'udp'"
|
||||
value="udp"
|
||||
name="protocol"
|
||||
@change="item.protocol = $event.target.value"
|
||||
>
|
||||
UDP
|
||||
</n-radio>
|
||||
</td>
|
||||
<td>
|
||||
<n-button @click="removePortRow(index)" size="small">{{
|
||||
$gettext('Delete')
|
||||
}}</n-button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</n-table>
|
||||
<n-button @click="addPortRow">{{ $gettext('Add') }}</n-button>
|
||||
</n-space>
|
||||
</n-form-item>
|
||||
<n-form-item path="network" :label="$gettext('Network')">
|
||||
<n-select v-model:value="createModel.network" :options="networks" />
|
||||
</n-form-item>
|
||||
<n-form-item path="mount" :label="$gettext('Mount')">
|
||||
<n-space vertical>
|
||||
<n-table striped>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $gettext('Host Directory') }}</th>
|
||||
<th>{{ $gettext('Container Directory') }}</th>
|
||||
<th>{{ $gettext('Permission') }}</th>
|
||||
<th>{{ $gettext('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, index) in createModel.volumes" :key="index">
|
||||
<td>
|
||||
<n-input v-model:value="item.host" type="text" @keydown.enter.prevent />
|
||||
</td>
|
||||
<td>
|
||||
<n-input v-model:value="item.container" type="text" @keydown.enter.prevent />
|
||||
</td>
|
||||
<td>
|
||||
<n-radio
|
||||
:checked="item.mode === 'rw'"
|
||||
value="rw"
|
||||
name="mode"
|
||||
@change="item.mode = $event.target.value"
|
||||
>
|
||||
{{ $gettext('Read-Write') }}
|
||||
</n-radio>
|
||||
<n-radio
|
||||
:checked="item.mode === 'ro'"
|
||||
value="ro"
|
||||
name="mode"
|
||||
@change="item.mode = $event.target.value"
|
||||
>
|
||||
{{ $gettext('Read-Only') }}
|
||||
</n-radio>
|
||||
</td>
|
||||
<td>
|
||||
<n-button @click="removeVolumeRow(index)" size="small">{{
|
||||
$gettext('Delete')
|
||||
}}</n-button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</n-table>
|
||||
<n-button @click="addVolumeRow">{{ $gettext('Add') }}</n-button>
|
||||
</n-space>
|
||||
</n-form-item>
|
||||
<n-form-item path="command" :label="$gettext('Command')">
|
||||
<n-dynamic-input v-model:value="createModel.command" :placeholder="$gettext('Command')" />
|
||||
</n-form-item>
|
||||
<n-form-item path="entrypoint" :label="$gettext('Entrypoint')">
|
||||
<n-dynamic-input
|
||||
v-model:value="createModel.entrypoint"
|
||||
:placeholder="$gettext('Entrypoint')"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-row :gutter="[0, 24]">
|
||||
<n-col :span="8">
|
||||
<n-form-item path="memory" :label="$gettext('Memory')">
|
||||
<n-input-number v-model:value="createModel.memory" />
|
||||
<n-tabs v-model:value="currentTab" type="line" animated>
|
||||
<!-- 基本设置 -->
|
||||
<n-tab-pane name="basic" :tab="$gettext('Basic Settings')">
|
||||
<n-form :model="createModel" label-placement="left" label-width="120">
|
||||
<n-form-item path="name" :label="$gettext('Container Name')">
|
||||
<n-input
|
||||
v-model:value="createModel.name"
|
||||
type="text"
|
||||
@keydown.enter.prevent
|
||||
:placeholder="$gettext('Optional, auto-generated if empty')"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-col>
|
||||
<n-col :span="8">
|
||||
<n-form-item path="cpus" label="CPU">
|
||||
<n-input-number v-model:value="createModel.cpus" />
|
||||
</n-form-item>
|
||||
</n-col>
|
||||
<n-col :span="8">
|
||||
<n-form-item path="cpu_shares" :label="$gettext('CPU Shares')">
|
||||
<n-input-number v-model:value="createModel.cpu_shares" />
|
||||
</n-form-item>
|
||||
</n-col>
|
||||
</n-row>
|
||||
<n-row :gutter="[0, 24]">
|
||||
<n-col :span="6">
|
||||
<n-form-item path="tty" :label="$gettext('TTY (-t)')">
|
||||
<n-switch v-model:value="createModel.tty" />
|
||||
</n-form-item>
|
||||
</n-col>
|
||||
<n-col :span="6">
|
||||
<n-form-item path="open_stdin" :label="$gettext('STDIN (-i)')">
|
||||
<n-switch v-model:value="createModel.open_stdin" />
|
||||
</n-form-item>
|
||||
</n-col>
|
||||
<n-col :span="6">
|
||||
<n-form-item path="auto_remove" :label="$gettext('Auto Remove')">
|
||||
<n-switch v-model:value="createModel.auto_remove" />
|
||||
</n-form-item>
|
||||
</n-col>
|
||||
<n-col :span="6">
|
||||
<n-form-item path="privileged" :label="$gettext('Privileged Mode')">
|
||||
<n-switch v-model:value="createModel.privileged" />
|
||||
</n-form-item>
|
||||
</n-col>
|
||||
</n-row>
|
||||
<n-form-item path="restart_policy" :label="$gettext('Restart Policy')">
|
||||
<n-select
|
||||
v-model:value="createModel.restart_policy"
|
||||
:placeholder="$gettext('Select restart policy')"
|
||||
:options="restartPolicyOptions"
|
||||
>
|
||||
{{ createModel.restart_policy || $gettext('Select restart policy') }}
|
||||
</n-select>
|
||||
</n-form-item>
|
||||
<n-form-item path="env" :label="$gettext('Environment Variables')">
|
||||
<n-dynamic-input
|
||||
v-model:value="createModel.env"
|
||||
preset="pair"
|
||||
:key-placeholder="$gettext('Variable Name')"
|
||||
:value-placeholder="$gettext('Variable Value')"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item path="labels" :label="$gettext('Labels')">
|
||||
<n-dynamic-input
|
||||
v-model:value="createModel.labels"
|
||||
preset="pair"
|
||||
:key-placeholder="$gettext('Label Name')"
|
||||
:value-placeholder="$gettext('Label Value')"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-button type="info" block :loading="doSubmit" :disabled="doSubmit" @click="handleSubmit">
|
||||
{{ $gettext('Submit') }}
|
||||
</n-button>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
<n-form-item path="image" :label="$gettext('Image')">
|
||||
<n-input
|
||||
v-model:value="createModel.image"
|
||||
type="text"
|
||||
@keydown.enter.prevent
|
||||
:placeholder="$gettext('e.g., nginx:latest, mysql:8.0')"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item path="network" :label="$gettext('Network')">
|
||||
<n-select
|
||||
v-model:value="createModel.network"
|
||||
:options="networks"
|
||||
:placeholder="$gettext('Select network')"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item path="restart_policy" :label="$gettext('Restart Policy')">
|
||||
<n-select
|
||||
v-model:value="createModel.restart_policy"
|
||||
:options="restartPolicyOptions"
|
||||
:placeholder="$gettext('Select restart policy')"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-divider title-placement="left">{{ $gettext('Container Options') }}</n-divider>
|
||||
|
||||
<n-row :gutter="[24, 0]">
|
||||
<n-col :span="6">
|
||||
<n-form-item path="tty" :label="$gettext('TTY (-t)')">
|
||||
<n-switch v-model:value="createModel.tty" />
|
||||
</n-form-item>
|
||||
</n-col>
|
||||
<n-col :span="6">
|
||||
<n-form-item path="open_stdin" :label="$gettext('STDIN (-i)')">
|
||||
<n-switch v-model:value="createModel.open_stdin" />
|
||||
</n-form-item>
|
||||
</n-col>
|
||||
<n-col :span="6">
|
||||
<n-form-item path="auto_remove" :label="$gettext('Auto Remove')">
|
||||
<n-switch v-model:value="createModel.auto_remove" />
|
||||
</n-form-item>
|
||||
</n-col>
|
||||
<n-col :span="6">
|
||||
<n-form-item path="privileged" :label="$gettext('Privileged')">
|
||||
<n-switch v-model:value="createModel.privileged" />
|
||||
</n-form-item>
|
||||
</n-col>
|
||||
</n-row>
|
||||
</n-form>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- 端口映射 -->
|
||||
<n-tab-pane name="ports" :tab="$gettext('Port Mapping')">
|
||||
<n-form :model="createModel" label-placement="left" label-width="120">
|
||||
<n-form-item :label="$gettext('Port Mode')">
|
||||
<n-radio-group v-model:value="createModel.publish_all_ports">
|
||||
<n-radio-button :value="false">{{ $gettext('Map Ports') }}</n-radio-button>
|
||||
<n-radio-button :value="true">{{ $gettext('Expose All') }}</n-radio-button>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item
|
||||
v-if="!createModel.publish_all_ports"
|
||||
:label="$gettext('Port Mapping')"
|
||||
:show-label="false"
|
||||
>
|
||||
<n-dynamic-input
|
||||
v-model:value="createModel.ports"
|
||||
:on-create="onCreatePort"
|
||||
show-sort-button
|
||||
>
|
||||
<template #default="{ value }">
|
||||
<n-flex align="center" :wrap="false" style="width: 100%">
|
||||
<n-input
|
||||
v-model:value="value.host"
|
||||
:placeholder="$gettext('IP (optional)')"
|
||||
style="width: 120px"
|
||||
/>
|
||||
<span>:</span>
|
||||
<n-input-number
|
||||
v-model:value="value.host_start"
|
||||
:min="1"
|
||||
:max="65535"
|
||||
:show-button="false"
|
||||
:placeholder="$gettext('Host Start')"
|
||||
style="width: 90px"
|
||||
/>
|
||||
<span>-</span>
|
||||
<n-input-number
|
||||
v-model:value="value.host_end"
|
||||
:min="1"
|
||||
:max="65535"
|
||||
:show-button="false"
|
||||
:placeholder="$gettext('Host End')"
|
||||
style="width: 90px"
|
||||
/>
|
||||
<span>:</span>
|
||||
<n-input-number
|
||||
v-model:value="value.container_start"
|
||||
:min="1"
|
||||
:max="65535"
|
||||
:show-button="false"
|
||||
:placeholder="$gettext('Container Start')"
|
||||
style="width: 90px"
|
||||
/>
|
||||
<span>-</span>
|
||||
<n-input-number
|
||||
v-model:value="value.container_end"
|
||||
:min="1"
|
||||
:max="65535"
|
||||
:show-button="false"
|
||||
:placeholder="$gettext('Container End')"
|
||||
style="width: 90px"
|
||||
/>
|
||||
<n-select
|
||||
v-model:value="value.protocol"
|
||||
:options="protocolOptions"
|
||||
style="width: 90px"
|
||||
/>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-dynamic-input>
|
||||
</n-form-item>
|
||||
|
||||
<n-alert v-if="createModel.publish_all_ports" type="info">
|
||||
{{
|
||||
$gettext(
|
||||
'All exposed ports in the image will be automatically mapped to random host ports.'
|
||||
)
|
||||
}}
|
||||
</n-alert>
|
||||
</n-form>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- 存储挂载 -->
|
||||
<n-tab-pane name="volumes" :tab="$gettext('Volumes')">
|
||||
<n-form :model="createModel" label-placement="left" label-width="120">
|
||||
<n-form-item :label="$gettext('Volume Mounts')" :show-label="false">
|
||||
<n-dynamic-input
|
||||
v-model:value="createModel.volumes"
|
||||
:on-create="onCreateVolume"
|
||||
show-sort-button
|
||||
>
|
||||
<template #default="{ value }">
|
||||
<n-flex align="center" :wrap="false" style="width: 100%">
|
||||
<n-input
|
||||
v-model:value="value.host"
|
||||
:placeholder="$gettext('Host path')"
|
||||
style="flex: 1"
|
||||
/>
|
||||
<span>:</span>
|
||||
<n-input
|
||||
v-model:value="value.container"
|
||||
:placeholder="$gettext('Container path')"
|
||||
style="flex: 1"
|
||||
/>
|
||||
<n-select
|
||||
v-model:value="value.mode"
|
||||
:options="volumeModeOptions"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-dynamic-input>
|
||||
</n-form-item>
|
||||
|
||||
<n-alert type="info" style="margin-top: 16px">
|
||||
{{
|
||||
$gettext(
|
||||
'Mount host directories or volumes into the container. Use absolute paths for host directories.'
|
||||
)
|
||||
}}
|
||||
</n-alert>
|
||||
</n-form>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- 资源限制 -->
|
||||
<n-tab-pane name="resources" :tab="$gettext('Resource Limits')">
|
||||
<n-form :model="createModel" label-placement="left" label-width="120">
|
||||
<n-alert type="info" style="margin-bottom: 16px">
|
||||
{{
|
||||
$gettext(
|
||||
'Set resource limits to prevent the container from consuming too many system resources. Set to 0 for no limit.'
|
||||
)
|
||||
}}
|
||||
</n-alert>
|
||||
|
||||
<n-row :gutter="[24, 0]">
|
||||
<n-col :span="8">
|
||||
<n-form-item path="memory" :label="$gettext('Memory (MB)')">
|
||||
<n-input-number
|
||||
v-model:value="createModel.memory"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
:placeholder="$gettext('0 = no limit')"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-col>
|
||||
<n-col :span="8">
|
||||
<n-form-item path="cpus" :label="$gettext('CPU Cores')">
|
||||
<n-input-number
|
||||
v-model:value="createModel.cpus"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.5"
|
||||
style="width: 100%"
|
||||
:placeholder="$gettext('0 = no limit')"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-col>
|
||||
<n-col :span="8">
|
||||
<n-form-item path="cpu_shares" :label="$gettext('CPU Shares')">
|
||||
<n-input-number
|
||||
v-model:value="createModel.cpu_shares"
|
||||
:min="0"
|
||||
:max="262144"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-col>
|
||||
</n-row>
|
||||
|
||||
<n-collapse style="margin-top: 16px">
|
||||
<n-collapse-item :title="$gettext('Resource Limit Description')">
|
||||
<n-descriptions :column="1" label-placement="left">
|
||||
<n-descriptions-item :label="$gettext('Memory')">
|
||||
{{ $gettext('Maximum memory the container can use, in MB. 0 means no limit.') }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item :label="$gettext('CPU Cores')">
|
||||
{{
|
||||
$gettext(
|
||||
'Number of CPU cores the container can use. 0.5 means half a core, 2 means 2 cores.'
|
||||
)
|
||||
}}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item :label="$gettext('CPU Shares')">
|
||||
{{
|
||||
$gettext(
|
||||
'Relative CPU weight. Default is 1024. Higher values get more CPU time when competing.'
|
||||
)
|
||||
}}
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</n-form>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- 环境与命令 -->
|
||||
<n-tab-pane name="environment" :tab="$gettext('Environment')">
|
||||
<n-form :model="createModel" label-placement="left" label-width="140">
|
||||
<n-form-item :label="$gettext('Environment Variables')">
|
||||
<n-dynamic-input
|
||||
v-model:value="createModel.env"
|
||||
:on-create="onCreateEnv"
|
||||
show-sort-button
|
||||
>
|
||||
<template #default="{ value }">
|
||||
<n-flex align="center" :wrap="false" style="width: 100%">
|
||||
<n-input
|
||||
v-model:value="value.key"
|
||||
:placeholder="$gettext('Variable name')"
|
||||
style="flex: 1"
|
||||
/>
|
||||
<span>=</span>
|
||||
<n-input
|
||||
v-model:value="value.value"
|
||||
:placeholder="$gettext('Variable value')"
|
||||
style="flex: 2"
|
||||
/>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-dynamic-input>
|
||||
</n-form-item>
|
||||
|
||||
<n-divider title-placement="left">{{ $gettext('Startup Commands') }}</n-divider>
|
||||
|
||||
<n-form-item path="command" :label="$gettext('Command')">
|
||||
<n-dynamic-input
|
||||
v-model:value="createModel.command"
|
||||
:placeholder="$gettext('Command argument')"
|
||||
/>
|
||||
<template #feedback>
|
||||
<span class="text-gray-400">
|
||||
{{ $gettext('Override the default CMD of the image') }}
|
||||
</span>
|
||||
</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item path="entrypoint" :label="$gettext('Entrypoint')">
|
||||
<n-dynamic-input
|
||||
v-model:value="createModel.entrypoint"
|
||||
:placeholder="$gettext('Entrypoint argument')"
|
||||
/>
|
||||
<template #feedback>
|
||||
<span class="text-gray-400">
|
||||
{{ $gettext('Override the default ENTRYPOINT of the image') }}
|
||||
</span>
|
||||
</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-divider title-placement="left">{{ $gettext('Labels') }}</n-divider>
|
||||
|
||||
<n-form-item :label="$gettext('Container Labels')">
|
||||
<n-dynamic-input
|
||||
v-model:value="createModel.labels"
|
||||
:on-create="onCreateLabel"
|
||||
show-sort-button
|
||||
>
|
||||
<template #default="{ value }">
|
||||
<n-flex align="center" :wrap="false" style="width: 100%">
|
||||
<n-input
|
||||
v-model:value="value.key"
|
||||
:placeholder="$gettext('Label name')"
|
||||
style="flex: 1"
|
||||
/>
|
||||
<span>=</span>
|
||||
<n-input
|
||||
v-model:value="value.value"
|
||||
:placeholder="$gettext('Label value')"
|
||||
style="flex: 2"
|
||||
/>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-dynamic-input>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="show = false" :disabled="doSubmit">
|
||||
{{ $gettext('Cancel') }}
|
||||
</n-button>
|
||||
<n-button type="primary" :loading="doSubmit" :disabled="doSubmit" @click="handleSubmit">
|
||||
{{ $gettext('Create') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<!-- 镜像拉取弹窗 -->
|
||||
<image-pull-modal
|
||||
v-model:show="showPullModal"
|
||||
:image="createModel.image"
|
||||
@success="onPullSuccess"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import '@fontsource-variable/jetbrains-mono/wght-italic.css'
|
||||
import '@fontsource-variable/jetbrains-mono/wght.css'
|
||||
import { AttachAddon } from '@xterm/addon-attach'
|
||||
import { ClipboardAddon } from '@xterm/addon-clipboard'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { Unicode11Addon } from '@xterm/addon-unicode11'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import { WebglAddon } from '@xterm/addon-webgl'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { NButton, NDataTable, NDropdown, NFlex, NInput, NSwitch, NTag } from 'naive-ui'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
import container from '@/api/panel/container'
|
||||
import ws from '@/api/ws'
|
||||
import ContainerCreate from '@/views/container/ContainerCreate.vue'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
@@ -15,6 +26,15 @@ const renameModel = ref({
|
||||
name: ''
|
||||
})
|
||||
|
||||
// 终端相关状态
|
||||
const terminalModal = ref(false)
|
||||
const terminalContainerName = ref('')
|
||||
const terminalRef = ref<HTMLElement | null>(null)
|
||||
const term = ref<Terminal | null>(null)
|
||||
let containerWs: WebSocket | null = null
|
||||
const fitAddon = new FitAddon()
|
||||
const webglAddon = new WebglAddon()
|
||||
|
||||
const containerCreateModal = ref(false)
|
||||
const selectedRowKeys = ref<any>([])
|
||||
|
||||
@@ -89,16 +109,29 @@ const columns: any = [
|
||||
{
|
||||
title: $gettext('Actions'),
|
||||
key: 'actions',
|
||||
width: 250,
|
||||
width: 320,
|
||||
hideInExcel: true,
|
||||
render(row: any) {
|
||||
return [
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'info',
|
||||
onClick: () => handleOpenTerminal(row),
|
||||
disabled: row.state !== 'running'
|
||||
},
|
||||
{
|
||||
default: () => $gettext('Terminal')
|
||||
}
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'warning',
|
||||
secondary: true,
|
||||
style: 'margin-left: 10px;',
|
||||
onClick: () => handleShowLog(row)
|
||||
},
|
||||
{
|
||||
@@ -110,7 +143,7 @@ const columns: any = [
|
||||
{
|
||||
size: 'small',
|
||||
type: 'success',
|
||||
style: 'margin-left: 15px;',
|
||||
style: 'margin-left: 10px;',
|
||||
onClick: () => {
|
||||
renameModel.value.id = row.id
|
||||
renameModel.value.name = row.name
|
||||
@@ -193,7 +226,7 @@ const columns: any = [
|
||||
{
|
||||
size: 'small',
|
||||
type: 'primary',
|
||||
style: 'margin-left: 15px;'
|
||||
style: 'margin-left: 10px;'
|
||||
},
|
||||
{
|
||||
default: () => $gettext('More')
|
||||
@@ -291,11 +324,6 @@ const handlePrune = () => {
|
||||
}
|
||||
|
||||
const bulkStart = async () => {
|
||||
if (selectedRowKeys.value.length === 0) {
|
||||
window.$message.info($gettext('Please select containers to start'))
|
||||
return
|
||||
}
|
||||
|
||||
const promises = selectedRowKeys.value.map((id: any) => container.containerStart(id))
|
||||
await Promise.all(promises)
|
||||
|
||||
@@ -305,11 +333,6 @@ const bulkStart = async () => {
|
||||
}
|
||||
|
||||
const bulkStop = async () => {
|
||||
if (selectedRowKeys.value.length === 0) {
|
||||
window.$message.info($gettext('Please select containers to stop'))
|
||||
return
|
||||
}
|
||||
|
||||
const promises = selectedRowKeys.value.map((id: any) => container.containerStop(id))
|
||||
await Promise.all(promises)
|
||||
|
||||
@@ -319,11 +342,6 @@ const bulkStop = async () => {
|
||||
}
|
||||
|
||||
const bulkRestart = async () => {
|
||||
if (selectedRowKeys.value.length === 0) {
|
||||
window.$message.info($gettext('Please select containers to restart'))
|
||||
return
|
||||
}
|
||||
|
||||
const promises = selectedRowKeys.value.map((id: any) => container.containerRestart(id))
|
||||
await Promise.all(promises)
|
||||
|
||||
@@ -333,11 +351,6 @@ const bulkRestart = async () => {
|
||||
}
|
||||
|
||||
const bulkForceStop = async () => {
|
||||
if (selectedRowKeys.value.length === 0) {
|
||||
window.$message.info($gettext('Please select containers to force stop'))
|
||||
return
|
||||
}
|
||||
|
||||
const promises = selectedRowKeys.value.map((id: any) => container.containerKill(id))
|
||||
await Promise.all(promises)
|
||||
|
||||
@@ -347,11 +360,6 @@ const bulkForceStop = async () => {
|
||||
}
|
||||
|
||||
const bulkDelete = async () => {
|
||||
if (selectedRowKeys.value.length === 0) {
|
||||
window.$message.info($gettext('Please select containers to delete'))
|
||||
return
|
||||
}
|
||||
|
||||
const promises = selectedRowKeys.value.map((id: any) => container.containerRemove(id))
|
||||
await Promise.all(promises)
|
||||
|
||||
@@ -361,11 +369,6 @@ const bulkDelete = async () => {
|
||||
}
|
||||
|
||||
const bulkPause = async () => {
|
||||
if (selectedRowKeys.value.length === 0) {
|
||||
window.$message.info($gettext('Please select containers to pause'))
|
||||
return
|
||||
}
|
||||
|
||||
const promises = selectedRowKeys.value.map((id: any) => container.containerPause(id))
|
||||
await Promise.all(promises)
|
||||
|
||||
@@ -375,11 +378,6 @@ const bulkPause = async () => {
|
||||
}
|
||||
|
||||
const bulkUnpause = async () => {
|
||||
if (selectedRowKeys.value.length === 0) {
|
||||
window.$message.info($gettext('Please select containers to resume'))
|
||||
return
|
||||
}
|
||||
|
||||
const promises = selectedRowKeys.value.map((id: any) => container.containerUnpause(id))
|
||||
await Promise.all(promises)
|
||||
|
||||
@@ -389,32 +387,168 @@ const bulkUnpause = async () => {
|
||||
}
|
||||
|
||||
const closeContainerCreateModal = () => {
|
||||
containerCreateModal.value = false
|
||||
refresh()
|
||||
}
|
||||
|
||||
// 打开容器终端
|
||||
const handleOpenTerminal = async (row: any) => {
|
||||
terminalContainerName.value = row.name
|
||||
terminalModal.value = true
|
||||
|
||||
await nextTick()
|
||||
|
||||
// 确保终端容器存在
|
||||
if (!terminalRef.value) {
|
||||
window.$message.error($gettext('Terminal container not found'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
containerWs = await ws.container(row.id)
|
||||
|
||||
term.value = new Terminal({
|
||||
allowProposedApi: true,
|
||||
lineHeight: 1.2,
|
||||
fontSize: 14,
|
||||
fontFamily: `'JetBrains Mono Variable', monospace`,
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'underline',
|
||||
tabStopWidth: 4,
|
||||
theme: { background: '#111', foreground: '#fff' }
|
||||
})
|
||||
|
||||
term.value.loadAddon(new AttachAddon(containerWs))
|
||||
term.value.loadAddon(fitAddon)
|
||||
term.value.loadAddon(new ClipboardAddon())
|
||||
term.value.loadAddon(new WebLinksAddon())
|
||||
term.value.loadAddon(new Unicode11Addon())
|
||||
term.value.unicode.activeVersion = '11'
|
||||
term.value.loadAddon(webglAddon)
|
||||
webglAddon.onContextLoss(() => {
|
||||
webglAddon.dispose()
|
||||
})
|
||||
term.value.open(terminalRef.value)
|
||||
|
||||
onTerminalResize()
|
||||
term.value.focus()
|
||||
window.addEventListener('resize', onTerminalResize, false)
|
||||
|
||||
containerWs.onclose = () => {
|
||||
if (term.value) {
|
||||
term.value.write('\r\n' + $gettext('Connection closed.'))
|
||||
}
|
||||
window.removeEventListener('resize', onTerminalResize)
|
||||
}
|
||||
|
||||
containerWs.onerror = (event) => {
|
||||
if (term.value) {
|
||||
term.value.write('\r\n' + $gettext('Connection error.'))
|
||||
}
|
||||
console.error(event)
|
||||
containerWs?.close()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to container terminal:', error)
|
||||
window.$message.error($gettext('Failed to connect to container terminal'))
|
||||
terminalModal.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭容器终端
|
||||
const closeTerminal = () => {
|
||||
try {
|
||||
if (term.value) {
|
||||
term.value.dispose()
|
||||
term.value = null
|
||||
}
|
||||
if (containerWs) {
|
||||
containerWs.close()
|
||||
containerWs = null
|
||||
}
|
||||
if (terminalRef.value) {
|
||||
terminalRef.value.innerHTML = ''
|
||||
}
|
||||
window.removeEventListener('resize', onTerminalResize)
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
// 终端大小调整
|
||||
const onTerminalResize = () => {
|
||||
fitAddon.fit()
|
||||
if (containerWs != null && containerWs.readyState === 1 && term.value) {
|
||||
const { cols, rows } = term.value
|
||||
containerWs.send(
|
||||
JSON.stringify({
|
||||
resize: true,
|
||||
columns: cols,
|
||||
rows: rows
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 终端滚轮缩放
|
||||
const onTerminalWheel = (event: WheelEvent) => {
|
||||
if (event.ctrlKey && term.value) {
|
||||
event.preventDefault()
|
||||
if (event.deltaY > 0) {
|
||||
if (term.value.options.fontSize! > 12) {
|
||||
term.value.options.fontSize = term.value.options.fontSize! - 1
|
||||
}
|
||||
} else {
|
||||
term.value.options.fontSize = term.value.options.fontSize! + 1
|
||||
}
|
||||
fitAddon.fit()
|
||||
}
|
||||
}
|
||||
|
||||
// 终端模态框关闭后清理
|
||||
const handleTerminalModalClose = () => {
|
||||
closeTerminal()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refresh()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
closeTerminal()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical :size="20">
|
||||
<n-flex>
|
||||
<n-button type="primary" @click="containerCreateModal = true">{{
|
||||
$gettext('Create Container')
|
||||
}}</n-button>
|
||||
<n-button type="primary" @click="handlePrune" ghost>{{
|
||||
$gettext('Cleanup Containers')
|
||||
}}</n-button>
|
||||
<n-button type="primary" @click="containerCreateModal = true">
|
||||
{{ $gettext('Create Container') }}
|
||||
</n-button>
|
||||
<n-button type="primary" @click="handlePrune" ghost>
|
||||
{{ $gettext('Cleanup Containers') }}
|
||||
</n-button>
|
||||
<n-button-group>
|
||||
<n-button @click="bulkStart">{{ $gettext('Start') }}</n-button>
|
||||
<n-button @click="bulkStop">{{ $gettext('Stop') }}</n-button>
|
||||
<n-button @click="bulkRestart">{{ $gettext('Restart') }}</n-button>
|
||||
<n-button @click="bulkForceStop">{{ $gettext('Force Stop') }}</n-button>
|
||||
<n-button @click="bulkPause">{{ $gettext('Pause') }}</n-button>
|
||||
<n-button @click="bulkUnpause">{{ $gettext('Resume') }}</n-button>
|
||||
<n-button @click="bulkDelete">{{ $gettext('Delete') }}</n-button>
|
||||
<n-button @click="bulkStart" :disabled="selectedRowKeys.length === 0" ghost>
|
||||
{{ $gettext('Start') }}
|
||||
</n-button>
|
||||
<n-button @click="bulkStop" :disabled="selectedRowKeys.length === 0" ghost>
|
||||
{{ $gettext('Stop') }}
|
||||
</n-button>
|
||||
<n-button @click="bulkRestart" :disabled="selectedRowKeys.length === 0" ghost>
|
||||
{{ $gettext('Restart') }}
|
||||
</n-button>
|
||||
<n-button @click="bulkForceStop" :disabled="selectedRowKeys.length === 0" ghost>
|
||||
{{ $gettext('Force Stop') }}
|
||||
</n-button>
|
||||
<n-button @click="bulkPause" :disabled="selectedRowKeys.length === 0" ghost>
|
||||
{{ $gettext('Pause') }}
|
||||
</n-button>
|
||||
<n-button @click="bulkUnpause" :disabled="selectedRowKeys.length === 0" ghost>
|
||||
{{ $gettext('Resume') }}
|
||||
</n-button>
|
||||
<n-button @click="bulkDelete" :disabled="selectedRowKeys.length === 0" ghost>
|
||||
{{ $gettext('Delete') }}
|
||||
</n-button>
|
||||
</n-button-group>
|
||||
</n-flex>
|
||||
<n-data-table
|
||||
@@ -471,5 +605,55 @@ onMounted(() => {
|
||||
</n-form>
|
||||
<n-button type="info" block @click="handleRename">{{ $gettext('Submit') }}</n-button>
|
||||
</n-modal>
|
||||
<ContainerCreate :show="containerCreateModal" @close="closeContainerCreateModal" />
|
||||
<n-modal
|
||||
v-model:show="terminalModal"
|
||||
preset="card"
|
||||
:title="$gettext('Terminal') + ' - ' + terminalContainerName"
|
||||
style="width: 90vw; height: 80vh"
|
||||
size="huge"
|
||||
:bordered="false"
|
||||
:segmented="false"
|
||||
@after-leave="handleTerminalModalClose"
|
||||
>
|
||||
<div
|
||||
ref="terminalRef"
|
||||
@wheel="onTerminalWheel"
|
||||
style="height: 100%; min-height: 60vh; background: #111"
|
||||
></div>
|
||||
</n-modal>
|
||||
<ContainerCreate v-model:show="containerCreateModal" @update:show="closeContainerCreateModal" />
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:deep(.xterm) {
|
||||
padding: 1rem !important;
|
||||
}
|
||||
|
||||
:deep(.xterm .xterm-viewport::-webkit-scrollbar) {
|
||||
border-radius: 0.4rem;
|
||||
height: 6px;
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
:deep(.xterm .xterm-viewport::-webkit-scrollbar-thumb) {
|
||||
background-color: #666;
|
||||
border-radius: 0.4rem;
|
||||
box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
|
||||
transition: all 1s;
|
||||
}
|
||||
|
||||
:deep(.xterm .xterm-viewport:hover::-webkit-scrollbar-thumb) {
|
||||
background-color: #aaa;
|
||||
}
|
||||
|
||||
:deep(.xterm .xterm-viewport::-webkit-scrollbar-track) {
|
||||
background-color: #111;
|
||||
border-radius: 0.4rem;
|
||||
box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
|
||||
transition: all 1s;
|
||||
}
|
||||
|
||||
:deep(.xterm .xterm-viewport:hover::-webkit-scrollbar-track) {
|
||||
background-color: #444;
|
||||
}
|
||||
</style>
|
||||
|
||||
204
web/src/views/container/ImagePullModal.vue
Normal file
204
web/src/views/container/ImagePullModal.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<script setup lang="ts">
|
||||
import ws from '@/api/ws'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
const show = defineModel<boolean>('show', { type: Boolean, required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
image: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
success: []
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const isPulling = ref(false)
|
||||
const pullProgress = ref<Map<string, any>>(new Map())
|
||||
const pullStatus = ref('')
|
||||
const pullError = ref('')
|
||||
let pullWs: WebSocket | null = null
|
||||
|
||||
// 计算总体拉取进度
|
||||
const totalProgress = computed(() => {
|
||||
const layers = Array.from(pullProgress.value.values())
|
||||
if (layers.length === 0) return 0
|
||||
|
||||
// 统计各状态的层数
|
||||
const completed = layers.filter(
|
||||
(p) => p.status === 'Pull complete' || p.status === 'Already exists'
|
||||
).length
|
||||
const total = layers.filter((p) => p.id && p.id.length === 12).length
|
||||
|
||||
return total > 0 ? Math.round((completed / total) * 100) : 0
|
||||
})
|
||||
|
||||
// 拉取镜像
|
||||
const pullImage = () => {
|
||||
isPulling.value = true
|
||||
pullProgress.value = new Map()
|
||||
pullStatus.value = $gettext('Connecting...')
|
||||
pullError.value = ''
|
||||
|
||||
ws.imagePull(props.image)
|
||||
.then((socket) => {
|
||||
pullWs = socket
|
||||
pullStatus.value = $gettext('Pulling image...')
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const data: any = JSON.parse(event.data)
|
||||
|
||||
if (data.error) {
|
||||
pullError.value = data.error
|
||||
isPulling.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (data.complete) {
|
||||
pullStatus.value = $gettext('Pull completed')
|
||||
isPulling.value = false
|
||||
show.value = false
|
||||
emit('success')
|
||||
return
|
||||
}
|
||||
|
||||
// 更新进度
|
||||
if (data.id) {
|
||||
pullProgress.value.set(data.id, data)
|
||||
// 触发响应式更新
|
||||
pullProgress.value = new Map(pullProgress.value)
|
||||
}
|
||||
pullStatus.value = data.status
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
|
||||
socket.onclose = () => {
|
||||
if (isPulling.value) {
|
||||
isPulling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
socket.onerror = () => {
|
||||
pullError.value = $gettext('Connection error')
|
||||
isPulling.value = false
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
pullError.value = err.message || $gettext('Failed to connect')
|
||||
isPulling.value = false
|
||||
})
|
||||
}
|
||||
|
||||
// 取消拉取
|
||||
const cancelPull = () => {
|
||||
if (pullWs) {
|
||||
pullWs.close()
|
||||
pullWs = null
|
||||
}
|
||||
resetState()
|
||||
show.value = false
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
const resetState = () => {
|
||||
isPulling.value = false
|
||||
pullProgress.value = new Map()
|
||||
pullStatus.value = ''
|
||||
pullError.value = ''
|
||||
}
|
||||
|
||||
watch(show, (val) => {
|
||||
if (val) {
|
||||
resetState()
|
||||
pullImage()
|
||||
} else {
|
||||
if (pullWs) {
|
||||
pullWs.close()
|
||||
pullWs = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pullWs) {
|
||||
pullWs.close()
|
||||
pullWs = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
v-model:show="show"
|
||||
:title="$gettext('Pulling Image')"
|
||||
preset="card"
|
||||
style="width: 60vw"
|
||||
size="medium"
|
||||
:bordered="false"
|
||||
:segmented="false"
|
||||
:mask-closable="false"
|
||||
:closable="false"
|
||||
>
|
||||
<!-- 拉取进度 -->
|
||||
<n-flex v-if="isPulling || (!pullError && pullProgress.size > 0)" vertical :size="16">
|
||||
<n-progress
|
||||
type="line"
|
||||
:percentage="totalProgress"
|
||||
:indicator-placement="'inside'"
|
||||
processing
|
||||
/>
|
||||
|
||||
<n-card size="small" :bordered="true" class="max-h-300 overflow-y-auto">
|
||||
<n-flex vertical :size="8">
|
||||
<div
|
||||
v-for="[id, progress] in pullProgress"
|
||||
:key="id"
|
||||
class="p-1 px-2 rounded bg-gray-100 dark:bg-gray-800"
|
||||
>
|
||||
<n-flex justify="space-between" align="center">
|
||||
<n-text depth="3" class="text-12 font-mono">
|
||||
{{ id.substring(0, 12) }}
|
||||
</n-text>
|
||||
<n-text depth="2" class="text-12">
|
||||
{{ progress.status }}
|
||||
<template v-if="progress.progress">
|
||||
{{ progress.progress }}
|
||||
</template>
|
||||
</n-text>
|
||||
</n-flex>
|
||||
</div>
|
||||
<n-text v-if="pullProgress.size === 0" depth="3">
|
||||
{{ pullStatus }}
|
||||
</n-text>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
|
||||
<n-flex justify="center">
|
||||
<n-button @click="cancelPull" type="error" ghost>
|
||||
{{ $gettext('Cancel') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
|
||||
<!-- 拉取错误 -->
|
||||
<n-result
|
||||
v-else-if="pullError"
|
||||
status="error"
|
||||
:title="$gettext('Pull Failed')"
|
||||
:description="pullError"
|
||||
>
|
||||
<template #footer>
|
||||
<n-flex justify="center">
|
||||
<n-button @click="cancelPull">{{ $gettext('Cancel') }}</n-button>
|
||||
<n-button type="primary" @click="pullImage">{{ $gettext('Retry') }}</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-result>
|
||||
</n-modal>
|
||||
</template>
|
||||
@@ -3,6 +3,7 @@ import { NButton, NDataTable, NFlex, NInput, NPopconfirm, NTag } from 'naive-ui'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
import container from '@/api/panel/container'
|
||||
import ws from '@/api/ws'
|
||||
import { formatDateTime } from '@/utils'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
@@ -16,6 +17,26 @@ const pullModel = ref({
|
||||
const pullModal = ref(false)
|
||||
const selectedRowKeys = ref<any>([])
|
||||
|
||||
// 镜像拉取进度状态
|
||||
const isPulling = ref(false)
|
||||
const pullProgress = ref<Map<string, any>>(new Map())
|
||||
const pullStatus = ref('')
|
||||
const pullError = ref('')
|
||||
let pullWs: WebSocket | null = null
|
||||
|
||||
// 计算总体拉取进度
|
||||
const totalProgress = computed(() => {
|
||||
const layers = Array.from(pullProgress.value.values())
|
||||
if (layers.length === 0) return 0
|
||||
|
||||
const completed = layers.filter(
|
||||
(p) => p.status === 'Pull complete' || p.status === 'Already exists'
|
||||
).length
|
||||
const total = layers.filter((p) => p.id && p.id.length === 12).length
|
||||
|
||||
return total > 0 ? Math.round((completed / total) * 100) : 0
|
||||
})
|
||||
|
||||
const columns: any = [
|
||||
{ type: 'selection', fixed: 'left' },
|
||||
{
|
||||
@@ -126,31 +147,136 @@ const handlePrune = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
const promises = selectedRowKeys.value.map((id: any) => container.imageRemove(id))
|
||||
await Promise.all(promises)
|
||||
|
||||
selectedRowKeys.value = []
|
||||
refresh()
|
||||
window.$message.success($gettext('Deleted successfully'))
|
||||
}
|
||||
|
||||
// 取消拉取
|
||||
const cancelPull = () => {
|
||||
if (pullWs) {
|
||||
pullWs.close()
|
||||
pullWs = null
|
||||
}
|
||||
resetState()
|
||||
}
|
||||
|
||||
// 重置拉取状态
|
||||
const resetState = () => {
|
||||
isPulling.value = false
|
||||
pullProgress.value = new Map()
|
||||
pullStatus.value = ''
|
||||
pullError.value = ''
|
||||
}
|
||||
|
||||
// 拉取镜像
|
||||
const handlePull = () => {
|
||||
loading.value = true
|
||||
useRequest(container.imagePull(pullModel.value))
|
||||
.onSuccess(() => {
|
||||
refresh()
|
||||
window.$message.success($gettext('Pull successful'))
|
||||
if (!pullModel.value.name) {
|
||||
window.$message.warning($gettext('Please enter image name'))
|
||||
return
|
||||
}
|
||||
|
||||
isPulling.value = true
|
||||
pullProgress.value = new Map()
|
||||
pullStatus.value = $gettext('Connecting...')
|
||||
pullError.value = ''
|
||||
|
||||
const auth = pullModel.value.auth
|
||||
? { username: pullModel.value.username, password: pullModel.value.password }
|
||||
: undefined
|
||||
|
||||
ws.imagePull(pullModel.value.name, auth)
|
||||
.then((socket) => {
|
||||
pullWs = socket
|
||||
pullStatus.value = $gettext('Pulling image...')
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const data: any = JSON.parse(event.data)
|
||||
|
||||
if (data.error) {
|
||||
pullError.value = data.error
|
||||
isPulling.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (data.complete) {
|
||||
pullStatus.value = $gettext('Pull completed')
|
||||
isPulling.value = false
|
||||
pullModal.value = false
|
||||
refresh()
|
||||
window.$message.success($gettext('Pull successful'))
|
||||
return
|
||||
}
|
||||
|
||||
// 更新进度
|
||||
if (data.id) {
|
||||
pullProgress.value.set(data.id, data)
|
||||
pullProgress.value = new Map(pullProgress.value)
|
||||
}
|
||||
pullStatus.value = data.status
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
|
||||
socket.onclose = () => {
|
||||
if (isPulling.value) {
|
||||
isPulling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
socket.onerror = () => {
|
||||
pullError.value = $gettext('Connection error')
|
||||
isPulling.value = false
|
||||
}
|
||||
})
|
||||
.onComplete(() => {
|
||||
loading.value = false
|
||||
pullModal.value = false
|
||||
.catch((err) => {
|
||||
pullError.value = err.message || $gettext('Failed to connect')
|
||||
isPulling.value = false
|
||||
})
|
||||
}
|
||||
|
||||
// 监听弹窗打开,重置状态
|
||||
watch(pullModal, (val) => {
|
||||
if (val) {
|
||||
resetState()
|
||||
} else {
|
||||
if (pullWs) {
|
||||
pullWs.close()
|
||||
pullWs = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
refresh()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelPull()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical :size="20">
|
||||
<n-flex>
|
||||
<n-button type="primary" @click="pullModal = true">{{ $gettext('Pull Image') }}</n-button>
|
||||
<n-button type="primary" @click="handlePrune" ghost>{{
|
||||
$gettext('Cleanup Images')
|
||||
}}</n-button>
|
||||
<n-button type="primary" @click="handlePrune" ghost>
|
||||
{{ $gettext('Cleanup Images') }}
|
||||
</n-button>
|
||||
<n-popconfirm @positive-click="handleBulkDelete">
|
||||
<template #trigger>
|
||||
<n-button type="error" :disabled="selectedRowKeys.length === 0" ghost>
|
||||
{{ $gettext('Delete') }}
|
||||
</n-button>
|
||||
</template>
|
||||
{{ $gettext('Are you sure you want to delete the selected images?') }}
|
||||
</n-popconfirm>
|
||||
</n-flex>
|
||||
<n-data-table
|
||||
striped
|
||||
@@ -182,39 +308,102 @@ onMounted(() => {
|
||||
size="huge"
|
||||
:bordered="false"
|
||||
:segmented="false"
|
||||
:mask-closable="!isPulling"
|
||||
:closable="!isPulling"
|
||||
>
|
||||
<n-form :model="pullModel">
|
||||
<n-form-item path="name" :label="$gettext('Image Name')">
|
||||
<n-input
|
||||
v-model:value="pullModel.name"
|
||||
type="text"
|
||||
@keydown.enter.prevent
|
||||
:placeholder="$gettext('docker.io/php:8.3-fpm')"
|
||||
<!-- 拉取进度 -->
|
||||
<template v-if="isPulling || pullProgress.size > 0">
|
||||
<n-flex vertical :size="16">
|
||||
<n-progress
|
||||
type="line"
|
||||
:percentage="totalProgress"
|
||||
:indicator-placement="'inside'"
|
||||
processing
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item path="auth" :label="$gettext('Authentication')">
|
||||
<n-switch v-model:value="pullModel.auth" />
|
||||
</n-form-item>
|
||||
<n-form-item v-if="pullModel.auth" path="username" :label="$gettext('Username')">
|
||||
<n-input
|
||||
v-model:value="pullModel.username"
|
||||
type="text"
|
||||
@keydown.enter.prevent
|
||||
:placeholder="$gettext('Enter username')"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item v-if="pullModel.auth" path="password" :label="$gettext('Password')">
|
||||
<n-input
|
||||
v-model:value="pullModel.password"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
@keydown.enter.prevent
|
||||
:placeholder="$gettext('Enter password')"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-button type="info" block :loading="loading" :disabled="loading" @click="handlePull">
|
||||
{{ $gettext('Submit') }}
|
||||
</n-button>
|
||||
|
||||
<n-card size="small" :bordered="true" class="max-h-300 overflow-y-auto">
|
||||
<n-flex vertical :size="8">
|
||||
<div
|
||||
v-for="[id, progress] in pullProgress"
|
||||
:key="id"
|
||||
class="p-1 px-2 rounded bg-gray-100 dark:bg-gray-800"
|
||||
>
|
||||
<n-flex justify="space-between" align="center">
|
||||
<n-text depth="3" class="text-12 font-mono">
|
||||
{{ id.substring(0, 12) }}
|
||||
</n-text>
|
||||
<n-text depth="2" class="text-12">
|
||||
{{ progress.status }}
|
||||
<template v-if="progress.progress">
|
||||
{{ progress.progress }}
|
||||
</template>
|
||||
</n-text>
|
||||
</n-flex>
|
||||
</div>
|
||||
<n-text v-if="pullProgress.size === 0" depth="3">
|
||||
{{ pullStatus }}
|
||||
</n-text>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
|
||||
<n-flex justify="center">
|
||||
<n-button @click="cancelPull" type="error" ghost>
|
||||
{{ $gettext('Cancel') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
<!-- 拉取错误 -->
|
||||
<n-result
|
||||
v-else-if="pullError"
|
||||
status="error"
|
||||
:title="$gettext('Pull Failed')"
|
||||
:description="pullError"
|
||||
>
|
||||
<template #footer>
|
||||
<n-flex justify="center">
|
||||
<n-button @click="resetState">{{ $gettext('Cancel') }}</n-button>
|
||||
<n-button type="primary" @click="handlePull">{{ $gettext('Retry') }}</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-result>
|
||||
|
||||
<!-- 拉取表单 -->
|
||||
<template v-else>
|
||||
<n-form :model="pullModel">
|
||||
<n-form-item path="name" :label="$gettext('Image Name')">
|
||||
<n-input
|
||||
v-model:value="pullModel.name"
|
||||
type="text"
|
||||
@keydown.enter.prevent
|
||||
:placeholder="$gettext('docker.io/php:8.3-fpm')"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item path="auth" :label="$gettext('Authentication')">
|
||||
<n-switch v-model:value="pullModel.auth" />
|
||||
</n-form-item>
|
||||
<n-form-item v-if="pullModel.auth" path="username" :label="$gettext('Username')">
|
||||
<n-input
|
||||
v-model:value="pullModel.username"
|
||||
type="text"
|
||||
@keydown.enter.prevent
|
||||
:placeholder="$gettext('Enter username')"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item v-if="pullModel.auth" path="password" :label="$gettext('Password')">
|
||||
<n-input
|
||||
v-model:value="pullModel.password"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
@keydown.enter.prevent
|
||||
:placeholder="$gettext('Enter password')"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-button type="info" block :loading="loading" :disabled="loading" @click="handlePull">
|
||||
{{ $gettext('Submit') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
@@ -166,6 +166,15 @@ const handlePrune = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
const promises = selectedRowKeys.value.map((id: any) => container.networkRemove(id))
|
||||
await Promise.all(promises)
|
||||
|
||||
selectedRowKeys.value = []
|
||||
refresh()
|
||||
window.$message.success($gettext('Deleted successfully'))
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
loading.value = true
|
||||
useRequest(container.networkCreate(createModel.value))
|
||||
@@ -193,6 +202,14 @@ onMounted(() => {
|
||||
<n-button type="primary" @click="handlePrune" ghost>{{
|
||||
$gettext('Cleanup Networks')
|
||||
}}</n-button>
|
||||
<n-popconfirm @positive-click="handleBulkDelete">
|
||||
<template #trigger>
|
||||
<n-button type="error" :disabled="selectedRowKeys.length === 0" ghost>
|
||||
{{ $gettext('Delete') }}
|
||||
</n-button>
|
||||
</template>
|
||||
{{ $gettext('Are you sure you want to delete the selected networks?') }}
|
||||
</n-popconfirm>
|
||||
</n-flex>
|
||||
<n-data-table
|
||||
striped
|
||||
|
||||
@@ -120,6 +120,15 @@ const handlePrune = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
const promises = selectedRowKeys.value.map((name: any) => container.volumeRemove(name))
|
||||
await Promise.all(promises)
|
||||
|
||||
selectedRowKeys.value = []
|
||||
refresh()
|
||||
window.$message.success($gettext('Deleted successfully'))
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
loading.value = true
|
||||
useRequest(container.volumeCreate(createModel.value))
|
||||
@@ -147,6 +156,14 @@ onMounted(() => {
|
||||
<n-button type="primary" @click="handlePrune" ghost>{{
|
||||
$gettext('Cleanup Volumes')
|
||||
}}</n-button>
|
||||
<n-popconfirm @positive-click="handleBulkDelete">
|
||||
<template #trigger>
|
||||
<n-button type="error" :disabled="selectedRowKeys.length === 0" ghost>
|
||||
{{ $gettext('Delete') }}
|
||||
</n-button>
|
||||
</template>
|
||||
{{ $gettext('Are you sure you want to delete the selected volumes?') }}
|
||||
</n-popconfirm>
|
||||
</n-flex>
|
||||
<n-data-table
|
||||
striped
|
||||
|
||||
@@ -176,11 +176,6 @@ const handlePaste = () => {
|
||||
}
|
||||
|
||||
const bulkDelete = async () => {
|
||||
if (!selected.value.length) {
|
||||
window.$message.error($gettext('Please select files/folders to delete'))
|
||||
return
|
||||
}
|
||||
|
||||
const promises = selected.value.map((path) => file.delete(path))
|
||||
await Promise.all(promises)
|
||||
|
||||
@@ -235,7 +230,9 @@ watch(
|
||||
<n-button @click="permission = true">{{ $gettext('Permission') }}</n-button>
|
||||
<n-popconfirm @positive-click="bulkDelete">
|
||||
<template #trigger>
|
||||
<n-button>{{ $gettext('Delete') }}</n-button>
|
||||
<n-button :disabled="selected.length === 0" ghost>
|
||||
{{ $gettext('Delete') }}
|
||||
</n-button>
|
||||
</template>
|
||||
{{ $gettext('Are you sure you want to delete in bulk?') }}
|
||||
</n-popconfirm>
|
||||
|
||||
@@ -172,11 +172,6 @@ const handleDelete = (id: number) => {
|
||||
}
|
||||
|
||||
const bulkDelete = async () => {
|
||||
if (selectedRowKeys.value.length === 0) {
|
||||
window.$message.info($gettext('Please select the projects to delete'))
|
||||
return
|
||||
}
|
||||
|
||||
const promises = selectedRowKeys.value.map((id: any) => project.delete(id))
|
||||
await Promise.all(promises)
|
||||
|
||||
@@ -203,8 +198,8 @@ watch(type, () => {
|
||||
</n-button>
|
||||
<n-popconfirm @positive-click="bulkDelete">
|
||||
<template #trigger>
|
||||
<n-button type="error">
|
||||
{{ $gettext('Batch Delete') }}
|
||||
<n-button type="error" :disabled="selectedRowKeys.length === 0" ghost>
|
||||
{{ $gettext('Delete') }}
|
||||
</n-button>
|
||||
</template>
|
||||
{{ $gettext('Are you sure you want to delete the selected projects?') }}
|
||||
|
||||
@@ -256,11 +256,6 @@ const handleDelete = (id: number) => {
|
||||
}
|
||||
|
||||
const bulkDelete = async () => {
|
||||
if (selectedRowKeys.value.length === 0) {
|
||||
window.$message.info($gettext('Please select the websites to delete'))
|
||||
return
|
||||
}
|
||||
|
||||
const promises = selectedRowKeys.value.map((id: any) => website.delete(id, true, false))
|
||||
await Promise.all(promises)
|
||||
|
||||
@@ -290,8 +285,8 @@ onMounted(() => {
|
||||
</n-button>
|
||||
<n-popconfirm @positive-click="bulkDelete">
|
||||
<template #trigger>
|
||||
<n-button type="error">
|
||||
{{ $gettext('Batch Delete') }}
|
||||
<n-button type="error" :disabled="selectedRowKeys.length === 0" ghost>
|
||||
{{ $gettext('Delete') }}
|
||||
</n-button>
|
||||
</template>
|
||||
{{
|
||||
|
||||
Reference in New Issue
Block a user