279 lines
11 KiB
Vue
279 lines
11 KiB
Vue
<script setup>
|
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { useRouter } from 'vue-router'
|
|
import { Browser } from '@wailsio/runtime'
|
|
import {
|
|
Activity, RefreshCw, Pin, Square, Search, ExternalLink, Cpu, MemoryStick,
|
|
HardDrive, Network, Gauge, ChevronDown, ChevronUp, Hexagon, Zap, FileCode2,
|
|
Coffee, Database, Globe, Boxes, Box
|
|
} from 'lucide-vue-next'
|
|
import ChartView from '../components/ChartView.vue'
|
|
import { call, on } from '../api'
|
|
|
|
const { t } = useI18n()
|
|
const router = useRouter()
|
|
|
|
const entries = ref([])
|
|
const loading = ref(false)
|
|
const showSys = ref(localStorage.getItem('cc-pm-sys') === '1')
|
|
const nameQ = ref('')
|
|
const portQ = ref('')
|
|
const busy = ref({})
|
|
const metrics = ref(null)
|
|
const history = ref([]) // { t, cpu, mem, gpu, recv, sent }
|
|
const showCharts = ref(localStorage.getItem('cc-pm-charts') !== '0')
|
|
const kindIcons = ref({})
|
|
const HISTORY_MAX = 60
|
|
let timer = 0
|
|
let metricsTimer = 0
|
|
let offChanged = null
|
|
|
|
const SYS_NAMES = ['system', 'svchost.exe', 'lsass.exe', 'wininit.exe', 'services.exe', 'csrss.exe', 'winlogon.exe', 'spoolsv.exe', 'searchindexer.exe', 'memcompression', 'registry']
|
|
const isSys = x => !x.id && (SYS_NAMES.includes((x.name || '').toLowerCase()) || /^PID \d+$/.test(x.name || ''))
|
|
|
|
const KIND_UI = {
|
|
node: { icon: Hexagon, color: '#8cc84b' },
|
|
go: { icon: Zap, color: '#00add8' },
|
|
python: { icon: FileCode2, color: '#ffd343' },
|
|
java: { icon: Coffee, color: '#f89820' },
|
|
php: { icon: FileCode2, color: '#a78bfa' },
|
|
dotnet: { icon: Boxes, color: '#8b5cf6' },
|
|
exe: { icon: Box, color: '#60a5fa' },
|
|
mysql: { icon: Database, color: '#4f9df5' },
|
|
redis: { icon: Database, color: '#f16a5b' },
|
|
nginx: { icon: Globe, color: '#26c795' },
|
|
web: { icon: Globe, color: '#4f9df5' },
|
|
other: { icon: Box, color: 'var(--muted)' }
|
|
}
|
|
const kindUI = k => KIND_UI[k] || KIND_UI.other
|
|
function displayIcon(x) {
|
|
if (x?.icon) return x.icon
|
|
return kindIcons.value[x?.kind] || ''
|
|
}
|
|
|
|
function matchEntry(x) {
|
|
const nq = nameQ.value.trim().toLowerCase()
|
|
if (nq) {
|
|
const hay = [x.name, x.exe, x.cmdline, x.dir].filter(Boolean).join('\n').toLowerCase()
|
|
if (!hay.includes(nq)) return false
|
|
}
|
|
const pq = portQ.value.trim()
|
|
if (pq) {
|
|
const ports = x.ports?.length ? x.ports : (x.port ? [x.port] : [])
|
|
if (!ports.some(p => String(p).includes(pq))) return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
const scanned = computed(() => entries.value.filter(x => !x.id && (showSys.value || !isSys(x)) && matchEntry(x)))
|
|
const hiddenCount = computed(() => entries.value.filter(x => !x.id && isSys(x)).length)
|
|
|
|
const fmtCPU = v => (v >= 10 ? Number(v).toFixed(0) : Number(v).toFixed(1)) + '%'
|
|
const fmtMem = v => v >= 1024 ? (v / 1024).toFixed(1) + ' GB' : Number(v).toFixed(0) + ' MB'
|
|
const fmtIO = v => v >= 1024 ? (v / 1024).toFixed(1) + ' MB/s' : Number(v).toFixed(0) + ' KB/s'
|
|
const fmtNet = v => {
|
|
const n = Number(v) || 0
|
|
if (n >= 1024) return (n / 1024).toFixed(2) + ' MB/s'
|
|
return n.toFixed(1) + ' KB/s'
|
|
}
|
|
|
|
async function load() {
|
|
loading.value = true
|
|
try { entries.value = await call('ListLaunchEntries') } catch { /* ignore */ }
|
|
loading.value = false
|
|
}
|
|
async function loadMetrics() {
|
|
try {
|
|
const m = await call('GetHostMetrics')
|
|
metrics.value = m
|
|
const point = {
|
|
t: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }),
|
|
cpu: Number(m.cpu) || 0,
|
|
mem: Number(m.memPercent) || 0,
|
|
gpu: m.gpu < 0 ? null : Number(m.gpu),
|
|
recv: Number(m.netRecvKBs) || 0,
|
|
sent: Number(m.netSentKBs) || 0
|
|
}
|
|
history.value = [...history.value, point].slice(-HISTORY_MAX)
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
function toggleSys() {
|
|
showSys.value = !showSys.value
|
|
localStorage.setItem('cc-pm-sys', showSys.value ? '1' : '0')
|
|
}
|
|
function toggleCharts() {
|
|
showCharts.value = !showCharts.value
|
|
localStorage.setItem('cc-pm-charts', showCharts.value ? '1' : '0')
|
|
}
|
|
|
|
function openPort(p) {
|
|
const n = Number(p)
|
|
if (!n || n <= 0) return
|
|
try { Browser.OpenURL(`http://127.0.0.1:${n}`) } catch { /* ignore */ }
|
|
}
|
|
|
|
async function pin(x) {
|
|
router.push({ path: '/launchpad', query: { pin: '1', name: x.name || '', kind: x.kind || 'other', port: String(x.port || x.ports?.[0] || 0), dir: x.dir || '', pid: String(x.pid || 0) } })
|
|
}
|
|
|
|
async function stop(x) {
|
|
if (!confirm(t('lpStopConfirm', { name: x.name }))) return
|
|
const key = x.pid
|
|
busy.value[key] = 'stop'
|
|
try { await call('StopLaunchApp', 0, x.pid || 0) } catch { /* ignore */ }
|
|
busy.value[key] = false
|
|
setTimeout(load, 500)
|
|
}
|
|
|
|
function lineOpt(series) {
|
|
return {
|
|
backgroundColor: 'transparent',
|
|
grid: { left: 36, right: 12, top: 24, bottom: 28 },
|
|
tooltip: { trigger: 'axis' },
|
|
legend: { show: series.length > 1, top: 0, textStyle: { color: 'var(--muted)', fontSize: 11 } },
|
|
xAxis: {
|
|
type: 'category',
|
|
data: history.value.map(h => h.t),
|
|
axisLabel: { color: 'var(--muted)', fontSize: 10 },
|
|
axisLine: { lineStyle: { color: 'var(--border)' } }
|
|
},
|
|
yAxis: {
|
|
type: 'value',
|
|
min: 0,
|
|
max: series.some(s => s.unit === '%') ? 100 : undefined,
|
|
axisLabel: { color: 'var(--muted)', fontSize: 10 },
|
|
splitLine: { lineStyle: { color: 'var(--border)', opacity: 0.45 } }
|
|
},
|
|
series: series.map(s => ({
|
|
name: s.name,
|
|
type: 'line',
|
|
smooth: true,
|
|
showSymbol: false,
|
|
areaStyle: { opacity: 0.12 },
|
|
lineStyle: { width: 2, color: s.color },
|
|
itemStyle: { color: s.color },
|
|
data: history.value.map(h => h[s.key] == null ? null : h[s.key])
|
|
}))
|
|
}
|
|
}
|
|
|
|
const cpuChart = computed(() => lineOpt([{ name: 'CPU', key: 'cpu', color: '#5da8ff', unit: '%' }]))
|
|
const memChart = computed(() => lineOpt([{ name: t('lpMem'), key: 'mem', color: '#53d6a2', unit: '%' }]))
|
|
const gpuChart = computed(() => lineOpt([{ name: 'GPU', key: 'gpu', color: '#f7cb4d', unit: '%' }]))
|
|
const netChart = computed(() => lineOpt([
|
|
{ name: t('pmNetDown'), key: 'recv', color: '#4fd1a1' },
|
|
{ name: t('pmNetUp'), key: 'sent', color: '#a78bfa' }
|
|
]))
|
|
|
|
const gpuAvailable = computed(() => metrics.value && metrics.value.gpu >= 0)
|
|
|
|
onMounted(async () => {
|
|
try { kindIcons.value = await call('ListKindIcons') || {} } catch { kindIcons.value = {} }
|
|
load()
|
|
loadMetrics()
|
|
timer = setInterval(load, 5000)
|
|
metricsTimer = setInterval(loadMetrics, 2000)
|
|
offChanged = on('launchpad:changed', load)
|
|
})
|
|
onUnmounted(() => {
|
|
clearInterval(timer)
|
|
clearInterval(metricsTimer)
|
|
offChanged?.()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="page port-monitor-page">
|
|
<header class="page-head sticky-head lp-head">
|
|
<div class="lp-head-top">
|
|
<div>
|
|
<h1>{{ t('portMonitor') }}</h1>
|
|
<p>{{ t('portMonitorSubtitle') }}</p>
|
|
</div>
|
|
<div class="lp-tools">
|
|
<button class="btn secondary" @click="toggleCharts">
|
|
<component :is="showCharts ? ChevronUp : ChevronDown" />
|
|
{{ showCharts ? t('pmHideCharts') : t('pmShowCharts') }}
|
|
</button>
|
|
<button class="btn secondary" :disabled="loading" @click="load"><RefreshCw :class="{ spinning: loading }" />{{ t('lpRefresh') }}</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<section class="pm-overview card">
|
|
<h2 class="lp-title"><Activity />{{ t('pmOverview') }}</h2>
|
|
<div class="pm-stats">
|
|
<div class="pm-stat">
|
|
<span class="pm-stat-label"><Cpu />CPU</span>
|
|
<b>{{ metrics ? fmtCPU(metrics.cpu) : '—' }}</b>
|
|
</div>
|
|
<div class="pm-stat">
|
|
<span class="pm-stat-label"><MemoryStick />{{ t('lpMem') }}</span>
|
|
<b>{{ metrics ? fmtCPU(metrics.memPercent) : '—' }}</b>
|
|
<small v-if="metrics">{{ fmtMem(metrics.memUsedMB) }} / {{ fmtMem(metrics.memTotalMB) }}</small>
|
|
</div>
|
|
<div class="pm-stat">
|
|
<span class="pm-stat-label"><Gauge />GPU</span>
|
|
<template v-if="gpuAvailable">
|
|
<b>{{ fmtCPU(metrics.gpu) }}</b>
|
|
<small>{{ metrics.gpuName }} · {{ fmtMem(metrics.gpuMemUsedMB) }} / {{ fmtMem(metrics.gpuMemTotalMB) }}</small>
|
|
</template>
|
|
<b v-else class="muted">{{ t('pmGpuNA') }}</b>
|
|
</div>
|
|
<div class="pm-stat">
|
|
<span class="pm-stat-label"><Network />{{ t('pmBandwidth') }}</span>
|
|
<b v-if="metrics">↓ {{ fmtNet(metrics.netRecvKBs) }} · ↑ {{ fmtNet(metrics.netSentKBs) }}</b>
|
|
<b v-else>—</b>
|
|
</div>
|
|
</div>
|
|
<div v-if="showCharts" class="pm-charts">
|
|
<div class="pm-chart-card"><header>CPU</header><ChartView :option="cpuChart" class="pm-chart" /></div>
|
|
<div class="pm-chart-card"><header>{{ t('lpMem') }}</header><ChartView :option="memChart" class="pm-chart" /></div>
|
|
<div class="pm-chart-card"><header>GPU</header><ChartView :option="gpuChart" class="pm-chart" /></div>
|
|
<div class="pm-chart-card"><header>{{ t('pmBandwidth') }}</header><ChartView :option="netChart" class="pm-chart" /></div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="lp-section">
|
|
<div class="pm-ports-head">
|
|
<h2 class="lp-title"><HardDrive />{{ t('lpScanned') }}<em>{{ scanned.length }}</em></h2>
|
|
<div class="lp-filters">
|
|
<label class="lp-sys-toggle"><input type="checkbox" :checked="showSys" @change="toggleSys" />{{ t('lpShowSys') }}<em v-if="hiddenCount && !showSys">{{ hiddenCount }}</em></label>
|
|
<label class="lp-search"><Search /><input v-model.trim="nameQ" type="search" :placeholder="t('lpSearchName')" /></label>
|
|
<label class="lp-search lp-search-port"><Search /><input v-model.trim="portQ" type="search" inputmode="numeric" :placeholder="t('lpSearchPort')" /></label>
|
|
</div>
|
|
</div>
|
|
<div v-if="!scanned.length" class="lp-empty">{{ t('lpEmptyScan') }}</div>
|
|
<div v-else class="lp-grid">
|
|
<article v-for="x in scanned" :key="'p' + x.pid" class="card lp-card running">
|
|
<header>
|
|
<span class="lp-icon" :style="{ color: kindUI(x.kind).color, background: `color-mix(in srgb, ${kindUI(x.kind).color} 14%, transparent)` }">
|
|
<img v-if="displayIcon(x)" :src="displayIcon(x)" alt="" />
|
|
<component v-else :is="kindUI(x.kind).icon" />
|
|
</span>
|
|
<div class="lp-name"><b :title="x.exe || x.name">{{ x.name }}</b><small>{{ x.kind }} · PID {{ x.pid }}</small></div>
|
|
<i class="lp-dot on" :title="t('lpRunning')" />
|
|
</header>
|
|
<div class="lp-ports">
|
|
<button v-for="p in x.ports.slice(0, 4)" :key="p" type="button" class="lp-port lp-port-link" :title="t('lpOpenPort', { p })" @click="openPort(p)">:{{ p }}<ExternalLink /></button>
|
|
<span v-if="x.ports.length > 4" class="lp-port more">+{{ x.ports.length - 4 }}</span>
|
|
</div>
|
|
<div class="lp-res">
|
|
<span :title="'CPU'"><Cpu />{{ fmtCPU(x.cpu) }}</span>
|
|
<span :title="t('lpMem')"><MemoryStick />{{ fmtMem(x.memMB) }}</span>
|
|
<span :title="'IO'"><HardDrive />{{ fmtIO(x.ioKBs) }}</span>
|
|
</div>
|
|
<p v-if="x.dir || x.cmdline || x.exe" class="lp-meta" :title="x.dir || x.cmdline || x.exe">{{ x.dir || x.cmdline || x.exe }}</p>
|
|
<footer>
|
|
<button class="lp-act" @click="pin(x)"><Pin />{{ t('lpPin') }}</button>
|
|
<span class="lp-gap" />
|
|
<button class="lp-act halt" :disabled="busy[x.pid]" @click="stop(x)"><Square />{{ t('lpStop') }}</button>
|
|
</footer>
|
|
</article>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</template>
|