implement Docker
This commit is contained in:
@@ -19,7 +19,7 @@
|
||||
"pinia": "^3.0.2",
|
||||
"tdesign-mobile-vue": "^1.13.2",
|
||||
"timi-tdesign-mobile": "0.0.9",
|
||||
"timi-web": "0.0.16",
|
||||
"timi-web": "0.0.17",
|
||||
"ts-node": "^10.9.2",
|
||||
"vue": "^3.5.16",
|
||||
"vue-router": "4.5.1",
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -30,8 +30,8 @@ importers:
|
||||
specifier: 0.0.9
|
||||
version: 0.0.9(typescript@5.8.3)
|
||||
timi-web:
|
||||
specifier: 0.0.16
|
||||
version: 0.0.16
|
||||
specifier: 0.0.17
|
||||
version: 0.0.17
|
||||
ts-node:
|
||||
specifier: ^10.9.2
|
||||
version: 10.9.2(@types/node@24.12.0)(typescript@5.8.3)
|
||||
@@ -1832,8 +1832,8 @@ packages:
|
||||
resolution: {integrity: sha512-SV8m/FsLsEabSmJ/NvI650fLyb/JCLz7JUNQrkWeoo5xICElsN4x39L3n4lTzu6ARidjpT6uA+XEmA4wnyQmuA==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
timi-web@0.0.16:
|
||||
resolution: {integrity: sha512-Tyma/szWvAmPTq7HRtXtRA0Of+YI1Acko96wsosJFRyO+tN1gDEUrwMoY1l2Uw8Q1g6Yv7huS6E6DLd9fA28og==}
|
||||
timi-web@0.0.17:
|
||||
resolution: {integrity: sha512-6ycsqfnl+zeQkgOICjhhJYHoo3CtCxnhaNFFirNLVfX/VrrTDjW7DiR+/W8bouZzx6AR19fZKyG+UE0BKKBURg==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
tinycolor2@1.6.0:
|
||||
@@ -3875,7 +3875,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
timi-web@0.0.16:
|
||||
timi-web@0.0.17:
|
||||
dependencies:
|
||||
axios: 1.13.5
|
||||
less: 4.5.1
|
||||
|
||||
39
src/api/DockerAPI.ts
Normal file
39
src/api/DockerAPI.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { axios } from "timi-web";
|
||||
import { useSettingStore } from "@/store/settingStore";
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
getContainers,
|
||||
getContainerStatus,
|
||||
getContainerHistory
|
||||
};
|
||||
@@ -2,12 +2,22 @@
|
||||
<t-cell class="t-cell-info" :class="{ 'has-description': hasDescription }">
|
||||
<template #title>
|
||||
<div class="content">
|
||||
<slot name="label">
|
||||
<div class="label" v-text="label"></div>
|
||||
</slot>
|
||||
<slot name="value">
|
||||
<div class="value clip-text light-gray" v-text="value"></div>
|
||||
</slot>
|
||||
<div class="label" :class="{
|
||||
'clip-text': clipText === 'label',
|
||||
'keep-text': clipText === 'value'
|
||||
}">
|
||||
<slot name="label">
|
||||
<span v-text="label"></span>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="value light-gray" :class="{
|
||||
'clip-text': clipText === 'value',
|
||||
'keep-text': clipText === 'label'
|
||||
}">
|
||||
<slot name="value">
|
||||
<span v-text="value"></span>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #description>
|
||||
@@ -26,16 +36,18 @@ const props = withDefaults(
|
||||
label?: string;
|
||||
value?: string;
|
||||
description?: string;
|
||||
clipText?: 'label' | 'value';
|
||||
}>(),
|
||||
{
|
||||
label: '',
|
||||
value: '',
|
||||
description: '',
|
||||
clipText: 'value',
|
||||
},
|
||||
);
|
||||
|
||||
const slots = useSlots();
|
||||
const { label, value, description } = toRefs(props);
|
||||
const { label, value, description, clipText } = toRefs(props);
|
||||
const hasDescription = computed(() => {
|
||||
return Boolean(props.description?.trim() || slots.description || slots.default);
|
||||
});
|
||||
@@ -75,13 +87,10 @@ defineSlots<{
|
||||
justify-content: space-between;
|
||||
|
||||
.label {
|
||||
white-space: nowrap;
|
||||
margin-right: .5rem;
|
||||
}
|
||||
|
||||
.value {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@@ -1,57 +1,933 @@
|
||||
<template>
|
||||
<div class="docker-dashboard">
|
||||
<section class="card">
|
||||
<p class="tag">Docker</p>
|
||||
<h2 class="header">容器状态</h2>
|
||||
<p class="desc">这里用于承载容器列表、运行状态、镜像占用和资源使用情况。</p>
|
||||
</section>
|
||||
<div v-if="isListLoading && !containerItems.length" class="loading-wrap">
|
||||
<t-loading text="加载 Docker 容器..." />
|
||||
</div>
|
||||
<template v-else-if="containerItems.length">
|
||||
<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 }]"
|
||||
@click="handleSelectContainer(item.id)"
|
||||
>
|
||||
<div class="top">
|
||||
<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>
|
||||
</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"></script>
|
||||
<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 { 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: "DockerDashboard"
|
||||
});
|
||||
|
||||
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)
|
||||
}
|
||||
];
|
||||
});
|
||||
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") {
|
||||
return "ok";
|
||||
}
|
||||
if (state === "exited" || state === "dead") {
|
||||
return "err";
|
||||
}
|
||||
return "wait";
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ---------- 图表 ----------
|
||||
|
||||
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"]
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
isListLoading.value = true;
|
||||
try {
|
||||
const list = await DockerAPI.getContainers();
|
||||
containerItems.value = list.slice().sort((left, right) => left.name.localeCompare(right.name));
|
||||
syncSelectedContainer();
|
||||
} catch (error) {
|
||||
Toast({
|
||||
theme: "error",
|
||||
message: error instanceof Error ? error.message : "请求失败,请稍后重试"
|
||||
});
|
||||
} finally {
|
||||
isListLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
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 {
|
||||
if (listTimer !== null) {
|
||||
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>
|
||||
|
||||
<style scoped lang="less">
|
||||
.docker-dashboard {
|
||||
padding: 1rem;
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.card {
|
||||
gap: .6rem;
|
||||
.section {
|
||||
|
||||
&.list {
|
||||
gap: .6rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
.chip {
|
||||
gap: .45rem;
|
||||
border: 0;
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
padding: .6rem;
|
||||
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 {
|
||||
gap: .45rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
color: var(--app-text);
|
||||
font-size: .85rem;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
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;
|
||||
|
||||
&.ok {
|
||||
color: #047857;
|
||||
background: rgba(16, 185, 129, .15);
|
||||
}
|
||||
|
||||
&.err {
|
||||
color: #b42318;
|
||||
background: rgba(250, 76, 76, .14);
|
||||
}
|
||||
|
||||
&.wait {
|
||||
color: #8a5b00;
|
||||
background: rgba(245, 158, 11, .18);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.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;
|
||||
padding: 1rem;
|
||||
border-radius: 1rem;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--app-line);
|
||||
background: var(--app-card);
|
||||
box-shadow: 0 .35rem 1rem rgba(17, 32, 56, .05);
|
||||
}
|
||||
|
||||
.tag,
|
||||
.header,
|
||||
.desc {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tag,
|
||||
.desc {
|
||||
color: var(--app-sub);
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: .875rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.desc {
|
||||
line-height: 1.6;
|
||||
min-height: 4rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.theme-dark) .docker-dashboard {
|
||||
.card {
|
||||
box-shadow: 0 .35rem 1rem rgba(0, 0, 0, .2);
|
||||
: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 {
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
&.err {
|
||||
color: #fda4af;
|
||||
}
|
||||
|
||||
&.wait {
|
||||
color: #fcd34d;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -53,8 +53,9 @@
|
||||
class="item"
|
||||
v-for="item in snapshotView?.snapshot?.storagePartitions"
|
||||
:key="item.uuid"
|
||||
:label="item.mountPoint"
|
||||
:label="item.mountPoint || item.partitionId"
|
||||
:value="`${IOSize.format(item.used)} / ${IOSize.format(item.total)}`"
|
||||
clip-text="label"
|
||||
>
|
||||
<progress-group
|
||||
mode="heap"
|
||||
@@ -123,7 +124,7 @@ import SystemAPI from "@/api/SystemAPI";
|
||||
import type { SystemStatusHistoryPoint, SystemStatusSnapshotView } from "@/types/System";
|
||||
import type { DashboardHistoryMetric } from "@/store/settingStore";
|
||||
import { useSettingStore } from "@/store/settingStore";
|
||||
import { IOSize, type LabelValue, Text, Time } from "timi-web";
|
||||
import { IOSize, type LabelValue, Text, Time, Toolkit } from "timi-web";
|
||||
import TCellInfo from "@/components/TCellInfo.vue";
|
||||
import type { ProgressItem } from "@/components/ProgressGroup.vue";
|
||||
import ProgressGroup from "@/components/ProgressGroup.vue";
|
||||
@@ -230,7 +231,6 @@ const historyChartUpdateOptions = Object.freeze({
|
||||
notMerge: true
|
||||
});
|
||||
const isDarkTheme = ref(false);
|
||||
let historyTimer: number | null;
|
||||
|
||||
// ---------- Tab ----------
|
||||
|
||||
@@ -605,12 +605,10 @@ function resolveLineSeries(name: string, data: Array<number | null>, yAxisIndex?
|
||||
|
||||
// ---------- 加载数据:当前状态 ----------
|
||||
|
||||
let snapshotTimer: number | null;
|
||||
let snapshotTimer: number | undefined;
|
||||
onUnmounted(() => {
|
||||
if (snapshotTimer !== null) {
|
||||
window.clearInterval(snapshotTimer);
|
||||
snapshotTimer = null;
|
||||
}
|
||||
window.clearInterval(snapshotTimer);
|
||||
snapshotTimer = undefined;
|
||||
});
|
||||
|
||||
async function refreshSnapshot(): Promise<void> {
|
||||
@@ -633,6 +631,8 @@ async function refreshSnapshot(): Promise<void> {
|
||||
|
||||
// ---------- 加载数据:历史采集 ----------
|
||||
|
||||
let historyTimer: number | undefined;
|
||||
|
||||
async function refreshHistory(): Promise<void> {
|
||||
if (isHistoryLoading.value) {
|
||||
return;
|
||||
@@ -655,6 +655,11 @@ async function refreshHistory(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
window.clearInterval(historyTimer);
|
||||
historyTimer = undefined;
|
||||
});
|
||||
|
||||
// ---------- 自动刷新 ----------
|
||||
|
||||
watch(() => settingStore.dashboard.server, restartAutoRefresh, { deep: true });
|
||||
@@ -684,11 +689,11 @@ function restartAutoRefresh(): void {
|
||||
function clearAutoRefreshTimer(): void {
|
||||
if (snapshotTimer !== null) {
|
||||
window.clearInterval(snapshotTimer);
|
||||
snapshotTimer = null;
|
||||
snapshotTimer = undefined;
|
||||
}
|
||||
if (historyTimer !== null) {
|
||||
window.clearInterval(historyTimer);
|
||||
historyTimer = null;
|
||||
historyTimer = undefined;
|
||||
}
|
||||
}
|
||||
onMounted(restartAutoRefresh);
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<t-cell title="主板序列号" :note="hardware?.baseboard?.serialNumber" />
|
||||
<t-cell title="固件厂商" :note="hardware?.firmware?.manufacturer" />
|
||||
<t-cell title="固件名称" :note="hardware?.firmware?.name" />
|
||||
<t-cell title="固件描述" :note="hardware?.firmware?.description" />
|
||||
<t-cell-info label="固件描述" :value="hardware?.firmware?.description" />
|
||||
<t-cell title="固件版本" :note="hardware?.firmware?.version" />
|
||||
<t-cell title="固件发布日期" :note="hardware?.firmware?.releaseDate" />
|
||||
</t-cell-group>
|
||||
|
||||
@@ -14,13 +14,13 @@
|
||||
<t-cell-info label="磁盘型号" :value="Text.display(item.diskModel)" />
|
||||
<t-cell-info label="磁盘序列号" :value="Text.display(item.diskSerial)" />
|
||||
<t-cell title="分区 ID" :note="Text.display(item.partitionId)" />
|
||||
<t-cell title="分区名称" :note="Text.display(item.partitionName)" />
|
||||
<t-cell-info label="分区名称" :value="Text.display(item.partitionName)" />
|
||||
<t-cell title="分区类型" :note="Text.display(item.partitionType)" />
|
||||
<t-cell-info label="UUID" :value="Text.display(item.uuid)" />
|
||||
<t-cell title="总容量" :note="IOSize.format(item.total)" />
|
||||
<t-cell title="已使用" :note="IOSize.format(item.used)" />
|
||||
<t-cell title="可用容量" :note="IOSize.format(resolveAvailableBytes(item))" />
|
||||
<t-cell title="使用率" :note="Text.unit(resolveUsageRate(item) * 100, '%')" />
|
||||
<t-cell title="可用容量" :note="IOSize.format(item.total - item.used)" />
|
||||
<t-cell title="使用率" :note="Text.unit(item.used / item.total * 100, '%')" />
|
||||
<t-cell
|
||||
v-if="typeof item.transferTimeMs === 'number'"
|
||||
title="传输耗时"
|
||||
@@ -74,24 +74,13 @@ onUnmounted(() => {
|
||||
|
||||
function resolvePartitionTitle(item: Partition): string {
|
||||
const mountPoint = Text.display(item.mountPoint);
|
||||
const partitionName = Text.display(item.partitionName);
|
||||
if (partitionName !== "-") {
|
||||
return `${mountPoint} (${partitionName})`;
|
||||
const partitionId = Text.display(item.partitionId);
|
||||
if (partitionId !== "-") {
|
||||
return `${mountPoint} (${partitionId})`;
|
||||
}
|
||||
return mountPoint;
|
||||
}
|
||||
|
||||
function resolveUsageRate(item: Partition): number {
|
||||
if (item.total < 1) {
|
||||
return 0;
|
||||
}
|
||||
return item.used / item.total;
|
||||
}
|
||||
|
||||
function resolveAvailableBytes(item: Partition): number {
|
||||
return Math.max(item.total - item.used, 0);
|
||||
}
|
||||
|
||||
async function refreshDetail(): Promise<void> {
|
||||
if (isLoading.value) {
|
||||
return;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<t-cell title="设备 ID" :note="Text.display(statusView?.deviceId)" />
|
||||
<t-cell title="UPS 类型" :note="Text.display(statusView?.upsType)" />
|
||||
<t-cell title="工作模式" :note="Text.display(statusView?.workMode)" />
|
||||
<t-cell title="数据时间" :note="Time.toPassedDateTime(statusView?.upsTime)" />
|
||||
<t-cell title="数据时间" :note="Time.toDateTime(statusView?.upsTime)" />
|
||||
<t-cell title="输出状态" :note="Text.displayBool(statusView?.outputOn, '开启', '关闭')" />
|
||||
<t-cell title="充电状态" :note="Text.displayBool(statusView?.charging, '充电中', '未充电')" />
|
||||
<t-cell title="旁路状态" :note="Text.displayBool(statusView?.bypassActive, '旁路运行', '正常供电')" />
|
||||
|
||||
63
src/types/Docker.ts
Normal file
63
src/types/Docker.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export interface DockerContainerSummaryView {
|
||||
id: string;
|
||||
name: string;
|
||||
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;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface DockerContainerStatusView {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
imageId: string;
|
||||
createdAt: number;
|
||||
state: string;
|
||||
status: string;
|
||||
healthStatus?: string | null;
|
||||
startedAt?: string | null;
|
||||
finishedAt?: string | null;
|
||||
exitCode?: number | null;
|
||||
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;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface DockerContainerHistoryView {
|
||||
id: string;
|
||||
name: string;
|
||||
serverTime: number;
|
||||
sampleRateMs: number;
|
||||
from: number;
|
||||
to: number;
|
||||
points: DockerContainerHistoryPointView[];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user