2
0
mirror of https://github.com/acepanel/panel.git synced 2026-02-04 03:07:20 +08:00

feat(#108): 容器编排

This commit is contained in:
耗子
2025-03-27 02:55:45 +08:00
parent 0956385284
commit 69b430d7cb
9 changed files with 419 additions and 30 deletions

View File

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

View File

@@ -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 创建编排文件

View File

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

View File

@@ -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"`
}

View File

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

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

View File

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

View File

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

View File

@@ -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('删除成功')
})
}