mirror of
https://github.com/acepanel/panel.git
synced 2026-02-04 03:07:20 +08:00
feat(#108): 容器编排
This commit is contained in:
@@ -4,7 +4,7 @@ import "github.com/tnb-labs/panel/pkg/types"
|
||||
|
||||
type ContainerComposeRepo interface {
|
||||
List() ([]types.ContainerCompose, error)
|
||||
Get(name string) (string, string, error)
|
||||
Get(name string) (string, []types.KV, error)
|
||||
Create(name, compose string, envs []types.KV) error
|
||||
Update(name, compose string, envs []types.KV) error
|
||||
Up(name string, force bool) error
|
||||
|
||||
@@ -2,7 +2,6 @@ package data
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -22,7 +21,7 @@ func NewContainerComposeRepo() biz.ContainerComposeRepo {
|
||||
|
||||
// List 列出所有编排文件名
|
||||
func (r *containerComposeRepo) List() ([]types.ContainerCompose, error) {
|
||||
raw, err := shell.Execf("docker compose ls --format json")
|
||||
raw, err := shell.Execf("docker compose ls -a --format json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -32,35 +31,33 @@ func (r *containerComposeRepo) List() ([]types.ContainerCompose, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
composeDir := filepath.Join(app.Root, "server", "compose")
|
||||
entries, err := os.ReadDir(composeDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var composes []types.ContainerCompose
|
||||
index := make(map[string]int)
|
||||
composeDir := filepath.Join(app.Root, "server", "compose")
|
||||
err = filepath.WalkDir(composeDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 跳过自身
|
||||
if path == composeDir {
|
||||
return nil
|
||||
}
|
||||
if !d.IsDir() {
|
||||
return nil
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
path := filepath.Join(composeDir, entry.Name())
|
||||
var createdAt time.Time
|
||||
if info, err := d.Info(); err == nil {
|
||||
if info, err := entry.Info(); err == nil {
|
||||
createdAt = info.ModTime()
|
||||
}
|
||||
|
||||
composes = append(composes, types.ContainerCompose{
|
||||
Name: filepath.Base(path),
|
||||
Dir: path,
|
||||
Name: entry.Name(),
|
||||
Path: path,
|
||||
Status: "unknown",
|
||||
CreatedAt: createdAt,
|
||||
})
|
||||
index[filepath.Base(path)] = len(composes) - 1
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
index[entry.Name()] = len(composes) - 1
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
@@ -74,10 +71,23 @@ func (r *containerComposeRepo) List() ([]types.ContainerCompose, error) {
|
||||
}
|
||||
|
||||
// Get 获取编排文件和环境变量内容
|
||||
func (r *containerComposeRepo) Get(name string) (string, string, error) {
|
||||
func (r *containerComposeRepo) Get(name string) (string, []types.KV, error) {
|
||||
content, _ := os.ReadFile(filepath.Join(app.Root, "server", "compose", name, "docker-compose.yml"))
|
||||
env, _ := os.ReadFile(filepath.Join(app.Root, "server", "compose", name, ".env"))
|
||||
return string(content), string(env), nil // 有意忽略错误,这样可以允许新建文件
|
||||
|
||||
var envs []types.KV
|
||||
for _, line := range strings.Split(string(env), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
kv := strings.SplitN(line, "=", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
envs = append(envs, types.KV{Key: kv[0], Value: kv[1]})
|
||||
}
|
||||
|
||||
return string(content), envs, nil // 有意忽略错误,这样可以允许新建文件
|
||||
}
|
||||
|
||||
// Create 创建编排文件
|
||||
|
||||
@@ -41,7 +41,7 @@ func (s *ContainerComposeService) Get(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
compose, env, err := s.containerComposeRepo.Get(req.Name)
|
||||
compose, envs, err := s.containerComposeRepo.Get(req.Name)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
@@ -49,7 +49,7 @@ func (s *ContainerComposeService) Get(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
Success(w, chix.M{
|
||||
"compose": compose,
|
||||
"env": env,
|
||||
"envs": envs,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ type ContainerComposeRaw struct {
|
||||
|
||||
type ContainerCompose struct {
|
||||
Name string `json:"name"`
|
||||
Dir string `json:"dir"`
|
||||
Path string `json:"path"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -27,6 +27,22 @@ export default {
|
||||
containerLogs: (id: string): any => http.Get(`/container/container/${id}/logs`),
|
||||
// 清理容器
|
||||
containerPrune: (): any => http.Post(`/container/container/prune`),
|
||||
// 获取编排列表
|
||||
composeList: (page: number, limit: number): any =>
|
||||
http.Get('/container/compose', { params: { page, limit } }),
|
||||
// 获取编排
|
||||
composeGet: (name: string): any => http.Get(`/container/compose/${name}`),
|
||||
// 创建编排
|
||||
composeCreate: (config: any): any => http.Post('/container/compose', config),
|
||||
// 更新编排
|
||||
composeUpdate: (name: string, config: any): any => http.Put(`/container/compose/${name}`, config),
|
||||
// 删除编排
|
||||
composeRemove: (name: string): any => http.Delete(`/container/compose/${name}`),
|
||||
// 启动编排
|
||||
composeUp: (name: string, force: boolean): any =>
|
||||
http.Post(`/container/compose/${name}/up`, { force }),
|
||||
// 停止编排
|
||||
composeDown: (name: string): any => http.Post(`/container/compose/${name}/down`),
|
||||
// 获取网络列表
|
||||
networkList: (page: number, limit: number): any =>
|
||||
http.Get(`/container/network`, { params: { page, limit } }),
|
||||
|
||||
357
web/src/views/container/ComposeView.vue
Normal file
357
web/src/views/container/ComposeView.vue
Normal file
@@ -0,0 +1,357 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NCheckbox, NDataTable, NFlex, NInput, NPopconfirm, NTag } from 'naive-ui'
|
||||
|
||||
import container from '@/api/panel/container'
|
||||
import { useFileStore } from '@/store'
|
||||
import { formatDateTime } from '@/utils'
|
||||
|
||||
const fileStore = useFileStore()
|
||||
const router = useRouter()
|
||||
|
||||
const forcePush = ref(false)
|
||||
|
||||
const createModel = ref({
|
||||
name: '',
|
||||
compose: '',
|
||||
envs: []
|
||||
})
|
||||
const createModal = ref(false)
|
||||
|
||||
const updateModel = ref({
|
||||
name: '',
|
||||
compose: '',
|
||||
envs: []
|
||||
})
|
||||
const updateModal = ref(false)
|
||||
|
||||
const columns: any = [
|
||||
{
|
||||
title: '名称',
|
||||
key: 'name',
|
||||
minWidth: 150,
|
||||
resizable: true,
|
||||
ellipsis: { tooltip: true }
|
||||
},
|
||||
{
|
||||
title: '目录',
|
||||
key: 'path',
|
||||
minWidth: 150,
|
||||
resizable: true,
|
||||
render(row: any) {
|
||||
return h(
|
||||
NTag,
|
||||
{
|
||||
class: 'cursor-pointer hover:opacity-60',
|
||||
type: 'info',
|
||||
onClick: () => {
|
||||
fileStore.path = row.path
|
||||
router.push({ name: 'file-index' })
|
||||
}
|
||||
},
|
||||
{ default: () => row.path }
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 150,
|
||||
resizable: true,
|
||||
ellipsis: { tooltip: true }
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
key: 'created_at',
|
||||
width: 200,
|
||||
resizable: true,
|
||||
render(row: any) {
|
||||
return formatDateTime(row.created_at)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 280,
|
||||
align: 'center',
|
||||
hideInExcel: true,
|
||||
render(row: any) {
|
||||
return [
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
onClick: () => {
|
||||
useRequest(container.composeGet(row.name)).onSuccess(({ data }: { data: any }) => {
|
||||
updateModel.value = {
|
||||
name: row.name,
|
||||
compose: data.compose,
|
||||
envs: data.envs
|
||||
}
|
||||
updateModal.value = true
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () => '编辑'
|
||||
}
|
||||
),
|
||||
h(
|
||||
NPopconfirm,
|
||||
{
|
||||
showIcon: false,
|
||||
onPositiveClick: () => {
|
||||
const messageReactive = window.$message.loading('启动中...', {
|
||||
duration: 0
|
||||
})
|
||||
useRequest(container.composeUp(row.name, forcePush.value))
|
||||
.onSuccess(() => {
|
||||
refresh()
|
||||
forcePush.value = false
|
||||
window.$message.success('启动成功')
|
||||
})
|
||||
.onComplete(() => {
|
||||
messageReactive?.destroy()
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () => {
|
||||
return h(
|
||||
NFlex,
|
||||
{
|
||||
vertical: true
|
||||
},
|
||||
{
|
||||
default: () => [
|
||||
h('strong', {}, { default: () => `确定启动编排 ${row.name} 吗?` }),
|
||||
h(
|
||||
NCheckbox,
|
||||
{
|
||||
checked: forcePush.value,
|
||||
onUpdateChecked: (v) => (forcePush.value = v)
|
||||
},
|
||||
{ default: () => '强制拉取镜像' }
|
||||
)
|
||||
]
|
||||
}
|
||||
)
|
||||
},
|
||||
trigger: () => {
|
||||
return h(
|
||||
NButton,
|
||||
{
|
||||
style: 'margin-left: 15px;',
|
||||
size: 'small',
|
||||
type: 'success'
|
||||
},
|
||||
{
|
||||
default: () => '启动'
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
),
|
||||
h(
|
||||
NPopconfirm,
|
||||
{
|
||||
onPositiveClick: () => {
|
||||
useRequest(container.composeDown(row.name)).onSuccess(() => {
|
||||
refresh()
|
||||
window.$message.success('停止成功')
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () => {
|
||||
return `确定停止编排 ${row.name} 吗?`
|
||||
},
|
||||
trigger: () => {
|
||||
return h(
|
||||
NButton,
|
||||
{
|
||||
style: 'margin-left: 15px;',
|
||||
size: 'small',
|
||||
type: 'warning'
|
||||
},
|
||||
{
|
||||
default: () => '停止'
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
),
|
||||
h(
|
||||
NPopconfirm,
|
||||
{
|
||||
onPositiveClick: () => {
|
||||
useRequest(container.composeRemove(row.name)).onSuccess(() => {
|
||||
refresh()
|
||||
window.$message.success('删除成功')
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () => {
|
||||
return `确定删除编排 ${row.name} 吗?`
|
||||
},
|
||||
trigger: () => {
|
||||
return h(
|
||||
NButton,
|
||||
{
|
||||
style: 'margin-left: 15px;',
|
||||
size: 'small',
|
||||
type: 'error'
|
||||
},
|
||||
{
|
||||
default: () => '删除'
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const { loading, data, page, total, pageSize, pageCount, refresh } = usePagination(
|
||||
(page, pageSize) => container.composeList(page, pageSize),
|
||||
{
|
||||
initialData: { total: 0, list: [] },
|
||||
initialPageSize: 20,
|
||||
total: (res: any) => res.total,
|
||||
data: (res: any) => res.items
|
||||
}
|
||||
)
|
||||
|
||||
const handleCreate = () => {
|
||||
loading.value = true
|
||||
useRequest(container.composeCreate(createModel.value))
|
||||
.onSuccess(() => {
|
||||
refresh()
|
||||
window.$message.success('创建成功')
|
||||
})
|
||||
.onComplete(() => {
|
||||
loading.value = false
|
||||
createModal.value = false
|
||||
createModel.value = {
|
||||
name: '',
|
||||
compose: '',
|
||||
envs: []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpdate = () => {
|
||||
loading.value = true
|
||||
useRequest(container.composeUpdate(updateModel.value.name, updateModel.value))
|
||||
.onSuccess(() => {
|
||||
refresh()
|
||||
window.$message.success('更新成功')
|
||||
})
|
||||
.onComplete(() => {
|
||||
loading.value = false
|
||||
updateModal.value = false
|
||||
updateModel.value = {
|
||||
name: '',
|
||||
compose: '',
|
||||
envs: []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical :size="20">
|
||||
<n-flex>
|
||||
<n-button type="primary" @click="createModal = true">创建编排</n-button>
|
||||
</n-flex>
|
||||
<n-data-table
|
||||
striped
|
||||
remote
|
||||
:loading="loading"
|
||||
:scroll-x="1000"
|
||||
:data="data"
|
||||
:columns="columns"
|
||||
:row-key="(row: any) => row.id"
|
||||
v-model:page="page"
|
||||
v-model:pageSize="pageSize"
|
||||
:pagination="{
|
||||
page: page,
|
||||
pageCount: pageCount,
|
||||
pageSize: pageSize,
|
||||
itemCount: total,
|
||||
showQuickJumper: true,
|
||||
showSizePicker: true,
|
||||
pageSizes: [20, 50, 100, 200]
|
||||
}"
|
||||
/>
|
||||
</n-flex>
|
||||
<n-modal
|
||||
v-model:show="createModal"
|
||||
preset="card"
|
||||
title="创建编排"
|
||||
style="width: 60vw"
|
||||
size="huge"
|
||||
:bordered="false"
|
||||
:segmented="false"
|
||||
>
|
||||
<n-form :model="createModel">
|
||||
<n-form-item path="name" label="编排名">
|
||||
<n-input v-model:value="createModel.name" type="text" />
|
||||
</n-form-item>
|
||||
<n-form-item path="compose" label="编排">
|
||||
<n-input
|
||||
v-model:value="createModel.compose"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 10, maxRows: 20 }"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item path="envs" label="环境变量">
|
||||
<n-dynamic-input
|
||||
v-model:value="createModel.envs"
|
||||
preset="pair"
|
||||
key-placeholder="变量名"
|
||||
value-placeholder="变量值"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-button type="info" block :loading="loading" :disabled="loading" @click="handleCreate">
|
||||
提交
|
||||
</n-button>
|
||||
</n-modal>
|
||||
<n-modal
|
||||
v-model:show="updateModal"
|
||||
preset="card"
|
||||
title="编辑编排"
|
||||
style="width: 60vw"
|
||||
size="huge"
|
||||
:bordered="false"
|
||||
:segmented="false"
|
||||
>
|
||||
<n-form :model="updateModel">
|
||||
<n-form-item path="compose" label="编排">
|
||||
<n-input
|
||||
v-model:value="updateModel.compose"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 10, maxRows: 20 }"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item path="envs" label="环境变量">
|
||||
<n-dynamic-input
|
||||
v-model:value="updateModel.envs"
|
||||
preset="pair"
|
||||
key-placeholder="变量名"
|
||||
value-placeholder="变量值"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-button type="info" block :loading="loading" :disabled="loading" @click="handleUpdate">
|
||||
提交
|
||||
</n-button>
|
||||
</n-modal>
|
||||
</template>
|
||||
@@ -335,11 +335,11 @@ onMounted(() => {
|
||||
<n-dynamic-input
|
||||
v-model:value="createModel.env"
|
||||
preset="pair"
|
||||
key-placeholder="环境变量名"
|
||||
value-placeholder="环境变量值"
|
||||
key-placeholder="变量名"
|
||||
value-placeholder="变量值"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item path="env" label="标签">
|
||||
<n-form-item path="labels" label="标签">
|
||||
<n-dynamic-input
|
||||
v-model:value="createModel.labels"
|
||||
preset="pair"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import ComposeView from '@/views/container/ComposeView.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'container-index'
|
||||
})
|
||||
@@ -17,6 +19,9 @@ const current = ref('container')
|
||||
<n-tab-pane name="container" tab="容器">
|
||||
<container-view />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="compose" tab="编排">
|
||||
<compose-view />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="image" tab="镜像">
|
||||
<image-view />
|
||||
</n-tab-pane>
|
||||
|
||||
@@ -152,6 +152,7 @@ const { loading, data, page, total, pageSize, pageCount, refresh } = usePaginati
|
||||
|
||||
const handleDelete = (row: any) => {
|
||||
useRequest(container.networkRemove(row.id)).onSuccess(() => {
|
||||
refresh()
|
||||
window.$message.success('删除成功')
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user