add DockerContainerDetail.vue

This commit is contained in:
Timi
2026-04-13 14:40:08 +08:00
parent 3393cca441
commit df6c6b78c9
9 changed files with 981 additions and 795 deletions

View File

@@ -1,35 +1,24 @@
import { axios } from "timi-web";
import { axios, Text } from "timi-web";
import { useSettingStore } from "@/store/settingStore";
import type {
DockerContainerHistoryView,
DockerContainerStatusView,
DockerContainerSummaryView
} from "@/types/Docker";
import type { DockerContainerHistoryView, DockerContainerStatusView, DockerContainerSummaryView } from "@/types/Docker";
async function getContainers(): Promise<DockerContainerSummaryView[]> {
const settingStore = useSettingStore();
return await axios.get(`${settingStore.resolveBaseURL()}/system/docker/container`, {
timeout: 15000
});
return await axios.get(`${settingStore.resolveBaseURL()}/system/docker/container`);
}
async function getContainerStatus(containerId: string): Promise<DockerContainerStatusView | null> {
const settingStore = useSettingStore();
return await axios.get(`${settingStore.resolveBaseURL()}/system/docker/container/${encodeURIComponent(containerId)}/status`, {
timeout: 15000
});
return await axios.get(`${settingStore.resolveBaseURL()}/system/docker/container/${encodeURIComponent(containerId)}/status`);
}
async function getContainerHistory(containerId: string, params?: {
window?: string;
}): Promise<DockerContainerHistoryView | null> {
const settingStore = useSettingStore();
return await axios.get(`${settingStore.resolveBaseURL()}/system/docker/container/${encodeURIComponent(containerId)}/history`, {
params: {
...(params?.window ? { window: params.window } : {})
},
timeout: 15000
});
return await axios.get(`${settingStore.resolveBaseURL()}/system/docker/container/${encodeURIComponent(containerId)}/history?${Text.urlArgs({
window: params?.window
})}`);
}
export default {

View File

@@ -29,7 +29,7 @@
}"
/>
</template>
<div v-if="Toolkit.isNotEmpty(note)" class="note" v-text="note" />
<div v-if="Toolkit.isNotEmpty(note)" class="health" v-text="note" />
</div>
<div v-if="Toolkit.isNotEmpty(legendList)" class="legends">
<div
@@ -158,7 +158,7 @@ const legendList = computed<RenderProgressItem[]>(() => {
transition: width 320ms var(--tui-bezier);
}
.note {
.health {
top: 50%;
right: .25rem;
z-index: 2;

View File

@@ -0,0 +1,762 @@
<template>
<div class="docker-container-detail">
<t-empty v-if="!containerId" description="缺少容器 ID 参数" />
<template v-else>
<div v-if="isStatusLoading && !statusView" class="loading-wrap">
<t-loading text="加载容器状态..." />
</div>
<template v-else-if="statusView">
<t-cell-group title="容器概览" theme="card">
<t-cell title="名称" :note="Text.display(statusView?.name)" />
<t-cell title="镜像" :note="Text.display(statusView?.image)" />
<t-cell title="状态" :note="resolveStateText(statusView?.state)" />
<t-cell title="状态详情" :note="Text.display(statusView?.status)" />
<t-cell title="健康检查" :note="resolveHealthText(statusView?.healthStatus)" />
<t-cell title="容器 ID" :note="Text.display(statusView?.id?.slice(0, 12))" />
<t-cell title="镜像 ID" :note="Text.display(statusView?.imageId?.slice(0, 12))" />
<t-cell title="创建时间" :note="Time.toPassedDateTime(statusView?.createdAt)" />
<t-cell title="启动时间" :note="resolveDockerRawTime(statusView?.startedAt)" />
<t-cell title="结束时间" :note="resolveDockerRawTime(statusView?.finishedAt)" />
<t-cell title="采样时间" :note="Time.toPassedDateTime(statusView?.updatedAt)" />
</t-cell-group>
<t-cell-group class="section resource" title="资源使用" theme="card">
<t-cell-info label="CPU 使用率" :value="Text.unit(statusView?.cpuPercent, '%')">
<progress-group mode="heap" :progress="cpuProgressList" />
</t-cell-info>
<t-cell-info label="内存使用率" :value="memoryUsageText">
<progress-group mode="heap" :progress="memoryProgressList" :note="Text.unit(statusView?.memoryPercent, '%')" />
</t-cell-info>
<t-cell title="进程数" :note="Text.display(statusView?.pids)" />
<t-cell title="重启次数" :note="Text.display(statusView?.restartCount)" />
<t-cell title="退出码" :note="Text.display(statusView?.exitCode)" />
<t-cell title="OOM Kill" :note="Text.displayBool(statusView?.oomKilled, '是', '否')" />
</t-cell-group>
<t-cell-group class="section traffic" title="网络与块设备" theme="card">
<t-cell title="网络接收" :note="IOSize.format(statusView?.networkRxBytes)" />
<t-cell title="网络发送" :note="IOSize.format(statusView?.networkTxBytes)" />
<t-cell title="块设备读取" :note="IOSize.format(statusView?.blockReadBytes)" />
<t-cell title="块设备写入" :note="IOSize.format(statusView?.blockWriteBytes)" />
</t-cell-group>
</template>
<t-empty v-else description="暂无容器状态数据" />
<t-cell-group class="section history" title="历史采样" theme="card">
<t-tabs class="metric-tabs" :value="historyMetric" @change="handleHistoryMetricChange">
<t-tab-panel v-for="item in historyMetricTabs" :key="item.value" :value="item.value" :label="item.label" />
</t-tabs>
<div v-if="isHistoryLoading && !historyItems.length" class="loading-wrap">
<t-loading text="加载容器历史..." />
</div>
<template v-else-if="historyItems.length">
<v-chart
ref="chartRef"
class="chart"
:option="historyChartOption"
:update-options="historyChartUpdateOptions"
:autoresize="true"
/>
<div class="slider bg-white">
<span class="label">较早</span>
<t-slider
class="control"
:min="0"
:max="Math.max(historyItems.length - historyVisibleCount, 0)"
:step="1"
:value="historySliderValue"
@change="handleHistorySliderChange"
/>
<span class="label">最新</span>
</div>
<t-cell-group>
<t-cell v-for="item in historySummary" :key="item.label" :title="item.label" :note="item.value" />
</t-cell-group>
</template>
<t-empty v-else description="暂无容器历史数据" />
</t-cell-group>
</template>
</div>
</template>
<script setup lang="ts">
import { Toast } from "tdesign-mobile-vue";
import { use } from "echarts/core";
import { SVGRenderer } from "echarts/renderers";
import { LineChart } from "echarts/charts";
import { GridComponent, LegendComponent, TooltipComponent } from "echarts/components";
import VChart from "vue-echarts";
import type { EChartsOption, SeriesOption } from "echarts";
import DockerAPI from "@/api/DockerAPI";
import type {
DockerContainerHistoryPointView,
DockerContainerStatusView
} from "@/types/Docker";
import { useSettingStore } from "@/store/settingStore";
import { IOSize, type LabelValue, Text, Time } from "timi-web";
import TCellInfo from "@/components/TCellInfo.vue";
import type { ProgressItem } from "@/components/ProgressGroup.vue";
import ProgressGroup from "@/components/ProgressGroup.vue";
type DockerHistoryMetric = "cpu" | "memory" | "network" | "block" | "pids";
use([
SVGRenderer,
LineChart,
GridComponent,
TooltipComponent,
LegendComponent
]);
defineOptions({
name: "DockerContainerDetail"
});
const route = useRoute();
const settingStore = useSettingStore();
const containerId = computed(() => {
const queryContainerId = route.query.containerId;
if (typeof queryContainerId === "string") {
return queryContainerId;
}
if (Array.isArray(queryContainerId) && queryContainerId.length) {
return queryContainerId[0];
}
return "";
});
const isStatusLoading = ref(false);
const isHistoryLoading = ref(false);
const statusView = ref<DockerContainerStatusView | null>(null);
const historyPoints = ref<DockerContainerHistoryPointView[]>([]);
const chartRef = ref<InstanceType<typeof VChart> | null>(null);
const historyChartUpdateOptions = Object.freeze({
notMerge: true
});
// ---------- 当前状态 ----------
const cpuProgressList = computed<ProgressItem[]>(() => {
return [
{
color: "var(--td-brand-color)",
value: normalizePercent(statusView.value?.cpuPercent)
}
];
});
const memoryUsageText = computed(() => {
const usage = IOSize.format(statusView.value?.memoryUsageBytes || 0);
const limitValue = statusView.value?.memoryLimitBytes || 0;
const limit = limitValue < 1 ? "无限制" : IOSize.format(limitValue);
return `${usage} / ${limit}`;
});
const memoryProgressList = computed<ProgressItem[]>(() => {
const limit = statusView.value?.memoryLimitBytes || 0;
const usage = statusView.value?.memoryUsageBytes || 0;
const fallback = limit < 1 ? 0 : usage / limit;
const percent = statusView.value?.memoryPercent;
return [
{
color: "var(--td-warning-color)",
value: normalizePercent(percent, fallback)
}
];
});
function normalizePercent(percent?: number | null, fallback = 0): number {
const source = typeof percent === "number" ? percent / 100 : fallback;
if (Number.isNaN(source) || !Number.isFinite(source)) {
return 0;
}
return Math.min(Math.max(source, 0), 1);
}
function resolveStateText(state?: string | null): string {
if (!state) {
return "-";
}
const map: Record<string, string> = {
created: "已创建",
running: "运行中",
restarting: "重启中",
exited: "已退出",
paused: "已暂停",
dead: "已停止"
};
return map[state] || state;
}
function resolveHealthText(health?: string | null): string {
if (!health) {
return "未配置";
}
const map: Record<string, string> = {
healthy: "健康",
unhealthy: "异常",
starting: "启动中"
};
return map[health] || health;
}
function resolveDockerRawTime(value?: string | null): string {
if (!value || value.startsWith("0001-01-01")) {
return "-";
}
const dateValue = Date.parse(value);
if (Number.isNaN(dateValue)) {
return value;
}
return Time.toPassedDateTime(dateValue);
}
// ---------- 历史采样 ----------
const historyMetric = ref<DockerHistoryMetric>("cpu");
const historyMetricTabs = computed(() => {
const labelMap: Record<DockerHistoryMetric, string> = {
cpu: "CPU",
memory: "内存",
network: "网络",
block: "块设备",
pids: "进程"
};
return (Object.keys(labelMap) as DockerHistoryMetric[]).map((item) => ({
value: item,
label: labelMap[item]
}));
});
function handleHistoryMetricChange(value: string): void {
const nextValue = value as DockerHistoryMetric;
if (nextValue === "cpu" || nextValue === "memory" || nextValue === "network" || nextValue === "block" || nextValue === "pids") {
historyMetric.value = nextValue;
}
}
const historyItems = computed<DockerContainerHistoryPointView[]>(() => {
return historyPoints.value.slice().sort((left, right) => left.at - right.at);
});
const historySummary = computed<LabelValue<string, any>[]>(() => {
const lastPoint = historyItems.value[historyItems.value.length - 1];
if (!lastPoint) {
return [];
}
if (historyMetric.value === "cpu") {
return [
{ label: "CPU 使用率", value: Text.unit(lastPoint.cpuPercent, "%") }
];
}
if (historyMetric.value === "memory") {
return [
{ label: "内存占用", value: IOSize.format(lastPoint.memoryUsageBytes || 0) },
{ label: "内存使用率", value: Text.unit(lastPoint.memoryPercent, "%") }
];
}
if (historyMetric.value === "network") {
return [
{ label: "累计接收", value: IOSize.format(lastPoint.networkRxBytes || 0) },
{ label: "累计发送", value: IOSize.format(lastPoint.networkTxBytes || 0) }
];
}
if (historyMetric.value === "block") {
return [
{ label: "累计读取", value: IOSize.format(lastPoint.blockReadBytes || 0) },
{ label: "累计写入", value: IOSize.format(lastPoint.blockWriteBytes || 0) }
];
}
return [
{ label: "进程数", value: Text.display(lastPoint.pids) }
];
});
const isHistorySliderTouched = ref(false);
const historySliderValue = ref(0);
const HISTORY_DEFAULT_VISIBLE_COUNT = 24;
const historyVisibleCount = computed(() => {
if (historyItems.value.length < 1) {
return 1;
}
return Math.min(HISTORY_DEFAULT_VISIBLE_COUNT, historyItems.value.length);
});
const historySliderMaxStart = computed(() => {
return Math.max(historyItems.value.length - historyVisibleCount.value, 0);
});
const currentHistoryRange = computed(() => {
const length = historyItems.value.length;
if (length < 1) {
return { startValue: 0, endValue: 0 };
}
const startValue = Math.min(Math.max(Math.floor(historySliderValue.value), 0), historySliderMaxStart.value);
const endValue = Math.min(startValue + historyVisibleCount.value - 1, length - 1);
return { startValue, endValue };
});
const visibleHistoryItems = computed<DockerContainerHistoryPointView[]>(() => {
if (!historyItems.value.length) {
return [];
}
return historyItems.value.slice(currentHistoryRange.value.startValue, currentHistoryRange.value.endValue + 1);
});
watch(() => historyItems.value.length, (length) => {
if (length < 1) {
historySliderValue.value = 0;
isHistorySliderTouched.value = false;
return;
}
if (!isHistorySliderTouched.value) {
historySliderValue.value = historySliderMaxStart.value;
return;
}
historySliderValue.value = Math.min(historySliderValue.value, historySliderMaxStart.value);
});
function handleHistorySliderChange(value: number): void {
historySliderValue.value = Math.min(Math.max(Math.floor(value), 0), historySliderMaxStart.value);
isHistorySliderTouched.value = true;
}
function resetHistorySlider(): void {
historySliderValue.value = 0;
isHistorySliderTouched.value = false;
}
// ---------- 图表 ----------
const isDarkTheme = ref(false);
let themeObserver: MutationObserver | null = null;
const historyChartOption = computed((): EChartsOption => {
const axisColor = isDarkTheme.value ? "#9aa4b2" : "#7e8a9a";
const lineColor = isDarkTheme.value ? "rgba(255, 255, 255, .12)" : "rgba(18, 42, 66, .08)";
const legendColor = isDarkTheme.value ? "#d7dee8" : "#334155";
const commonOption: EChartsOption = {
tooltip: {
trigger: "axis",
confine: true,
position: (point, _params, _dom, _rect, size) => {
const gap = 6;
const fingerGap = 24;
const contentSize = size?.contentSize || [0, 0];
const viewSize = size?.viewSize || [0, 0];
const maxLeft = Math.max(viewSize[0] - contentSize[0] - gap, gap);
const maxTop = Math.max(viewSize[1] - contentSize[1] - gap, gap);
const clamp = (value: number, min: number, max: number): number => {
return Math.min(Math.max(value, min), max);
};
const preferLeft = point[0] - contentSize[0] - gap;
const preferRight = point[0] + gap;
const left = point[0] < viewSize[0] / 2
? clamp(preferRight, gap, maxLeft)
: clamp(preferLeft, gap, maxLeft);
const preferTop = point[1] - contentSize[1] - fingerGap;
const preferBottom = point[1] + fingerGap;
const top = preferTop >= gap
? clamp(preferTop, gap, maxTop)
: clamp(preferBottom, gap, maxTop);
return [left, top];
}
},
legend: {
top: 10,
textStyle: {
color: legendColor
}
},
grid: {
top: 40,
left: 8,
right: 8,
bottom: 8,
containLabel: true
},
xAxis: {
type: "category",
boundaryGap: false,
data: visibleHistoryItems.value.map((item) => item.at),
axisLabel: {
color: axisColor,
formatter: val => Time.toShortTime(Number(val))
},
axisLine: {
lineStyle: {
color: lineColor
}
}
}
};
if (!historyItems.value.length) {
return commonOption;
}
if (historyMetric.value === "cpu") {
return {
...commonOption,
tooltip: {
...commonOption.tooltip,
formatter: params => formatPercentTooltip(params, " %")
},
yAxis: {
type: "value",
min: 0,
max: 100,
axisLabel: {
color: axisColor,
formatter: "{value} %"
},
splitLine: {
lineStyle: {
color: lineColor
}
}
},
series: [
resolveLineSeries("CPU", visibleHistoryItems.value.map((item) => item.cpuPercent || 0))
]
};
}
if (historyMetric.value === "memory") {
return {
...commonOption,
tooltip: {
...commonOption.tooltip,
formatter: params => {
const list = Array.isArray(params) ? params : [params];
const title = `时间:${Time.toDateTime(Number(list[0]?.name))}`;
const lines = list.map((item) => {
const currentName = item.seriesName || "";
if (currentName === "内存占用") {
return `${item.marker}${currentName}: ${IOSize.format(Number(item.value))}`;
}
return `${item.marker}${currentName}: ${Text.unit(Number(item.value), " %")}`;
});
return [title, ...lines].join("<br />");
}
},
yAxis: [
{
type: "value",
axisLabel: {
color: axisColor,
formatter: (value: number) => IOSize.format(value)
},
splitLine: {
lineStyle: {
color: lineColor
}
}
},
{
type: "value",
min: 0,
max: 100,
axisLabel: {
color: axisColor,
formatter: "{value} %"
},
splitLine: {
show: false
}
}
],
series: [
resolveLineSeries("内存占用", visibleHistoryItems.value.map((item) => item.memoryUsageBytes || 0)),
resolveLineSeries("内存使用率", visibleHistoryItems.value.map((item) => item.memoryPercent || 0), 1)
]
};
}
if (historyMetric.value === "network") {
return {
...commonOption,
tooltip: {
...commonOption.tooltip,
formatter: params => formatSizeTooltip(params)
},
yAxis: {
type: "value",
axisLabel: {
color: axisColor,
formatter: (value: number) => IOSize.format(value)
},
splitLine: {
lineStyle: {
color: lineColor
}
}
},
series: [
resolveLineSeries("接收", visibleHistoryItems.value.map((item) => item.networkRxBytes || 0)),
resolveLineSeries("发送", visibleHistoryItems.value.map((item) => item.networkTxBytes || 0))
]
};
}
if (historyMetric.value === "block") {
return {
...commonOption,
tooltip: {
...commonOption.tooltip,
formatter: params => formatSizeTooltip(params)
},
yAxis: {
type: "value",
axisLabel: {
color: axisColor,
formatter: (value: number) => IOSize.format(value)
},
splitLine: {
lineStyle: {
color: lineColor
}
}
},
series: [
resolveLineSeries("读取", visibleHistoryItems.value.map((item) => item.blockReadBytes || 0)),
resolveLineSeries("写入", visibleHistoryItems.value.map((item) => item.blockWriteBytes || 0))
]
};
}
return {
...commonOption,
tooltip: {
...commonOption.tooltip,
formatter: params => {
const list = Array.isArray(params) ? params : [params];
const title = `时间:${Time.toDateTime(Number(list[0]?.name))}`;
const lines = list.map((item) => `${item.marker}${item.seriesName}: ${Text.display(Number(item.value))}`);
return [title, ...lines].join("<br />");
}
},
yAxis: {
type: "value",
minInterval: 1,
axisLabel: {
color: axisColor
},
splitLine: {
lineStyle: {
color: lineColor
}
}
},
series: [
resolveLineSeries("进程数", visibleHistoryItems.value.map((item) => item.pids || 0))
]
};
});
function formatPercentTooltip(params: unknown, unit: string): string {
const list = Array.isArray(params) ? params : [params];
const title = `时间:${Time.toDateTime(Number((list[0] as { name?: string }).name || 0))}`;
const lines = list.map((item) => {
const current = item as { marker?: string; seriesName?: string; value?: number };
return `${current.marker || ""}${current.seriesName || ""}: ${Text.unit(Number(current.value || 0), unit)}`;
});
return [title, ...lines].join("<br />");
}
function formatSizeTooltip(params: unknown): string {
const list = Array.isArray(params) ? params : [params];
const title = `时间:${Time.toDateTime(Number((list[0] as { name?: string }).name || 0))}`;
const lines = list.map((item) => {
const current = item as { marker?: string; seriesName?: string; value?: number };
return `${current.marker || ""}${current.seriesName || ""}: ${IOSize.format(Number(current.value || 0))}`;
});
return [title, ...lines].join("<br />");
}
function resolveLineSeries(name: string, data: Array<number | null>, yAxisIndex?: number): SeriesOption {
const series: SeriesOption = {
type: "line",
name,
showSymbol: false,
smooth: true,
lineStyle: {
width: 2
},
areaStyle: {
opacity: .18
},
data
};
if (typeof yAxisIndex === "number") {
series.yAxisIndex = yAxisIndex;
}
return series;
}
// ---------- 加载与自动刷新 ----------
let statusTimer: number | null = null;
let historyTimer: number | null = null;
watch(() => settingStore.dashboard.server, restartAutoRefresh, { deep: true });
watch(containerId, () => {
statusView.value = null;
historyPoints.value = [];
resetHistorySlider();
restartAutoRefresh();
}, { immediate: true });
onMounted(() => {
updateThemeState();
themeObserver = new MutationObserver(() => {
updateThemeState();
});
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"]
});
});
onUnmounted(() => {
clearAutoRefreshTimer();
if (themeObserver) {
themeObserver.disconnect();
themeObserver = null;
}
});
function updateThemeState(): void {
isDarkTheme.value = document.documentElement.classList.contains("theme-dark");
}
async function refreshContainerStatus(): Promise<void> {
if (!containerId.value) {
statusView.value = null;
return;
}
if (isStatusLoading.value) {
return;
}
isStatusLoading.value = true;
try {
statusView.value = await DockerAPI.getContainerStatus(containerId.value);
} catch (error) {
Toast({
theme: "error",
message: error instanceof Error ? error.message : "请求失败,请稍后重试"
});
} finally {
isStatusLoading.value = false;
}
}
async function refreshContainerHistory(): Promise<void> {
if (!containerId.value) {
historyPoints.value = [];
return;
}
if (isHistoryLoading.value) {
return;
}
isHistoryLoading.value = true;
try {
const historyView = await DockerAPI.getContainerHistory(containerId.value, {
window: "2h"
});
historyPoints.value = historyView?.points || [];
} catch (error) {
Toast({
theme: "error",
message: error instanceof Error ? error.message : "请求失败,请稍后重试"
});
} finally {
isHistoryLoading.value = false;
}
}
async function refreshDashboard(): Promise<void> {
await refreshContainerStatus();
await refreshContainerHistory();
}
function restartAutoRefresh(): void {
clearAutoRefreshTimer();
if (!containerId.value) {
return;
}
void refreshDashboard();
const snapshotIntervalMs = Math.max(settingStore.dashboard.server.snapshotRefreshSeconds, 1) * 1000;
const historyIntervalMs = Math.max(settingStore.dashboard.server.historyRefreshSeconds, 1) * 1000;
statusTimer = window.setInterval(() => {
void refreshContainerStatus();
}, snapshotIntervalMs);
historyTimer = window.setInterval(() => {
void refreshContainerHistory();
}, historyIntervalMs);
}
function clearAutoRefreshTimer(): void {
if (statusTimer !== null) {
window.clearInterval(statusTimer);
statusTimer = null;
}
if (historyTimer !== null) {
window.clearInterval(historyTimer);
historyTimer = null;
}
}
</script>
<style scoped lang="less">
.docker-container-detail {
gap: 1rem;
display: flex;
padding: var(--app-nav-offset) 0 1rem 0;
flex-direction: column;
.section {
&.history {
.metric-tabs {
width: 100%;
--td-tab-track-width: 4rem;
}
.chart {
width: 100%;
height: 16rem;
background: #FFF;
}
.slider {
gap: .5rem;
display: flex;
padding: .5rem 2rem;
align-items: center;
.label {
flex: none;
font-size: .75rem;
color: var(--app-sub);
}
.control {
flex: 1;
}
}
}
}
.loading-wrap {
display: flex;
min-height: 4rem;
align-items: center;
justify-content: center;
}
}
</style>

View File

@@ -4,187 +4,66 @@
<t-loading text="加载 Docker 容器..." />
</div>
<template v-else-if="containerItems.length">
<t-cell-group title="状态统计" theme="card">
<div class="section overview">
<div v-for="item in containerOverviewStats" :key="item.key" class="stat bg-white">
<span class="label" v-text="item.label" />
<span :class="['value', item.className]" v-text="Text.display(item.value)" />
</div>
</div>
</t-cell-group>
<t-cell-group title="容器列表" theme="card">
<div class="section list">
<button
v-for="item in containerItems"
:key="item.id"
type="button"
:class="['chip', { active: item.id === selectedContainerId }]"
class="chip bg-white"
@click="handleSelectContainer(item.id)"
>
<div class="top">
<span class="header">
<span class="name" v-text="Text.display(item.name)" />
<span :class="['state', resolveStateClass(item.state)]" v-text="resolveStateText(item.state)" />
</div>
<div class="bottom">
<span class="img" v-text="Text.display(item.image)" />
<span class="note" v-text="resolveHealthText(item.healthStatus)" />
</div>
</span>
<span class="content">
<span class="img clip-text" v-text="Text.display(item.image)" />
<span class="health keep-text" v-text="resolveHealthText(item.healthStatus)" />
</span>
<span class="bottom">
<span class="id" v-text="Text.display(item.id.slice(0, 12))" />
</span>
</button>
</div>
</t-cell-group>
<div v-if="isStatusLoading && !statusView" class="loading-wrap">
<t-loading text="加载容器状态..." />
</div>
<template v-else-if="statusView">
<t-cell-group title="容器概览" theme="card">
<t-cell title="名称" :note="Text.display(statusView?.name)" />
<t-cell title="镜像" :note="Text.display(statusView?.image)" />
<t-cell title="状态" :note="resolveStateText(statusView?.state)" />
<t-cell title="状态详情" :note="Text.display(statusView?.status)" />
<t-cell title="健康检查" :note="resolveHealthText(statusView?.healthStatus)" />
<t-cell title="容器 ID" :note="Text.display(statusView?.id?.slice(0, 12))" />
<t-cell title="镜像 ID" :note="Text.display(statusView?.imageId?.slice(0, 12))" />
<t-cell title="创建时间" :note="Time.toPassedDateTime(statusView?.createdAt)" />
<t-cell title="启动时间" :note="resolveDockerRawTime(statusView?.startedAt)" />
<t-cell title="结束时间" :note="resolveDockerRawTime(statusView?.finishedAt)" />
<t-cell title="采样时间" :note="Time.toPassedDateTime(statusView?.updatedAt)" />
</t-cell-group>
<t-cell-group class="section resource" title="资源使用" theme="card">
<t-cell-info label="CPU 使用率" :value="Text.unit(statusView?.cpuPercent, '%')">
<progress-group mode="heap" :progress="cpuProgressList" />
</t-cell-info>
<t-cell-info label="内存使用率" :value="memoryUsageText">
<progress-group mode="heap" :progress="memoryProgressList" :note="Text.unit(statusView?.memoryPercent, '%')" />
</t-cell-info>
<t-cell title="进程数" :note="Text.display(statusView?.pids)" />
<t-cell title="重启次数" :note="Text.display(statusView?.restartCount)" />
<t-cell title="退出码" :note="Text.display(statusView?.exitCode)" />
<t-cell title="OOM Kill" :note="Text.displayBool(statusView?.oomKilled, '是', '否')" />
</t-cell-group>
<t-cell-group class="section traffic" title="网络与块设备" theme="card">
<t-cell title="网络接收" :note="IOSize.format(statusView?.networkRxBytes)" />
<t-cell title="网络发送" :note="IOSize.format(statusView?.networkTxBytes)" />
<t-cell title="块设备读取" :note="IOSize.format(statusView?.blockReadBytes)" />
<t-cell title="块设备写入" :note="IOSize.format(statusView?.blockWriteBytes)" />
</t-cell-group>
</template>
<t-empty v-else description="暂无容器状态数据" />
</template>
<t-empty v-else description="暂无 Docker 容器数据" />
<t-cell-group class="section history" title="历史采样" theme="card">
<t-tabs class="metric-tabs" :value="historyMetric" @change="handleHistoryMetricChange">
<t-tab-panel v-for="item in historyMetricTabs" :key="item.value" :value="item.value" :label="item.label" />
</t-tabs>
<div v-if="isHistoryLoading && !historyItems.length" class="loading-wrap">
<t-loading text="加载容器历史..." />
</div>
<template v-else-if="historyItems.length">
<v-chart
ref="chartRef"
class="chart"
:option="historyChartOption"
:update-options="historyChartUpdateOptions"
:autoresize="true"
/>
<div class="slider bg-white">
<span class="label">较早</span>
<t-slider
class="control"
:min="0"
:max="Math.max(historyItems.length - historyVisibleCount, 0)"
:step="1"
:value="historySliderValue"
@change="handleHistorySliderChange"
/>
<span class="label">最新</span>
</div>
<t-cell-group>
<t-cell v-for="item in historySummary" :key="item.label" :title="item.label" :note="item.value" />
</t-cell-group>
</template>
<t-empty v-else :description="selectedContainerId ? '暂无容器历史数据' : '请先选择容器'" />
</t-cell-group>
</div>
</template>
<script setup lang="ts">
import { Toast } from "tdesign-mobile-vue";
import { use } from "echarts/core";
import { SVGRenderer } from "echarts/renderers";
import { LineChart } from "echarts/charts";
import { GridComponent, LegendComponent, TooltipComponent } from "echarts/components";
import VChart from "vue-echarts";
import type { EChartsOption, SeriesOption } from "echarts";
import DockerAPI from "@/api/DockerAPI";
import type {
DockerContainerHistoryPointView,
DockerContainerStatusView,
DockerContainerSummaryView
} from "@/types/Docker";
import type { DockerContainerSummaryView } from "@/types/Docker";
import { useSettingStore } from "@/store/settingStore";
import { IOSize, type LabelValue, Text, Time } from "timi-web";
import TCellInfo from "@/components/TCellInfo.vue";
import type { ProgressItem } from "@/components/ProgressGroup.vue";
import ProgressGroup from "@/components/ProgressGroup.vue";
type DockerHistoryMetric = "cpu" | "memory" | "network" | "block" | "pids";
use([
SVGRenderer,
LineChart,
GridComponent,
TooltipComponent,
LegendComponent
]);
import { Text } from "timi-web";
defineOptions({
name: "DockerDashboard"
});
const router = useRouter();
const settingStore = useSettingStore();
const isListLoading = ref(false);
const isStatusLoading = ref(false);
const isHistoryLoading = ref(false);
const containerItems = ref<DockerContainerSummaryView[]>([]);
const selectedContainerId = ref("");
const statusView = ref<DockerContainerStatusView | null>(null);
const historyPoints = ref<DockerContainerHistoryPointView[]>([]);
const chartRef = ref<InstanceType<typeof VChart> | null>(null);
const historyChartUpdateOptions = Object.freeze({
notMerge: true
});
// ---------- 当前状态 ----------
const cpuProgressList = computed<ProgressItem[]>(() => {
return [
{
color: "var(--td-brand-color)",
value: normalizePercent(statusView.value?.cpuPercent)
async function handleSelectContainer(containerId: string): Promise<void> {
await router.push({
name: "DockerContainerDetail",
query: {
containerId
}
];
});
const memoryUsageText = computed(() => {
const usage = IOSize.format(statusView.value?.memoryUsageBytes || 0);
const limitValue = statusView.value?.memoryLimitBytes || 0;
const limit = limitValue < 1 ? "无限制" : IOSize.format(limitValue);
return `${usage} / ${limit}`;
});
const memoryProgressList = computed<ProgressItem[]>(() => {
const limit = statusView.value?.memoryLimitBytes || 0;
const usage = statusView.value?.memoryUsageBytes || 0;
const fallback = limit < 1 ? 0 : usage / limit;
const percent = statusView.value?.memoryPercent;
return [
{
color: "var(--td-warning-color)",
value: normalizePercent(percent, fallback)
}
];
});
function normalizePercent(percent?: number | null, fallback = 0): number {
const source = typeof percent === "number" ? percent / 100 : fallback;
if (Number.isNaN(source) || !Number.isFinite(source)) {
return 0;
}
return Math.min(Math.max(source, 0), 1);
}
// ---------- 格式化 ----------
function resolveStateClass(state?: string | null): string {
if (state === "running") {
@@ -223,446 +102,47 @@ function resolveHealthText(health?: string | null): string {
return map[health] || health;
}
function resolveDockerRawTime(value?: string | null): string {
if (!value || value.startsWith("0001-01-01")) {
return "-";
}
const dateValue = Date.parse(value);
if (Number.isNaN(dateValue)) {
return value;
}
return Time.toPassedDateTime(dateValue);
}
// ---------- 数据加载 ----------
// ---------- 历史采样 ----------
const historyMetric = ref<DockerHistoryMetric>("cpu");
const historyMetricTabs = computed(() => {
const labelMap: Record<DockerHistoryMetric, string> = {
cpu: "CPU",
memory: "内存",
network: "网络",
block: "块设备",
pids: "进程"
const isListLoading = ref(false);
const containerItems = ref<DockerContainerSummaryView[]>([]);
const containerOverviewStats = computed(() => {
const overview = {
total: containerItems.value.length,
running: 0,
stopped: 0,
other: 0
};
return (Object.keys(labelMap) as DockerHistoryMetric[]).map((item) => ({
value: item,
label: labelMap[item]
}));
});
function handleHistoryMetricChange(value: string): void {
const nextValue = value as DockerHistoryMetric;
if (nextValue === "cpu" || nextValue === "memory" || nextValue === "network" || nextValue === "block" || nextValue === "pids") {
historyMetric.value = nextValue;
for (const item of containerItems.value) {
const stateClass = resolveStateClass(item.state);
if (stateClass === "ok") {
overview.running += 1;
continue;
}
if (stateClass === "err") {
overview.stopped += 1;
continue;
}
overview.other += 1;
}
const historyItems = computed<DockerContainerHistoryPointView[]>(() => {
return historyPoints.value.slice().sort((left, right) => left.at - right.at);
});
const historySummary = computed<LabelValue<string, any>[]>(() => {
const lastPoint = historyItems.value[historyItems.value.length - 1];
if (!lastPoint) {
return [];
}
if (historyMetric.value === "cpu") {
return [
{ label: "CPU 使用率", value: Text.unit(lastPoint.cpuPercent, "%") }
];
}
if (historyMetric.value === "memory") {
return [
{ label: "内存占用", value: IOSize.format(lastPoint.memoryUsageBytes || 0) },
{ label: "内存使用率", value: Text.unit(lastPoint.memoryPercent, "%") }
];
}
if (historyMetric.value === "network") {
return [
{ label: "累计接收", value: IOSize.format(lastPoint.networkRxBytes || 0) },
{ label: "累计发送", value: IOSize.format(lastPoint.networkTxBytes || 0) }
];
}
if (historyMetric.value === "block") {
return [
{ label: "累计读取", value: IOSize.format(lastPoint.blockReadBytes || 0) },
{ label: "累计写入", value: IOSize.format(lastPoint.blockWriteBytes || 0) }
];
}
return [
{ label: "进程数", value: Text.display(lastPoint.pids) }
{ key: "total", label: "总数", value: overview.total, className: "all" },
{ key: "running", label: "运行中", value: overview.running, className: "ok" },
{ key: "stopped", label: "已停止", value: overview.stopped, className: "err" },
{ key: "other", label: "其他", value: overview.other, className: "wait" }
];
});
const isHistorySliderTouched = ref(false);
const historySliderValue = ref(0);
const HISTORY_DEFAULT_VISIBLE_COUNT = 24;
const historyVisibleCount = computed(() => {
if (historyItems.value.length < 1) {
return 1;
}
return Math.min(HISTORY_DEFAULT_VISIBLE_COUNT, historyItems.value.length);
});
const historySliderMaxStart = computed(() => {
return Math.max(historyItems.value.length - historyVisibleCount.value, 0);
});
const currentHistoryRange = computed(() => {
const length = historyItems.value.length;
if (length < 1) {
return { startValue: 0, endValue: 0 };
}
const startValue = Math.min(Math.max(Math.floor(historySliderValue.value), 0), historySliderMaxStart.value);
const endValue = Math.min(startValue + historyVisibleCount.value - 1, length - 1);
return { startValue, endValue };
});
const visibleHistoryItems = computed<DockerContainerHistoryPointView[]>(() => {
if (!historyItems.value.length) {
return [];
}
return historyItems.value.slice(currentHistoryRange.value.startValue, currentHistoryRange.value.endValue + 1);
});
watch(() => historyItems.value.length, (length) => {
if (length < 1) {
historySliderValue.value = 0;
isHistorySliderTouched.value = false;
return;
}
if (!isHistorySliderTouched.value) {
historySliderValue.value = historySliderMaxStart.value;
return;
}
historySliderValue.value = Math.min(historySliderValue.value, historySliderMaxStart.value);
});
function handleHistorySliderChange(value: number): void {
historySliderValue.value = Math.min(Math.max(Math.floor(value), 0), historySliderMaxStart.value);
isHistorySliderTouched.value = true;
}
// ---------- 图表 ----------
const isDarkTheme = ref(false);
let themeObserver: MutationObserver | null = null;
const historyChartOption = computed((): EChartsOption => {
const axisColor = isDarkTheme.value ? "#9aa4b2" : "#7e8a9a";
const lineColor = isDarkTheme.value ? "rgba(255, 255, 255, .12)" : "rgba(18, 42, 66, .08)";
const legendColor = isDarkTheme.value ? "#d7dee8" : "#334155";
const commonOption: EChartsOption = {
tooltip: {
trigger: "axis",
confine: true,
position: (point, _params, _dom, _rect, size) => {
const gap = 6;
const fingerGap = 24;
const contentSize = size?.contentSize || [0, 0];
const viewSize = size?.viewSize || [0, 0];
const maxLeft = Math.max(viewSize[0] - contentSize[0] - gap, gap);
const maxTop = Math.max(viewSize[1] - contentSize[1] - gap, gap);
const clamp = (value: number, min: number, max: number): number => {
return Math.min(Math.max(value, min), max);
};
const preferLeft = point[0] - contentSize[0] - gap;
const preferRight = point[0] + gap;
const left = point[0] < viewSize[0] / 2
? clamp(preferRight, gap, maxLeft)
: clamp(preferLeft, gap, maxLeft);
const preferTop = point[1] - contentSize[1] - fingerGap;
const preferBottom = point[1] + fingerGap;
const top = preferTop >= gap
? clamp(preferTop, gap, maxTop)
: clamp(preferBottom, gap, maxTop);
return [left, top];
}
},
legend: {
top: 10,
textStyle: {
color: legendColor
}
},
grid: {
top: 40,
left: 8,
right: 8,
bottom: 8,
containLabel: true
},
xAxis: {
type: "category",
boundaryGap: false,
data: visibleHistoryItems.value.map((item) => item.at),
axisLabel: {
color: axisColor,
formatter: val => Time.toShortTime(Number(val))
},
axisLine: {
lineStyle: {
color: lineColor
}
}
}
};
if (!historyItems.value.length) {
return commonOption;
}
if (historyMetric.value === "cpu") {
return {
...commonOption,
tooltip: {
...commonOption.tooltip,
formatter: params => formatPercentTooltip(params, " %")
},
yAxis: {
type: "value",
min: 0,
max: 100,
axisLabel: {
color: axisColor,
formatter: "{value} %"
},
splitLine: {
lineStyle: {
color: lineColor
}
}
},
series: [
resolveLineSeries("CPU", visibleHistoryItems.value.map((item) => item.cpuPercent || 0))
]
};
}
if (historyMetric.value === "memory") {
return {
...commonOption,
tooltip: {
...commonOption.tooltip,
formatter: params => {
const list = Array.isArray(params) ? params : [params];
const title = `时间:${Time.toDateTime(Number(list[0]?.name))}`;
const lines = list.map((item) => {
const currentName = item.seriesName || "";
if (currentName === "内存占用") {
return `${item.marker}${currentName}: ${IOSize.format(Number(item.value))}`;
}
return `${item.marker}${currentName}: ${Text.unit(Number(item.value), " %")}`;
});
return [title, ...lines].join("<br />");
}
},
yAxis: [
{
type: "value",
axisLabel: {
color: axisColor,
formatter: (value: number) => IOSize.format(value)
},
splitLine: {
lineStyle: {
color: lineColor
}
}
},
{
type: "value",
min: 0,
max: 100,
axisLabel: {
color: axisColor,
formatter: "{value} %"
},
splitLine: {
show: false
}
}
],
series: [
resolveLineSeries("内存占用", visibleHistoryItems.value.map((item) => item.memoryUsageBytes || 0)),
resolveLineSeries("内存使用率", visibleHistoryItems.value.map((item) => item.memoryPercent || 0), 1)
]
};
}
if (historyMetric.value === "network") {
return {
...commonOption,
tooltip: {
...commonOption.tooltip,
formatter: params => formatSizeTooltip(params)
},
yAxis: {
type: "value",
axisLabel: {
color: axisColor,
formatter: (value: number) => IOSize.format(value)
},
splitLine: {
lineStyle: {
color: lineColor
}
}
},
series: [
resolveLineSeries("接收", visibleHistoryItems.value.map((item) => item.networkRxBytes || 0)),
resolveLineSeries("发送", visibleHistoryItems.value.map((item) => item.networkTxBytes || 0))
]
};
}
if (historyMetric.value === "block") {
return {
...commonOption,
tooltip: {
...commonOption.tooltip,
formatter: params => formatSizeTooltip(params)
},
yAxis: {
type: "value",
axisLabel: {
color: axisColor,
formatter: (value: number) => IOSize.format(value)
},
splitLine: {
lineStyle: {
color: lineColor
}
}
},
series: [
resolveLineSeries("读取", visibleHistoryItems.value.map((item) => item.blockReadBytes || 0)),
resolveLineSeries("写入", visibleHistoryItems.value.map((item) => item.blockWriteBytes || 0))
]
};
}
return {
...commonOption,
tooltip: {
...commonOption.tooltip,
formatter: params => {
const list = Array.isArray(params) ? params : [params];
const title = `时间:${Time.toDateTime(Number(list[0]?.name))}`;
const lines = list.map((item) => `${item.marker}${item.seriesName}: ${Text.display(Number(item.value))}`);
return [title, ...lines].join("<br />");
}
},
yAxis: {
type: "value",
minInterval: 1,
axisLabel: {
color: axisColor
},
splitLine: {
lineStyle: {
color: lineColor
}
}
},
series: [
resolveLineSeries("进程数", visibleHistoryItems.value.map((item) => item.pids || 0))
]
};
});
function formatPercentTooltip(params: unknown, unit: string): string {
const list = Array.isArray(params) ? params : [params];
const title = `时间:${Time.toDateTime(Number((list[0] as { name?: string }).name || 0))}`;
const lines = list.map((item) => {
const current = item as { marker?: string; seriesName?: string; value?: number };
return `${current.marker || ""}${current.seriesName || ""}: ${Text.unit(Number(current.value || 0), unit)}`;
});
return [title, ...lines].join("<br />");
}
function formatSizeTooltip(params: unknown): string {
const list = Array.isArray(params) ? params : [params];
const title = `时间:${Time.toDateTime(Number((list[0] as { name?: string }).name || 0))}`;
const lines = list.map((item) => {
const current = item as { marker?: string; seriesName?: string; value?: number };
return `${current.marker || ""}${current.seriesName || ""}: ${IOSize.format(Number(current.value || 0))}`;
});
return [title, ...lines].join("<br />");
}
function resolveLineSeries(name: string, data: Array<number | null>, yAxisIndex?: number): SeriesOption {
const series: SeriesOption = {
type: "line",
name,
showSymbol: false,
smooth: true,
lineStyle: {
width: 2
},
areaStyle: {
opacity: .18
},
data
};
if (typeof yAxisIndex === "number") {
series.yAxisIndex = yAxisIndex;
}
return series;
}
// ---------- 加载与自动刷新 ----------
let listTimer: number | null = null;
let statusTimer: number | null = null;
let historyTimer: number | null = null;
watch(() => settingStore.dashboard.server, restartAutoRefresh, { deep: true });
onMounted(() => {
updateThemeState();
restartAutoRefresh();
themeObserver = new MutationObserver(() => {
updateThemeState();
});
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"]
});
});
onMounted(restartAutoRefresh);
onUnmounted(() => {
clearAutoRefreshTimer();
if (themeObserver) {
themeObserver.disconnect();
themeObserver = null;
}
});
function updateThemeState(): void {
isDarkTheme.value = document.documentElement.classList.contains("theme-dark");
}
function syncSelectedContainer(): void {
if (!containerItems.value.length) {
selectedContainerId.value = "";
statusView.value = null;
historyPoints.value = [];
return;
}
const currentExists = containerItems.value.some((item) => item.id === selectedContainerId.value);
if (!currentExists) {
selectedContainerId.value = containerItems.value[0].id;
}
}
function resetHistorySlider(): void {
historySliderValue.value = 0;
isHistorySliderTouched.value = false;
}
async function refreshContainers(): Promise<void> {
if (isListLoading.value) {
return;
@@ -671,7 +151,6 @@ async function refreshContainers(): Promise<void> {
try {
const list = await DockerAPI.getContainers();
containerItems.value = list.slice().sort((left, right) => left.name.localeCompare(right.name));
syncSelectedContainer();
} catch (error) {
Toast({
theme: "error",
@@ -682,79 +161,13 @@ async function refreshContainers(): Promise<void> {
}
}
async function refreshContainerStatus(): Promise<void> {
if (!selectedContainerId.value) {
statusView.value = null;
return;
}
if (isStatusLoading.value) {
return;
}
isStatusLoading.value = true;
try {
statusView.value = await DockerAPI.getContainerStatus(selectedContainerId.value);
} catch (error) {
Toast({
theme: "error",
message: error instanceof Error ? error.message : "请求失败,请稍后重试"
});
} finally {
isStatusLoading.value = false;
}
}
async function refreshContainerHistory(): Promise<void> {
if (!selectedContainerId.value) {
historyPoints.value = [];
return;
}
if (isHistoryLoading.value) {
return;
}
isHistoryLoading.value = true;
try {
const historyView = await DockerAPI.getContainerHistory(selectedContainerId.value, {
window: "2h"
});
historyPoints.value = historyView?.points || [];
} catch (error) {
Toast({
theme: "error",
message: error instanceof Error ? error.message : "请求失败,请稍后重试"
});
} finally {
isHistoryLoading.value = false;
}
}
async function refreshSnapshot(): Promise<void> {
await refreshContainers();
await refreshContainerStatus();
}
async function refreshDashboard(): Promise<void> {
await refreshSnapshot();
await refreshContainerHistory();
}
function restartAutoRefresh(): void {
void refreshDashboard();
void refreshContainers();
clearAutoRefreshTimer();
const snapshotIntervalMs = Math.max(settingStore.dashboard.server.snapshotRefreshSeconds, 1) * 1000;
const historyIntervalMs = Math.max(settingStore.dashboard.server.historyRefreshSeconds, 1) * 1000;
listTimer = window.setInterval(() => {
void refreshContainers();
}, snapshotIntervalMs);
statusTimer = window.setInterval(() => {
void refreshContainerStatus();
}, snapshotIntervalMs);
historyTimer = window.setInterval(() => {
void refreshContainerHistory();
}, historyIntervalMs);
}
function clearAutoRefreshTimer(): void {
@@ -762,26 +175,6 @@ function clearAutoRefreshTimer(): void {
window.clearInterval(listTimer);
listTimer = null;
}
if (statusTimer !== null) {
window.clearInterval(statusTimer);
statusTimer = null;
}
if (historyTimer !== null) {
window.clearInterval(historyTimer);
historyTimer = null;
}
}
async function handleSelectContainer(containerId: string): Promise<void> {
if (containerId === selectedContainerId.value) {
return;
}
selectedContainerId.value = containerId;
statusView.value = null;
historyPoints.value = [];
resetHistorySlider();
await refreshContainerStatus();
await refreshContainerHistory();
}
</script>
@@ -789,10 +182,48 @@ async function handleSelectContainer(containerId: string): Promise<void> {
.docker-dashboard {
gap: 1rem;
display: flex;
padding: 0 0 1rem 0;
flex-direction: column;
.section {
&.overview {
gap: .6rem;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
.stat {
gap: .25rem;
display: flex;
padding: .5rem;
border-radius: .5rem;
flex-direction: column;
.label {
color: var(--app-sub);
font-size: .72rem;
}
.value {
font-size: .95rem;
font-weight: 700;
color: var(--app-text);
&.ok {
color: #047857;
}
&.err {
color: #b42318;
}
&.wait {
color: #8a5b00;
}
}
}
}
&.list {
gap: .6rem;
display: grid;
@@ -807,21 +238,17 @@ async function handleSelectContainer(containerId: string): Promise<void> {
text-align: left;
border-radius: .75rem;
flex-direction: column;
background: var(--app-bg-soft, #f6f7f9);
&.active {
background: color-mix(in srgb, var(--td-brand-color) 12%, var(--app-bg-soft, #f6f7f9));
box-shadow: 0 0 0 1px color-mix(in srgb, var(--td-brand-color) 45%, transparent);
}
.top,
.bottom {
.header,
.content {
gap: .45rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.header {
.name {
flex: 1;
color: var(--app-text);
@@ -832,23 +259,10 @@ async function handleSelectContainer(containerId: string): Promise<void> {
text-overflow: ellipsis;
}
.img,
.note {
color: var(--app-sub);
font-size: .72rem;
}
.img {
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.state {
flex: none;
padding: .1rem .4rem;
border-radius: .5rem;
border-radius: .25rem;
&.ok {
color: #047857;
@@ -866,35 +280,27 @@ async function handleSelectContainer(containerId: string): Promise<void> {
}
}
}
}
&.history {
.metric-tabs {
width: 100%;
--td-tab-track-width: 4rem;
}
.chart {
width: 100%;
height: 16rem;
background: #FFF;
}
.slider {
gap: .5rem;
display: flex;
padding: .5rem 2rem;
align-items: center;
.label {
flex: none;
font-size: .75rem;
.img,
.health {
color: var(--app-sub);
font-size: .72rem;
}
.control {
flex: 1;
.bottom {
display: flex;
align-items: center;
justify-content: space-between;
.id {
color: var(--app-sub);
font-size: .72rem;
}
.goto {
color: var(--td-brand-color);
font-size: .72rem;
}
}
}
}
@@ -911,10 +317,6 @@ async function handleSelectContainer(containerId: string): Promise<void> {
:global(.theme-dark) .docker-dashboard .section.list .chip {
background: rgba(255, 255, 255, .06);
&.active {
background: color-mix(in srgb, var(--td-brand-color) 20%, rgba(255, 255, 255, .06));
}
.state {
&.ok {
@@ -930,4 +332,23 @@ async function handleSelectContainer(containerId: string): Promise<void> {
}
}
}
:global(.theme-dark) .docker-dashboard .section.overview .stat {
background: rgba(255, 255, 255, .06);
.value {
&.ok {
color: #86efac;
}
&.err {
color: #fda4af;
}
&.wait {
color: #fcd34d;
}
}
}
</style>

View File

@@ -733,7 +733,7 @@ onMounted(restartAutoRefresh);
font-weight: bold;
}
.note {
.health {
font-size: .75rem;
color: var(--app-sub);
}

View File

@@ -6,7 +6,7 @@
key-field="key"
>
<template #before>
<div aria-hidden="true" class="spacer top"></div>
<div aria-hidden="true" class="spacer header"></div>
</template>
<template #default="{ item }">
<div class="grid-row">
@@ -31,7 +31,7 @@
</div>
</template>
<template #after>
<div aria-hidden="true" class="spacer bottom"></div>
<div aria-hidden="true" class="spacer content"></div>
</template>
</recycle-scroller>
</template>
@@ -94,11 +94,11 @@ function handleOpen(item: ExplorerItem): void {
width: 100%;
pointer-events: none;
&.top {
&.header {
height: var(--top-gap);
}
&.bottom {
&.content {
height: var(--bottom-gap);
}
}

View File

@@ -6,7 +6,7 @@
key-field="path"
>
<template #before>
<div aria-hidden="true" class="spacer top"></div>
<div aria-hidden="true" class="spacer header"></div>
</template>
<template #default="{ item }">
<t-swipe-cell :disabled="!isSwipeActionVisible(item)" :left="resolveLeftActions(item)">
@@ -17,7 +17,7 @@
@click="handleOpen(item)"
>
<template #note>
<div class="note">
<div class="health">
<t-loading v-if="item.path === pendingPath" size="1rem" />
<span v-if="item.type === 'file'" v-text="item.size"></span>
</div>
@@ -29,7 +29,7 @@
</t-swipe-cell>
</template>
<template #after>
<div aria-hidden="true" class="spacer bottom"></div>
<div aria-hidden="true" class="spacer content"></div>
</template>
</recycle-scroller>
</template>
@@ -145,16 +145,16 @@ function handlePlay(item: ExplorerItem): void {
width: 100%;
pointer-events: none;
&.top {
&.header {
height: var(--top-gap);
}
&.bottom {
&.content {
height: var(--bottom-gap);
}
}
.note {
.health {
gap: .375rem;
display: flex;
flex: 0 0 auto;

View File

@@ -13,6 +13,7 @@ import ThemeSetting from "@/pages/setting/ThemeSetting.vue";
import ServerDetail from "@/pages/dashboard/ServerDashboard/ServerDetail.vue";
import ServerPerformanceDetail from "@/pages/dashboard/ServerDashboard/ServerPerformanceDetail.vue";
import ServerPartitionsDetail from "@/pages/dashboard/ServerDashboard/ServerPartitionsDetail.vue";
import DockerContainerDetail from "@/pages/dashboard/DockerDashboard/DockerContainerDetail.vue";
const router = createRouter({
history: createWebHistory("/"),
@@ -109,6 +110,19 @@ const router = createRouter({
},
component: ServerPartitionsDetail
},
{
path: "/server/docker-container-detail",
name: "DockerContainerDetail",
meta: {
depth: 3,
navBarVisible: true,
navBarCanBack: true,
navBarTitle: "容器详情",
tabBarVisible: false,
bodyBackground: "#F4F4F4"
},
component: DockerContainerDetail
},
{
path: "/settings/connect",
name: "ConnectSetting",

View File

@@ -4,13 +4,13 @@ export interface DockerContainerSummaryView {
image: string;
state: string;
status: string;
healthStatus?: string | null;
cpuPercent?: number | null;
memoryUsageBytes?: number | null;
memoryLimitBytes?: number | null;
memoryPercent?: number | null;
networkRxBytes?: number | null;
networkTxBytes?: number | null;
healthStatus?: string;
cpuPercent?: number;
memoryUsageBytes?: number;
memoryLimitBytes?: number;
memoryPercent?: number;
networkRxBytes?: number;
networkTxBytes?: number;
updatedAt: number;
}
@@ -22,21 +22,21 @@ export interface DockerContainerStatusView {
createdAt: number;
state: string;
status: string;
healthStatus?: string | null;
startedAt?: string | null;
finishedAt?: string | null;
exitCode?: number | null;
healthStatus?: string;
startedAt?: string;
finishedAt?: string;
exitCode?: number;
restartCount: number;
oomKilled: boolean;
cpuPercent?: number | null;
memoryUsageBytes?: number | null;
memoryLimitBytes?: number | null;
memoryPercent?: number | null;
networkRxBytes?: number | null;
networkTxBytes?: number | null;
blockReadBytes?: number | null;
blockWriteBytes?: number | null;
pids?: number | null;
cpuPercent?: number;
memoryUsageBytes?: number;
memoryLimitBytes?: number;
memoryPercent?: number;
networkRxBytes?: number;
networkTxBytes?: number;
blockReadBytes?: number;
blockWriteBytes?: number;
pids?: number;
updatedAt: number;
}
@@ -52,12 +52,12 @@ export interface DockerContainerHistoryView {
export interface DockerContainerHistoryPointView {
at: number;
cpuPercent?: number | null;
memoryUsageBytes?: number | null;
memoryPercent?: number | null;
networkRxBytes?: number | null;
networkTxBytes?: number | null;
blockReadBytes?: number | null;
blockWriteBytes?: number | null;
pids?: number | null;
cpuPercent?: number;
memoryUsageBytes?: number;
memoryPercent?: number;
networkRxBytes?: number;
networkTxBytes?: number;
blockReadBytes?: number;
blockWriteBytes?: number;
pids?: number;
}