mirror of
https://github.com/snowykami/server-status-web.git
synced 2025-09-06 12:46:24 +00:00
✨ 新增截图功能,优化UI样式
This commit is contained in:
@ -8,7 +8,8 @@ const year = new Date().getFullYear()
|
||||
<h1 style="text-align: center">Server Status</h1>
|
||||
<router-view/>
|
||||
<footer>
|
||||
© Copyright 2024-{{year}} <a href="https://sfkm.me" target="_blank">snowykami</a> All Rights Reserved
|
||||
<a href="https://github.com/snowykami/server-status-server">Server Status Dashboard</a><br>
|
||||
© Copyright 2024-{{year}} <a href="https://sfkm.me" target="_blank">Snowykami</a> All Rights Reserved
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
@ -30,4 +31,7 @@ footer {
|
||||
padding: 1em;
|
||||
color: #666;
|
||||
}
|
||||
a {
|
||||
color: #36a7ec;
|
||||
}
|
||||
</style>
|
||||
|
@ -13,6 +13,7 @@ export interface Status {
|
||||
link: string | null; // 链接或是nil
|
||||
observed_at: number; // unix timestamp
|
||||
start_time: number; // unix timestamp
|
||||
timezone: string; // Asia/Shanghai
|
||||
};
|
||||
hardware: {
|
||||
mem: {
|
||||
@ -32,6 +33,9 @@ export interface Status {
|
||||
[key: string]: {
|
||||
used: number;
|
||||
total: number;
|
||||
mountpoint: string;
|
||||
fstype: string;
|
||||
device: string;
|
||||
};
|
||||
};
|
||||
net: {
|
||||
|
84
src/components/Disk.vue
Normal file
84
src/components/Disk.vue
Normal file
@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {format2Size, getBaseColor, getBlankColor} from "../api/utils.ts";
|
||||
|
||||
const props = defineProps<{
|
||||
mountpoint: string
|
||||
device: string
|
||||
used: number
|
||||
total: number
|
||||
fstype: string
|
||||
}>()
|
||||
|
||||
const colorUsed = getBaseColor(props.used / props.total * 100)
|
||||
const colorBlank = getBlankColor(props.used / props.total * 100)
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="disk" :style="{backgroundColor: colorBlank}">
|
||||
<div class="disk-used" :style="{width: props.used / props.total * 100 + '%', backgroundColor: colorUsed}"></div>
|
||||
<div class="hover-text">
|
||||
<div class="left-text">
|
||||
<span class="disk-text">{{props.mountpoint}}</span>
|
||||
</div>
|
||||
<div class="right-text">
|
||||
<span class="disk-text">{{ format2Size(props.used, props.total) }} [{{ props.fstype }}]</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
:host {
|
||||
--text-size: 2px;
|
||||
}
|
||||
|
||||
.disk {
|
||||
margin-top: 0.5rem;
|
||||
height: 2rem;
|
||||
width: 100%;
|
||||
border-radius: 100px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.disk-used {
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
border-radius: 100px 0 0 100px;
|
||||
}
|
||||
|
||||
.hover-text {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
text-align: center;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.left-text {
|
||||
margin-left: 1rem;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.right-text {
|
||||
margin-right: 1rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.disk-text {
|
||||
font-size: var(--text-size);
|
||||
color: var(--text-color-2);
|
||||
}
|
||||
</style>
|
@ -14,6 +14,8 @@ import {
|
||||
} from "../api/utils.ts";
|
||||
|
||||
import OutlineAnime from "./OutlineAnime.vue";
|
||||
import Disk from "./Disk.vue";
|
||||
import html2canvas from "html2canvas";
|
||||
|
||||
const props = defineProps<{
|
||||
status: Status
|
||||
@ -24,7 +26,6 @@ const status = computed(
|
||||
)
|
||||
|
||||
const uptime = ref(formatUptime(status.value.meta.uptime))
|
||||
console.log(uptime.value)
|
||||
const cpuChartRef = ref(null);
|
||||
const memoryChartRef = ref(null);
|
||||
const swapChartRef = ref(null);
|
||||
@ -33,10 +34,10 @@ const swapChartRef = ref(null);
|
||||
const netChartRef = ref(null);
|
||||
let netStats: [number, number, number][] = []
|
||||
const isOnline = ref(true)
|
||||
const dotColor = computed(
|
||||
const statusColor = computed(
|
||||
() => isOnline.value ? '#22c55e' : '#ff4d4f'
|
||||
)
|
||||
const spreadColor = computed(
|
||||
const statusColor2 = computed(
|
||||
() => isOnline.value ? '#80ffb0' : '#fd8182'
|
||||
)
|
||||
const deltaTime = ref('0')
|
||||
@ -52,18 +53,40 @@ const swapDetail = computed(() => {
|
||||
return status.value.hardware.swap.total > 0 ? format2Size(status.value.hardware.swap.used, status.value.hardware.swap.total) : 'N/A'
|
||||
})
|
||||
|
||||
const gradientStyle = computed(() => {
|
||||
return {
|
||||
borderColor: `linear-gradient(90deg, ${statusColor.value}, ${statusColor2.value})`
|
||||
}
|
||||
})
|
||||
|
||||
const hoverBorderColor = computed(() => {
|
||||
return statusColor.value
|
||||
})
|
||||
|
||||
|
||||
function onMountedFunc() {
|
||||
const cpuChart = echarts.init(cpuChartRef.value);
|
||||
const memoryChart = echarts.init(memoryChartRef.value);
|
||||
const swapChart = echarts.init(swapChartRef.value);
|
||||
const netChart = echarts.init(netChartRef.value);
|
||||
|
||||
// style
|
||||
const titleStyle = {
|
||||
color: 'rgba(0, 0, 0, 0.8)',
|
||||
fontSize: 13,
|
||||
fontSize: 15,
|
||||
}
|
||||
const radius = ['65%', '90%']
|
||||
const netColor = ['#a2d8f4', '#0194e3'] // Tx Rx
|
||||
const pieLabelPosition = 'center'
|
||||
const emphasis = {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: 15,
|
||||
position: ['50%', '20%'] // 设置标签位置为圆环外部
|
||||
},
|
||||
}
|
||||
|
||||
const netColor = ['#a2d8f4', '#10a0ed']
|
||||
// 更新时间
|
||||
setInterval(() => {
|
||||
if (isOnline.value) {
|
||||
const deltaTime = (Date.now()) / 1000 - status.value.meta.observed_at
|
||||
@ -76,7 +99,7 @@ function onMountedFunc() {
|
||||
deltaTime.value = timeDiff.toFixed(1)
|
||||
// 判断该时间与上一个时间不同才push
|
||||
if (netStats.length === 0 || netStats[netStats.length - 1][0] !== status.value.meta.observed_at) {
|
||||
netStats.push([status.value.meta.observed_at, status.value.hardware.net.up, status.value.hardware.net.down])
|
||||
netStats.push([status.value.meta.observed_at, status.value.hardware.net.up, status.value.hardware.net.down]) // 时间 上行 下行
|
||||
}
|
||||
|
||||
if (netStats.length > 20) {
|
||||
@ -94,7 +117,7 @@ function onMountedFunc() {
|
||||
text: status.value.hardware.cpu.percent + '%',
|
||||
left: 'center',
|
||||
top: 'center',
|
||||
textStyle: titleStyle
|
||||
textStyle: titleStyle,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
@ -103,22 +126,16 @@ function onMountedFunc() {
|
||||
avoidLabelOverlap: false,
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center'
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: '20',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
position: pieLabelPosition
|
||||
},
|
||||
emphasis: emphasis,
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: computed(
|
||||
() => [
|
||||
{value: status.value.hardware.cpu.percent},
|
||||
{value: 100 - status.value.hardware.cpu.percent}
|
||||
{value: status.value.hardware.cpu.percent, name: 'Used'},
|
||||
{value: 100 - status.value.hardware.cpu.percent, name: 'Free'}
|
||||
]
|
||||
).value
|
||||
}
|
||||
@ -144,21 +161,15 @@ function onMountedFunc() {
|
||||
avoidLabelOverlap: false,
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center'
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: '20',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
position: pieLabelPosition
|
||||
},
|
||||
emphasis: emphasis,
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: [
|
||||
{value: status.value.hardware.mem.used},
|
||||
{value: status.value.hardware.mem.total - status.value.hardware.mem.used}
|
||||
{value: status.value.hardware.mem.used, name: 'Used'},
|
||||
{value: status.value.hardware.mem.total - status.value.hardware.mem.used, name: 'Free'}
|
||||
]
|
||||
}
|
||||
]
|
||||
@ -183,22 +194,17 @@ function onMountedFunc() {
|
||||
avoidLabelOverlap: false,
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center'
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: '20',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
position: pieLabelPosition
|
||||
},
|
||||
emphasis: emphasis,
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: [
|
||||
{value: status.value.hardware.swap.total > 0 ? status.value.hardware.swap.used : 0},
|
||||
{value: status.value.hardware.swap.total > 0 ? status.value.hardware.swap.used : 0, name: 'Used'},
|
||||
{
|
||||
value: status.value.hardware.swap.total > 0 ? status.value.hardware.swap.total - status.value.hardware.swap.used : 100
|
||||
value: status.value.hardware.swap.total > 0 ? status.value.hardware.swap.total - status.value.hardware.swap.used : 100,
|
||||
name: 'Free'
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -209,16 +215,28 @@ function onMountedFunc() {
|
||||
netChart.setOption(
|
||||
{
|
||||
color: netColor,
|
||||
title: {
|
||||
text: 'Network',
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
label: {
|
||||
backgroundColor: '#6a7985'
|
||||
backgroundColor: '#0f7bc5',
|
||||
borderRadius: 50,
|
||||
formatter: function (params: any) {
|
||||
if (params.axisDimension === 'y') {
|
||||
return formatSizeByUnit(params.value * 8, null, 'bps');
|
||||
} else {
|
||||
return formatDate(params.value, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
formatter: function (params: any) {
|
||||
let result = formatDate(params[0].name, true) + '<br/>';
|
||||
params.forEach(function (item: any) {
|
||||
result += item.marker + (item.seriesName == 'Tx' ? '↑' : '↓') + ': ' + formatSizeByUnit(item.value * 8, null, 'bps') + '<br/>';
|
||||
});
|
||||
return result;
|
||||
}
|
||||
},
|
||||
toolbox: {
|
||||
@ -257,24 +275,29 @@ function onMountedFunc() {
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: 'Up',
|
||||
name: 'Tx',
|
||||
type: 'line',
|
||||
stack: 'Total',
|
||||
areaStyle: {},
|
||||
emphasis: {
|
||||
focus: 'series'
|
||||
},
|
||||
data: netStats.map(item => item[1])
|
||||
data: netStats.map(item => item[1]),
|
||||
showSymbol: false,
|
||||
lineStyle: {
|
||||
type: 'dashed'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Down',
|
||||
name: 'Rx',
|
||||
type: 'line',
|
||||
stack: 'Total',
|
||||
areaStyle: {},
|
||||
emphasis: {
|
||||
focus: 'series'
|
||||
},
|
||||
data: netStats.map(item => item[2])
|
||||
data: netStats.map(item => item[2]),
|
||||
showSymbol: false
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -292,6 +315,21 @@ function onMountedFunc() {
|
||||
}
|
||||
|
||||
|
||||
// link.download = `screenshot-${status.value.meta.id}-${formatDate(Date.now(), false)}.svg`;
|
||||
|
||||
function downloadScreenshot() {
|
||||
const hostElement = document.querySelector(".host#"+status.value.meta.id);
|
||||
if (hostElement) {
|
||||
html2canvas(<HTMLElement>hostElement, { scale: 2 }).then((canvas) => {
|
||||
const dataURL = canvas.toDataURL("image/png");
|
||||
const link = document.createElement("a");
|
||||
link.href = dataURL;
|
||||
link.download = `screenshot-${status.value.meta.id}-${formatDate(Date.now(), false)}.png`;
|
||||
link.click();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(
|
||||
() => {
|
||||
onMountedFunc()
|
||||
@ -301,25 +339,36 @@ onMounted(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="host">
|
||||
<div class="host" :style="[gradientStyle, { '--hover-border-color': hoverBorderColor }]" :id="status.meta.id">
|
||||
<div class="meta-1" style="display: flex; justify-content: space-between">
|
||||
<div class="meta1-left" style="display: flex; justify-content: flex-start; align-items: center">
|
||||
<OutlineAnime class="outline-anime" :color="dotColor" :spreadColor="spreadColor" :is-online="isOnline"/>
|
||||
<span class="host-name" style="display: flex">{{ props.status.meta.name }}</span>
|
||||
<OutlineAnime class="outline-anime" :color="statusColor" :spreadColor="statusColor2" :is-online="isOnline"/>
|
||||
<span class="host-name" style="display: flex">{{ status.meta.name }}</span>
|
||||
</div>
|
||||
<div class="meta1-right" style="display: flex; justify-content: flex-end; align-items: center">
|
||||
<div style="margin-right: 5px">{{ uptime }}</div>
|
||||
<div class="uptime" style="margin-right: 5px"
|
||||
:style="{backgroundColor: statusColor2, borderColor: statusColor}">{{ uptime }}
|
||||
</div>
|
||||
<img @click="downloadScreenshot" class="icon" src="/svg/screenshots.svg" alt="download" style="width: 20px; height: 20px">
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta-2" style="display: flex">
|
||||
<div class="meta-2">
|
||||
<div class="section">
|
||||
<img class="icon" :src="os.icon" alt="system">
|
||||
<span class="meta2-text">{{ os.name }} · {{ props.status.meta.location }}</span>
|
||||
<span class="meta2-text">{{ os.name }}</span>
|
||||
</div>
|
||||
<div class="section">
|
||||
<img class="icon" src="/svg/timezone.svg" alt="location">
|
||||
<span class="meta2-text">{{ status.meta.location }} · {{ status.meta.timezone }}</span>
|
||||
</div>
|
||||
<div class="labels section" style="display: flex; justify-content: flex-start">
|
||||
<img class="icon" src="/svg/label.svg" alt="labels">
|
||||
<span class="label" v-for="label in status.meta.labels" :key="label">{{ label }}</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="labels" style="display: flex; justify-content: flex-start">
|
||||
<span class="label" v-for="label in props.status.meta.labels" :key="label">{{ label }}</span>
|
||||
<div class="section-name">
|
||||
Hardware
|
||||
</div>
|
||||
<div class="charts-container" style="display: flex; justify-content: space-between">
|
||||
<div class="cpu-info hw-info">
|
||||
@ -339,8 +388,18 @@ onMounted(
|
||||
</div>
|
||||
</div>
|
||||
<div class="net">
|
||||
<div class="section-name">
|
||||
Network
|
||||
</div>
|
||||
<div class="net-chart" ref="netChartRef"></div>
|
||||
</div>
|
||||
<div class="disks">
|
||||
<div class="section-name">
|
||||
Storage
|
||||
</div>
|
||||
<Disk v-for="disk in status.hardware.disks" :key="disk.mountpoint" :mountpoint="disk.mountpoint"
|
||||
:device="disk.device" :used="disk.used" :total="disk.total" :fstype="disk.fstype"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
@ -348,12 +407,24 @@ onMounted(
|
||||
<style scoped>
|
||||
|
||||
:root {
|
||||
--text-color-1: #000;
|
||||
--text-color-2: #383838;
|
||||
|
||||
--liteyuki-color-1: #d0e9ff;
|
||||
--liteyuki-color-2: #a2d8f4;
|
||||
--hover-border-color: #ccc;
|
||||
}
|
||||
|
||||
.host {
|
||||
padding: 1em;
|
||||
border: 2px solid #ccc;
|
||||
border-radius: 20px;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.host:hover {
|
||||
border-color: var(--hover-border-color); /* Change border color on hover */
|
||||
}
|
||||
|
||||
.meta-1 {
|
||||
.outline-anime {
|
||||
@ -376,6 +447,10 @@ onMounted(
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-color-2);
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.labels {
|
||||
@ -383,7 +458,7 @@ onMounted(
|
||||
|
||||
.label {
|
||||
padding: 0.05rem 0.5rem;
|
||||
border: 2px dashed;
|
||||
border: 1px dashed;
|
||||
border-color: var(--text-color-1);
|
||||
border-radius: 50px;
|
||||
margin-right: 10px;
|
||||
@ -393,14 +468,6 @@ onMounted(
|
||||
}
|
||||
}
|
||||
|
||||
.host {
|
||||
padding: 1em;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 20px;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-right: 0.3em;
|
||||
height: 16px;
|
||||
@ -412,6 +479,13 @@ onMounted(
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.uptime {
|
||||
padding: 0.1em 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
border-radius: 50px;
|
||||
border: 1px dashed;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.charts-container {
|
||||
margin-top: 0.5rem;
|
||||
@ -437,8 +511,14 @@ onMounted(
|
||||
}
|
||||
}
|
||||
|
||||
.section-name {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.net {
|
||||
margin-top: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
|
||||
.net-chart {
|
||||
width: 100%;
|
||||
@ -446,4 +526,8 @@ onMounted(
|
||||
}
|
||||
}
|
||||
|
||||
.disks {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
</style>
|
@ -11,6 +11,8 @@
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
--text-color-1: #000;
|
||||
--text-color-2: #383838;
|
||||
}
|
||||
|
||||
.dark {
|
||||
|
@ -56,7 +56,7 @@ onUnmounted(() => {
|
||||
.grid-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 10px;
|
||||
gap: 20px;
|
||||
padding: 10px;
|
||||
}
|
||||
</style>
|
Reference in New Issue
Block a user