2
0
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:
Copilot
2026-01-11 05:30:52 +08:00
committed by GitHub
parent 35d398a4b7
commit 3d8623a9f9
20 changed files with 1564 additions and 451 deletions

View File

@@ -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

View File

@@ -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")

View File

@@ -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)

View File

@@ -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)
})
}

View File

@@ -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 {

View File

@@ -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
View 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)
}

View File

@@ -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),
// 删除镜像

View File

@@ -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)
})
}
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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?') }}

View File

@@ -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>
{{