サーバー情報ウィジェット

This commit is contained in:
syuilo 2021-01-03 22:38:32 +09:00
parent 9ade3f2f93
commit 0f769c7822
12 changed files with 757 additions and 8 deletions

View File

@ -1030,6 +1030,7 @@ _widgets:
slideshow: "スライドショー" slideshow: "スライドショー"
button: "ボタン" button: "ボタン"
onlineUsers: "オンラインユーザー" onlineUsers: "オンラインユーザー"
serverMetric: "サーバーメトリクス"
_cw: _cw:
hide: "隠す" hide: "隠す"

View File

@ -36,7 +36,7 @@ import { faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons';
import { faDownload, faLink, faICursor, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; import { faDownload, faLink, faICursor, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import copyToClipboard from '@/scripts/copy-to-clipboard'; import copyToClipboard from '@/scripts/copy-to-clipboard';
import MkDriveFileThumbnail from './drive-file-thumbnail.vue'; import MkDriveFileThumbnail from './drive-file-thumbnail.vue';
import bytes from '../filters/bytes'; import bytes from '@/filters/bytes';
import * as os from '@/os'; import * as os from '@/os';
export default defineComponent({ export default defineComponent({

View File

@ -14,6 +14,7 @@ export default function(app: App) {
app.component('MkwFederation', defineAsyncComponent(() => import('./federation.vue'))); app.component('MkwFederation', defineAsyncComponent(() => import('./federation.vue')));
app.component('MkwPostForm', defineAsyncComponent(() => import('./post-form.vue'))); app.component('MkwPostForm', defineAsyncComponent(() => import('./post-form.vue')));
app.component('MkwSlideshow', defineAsyncComponent(() => import('./slideshow.vue'))); app.component('MkwSlideshow', defineAsyncComponent(() => import('./slideshow.vue')));
app.component('MkwServerMetric', defineAsyncComponent(() => import('./server-metric/index.vue')));
app.component('MkwOnlineUsers', defineAsyncComponent(() => import('./online-users.vue'))); app.component('MkwOnlineUsers', defineAsyncComponent(() => import('./online-users.vue')));
app.component('MkwButton', defineAsyncComponent(() => import('./button.vue'))); app.component('MkwButton', defineAsyncComponent(() => import('./button.vue')));
} }
@ -32,6 +33,7 @@ export const widgets = [
'federation', 'federation',
'postForm', 'postForm',
'slideshow', 'slideshow',
'serverMetric',
'onlineUsers', 'onlineUsers',
'button', 'button',
]; ];

View File

@ -0,0 +1,174 @@
<template>
<div class="lcfyofjk">
<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`">
<defs>
<linearGradient :id="cpuGradientId" x1="0" x2="0" y1="1" y2="0">
<stop offset="0%" stop-color="hsl(180, 80%, 70%)"></stop>
<stop offset="100%" stop-color="hsl(0, 80%, 70%)"></stop>
</linearGradient>
<mask :id="cpuMaskId" x="0" y="0" :width="viewBoxX" :height="viewBoxY">
<polygon
:points="cpuPolygonPoints"
fill="#fff"
fill-opacity="0.5"
/>
<polyline
:points="cpuPolylinePoints"
fill="none"
stroke="#fff"
stroke-width="1"
/>
<circle
:cx="cpuHeadX"
:cy="cpuHeadY"
r="1.5"
fill="#fff"
/>
</mask>
</defs>
<rect
x="-2" y="-2"
:width="viewBoxX + 4" :height="viewBoxY + 4"
:style="`stroke: none; fill: url(#${ cpuGradientId }); mask: url(#${ cpuMaskId })`"
/>
<text x="1" y="5">CPU <tspan>{{ cpuP }}%</tspan></text>
</svg>
<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`">
<defs>
<linearGradient :id="memGradientId" x1="0" x2="0" y1="1" y2="0">
<stop offset="0%" stop-color="hsl(180, 80%, 70%)"></stop>
<stop offset="100%" stop-color="hsl(0, 80%, 70%)"></stop>
</linearGradient>
<mask :id="memMaskId" x="0" y="0" :width="viewBoxX" :height="viewBoxY">
<polygon
:points="memPolygonPoints"
fill="#fff"
fill-opacity="0.5"
/>
<polyline
:points="memPolylinePoints"
fill="none"
stroke="#fff"
stroke-width="1"
/>
<circle
:cx="memHeadX"
:cy="memHeadY"
r="1.5"
fill="#fff"
/>
</mask>
</defs>
<rect
x="-2" y="-2"
:width="viewBoxX + 4" :height="viewBoxY + 4"
:style="`stroke: none; fill: url(#${ memGradientId }); mask: url(#${ memMaskId })`"
/>
<text x="1" y="5">MEM <tspan>{{ memP }}%</tspan></text>
</svg>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { v4 as uuid } from 'uuid';
export default defineComponent({
props: {
connection: {
required: true,
},
meta: {
required: true,
}
},
data() {
return {
viewBoxX: 50,
viewBoxY: 30,
stats: [],
cpuGradientId: uuid(),
cpuMaskId: uuid(),
memGradientId: uuid(),
memMaskId: uuid(),
cpuPolylinePoints: '',
memPolylinePoints: '',
cpuPolygonPoints: '',
memPolygonPoints: '',
cpuHeadX: null,
cpuHeadY: null,
memHeadX: null,
memHeadY: null,
cpuP: '',
memP: ''
};
},
mounted() {
this.connection.on('stats', this.onStats);
this.connection.on('statsLog', this.onStatsLog);
this.connection.send('requestLog', {
id: Math.random().toString().substr(2, 8)
});
},
beforeUnmount() {
this.connection.off('stats', this.onStats);
this.connection.off('statsLog', this.onStatsLog);
},
methods: {
onStats(stats) {
this.stats.push(stats);
if (this.stats.length > 50) this.stats.shift();
const cpuPolylinePoints = this.stats.map((s, i) => [this.viewBoxX - ((this.stats.length - 1) - i), (1 - s.cpu) * this.viewBoxY]);
const memPolylinePoints = this.stats.map((s, i) => [this.viewBoxX - ((this.stats.length - 1) - i), (1 - (s.mem.used / this.meta.mem.total)) * this.viewBoxY]);
this.cpuPolylinePoints = cpuPolylinePoints.map(xy => `${xy[0]},${xy[1]}`).join(' ');
this.memPolylinePoints = memPolylinePoints.map(xy => `${xy[0]},${xy[1]}`).join(' ');
this.cpuPolygonPoints = `${this.viewBoxX - (this.stats.length - 1)},${this.viewBoxY} ${this.cpuPolylinePoints} ${this.viewBoxX},${this.viewBoxY}`;
this.memPolygonPoints = `${this.viewBoxX - (this.stats.length - 1)},${this.viewBoxY} ${this.memPolylinePoints} ${this.viewBoxX},${this.viewBoxY}`;
this.cpuHeadX = cpuPolylinePoints[cpuPolylinePoints.length - 1][0];
this.cpuHeadY = cpuPolylinePoints[cpuPolylinePoints.length - 1][1];
this.memHeadX = memPolylinePoints[memPolylinePoints.length - 1][0];
this.memHeadY = memPolylinePoints[memPolylinePoints.length - 1][1];
this.cpuP = (stats.cpu * 100).toFixed(0);
this.memP = (stats.mem.used / this.meta.mem.total * 100).toFixed(0);
},
onStatsLog(statsLog) {
for (const stats of [...statsLog].reverse()) {
this.onStats(stats);
}
}
}
});
</script>
<style lang="scss" scoped>
.lcfyofjk {
display: flex;
> svg {
display: block;
padding: 10px;
width: 50%;
&:first-child {
padding-right: 5px;
}
&:last-child {
padding-left: 5px;
}
> text {
font-size: 5px;
fill: currentColor;
> tspan {
opacity: 0.5;
}
}
}
}
</style>

View File

@ -0,0 +1,78 @@
<template>
<div class="vrvdvrys">
<XPie class="pie" :value="usage"/>
<div>
<p><fa :icon="faMicrochip"/>CPU</p>
<p>{{ meta.cpu.cores }} Logical cores</p>
<p>{{ meta.cpu.model }}</p>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { faMicrochip } from '@fortawesome/free-solid-svg-icons';
import XPie from './pie.vue';
export default defineComponent({
components: {
XPie
},
props: {
connection: {
required: true,
},
meta: {
required: true,
}
},
data() {
return {
usage: 0,
faMicrochip,
};
},
mounted() {
this.connection.on('stats', this.onStats);
},
beforeUnmount() {
this.connection.off('stats', this.onStats);
},
methods: {
onStats(stats) {
this.usage = stats.cpu;
}
}
});
</script>
<style lang="scss" scoped>
.vrvdvrys {
display: flex;
padding: 16px;
> .pie {
height: 82px;
flex-shrink: 0;
margin-right: 16px;
}
> div {
flex: 1;
> p {
margin: 0;
font-size: 0.8em;
&:first-child {
font-weight: bold;
margin-bottom: 4px;
> [data-icon] {
margin-right: 4px;
}
}
}
}
}
</style>

View File

@ -0,0 +1,72 @@
<template>
<div class="zbwaqsat">
<XPie class="pie" :value="usage"/>
<div>
<p><fa :icon="faHdd"/>Disk</p>
<p>Total: {{ bytes(total, 1) }}</p>
<p>Free: {{ bytes(available, 1) }}</p>
<p>Used: {{ bytes(used, 1) }}</p>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { faHdd } from '@fortawesome/free-solid-svg-icons';
import XPie from './pie.vue';
import bytes from '@/filters/bytes';
export default defineComponent({
components: {
XPie
},
props: {
meta: {
required: true,
}
},
data() {
return {
usage: this.meta.fs.used / this.meta.fs.total,
total: this.meta.fs.total,
used: this.meta.fs.used,
available: this.meta.fs.total - this.meta.fs.used,
faHdd,
};
},
methods: {
bytes
}
});
</script>
<style lang="scss" scoped>
.zbwaqsat {
display: flex;
padding: 16px;
> .pie {
height: 82px;
flex-shrink: 0;
margin-right: 16px;
}
> div {
flex: 1;
> p {
margin: 0;
font-size: 0.8em;
&:first-child {
font-weight: bold;
margin-bottom: 4px;
> [data-icon] {
margin-right: 4px;
}
}
}
}
}
</style>

View File

@ -0,0 +1,84 @@
<template>
<MkContainer :show-header="props.showHeader" :naked="props.transparent">
<template #header><Fa :icon="faServer"/>{{ $ts._widgets.serverMetric }}</template>
<template #func><button @click="toggleView()" class="_button"><Fa :icon="faSort"/></button></template>
<div class="mkw-serverMetric" v-if="meta">
<XCpuMemory v-if="props.view === 0" :connection="connection" :meta="meta"/>
<XNet v-if="props.view === 1" :connection="connection" :meta="meta"/>
<XCpu v-if="props.view === 2" :connection="connection" :meta="meta"/>
<XMemory v-if="props.view === 3" :connection="connection" :meta="meta"/>
<XDisk v-if="props.view === 4" :connection="connection" :meta="meta"/>
</div>
</MkContainer>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { faServer, faSort } from '@fortawesome/free-solid-svg-icons';
import define from '../define';
import MkContainer from '@/components/ui/container.vue';
import XCpuMemory from './cpu-mem.vue';
import XNet from './net.vue';
import XCpu from './cpu.vue';
import XMemory from './mem.vue';
import XDisk from './disk.vue';
import * as os from '@/os';
const widget = define({
name: 'serverMetric',
props: () => ({
showHeader: {
type: 'boolean',
default: true,
},
transparent: {
type: 'boolean',
default: false,
},
view: {
type: 'number',
default: 0,
hidden: true,
},
})
});
export default defineComponent({
extends: widget,
components: {
MkContainer,
XCpuMemory,
XNet,
XCpu,
XMemory,
XDisk,
},
data() {
return {
meta: null,
connection: null,
faServer, faSort,
};
},
created() {
os.api('server-info', {}).then(res => {
this.meta = res;
});
this.connection = os.stream.useSharedConnection('serverStats');
},
unmounted() {
this.connection.dispose();
},
methods: {
toggleView() {
if (this.props.view == 4) {
this.props.view = 0;
} else {
this.props.view++;
}
this.save();
},
}
});
</script>

View File

@ -0,0 +1,87 @@
<template>
<div class="zlxnikvl">
<XPie class="pie" :value="usage"/>
<div>
<p><fa :icon="faMemory"/>RAM</p>
<p>Total: {{ bytes(total, 1) }}</p>
<p>Used: {{ bytes(used, 1) }}</p>
<p>Free: {{ bytes(free, 1) }}</p>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { faMemory } from '@fortawesome/free-solid-svg-icons';
import XPie from './pie.vue';
import bytes from '@/filters/bytes';
export default defineComponent({
components: {
XPie
},
props: {
connection: {
required: true,
},
meta: {
required: true,
}
},
data() {
return {
usage: 0,
total: 0,
used: 0,
free: 0,
faMemory,
};
},
mounted() {
this.connection.on('stats', this.onStats);
},
beforeUnmount() {
this.connection.off('stats', this.onStats);
},
methods: {
onStats(stats) {
this.usage = stats.mem.used / this.meta.mem.total;
this.total = this.meta.mem.total;
this.used = stats.mem.used;
this.free = this.meta.mem.total - stats.mem.used;
},
bytes
}
});
</script>
<style lang="scss" scoped>
.zlxnikvl {
display: flex;
padding: 16px;
> .pie {
height: 82px;
flex-shrink: 0;
margin-right: 16px;
}
> div {
flex: 1;
> p {
margin: 0;
font-size: 0.8em;
&:first-child {
font-weight: bold;
margin-bottom: 4px;
> [data-icon] {
margin-right: 4px;
}
}
}
}
}
</style>

View File

@ -0,0 +1,148 @@
<template>
<div class="oxxrhrto">
<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`">
<polygon
:points="inPolygonPoints"
fill="#94a029"
fill-opacity="0.5"
/>
<polyline
:points="inPolylinePoints"
fill="none"
stroke="#94a029"
stroke-width="1"
/>
<circle
:cx="inHeadX"
:cy="inHeadY"
r="1.5"
fill="#94a029"
/>
<text x="1" y="5">NET rx <tspan>{{ bytes(inRecent) }}</tspan></text>
</svg>
<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`">
<polygon
:points="outPolygonPoints"
fill="#ff9156"
fill-opacity="0.5"
/>
<polyline
:points="outPolylinePoints"
fill="none"
stroke="#ff9156"
stroke-width="1"
/>
<circle
:cx="outHeadX"
:cy="outHeadY"
r="1.5"
fill="#ff9156"
/>
<text x="1" y="5">NET tx <tspan>{{ bytes(outRecent) }}</tspan></text>
</svg>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import bytes from '@/filters/bytes';
export default defineComponent({
props: {
connection: {
required: true,
},
meta: {
required: true,
}
},
data() {
return {
viewBoxX: 50,
viewBoxY: 30,
stats: [],
inPolylinePoints: '',
outPolylinePoints: '',
inPolygonPoints: '',
outPolygonPoints: '',
inHeadX: null,
inHeadY: null,
outHeadX: null,
outHeadY: null,
inRecent: 0,
outRecent: 0
};
},
mounted() {
this.connection.on('stats', this.onStats);
this.connection.on('statsLog', this.onStatsLog);
this.connection.send('requestLog', {
id: Math.random().toString().substr(2, 8)
});
},
beforeUnmount() {
this.connection.off('stats', this.onStats);
this.connection.off('statsLog', this.onStatsLog);
},
methods: {
onStats(stats) {
this.stats.push(stats);
if (this.stats.length > 50) this.stats.shift();
const inPeak = Math.max(1024 * 64, Math.max(...this.stats.map(s => s.net.rx)));
const outPeak = Math.max(1024 * 64, Math.max(...this.stats.map(s => s.net.tx)));
const inPolylinePoints = this.stats.map((s, i) => [this.viewBoxX - ((this.stats.length - 1) - i), (1 - (s.net.rx / inPeak)) * this.viewBoxY]);
const outPolylinePoints = this.stats.map((s, i) => [this.viewBoxX - ((this.stats.length - 1) - i), (1 - (s.net.tx / outPeak)) * this.viewBoxY]);
this.inPolylinePoints = inPolylinePoints.map(xy => `${xy[0]},${xy[1]}`).join(' ');
this.outPolylinePoints = outPolylinePoints.map(xy => `${xy[0]},${xy[1]}`).join(' ');
this.inPolygonPoints = `${this.viewBoxX - (this.stats.length - 1)},${this.viewBoxY} ${this.inPolylinePoints} ${this.viewBoxX},${this.viewBoxY}`;
this.outPolygonPoints = `${this.viewBoxX - (this.stats.length - 1)},${this.viewBoxY} ${this.outPolylinePoints} ${this.viewBoxX},${this.viewBoxY}`;
this.inHeadX = inPolylinePoints[inPolylinePoints.length - 1][0];
this.inHeadY = inPolylinePoints[inPolylinePoints.length - 1][1];
this.outHeadX = outPolylinePoints[outPolylinePoints.length - 1][0];
this.outHeadY = outPolylinePoints[outPolylinePoints.length - 1][1];
this.inRecent = stats.net.rx;
this.outRecent = stats.net.tx;
},
onStatsLog(statsLog) {
for (const stats of [...statsLog].reverse()) {
this.onStats(stats);
}
},
bytes
}
});
</script>
<style lang="scss" scoped>
.oxxrhrto {
display: flex;
> svg {
display: block;
padding: 10px;
width: 50%;
&:first-child {
padding-right: 5px;
}
&:last-child {
padding-left: 5px;
}
> text {
font-size: 5px;
fill: currentColor;
> tspan {
opacity: 0.5;
}
}
}
}
</style>

View File

@ -0,0 +1,65 @@
<template>
<svg class="hsalcinq" viewBox="0 0 1 1" preserveAspectRatio="none">
<circle
:r="r"
cx="50%" cy="50%"
fill="none"
stroke-width="0.1"
stroke="rgba(0, 0, 0, 0.05)"
/>
<circle
:r="r"
cx="50%" cy="50%"
:stroke-dasharray="Math.PI * (r * 2)"
:stroke-dashoffset="strokeDashoffset"
fill="none"
stroke-width="0.1"
:stroke="color"
/>
<text x="50%" y="50%" dy="0.05" text-anchor="middle">{{ (value * 100).toFixed(0) }}%</text>
</svg>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
props: {
value: {
type: Number,
required: true
}
},
data() {
return {
r: 0.45
};
},
computed: {
color(): string {
return `hsl(${180 - (this.value * 180)}, 80%, 70%)`;
},
strokeDashoffset(): number {
return (1 - this.value) * (Math.PI * (this.r * 2));
}
}
});
</script>
<style lang="scss" scoped>
.hsalcinq {
display: block;
height: 100%;
> circle {
transform-origin: center;
transform: rotate(-90deg);
transition: stroke-dashoffset 0.5s ease;
}
> text {
font-size: 0.15px;
fill: currentColor;
}
}
</style>

View File

@ -6,6 +6,9 @@ const ev = new Xev();
const interval = 2000; const interval = 2000;
const roundCpu = (num: number) => Math.round(num * 1000) / 1000;
const round = (num: number) => Math.round(num * 10) / 10;
/** /**
* Report server stats regularly * Report server stats regularly
*/ */
@ -23,18 +26,18 @@ export default function() {
const fsStats = await fs(); const fsStats = await fs();
const stats = { const stats = {
cpu: cpu, cpu: roundCpu(cpu),
mem: { mem: {
used: memStats.used, used: round(memStats.used),
active: memStats.active, active: round(memStats.active),
}, },
net: { net: {
rx: Math.max(0, netStats.rx_sec), rx: round(Math.max(0, netStats.rx_sec)),
tx: Math.max(0, netStats.tx_sec), tx: round(Math.max(0, netStats.tx_sec)),
}, },
fs: { fs: {
r: Math.max(0, fsStats.rIO_sec), r: round(Math.max(0, fsStats.rIO_sec)),
w: Math.max(0, fsStats.wIO_sec), w: round(Math.max(0, fsStats.wIO_sec)),
} }
}; };
ev.emit('serverStats', stats); ev.emit('serverStats', stats);

View File

@ -0,0 +1,35 @@
import * as os from 'os';
import * as si from 'systeminformation';
import define from '../define';
export const meta = {
requireCredential: false as const,
desc: {
},
tags: ['meta'],
params: {
},
};
export default define(meta, async () => {
const memStats = await si.mem();
const fsStats = await si.fsSize();
return {
machine: os.hostname(),
cpu: {
model: os.cpus()[0].model,
cores: os.cpus().length
},
mem: {
total: memStats.total
},
fs: {
total: fsStats[0].size,
used: fsStats[0].used,
},
};
});