feat: Enhance log container and compose management with new UI components and improved functionality

This commit is contained in:
HynoR 2025-11-18 19:42:36 +08:00
parent 7bd9b0c9a9
commit 912a9ad6b4
3 changed files with 564 additions and 750 deletions

View file

@ -1,5 +1,5 @@
<template> <template>
<div> <div v-if="showControl">
<el-select @change="searchLogs" class="fetchClass" v-model="logSearch.mode"> <el-select @change="searchLogs" class="fetchClass" v-model="logSearch.mode">
<template #prefix>{{ $t('container.fetch') }}</template> <template #prefix>{{ $t('container.fetch') }}</template>
<el-option v-for="item in timeOptions" :key="item.label" :value="item.value" :label="item.label" /> <el-option v-for="item in timeOptions" :key="item.label" :value="item.value" :label="item.label" />
@ -69,6 +69,14 @@ const props = defineProps({
type: String, type: String,
default: '', default: '',
}, },
showControl: {
type: Boolean,
default: true,
},
defaultFollow: {
type: Boolean,
default: false,
},
}); });
const styleVars = computed(() => ({ const styleVars = computed(() => ({
@ -80,10 +88,10 @@ const logContainer = ref<HTMLElement | null>(null);
const logs = ref<string[]>([]); const logs = ref<string[]>([]);
let eventSource: EventSource | null = null; let eventSource: EventSource | null = null;
const logSearch = reactive({ const logSearch = reactive({
isWatch: true, isWatch: props.defaultFollow ? true : true,
container: '', container: '',
mode: 'all', mode: 'all',
tail: 100, tail: props.defaultFollow ? 0 : 100,
compose: '', compose: '',
}); });
const logHeight = 20; const logHeight = 20;

View file

@ -1,564 +0,0 @@
<template>
<div v-loading="pageLoading">
<el-card v-if="isExist && !isActive" class="mask-prompt">
<span>{{ $t('container.serviceUnavailable') }}</span>
<el-button type="primary" link class="bt" @click="goSetting"> {{ $t('container.setting') }} </el-button>
<span>{{ $t('container.startIn') }}</span>
</el-card>
<NoSuchService v-if="!isExist" name="Docker" />
<LayoutContent v-if="isExist" backName="Compose" :title="composeTitle" :class="{ mask: !isActive }">
<template #main>
<el-empty v-if="!composeName" :description="$t('commons.msg.noneData')" />
<div v-else class="w-full">
<div class="app-status mb-4">
<el-card>
<div class="flex w-full flex-col gap-4 md:flex-row">
<div class="flex items-center gap-1">
<el-button
type="success"
plain
:loading="operateLoading && currentOperation === 'up'"
:disabled="disableOperate"
@click="handleComposeOperate('up')"
>
{{ $t('commons.operate.start') }}
</el-button>
<el-divider direction="vertical" />
<el-button
type="danger"
plain
:loading="operateLoading && currentOperation === 'stop'"
:disabled="disableOperate"
@click="handleComposeOperate('stop')"
>
{{ $t('commons.operate.stop') }}
</el-button>
<el-divider direction="vertical" />
<el-button
type="warning"
plain
:loading="operateLoading && currentOperation === 'restart'"
:disabled="disableOperate"
@click="handleComposeOperate('restart')"
>
{{ $t('commons.operate.restart') }}
</el-button>
<el-divider direction="vertical" />
<el-button type="primary" link :loading="detailLoading" @click="refreshDetail">
{{ $t('commons.button.refresh') }}
</el-button>
<el-divider direction="vertical" />
<el-button
type="primary"
link
:disabled="!composeInfo?.workdir"
@click="openComposeFolder"
>
{{ $t('container.composeDirectory') }}
</el-button>
</div>
</div>
<el-alert
v-if="showOperateHelper"
type="warning"
:closable="false"
:title="$t('container.composeDetailHelper')"
class="mt-3"
/>
</el-card>
</div>
<el-card v-if="composeInfo && composeContainers.length > 0" class="mb-4" shadow="never">
<template #header>
<div class="flex flex-wrap items-center justify-between gap-3">
<span class="text-sm font-medium">
{{ $t('container.containerStatus') }}
( {{ composeInfo?.containerCount || 0 }} / {{ composeInfo?.runningCount || 0 }} )
</span>
</div>
</template>
<ComplexTable :data="tableData" :heightDiff="400">
<el-table-column
:label="$t('commons.table.name')"
min-width="150"
prop="name"
show-overflow-tooltip
>
<template #default="{ row }">
<el-text type="primary" class="cursor-pointer" @click="onInspectContainer(row)">
{{ row.name }}
</el-text>
</template>
</el-table-column>
<el-table-column :label="$t('commons.table.status')" min-width="100" prop="state">
<template #default="{ row }">
<Status :key="row.state" :status="row.state"></Status>
</template>
</el-table-column>
<el-table-column
:label="$t('container.source')"
show-overflow-tooltip
prop="resource"
min-width="120"
>
<template #default="{ row }">
<div v-if="row.hasLoad" class="flex items-center">
<div class="text-xs">
<div>CPU: {{ row.cpuPercent.toFixed(2) }}%</div>
<div class="text-xs">
{{ $t('monitor.memory') }}: {{ row.memoryPercent.toFixed(2) }}%
</div>
</div>
<el-popover placement="right" width="500px" trigger="hover">
<template #reference>
<el-icon
class="cursor-pointer text-gray-500 hover:text-primary ml-1"
:size="16"
>
<Histogram />
</el-icon>
</template>
<template #default>
<el-descriptions direction="vertical" border :column="3" size="small">
<el-descriptions-item :label="$t('container.cpuUsage')">
{{ computeCPU(row.cpuTotalUsage) }}
</el-descriptions-item>
<el-descriptions-item :label="$t('container.cpuTotal')">
{{ computeCPU(row.systemUsage) }}
</el-descriptions-item>
<el-descriptions-item :label="$t('container.core')">
{{ row.percpuUsage }}
</el-descriptions-item>
<el-descriptions-item :label="$t('container.memUsage')">
{{ computeSizeForDocker(row.memoryUsage) }}
</el-descriptions-item>
<el-descriptions-item :label="$t('container.memCache')">
{{ computeSizeForDocker(row.memoryCache) }}
</el-descriptions-item>
<el-descriptions-item :label="$t('container.memTotal')">
{{ computeSizeForDocker(row.memoryLimit) }}
</el-descriptions-item>
<el-descriptions-item>
<template #label>
{{ $t('container.sizeRw') }}
<el-tooltip :content="$t('container.sizeRwHelper')">
<el-icon class="icon-item"><InfoFilled /></el-icon>
</el-tooltip>
</template>
{{ computeSize2(row.sizeRw) }}
</el-descriptions-item>
<el-descriptions-item :label="$t('container.sizeRootFs')">
<template #label>
{{ $t('container.sizeRootFs') }}
<el-tooltip :content="$t('container.sizeRootFsHelper')">
<el-icon class="icon-item"><InfoFilled /></el-icon>
</el-tooltip>
</template>
{{ computeSize2(row.sizeRootFs) }}
</el-descriptions-item>
</el-descriptions>
</template>
</el-popover>
</div>
<div v-if="!row.hasLoad">
<el-button link loading></el-button>
</div>
</template>
</el-table-column>
<el-table-column :label="$t('commons.table.operate')" width="200px" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="onOpenTerminal(row)">
{{ $t('menu.terminal') }}
</el-button>
<el-divider direction="vertical" />
<el-button type="primary" link @click="onOpenLog(row)">
{{ $t('commons.button.log') }}
</el-button>
</template>
</el-table-column>
</ComplexTable>
</el-card>
<div class="grid min-h-[600px] grid-cols-1 gap-4 xl:grid-cols-[minmax(0,5fr)_minmax(0,7fr)]">
<el-card class="h-full flex flex-col compose-detail-editor" shadow="never">
<template #header>
<div class="font-medium">
{{ $t('container.composeTemplate') }}
</div>
</template>
<div class="flex-1">
<el-form label-position="top" class="flex h-full flex-col" @submit.prevent>
<el-form-item>
<CodemirrorPro
v-model="composeContent"
mode="yaml"
:disabled="disableEdit"
:heightDiff="200"
placeholder="#Define or paste the content of your docker-compose file here"
/>
</el-form-item>
<div v-if="showEnvSetting" class="mt-4">
<el-form-item :label="$t('container.env')">
<el-input
v-model="envStr"
type="textarea"
:rows="3"
:placeholder="$t('container.tagHelper')"
/>
</el-form-item>
<span class="input-help whitespace-break-spaces">
{{ $t('container.editComposeHelper') }}
</span>
<CodemirrorPro
v-model="envFileContent"
:height="45"
:minHeight="45"
disabled
mode="yaml"
/>
</div>
</el-form>
</div>
<div class="mt-4 flex justify-end gap-3">
<el-button
type="primary"
:disabled="disableEdit"
:loading="saving"
@click="onSubmitEdit"
>
{{ $t('commons.button.save') }}
</el-button>
</div>
</el-card>
<el-card class="h-full compose-detail-log" shadow="never">
<template #header>
<div class="flex items-center justify-between gap-3">
<span>{{ $t('commons.button.log') }}</span>
</div>
</template>
<div class="flex flex-1 flex-col">
<ContainerLog
v-if="composePath && shouldLoadLog"
:key="logKey"
:compose="composePath"
:resource="composeName"
:highlightDiff="logHeightDiff"
/>
<el-empty v-else :description="$t('commons.msg.noneData')" />
</div>
</el-card>
</div>
</div>
</template>
</LayoutContent>
<ContainerInspectDialog ref="containerInspectRef" />
<TerminalDialog ref="terminalDialogRef" />
<ContainerLogDialog ref="containerLogDialogRef" :highlightDiff="210" />
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import NoSuchService from '@/components/layout-content/no-such-service.vue';
import CodemirrorPro from '@/components/codemirror-pro/index.vue';
import ContainerLog from '@/components/log/container/index.vue';
import ContainerInspectDialog from '@/views/container/container/inspect/index.vue';
import ComplexTable from '@/components/complex-table/index.vue';
import Status from '@/components/status/index.vue';
import TerminalDialog from '@/views/container/container/terminal/index.vue';
import ContainerLogDialog from '@/components/log/container-drawer/index.vue';
import {
composeOperator,
composeUpdate,
containerListStats,
inspect,
loadDockerStatus,
searchCompose,
} from '@/api/modules/container';
import { Container } from '@/api/interface/container';
import { routerToFileWithPath, routerToName } from '@/utils/router';
import { MsgError, MsgSuccess } from '@/utils/message';
import { computeCPU, computeSize2, computeSizeForDocker } from '@/utils/util';
import i18n from '@/lang';
import { Histogram } from '@element-plus/icons-vue';
const route = useRoute();
const composeName = ref('');
const composeContent = ref('');
const envStr = ref('');
const envFileContent = ref(`env_file:\n - 1panel.env`);
const composeInfo = ref<Container.ComposeInfo>();
const isActive = ref(false);
const isExist = ref(false);
const dockerLoading = ref(false);
const detailLoading = ref(false);
const saving = ref(false);
const operateLoading = ref(false);
const currentOperation = ref('');
const logKey = ref(0);
const logHeightDiff = 220;
const containerInspectRef = ref();
const containerStats = ref<any[]>([]);
const terminalDialogRef = ref();
const containerLogDialogRef = ref();
const shouldLoadLog = ref(false);
const pageLoading = computed(() => dockerLoading.value || detailLoading.value);
const composeTitle = computed(() => {
if (!composeName.value) {
return i18n.global.t('container.compose');
}
return `${i18n.global.t('container.compose')} · ${composeName.value}`;
});
const composePath = computed(() => composeInfo.value?.path || (route.query.path as string) || '');
const composeContainers = computed(() => composeInfo.value?.containers || []);
const disableEdit = computed(() => composeInfo.value?.createdBy === 'Local');
const showEnvSetting = computed(() => composeInfo.value?.createdBy === '1Panel');
const showOperateHelper = computed(() => !composeInfo.value?.createdBy || composeInfo.value?.createdBy === 'Local');
const disableOperate = computed(
() => !composeInfo.value || !composePath.value || !isActive.value || !isExist.value || operateLoading.value,
);
const tableData = computed(() => {
return composeContainers.value.map((container) => {
const stats = containerStats.value.find((s) => s.containerID === container.containerID);
return {
...container,
hasLoad: !!stats,
cpuPercent: stats?.cpuPercent || 0,
memoryPercent: stats?.memoryPercent || 0,
cpuTotalUsage: stats?.cpuTotalUsage || 0,
systemUsage: stats?.systemUsage || 0,
percpuUsage: stats?.percpuUsage || 0,
memoryCache: stats?.memoryCache || 0,
memoryUsage: stats?.memoryUsage || 0,
memoryLimit: stats?.memoryLimit || 0,
sizeRw: stats?.sizeRw || 0,
sizeRootFs: stats?.sizeRootFs || 0,
};
});
});
const syncRouteParams = () => {
composeName.value = (route.query.name as string) || (route.params.name as string) || '';
};
const loadStatus = async () => {
dockerLoading.value = true;
await loadDockerStatus()
.then((res) => {
isActive.value = res.data.isActive;
isExist.value = res.data.isExist;
dockerLoading.value = false;
loadInitialDetail();
})
.catch(() => {
dockerLoading.value = false;
isActive.value = false;
isExist.value = false;
});
};
const loadInitialDetail = async () => {
if (!composeName.value || !isActive.value || !isExist.value) {
return;
}
detailLoading.value = true;
shouldLoadLog.value = false;
try {
await loadComposeContent();
await new Promise((resolve) => setTimeout(resolve, 100));
await loadComposeInfo();
detailLoading.value = false;
await new Promise((resolve) => setTimeout(resolve, 100));
shouldLoadLog.value = true;
logKey.value++;
await new Promise((resolve) => setTimeout(resolve, 100));
await loadContainerStats();
} catch (error) {
detailLoading.value = false;
throw error;
}
};
const refreshDetail = async () => {
if (!composeName.value || !isActive.value || !isExist.value) {
return;
}
detailLoading.value = true;
try {
await loadComposeInfo();
detailLoading.value = false;
await new Promise((resolve) => setTimeout(resolve, 300));
await loadContainerStats();
} catch (error) {
detailLoading.value = false;
throw error;
}
};
const loadComposeInfo = async () => {
const params = {
info: composeName.value,
page: 1,
pageSize: 1,
};
const res = await searchCompose(params);
const items = res.data?.items || [];
const target = items.find((item) => item.name === composeName.value) || items[0];
if (!target) {
composeInfo.value = undefined;
envStr.value = '';
MsgError(i18n.global.t('commons.msg.noneData'));
return;
}
composeInfo.value = target;
envStr.value = (target.env || []).join('\n');
};
const loadComposeContent = async () => {
const res = await inspect({ id: composeName.value, type: 'compose' });
composeContent.value = res.data;
};
const loadContainerStats = async () => {
try {
const res = await containerListStats();
containerStats.value = res.data || [];
} catch (error) {
containerStats.value = [];
}
};
const onSubmitEdit = async () => {
if (!composeInfo.value || !composePath.value || disableEdit.value) {
return;
}
const param = {
name: composeName.value,
path: composePath.value,
content: composeContent.value,
createdBy: composeInfo.value.createdBy,
env: envStr.value ? envStr.value.split('\n') : [],
};
saving.value = true;
await composeUpdate(param)
.then(async () => {
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
await loadComposeContent();
refreshDetail();
})
.finally(() => {
saving.value = false;
});
};
const handleComposeOperate = async (operation: 'up' | 'stop' | 'restart') => {
if (!composeInfo.value || !composePath.value) {
return;
}
const mes = i18n.global.t('container.composeOperatorHelper', [
composeInfo.value.name,
i18n.global.t('commons.operate.' + operation),
]);
ElMessageBox.confirm(mes, i18n.global.t('commons.operate.' + operation), {
confirmButtonText: i18n.global.t('commons.button.confirm'),
cancelButtonText: i18n.global.t('commons.button.cancel'),
type: 'info',
}).then(async () => {
currentOperation.value = operation;
operateLoading.value = true;
const params = {
name: composeInfo.value!.name,
path: composePath.value,
operation: operation,
withFile: false,
force: false,
};
await composeOperator(params)
.then(() => {
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
refreshDetail();
})
.finally(() => {
operateLoading.value = false;
currentOperation.value = '';
});
});
};
const openComposeFolder = () => {
if (composeInfo.value?.workdir) {
routerToFileWithPath(composeInfo.value.workdir);
}
};
const goSetting = async () => {
routerToName('ContainerSetting');
};
const onInspectContainer = async (item: any) => {
if (!item.containerID) {
return;
}
const res = await inspect({ id: item.containerID, type: 'container' });
containerInspectRef.value!.acceptParams({ data: res.data, ports: item.ports || [] });
};
const onOpenTerminal = (row: any) => {
terminalDialogRef.value?.acceptParams({ container: row.name });
};
const onOpenLog = (row: any) => {
containerLogDialogRef.value?.acceptParams({ container: row.name });
};
onMounted(() => {
syncRouteParams();
loadStatus();
});
watch(
() => route.fullPath,
() => {
syncRouteParams();
loadInitialDetail();
},
);
</script>
<style scoped lang="scss">
.app-status {
:deep(.el-card__body) {
padding: 16px 20px;
}
}
.compose-detail-editor {
:deep(.el-card__body) {
flex: 1;
display: flex;
flex-direction: column;
}
}
.compose-detail-log {
:deep(.el-card__body) {
height: 100%;
display: flex;
flex-direction: column;
}
}
</style>

View file

@ -13,124 +13,440 @@
{{ $t('container.createCompose') }} {{ $t('container.createCompose') }}
</el-button> </el-button>
</template> </template>
<template #rightToolBar>
<TableSearch @search="search()" v-model:searchName="searchName" />
<TableRefresh @search="search()" />
<TableSetting title="compose-refresh" @search="search()" />
</template>
<template #main> <template #main>
<ComplexTable <div class="flex gap-4 h-full">
:pagination-config="paginationConfig" <div class="w-45 flex-shrink-0 flex flex-col border-r pr-4">
v-model:selects="selects" <el-input
:data="data" v-model="searchName"
@search="search" :placeholder="$t('commons.button.search')"
:heightDiff="350" clearable
> class="mb-4"
<el-table-column @clear="search"
:label="$t('commons.table.name')" @keyup.enter="search"
width="170" >
prop="name" <template #prefix>
sortable <el-icon><Search /></el-icon>
fix </template>
show-overflow-tooltip </el-input>
>
<template #default="{ row }"> <div class="flex-1 overflow-auto">
<el-text type="primary" class="cursor-pointer" @click="loadDetail(row)"> <div
{{ row.name }} v-for="item in data"
</el-text> :key="item.name"
</template> class="compose-list-item p-3 mb-2 rounded cursor-pointer border"
</el-table-column> :class="{
<el-table-column :label="$t('app.source')" prop="createdBy" min-width="80" fix> 'border-primary bg-blue-50': selectedCompose?.name === item.name,
<template #default="{ row }"> 'border-transparent hover:bg-gray-50': selectedCompose?.name !== item.name,
<span v-if="row.createdBy === ''">{{ $t('commons.table.local') }}</span> }"
<span v-if="row.createdBy === 'Apps'">{{ $t('menu.apps') }}</span> @click="loadDetail(item)"
<span v-if="row.createdBy === '1Panel'">1Panel</span> >
</template> <div class="min-w-0">
</el-table-column> <div class="font-medium truncate">{{ item.name }}</div>
<el-table-column :label="$t('container.composeDirectory')" min-width="80" fix> <div class="text-xs text-gray-500 mt-1">
<template #default="{ row }"> <el-text v-if="item.containerCount === 0" type="danger" size="small">
<el-tooltip :content="row.workdir"> {{ $t('container.exited') }}
<el-button type="primary" link @click="toComposeFolder(row)"> </el-text>
<el-icon> <el-text
<FolderOpened /> v-else
</el-icon> :type="item.containerCount === item.runningCount ? 'success' : 'warning'"
</el-button> size="small"
</el-tooltip> >
</template> {{ $t('container.running', [item.runningCount, item.containerCount]) }}
</el-table-column> </el-text>
<el-table-column :label="$t('container.containerStatus')" min-width="80" fix> </div>
<template #default="{ row }">
<el-text class="mx-1" v-if="row.containerCount == 0" type="danger">
{{ $t('container.exited') }}
</el-text>
<el-popover width="300px" v-else>
<template #reference>
<el-text
class="cursor-pointer"
size="small"
:type="row.containerCount === row.runningCount ? 'success' : 'warning'"
>
{{ $t('container.running', [row.runningCount, row.containerCount]) }}
</el-text>
</template>
<div v-for="(item, index) in row.containers" :key="index" class="mt-2">
<span>{{ item.name }}</span>
<Status class="float-right" :key="item.state" :status="item.state" />
</div> </div>
</el-popover> </div>
</template> <el-empty v-if="data.length === 0" :description="$t('commons.msg.noneData')" />
</el-table-column> </div>
<el-table-column :label="$t('commons.table.createdAt')" prop="createdAt" min-width="80" fix /> </div>
<fu-table-operations
width="200px" <div v-if="selectedCompose" class="flex-1 min-w-0">
:ellipsis="2" <div v-loading="detailLoading" class="h-full flex flex-col gap-4">
:buttons="buttons" <el-card shadow="never">
:label="$t('commons.table.operate')" <div class="mb-3">
fix <div class="flex items-center gap-4 mb-2">
/> <div class="text-sm">
</ComplexTable> <span class="text-gray-500">{{ $t('app.source') }}:</span>
<el-tag v-if="composeInfo?.createdBy === ''" size="small">
{{ $t('commons.table.local') }}
</el-tag>
<el-tag
v-else-if="composeInfo?.createdBy === 'Apps'"
type="success"
size="small"
>
{{ $t('menu.apps') }}
</el-tag>
<el-tag
v-else-if="composeInfo?.createdBy === '1Panel'"
type="primary"
size="small"
>
1Panel
</el-tag>
</div>
<div class="text-sm text-gray-500">
{{ $t('commons.table.createdAt') }}: {{ composeInfo?.createdAt }}
</div>
</div>
</div>
<div class="flex items-center justify-between gap-2 flex-wrap">
<div class="flex items-center gap-2">
<el-button
type="success"
plain
size="small"
:loading="operateLoading && currentOperation === 'up'"
:disabled="disableOperate"
@click="handleComposeOperate('up')"
>
{{ $t('commons.operate.start') }}
</el-button>
<el-button
type="danger"
plain
size="small"
:loading="operateLoading && currentOperation === 'stop'"
:disabled="disableOperate"
@click="handleComposeOperate('stop')"
>
{{ $t('commons.operate.stop') }}
</el-button>
<el-button
type="warning"
plain
size="small"
:loading="operateLoading && currentOperation === 'restart'"
:disabled="disableOperate"
@click="handleComposeOperate('restart')"
>
{{ $t('commons.operate.restart') }}
</el-button>
<el-divider direction="vertical" />
<el-button
type="primary"
link
size="small"
:loading="detailLoading"
@click="refreshDetail"
>
{{ $t('commons.button.refresh') }}
</el-button>
<el-divider direction="vertical" />
<el-button
type="primary"
link
size="small"
:disabled="!composeInfo?.workdir"
@click="openComposeFolder"
>
{{ $t('container.composeDirectory') }}
</el-button>
<el-divider direction="vertical" />
<el-button type="danger" link size="small" @click="onDeleteCompose">
{{ $t('commons.button.delete') }}
</el-button>
</div>
</div>
</el-card>
<el-card v-if="composeInfo && composeContainers.length > 0" shadow="never">
<template #header>
<span class="text-sm font-medium">
{{ $t('container.containerStatus') }}
({{ composeInfo?.runningCount || 0 }}/{{ composeInfo?.containerCount || 0 }})
</span>
</template>
<el-table :data="tableData" size="small" max-height="200">
<el-table-column
:label="$t('commons.table.name')"
prop="name"
show-overflow-tooltip
>
<template #default="{ row }">
<el-text
type="primary"
class="cursor-pointer"
@click="onInspectContainer(row)"
>
{{ row.name }}
</el-text>
</template>
</el-table-column>
<el-table-column :label="$t('commons.table.status')" prop="state" width="100">
<template #default="{ row }">
<Status :key="row.state" :status="row.state"></Status>
</template>
</el-table-column>
<el-table-column
:label="$t('container.source')"
show-overflow-tooltip
prop="resource"
>
<template #default="{ row }">
<div v-if="row.hasLoad" class="flex items-center">
<div class="text-xs">
<div>CPU: {{ row.cpuPercent.toFixed(2) }}%</div>
<div>
{{ $t('monitor.memory') }}: {{ row.memoryPercent.toFixed(2) }}%
</div>
</div>
<el-popover placement="right" width="500px" trigger="hover">
<template #reference>
<el-icon
class="cursor-pointer text-gray-500 hover:text-primary ml-1"
:size="16"
>
<Histogram />
</el-icon>
</template>
<template #default>
<el-descriptions
direction="vertical"
border
:column="3"
size="small"
>
<el-descriptions-item :label="$t('container.cpuUsage')">
{{ computeCPU(row.cpuTotalUsage) }}
</el-descriptions-item>
<el-descriptions-item :label="$t('container.cpuTotal')">
{{ computeCPU(row.systemUsage) }}
</el-descriptions-item>
<el-descriptions-item :label="$t('container.core')">
{{ row.percpuUsage }}
</el-descriptions-item>
<el-descriptions-item :label="$t('container.memUsage')">
{{ computeSizeForDocker(row.memoryUsage) }}
</el-descriptions-item>
<el-descriptions-item :label="$t('container.memCache')">
{{ computeSizeForDocker(row.memoryCache) }}
</el-descriptions-item>
<el-descriptions-item :label="$t('container.memTotal')">
{{ computeSizeForDocker(row.memoryLimit) }}
</el-descriptions-item>
<el-descriptions-item>
<template #label>
{{ $t('container.sizeRw') }}
<el-tooltip :content="$t('container.sizeRwHelper')">
<el-icon class="icon-item">
<InfoFilled />
</el-icon>
</el-tooltip>
</template>
{{ computeSize2(row.sizeRw) }}
</el-descriptions-item>
<el-descriptions-item :label="$t('container.sizeRootFs')">
<template #label>
{{ $t('container.sizeRootFs') }}
<el-tooltip
:content="$t('container.sizeRootFsHelper')"
>
<el-icon class="icon-item">
<InfoFilled />
</el-icon>
</el-tooltip>
</template>
{{ computeSize2(row.sizeRootFs) }}
</el-descriptions-item>
</el-descriptions>
</template>
</el-popover>
</div>
<div v-if="!row.hasLoad">
<el-button link loading></el-button>
</div>
</template>
</el-table-column>
<el-table-column :label="$t('commons.table.operate')" width="180" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="onOpenTerminal(row)">
{{ $t('menu.terminal') }}
</el-button>
<el-divider direction="vertical" />
<el-button type="primary" link size="small" @click="onOpenLog(row)">
{{ $t('commons.button.log') }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<div class="flex-1 flex gap-4 min-h-0">
<div class="flex flex-col gap-4 min-h-0 min-w-0 flex-[1]">
<el-card shadow="never" class="flex flex-col">
<template #header>
<div class="flex items-center justify-between">
<span class="font-medium">{{ $t('container.compose') }}</span>
<el-button
type="primary"
size="small"
:disabled="disableEdit"
:loading="saving"
@click="onSubmitEdit"
>
{{ $t('commons.button.save') }}
</el-button>
</div>
</template>
<div class="flex-1 overflow-hidden">
<CodemirrorPro
v-model="composeContent"
mode="yaml"
:disabled="disableEdit"
:heightDiff="100"
placeholder="#Define or paste the content of your docker-compose file here"
/>
</div>
</el-card>
<el-card v-if="showEnvSetting" shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span class="font-medium">.env</span>
<el-button
type="primary"
size="small"
:disabled="disableEdit"
:loading="saving"
@click="onSubmitEdit"
>
{{ $t('commons.button.save') }}
</el-button>
</div>
</template>
<el-form-item :label="$t('container.env')">
<el-input
v-model="envStr"
type="textarea"
:rows="3"
:placeholder="$t('container.tagHelper')"
/>
</el-form-item>
</el-card>
</div>
<el-card shadow="never" class="flex flex-col min-h-0 min-w-0 flex-[2]">
<template #header>
<div class="flex items-center justify-between">
<span class="font-medium">{{ $t('commons.button.log') }}</span>
<el-button type="primary" size="small" @click="openComposeLogDrawer">
{{ $t('commons.button.view') }}
</el-button>
</div>
</template>
<div class="flex-1 overflow-auto">
<ContainerLog
v-if="composePath && shouldLoadLog"
:key="logKey"
:compose="composePath"
:resource="composeName"
:highlightDiff="200"
:showControl="false"
:defaultFollow="true"
/>
<el-empty v-else :description="$t('commons.msg.noneData')" />
</div>
</el-card>
</div>
</div>
</div>
</div>
</template> </template>
</LayoutContent> </LayoutContent>
<ComposeLogs ref="composeLogRef" />
<EditDialog ref="dialogEditRef" @search="search" />
<CreateDialog @search="search" ref="dialogRef" /> <CreateDialog @search="search" ref="dialogRef" />
<DeleteDialog @search="search" ref="dialogDelRef" /> <DeleteDialog @search="search" ref="dialogDelRef" />
<ContainerInspectDialog ref="containerInspectRef" />
<TerminalDialog ref="terminalDialogRef" />
<ContainerLogDialog ref="containerLogDialogRef" :highlightDiff="210" />
<ComposeLogs ref="composeLogRef" />
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { reactive, ref } from 'vue'; import { computed, ref } from 'vue';
import EditDialog from '@/views/container/compose/edit/index.vue'; import CodemirrorPro from '@/components/codemirror-pro/index.vue';
import ContainerLog from '@/components/log/container/index.vue';
import ContainerInspectDialog from '@/views/container/container/inspect/index.vue';
import TerminalDialog from '@/views/container/container/terminal/index.vue';
import ContainerLogDialog from '@/components/log/container-drawer/index.vue';
import CreateDialog from '@/views/container/compose/create/index.vue'; import CreateDialog from '@/views/container/compose/create/index.vue';
import DeleteDialog from '@/views/container/compose/delete/index.vue'; import DeleteDialog from '@/views/container/compose/delete/index.vue';
import ComposeLogs from '@/components/log/compose/index.vue'; import ComposeLogs from '@/components/log/compose/index.vue';
import { composeOperator, inspect, searchCompose } from '@/api/modules/container'; import { composeOperator, composeUpdate, containerListStats, inspect, searchCompose } from '@/api/modules/container';
import DockerStatus from '@/views/container/docker-status/index.vue'; import DockerStatus from '@/views/container/docker-status/index.vue';
import i18n from '@/lang'; import i18n from '@/lang';
import { Container } from '@/api/interface/container'; import { Container } from '@/api/interface/container';
import { routerToFileWithPath, routerToNameWithQuery } from '@/utils/router'; import { routerToFileWithPath } from '@/utils/router';
import { MsgSuccess } from '@/utils/message'; import { MsgError, MsgSuccess } from '@/utils/message';
import { computeCPU, computeSize2, computeSizeForDocker } from '@/utils/util';
import { Histogram, Search } from '@element-plus/icons-vue';
const data = ref(); const data = ref<any[]>([]);
const selects = ref<any>([]);
const loading = ref(false); const loading = ref(false);
const selectedCompose = ref<Container.ComposeInfo | null>(null);
const paginationConfig = reactive({ const detailLoading = ref(false);
cacheSizeKey: 'container-compose-page-size', const operateLoading = ref(false);
currentPage: 1, const currentOperation = ref('');
pageSize: Number(localStorage.getItem('container-compose-page-size')) || 20, const saving = ref(false);
total: 0, const composeName = ref('');
}); const composeContent = ref('');
const searchName = ref(); const envStr = ref('');
const composeInfo = ref<Container.ComposeInfo>();
const containerStats = ref<any[]>([]);
const logKey = ref(0);
const shouldLoadLog = ref(false);
const containerInspectRef = ref();
const terminalDialogRef = ref();
const containerLogDialogRef = ref();
const composeLogRef = ref(); const composeLogRef = ref();
const searchName = ref('');
const isActive = ref(false); const isActive = ref(false);
const isExist = ref(false); const isExist = ref(false);
const toComposeFolder = async (row: Container.ComposeInfo) => { const composePath = computed(() => composeInfo.value?.path || selectedCompose.value?.path || '');
routerToFileWithPath(row.workdir); const composeContainers = computed(() => composeInfo.value?.containers || []);
const disableEdit = computed(() => composeInfo.value?.createdBy === 'Local');
const showEnvSetting = computed(() => composeInfo.value?.createdBy === '1Panel');
const disableOperate = computed(
() => !composeInfo.value || !composePath.value || !isActive.value || !isExist.value || operateLoading.value,
);
const tableData = computed(() => {
return composeContainers.value.map((container) => {
const stats = containerStats.value.find((s) => s.containerID === container.containerID);
return {
...container,
hasLoad: !!stats,
cpuPercent: stats?.cpuPercent || 0,
memoryPercent: stats?.memoryPercent || 0,
cpuTotalUsage: stats?.cpuTotalUsage || 0,
systemUsage: stats?.systemUsage || 0,
percpuUsage: stats?.percpuUsage || 0,
memoryCache: stats?.memoryCache || 0,
memoryUsage: stats?.memoryUsage || 0,
memoryLimit: stats?.memoryLimit || 0,
sizeRw: stats?.sizeRw || 0,
sizeRootFs: stats?.sizeRootFs || 0,
};
});
});
const closeDetail = () => {
selectedCompose.value = null;
composeName.value = '';
composeInfo.value = undefined;
composeContent.value = '';
envStr.value = '';
shouldLoadLog.value = false;
};
const openComposeFolder = () => {
if (composeInfo.value?.workdir) {
routerToFileWithPath(composeInfo.value.workdir);
}
}; };
const search = async () => { const search = async () => {
@ -139,15 +455,14 @@ const search = async () => {
} }
let params = { let params = {
info: searchName.value, info: searchName.value,
page: paginationConfig.currentPage, page: 1,
pageSize: paginationConfig.pageSize, pageSize: 100,
}; };
loading.value = true; loading.value = true;
await searchCompose(params) await searchCompose(params)
.then((res) => { .then((res) => {
loading.value = false; loading.value = false;
data.value = res.data.items || []; data.value = res.data.items || [];
paginationConfig.total = res.data.total;
}) })
.finally(() => { .finally(() => {
loading.value = false; loading.value = false;
@ -155,7 +470,77 @@ const search = async () => {
}; };
const loadDetail = async (row: Container.ComposeInfo) => { const loadDetail = async (row: Container.ComposeInfo) => {
routerToNameWithQuery('ComposeDetail', { name: row.name, path: row.path }); if (selectedCompose.value?.name === row.name) {
closeDetail();
return;
}
selectedCompose.value = row;
composeName.value = row.name;
detailLoading.value = true;
shouldLoadLog.value = false;
try {
await loadComposeContent();
await new Promise((resolve) => setTimeout(resolve, 100));
await loadComposeInfo();
detailLoading.value = false;
await new Promise((resolve) => setTimeout(resolve, 100));
shouldLoadLog.value = true;
logKey.value++;
await new Promise((resolve) => setTimeout(resolve, 100));
await loadContainerStats();
} catch (error) {
detailLoading.value = false;
throw error;
}
};
const loadComposeInfo = async () => {
const params = {
info: composeName.value,
page: 1,
pageSize: 1,
};
const res = await searchCompose(params);
const items = res.data?.items || [];
const target = items.find((item) => item.name === composeName.value) || items[0];
if (!target) {
composeInfo.value = undefined;
envStr.value = '';
MsgError(i18n.global.t('commons.msg.noneData'));
return;
}
composeInfo.value = target;
envStr.value = (target.env || []).join('\n');
};
const loadComposeContent = async () => {
const res = await inspect({ id: composeName.value, type: 'compose' });
composeContent.value = res.data;
};
const loadContainerStats = async () => {
try {
const res = await containerListStats();
containerStats.value = res.data || [];
} catch (error) {
containerStats.value = [];
}
};
const refreshDetail = async () => {
if (!composeName.value || !isActive.value || !isExist.value) {
return;
}
detailLoading.value = true;
try {
await loadComposeInfo();
detailLoading.value = false;
await new Promise((resolve) => setTimeout(resolve, 300));
await loadContainerStats();
} catch (error) {
detailLoading.value = false;
throw error;
}
}; };
const dialogRef = ref(); const dialogRef = ref();
@ -163,111 +548,96 @@ const onOpenDialog = async () => {
dialogRef.value!.acceptParams(); dialogRef.value!.acceptParams();
}; };
const onDeleteCompose = () => {
if (!selectedCompose.value) return;
dialogDelRef.value.acceptParams({
name: selectedCompose.value.name,
path: selectedCompose.value.path,
});
};
const dialogDelRef = ref(); const dialogDelRef = ref();
const onDelete = async (row: Container.ComposeInfo) => {
const param = {
name: row.name,
path: row.path,
};
dialogDelRef.value.acceptParams(param);
};
const dialogEditRef = ref(); const handleComposeOperate = async (operation: 'up' | 'stop' | 'restart') => {
const onEdit = async (row: Container.ComposeInfo) => { if (!composeInfo.value || !composePath.value) {
const res = await inspect({ id: row.name, type: 'compose' }); return;
let params = { }
name: row.name, const mes = i18n.global.t('container.composeOperatorHelper', [
path: row.path, composeInfo.value.name,
content: res.data, i18n.global.t('commons.operate.' + operation),
env: row.env, ]);
createdBy: row.createdBy,
};
dialogEditRef.value!.acceptParams(params);
};
const onComposeOperate = async (operation: string, row: any) => {
let mes =
operation === 'down'
? i18n.global.t('container.composeDownHelper', [row.name])
: i18n.global.t('container.composeOperatorHelper', [
row.name,
i18n.global.t('commons.operate.' + operation),
]);
ElMessageBox.confirm(mes, i18n.global.t('commons.operate.' + operation), { ElMessageBox.confirm(mes, i18n.global.t('commons.operate.' + operation), {
confirmButtonText: i18n.global.t('commons.button.confirm'), confirmButtonText: i18n.global.t('commons.button.confirm'),
cancelButtonText: i18n.global.t('commons.button.cancel'), cancelButtonText: i18n.global.t('commons.button.cancel'),
type: 'info', type: 'info',
}).then(async () => { }).then(async () => {
let params = { currentOperation.value = operation;
name: row.name, operateLoading.value = true;
path: row.path, const params = {
name: composeInfo.value!.name,
path: composePath.value,
operation: operation, operation: operation,
withFile: false, withFile: false,
force: false, force: false,
}; };
loading.value = true;
await composeOperator(params) await composeOperator(params)
.then(() => { .then(() => {
loading.value = false;
MsgSuccess(i18n.global.t('commons.msg.operationSuccess')); MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
refreshDetail();
search(); search();
}) })
.catch(() => { .finally(() => {
loading.value = false; operateLoading.value = false;
currentOperation.value = '';
}); });
}); });
}; };
const openLog = (row: any) => { const onSubmitEdit = async () => {
composeLogRef.value.acceptParams({ if (!composeInfo.value || !composePath.value || disableEdit.value) {
compose: row.path, return;
resource: row.name, }
container: row.container, const param = {
}); name: composeName.value,
path: composePath.value,
content: composeContent.value,
createdBy: composeInfo.value.createdBy,
env: envStr.value ? envStr.value.split('\n') : [],
};
saving.value = true;
await composeUpdate(param)
.then(async () => {
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
await loadComposeContent();
refreshDetail();
})
.finally(() => {
saving.value = false;
});
}; };
const buttons = [ const onInspectContainer = async (item: any) => {
{ if (!item.containerID) {
label: i18n.global.t('commons.button.edit'), return;
click: (row: Container.ComposeInfo) => { }
onEdit(row); const res = await inspect({ id: item.containerID, type: 'container' });
}, containerInspectRef.value!.acceptParams({ data: res.data, ports: item.ports || [] });
disabled: (row: any) => { };
return row.createdBy === 'Local';
}, const onOpenTerminal = (row: any) => {
}, terminalDialogRef.value?.acceptParams({ container: row.name });
{ };
label: i18n.global.t('commons.button.log'),
click: (row: Container.ComposeInfo) => { const onOpenLog = (row: any) => {
openLog(row); containerLogDialogRef.value?.acceptParams({ container: row.name });
}, };
},
{ const openComposeLogDrawer = () => {
label: i18n.global.t('commons.operate.start'), if (!composePath.value || !composeName.value) return;
click: (row: Container.ComposeInfo) => { composeLogRef.value?.acceptParams({
onComposeOperate('up', row); compose: composePath.value,
}, resource: composeName.value,
}, container: '',
{ });
label: i18n.global.t('commons.operate.stop'), };
click: (row: Container.ComposeInfo) => {
onComposeOperate('stop', row);
},
},
{
label: i18n.global.t('commons.operate.restart'),
click: (row: Container.ComposeInfo) => {
onComposeOperate('restart', row);
},
},
{
label: i18n.global.t('commons.operate.delete'),
click: (row: Container.ComposeInfo) => {
onDelete(row);
},
disabled: (row: any) => {
return row.createdBy !== '1Panel';
},
},
];
</script> </script>